/** * OAuth 2.1 integration tests. * Covers oauthPublicRouter (/.well-known, /oauth/token, /oauth/revoke) * and oauthApiRouter (/api/oauth/*). */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; import crypto from 'crypto'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA busy_timeout = 5000'); const mock = { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: (placeId: number) => { const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId); if (!place) return null; const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId); return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags }; }, canAccessTrip: (tripId: any, userId: number) => db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), isOwner: (tripId: any, userId: number) => !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; return { testDb: db, dbMock: mock }; }); vi.mock('../../src/db/database', () => dbMock); vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); const { isAddonEnabledMock } = vi.hoisted(() => { const isAddonEnabledMock = vi.fn().mockReturnValue(true); return { isAddonEnabledMock }; }); vi.mock('../../src/services/adminService', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, isAddonEnabled: isAddonEnabledMock }; }); vi.mock('../../src/services/oidcService', () => ({ getAppUrl: () => 'https://trek.example.com' })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { createApp } from '../../src/app'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { createOAuthClient, createAuthCode } from '../../src/services/oauthService'; const app: Application = createApp(); // PKCE helpers function makePkce() { const verifier = crypto.randomBytes(32).toString('base64url'); const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); return { verifier, challenge }; } beforeAll(() => { createTables(testDb); runMigrations(testDb); }); beforeEach(() => { resetTestDb(testDb); loginAttempts.clear(); mfaAttempts.clear(); isAddonEnabledMock.mockReturnValue(true); }); afterAll(() => { testDb.close(); }); // ───────────────────────────────────────────────────────────────────────────── // Discovery document // ───────────────────────────────────────────────────────────────────────────── describe('GET /.well-known/oauth-authorization-server', () => { it('OAUTH-001 — returns RFC 8414 discovery document', async () => { const res = await request(app).get('/.well-known/oauth-authorization-server'); expect(res.status).toBe(200); expect(res.body.issuer).toBe('https://trek.example.com'); expect(res.body.authorization_endpoint).toContain('/oauth/authorize'); expect(res.body.token_endpoint).toContain('/oauth/token'); expect(Array.isArray(res.body.scopes_supported)).toBe(true); expect(res.body.scopes_supported.length).toBeGreaterThan(0); }); }); // ───────────────────────────────────────────────────────────────────────────── // POST /oauth/token — authorization_code grant // ───────────────────────────────────────────────────────────────────────────── describe('POST /oauth/token — authorization_code grant', () => { it('OAUTH-002 — missing client_id/client_secret returns 401 invalid_client', async () => { const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', code: 'x', redirect_uri: 'https://example.com/cb', code_verifier: 'y' }); expect(res.status).toBe(401); expect(res.body.error).toBe('invalid_client'); }); it('OAUTH-003 — MCP addon disabled returns 403 mcp_disabled', async () => { isAddonEnabledMock.mockReturnValue(false); const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' }); expect(res.status).toBe(403); expect(res.body.error).toBe('mcp_disabled'); }); it('OAUTH-004 — missing code/redirect_uri/code_verifier returns 400 invalid_request', async () => { const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y' }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); }); it('OAUTH-005 — invalid auth code returns 400 invalid_grant', async () => { const { user } = createUser(testDb); const clientResult = createOAuthClient(user.id, 'TestApp', ['https://app.example.com/cb'], ['trips:read']); const client = clientResult.client!; const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: client.client_id, client_secret: clientResult.client!.client_secret, code: 'invalid-code-xyz', redirect_uri: 'https://app.example.com/cb', code_verifier: 'verifier', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_grant'); }); it('OAUTH-006 — client_id mismatch returns 400 invalid_grant', async () => { const { user } = createUser(testDb); const r1 = createOAuthClient(user.id, 'App1', ['https://app1.example.com/cb'], ['trips:read']); const r2 = createOAuthClient(user.id, 'App2', ['https://app2.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); // Create code for client1 const code = createAuthCode({ clientId: r1.client!.client_id as string, userId: user.id, redirectUri: 'https://app1.example.com/cb', scopes: ['trips:read'], codeChallenge: challenge, codeChallengeMethod: 'S256', }); // Try to use it with client2 const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: r2.client!.client_id, client_secret: r2.client!.client_secret, code, redirect_uri: 'https://app1.example.com/cb', code_verifier: verifier, }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_grant'); }); it('OAUTH-007 — redirect_uri mismatch returns 400 invalid_grant', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ clientId: r.client!.client_id as string, userId: user.id, redirectUri: 'https://app.example.com/cb', scopes: ['trips:read'], codeChallenge: challenge, codeChallengeMethod: 'S256', }); const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: r.client!.client_id, client_secret: r.client!.client_secret, code, redirect_uri: 'https://wrong.example.com/cb', code_verifier: verifier, }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_grant'); }); it('OAUTH-008 — wrong client_secret returns 401 invalid_client', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ clientId: r.client!.client_id as string, userId: user.id, redirectUri: 'https://app.example.com/cb', scopes: ['trips:read'], codeChallenge: challenge, codeChallengeMethod: 'S256', }); const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: r.client!.client_id, client_secret: 'wrong-secret', code, redirect_uri: 'https://app.example.com/cb', code_verifier: verifier, }); expect(res.status).toBe(401); expect(res.body.error).toBe('invalid_client'); }); it('OAUTH-009 — PKCE failure returns 400 invalid_grant', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const code = createAuthCode({ clientId: r.client!.client_id as string, userId: user.id, redirectUri: 'https://app.example.com/cb', scopes: ['trips:read'], codeChallenge: challenge, codeChallengeMethod: 'S256', }); const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: r.client!.client_id, client_secret: r.client!.client_secret, code, redirect_uri: 'https://app.example.com/cb', code_verifier: 'this-is-a-wrong-verifier', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_grant'); }); it('OAUTH-010 — happy path: exchange auth code for tokens', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ clientId: r.client!.client_id as string, userId: user.id, redirectUri: 'https://app.example.com/cb', scopes: ['trips:read'], codeChallenge: challenge, codeChallengeMethod: 'S256', }); const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: r.client!.client_id, client_secret: r.client!.client_secret, code, redirect_uri: 'https://app.example.com/cb', code_verifier: verifier, }); expect(res.status).toBe(200); expect(res.body.access_token).toBeDefined(); expect(res.body.refresh_token).toBeDefined(); expect(res.body.token_type).toBe('Bearer'); expect(typeof res.body.expires_in).toBe('number'); expect(res.body.scope).toBe('trips:read'); }); }); // ───────────────────────────────────────────────────────────────────────────── // POST /oauth/token — refresh_token grant // ───────────────────────────────────────────────────────────────────────────── describe('POST /oauth/token — refresh_token grant', () => { it('OAUTH-011 — missing refresh_token returns 400 invalid_request', async () => { const res = await request(app) .post('/oauth/token') .send({ grant_type: 'refresh_token', client_id: 'x', client_secret: 'y' }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); }); it('OAUTH-012 — invalid refresh token returns 400 invalid_grant', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .post('/oauth/token') .send({ grant_type: 'refresh_token', client_id: r.client!.client_id, client_secret: r.client!.client_secret, refresh_token: 'invalid-refresh-token', }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_grant'); }); it('OAUTH-013 — happy path: issue then refresh tokens', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ clientId: r.client!.client_id as string, userId: user.id, redirectUri: 'https://app.example.com/cb', scopes: ['trips:read'], codeChallenge: challenge, codeChallengeMethod: 'S256', }); // Exchange code for tokens const tokenRes = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: r.client!.client_id, client_secret: r.client!.client_secret, code, redirect_uri: 'https://app.example.com/cb', code_verifier: verifier, }); expect(tokenRes.status).toBe(200); const { refresh_token } = tokenRes.body; // Use refresh token to get new tokens const refreshRes = await request(app) .post('/oauth/token') .send({ grant_type: 'refresh_token', client_id: r.client!.client_id, client_secret: r.client!.client_secret, refresh_token, }); expect(refreshRes.status).toBe(200); expect(refreshRes.body.access_token).toBeDefined(); expect(refreshRes.body.refresh_token).toBeDefined(); }); }); // ───────────────────────────────────────────────────────────────────────────── // POST /oauth/token — unsupported grant_type // ───────────────────────────────────────────────────────────────────────────── describe('POST /oauth/token — unsupported grant_type', () => { it('OAUTH-014 — returns 400 unsupported_grant_type', async () => { const res = await request(app) .post('/oauth/token') .send({ grant_type: 'password', client_id: 'x', client_secret: 'y' }); expect(res.status).toBe(400); expect(res.body.error).toBe('unsupported_grant_type'); }); }); // ───────────────────────────────────────────────────────────────────────────── // POST /oauth/revoke // ───────────────────────────────────────────────────────────────────────────── describe('POST /oauth/revoke', () => { it('OAUTH-015 — missing params returns 400 invalid_request', async () => { const res = await request(app) .post('/oauth/revoke') .send({ token: 'x' }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid_request'); }); it('OAUTH-016 — wrong client_secret returns 401 invalid_client', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .post('/oauth/revoke') .send({ token: 'sometoken', client_id: r.client!.client_id, client_secret: 'wrong' }); expect(res.status).toBe(401); expect(res.body.error).toBe('invalid_client'); }); it('OAUTH-017 — valid revoke returns 200 even for unknown token (RFC 7009)', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .post('/oauth/revoke') .send({ token: 'nonexistent-token', client_id: r.client!.client_id, client_secret: r.client!.client_secret }); expect(res.status).toBe(200); }); it('OAUTH-018 — happy path: issue token, revoke it, verify refresh no longer works', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ clientId: r.client!.client_id as string, userId: user.id, redirectUri: 'https://app.example.com/cb', scopes: ['trips:read'], codeChallenge: challenge, codeChallengeMethod: 'S256', }); const tokenRes = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: r.client!.client_id, client_secret: r.client!.client_secret, code, redirect_uri: 'https://app.example.com/cb', code_verifier: verifier, }); expect(tokenRes.status).toBe(200); const { refresh_token } = tokenRes.body; // Revoke the refresh token const revokeRes = await request(app) .post('/oauth/revoke') .send({ token: refresh_token, client_id: r.client!.client_id, client_secret: r.client!.client_secret }); expect(revokeRes.status).toBe(200); // Try to use the revoked token — should fail const retryRes = await request(app) .post('/oauth/token') .send({ grant_type: 'refresh_token', client_id: r.client!.client_id, client_secret: r.client!.client_secret, refresh_token, }); expect(retryRes.status).toBe(400); expect(retryRes.body.error).toBe('invalid_grant'); }); }); // ───────────────────────────────────────────────────────────────────────────── // GET /api/oauth/authorize/validate // ───────────────────────────────────────────────────────────────────────────── describe('GET /api/oauth/authorize/validate', () => { it('OAUTH-019 — returns 200 with valid:false when MCP addon disabled', async () => { isAddonEnabledMock.mockReturnValue(false); const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'code', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' }); expect(res.status).toBe(200); expect(res.body.valid).toBe(false); expect(res.body.error).toBe('mcp_disabled'); }); it('OAUTH-020 — returns 200 with valid:false for wrong response_type', async () => { const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'token', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' }); expect(res.status).toBe(200); expect(res.body.valid).toBe(false); expect(res.body.error).toBe('unsupported_response_type'); }); it('OAUTH-021 — returns 200 with valid:false for missing PKCE', async () => { const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'code', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read' }); expect(res.status).toBe(200); expect(res.body.valid).toBe(false); expect(res.body.error).toBe('invalid_request'); }); it('OAUTH-022 — returns 200 with valid:false for unknown client_id', async () => { const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'code', client_id: 'unknown-client', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'abc', code_challenge_method: 'S256' }); expect(res.status).toBe(200); expect(res.body.valid).toBe(false); expect(res.body.error).toBe('invalid_client'); }); it('OAUTH-023 — returns 200 with valid:false for mismatched redirect_uri', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'code', client_id: r.client!.client_id, redirect_uri: 'https://evil.example.com/cb', scope: 'trips:read', code_challenge: 'abc', code_challenge_method: 'S256', }); expect(res.status).toBe(200); expect(res.body.valid).toBe(false); expect(res.body.error).toBe('invalid_redirect_uri'); }); it('OAUTH-024 — returns 200 with valid:false for empty scope', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'code', client_id: r.client!.client_id, redirect_uri: 'https://app.example.com/cb', scope: '', code_challenge: 'abc', code_challenge_method: 'S256', }); expect(res.status).toBe(200); expect(res.body.valid).toBe(false); expect(res.body.error).toBe('invalid_scope'); }); it('OAUTH-025a — narrows scope to allowed intersection when client lacks some requested scopes', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'code', client_id: r.client!.client_id, redirect_uri: 'https://app.example.com/cb', scope: 'trips:read trips:delete', code_challenge: 'abc', code_challenge_method: 'S256', }); expect(res.status).toBe(200); expect(res.body.valid).toBe(true); // trips:delete was dropped — only trips:read granted expect(res.body.scopes).toEqual(['trips:read']); }); it('OAUTH-025b — returns 200 with valid:false when no requested scope is allowed', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'code', client_id: r.client!.client_id, redirect_uri: 'https://app.example.com/cb', scope: 'budget:write', code_challenge: 'abc', code_challenge_method: 'S256', }); expect(res.status).toBe(200); expect(res.body.valid).toBe(false); expect(res.body.error).toBe('invalid_scope'); }); it('OAUTH-026 — returns 200 with loginRequired=true when no cookie session', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'code', client_id: r.client!.client_id, redirect_uri: 'https://app.example.com/cb', scope: 'trips:read', code_challenge: 'abc', code_challenge_method: 'S256', }); expect(res.status).toBe(200); expect(res.body.valid).toBe(true); expect(res.body.loginRequired).toBe(true); }); it('OAUTH-027 — returns 200 with loginRequired or consentRequired when session present but no prior consent', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .get('/api/oauth/authorize/validate') .set('Cookie', authCookie(user.id)) .query({ response_type: 'code', client_id: r.client!.client_id, redirect_uri: 'https://app.example.com/cb', scope: 'trips:read', code_challenge: 'abc', code_challenge_method: 'S256', }); expect(res.status).toBe(200); expect(res.body.valid).toBe(true); // Either loginRequired=true (cookie not decoded in test env) or consentRequired=true (full decode working) expect(res.body.loginRequired === true || res.body.consentRequired === true).toBe(true); }); }); // ───────────────────────────────────────────────────────────────────────────── // POST /api/oauth/authorize // ───────────────────────────────────────────────────────────────────────────── describe('POST /api/oauth/authorize', () => { it('OAUTH-028 — unauthenticated returns 401', async () => { const res = await request(app) .post('/api/oauth/authorize') .send({ approved: true, client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' }); expect(res.status).toBe(401); }); it('OAUTH-029 — 403 when MCP disabled', async () => { isAddonEnabledMock.mockReturnValue(false); const { user } = createUser(testDb); const res = await request(app) .post('/api/oauth/authorize') .set('Cookie', authCookie(user.id)) .send({ approved: true, client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' }); expect(res.status).toBe(403); }); it('OAUTH-030 — user denied returns redirect with error=access_denied', async () => { const { user } = createUser(testDb); const res = await request(app) .post('/api/oauth/authorize') .set('Cookie', authCookie(user.id)) .send({ approved: false, client_id: 'any', redirect_uri: 'https://app.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256', }); expect(res.status).toBe(200); expect(res.body.redirect).toContain('error=access_denied'); }); it('OAUTH-031 — invalid params returns 400', async () => { const { user } = createUser(testDb); const res = await request(app) .post('/api/oauth/authorize') .set('Cookie', authCookie(user.id)) .send({ approved: true, client_id: 'unknown-client', redirect_uri: 'https://app.example.com/cb', scope: 'trips:read', code_challenge: 'abc', code_challenge_method: 'S256', }); expect(res.status).toBe(400); }); it('OAUTH-032 — happy path: approve returns redirect with code', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .post('/api/oauth/authorize') .set('Cookie', authCookie(user.id)) .send({ approved: true, client_id: r.client!.client_id, redirect_uri: 'https://app.example.com/cb', scope: 'trips:read', code_challenge: 'abc', code_challenge_method: 'S256', }); expect(res.status).toBe(200); expect(res.body.redirect).toBeDefined(); expect(res.body.redirect).toContain('code='); expect(res.body.redirect).not.toContain('error='); }); }); // ───────────────────────────────────────────────────────────────────────────── // Client CRUD // ───────────────────────────────────────────────────────────────────────────── describe('Client CRUD — /api/oauth/clients', () => { it('OAUTH-033 — GET returns 403 when addon disabled', async () => { isAddonEnabledMock.mockReturnValue(false); const { user } = createUser(testDb); const res = await request(app) .get('/api/oauth/clients') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(403); }); it('OAUTH-034 — GET returns 200 with clients list', async () => { const { user } = createUser(testDb); createOAuthClient(user.id, 'MyApp', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .get('/api/oauth/clients') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(Array.isArray(res.body.clients)).toBe(true); expect(res.body.clients).toHaveLength(1); expect(res.body.clients[0].name).toBe('MyApp'); }); it('OAUTH-035 — POST creates client and returns 201 with client_secret', async () => { const { user } = createUser(testDb); const res = await request(app) .post('/api/oauth/clients') .set('Cookie', authCookie(user.id)) .send({ name: 'NewApp', redirect_uris: ['https://newapp.example.com/cb'], allowed_scopes: ['trips:read'] }); expect(res.status).toBe(201); expect(res.body.client).toBeDefined(); expect(res.body.client.client_id).toBeDefined(); expect(res.body.client.client_secret).toBeDefined(); expect(res.body.client.name).toBe('NewApp'); }); it('OAUTH-036 — POST returns 403 when addon disabled', async () => { isAddonEnabledMock.mockReturnValue(false); const { user } = createUser(testDb); const res = await request(app) .post('/api/oauth/clients') .set('Cookie', authCookie(user.id)) .send({ name: 'App', redirect_uris: ['https://app.example.com/cb'], allowed_scopes: ['trips:read'] }); expect(res.status).toBe(403); }); it('OAUTH-037 — POST /clients/:id/rotate rotates secret', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .post(`/api/oauth/clients/${r.client!.id}/rotate`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.client_secret).toBeDefined(); expect(res.body.client_secret).not.toBe(r.client!.client_secret); }); it('OAUTH-038 — DELETE /clients/:id deletes client', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .delete(`/api/oauth/clients/${r.client!.id}`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); it('OAUTH-039 — DELETE /clients/:id returns 404 for non-existent', async () => { const { user } = createUser(testDb); const res = await request(app) .delete('/api/oauth/clients/nonexistent-id') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(404); }); }); // ───────────────────────────────────────────────────────────────────────────── // Sessions // ───────────────────────────────────────────────────────────────────────────── describe('Sessions — /api/oauth/sessions', () => { it('OAUTH-040 — GET returns 403 when addon disabled', async () => { isAddonEnabledMock.mockReturnValue(false); const { user } = createUser(testDb); const res = await request(app) .get('/api/oauth/sessions') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(403); }); it('OAUTH-041 — GET returns 200 with sessions list', async () => { const { user } = createUser(testDb); const res = await request(app) .get('/api/oauth/sessions') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(Array.isArray(res.body.sessions)).toBe(true); }); it('OAUTH-042 — DELETE /sessions/:id revokes session', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ clientId: r.client!.client_id as string, userId: user.id, redirectUri: 'https://app.example.com/cb', scopes: ['trips:read'], codeChallenge: challenge, codeChallengeMethod: 'S256', }); // Get a token so there's a session to revoke await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: r.client!.client_id, client_secret: r.client!.client_secret, code, redirect_uri: 'https://app.example.com/cb', code_verifier: verifier, }); const sessionsRes = await request(app) .get('/api/oauth/sessions') .set('Cookie', authCookie(user.id)); expect(sessionsRes.body.sessions).toHaveLength(1); const sessionId = sessionsRes.body.sessions[0].id; const deleteRes = await request(app) .delete(`/api/oauth/sessions/${sessionId}`) .set('Cookie', authCookie(user.id)); expect(deleteRes.status).toBe(200); expect(deleteRes.body.success).toBe(true); }); it('OAUTH-043 — DELETE /sessions/:id returns 404 for non-existent', async () => { const { user } = createUser(testDb); const res = await request(app) .delete('/api/oauth/sessions/99999') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(404); }); it('OAUTH-044 — DELETE /sessions/:id returns 403 when addon disabled', async () => { isAddonEnabledMock.mockReturnValue(false); const { user } = createUser(testDb); const res = await request(app) .delete('/api/oauth/sessions/1') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(403); }); });