import "reflect-metadata"; import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"; import { createHash, randomUUID } from "node:crypto"; import { Module, UnauthorizedException, type ExecutionContext, type INestApplication } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ConnectorConnection, NormalizedIdentity } from "@tactical-media-broadcast-hub/core"; import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk"; import { createMastodonConnector } from "../../../../packages/connector-mastodon/src/index.js"; import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram"; import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js"; import { Vault, type SealedCredential } from "../vault/vault.js"; import type { SessionPayload } from "../auth/session.js"; import { RbacGuard } from "../rbac/rbac.guard.js"; import { logger } from "../telemetry/logger.js"; import { ConnectionsController } from "./connections.controller.js"; import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js"; import type { PublishGovernance } from "../publishing/publish-governance.js"; import type { ConnectionStore, StoredConnection } from "./connection-store.js"; import { RedisPendingStore } from "./pending.js"; import { MemoryPendingRedis } from "./testing.js"; import { injectHttp } from "./http-test-transport.js"; class MemoryConnections implements ConnectionStore { readonly rows = new Map(); readonly audit: Array> = []; authorized = true; async assertAuthorized(session: SessionPayload): Promise { if (!this.authorized || session.workspaceId !== sessionFixture.workspaceId) throw new Error("membership revoked"); } async list(workspaceId: string): Promise { return [...this.rows.values()].filter(({ connection }) => connection.workspaceId === workspaceId).map(({ connection, credential }) => ({ id: connection.id, provider: connection.provider, channelId: connection.channelId, fields: { ...connection.fields }, health: credential ? "unavailable" : "needs_credentials", credentialRotatedAt: null, recentPublishes: [], createdAt: connection.createdAt.toISOString(), })); } async get(workspaceId: string, id: string): Promise { const row = this.rows.get(id); if (!row || row.connection.workspaceId !== workspaceId) throw new Error("connection not found"); return row; } async save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise { await this.assertAuthorized(session); if (reconnect && !this.rows.has(connection.id)) throw new Error("connection removed"); this.rows.set(connection.id, { connection: { ...connection, fields: { ...connection.fields, remoteId: identity.remoteId, handle: identity.handle } }, credential }); this.audit.push({ action: reconnect ? "connection.reconnect" : "connection.connect", provider: connection.provider }); } async remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise): Promise { await this.assertAuthorized(session); await revoke(await this.get(session.workspaceId, id)); this.rows.delete(id); this.audit.push({ action: "connection.remove" }); } async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise { await this.assertAuthorized(session); const row = await this.get(session.workspaceId, id); const fields = { ...row.connection.fields }; delete fields.publishGovernance; row.connection.fields = mode === null ? fields : { ...fields, publishGovernance: mode }; this.audit.push({ action: "connection.publish_governance" }); } async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise { const row = await this.get(workspaceId, id); if (row.credential?.ciphertext.equals(previous.ciphertext)) { row.credential = credential; this.audit.push({ action: "credential.refresh" }); } } } const sessionFixture: SessionPayload = { userId: randomUUID(), workspaceId: randomUUID(), role: "Owner", authzEpoch: 1, issuedAt: Math.floor(Date.now() / 1000), expiresAt: Math.floor(Date.now() / 1000) + 3600, }; @Module({}) class TestModule {} async function form(request: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(Buffer.from(chunk as Uint8Array)); return new URLSearchParams(Buffer.concat(chunks).toString()); } function json(response: ServerResponse, value: unknown, status = 200): void { response.writeHead(status, { "content-type": "application/json" }); response.end(JSON.stringify(value)); } describe("HTTP OAuth connect with real connectors and mock authorization server", () => { let mock: Server; let origin: string; let apiOrigin: string; let app: INestApplication; const store = new MemoryConnections(); const redis = new MemoryPendingRedis(); const encryption = new Vault(); const activeTokens = new Set(); const grants = new Map(); const revokedTokens: string[] = []; const guard = vi.spyOn(RbacGuard.prototype, "canActivate").mockImplementation(async (context: ExecutionContext) => { const request = context.switchToHttp().getRequest<{ headers: Record; session?: SessionPayload }>(); if (request.headers.authorization !== "Bearer test-session") throw new UnauthorizedException(); request.session = sessionFixture; return true; }); const warn = vi.spyOn(logger, "warn").mockImplementation(() => undefined); const info = vi.spyOn(logger, "info").mockImplementation(() => undefined); let nextAccount = "account-1"; let issued = 0; let failExchange = false; let bridgeConfigured = false; const botToken = "12345:bot_token_secret"; beforeEach(() => { store.rows.clear(); store.audit.length = 0; store.authorized = true; redis.entries.clear(); activeTokens.clear(); grants.clear(); revokedTokens.length = 0; nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false; warn.mockClear(); info.mockClear(); }); beforeAll(async () => { mock = createServer((request, response) => { void (async () => { const url = new URL(request.url ?? "/", origin); if (url.pathname === "/api/v1/apps") { const body = await form(request); expect(body.get("redirect_uris")).toBe(`${apiOrigin}/connections/callback`); json(response, { client_id: "client-id", client_secret: "registered-client-secret" }); } else if (url.pathname === "/oauth/authorize") { const code = randomUUID(); grants.set(code, { challenge: url.searchParams.get("code_challenge") ?? "", redirectUri: url.searchParams.get("redirect_uri") ?? "" }); const redirect = new URL(url.searchParams.get("redirect_uri") ?? ""); redirect.searchParams.set("state", url.searchParams.get("state") ?? ""); redirect.searchParams.set("code", code); response.writeHead(302, { location: redirect.toString() }); response.end(); } else if (url.pathname === "/oauth/token") { const body = await form(request); const code = body.get("code") ?? ""; const grant = grants.get(code); grants.delete(code); if (failExchange || !grant || grant.redirectUri !== body.get("redirect_uri") || grant.challenge !== createHash("sha256").update(body.get("code_verifier") ?? "").digest("base64url") || body.get("client_secret") !== "registered-client-secret") { json(response, { error: "secret echoed: registered-client-secret token-secret-leak" }, 400); return; } const token = `access-token-secret-${++issued}`; activeTokens.add(token); json(response, { access_token: token, refresh_token: `refresh-token-secret-${issued}`, token_type: "Bearer", scope: "read write:statuses write:media", created_at: Date.now() / 1000 }); } else if (url.pathname === "/api/v1/accounts/verify_credentials") { const token = (request.headers.authorization ?? "").slice(7); if (!activeTokens.has(token)) { json(response, { error: `revoked ${token}` }, 401); return; } json(response, { id: nextAccount, username: "alice", acct: "alice", display_name: "Alice", avatar: "", url: `${origin}/@alice` }); } else if (url.pathname === "/api/v1/oauth-clients/local") { json(response, { client_id: "client-id", client_secret: "registered-client-secret" }); } else if (url.pathname === "/api/v1/users/me") { const token = (request.headers.authorization ?? "").slice(7); if (!activeTokens.has(token)) { json(response, { error: "invalid token" }, 401); return; } json(response, { id: 1, username: "alice", account: { id: 1, name: "alice", host: "127.0.0.1", displayName: "Alice" } }); } else if (url.pathname === "/api/v1/users/token") { const body = await form(request); if (body.get("grant_type") !== "refresh_token" || body.get("client_secret") !== "registered-client-secret") { json(response, { error: "invalid refresh" }, 401); return; } const token = `access-token-secret-${++issued}`; activeTokens.add(token); json(response, { access_token: token, refresh_token: `refresh-token-secret-${issued}` }); } else if (url.pathname === "/api/v1/users/revoke-token") { const token = (request.headers.authorization ?? "").slice(7); revokedTokens.push(token); activeTokens.delete(token); json(response, {}); } else if (url.pathname === "/oauth/revoke") { const body = await form(request); const token = body.get("token") ?? ""; revokedTokens.push(token); activeTokens.delete(token); json(response, {}); } else if (url.pathname.startsWith("/bot")) { if (!url.pathname.startsWith(`/bot${botToken}/`)) { json(response, { ok: false, error_code: 401, description: `unauthorized ${url.pathname}` }); return; } json(response, { ok: true, result: { id: 12345, is_bot: true, username: "test_bot", first_name: "Test bot" } }); } else { json(response, { error: "not found" }, 404); } })().catch(() => json(response, { error: "mock failure" }, 500)); }); origin = "http://127.0.0.1:4500"; const deps: ConnectionDependencies = { renewSession: async () => "replacement-session", store, pending: new RedisPendingStore(redis, encryption), encryption, appUrl: "http://localhost:3001", callbackUrl: "", registry: (credentials) => { const registry = new ConnectorRegistry(); registry.register(createMastodonConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000 })); registry.register(createPeerTubeConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000, env: bridgeConfigured ? { PEERTUBE_AUTHORIZE_PATH: "/oauth/authorize", PEERTUBE_TOKEN_PATH: "/oauth/token" } : {}, })); const botFetch: typeof fetch = (input, init) => fetch(String(input).replace("https://api.telegram.org", origin), init); registry.register(createTelegramConnector({ credentials, fetchImpl: botFetch, requestsPerSecond: 100_000 })); return registry; }, }; app = await NestFactory.create({ module: TestModule, controllers: [ConnectionsController], providers: [ ConnectionsService, { provide: CONNECTION_DEPENDENCIES, useValue: deps }, ] }, { logger: false }); await app.init(); apiOrigin = "http://api.local"; deps.callbackUrl = `${apiOrigin}/connections/callback`; }); afterAll(async () => { await app?.close(); warn.mockRestore(); info.mockRestore(); guard.mockRestore(); }); const mockFetch: typeof globalThis.fetch = (input, init) => injectHttp((request, response) => { mock.emit("request", request, response); }, input, init); const fetch: typeof globalThis.fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.origin === origin) return mockFetch(input, init); if (url.origin !== apiOrigin) throw new Error("Unexpected test destination"); const adapter = app.getHttpAdapter(); return injectHttp((request, response) => { adapter.getInstance().handle(request, response); }, input, init); }; async function api(path: string, method = "GET", body?: unknown): Promise { return fetch(`${apiOrigin}/connections${path}`, { method, headers: { authorization: "Bearer test-session", "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) }); } async function begin(id?: string, provider = "mastodon"): Promise<{ redirectUrl: string; cookie: string }> { const response = id ? await api(`/${id}/reconnect`, "POST", {}) : await api("/authorize", "POST", { provider, instanceUrl: origin }); expect(response.status).toBe(201); const { redirectUrl } = await response.json() as { redirectUrl: string }; expect(new URL(redirectUrl).searchParams.get("code_challenge_method")).toBe("S256"); return { redirectUrl, cookie: response.headers.get("set-cookie")?.split(";")[0] ?? "" }; } async function finish(authorization: { redirectUrl: string; cookie: string }): Promise { const consent = await fetch(authorization.redirectUrl, { redirect: "manual" }); expect(consent.status).toBe(302); return fetch(consent.headers.get("location") ?? "", { redirect: "manual", headers: { cookie: authorization.cookie } }); } async function listed(): Promise { return (await (await api("")).json()) as ConnectionSummary[]; } it("authorize → provider → unauthenticated callback → healthy list; reconnect rotates; remove revokes and clears row + vault envelope", async () => { const auth = await begin(); expect(auth.cookie).toMatch(/^ap_connect_/); expect(JSON.stringify([...redis.entries.values()])).not.toContain("registered-client-secret"); const callback = await finish(auth); expect(callback.status).toBe(303); expect(callback.headers.get("location")).toBe("http://localhost:3001/connections?connect=success"); expect(callback.headers.get("set-cookie")).toContain("Expires="); const rows = await listed(); expect(rows).toHaveLength(1); expect(rows[0]?.health).toBe("healthy"); expect(rows[0]?.capabilities).toContain("publishText"); const id = rows[0]?.id ?? ""; const before = store.rows.get(id)?.credential?.ciphertext; expect(before).toBeDefined(); const stored = store.rows.get(id)?.credential; if (!stored) throw new Error("credential missing"); expect((await new Vault().open(sessionFixture.workspaceId, id, stored)).toString()).toContain("access-token-secret-1"); expect(JSON.stringify(rows)).not.toContain("token-secret"); const second = await finish(await begin(id)); expect(second.headers.get("location")).toContain("connect=success"); expect((await listed())[0]?.id).toBe(id); expect(store.rows.get(id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(false); expect(store.audit.at(-1)?.action).toBe("connection.reconnect"); const replay = await finish(auth); expect(replay.headers.get("location")).toContain("connect=failure"); expect((await api(`/${id}`, "DELETE")).status).toBe(200); expect(revokedTokens).toContain("access-token-secret-2"); expect(await listed()).toHaveLength(0); expect(store.rows.size).toBe(0); expect(redis.entries.size).toBe(0); const observable = JSON.stringify({ audit: store.audit, warn: warn.mock.calls, info: info.mock.calls }); for (const secret of ["access-token-secret", "refresh-token-secret", "registered-client-secret"]) expect(observable).not.toContain(secret); }); it("rejects missing auth on authorize, missing/wrong browser cookie and preserves a valid pending state", async () => { expect((await fetch(`${apiOrigin}/connections/authorize`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: "mastodon", instanceUrl: origin }) })).status).toBe(401); const auth = await begin(); const consent = await fetch(auth.redirectUrl, { redirect: "manual" }); const callbackUrl = consent.headers.get("location") ?? ""; const missing = await fetch(callbackUrl, { redirect: "manual" }); expect(missing.headers.get("location")).toContain("connect=failure"); const wrong = await fetch(callbackUrl, { redirect: "manual", headers: { cookie: auth.cookie.replace(sessionFixture.workspaceId, randomUUID()) } }); expect(wrong.headers.get("location")).toContain("connect=failure"); const good = await fetch(callbackUrl, { redirect: "manual", headers: { cookie: auth.cookie } }); expect(good.headers.get("location")).toContain("connect=success"); }); it("failed exchange and different-account reconnect retain the previous encrypted credential; health detects revoked tokens", async () => { await finish(await begin()); const row = (await listed())[0]; if (!row) throw new Error("connection missing"); const before = store.rows.get(row.id)?.credential?.ciphertext; failExchange = true; const failure = await finish(await begin(row.id)); failExchange = false; expect(failure.headers.get("location")).toContain("connect=failure"); expect(store.rows.get(row.id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(true); nextAccount = "other-account"; const wrongAccount = await finish(await begin(row.id)); nextAccount = "account-1"; expect(wrongAccount.headers.get("location")).toContain("connect=failure"); expect(store.rows.get(row.id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(true); activeTokens.clear(); expect((await listed())[0]?.health).toBe("needs_credentials"); expect((await finish(await begin(row.id))).headers.get("location")).toContain("connect=success"); expect((await listed())[0]?.health).toBe("healthy"); expect(JSON.stringify(warn.mock.calls)).not.toContain("token-secret-leak"); expect(JSON.stringify(warn.mock.calls)).not.toContain("registered-client-secret"); }); it("rechecks workspace membership during callback, handles denial, and does not expose provider errors", async () => { const revoked = await begin(); store.authorized = false; expect((await finish(revoked)).headers.get("location")).toContain("connect=failure"); store.authorized = true; const denied = await begin(); const url = new URL(`${apiOrigin}/connections/callback`); url.searchParams.set("state", new URL(denied.redirectUrl).searchParams.get("state") ?? ""); url.searchParams.set("error", "access_denied token-secret-leak"); const response = await fetch(url, { redirect: "manual", headers: { cookie: denied.cookie } }); expect(response.headers.get("location")).toContain("connect=failure"); expect(JSON.stringify(warn.mock.calls)).not.toContain("token-secret-leak"); }); it("validates Telegram getMe, encrypts tokens, rotates in place and removes locally; PeerTube is capability-honest", async () => { const bad = await api("/telegram/bot-token", "POST", { token: "12345:bad_secret" }); expect(bad.status).toBe(400); expect(await bad.text()).not.toContain("bad_secret"); const response = await api("/telegram/bot-token", "POST", { token: botToken, channelId: "@target" }); expect(response.status).toBe(201); const bot = (await listed()).find((row) => row.provider === "telegram"); expect(bot?.health).toBe("healthy"); expect(bot?.channelId).toBe("@target"); const id = bot?.id ?? ""; const before = store.rows.get(id)?.credential?.ciphertext; expect((await api("/telegram/bot-token", "POST", { token: botToken, connectionId: id })).status).toBe(201); expect(store.rows.get(id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(false); expect((await listed()).filter((row) => row.provider === "telegram")).toHaveLength(1); expect((await api(`/${id}`, "DELETE")).status).toBe(200); expect(store.rows.has(id)).toBe(false); const providers = await (await api("/providers")).json() as Array<{ id: string; enabled: boolean; note?: string }>; expect(providers.find((provider) => provider.id === "peertube")?.enabled).toBe(false); expect((await api("/authorize", "POST", { provider: "peertube", instanceUrl: origin })).status).toBe(400); expect(JSON.stringify({ audit: store.audit, warn: warn.mock.calls, info: info.mock.calls })).not.toContain(botToken); }); it("runs PeerTube code flow only with an explicitly configured instance bridge", async () => { bridgeConfigured = true; expect((await finish(await begin(undefined, "peertube"))).headers.get("location")).toContain("connect=success"); const row = (await listed()).find((connection) => connection.provider === "peertube"); expect(row?.health).toBe("healthy"); const id = row?.id ?? ""; expect((await finish(await begin(id))).headers.get("location")).toContain("connect=success"); expect((await listed()).filter((connection) => connection.id === id)).toHaveLength(1); const stored = store.rows.get(id)?.credential; if (!stored) throw new Error("PeerTube credential missing"); const secrets = JSON.parse((await encryption.open(sessionFixture.workspaceId, id, stored)).toString()) as Record; activeTokens.delete(secrets.access_token ?? ""); expect((await listed()).find((connection) => connection.id === id)?.health).toBe("healthy"); expect(store.audit.at(-1)?.action).toBe("credential.refresh"); expect(store.rows.get(id)?.credential?.ciphertext.equals(stored.ciphertext)).toBe(false); expect((await api(`/${id}`, "DELETE")).status).toBe(200); bridgeConfigured = false; }); it("sets, validates and clears per-connection publish governance", async () => { await finish(await begin()); const id = (await listed())[0]?.id ?? ""; const set = await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "admin_majority" }); expect(set.status).toBe(200); expect(await set.json()).toEqual({ connectionId: id, publishGovernance: "admin_majority" }); expect((await listed())[0]?.fields.publishGovernance).toBe("admin_majority"); expect(store.audit.at(-1)?.action).toBe("connection.publish_governance"); expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "everyone" })).status).toBe(400); expect((await api(`/${id}/publish-governance`, "PATCH", {})).status).toBe(400); expect((await api("/not-a-uuid/publish-governance", "PATCH", { publishGovernance: "auto" })).status).toBe(400); expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: null })).status).toBe(200); expect((await listed())[0]?.fields).not.toHaveProperty("publishGovernance"); }); it("does not resurrect a connection removed while reconnect consent is pending", async () => { await finish(await begin()); const row = (await listed())[0]; if (!row) throw new Error("connection missing"); const pending = await begin(row.id); expect((await api(`/${row.id}`, "DELETE")).status).toBe(200); expect((await finish(pending)).headers.get("location")).toContain("connect=failure"); expect(store.rows.has(row.id)).toBe(false); }); });