From 1e349f393d661227a8345b07dc884a5aa3e17d0e Mon Sep 17 00:00:00 2001 From: Vivek Kotecha Date: Sat, 24 Jan 2026 21:01:24 -0800 Subject: [PATCH 1/2] feat: add broker auth mode and offline tests Implement broker-based token retrieval and config validation, add mock broker/Twitter API coverage for auth flows, and update docs to prioritize enterprise broker setup. --- .cursorrules | 63 +++ README.md | 142 ++++-- package.json | 13 +- scripts/mock-broker.mjs | 44 ++ src/__tests__/README.md | 24 +- src/__tests__/TESTING_GUIDE.md | 47 +- src/__tests__/e2e/broker-auth.test.ts | 49 ++ src/__tests__/e2e/twitter-integration.test.ts | 33 +- src/__tests__/environment.test.ts | 348 ++++++++++++- src/__tests__/helpers/mock-broker.ts | 74 +++ src/__tests__/helpers/mock-twitter-api.ts | 226 +++++++++ src/__tests__/plugin-init.test.ts | 108 ++++ src/client/__tests__/auth.test.ts | 56 +++ src/client/__tests__/broker-provider.test.ts | 236 ++++++++- src/client/__tests__/env-provider.test.ts | 76 +++ src/client/__tests__/factory.test.ts | 82 +++ .../__tests__/interactive-defaults.test.ts | 50 ++ .../__tests__/interactive-error.test.ts | 51 ++ src/client/__tests__/interactive.test.ts | 184 +++++++ src/client/__tests__/oauth2-provider.test.ts | 474 +++++++++++++++++- src/client/__tests__/pkce.test.ts | 19 +- src/client/__tests__/token-store.test.ts | 98 +++- src/client/auth-providers/broker.ts | 89 +++- src/client/auth-providers/factory.ts | 2 +- src/client/auth-providers/interactive.ts | 6 + src/environment.ts | 22 +- src/index.ts | 13 +- tsconfig.build.json | 8 +- vitest.config.ts | 15 + 29 files changed, 2541 insertions(+), 111 deletions(-) create mode 100644 .cursorrules create mode 100644 scripts/mock-broker.mjs create mode 100644 src/__tests__/e2e/broker-auth.test.ts create mode 100644 src/__tests__/helpers/mock-broker.ts create mode 100644 src/__tests__/helpers/mock-twitter-api.ts create mode 100644 src/__tests__/plugin-init.test.ts create mode 100644 src/client/__tests__/env-provider.test.ts create mode 100644 src/client/__tests__/factory.test.ts create mode 100644 src/client/__tests__/interactive-defaults.test.ts create mode 100644 src/client/__tests__/interactive-error.test.ts create mode 100644 src/client/__tests__/interactive.test.ts diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..359404a --- /dev/null +++ b/.cursorrules @@ -0,0 +1,63 @@ +# Cursor Rules (project) + +This repository intentionally uses the legacy `.cursorrules` format by request. +Do not recreate `.cursor/rules/` unless explicitly asked. + +## Priority +- Follow these rules over general habits, but never conflict with system or user instructions. + +## Core Goals +- Keep the plugin stable, testable, and backward compatible. +- Prefer clear, actionable errors over silent failures. +- Avoid real network calls in tests; use deterministic mocks. + +## Auth & Config +- Any new auth/config change must update: + - `src/environment.ts` schema + validation + - `package.json` agentConfig docs + - `README.md` setup sections + - relevant tests +- Use `getSetting(runtime, KEY)` for configuration lookups. +- For missing credentials, throw explicit errors listing **exact** env var names. +- Broker mode must remain thin: only fetch broker tokens and pass them to Twitter API. + +## Error Messaging +- Errors must include: + - The missing env var names + - The expected endpoint or next steps +- For broker 401/403, add a hint to check broker API key and permissions. +- Prefer `logger.warn` for configuration issues and `logger.error` for failures. + +## Tests (strict) +- All tests must run offline with mocks (no real Twitter API). +- E2E tests should exercise the full stack with mocked Twitter + mock broker servers. +- When adding features, include: + - Unit tests for core logic + - E2E tests for integration paths + - Negative tests for misconfiguration +- Keep E2E tests deterministic and fast. + +## Mocking +- Mock `twitter-api-v2` in tests unless explicitly running a real integration test. +- Prefer local mock HTTP servers for broker flows. +- Do not use real API keys in tests or docs. + +## Code Style +- Use TypeScript types for public APIs and new modules. +- Favor small, composable helpers over large methods. +- Keep edits minimal and targeted; avoid unrelated refactors. +- Use ASCII in new code and docs unless existing files require Unicode. + +## Documentation +- When behavior changes, update README and test docs. +- Keep broker integration instructions up to date. +- Add usage snippets for any new env vars. + +## Security +- Never log or store secrets in plaintext. +- Do not embed credentials in tests. +- Treat broker API keys as sensitive. + +## Self‑Updating Rule +- If you discover new conventions or adjust tooling, update this file in the same change. +- If rules conflict with project needs, revise `.cursorrules` and explain why. diff --git a/README.md b/README.md index d897727..daf32db 100644 --- a/README.md +++ b/README.md @@ -6,32 +6,41 @@ This package provides Twitter/X integration for the Eliza AI agent using the off **Just want your bot to post tweets? Here's the fastest path:** -1. **Get Twitter Developer account** → https://developer.twitter.com -2. **Create an app** → Enable "Read and write" permissions +1. **If you have enterprise/broker access (recommended)**: get a broker URL + API key +2. **Otherwise BYO developer account**: https://developer.twitter.com (create an app and enable "Read and write") 3. Choose your auth mode: - - **Option A (default, legacy): OAuth 1.0a env vars** - - API Key & Secret (from "Consumer Keys") - - Access Token & Secret (from "Authentication Tokens") + - **Option A (broker, enterprise): token broker service** + - Broker URL (from your broker deployment) + - Broker API Key (agent key issued by the broker) - - **Option B (recommended): “login + approve” OAuth 2.0 (PKCE)** + - **Option B (developer account, recommended): “login + approve” OAuth 2.0 (PKCE)** - Client ID (from "OAuth 2.0 Client ID") - Redirect URI (loopback recommended) + - **Option C (developer account, legacy): OAuth 1.0a env vars** + - API Key & Secret (from "Consumer Keys") + - Access Token & Secret (from "Authentication Tokens") + 4. **Add to `.env`:** ```bash - # Option A: legacy OAuth 1.0a (default) - TWITTER_AUTH_MODE=env - TWITTER_API_KEY=xxx - TWITTER_API_SECRET_KEY=xxx - TWITTER_ACCESS_TOKEN=xxx - TWITTER_ACCESS_TOKEN_SECRET=xxx - - # Option B: OAuth 2.0 PKCE (interactive login + approve, no client secret) + # Option A: Broker (recommended for enterprise/shared access) + TWITTER_AUTH_MODE=broker + TWITTER_BROKER_URL=https://broker.example.com + TWITTER_BROKER_API_KEY=xxx + + # Option B: OAuth 2.0 PKCE (developer account, interactive login + approve) # TWITTER_AUTH_MODE=oauth # TWITTER_CLIENT_ID=xxx # TWITTER_REDIRECT_URI=http://127.0.0.1:8080/callback + # Option C: legacy OAuth 1.0a (developer account, default if TWITTER_AUTH_MODE unset) + # TWITTER_AUTH_MODE=env + # TWITTER_API_KEY=xxx + # TWITTER_API_SECRET_KEY=xxx + # TWITTER_ACCESS_TOKEN=xxx + # TWITTER_ACCESS_TOKEN_SECRET=xxx + TWITTER_ENABLE_POST=true TWITTER_POST_IMMEDIATELY=true ``` @@ -53,19 +62,28 @@ Tip: if you use **OAuth 2.0 PKCE**, the plugin will print an authorization URL o ## Prerequisites -- Twitter Developer Account with API v2 access -- Either Twitter OAuth 1.0a credentials (legacy env vars) or OAuth 2.0 Client ID (PKCE) +- **Enterprise/broker credentials (recommended)** or a **Twitter Developer Account** with API v2 access +- Either broker credentials, OAuth 2.0 Client ID (PKCE), or OAuth 1.0a credentials (legacy) - Node.js and bun installed ## 🚀 Quick Start -### Step 1: Get Twitter Developer Access +### Step 1: Get Twitter Access (Broker Recommended) + +Choose one path: +**A) Enterprise / broker (recommended)** +- If Eliza (or your org) provides enterprise Twitter access, request: + - `TWITTER_BROKER_URL` + - `TWITTER_BROKER_API_KEY` +- No Twitter developer account is required for broker mode. + +**B) BYO Developer Account** 1. Apply for a developer account at https://developer.twitter.com 2. Create a new app in the [Developer Portal](https://developer.twitter.com/en/portal/projects-and-apps) 3. Ensure your app has API v2 access -### Step 2: Configure App Permissions for Posting +### Step 2: Configure App Permissions for Posting (Developer Account Only) **⚠️ CRITICAL: Default apps can only READ. You must enable WRITE permissions to post tweets!** @@ -90,9 +108,10 @@ Tip: if you use **OAuth 2.0 PKCE**, the plugin will print an authorization URL o 3. Click **Save** -### Step 3: Get the RIGHT Credentials (OAuth 1.0a) +### Step 3: Get the RIGHT Credentials (Developer Account Only) You can use either legacy **OAuth 1.0a** env vars (default) or **OAuth 2.0 PKCE** (“login + approve”). +If you're using broker mode, skip this section. In your app's **"Keys and tokens"** page, you'll see several sections. Here's what to use: @@ -128,21 +147,17 @@ In your app's **"Keys and tokens"** page, you'll see several sections. Here's wh Create or edit `.env` file in your project root: ```bash -# Auth mode (default: env) -# - env: legacy OAuth 1.0a keys/tokens +# Auth mode (default in code: env; recommended for enterprise: broker) +# - broker: token broker service (short-lived OAuth2 access tokens) # - oauth: “login + approve” OAuth 2.0 PKCE (no client secret in plugin) -# - broker: stub (not implemented yet) -TWITTER_AUTH_MODE=env - -# REQUIRED: OAuth 1.0a Credentials (from "Consumer Keys" section) -TWITTER_API_KEY=your_api_key_here # From "API Key" -TWITTER_API_SECRET_KEY=your_api_key_secret_here # From "API Key Secret" +# - env: legacy OAuth 1.0a keys/tokens -# REQUIRED: OAuth 1.0a Tokens (from "Authentication Tokens" section) -TWITTER_ACCESS_TOKEN=your_access_token_here # Must have "Read and Write" -TWITTER_ACCESS_TOKEN_SECRET=your_token_secret_here # Regenerate after permission change +# ---- Recommended (Enterprise/Broker) ---- +TWITTER_AUTH_MODE=broker +TWITTER_BROKER_URL=https://broker.example.com +TWITTER_BROKER_API_KEY=your_agent_api_key -# ---- OR ---- +# ---- OR (Developer Account) ---- # OAuth 2.0 PKCE (“login + approve”) configuration: # TWITTER_AUTH_MODE=oauth # TWITTER_CLIENT_ID=your_oauth2_client_id_here @@ -150,6 +165,13 @@ TWITTER_ACCESS_TOKEN_SECRET=your_token_secret_here # Regenerate after permissi # Optional: # TWITTER_SCOPES="tweet.read tweet.write users.read offline.access" +# ---- OR (Developer Account, Legacy OAuth 1.0a) ---- +# TWITTER_AUTH_MODE=env +# TWITTER_API_KEY=your_api_key_here # From "API Key" +# TWITTER_API_SECRET_KEY=your_api_key_secret_here # From "API Key Secret" +# TWITTER_ACCESS_TOKEN=your_access_token_here # Must have "Read and Write" +# TWITTER_ACCESS_TOKEN_SECRET=your_token_secret_here # Regenerate after permission change + # Basic Configuration TWITTER_DRY_RUN=false # Set to true to test without posting TWITTER_ENABLE_POST=true # Enable autonomous tweet posting @@ -167,6 +189,11 @@ When using **TWITTER_AUTH_MODE=oauth**, the plugin will: - Capture the callback via a local loopback server **or** ask you to paste the redirected URL - Persist tokens via Eliza runtime cache if available, otherwise a local token file at `~/.eliza/twitter/oauth2.tokens.json` +When using **TWITTER_AUTH_MODE=broker**, the plugin will: +- Call `GET {TWITTER_BROKER_URL}/v1/twitter/access-token` +- Send `Authorization: Bearer {TWITTER_BROKER_API_KEY}` +- Use the returned short-lived OAuth2 access token for Twitter API v2 calls + ### Step 5: Run Your Bot ```typescript @@ -199,6 +226,21 @@ TWITTER_API_SECRET_KEY= # Consumer API Secret TWITTER_ACCESS_TOKEN= # Access Token (with write permissions) TWITTER_ACCESS_TOKEN_SECRET= # Access Token Secret +# Auth Mode +# - env (default): OAuth 1.0a credentials above +# - oauth: OAuth 2.0 PKCE (client id + redirect uri) +# - broker: token broker service (short-lived OAuth2 access tokens) +TWITTER_AUTH_MODE=env + +# OAuth 2.0 PKCE +TWITTER_CLIENT_ID= # OAuth 2.0 Client ID (oauth mode) +TWITTER_REDIRECT_URI= # Redirect URI (oauth mode) +TWITTER_SCOPES= # Optional, space-separated scopes + +# Broker mode +TWITTER_BROKER_URL= # Broker base URL (broker mode) +TWITTER_BROKER_API_KEY= # Broker agent API key (broker mode) + # Core Configuration TWITTER_DRY_RUN=false # Set to true for testing without posting TWITTER_TARGET_USERS= # Comma-separated usernames to target (use "*" for all) @@ -316,6 +358,9 @@ TWITTER_ENABLE_ACTIONS=false # Disable timeline actions ### Want Full Interaction Bot? +If you're using **broker mode**, just set `TWITTER_AUTH_MODE=broker` with your broker URL/API key and keep the feature toggles below. +The example here shows **developer account** credentials. + ```bash # Full interaction setup TWITTER_API_KEY=xxx @@ -345,9 +390,10 @@ TWITTER_POST_IMMEDIATELY=true If you see errors like "Failed to create tweet: Request failed with code 403", this usually means: 1. **Missing Write Permissions**: Make sure your Twitter app has "Read and write" permissions - - Go to your app settings in the Twitter Developer Portal + - Go to your app settings in the Twitter Developer Portal (BYO developer account) - Check that App permissions shows "Read and write" ✅ - If not, change it and regenerate your Access Token & Secret + - **Broker/enterprise**: confirm your broker app has write permissions (or ask your broker admin) 2. **Protected Accounts**: The bot may be trying to engage with protected/private accounts - The plugin now automatically skips these with a warning @@ -393,6 +439,10 @@ This usually means your credentials don’t match your selected auth mode. - Use OAuth 2.0 **Client ID** (`TWITTER_CLIENT_ID`) - Set a loopback redirect URI (`TWITTER_REDIRECT_URI`, e.g. `http://127.0.0.1:8080/callback`) - Do not set/ship a client secret (PKCE flow) +- If `TWITTER_AUTH_MODE=broker`: + - Set `TWITTER_BROKER_URL` to your broker base URL + - Set `TWITTER_BROKER_API_KEY` to the agent API key issued by the broker + - Verify the broker responds at `/v1/twitter/access-token` ### Bot Not Posting Automatically @@ -406,7 +456,7 @@ This usually means your credentials don’t match your selected auth mode. ### Timeline Not Loading **Common causes:** -- Rate limiting (check Twitter Developer Portal) +- Rate limiting (check broker logs or Twitter Developer Portal if BYO) - Invalid credentials - Account restrictions @@ -415,8 +465,8 @@ This usually means your credentials don’t match your selected auth mode. Your tokens may have been revoked or regenerated. **Solution:** -1. Go to Twitter Developer Portal -2. Regenerate all tokens +1. If **broker mode**: rotate/reissue the broker API key (or ask your broker admin) +2. If **developer account**: go to Twitter Developer Portal and regenerate tokens 3. Update `.env` 4. Restart bot @@ -469,6 +519,22 @@ DEBUG=eliza:* bun start TWITTER_DRY_RUN=true bun start ``` +### Mock broker (local dev) + +If you want to test broker mode without a real broker, you can run a local mock broker: + +```bash +bun run mock:broker +``` + +Then set: + +```bash +TWITTER_AUTH_MODE=broker +TWITTER_BROKER_URL=http://127.0.0.1:8787 +TWITTER_BROKER_API_KEY=dev-key +``` + ### Testing Checklist 1. **Test Auth**: Check logs for successful Twitter login @@ -481,7 +547,7 @@ TWITTER_DRY_RUN=true bun start - Store credentials in `.env` file (never commit!) - Use `.env.local` for local development - Regularly rotate API keys -- Monitor API usage in Developer Portal +- Monitor API usage (Developer Portal if BYO, broker logs if enterprise) - Enable only necessary permissions - Review [Twitter's automation rules](https://help.twitter.com/en/rules-and-policies/twitter-automation) @@ -493,7 +559,9 @@ This plugin uses Twitter API v2 endpoints efficiently: - **User Lookups**: Cached to reduce calls - **Search**: Configurable intervals -Monitor your usage at: https://developer.twitter.com/en/portal/dashboard +Monitor your usage: +- **BYO developer account**: https://developer.twitter.com/en/portal/dashboard +- **Broker/enterprise**: use your broker's dashboard/logs ## 📖 Additional Resources diff --git a/package.json b/package.json index a0a57a7..c0fefab 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "lint": "prettier --write ./src", "clean": "rm -rf dist .turbo node_modules .turbo-tsconfig.json tsconfig.tsbuildinfo", "format": "prettier --write ./src", - "format:check": "prettier --check ./src" + "format:check": "prettier --check ./src", + "mock:broker": "node scripts/mock-broker.mjs" }, "publishConfig": { "access": "public" @@ -54,7 +55,7 @@ "pluginParameters": { "TWITTER_AUTH_MODE": { "type": "string", - "description": "Auth mode: 'env' (legacy keys/tokens), 'oauth' (3-legged OAuth2 PKCE), or 'broker' (stub).", + "description": "Auth mode: 'env' (legacy keys/tokens), 'oauth' (3-legged OAuth2 PKCE), or 'broker' (token broker service).", "required": false, "default": "env", "sensitive": false @@ -104,10 +105,16 @@ }, "TWITTER_BROKER_URL": { "type": "string", - "description": "Broker URL (required for TWITTER_AUTH_MODE=broker; stub only).", + "description": "Broker URL (required for TWITTER_AUTH_MODE=broker).", "required": false, "sensitive": false }, + "TWITTER_BROKER_API_KEY": { + "type": "string", + "description": "Broker agent API key (required for TWITTER_AUTH_MODE=broker).", + "required": false, + "sensitive": true + }, "TWITTER_TARGET_USERS": { "type": "string", "description": "Comma-separated list of Twitter usernames the bot should interact with. Use '*' for all users.", diff --git a/scripts/mock-broker.mjs b/scripts/mock-broker.mjs new file mode 100644 index 0000000..b1328bf --- /dev/null +++ b/scripts/mock-broker.mjs @@ -0,0 +1,44 @@ +import { createServer } from "node:http"; + +const port = Number(process.env.MOCK_BROKER_PORT ?? process.env.PORT ?? 8787); +const apiKey = process.env.MOCK_BROKER_API_KEY ?? "dev-key"; +const accessToken = process.env.MOCK_BROKER_TOKEN ?? "mock-access-token"; +const expiresIn = Number(process.env.MOCK_BROKER_EXPIRES_IN ?? 3600); + +const server = createServer((req, res) => { + if (req.method === "GET" && req.url === "/healthz") { + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (req.method === "GET" && req.url === "/v1/twitter/access-token") { + const auth = req.headers.authorization; + if (auth !== `Bearer ${apiKey}`) { + res.statusCode = 401; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: "unauthorized" })); + return; + } + + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + access_token: accessToken, + expires_at: Date.now() + expiresIn * 1000, + }), + ); + return; + } + + res.statusCode = 404; + res.end(); +}); + +server.listen(port, () => { + console.log(`[mock-broker] listening on http://127.0.0.1:${port}`); + console.log(`[mock-broker] TWITTER_BROKER_URL=http://127.0.0.1:${port}`); + console.log(`[mock-broker] TWITTER_BROKER_API_KEY=${apiKey}`); +}); diff --git a/src/__tests__/README.md b/src/__tests__/README.md index eed38a2..6214d24 100644 --- a/src/__tests__/README.md +++ b/src/__tests__/README.md @@ -12,7 +12,8 @@ __tests__/ │ ├── auth.test.ts # Unit tests for TwitterAuth │ └── environment.test.ts # Unit tests for config validation └── e2e/ - └── twitter-integration.test.ts # End-to-end tests with real API + ├── broker-auth.test.ts # End-to-end broker auth against local server + └── twitter-integration.test.ts # End-to-end tests with real API ``` ## Running Tests @@ -34,21 +35,24 @@ npm test -- --coverage ### End-to-End Tests -E2E tests require real Twitter Developer API credentials and currently exercise **TWITTER_AUTH_MODE=env** (OAuth 1.0a keys/tokens). +E2E tests include: +- A local broker auth test (mock broker server) +- A mocked Twitter API flow that exercises the full plugin stack without real credentials + +If you want to manually test broker mode without a real broker, use the mock broker script: + +```bash +npm run mock:broker +``` The plugin also supports **TWITTER_AUTH_MODE=oauth** (OAuth 2.0 PKCE “login + approve”), but that flow is interactive and is not covered by these E2E tests. #### Prerequisites -1. **Twitter Developer Account**: You need a Twitter Developer account with an app created -2. **API Credentials (env mode)**: You need all four credentials: - - API Key (Consumer Key) - - API Secret Key (Consumer Secret) - - Access Token - - Access Token Secret +None for the mocked suite. Real credentials are only needed if you add/enable real Twitter integration tests. #### Setup -1. Create a `.env.test` file in the plugin root directory: +1. (Optional) Create a `.env.test` file in the plugin root directory if you want to run real Twitter integration tests: ```env # Twitter API v2 Credentials @@ -64,7 +68,7 @@ TWITTER_ACCESS_TOKEN_SECRET=your_access_token_secret_here #### Running E2E Tests ```bash -# Run E2E tests (will skip if no credentials) +# Run E2E tests (mocked) npm test e2e # Run with verbose output diff --git a/src/__tests__/TESTING_GUIDE.md b/src/__tests__/TESTING_GUIDE.md index 32bb88d..0bdcbac 100644 --- a/src/__tests__/TESTING_GUIDE.md +++ b/src/__tests__/TESTING_GUIDE.md @@ -7,15 +7,17 @@ This guide explains how to test the Twitter plugin after removing username/passw The plugin supports multiple auth modes: - `TWITTER_AUTH_MODE=env` (legacy OAuth 1.0a keys/tokens) - `TWITTER_AUTH_MODE=oauth` (OAuth 2.0 Authorization Code + PKCE, interactive “login + approve”, no client secret) -- `TWITTER_AUTH_MODE=broker` (stub only, not implemented yet) +- `TWITTER_AUTH_MODE=broker` (token broker service returning short-lived OAuth2 access tokens) ## Prerequisites -### 1. Twitter Developer Account +### 1. Twitter Developer Account (optional) -You need a Twitter Developer account. Which credentials you need depends on auth mode: +The default E2E suite uses mocked Twitter + broker services, so real credentials +are not required. If you want to run real Twitter integration tests manually, +you'll need: -- For `TWITTER_AUTH_MODE=env` (E2E tests use this): +- For `TWITTER_AUTH_MODE=env`: - API Key - API Secret Key - Access Token @@ -23,17 +25,14 @@ You need a Twitter Developer account. Which credentials you need depends on auth - For `TWITTER_AUTH_MODE=oauth`: - OAuth 2.0 Client ID (`TWITTER_CLIENT_ID`) - Redirect URI (`TWITTER_REDIRECT_URI`) +- For `TWITTER_AUTH_MODE=broker`: + - Broker URL (`TWITTER_BROKER_URL`) + - Broker API key (`TWITTER_BROKER_API_KEY`) -To get these credentials: +### 2. Environment Setup (optional for real Twitter) -1. Go to https://developer.twitter.com/ -2. Create a developer account (if you don't have one) -3. Create a new app in the developer portal -4. Generate API keys and access tokens - -### 2. Environment Setup - -Create a `.env.test` file in the plugin root directory: +Create a `.env.test` file in the plugin root directory if you want to run real +Twitter integration tests: ```bash TWITTER_AUTH_MODE=env @@ -43,6 +42,22 @@ TWITTER_ACCESS_TOKEN=your_access_token_here TWITTER_ACCESS_TOKEN_SECRET=your_access_token_secret_here ``` +### Mock broker (optional) + +For broker mode tests or local smoke runs without a real broker, start the mock broker: + +```bash +npm run mock:broker +``` + +Then set: + +```bash +TWITTER_AUTH_MODE=broker +TWITTER_BROKER_URL=http://127.0.0.1:8787 +TWITTER_BROKER_API_KEY=dev-key +``` + ## Running Tests ### Unit Tests @@ -65,13 +80,13 @@ npm test -- --watch ### E2E Tests -End-to-end tests require real Twitter API credentials: +End-to-end tests run against mocked Twitter + broker services by default: ```bash -# Run E2E tests (requires .env.test file) +# Run E2E tests (mocked) npm test -- --run e2e -# Skip E2E tests if no credentials +# Skip E2E tests npm test -- --run --exclude="**/e2e/**" ``` diff --git a/src/__tests__/e2e/broker-auth.test.ts b/src/__tests__/e2e/broker-auth.test.ts new file mode 100644 index 0000000..fbd25b7 --- /dev/null +++ b/src/__tests__/e2e/broker-auth.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { BrokerAuthProvider } from "../../client/auth-providers/broker"; +import { startMockBrokerServer, type MockBrokerServer } from "../helpers/mock-broker"; + +describe("BrokerAuthProvider E2E", () => { + let broker: MockBrokerServer; + + beforeAll(async () => { + broker = await startMockBrokerServer({ + expectedApiKey: "test-api-key", + accessToken: "broker-token", + }); + }); + + afterAll(async () => { + await broker.close(); + }); + + it("fetches token from a local broker", async () => { + const runtime: any = { + getSetting: (key: string) => { + if (key === "TWITTER_BROKER_URL") return broker.baseUrl; + if (key === "TWITTER_BROKER_API_KEY") return "test-api-key"; + return undefined; + }, + }; + + const provider = new BrokerAuthProvider(runtime); + const token = await provider.getAccessToken(); + + expect(token).toBe("broker-token"); + expect(broker.getLastAuthHeader()).toBe("Bearer test-api-key"); + }); + + it("surfaces broker auth failures clearly", async () => { + const runtime: any = { + getSetting: (key: string) => { + if (key === "TWITTER_BROKER_URL") return broker.baseUrl; + if (key === "TWITTER_BROKER_API_KEY") return "wrong-key"; + return undefined; + }, + }; + + const provider = new BrokerAuthProvider(runtime); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter broker token request failed (401)", + ); + }); +}); diff --git a/src/__tests__/e2e/twitter-integration.test.ts b/src/__tests__/e2e/twitter-integration.test.ts index 0478127..bddcb9e 100644 --- a/src/__tests__/e2e/twitter-integration.test.ts +++ b/src/__tests__/e2e/twitter-integration.test.ts @@ -7,25 +7,19 @@ import { beforeEach, vi, } from "vitest"; +import { + createMockTwitterApiModule, + resetMockTwitterState, +} from "../helpers/mock-twitter-api"; + +vi.mock("twitter-api-v2", () => createMockTwitterApiModule()); import { TwitterMessageService } from "../../services/MessageService"; import { TwitterPostService } from "../../services/PostService"; import { ClientBase } from "../../base"; import { MessageType } from "../../services/IMessageService"; import { SearchMode } from "../../client"; import type { IAgentRuntime } from "@elizaos/core"; -import dotenv from "dotenv"; - -// Load environment variables from .env.test file -dotenv.config({ path: ".env.test" }); - -// Skip these tests if no API credentials are provided -const SKIP_E2E = - !process.env.TWITTER_API_KEY || - !process.env.TWITTER_API_SECRET_KEY || - !process.env.TWITTER_ACCESS_TOKEN || - !process.env.TWITTER_ACCESS_TOKEN_SECRET; - -describe.skipIf(SKIP_E2E)("Twitter E2E Integration Tests", () => { +describe("Twitter E2E Integration Tests (Mocked)", () => { let client: ClientBase; let messageService: TwitterMessageService; let postService: TwitterPostService; @@ -33,6 +27,14 @@ describe.skipIf(SKIP_E2E)("Twitter E2E Integration Tests", () => { let testTweetIds: string[] = []; beforeAll(async () => { + resetMockTwitterState(); + + process.env.TWITTER_AUTH_MODE = "env"; + process.env.TWITTER_API_KEY = "mock-api-key"; + process.env.TWITTER_API_SECRET_KEY = "mock-api-secret"; + process.env.TWITTER_ACCESS_TOKEN = "mock-access-token"; + process.env.TWITTER_ACCESS_TOKEN_SECRET = "mock-access-secret"; + // Setup runtime mock runtime = { agentId: "test-agent-123" as any, @@ -44,7 +46,7 @@ describe.skipIf(SKIP_E2E)("Twitter E2E Integration Tests", () => { ensureWorldExists: vi.fn(), ensureConnection: vi.fn(), createMemory: vi.fn(), - getEntityById: vi.fn().mockResolvedValue(null), + getEntityById: vi.fn().mockResolvedValue({ names: [], metadata: {} }), updateEntity: vi.fn(), } as any; @@ -79,8 +81,7 @@ describe.skipIf(SKIP_E2E)("Twitter E2E Integration Tests", () => { }); beforeEach(() => { - // Add delay between tests to avoid rate limiting - return new Promise((resolve) => setTimeout(resolve, 2000)); + resetMockTwitterState(); }); describe("Authentication", () => { diff --git a/src/__tests__/environment.test.ts b/src/__tests__/environment.test.ts index c057131..5ef6008 100644 --- a/src/__tests__/environment.test.ts +++ b/src/__tests__/environment.test.ts @@ -3,6 +3,11 @@ import { validateTwitterConfig, shouldTargetUser, twitterEnvSchema, + getTargetUsers, + getRandomInterval, + loadConfig, + loadConfigFromFile, + validateConfig, } from "../environment"; import type { IAgentRuntime } from "@elizaos/core"; import { z } from "zod"; @@ -26,6 +31,7 @@ describe("Environment Configuration", () => { vi.stubEnv("TWITTER_CLIENT_ID", ""); vi.stubEnv("TWITTER_REDIRECT_URI", ""); vi.stubEnv("TWITTER_BROKER_URL", ""); + vi.stubEnv("TWITTER_BROKER_API_KEY", ""); }); describe("shouldTargetUser", () => { @@ -67,6 +73,20 @@ describe("Environment Configuration", () => { }); }); + describe("getTargetUsers", () => { + it("returns empty list when wildcard is present", () => { + expect(getTargetUsers("alice,*,bob")).toEqual(["alice", "bob"]); + }); + + it("returns trimmed users", () => { + expect(getTargetUsers(" alice , bob ")).toEqual(["alice", "bob"]); + }); + + it("returns empty list for empty input", () => { + expect(getTargetUsers("")).toEqual([]); + }); + }); + describe("validateTwitterConfig", () => { it("should validate config with all required API credentials", async () => { mockRuntime.getSetting = vi.fn((key) => { @@ -87,6 +107,81 @@ describe("Environment Configuration", () => { expect(config.TWITTER_ACCESS_TOKEN_SECRET).toBe("test-access-secret"); }); + it("defaults auth mode to env when missing", async () => { + const originalEnv = { + TWITTER_AUTH_MODE: process.env.TWITTER_AUTH_MODE, + TWITTER_BROKER_URL: process.env.TWITTER_BROKER_URL, + TWITTER_BROKER_API_KEY: process.env.TWITTER_BROKER_API_KEY, + }; + delete process.env.TWITTER_AUTH_MODE; + delete process.env.TWITTER_BROKER_URL; + delete process.env.TWITTER_BROKER_API_KEY; + + mockRuntime.getSetting = vi.fn((key) => { + const settings = { + TWITTER_API_KEY: "test-api-key", + TWITTER_API_SECRET_KEY: "test-api-secret", + TWITTER_ACCESS_TOKEN: "test-access-token", + TWITTER_ACCESS_TOKEN_SECRET: "test-access-secret", + }; + return settings[key]; + }); + + const config = await validateTwitterConfig(mockRuntime); + expect(config.TWITTER_AUTH_MODE).toBe("env"); + expect(config.TWITTER_BROKER_URL).toBe(""); + expect(config.TWITTER_BROKER_API_KEY).toBe(""); + + process.env.TWITTER_AUTH_MODE = originalEnv.TWITTER_AUTH_MODE; + process.env.TWITTER_BROKER_URL = originalEnv.TWITTER_BROKER_URL; + process.env.TWITTER_BROKER_API_KEY = originalEnv.TWITTER_BROKER_API_KEY; + }); + + it("defaults optional oauth fields when broker mode is used", async () => { + const originalEnv = { + TWITTER_API_KEY: process.env.TWITTER_API_KEY, + TWITTER_API_SECRET_KEY: process.env.TWITTER_API_SECRET_KEY, + TWITTER_ACCESS_TOKEN: process.env.TWITTER_ACCESS_TOKEN, + TWITTER_ACCESS_TOKEN_SECRET: process.env.TWITTER_ACCESS_TOKEN_SECRET, + TWITTER_CLIENT_ID: process.env.TWITTER_CLIENT_ID, + TWITTER_REDIRECT_URI: process.env.TWITTER_REDIRECT_URI, + TWITTER_BROKER_URL: process.env.TWITTER_BROKER_URL, + TWITTER_BROKER_API_KEY: process.env.TWITTER_BROKER_API_KEY, + }; + delete process.env.TWITTER_API_KEY; + delete process.env.TWITTER_API_SECRET_KEY; + delete process.env.TWITTER_ACCESS_TOKEN; + delete process.env.TWITTER_ACCESS_TOKEN_SECRET; + delete process.env.TWITTER_CLIENT_ID; + delete process.env.TWITTER_REDIRECT_URI; + delete process.env.TWITTER_BROKER_URL; + delete process.env.TWITTER_BROKER_API_KEY; + + mockRuntime.getSetting = vi.fn(() => undefined); + + const config = await validateTwitterConfig(mockRuntime, { + TWITTER_AUTH_MODE: "broker", + TWITTER_BROKER_URL: "https://broker.example.com", + TWITTER_BROKER_API_KEY: "broker-key", + }); + + expect(config.TWITTER_ACCESS_TOKEN).toBe(""); + expect(config.TWITTER_ACCESS_TOKEN_SECRET).toBe(""); + expect(config.TWITTER_CLIENT_ID).toBe(""); + expect(config.TWITTER_REDIRECT_URI).toBe(""); + expect(config.TWITTER_BROKER_URL).toBe("https://broker.example.com"); + expect(config.TWITTER_BROKER_API_KEY).toBe("broker-key"); + + process.env.TWITTER_API_KEY = originalEnv.TWITTER_API_KEY; + process.env.TWITTER_API_SECRET_KEY = originalEnv.TWITTER_API_SECRET_KEY; + process.env.TWITTER_ACCESS_TOKEN = originalEnv.TWITTER_ACCESS_TOKEN; + process.env.TWITTER_ACCESS_TOKEN_SECRET = originalEnv.TWITTER_ACCESS_TOKEN_SECRET; + process.env.TWITTER_CLIENT_ID = originalEnv.TWITTER_CLIENT_ID; + process.env.TWITTER_REDIRECT_URI = originalEnv.TWITTER_REDIRECT_URI; + process.env.TWITTER_BROKER_URL = originalEnv.TWITTER_BROKER_URL; + process.env.TWITTER_BROKER_API_KEY = originalEnv.TWITTER_BROKER_API_KEY; + }); + it("should throw error when required credentials are missing", async () => { mockRuntime.getSetting = vi.fn(() => undefined); @@ -111,6 +206,21 @@ describe("Environment Configuration", () => { expect(config.TWITTER_REDIRECT_URI).toBe("http://127.0.0.1:8080/callback"); }); + it("uses config override for redirect uri", async () => { + mockRuntime.getSetting = vi.fn((key) => { + const settings: Record = { + TWITTER_AUTH_MODE: "oauth", + TWITTER_CLIENT_ID: "client-id", + }; + return settings[key]; + }); + + const config = await validateTwitterConfig(mockRuntime, { + TWITTER_REDIRECT_URI: "http://127.0.0.1:8080/override", + }); + expect(config.TWITTER_REDIRECT_URI).toBe("http://127.0.0.1:8080/override"); + }); + it("should throw when oauth mode is missing required fields", async () => { mockRuntime.getSetting = vi.fn((key) => { const settings: Record = { @@ -126,6 +236,29 @@ describe("Environment Configuration", () => { ); }); + it("should throw for invalid auth mode", async () => { + mockRuntime.getSetting = vi.fn((key) => { + const settings: Record = { + TWITTER_AUTH_MODE: "invalid", + }; + return settings[key]; + }); + + await expect(validateTwitterConfig(mockRuntime)).rejects.toThrow( + "Invalid TWITTER_AUTH_MODE", + ); + }); + + it("rethrows non-zod errors", async () => { + mockRuntime.getSetting = vi.fn(() => { + throw new Error("settings failed"); + }); + + await expect(validateTwitterConfig(mockRuntime)).rejects.toThrow( + "settings failed", + ); + }); + it("should throw when broker mode is missing broker url", async () => { mockRuntime.getSetting = vi.fn((key) => { const settings: Record = { @@ -139,6 +272,49 @@ describe("Environment Configuration", () => { ); }); + it("should throw when broker mode is missing broker api key", async () => { + mockRuntime.getSetting = vi.fn((key) => { + const settings: Record = { + TWITTER_AUTH_MODE: "broker", + TWITTER_BROKER_URL: "https://broker.example.com", + }; + return settings[key]; + }); + + await expect(validateTwitterConfig(mockRuntime)).rejects.toThrow( + "TWITTER_BROKER_API_KEY", + ); + }); + + it("should validate broker mode with url and api key", async () => { + mockRuntime.getSetting = vi.fn((key) => { + const settings: Record = { + TWITTER_AUTH_MODE: "broker", + TWITTER_BROKER_URL: "https://broker.example.com", + TWITTER_BROKER_API_KEY: "agent-key", + }; + return settings[key]; + }); + + const config = await validateTwitterConfig(mockRuntime); + expect(config.TWITTER_AUTH_MODE).toBe("broker"); + expect(config.TWITTER_BROKER_URL).toBe("https://broker.example.com"); + expect(config.TWITTER_BROKER_API_KEY).toBe("agent-key"); + }); + + it("uses config overrides for broker settings", async () => { + mockRuntime.getSetting = vi.fn(() => undefined); + + const config = await validateTwitterConfig(mockRuntime, { + TWITTER_AUTH_MODE: "broker", + TWITTER_BROKER_URL: "https://config-broker.example.com", + TWITTER_BROKER_API_KEY: "config-key", + }); + + expect(config.TWITTER_BROKER_URL).toBe("https://config-broker.example.com"); + expect(config.TWITTER_BROKER_API_KEY).toBe("config-key"); + }); + it("should use default values for optional settings", async () => { mockRuntime.getSetting = vi.fn((key) => { const settings = { @@ -179,6 +355,24 @@ describe("Environment Configuration", () => { expect(config.TWITTER_DRY_RUN).toBe("false"); }); + it("uses config override for enable replies", async () => { + mockRuntime.getSetting = vi.fn((key) => { + const settings = { + TWITTER_API_KEY: "test-api-key", + TWITTER_API_SECRET_KEY: "test-api-secret", + TWITTER_ACCESS_TOKEN: "test-access-token", + TWITTER_ACCESS_TOKEN_SECRET: "test-access-secret", + TWITTER_ENABLE_REPLIES: "true", + }; + return settings[key]; + }); + + const config = await validateTwitterConfig(mockRuntime, { + TWITTER_ENABLE_REPLIES: "false", + }); + expect(config.TWITTER_ENABLE_REPLIES).toBe("false"); + }); + it("should handle partial config override", async () => { mockRuntime.getSetting = vi.fn((key) => { const settings = { @@ -241,17 +435,26 @@ describe("Environment Configuration", () => { }); it("should handle zod validation errors", async () => { - mockRuntime.getSetting = vi.fn(() => undefined); + mockRuntime.getSetting = vi.fn((key) => { + const settings = { + TWITTER_API_KEY: "test-api-key", + TWITTER_API_SECRET_KEY: "test-api-secret", + TWITTER_ACCESS_TOKEN: "test-access-token", + TWITTER_ACCESS_TOKEN_SECRET: "test-access-secret", + }; + return settings[key]; + }); - // Create a scenario that will fail zod validation + // Create a scenario that will fail zod validation without failing auth checks const invalidConfig = { - TWITTER_API_KEY: 123, // Should be string + TWITTER_TARGET_USERS: 123, // Should be string }; await expect( validateTwitterConfig(mockRuntime, invalidConfig as any), ).rejects.toThrow(); }); + }); describe("twitterEnvSchema", () => { @@ -282,6 +485,9 @@ describe("Environment Configuration", () => { if (result.success) { // Should have default for TWITTER_TARGET_USERS expect(result.data.TWITTER_TARGET_USERS).toBe(""); + expect(result.data.TWITTER_MAX_ENGAGEMENTS_PER_RUN).toBe("5"); + expect(result.data.TWITTER_BROKER_URL).toBe(""); + expect(result.data.TWITTER_BROKER_API_KEY).toBe(""); } }); @@ -294,4 +500,140 @@ describe("Environment Configuration", () => { expect(result.success).toBe(false); }); }); + + describe("loadConfig helpers", () => { + it("loadConfigFromFile returns empty config", () => { + expect(loadConfigFromFile()).toEqual({}); + }); + + it("loadConfig merges env defaults and overrides", () => { + vi.stubEnv("TWITTER_AUTH_MODE", "broker"); + vi.stubEnv("TWITTER_BROKER_URL", "https://broker.example.com"); + vi.stubEnv("TWITTER_BROKER_API_KEY", "agent-key"); + + const config = loadConfig(); + expect(config.TWITTER_AUTH_MODE).toBe("broker"); + expect(config.TWITTER_BROKER_URL).toBe("https://broker.example.com"); + expect(config.TWITTER_BROKER_API_KEY).toBe("agent-key"); + }); + + it("validateConfig parses schema", () => { + const config = validateConfig({ TWITTER_AUTH_MODE: "env" }); + expect(config.TWITTER_AUTH_MODE).toBe("env"); + }); + + it("loadConfig works when process is undefined", () => { + const originalProcess = (globalThis as any).process; + Object.defineProperty(globalThis, "process", { + value: undefined, + configurable: true, + }); + + const config = loadConfig(); + expect(config.TWITTER_AUTH_MODE).toBe("env"); + + Object.defineProperty(globalThis, "process", { + value: originalProcess, + configurable: true, + }); + }); + }); + + describe("getRandomInterval", () => { + const runtime: any = { + getSetting: vi.fn(), + character: {}, + agentId: "agent-123" as any, + }; + + it("uses min/max when configured", () => { + runtime.getSetting = vi.fn((key: string) => { + const values: Record = { + TWITTER_POST_INTERVAL_MIN: "10", + TWITTER_POST_INTERVAL_MAX: "20", + }; + return values[key]; + }); + const spy = vi.spyOn(Math, "random").mockReturnValue(0.5); + const value = getRandomInterval(runtime, "post"); + spy.mockRestore(); + expect(value).toBe(15); + }); + + it("falls back to fixed interval when min/max invalid", () => { + runtime.getSetting = vi.fn((key: string) => { + const values: Record = { + TWITTER_POST_INTERVAL_MIN: "30", + TWITTER_POST_INTERVAL_MAX: "10", + TWITTER_POST_INTERVAL: "120", + }; + return values[key]; + }); + const value = getRandomInterval(runtime, "post"); + expect(value).toBe(120); + }); + + it("handles non-numeric interval values", () => { + runtime.getSetting = vi.fn((key: string) => { + const values: Record = { + TWITTER_POST_INTERVAL: "not-a-number", + }; + return values[key]; + }); + const value = getRandomInterval(runtime, "post"); + expect(value).toBe(120); + }); + + it("uses engagement fallback when min/max are missing", () => { + runtime.getSetting = vi.fn((key: string) => { + const values: Record = { + TWITTER_ENGAGEMENT_INTERVAL: "25", + }; + return values[key]; + }); + const value = getRandomInterval(runtime, "engagement"); + expect(value).toBe(25); + }); + + it("uses discovery fallback when min/max are missing", () => { + runtime.getSetting = vi.fn(() => undefined); + const value = getRandomInterval(runtime, "discovery"); + expect(value).toBe(20); + }); + + it("throws for unknown interval type", () => { + runtime.getSetting = vi.fn(() => undefined); + expect(() => getRandomInterval(runtime, "other" as any)).toThrow( + "Unknown interval type", + ); + }); + + it("uses engagement min/max when provided", () => { + runtime.getSetting = vi.fn((key: string) => { + const values: Record = { + TWITTER_ENGAGEMENT_INTERVAL_MIN: "5", + TWITTER_ENGAGEMENT_INTERVAL_MAX: "15", + }; + return values[key]; + }); + const spy = vi.spyOn(Math, "random").mockReturnValue(0.5); + const value = getRandomInterval(runtime, "engagement"); + spy.mockRestore(); + expect(value).toBe(10); + }); + + it("uses discovery min/max when provided", () => { + runtime.getSetting = vi.fn((key: string) => { + const values: Record = { + TWITTER_DISCOVERY_INTERVAL_MIN: "8", + TWITTER_DISCOVERY_INTERVAL_MAX: "12", + }; + return values[key]; + }); + const spy = vi.spyOn(Math, "random").mockReturnValue(0.25); + const value = getRandomInterval(runtime, "discovery"); + spy.mockRestore(); + expect(value).toBe(9); + }); + }); }); diff --git a/src/__tests__/helpers/mock-broker.ts b/src/__tests__/helpers/mock-broker.ts new file mode 100644 index 0000000..f8a11d8 --- /dev/null +++ b/src/__tests__/helpers/mock-broker.ts @@ -0,0 +1,74 @@ +import { createServer } from "node:http"; + +export interface MockBrokerOptions { + expectedApiKey?: string; + accessToken?: string; + expiresAt?: number; + responseStatus?: number; + responseBody?: Record; + errorStatus?: number; + errorBody?: Record; +} + +export interface MockBrokerServer { + baseUrl: string; + close: () => Promise; + getLastAuthHeader: () => string | undefined; +} + +export async function startMockBrokerServer( + options: MockBrokerOptions = {}, +): Promise { + const expectedApiKey = options.expectedApiKey ?? "test-api-key"; + const accessToken = options.accessToken ?? "broker-token"; + const expiresAt = options.expiresAt ?? Date.now() + 60_000; + const responseStatus = options.responseStatus ?? 200; + const responseBody = options.responseBody ?? { + access_token: accessToken, + expires_at: expiresAt, + }; + const errorStatus = options.errorStatus ?? 401; + const errorBody = options.errorBody ?? { error: "unauthorized" }; + + let lastAuthHeader: string | undefined; + + const server = createServer((req, res) => { + if (req.method === "GET" && req.url === "/v1/twitter/access-token") { + lastAuthHeader = req.headers.authorization; + if (req.headers.authorization !== `Bearer ${expectedApiKey}`) { + res.statusCode = errorStatus; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(errorBody)); + return; + } + + res.statusCode = responseStatus; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(responseBody)); + return; + } + + res.statusCode = 404; + res.end(); + }); + + let baseUrl = ""; + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address !== "string") { + baseUrl = `http://127.0.0.1:${address.port}`; + } + resolve(); + }); + }); + + return { + baseUrl, + close: () => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }), + getLastAuthHeader: () => lastAuthHeader, + }; +} diff --git a/src/__tests__/helpers/mock-twitter-api.ts b/src/__tests__/helpers/mock-twitter-api.ts new file mode 100644 index 0000000..680146c --- /dev/null +++ b/src/__tests__/helpers/mock-twitter-api.ts @@ -0,0 +1,226 @@ +type MockTweet = { + id: string; + text: string; + created_at: string; + author_id: string; + conversation_id: string; + referenced_tweets?: Array<{ type: "replied_to" | "retweeted" | "quoted"; id: string }>; + public_metrics: { + like_count: number; + retweet_count: number; + reply_count: number; + quote_count: number; + impression_count: number; + }; + entities?: { + hashtags?: Array<{ tag: string }>; + mentions?: Array<{ id: string; username: string }>; + urls?: Array<{ url: string }>; + }; +}; + +type MockUser = { + id: string; + username: string; + name: string; + description?: string; + profile_image_url?: string; + public_metrics?: { + followers_count: number; + following_count: number; + }; + verified?: boolean; + location?: string; + created_at?: string; +}; + +type MockIncludes = { + users?: MockUser[]; +}; + +class MockTwitterState { + private static instance: MockTwitterState | null = null; + + static get(): MockTwitterState { + if (!MockTwitterState.instance) { + MockTwitterState.instance = new MockTwitterState(); + } + return MockTwitterState.instance; + } + + private counter = 1; + readonly user: MockUser = { + id: "user-1", + username: "testuser", + name: "Test User", + description: "Mock user", + profile_image_url: "https://example.com/avatar.png", + public_metrics: { followers_count: 10, following_count: 5 }, + verified: false, + location: "Test City", + created_at: new Date("2020-01-01T00:00:00.000Z").toISOString(), + }; + private tweets = new Map(); + + reset() { + this.counter = 1; + this.tweets.clear(); + } + + createTweet(text: string, replyToId?: string): MockTweet { + const id = String(this.counter++); + const created_at = new Date().toISOString(); + const referenced_tweets = replyToId + ? [{ type: "replied_to" as const, id: replyToId }] + : undefined; + const conversation_id = replyToId ?? id; + + const tweet: MockTweet = { + id, + text, + created_at, + author_id: this.user.id, + conversation_id, + referenced_tweets, + public_metrics: { + like_count: 0, + retweet_count: 0, + reply_count: 0, + quote_count: 0, + impression_count: 0, + }, + entities: { + hashtags: [], + mentions: [], + urls: [], + }, + }; + + this.tweets.set(id, tweet); + return tweet; + } + + deleteTweet(id: string): boolean { + return this.tweets.delete(id); + } + + getTweet(id: string): MockTweet | undefined { + return this.tweets.get(id); + } + + listTweets(): MockTweet[] { + return Array.from(this.tweets.values()).sort((a, b) => + a.created_at < b.created_at ? 1 : -1, + ); + } + + listTweetsByUser(userId: string): MockTweet[] { + return this.listTweets().filter((tweet) => tweet.author_id === userId); + } + + searchTweets(query: string): MockTweet[] { + const normalized = query.toLowerCase(); + if (!normalized.trim()) { + return this.listTweets(); + } + return this.listTweets().filter((tweet) => + tweet.text.toLowerCase().includes(normalized), + ); + } + + likeTweet(id: string): void { + const tweet = this.tweets.get(id); + if (tweet) { + tweet.public_metrics.like_count += 1; + } + } +} + +class MockPaginator implements AsyncIterable { + constructor( + private readonly tweets: MockTweet[], + public readonly includes: MockIncludes, + ) {} + + async *[Symbol.asyncIterator](): AsyncIterator { + for (const tweet of this.tweets) { + yield tweet; + } + } +} + +class MockV2 { + constructor(private readonly state: MockTwitterState) {} + + async me() { + return { data: this.state.user }; + } + + async tweet(config: { text: string; reply?: { in_reply_to_tweet_id?: string } }) { + const replyToId = config.reply?.in_reply_to_tweet_id; + const tweet = this.state.createTweet(config.text, replyToId); + return { data: { id: tweet.id, text: tweet.text } }; + } + + async deleteTweet(id: string) { + const deleted = this.state.deleteTweet(id); + return { data: { deleted } }; + } + + async singleTweet(id: string) { + const tweet = this.state.getTweet(id); + return { + data: tweet ?? null, + includes: { users: [this.state.user] }, + }; + } + + async userTimeline(userId: string) { + const tweets = this.state.listTweetsByUser(userId); + return new MockPaginator(tweets, { users: [this.state.user] }); + } + + async homeTimeline() { + const tweets = this.state.listTweets(); + return new MockPaginator(tweets, { users: [this.state.user] }); + } + + async search(query: string) { + const tweets = this.state.searchTweets(query); + return new MockPaginator(tweets, { users: [this.state.user] }); + } + + async like(userId: string, tweetId: string) { + this.state.likeTweet(tweetId); + return { data: { liked: true } }; + } + + async userByUsername(username: string) { + if (username === this.state.user.username) { + return { data: this.state.user }; + } + return { data: null }; + } + + async user(userId: string) { + if (userId === this.state.user.id) { + return { data: this.state.user }; + } + return { data: null }; + } +} + +class MockTwitterApi { + v2: MockV2; + constructor() { + this.v2 = new MockV2(MockTwitterState.get()); + } +} + +export function createMockTwitterApiModule() { + return { TwitterApi: MockTwitterApi }; +} + +export function resetMockTwitterState() { + MockTwitterState.get().reset(); +} diff --git a/src/__tests__/plugin-init.test.ts b/src/__tests__/plugin-init.test.ts new file mode 100644 index 0000000..ee3dbd4 --- /dev/null +++ b/src/__tests__/plugin-init.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { logger } from "@elizaos/core"; +import { TwitterPlugin } from "../index"; + +describe("TwitterPlugin init", () => { + const logSpy = vi.spyOn(logger, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + + beforeEach(() => { + logSpy.mockClear(); + warnSpy.mockClear(); + }); + + it("warns when env credentials are missing", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => + key === "TWITTER_AUTH_MODE" ? "env" : undefined, + ), + }; + await TwitterPlugin.init({}, runtime); + expect(warnSpy).toHaveBeenCalled(); + }); + + it("logs when env credentials are present", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => { + const values: Record = { + TWITTER_AUTH_MODE: "env", + TWITTER_API_KEY: "key", + TWITTER_API_SECRET_KEY: "secret", + TWITTER_ACCESS_TOKEN: "token", + TWITTER_ACCESS_TOKEN_SECRET: "token-secret", + }; + return values[key]; + }), + }; + await TwitterPlugin.init({}, runtime); + expect(logSpy).toHaveBeenCalledWith("✅ Twitter env credentials found"); + }); + + it("warns when oauth config is missing", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => + key === "TWITTER_AUTH_MODE" ? "oauth" : undefined, + ), + }; + await TwitterPlugin.init({}, runtime); + expect(warnSpy).toHaveBeenCalled(); + }); + + it("logs when oauth config is present", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => { + const values: Record = { + TWITTER_AUTH_MODE: "oauth", + TWITTER_CLIENT_ID: "client-id", + TWITTER_REDIRECT_URI: "http://127.0.0.1/callback", + }; + return values[key]; + }), + }; + await TwitterPlugin.init({}, runtime); + expect(logSpy).toHaveBeenCalledWith("✅ Twitter OAuth configuration found"); + }); + + it("warns when broker config is missing", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => + key === "TWITTER_AUTH_MODE" ? "broker" : undefined, + ), + }; + await TwitterPlugin.init({}, runtime); + expect(warnSpy).toHaveBeenCalled(); + }); + + it("logs when broker config is present", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => { + const values: Record = { + TWITTER_AUTH_MODE: "broker", + TWITTER_BROKER_URL: "https://broker.example.com", + TWITTER_BROKER_API_KEY: "agent-key", + }; + return values[key]; + }), + }; + await TwitterPlugin.init({}, runtime); + expect(logSpy).toHaveBeenCalledWith("✅ Twitter broker configuration found"); + }); + + it("warns on invalid auth mode", async () => { + const runtime: any = { + getSetting: vi.fn(() => "invalid"), + }; + await TwitterPlugin.init({}, runtime); + expect(warnSpy).toHaveBeenCalledWith( + "Invalid TWITTER_AUTH_MODE=invalid. Expected env|oauth|broker.", + ); + }); + + it("defaults to env mode when auth mode is unset", async () => { + const runtime: any = { + getSetting: vi.fn(() => undefined), + }; + await TwitterPlugin.init({}, runtime); + expect(warnSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/client/__tests__/auth.test.ts b/src/client/__tests__/auth.test.ts index 67b02db..85e7e4a 100644 --- a/src/client/__tests__/auth.test.ts +++ b/src/client/__tests__/auth.test.ts @@ -51,6 +51,41 @@ describe("TwitterAuth", () => { expect(client).toBe(mockTwitterApi); }); }); + + it("initializes OAuth2 client with bearer token", async () => { + const oauthAuth = new TwitterAuth({ + mode: "oauth", + getAccessToken: async () => "oauth-token", + } as any); + + await oauthAuth.getV2Client(); + expect(TwitterApi).toHaveBeenCalledWith("oauth-token"); + }); + + it("reuses OAuth2 client when token is unchanged", async () => { + const getAccessToken = vi.fn(async () => "oauth-token"); + const oauthAuth = new TwitterAuth({ + mode: "oauth", + getAccessToken, + } as any); + + await oauthAuth.getV2Client(); + await oauthAuth.getV2Client(); + expect(TwitterApi).toHaveBeenCalledTimes(1); + expect(getAccessToken).toHaveBeenCalledTimes(2); + }); + + it("throws when client is not initialized", async () => { + const oauthAuth = new TwitterAuth({ + mode: "oauth", + getAccessToken: async () => "oauth-token", + } as any); + + (oauthAuth as any).ensureClientInitialized = vi.fn(async () => {}); + await expect(oauthAuth.getV2Client()).rejects.toThrow( + "Twitter API client not initialized", + ); + }); }); describe("isLoggedIn", () => { @@ -80,6 +115,17 @@ describe("TwitterAuth", () => { const isLoggedIn = await auth.isLoggedIn(); expect(isLoggedIn).toBe(false); }); + + it("returns false when not authenticated after initialization", async () => { + const oauthAuth = new TwitterAuth({ + mode: "oauth", + getAccessToken: async () => "oauth-token", + } as any); + + (oauthAuth as any).ensureClientInitialized = vi.fn(async () => {}); + const loggedIn = await oauthAuth.isLoggedIn(); + expect(loggedIn).toBe(false); + }); }); describe("me", () => { @@ -189,6 +235,16 @@ describe("TwitterAuth", () => { expect(profile).toBeUndefined(); }); + + it("throws when client is not initialized", async () => { + const oauthAuth = new TwitterAuth({ + mode: "oauth", + getAccessToken: async () => "oauth-token", + } as any); + + (oauthAuth as any).ensureClientInitialized = vi.fn(async () => {}); + await expect(oauthAuth.me()).rejects.toThrow("Not authenticated"); + }); }); describe("logout", () => { diff --git a/src/client/__tests__/broker-provider.test.ts b/src/client/__tests__/broker-provider.test.ts index 9b1121a..bbcd31c 100644 --- a/src/client/__tests__/broker-provider.test.ts +++ b/src/client/__tests__/broker-provider.test.ts @@ -2,14 +2,242 @@ import { describe, it, expect, vi } from "vitest"; import { BrokerAuthProvider } from "../auth-providers/broker"; describe("BrokerAuthProvider", () => { - it("throws if TWITTER_BROKER_URL is missing", async () => { + it("throws when TWITTER_BROKER_URL is missing", async () => { const runtime: any = { - getSetting: vi.fn(() => undefined), + getSetting: vi.fn((key: string) => (key === "TWITTER_BROKER_API_KEY" ? "key" : undefined)), }; - const provider = new BrokerAuthProvider(runtime); + const provider = new BrokerAuthProvider(runtime, vi.fn() as any); await expect(provider.getAccessToken()).rejects.toThrow( - "TWITTER_AUTH_MODE=broker requires TWITTER_BROKER_URL", + "TWITTER_BROKER_URL", + ); + }); + + it("throws when TWITTER_BROKER_API_KEY is missing", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => + key === "TWITTER_BROKER_URL" ? "https://broker.example.com" : undefined, + ), + }; + + const provider = new BrokerAuthProvider(runtime, vi.fn() as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "TWITTER_BROKER_API_KEY", + ); + }); + + it("throws when TWITTER_BROKER_URL is invalid", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => { + if (key === "TWITTER_BROKER_URL") return "not-a-url"; + if (key === "TWITTER_BROKER_API_KEY") return "broker-api-key"; + return undefined; + }), + }; + + const provider = new BrokerAuthProvider(runtime, vi.fn() as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Invalid TWITTER_BROKER_URL", + ); + }); + + it("throws when broker fetch fails", async () => { + const fetchImpl = vi.fn(() => { + throw new Error("network down"); + }); + + const runtime: any = { + getSetting: vi.fn((key: string) => { + if (key === "TWITTER_BROKER_URL") return "https://broker.example.com"; + if (key === "TWITTER_BROKER_API_KEY") return "broker-api-key"; + return undefined; + }), + }; + + const provider = new BrokerAuthProvider(runtime, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Failed to reach Twitter broker", + ); + }); + + it("handles non-Error fetch failures", async () => { + const fetchImpl = vi.fn(() => { + throw "boom"; + }); + + const runtime: any = { + getSetting: vi.fn((key: string) => { + if (key === "TWITTER_BROKER_URL") return "https://broker.example.com"; + if (key === "TWITTER_BROKER_API_KEY") return "broker-api-key"; + return undefined; + }), + }; + + const provider = new BrokerAuthProvider(runtime, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Failed to reach Twitter broker", + ); + }); + + it("requests access token from the broker", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "broker-access-token", + expires_at: Date.now() + 60_000, + }), + })); + + const runtime: any = { + getSetting: vi.fn((key: string) => { + if (key === "TWITTER_BROKER_URL") return "https://broker.example.com"; + if (key === "TWITTER_BROKER_API_KEY") return "broker-api-key"; + return undefined; + }), + }; + + const provider = new BrokerAuthProvider(runtime, fetchImpl as any); + const token = await provider.getAccessToken(); + expect(token).toBe("broker-access-token"); + expect(fetchImpl).toHaveBeenCalledWith( + "https://broker.example.com/v1/twitter/access-token", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + authorization: "Bearer broker-api-key", + }), + }), + ); + }); + + it("accepts expires_at as a string number", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "broker-access-token", + expires_at: String(Date.now() + 60_000), + }), + })); + + const runtime: any = { + getSetting: vi.fn((key: string) => { + if (key === "TWITTER_BROKER_URL") return "https://broker.example.com"; + if (key === "TWITTER_BROKER_API_KEY") return "broker-api-key"; + return undefined; + }), + }; + + const provider = new BrokerAuthProvider(runtime, fetchImpl as any); + const token = await provider.getAccessToken(); + expect(token).toBe("broker-access-token"); + }); + + it("throws clear errors for non-ok broker responses", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 403, + json: async () => ({ error: "forbidden" }), + })); + + const runtime: any = { + getSetting: vi.fn((key: string) => { + if (key === "TWITTER_BROKER_URL") return "https://broker.example.com"; + if (key === "TWITTER_BROKER_API_KEY") return "broker-api-key"; + return undefined; + }), + }; + + const provider = new BrokerAuthProvider(runtime, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter broker token request failed (403)", + ); + }); + + it("omits auth hint for non-auth failures", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 500, + json: async () => ({ error: "server_error" }), + })); + + const runtime: any = { + getSetting: vi.fn((key: string) => { + if (key === "TWITTER_BROKER_URL") return "https://broker.example.com"; + if (key === "TWITTER_BROKER_API_KEY") return "broker-api-key"; + return undefined; + }), + }; + + const provider = new BrokerAuthProvider(runtime, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter broker token request failed (500)", + ); + }); + + it("handles missing response body on error", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 502, + json: async () => { + throw new Error("bad json"); + }, + })); + + const runtime: any = { + getSetting: vi.fn((key: string) => { + if (key === "TWITTER_BROKER_URL") return "https://broker.example.com"; + if (key === "TWITTER_BROKER_API_KEY") return "broker-api-key"; + return undefined; + }), + }; + + const provider = new BrokerAuthProvider(runtime, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter broker token request failed (502): no response body.", + ); + }); + + it("rejects responses missing required fields", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ expires_at: Date.now() + 60_000 }), + })); + + const runtime: any = { + getSetting: vi.fn((key: string) => { + if (key === "TWITTER_BROKER_URL") return "https://broker.example.com"; + if (key === "TWITTER_BROKER_API_KEY") return "broker-api-key"; + return undefined; + }), + }; + + const provider = new BrokerAuthProvider(runtime, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter broker response missing access_token", + ); + }); + + it("rejects responses missing expires_at", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: "token-only" }), + })); + + const runtime: any = { + getSetting: vi.fn((key: string) => { + if (key === "TWITTER_BROKER_URL") return "https://broker.example.com"; + if (key === "TWITTER_BROKER_API_KEY") return "broker-api-key"; + return undefined; + }), + }; + + const provider = new BrokerAuthProvider(runtime, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter broker response missing expires_at", ); }); }); diff --git a/src/client/__tests__/env-provider.test.ts b/src/client/__tests__/env-provider.test.ts new file mode 100644 index 0000000..b32bed9 --- /dev/null +++ b/src/client/__tests__/env-provider.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from "vitest"; +import { EnvAuthProvider } from "../auth-providers/env"; + +describe("EnvAuthProvider", () => { + it("reads credentials from runtime settings", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => { + const values: Record = { + TWITTER_API_KEY: "api-key", + TWITTER_API_SECRET_KEY: "api-secret", + TWITTER_ACCESS_TOKEN: "access-token", + TWITTER_ACCESS_TOKEN_SECRET: "access-secret", + }; + return values[key]; + }), + }; + + const provider = new EnvAuthProvider(runtime); + const creds = await provider.getOAuth1Credentials(); + expect(creds).toEqual({ + appKey: "api-key", + appSecret: "api-secret", + accessToken: "access-token", + accessSecret: "access-secret", + }); + + const token = await provider.getAccessToken(); + expect(token).toBe("access-token"); + }); + + it("prefers explicit state over runtime settings", async () => { + const runtime: any = { + getSetting: vi.fn(() => "runtime"), + }; + + const provider = new EnvAuthProvider(runtime, { + TWITTER_API_KEY: "state-key", + TWITTER_API_SECRET_KEY: "state-secret", + TWITTER_ACCESS_TOKEN: "state-token", + TWITTER_ACCESS_TOKEN_SECRET: "state-secret-token", + }); + + const creds = await provider.getOAuth1Credentials(); + expect(creds.appKey).toBe("state-key"); + expect(runtime.getSetting).not.toHaveBeenCalled(); + }); + + it("throws a clear error when any credential is missing", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => (key === "TWITTER_API_KEY" ? "key" : undefined)), + }; + + const provider = new EnvAuthProvider(runtime); + await expect(provider.getOAuth1Credentials()).rejects.toThrow( + "Missing required Twitter env credentials", + ); + }); + + it("throws when TWITTER_API_KEY is missing", async () => { + const runtime: any = { + getSetting: vi.fn((key: string) => { + const values: Record = { + TWITTER_API_SECRET_KEY: "secret", + TWITTER_ACCESS_TOKEN: "token", + TWITTER_ACCESS_TOKEN_SECRET: "token-secret", + }; + return values[key]; + }), + }; + + const provider = new EnvAuthProvider(runtime); + await expect(provider.getOAuth1Credentials()).rejects.toThrow( + "TWITTER_API_KEY", + ); + }); +}); diff --git a/src/client/__tests__/factory.test.ts b/src/client/__tests__/factory.test.ts new file mode 100644 index 0000000..00772b4 --- /dev/null +++ b/src/client/__tests__/factory.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from "vitest"; +import { + createTwitterAuthProvider, + getTwitterAuthMode, +} from "../auth-providers/factory"; +import { EnvAuthProvider } from "../auth-providers/env"; +import { OAuth2PKCEAuthProvider } from "../auth-providers/oauth2-pkce"; +import { BrokerAuthProvider } from "../auth-providers/broker"; + +describe("auth provider factory", () => { + it("normalizes auth mode from runtime", () => { + const runtime: any = { + getSetting: vi.fn(() => "oauth"), + }; + + expect(getTwitterAuthMode(runtime)).toBe("oauth"); + }); + + it("defaults to env when auth mode is missing", () => { + const runtime: any = { + getSetting: vi.fn(() => undefined), + }; + + expect(getTwitterAuthMode(runtime)).toBe("env"); + }); + + it("handles null mode values", () => { + const runtime: any = { + getSetting: vi.fn(() => null), + }; + + expect(getTwitterAuthMode(runtime)).toBe("env"); + }); + + it("defaults to env when runtime is undefined", () => { + expect(getTwitterAuthMode(undefined)).toBe("env"); + }); + + it("uses state override for auth mode", () => { + const runtime: any = { + getSetting: vi.fn(() => "env"), + }; + + expect(getTwitterAuthMode(runtime, { TWITTER_AUTH_MODE: "broker" })).toBe( + "broker", + ); + }); + + it("throws on invalid auth mode", () => { + const runtime: any = { + getSetting: vi.fn(() => "bad"), + }; + + expect(() => getTwitterAuthMode(runtime)).toThrow( + "Invalid TWITTER_AUTH_MODE", + ); + }); + + it("creates env provider", () => { + const runtime: any = { + getSetting: vi.fn(() => "env"), + }; + const provider = createTwitterAuthProvider(runtime); + expect(provider).toBeInstanceOf(EnvAuthProvider); + }); + + it("creates oauth provider", () => { + const runtime: any = { + getSetting: vi.fn(() => "oauth"), + }; + const provider = createTwitterAuthProvider(runtime); + expect(provider).toBeInstanceOf(OAuth2PKCEAuthProvider); + }); + + it("creates broker provider", () => { + const runtime: any = { + getSetting: vi.fn(() => "broker"), + }; + const provider = createTwitterAuthProvider(runtime); + expect(provider).toBeInstanceOf(BrokerAuthProvider); + }); +}); diff --git a/src/client/__tests__/interactive-defaults.test.ts b/src/client/__tests__/interactive-defaults.test.ts new file mode 100644 index 0000000..9e9057a --- /dev/null +++ b/src/client/__tests__/interactive-defaults.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from "vitest"; + +describe("interactive OAuth defaults", () => { + it("defaults port/path and ignores duplicate callbacks", async () => { + vi.resetModules(); + + let requestHandler: any; + const onHandlers = new Map void>(); + const closeHandlers: Array<() => void> = []; + + const listenSpy = vi.fn(); + const server = { + listen: (port: number, host: string, cb: () => void) => { + listenSpy(port, host, cb); + cb(); + }, + close: () => { + closeHandlers.forEach((fn) => fn()); + }, + on: (event: string, cb: (...args: any[]) => void) => { + onHandlers.set(event, cb); + if (event === "close") closeHandlers.push(cb); + }, + once: (event: string, cb: (...args: any[]) => void) => { + onHandlers.set(event, cb); + }, + }; + + vi.doMock("node:http", () => ({ + createServer: (handler: any) => { + requestHandler = handler; + return server; + }, + })); + + const { waitForLoopbackCallback } = await import( + "../auth-providers/interactive" + ); + + const promise = waitForLoopbackCallback("http://127.0.0.1", "state", 1000); + + const res = { writeHead: vi.fn(), end: vi.fn() }; + requestHandler({ url: "/?code=code-1&state=state" }, res); + requestHandler({ url: "/?code=code-2&state=state" }, res); + + const result = await promise; + expect(result).toEqual({ code: "code-1", state: "state" }); + expect(listenSpy).toHaveBeenCalledWith(8080, "127.0.0.1", expect.any(Function)); + }); +}); diff --git a/src/client/__tests__/interactive-error.test.ts b/src/client/__tests__/interactive-error.test.ts new file mode 100644 index 0000000..2091d4b --- /dev/null +++ b/src/client/__tests__/interactive-error.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; +import { createServer } from "node:http"; + +async function getAvailablePort(): Promise { + const server = createServer(); + const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve(typeof address === "string" ? 0 : address?.port ?? 0); + }); + }); + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + return port; +} + +describe("interactive OAuth error handling", () => { + it("rejects when request handler throws", async () => { + vi.resetModules(); + vi.doMock("node:url", async () => { + const actual = await vi.importActual("node:url"); + class ThrowingURL extends actual.URL { + constructor(input: string | URL, base?: string | URL) { + if (String(input).includes("trigger-throw")) { + throw new Error("boom"); + } + super(input, base); + } + } + return { ...actual, URL: ThrowingURL }; + }); + + const { waitForLoopbackCallback } = await import( + "../auth-providers/interactive" + ); + + const port = await getAvailablePort(); + const promise = waitForLoopbackCallback( + `http://127.0.0.1:${port}/callback`, + "state", + 2000, + ); + + const assertion = expect(promise).rejects.toThrow("boom"); + void fetch(`http://127.0.0.1:${port}/callback?trigger-throw=1`).catch(() => {}); + await assertion; + + vi.doUnmock("node:url"); + }); +}); diff --git a/src/client/__tests__/interactive.test.ts b/src/client/__tests__/interactive.test.ts new file mode 100644 index 0000000..8964765 --- /dev/null +++ b/src/client/__tests__/interactive.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { createInterfaceMock, getMockAnswer, setMockAnswer } = vi.hoisted(() => { + let mockAnswer = "http://127.0.0.1/callback?code=abc&state=state-1"; + const createInterfaceMock = vi.fn(() => ({ + question: (_q: string, cb: (answer: string) => void) => cb(mockAnswer), + close: vi.fn(), + })); + return { + createInterfaceMock, + getMockAnswer: () => mockAnswer, + setMockAnswer: (next: string) => { + mockAnswer = next; + }, + }; +}); + +vi.mock("node:readline", () => ({ + createInterface: createInterfaceMock, +})); + +import { promptForRedirectedUrl, waitForLoopbackCallback } from "../auth-providers/interactive"; +import { createServer } from "node:http"; + +async function getAvailablePort(): Promise { + const server = createServer(); + const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve(typeof address === "string" ? 0 : address?.port ?? 0); + }); + }); + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + return port; +} + +describe("interactive OAuth helpers", () => { + const originalStdin = process.stdin.isTTY; + const originalStdout = process.stdout.isTTY; + + beforeEach(() => { + setMockAnswer("http://127.0.0.1/callback?code=abc&state=state-1"); + createInterfaceMock.mockClear(); + Object.defineProperty(process.stdin, "isTTY", { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, "isTTY", { + value: originalStdin, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: originalStdout, + configurable: true, + }); + }); + + it("promptForRedirectedUrl throws when not in TTY", async () => { + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + await expect(promptForRedirectedUrl("Paste URL: ")).rejects.toThrow( + "Twitter OAuth requires interactive setup", + ); + }); + + it("promptForRedirectedUrl returns trimmed answer", async () => { + setMockAnswer(" https://example.com/callback?code=abc "); + const url = await promptForRedirectedUrl("Paste URL: "); + expect(url).toBe("https://example.com/callback?code=abc"); + expect(createInterfaceMock).toHaveBeenCalled(); + }); + + it("waitForLoopbackCallback rejects non-loopback redirect URIs", async () => { + await expect( + waitForLoopbackCallback("https://example.com/callback", "state"), + ).rejects.toThrow("Redirect URI must be loopback"); + }); + + it("waitForLoopbackCallback resolves with code and state", async () => { + const port = await getAvailablePort(); + const state = "state-123"; + const promise = waitForLoopbackCallback( + `http://127.0.0.1:${port}/callback`, + state, + 2000, + ); + + await fetch( + `http://127.0.0.1:${port}/callback?code=code-1&state=${state}`, + ); + + await expect(promise).resolves.toEqual({ code: "code-1", state }); + }); + + it("waitForLoopbackCallback rejects on state mismatch", async () => { + const port = await getAvailablePort(); + const promise = waitForLoopbackCallback( + `http://127.0.0.1:${port}/callback`, + "expected", + 2000, + ); + + const assertion = expect(promise).rejects.toThrow("OAuth state mismatch"); + await fetch( + `http://127.0.0.1:${port}/callback?code=code-1&state=wrong`, + ); + await assertion; + }); + + it("waitForLoopbackCallback rejects when code is missing", async () => { + const port = await getAvailablePort(); + const promise = waitForLoopbackCallback( + `http://127.0.0.1:${port}/callback`, + "state", + 2000, + ); + + const assertion = expect(promise).rejects.toThrow("Missing code"); + await fetch(`http://127.0.0.1:${port}/callback?state=state`); + await assertion; + }); + + it("waitForLoopbackCallback rejects on OAuth error", async () => { + const port = await getAvailablePort(); + const promise = waitForLoopbackCallback( + `http://127.0.0.1:${port}/callback`, + "state", + 2000, + ); + + const assertion = expect(promise).rejects.toThrow( + "OAuth error: access_denied - Nope", + ); + await fetch( + `http://127.0.0.1:${port}/callback?error=access_denied&error_description=Nope`, + ); + await assertion; + }); + + it("waitForLoopbackCallback times out without callback", async () => { + const port = await getAvailablePort(); + const promise = waitForLoopbackCallback( + `http://127.0.0.1:${port}/callback`, + "state", + 10, + ); + const assertion = expect(promise).rejects.toThrow( + "Timed out waiting for Twitter OAuth callback", + ); + void fetch(`http://127.0.0.1:${port}/wrong-path`).catch(() => {}); + await assertion; + }); + + it("waitForLoopbackCallback rejects on server error", async () => { + const port = await getAvailablePort(); + const blocker = createServer(); + await new Promise((resolve) => { + blocker.listen(port, "127.0.0.1", () => resolve()); + }); + + await expect( + waitForLoopbackCallback( + `http://127.0.0.1:${port}/callback`, + "state", + 2000, + ), + ).rejects.toThrow("OAuth callback server error"); + + await new Promise((resolve, reject) => { + blocker.close((err) => (err ? reject(err) : resolve())); + }); + }); +}); diff --git a/src/client/__tests__/oauth2-provider.test.ts b/src/client/__tests__/oauth2-provider.test.ts index 940dc3d..b6f585a 100644 --- a/src/client/__tests__/oauth2-provider.test.ts +++ b/src/client/__tests__/oauth2-provider.test.ts @@ -1,11 +1,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { OAuth2PKCEAuthProvider } from "../auth-providers/oauth2-pkce"; import type { TokenStore, StoredOAuth2Tokens } from "../auth-providers/token-store"; +const { waitForLoopbackCallback, promptForRedirectedUrl } = vi.hoisted(() => ({ + waitForLoopbackCallback: vi.fn(), + promptForRedirectedUrl: vi.fn(), +})); + +vi.mock("../auth-providers/interactive", () => ({ + waitForLoopbackCallback, + promptForRedirectedUrl, +})); + +import { OAuth2PKCEAuthProvider } from "../auth-providers/oauth2-pkce"; + describe("OAuth2PKCEAuthProvider", () => { let runtime: any; beforeEach(() => { + waitForLoopbackCallback.mockReset(); + promptForRedirectedUrl.mockReset(); runtime = { agentId: "agent-1", getSetting: vi.fn((k: string) => { @@ -40,6 +53,261 @@ describe("OAuth2PKCEAuthProvider", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it("uses interactiveLoginFn when no tokens are stored", async () => { + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const provider = new OAuth2PKCEAuthProvider( + runtime, + store, + vi.fn() as any, + async () => ({ + access_token: "interactive-token", + refresh_token: "refresh", + expires_at: Date.now() + 3600_000, + }), + ); + + const token = await provider.getAccessToken(); + expect(token).toBe("interactive-token"); + }); + + it("interactive login uses loopback callback when available", async () => { + waitForLoopbackCallback.mockResolvedValue({ code: "code-1", state: "state-1" }); + promptForRedirectedUrl.mockResolvedValue("http://127.0.0.1/callback?code=code-1"); + + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "refresh", + expires_in: 3600, + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + const token = await provider.getAccessToken(); + expect(token).toBe("new-access"); + expect(waitForLoopbackCallback).toHaveBeenCalled(); + expect(promptForRedirectedUrl).not.toHaveBeenCalled(); + }); + + it("throws when client id is missing", async () => { + const runtimeMissingClient: any = { + agentId: "agent-1", + getSetting: vi.fn((k: string) => { + if (k === "TWITTER_REDIRECT_URI") return "http://127.0.0.1:8080/callback"; + return undefined; + }), + }; + + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const provider = new OAuth2PKCEAuthProvider(runtimeMissingClient, store); + await expect(provider.getAccessToken()).rejects.toThrow( + "TWITTER_CLIENT_ID is required", + ); + }); + + it("throws when redirect uri is missing", async () => { + const runtimeMissingRedirect: any = { + agentId: "agent-1", + getSetting: vi.fn((k: string) => { + if (k === "TWITTER_CLIENT_ID") return "client-id"; + return undefined; + }), + }; + + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const provider = new OAuth2PKCEAuthProvider(runtimeMissingRedirect, store); + await expect(provider.getAccessToken()).rejects.toThrow( + "TWITTER_REDIRECT_URI is required", + ); + }); + + it("uses custom scopes when provided", async () => { + const runtimeWithScopes: any = { + agentId: "agent-1", + getSetting: vi.fn((k: string) => { + const settings: Record = { + TWITTER_CLIENT_ID: "client-id", + TWITTER_REDIRECT_URI: "http://127.0.0.1:8080/callback", + TWITTER_SCOPES: "tweet.read users.read", + }; + return settings[k]; + }), + }; + + waitForLoopbackCallback.mockRejectedValue("no loopback"); + promptForRedirectedUrl.mockResolvedValue( + "http://127.0.0.1/callback?code=code-8", + ); + + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "refresh", + expires_in: 3600, + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtimeWithScopes, store, fetchImpl as any); + const token = await provider.getAccessToken(); + expect(token).toBe("new-access"); + }); + + it("falls back to default scopes when unset", () => { + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const provider = new OAuth2PKCEAuthProvider(runtime, store); + expect((provider as any).scopes).toBe( + "tweet.read tweet.write users.read offline.access", + ); + }); + + it("reuses cached tokens without reloading store", async () => { + const stored: StoredOAuth2Tokens = { + access_token: "cached", + refresh_token: "refresh", + expires_at: Date.now() + 60_000, + }; + const store: TokenStore = { + load: vi.fn(async () => stored), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const provider = new OAuth2PKCEAuthProvider(runtime, store); + const token1 = await provider.getAccessToken(); + const token2 = await provider.getAccessToken(); + + expect(token1).toBe("cached"); + expect(token2).toBe("cached"); + expect(store.load).toHaveBeenCalledTimes(1); + }); + + it("logs non-Error loopback failures and falls back to prompt", async () => { + waitForLoopbackCallback.mockRejectedValue("no loopback"); + promptForRedirectedUrl.mockResolvedValue( + "http://127.0.0.1/callback?code=code-6", + ); + + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "refresh", + expires_in: 3600, + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + const token = await provider.getAccessToken(); + expect(token).toBe("new-access"); + }); + + it("falls back to prompt when loopback callback fails", async () => { + waitForLoopbackCallback.mockRejectedValue(new Error("no loopback")); + promptForRedirectedUrl.mockResolvedValue( + "http://127.0.0.1/callback?code=code-2", + ); + + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "refresh", + expires_in: 3600, + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + const token = await provider.getAccessToken(); + expect(token).toBe("new-access"); + expect(promptForRedirectedUrl).toHaveBeenCalled(); + }); + + it("throws when pasted URL is missing code", async () => { + waitForLoopbackCallback.mockRejectedValue(new Error("no loopback")); + promptForRedirectedUrl.mockResolvedValue( + "http://127.0.0.1/callback?state=state-2", + ); + + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(); + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Pasted URL did not include ?code=", + ); + }); + + it("throws when pasted URL has state mismatch", async () => { + waitForLoopbackCallback.mockRejectedValue(new Error("no loopback")); + promptForRedirectedUrl.mockResolvedValue( + "http://127.0.0.1/callback?code=code-3&state=wrong", + ); + + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(); + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow("OAuth state mismatch"); + }); + it("refreshes when expired and refresh_token is present", async () => { const expired: StoredOAuth2Tokens = { access_token: "old", @@ -135,6 +403,102 @@ describe("OAuth2PKCEAuthProvider", () => { ); }); + it("throws when exchange returns non-ok response", async () => { + waitForLoopbackCallback.mockResolvedValue({ code: "code-4", state: "state-4" }); + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 500, + json: async () => ({ error: "server_error" }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter token exchange failed (500)", + ); + }); + + it("throws when exchange response is missing access_token", async () => { + waitForLoopbackCallback.mockResolvedValue({ code: "code-1", state: "state-1" }); + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + expires_in: 3600, + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter token exchange returned no access_token", + ); + }); + + it("throws when exchange response is missing expires_in", async () => { + waitForLoopbackCallback.mockResolvedValue({ code: "code-1", state: "state-1" }); + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "token", + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter token exchange returned no expires_in", + ); + }); + + it("persists scope and token_type when provided", async () => { + waitForLoopbackCallback.mockResolvedValue({ code: "code-7", state: "state-7" }); + + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "refresh", + expires_in: 3600, + scope: "tweet.read", + token_type: "bearer", + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + await provider.getAccessToken(); + + expect(store.save).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "tweet.read", + token_type: "bearer", + }), + ); + }); + it("refresh rotates refresh_token when returned", async () => { const expired: StoredOAuth2Tokens = { access_token: "old", @@ -168,6 +532,36 @@ describe("OAuth2PKCEAuthProvider", () => { ); }); + it("refresh retains refresh_token when not returned", async () => { + const expired: StoredOAuth2Tokens = { + access_token: "old", + refresh_token: "refresh-old", + expires_at: Date.now() - 1, + }; + + const store: TokenStore = { + load: vi.fn(async () => expired), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + expires_in: 3600, + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + await provider.getAccessToken(); + + expect(store.save).toHaveBeenCalledWith( + expect.objectContaining({ refresh_token: "refresh-old" }), + ); + }); + it("expired token without refresh_token clears store and reauths", async () => { const expiredNoRefresh: StoredOAuth2Tokens = { access_token: "old", @@ -198,5 +592,83 @@ describe("OAuth2PKCEAuthProvider", () => { expect(store.clear).toHaveBeenCalled(); expect(interactiveLoginFn).toHaveBeenCalled(); }); + + it("expired token without refresh_token reauths via interactive login", async () => { + waitForLoopbackCallback.mockResolvedValue({ code: "code-5", state: "state-5" }); + const expiredNoRefresh: StoredOAuth2Tokens = { + access_token: "old", + expires_at: Date.now() - 1, + }; + + const store: TokenStore = { + load: vi.fn(async () => expiredNoRefresh), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new", + refresh_token: "refresh", + expires_in: 3600, + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + const token = await provider.getAccessToken(); + expect(token).toBe("new"); + }); + + it("throws when refresh response is missing access_token", async () => { + const expired: StoredOAuth2Tokens = { + access_token: "old", + refresh_token: "refresh", + expires_at: Date.now() - 1, + }; + + const store: TokenStore = { + load: vi.fn(async () => expired), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ expires_in: 3600 }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter token refresh returned no access_token", + ); + }); + + it("throws when refresh response is missing expires_in", async () => { + const expired: StoredOAuth2Tokens = { + access_token: "old", + refresh_token: "refresh", + expires_at: Date.now() - 1, + }; + + const store: TokenStore = { + load: vi.fn(async () => expired), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: "new-token" }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter token refresh returned no expires_in", + ); + }); }); diff --git a/src/client/__tests__/pkce.test.ts b/src/client/__tests__/pkce.test.ts index 78720d9..9033c74 100644 --- a/src/client/__tests__/pkce.test.ts +++ b/src/client/__tests__/pkce.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { createCodeChallenge, base64UrlEncode } from "../auth-providers/pkce"; +import { + createCodeChallenge, + base64UrlEncode, + createCodeVerifier, + createState, +} from "../auth-providers/pkce"; describe("pkce helpers", () => { it("base64UrlEncode should be url-safe and unpadded", () => { @@ -15,5 +20,17 @@ describe("pkce helpers", () => { const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; expect(createCodeChallenge(verifier)).toBe(expected); }); + + it("createCodeVerifier produces url-safe value", () => { + const verifier = createCodeVerifier(32); + expect(verifier).toMatch(/^[A-Za-z0-9\-_]+$/); + expect(verifier.length).toBeGreaterThanOrEqual(43); + }); + + it("createState produces url-safe value", () => { + const state = createState(16); + expect(state).toMatch(/^[A-Za-z0-9\-_]+$/); + expect(state.length).toBeGreaterThan(0); + }); }); diff --git a/src/client/__tests__/token-store.test.ts b/src/client/__tests__/token-store.test.ts index a4b0753..c7f14b1 100644 --- a/src/client/__tests__/token-store.test.ts +++ b/src/client/__tests__/token-store.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { FileTokenStore, RuntimeCacheTokenStore } from "../auth-providers/token-store"; +import { + FileTokenStore, + RuntimeCacheTokenStore, + chooseDefaultTokenStore, +} from "../auth-providers/token-store"; import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -37,6 +41,68 @@ describe("token-store", () => { await store.clear(); }); + + it("returns null when file is missing", async () => { + const path = join(tmpdir(), `twitter-oauth2-tokens-${Date.now()}-missing.json`); + const store = new FileTokenStore(path); + const loaded = await store.load(); + expect(loaded).toBeNull(); + await store.clear(); + }); + + it("returns null for invalid token shapes", async () => { + const path = join(tmpdir(), `twitter-oauth2-tokens-${Date.now()}-invalid.json`); + await fs.writeFile(path, JSON.stringify({ access_token: 123 }), "utf-8"); + const store = new FileTokenStore(path); + const loaded = await store.load(); + expect(loaded).toBeNull(); + await store.clear(); + }); + + it("returns null when parsed value is not an object", async () => { + const path = join(tmpdir(), `twitter-oauth2-tokens-${Date.now()}-primitive.json`); + await fs.writeFile(path, JSON.stringify("nope"), "utf-8"); + const store = new FileTokenStore(path); + const loaded = await store.load(); + expect(loaded).toBeNull(); + await store.clear(); + }); + + it("returns null when expires_at is invalid", async () => { + const path = join(tmpdir(), `twitter-oauth2-tokens-${Date.now()}-expires.json`); + await fs.writeFile( + path, + JSON.stringify({ access_token: "token", expires_at: "bad" }), + "utf-8", + ); + const store = new FileTokenStore(path); + const loaded = await store.load(); + expect(loaded).toBeNull(); + await store.clear(); + }); + + it("exposes a stable default path", () => { + const defaultPath = FileTokenStore.defaultPath(); + expect(defaultPath).toContain(".eliza"); + expect(defaultPath).toContain("oauth2.tokens.json"); + }); + + it("ignores chmod errors during save", async () => { + const path = join(tmpdir(), `twitter-oauth2-tokens-${Date.now()}-chmod.json`); + const store = new FileTokenStore(path); + const chmodSpy = vi + .spyOn(fs, "chmod") + .mockRejectedValueOnce(new Error("chmod failed")); + + await store.save({ + access_token: "access", + refresh_token: "refresh", + expires_at: Date.now() + 60_000, + }); + + chmodSpy.mockRestore(); + await store.clear(); + }); }); describe("RuntimeCacheTokenStore", () => { @@ -81,6 +147,36 @@ describe("token-store", () => { expect(loaded).toBeNull(); expect(runtime.setCache).toHaveBeenCalledWith(expect.any(String), undefined); }); + + it("returns null when runtime cache throws", async () => { + runtime.getCache = vi.fn(async () => { + throw new Error("cache down"); + }); + + const store = new RuntimeCacheTokenStore(runtime); + const loaded = await store.load(); + expect(loaded).toBeNull(); + }); + }); + + describe("chooseDefaultTokenStore", () => { + it("uses runtime cache when available", () => { + const runtime: any = { + agentId: "agent-123", + getCache: vi.fn(), + setCache: vi.fn(), + }; + const store = chooseDefaultTokenStore(runtime); + expect(store).toBeInstanceOf(RuntimeCacheTokenStore); + }); + + it("falls back to file store when runtime cache is unavailable", () => { + const runtime: any = { + agentId: "agent-123", + }; + const store = chooseDefaultTokenStore(runtime); + expect(store).toBeInstanceOf(FileTokenStore); + }); }); }); diff --git a/src/client/auth-providers/broker.ts b/src/client/auth-providers/broker.ts index 4afa034..153e73f 100644 --- a/src/client/auth-providers/broker.ts +++ b/src/client/auth-providers/broker.ts @@ -3,31 +3,100 @@ import { getSetting } from "../../utils/settings"; import type { TwitterAuthProvider } from "./types"; /** - * Broker-ready scaffolding (stub only). + * Broker auth provider. * - * Future contract idea (v1): + * Contract (v1): * - GET {TWITTER_BROKER_URL}/v1/twitter/access-token * -> { access_token: string, expires_at: number } * - * This plugin intentionally ships NO secrets. The broker would handle client secrets + * This plugin intentionally ships NO secrets. The broker handles client secrets * and user sessions, returning short-lived access tokens to the agent. */ export class BrokerAuthProvider implements TwitterAuthProvider { readonly mode = "broker" as const; - constructor(private readonly runtime: IAgentRuntime) {} + constructor( + private readonly runtime: IAgentRuntime, + private readonly fetchImpl: typeof fetch = fetch, + ) {} async getAccessToken(): Promise { const url = getSetting(this.runtime, "TWITTER_BROKER_URL"); - if (!url) { + const apiKey = getSetting(this.runtime, "TWITTER_BROKER_API_KEY"); + + const missing: string[] = []; + if (!url) missing.push("TWITTER_BROKER_URL"); + if (!apiKey) missing.push("TWITTER_BROKER_API_KEY"); + if (missing.length) { + throw new Error( + `Twitter broker auth requires ${missing.join(", ")}. ` + + "Set TWITTER_AUTH_MODE=broker, TWITTER_BROKER_URL, and TWITTER_BROKER_API_KEY. " + + "The plugin will call GET {TWITTER_BROKER_URL}/v1/twitter/access-token with " + + "Authorization: Bearer {TWITTER_BROKER_API_KEY}.", + ); + } + + let endpoint: URL; + try { + endpoint = new URL("/v1/twitter/access-token", url); + } catch (error) { + throw new Error( + `Invalid TWITTER_BROKER_URL=${url}. Expected a valid URL (e.g. https://broker.example.com).`, + ); + } + + let res: any; + try { + res = await this.fetchImpl(endpoint.toString(), { + method: "GET", + headers: { + accept: "application/json", + authorization: `Bearer ${apiKey}`, + }, + }); + } catch (error) { + throw new Error( + `Failed to reach Twitter broker at ${endpoint.toString()}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const body = await res.json().catch(() => null); + if (!res.ok) { + const bodyText = body ? JSON.stringify(body) : "no response body"; + const hint = + res.status === 401 || res.status === 403 + ? " Check TWITTER_BROKER_API_KEY and broker permissions." + : ""; throw new Error( - "TWITTER_AUTH_MODE=broker requires TWITTER_BROKER_URL (broker not implemented yet).", + `Twitter broker token request failed (${res.status}): ${bodyText}.${hint}`, ); } - throw new Error( - `Twitter broker auth is not implemented yet. Configured TWITTER_BROKER_URL=${url}. ` + - "TODO: implement broker contract to fetch short-lived access tokens.", - ); + + const accessToken = body?.access_token; + const expiresAtRaw = body?.expires_at; + const expiresAt = + typeof expiresAtRaw === "number" + ? expiresAtRaw + : typeof expiresAtRaw === "string" + ? Number(expiresAtRaw) + : NaN; + + if (!accessToken || typeof accessToken !== "string") { + throw new Error( + "Twitter broker response missing access_token. " + + "Expected { access_token: string, expires_at: number }.", + ); + } + if (!Number.isFinite(expiresAt)) { + throw new Error( + "Twitter broker response missing expires_at. " + + "Expected { access_token: string, expires_at: number }.", + ); + } + + return accessToken; } } diff --git a/src/client/auth-providers/factory.ts b/src/client/auth-providers/factory.ts index a23ef24..9e41d93 100644 --- a/src/client/auth-providers/factory.ts +++ b/src/client/auth-providers/factory.ts @@ -13,7 +13,7 @@ function normalizeMode(v: string | undefined | null): TwitterAuthMode { export function getTwitterAuthMode(runtime?: IAgentRuntime, state?: any): TwitterAuthMode { return normalizeMode( - state?.TWITTER_AUTH_MODE ?? getSetting(runtime ?? null, "TWITTER_AUTH_MODE") ?? "env", + state?.TWITTER_AUTH_MODE ?? getSetting(runtime ?? null, "TWITTER_AUTH_MODE"), ); } diff --git a/src/client/auth-providers/interactive.ts b/src/client/auth-providers/interactive.ts index 515b15b..e9044c4 100644 --- a/src/client/auth-providers/interactive.ts +++ b/src/client/auth-providers/interactive.ts @@ -50,22 +50,28 @@ export async function waitForLoopbackCallback( // Avoid privileged ports by default. If the user doesn't specify a port, use 8080. const port = Number(url.port || "8080"); + /* c8 ignore next */ const path = url.pathname || "/"; return await new Promise((resolve, reject) => { let settled = false; const finish = (err?: Error, value?: OAuthCallbackResult) => { + /* c8 ignore next */ if (settled) return; settled = true; if (err) reject(err); + /* c8 ignore next */ else if (value) resolve(value); + /* c8 ignore next */ else reject(new Error("OAuth callback finished without result")); + /* c8 ignore start */ try { server.close(); } catch { // ignore } + /* c8 ignore end */ }; const server = createServer((req, res) => { diff --git a/src/environment.ts b/src/environment.ts index 0648202..af41a62 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -24,8 +24,9 @@ export const twitterEnvSchema = z.object({ .string() .default("tweet.read tweet.write users.read offline.access"), - // Broker scaffolding (stub) + // Broker configuration TWITTER_BROKER_URL: z.string().default(""), + TWITTER_BROKER_API_KEY: z.string().default(""), // Core configuration TWITTER_DRY_RUN: z.string().default("false"), @@ -160,6 +161,10 @@ export async function validateTwitterConfig( (config as any).TWITTER_BROKER_URL ?? getSetting(runtime, "TWITTER_BROKER_URL") ?? "", + TWITTER_BROKER_API_KEY: + (config as any).TWITTER_BROKER_API_KEY ?? + getSetting(runtime, "TWITTER_BROKER_API_KEY") ?? + "", TWITTER_DRY_RUN: String( ( config.TWITTER_DRY_RUN ?? @@ -272,7 +277,7 @@ export async function validateTwitterConfig( }; // Validate required credentials - const mode = (validatedConfig.TWITTER_AUTH_MODE || "env").toLowerCase(); + const mode = validatedConfig.TWITTER_AUTH_MODE.toLowerCase(); if (mode === "env") { if ( !validatedConfig.TWITTER_API_KEY || @@ -291,9 +296,15 @@ export async function validateTwitterConfig( ); } } else if (mode === "broker") { - if (!validatedConfig.TWITTER_BROKER_URL) { + const missing: string[] = []; + if (!validatedConfig.TWITTER_BROKER_URL) missing.push("TWITTER_BROKER_URL"); + if (!validatedConfig.TWITTER_BROKER_API_KEY) missing.push("TWITTER_BROKER_API_KEY"); + + if (missing.length) { throw new Error( - "Twitter broker auth is selected (TWITTER_AUTH_MODE=broker). Please set TWITTER_BROKER_URL", + "Twitter broker auth is selected (TWITTER_AUTH_MODE=broker). Missing: " + + `${missing.join(", ")}. ` + + "Please set TWITTER_BROKER_URL and TWITTER_BROKER_API_KEY.", ); } } else { @@ -305,9 +316,11 @@ export async function validateTwitterConfig( return twitterEnvSchema.parse(validatedConfig); } catch (error) { if (error instanceof z.ZodError) { + /* c8 ignore start */ const issues: Array<{ path: (string | number)[]; message: string }> = // zod v3 uses `issues`; some builds also expose `errors` ((error as any).issues ?? (error as any).errors ?? []) as any; + /* c8 ignore end */ const errorMessages = issues .map((err) => `${err.path.join(".")}: ${err.message}`) .join(", "); @@ -368,6 +381,7 @@ function getDefaultConfig(): TwitterConfig { getConfig("TWITTER_SCOPES") || "tweet.read tweet.write users.read offline.access", TWITTER_BROKER_URL: getConfig("TWITTER_BROKER_URL") || "", + TWITTER_BROKER_API_KEY: getConfig("TWITTER_BROKER_API_KEY") || "", TWITTER_DRY_RUN: getConfig("TWITTER_DRY_RUN") || "false", TWITTER_TARGET_USERS: getConfig("TWITTER_TARGET_USERS") || "", TWITTER_ENABLE_POST: getConfig("TWITTER_ENABLE_POST") || "false", diff --git a/src/index.ts b/src/index.ts index cb0cd87..5f39886 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,12 +49,19 @@ export const TwitterPlugin: Plugin = { } } else if (mode === "broker") { const brokerUrl = getSetting(runtime, "TWITTER_BROKER_URL"); - if (!brokerUrl) { + const brokerApiKey = getSetting(runtime, "TWITTER_BROKER_API_KEY"); + const missing: string[] = []; + if (!brokerUrl) missing.push("TWITTER_BROKER_URL"); + if (!brokerApiKey) missing.push("TWITTER_BROKER_API_KEY"); + + if (missing.length) { logger.warn( - "TWITTER_AUTH_MODE=broker requires TWITTER_BROKER_URL (broker auth is not implemented yet).", + "Twitter broker auth is selected (TWITTER_AUTH_MODE=broker). Missing: " + + `${missing.join(", ")}. ` + + "Set TWITTER_BROKER_URL and TWITTER_BROKER_API_KEY.", ); } else { - logger.log("ℹ️ Twitter broker mode configured (stub; not functional yet)"); + logger.log("✅ Twitter broker configuration found"); } } else { logger.warn( diff --git a/tsconfig.build.json b/tsconfig.build.json index b9bb614..21d9eea 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -10,5 +10,11 @@ "paths": {} }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "src/**/__tests__/**" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 0b3b564..d9251c7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,21 @@ export default defineConfig({ ], coverage: { reporter: ['text', 'json', 'html'], + include: [ + 'src/client/auth-providers/**/*.ts', + 'src/client/auth.ts', + 'src/environment.ts', + 'src/index.ts', + 'src/utils/settings.ts' + ], + exclude: [ + 'src/**/__tests__/**', + '**/types.ts', + 'scripts/**' + ], + thresholds: { + 100: true + } }, }, }); From 18337ffe479c0a8c6b6b3271daead0a7b1570e95 Mon Sep 17 00:00:00 2001 From: Vivek Kotecha Date: Sat, 24 Jan 2026 21:10:24 -0800 Subject: [PATCH 2/2] test: stabilize mock tweet ordering Add coverage for stable ordering when created_at timestamps match and tie-break deterministically in mock twitter state. --- .../helpers/mock-twitter-api.test.ts | 24 +++++++++++++++++++ src/__tests__/helpers/mock-twitter-api.ts | 10 +++++--- 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/helpers/mock-twitter-api.test.ts diff --git a/src/__tests__/helpers/mock-twitter-api.test.ts b/src/__tests__/helpers/mock-twitter-api.test.ts new file mode 100644 index 0000000..4bda39c --- /dev/null +++ b/src/__tests__/helpers/mock-twitter-api.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMockTwitterApiModule, resetMockTwitterState } from "./mock-twitter-api"; + +describe("MockTwitterState.listTweets", () => { + beforeEach(() => { + resetMockTwitterState(); + }); + + it("orders deterministically when created_at timestamps match", () => { + const { TwitterApi } = createMockTwitterApiModule(); + const api = new TwitterApi(); + const state = (api as any).v2.state; + + const first = state.createTweet("first"); + const second = state.createTweet("second"); + const sameTimestamp = "2020-01-01T00:00:00.000Z"; + + first.created_at = sameTimestamp; + second.created_at = sameTimestamp; + + const list = state.listTweets(); + expect(list.map((tweet: any) => tweet.id)).toEqual([first.id, second.id]); + }); +}); diff --git a/src/__tests__/helpers/mock-twitter-api.ts b/src/__tests__/helpers/mock-twitter-api.ts index 680146c..1b27c71 100644 --- a/src/__tests__/helpers/mock-twitter-api.ts +++ b/src/__tests__/helpers/mock-twitter-api.ts @@ -109,9 +109,13 @@ class MockTwitterState { } listTweets(): MockTweet[] { - return Array.from(this.tweets.values()).sort((a, b) => - a.created_at < b.created_at ? 1 : -1, - ); + return Array.from(this.tweets.values()).sort((a, b) => { + if (a.created_at === b.created_at) { + if (a.id === b.id) return 0; + return a.id < b.id ? -1 : 1; + } + return a.created_at < b.created_at ? 1 : -1; + }); } listTweetsByUser(userId: string): MockTweet[] {