From 8bdc730b20da3b45bf807cce1cf32a73f68349d1 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 21 Sep 2026 21:54:19 +0200 Subject: [PATCH] test(server): suites await the methods Task 1's forced edits made async --- server/tests/integration/oauth.test.ts | 84 ++-- .../tests/unit/nest/admin.controller.test.ts | 28 +- server/tests/unit/nest/admin.service.test.ts | 42 +- server/tests/unit/nest/auth-guard.test.ts | 2 +- .../tests/unit/nest/auth.controller.test.ts | 30 +- .../nest/dawarich-suggestions.service.test.ts | 48 +- .../unit/nest/discovery.controller.test.ts | 16 +- server/tests/unit/nest/mcp-addon-gate.test.ts | 16 +- server/tests/unit/nest/oauth.service.test.ts | 430 +++++++++--------- .../tests/unit/nest/places.controller.test.ts | 22 +- .../tests/unit/nest/public-api-scopes.test.ts | 27 +- .../tests/unit/nest/share.controller.test.ts | 20 +- .../tests/unit/nest/trips.controller.test.ts | 34 +- .../nest/unified-memories.service.test.ts | 28 +- 14 files changed, 426 insertions(+), 401 deletions(-) diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index 42b96ccdf..922fdc068 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -290,7 +290,7 @@ describe('POST /oauth/token — authorization_code grant', () => { 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 clientResult = await createOAuthClient(user.id, 'TestApp', ['https://app.example.com/cb'], ['trips:read']); const client = clientResult.client!; const res = await request(app) @@ -309,8 +309,8 @@ describe('POST /oauth/token — authorization_code 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 r1 = await createOAuthClient(user.id, 'App1', ['https://app1.example.com/cb'], ['trips:read']); + const r2 = await createOAuthClient(user.id, 'App2', ['https://app2.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); // Create code for client1 @@ -341,7 +341,7 @@ describe('POST /oauth/token — authorization_code 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ @@ -370,7 +370,7 @@ describe('POST /oauth/token — authorization_code grant', () => { it('OAUTH-008 — wrong client_secret returns 401 invalid_client (timing-safe check)', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ @@ -399,7 +399,7 @@ describe('POST /oauth/token — authorization_code grant', () => { 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const code = createAuthCode({ @@ -428,7 +428,7 @@ describe('POST /oauth/token — authorization_code 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ @@ -475,7 +475,7 @@ describe('POST /oauth/token — refresh_token grant', () => { 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .post('/oauth/token') @@ -491,7 +491,7 @@ describe('POST /oauth/token — refresh_token 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ @@ -562,7 +562,7 @@ describe('POST /oauth/revoke', () => { 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .post('/oauth/revoke') @@ -573,7 +573,7 @@ describe('POST /oauth/revoke', () => { 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .post('/oauth/revoke') @@ -583,7 +583,7 @@ describe('POST /oauth/revoke', () => { 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ @@ -731,7 +731,7 @@ describe('GET /api/oauth/authorize/validate', () => { it('OAUTH-023 — returns 200 with valid:false for mismatched redirect_uri (authenticated)', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const res = await request(app) @@ -752,7 +752,7 @@ describe('GET /api/oauth/authorize/validate', () => { it('OAUTH-024 — returns 200 with valid:false for empty scope (authenticated)', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const res = await request(app) @@ -773,7 +773,7 @@ describe('GET /api/oauth/authorize/validate', () => { it('OAUTH-025a — narrows scope to allowed intersection when client lacks some requested scopes (authenticated)', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const res = await request(app) @@ -795,7 +795,7 @@ describe('GET /api/oauth/authorize/validate', () => { it('OAUTH-025b — returns 200 with valid:false when no requested scope is allowed (authenticated)', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const res = await request(app) @@ -816,7 +816,7 @@ describe('GET /api/oauth/authorize/validate', () => { it('OAUTH-026 — unauthenticated valid request returns loginRequired=true (H3: minimal response, no client info)', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const res = await request(app) @@ -839,7 +839,7 @@ describe('GET /api/oauth/authorize/validate', () => { it('OAUTH-027 — authenticated with no prior consent returns consentRequired=true with client details', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const res = await request(app) @@ -887,7 +887,7 @@ describe('POST /api/oauth/authorize', () => { it('OAUTH-030 — user denied returns redirect with error=access_denied', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const res = await request(app) @@ -907,7 +907,7 @@ describe('POST /api/oauth/authorize', () => { it('OAUTH-030b — a denial for an unregistered redirect_uri is refused, not redirected', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const res = await request(app) @@ -945,7 +945,7 @@ describe('POST /api/oauth/authorize', () => { 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const res = await request(app) @@ -983,7 +983,7 @@ describe('Client CRUD — /api/oauth/clients', () => { 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']); + await createOAuthClient(user.id, 'MyApp', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .get('/api/oauth/clients') @@ -1021,7 +1021,7 @@ describe('Client CRUD — /api/oauth/clients', () => { 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .post(`/api/oauth/clients/${r.client!.id}/rotate`) @@ -1033,7 +1033,7 @@ describe('Client CRUD — /api/oauth/clients', () => { 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .delete(`/api/oauth/clients/${r.client!.id}`) @@ -1080,7 +1080,7 @@ describe('Sessions — /api/oauth/sessions', () => { 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 r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ @@ -1170,7 +1170,7 @@ describe('M2 — 404 when MCP disabled on discovery + revoke endpoints', () => { describe('H1 — PKCE format validation', () => { it('OAUTH-SEC-004 — short code_challenge (<43 chars) rejected on /authorize/validate', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await 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)) @@ -1189,7 +1189,7 @@ describe('H1 — PKCE format validation', () => { it('OAUTH-SEC-005 — wrong code_verifier format rejected on /oauth/token (invalid_grant)', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); const code = createAuthCode({ @@ -1221,7 +1221,7 @@ describe('H1 — PKCE format validation', () => { describe('H3 — Unauthenticated /authorize/validate returns minimal response', () => { it('OAUTH-SEC-006 — invalid request by unauthenticated caller returns generic error (no oracle)', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { challenge } = makePkce(); // Deliberately wrong redirect_uri — should get generic error, not invalid_redirect_uri @@ -1247,7 +1247,7 @@ describe('H3 — Unauthenticated /authorize/validate returns minimal response', describe('H5 — All invalid_grant cases return identical response body', () => { it('OAUTH-SEC-007 — expired/bad code, client_id mismatch, redirect_uri mismatch all return same body', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ @@ -1301,7 +1301,7 @@ describe('H5 — All invalid_grant cases return identical response body', () => describe('M5 — Consent scope union (re-authorize adds to existing consent)', () => { it('OAUTH-SEC-008 — second consent adds new scope without losing old scope', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read', 'places:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read', 'places:read']); const { challenge: ch1 } = makePkce(); const { challenge: ch2 } = makePkce(); @@ -1405,7 +1405,7 @@ describe('C3 — Refresh token replay detection', () => { it('OAUTH-SEC-012 — replaying a rotated (old) refresh token returns invalid_grant', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ @@ -1453,7 +1453,7 @@ describe('C3 — Refresh token replay detection', () => { it('OAUTH-SEC-012b — two clients refreshing the same token at once both keep working (#1007)', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ @@ -1493,7 +1493,7 @@ describe('C3 — Refresh token replay detection', () => { it('OAUTH-SEC-013 — replaying old token also invalidates the new chain', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); const code = createAuthCode({ @@ -1553,7 +1553,7 @@ describe('C3 — Refresh token replay detection', () => { describe('POST /oauth/token — client_credentials grant', () => { it('OAUTH-CC-001 — happy path: issues access token with no refresh_token', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); + const r = await createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); const res = await request(app) .post('/oauth/token') @@ -1573,7 +1573,7 @@ describe('POST /oauth/token — client_credentials grant', () => { it('OAUTH-CC-002 — issued token resolves to the client owner user', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); + const r = await createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); const res = await request(app) .post('/oauth/token') @@ -1592,7 +1592,7 @@ describe('POST /oauth/token — client_credentials grant', () => { it('OAUTH-CC-003 — wrong client_secret returns 401 invalid_client', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); + const r = await createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); const res = await request(app) .post('/oauth/token') @@ -1608,7 +1608,7 @@ describe('POST /oauth/token — client_credentials grant', () => { it('OAUTH-CC-004 — missing client_secret returns 401 invalid_client', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); + const r = await createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); const res = await request(app) .post('/oauth/token') @@ -1623,7 +1623,7 @@ describe('POST /oauth/token — client_credentials grant', () => { it('OAUTH-CC-005 — non-machine client returns 400 unauthorized_client', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'BrowserApp', ['https://app.example.com/cb'], ['trips:read']); + const r = await createOAuthClient(user.id, 'BrowserApp', ['https://app.example.com/cb'], ['trips:read']); const res = await request(app) .post('/oauth/token') @@ -1639,7 +1639,7 @@ describe('POST /oauth/token — client_credentials grant', () => { it('OAUTH-CC-006 — scope narrowing: requested subset is honoured', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'Machine', [], ['trips:read', 'places:read'], null, { allowsClientCredentials: true }); + const r = await createOAuthClient(user.id, 'Machine', [], ['trips:read', 'places:read'], null, { allowsClientCredentials: true }); const res = await request(app) .post('/oauth/token') @@ -1656,7 +1656,7 @@ describe('POST /oauth/token — client_credentials grant', () => { it('OAUTH-CC-007 — scope outside allowed_scopes returns 400 invalid_scope', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); + const r = await createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); const res = await request(app) .post('/oauth/token') @@ -1671,9 +1671,9 @@ describe('POST /oauth/token — client_credentials grant', () => { expect(res.body.error).toBe('invalid_scope'); }); - it('OAUTH-CC-008 — createOAuthClient with allowsClientCredentials succeeds without redirect URIs', () => { + it('OAUTH-CC-008 — createOAuthClient with allowsClientCredentials succeeds without redirect URIs', async () => { const { user } = createUser(testDb); - const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); + const r = await createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true }); expect(r.error).toBeUndefined(); expect(r.client).toBeDefined(); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index edcef987a..7a103daf6 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -69,8 +69,8 @@ describe('AdminController system-info', () => { }); }); -function thrown(fn: () => unknown): { status: number; body: unknown } { - try { fn(); } catch (err) { +async function thrown(fn: () => Promise): Promise<{ status: number; body: unknown }> { + try { await fn(); } catch (err) { if (err instanceof NotFoundException) return { status: 404, body: err.getResponse() }; expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; @@ -85,7 +85,7 @@ afterEach(() => { delete process.env.NODE_ENV; }); describe('AdminController users', () => { it('lists, creates (201 + audit), maps an error', async () => { expect(adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listUsers()).toEqual({ users: [{ id: 1 }] }); - expect(thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } }); + expect(await thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } }); const c = adminCtl(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial)); expect(await c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.user_create' })); @@ -93,7 +93,7 @@ describe('AdminController users', () => { it('update + delete audit and map errors', async () => { expect(await adminCtl(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } }); - expect(thrown(() => adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } }); + expect(await thrown(() => adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } }); expect(await adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial)).deleteUser(user, '2', req)).toEqual({ success: true }); }); }); @@ -111,7 +111,7 @@ describe('AdminController permissions + oidc + misc', () => { it('save-demo-baseline maps error, else returns message', async () => { - expect(thrown(() => adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } }); + expect(await thrown(() => adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } }); expect(await adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' }); }); }); @@ -124,7 +124,7 @@ describe('AdminController invites', () => { const c = adminCtl(svc(), undefined, undefined, {}, { createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) }); expect(await c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' })); - expect(thrown(() => adminCtl(svc(), undefined, undefined, {}, { deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) }).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } }); + expect(await thrown(() => adminCtl(svc(), undefined, undefined, {}, { deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) }).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } }); }); @@ -167,15 +167,15 @@ describe('AdminController addons + sessions + jwt + defaults', () => { it('oauth-sessions revoke audits; rotate-jwt maps error', async () => { expect(await adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({}) }).revokeOAuthSession(user, '3', req)).toEqual({ success: true }); - expect(thrown(() => adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } }); + expect(await thrown(() => adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } }); expect(await adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial)).rotateJwtSecret(user, req)).toEqual({ success: true }); }); }); describe('AdminController error envelope fallbacks', () => { - it('ok() defaults to 400 when the error envelope omits a status', () => { - expect(thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } }); + it('ok() defaults to 400 when the error envelope omits a status', async () => { + expect(await thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } }); }); @@ -210,8 +210,8 @@ describe('AdminController read-only getters', () => { describe('AdminController tokens + sessions', () => { it('mcp token + oauth session deletes return success and map errors', async () => { expect(await adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, '2', req)).toEqual({ success: true }); - expect(thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken(user, '9', req))).toEqual({ status: 404, body: { error: 'no token' } }); - expect(thrown(() => adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) }).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } }); + expect(await thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken(user, '9', req))).toEqual({ status: 404, body: { error: 'no token' } }); + expect(await thrown(() => adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) }).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } }); }); }); @@ -251,10 +251,10 @@ describe('AdminController dev test-notification', () => { // The feature toggles now go straight to AddonsService — these cases used to assert // the same thing through AdminService pass-throughs that no longer exist. describe('AdminController feature toggles', () => { - it('ADMIN-TOGGLE-001 each toggle forwards to AddonsService and writes its own audit action', () => { + it('ADMIN-TOGGLE-001 each toggle forwards to AddonsService and writes its own audit action', async () => { const addons = addonsStub(); const c = adminCtl(svc(), undefined, addons); - const cases: Array<[() => unknown, string, keyof AddonsService]> = [ + const cases: Array<[() => Promise, string, keyof AddonsService]> = [ [() => c.updateBagTracking(user, { enabled: true }, req), 'admin.bag_tracking', 'updateBagTracking'], [() => c.updatePlacesPhotos(user, { enabled: true }, req), 'admin.places_photos', 'updatePlacesPhotos'], [() => c.updatePlacesAutocomplete(user, { enabled: true }, req), 'admin.places_autocomplete', 'updatePlacesAutocomplete'], @@ -263,7 +263,7 @@ describe('AdminController feature toggles', () => { ]; for (const [run, action, method] of cases) { writeAudit.mockClear(); - expect(run()).toEqual({ enabled: true }); + expect(await run()).toEqual({ enabled: true }); expect(addons[method]).toHaveBeenCalledWith(true); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action, details: { enabled: true } })); } diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 9cf8ea4f2..5db9e7d75 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -282,15 +282,15 @@ describe('getStats', () => { // ── getPermissions / savePermissions ───────────────────────────────────────── describe('Permissions', () => { - it('ADMIN-SVC-019 — getPermissions returns an array of actions', () => { - const result = getPermissions() as any; + it('ADMIN-SVC-019 — getPermissions returns an array of actions', async () => { + const result = await getPermissions() as any; expect(Array.isArray(result.permissions)).toBe(true); expect(result.permissions.length).toBeGreaterThan(0); }); it('ADMIN-SVC-020 — savePermissions persists a permission change', async () => { await savePermissions({ trip_create: 'admin' }); - const result = getPermissions() as any; + const result = await getPermissions() as any; const perm = result.permissions.find((p: any) => p.key === 'trip_create'); expect(perm.level).toBe('admin'); }); @@ -490,17 +490,17 @@ describe('listAddons', () => { // ── updateAddon ─────────────────────────────────────────────────────────────── describe('updateAddon', () => { - it('ADMIN-SVC-066 — updateAddon enables and disables a seeded addon', () => { - const disabled = updateAddon('mcp', { enabled: false }) as any; + it('ADMIN-SVC-066 — updateAddon enables and disables a seeded addon', async () => { + const disabled = await updateAddon('mcp', { enabled: false }) as any; expect(disabled.addon).toBeDefined(); expect(disabled.addon.enabled).toBe(false); - const enabled = updateAddon('mcp', { enabled: true }) as any; + const enabled = await updateAddon('mcp', { enabled: true }) as any; expect(enabled.addon.enabled).toBe(true); }); - it('ADMIN-SVC-067 — updateAddon returns 404 for unknown addon id', () => { - const result = updateAddon('nonexistent-addon-xyz', { enabled: true }) as any; + it('ADMIN-SVC-067 — updateAddon returns 404 for unknown addon id', async () => { + const result = await updateAddon('nonexistent-addon-xyz', { enabled: true }) as any; expect(result.status).toBe(404); expect(result.error).toBeDefined(); }); @@ -508,52 +508,52 @@ describe('updateAddon', () => { it('ADMIN-SVC-069 — mcpAffected only fires on a real enabled-flip of an MCP-relevant addon (#1414)', async () => { await updateAddon('packing', { enabled: true }); // no-op save (enabled already true) → sessions survive - expect((updateAddon('packing', { enabled: true }) as any).mcpAffected).toBe(false); + expect((await updateAddon('packing', { enabled: true }) as any).mcpAffected).toBe(false); // config-only save → sessions survive - expect((updateAddon('packing', { config: { foo: 'bar' } }) as any).mcpAffected).toBe(false); + expect((await updateAddon('packing', { config: { foo: 'bar' } }) as any).mcpAffected).toBe(false); // real flip of an MCP-relevant addon → invalidate - expect((updateAddon('packing', { enabled: false }) as any).mcpAffected).toBe(true); - expect((updateAddon('packing', { enabled: true }) as any).mcpAffected).toBe(true); + expect((await updateAddon('packing', { enabled: false }) as any).mcpAffected).toBe(true); + expect((await updateAddon('packing', { enabled: true }) as any).mcpAffected).toBe(true); // real flip of an addon with no MCP surface → sessions survive. Taken from // the list rather than named, because an addon that grows MCP tools joins it // and would otherwise turn this assertion false without changing anything // it is actually about (documents did, when document sync landed). const noMcp = Object.values(ADDON_IDS).find(id => !MCP_GATED_ADDON_IDS.includes(id)); if (noMcp) { - const flip = updateAddon(noMcp, { enabled: false }) as any; + const flip = await updateAddon(noMcp, { enabled: false }) as any; if (!flip.error) expect(flip.mcpAffected).toBe(false); } // and the one this change put on the list carries the opposite verdict - const docsFlip = updateAddon('documents', { enabled: false }) as any; + const docsFlip = await updateAddon('documents', { enabled: false }) as any; if (!docsFlip.error) expect(docsFlip.mcpAffected).toBe(true); }); - it('ADMIN-SVC-087 — refuses to enable a photo provider while journey is off', () => { + it('ADMIN-SVC-087 — refuses to enable a photo provider while journey is off', async () => { testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run(); testDb.prepare("UPDATE photo_providers SET enabled = 0 WHERE id = 'immich'").run(); - const result = updateAddon('immich', { enabled: true }) as any; + const result = await updateAddon('immich', { enabled: true }) as any; expect(result).toEqual({ error: 'Enable the Journey addon first', status: 409 }); expect(testDb.prepare("SELECT enabled FROM photo_providers WHERE id = 'immich'").get()).toEqual({ enabled: 0 }); }); - it('ADMIN-SVC-088 — enables a provider under an enabled journey; disabling never needs journey', () => { + it('ADMIN-SVC-088 — enables a provider under an enabled journey; disabling never needs journey', async () => { testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run(); - const enabled = updateAddon('immich', { enabled: true }) as any; + const enabled = await updateAddon('immich', { enabled: true }) as any; expect(enabled.addon).toMatchObject({ id: 'immich', type: 'photo_provider', enabled: true }); // Switching a provider OFF stays possible with journey off — cleanup must not dead-end. testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run(); - const disabled = updateAddon('immich', { enabled: false }) as any; + const disabled = await updateAddon('immich', { enabled: false }) as any; expect(disabled.addon.enabled).toBe(false); }); - it('ADMIN-SVC-089 — disabling journey cascades every photo provider off', () => { + it('ADMIN-SVC-089 — disabling journey cascades every photo provider off', async () => { testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run(); testDb.prepare('UPDATE photo_providers SET enabled = 1').run(); - const result = updateAddon('journey', { enabled: false }) as any; + const result = await updateAddon('journey', { enabled: false }) as any; expect(result.addon.enabled).toBe(false); const rows = testDb.prepare('SELECT enabled FROM photo_providers').all() as Array<{ enabled: number }>; expect(rows.length).toBeGreaterThan(0); diff --git a/server/tests/unit/nest/auth-guard.test.ts b/server/tests/unit/nest/auth-guard.test.ts index 3ee47a92d..3d2ad8905 100644 --- a/server/tests/unit/nest/auth-guard.test.ts +++ b/server/tests/unit/nest/auth-guard.test.ts @@ -250,7 +250,7 @@ describe('PasskeyController', () => { expect(pc(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true }); passkey.deletePasskey.mockReturnValue({ error: 'Incorrect password', status: 401 }); - expect(thrown(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); + expect(await thrownAsync(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); passkey.deletePasskey.mockReturnValue({ success: true }); expect(await pc(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' })); diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index 4852805b6..f60c11872 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -92,7 +92,7 @@ describe('AuthPublicController', () => { }); it('register audits + sets cookie; maps error', async () => { - expect(thrown(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial), rl()).register(anyBody(), req, res))).toEqual({ status: 409, body: { error: 'Email taken' } }); + expect(await thrownAsync(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial), rl()).register(anyBody(), req, res))).toEqual({ status: 409, body: { error: 'Email taken' } }); const setAuthCookie = vi.fn(); const c = apc(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial), rl()); expect(await c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user }); @@ -127,7 +127,7 @@ describe('AuthPublicController', () => { }, 10000); it('reset-password: error audits a fail, mfa branch, success', async () => { - expect(thrown(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial), rl()).resetPassword(anyBody(), req))).toEqual({ status: 400, body: { error: 'Invalid token' } }); + expect(await thrownAsync(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial), rl()).resetPassword(anyBody(), req))).toEqual({ status: 400, body: { error: 'Invalid token' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' })); expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial), rl()).resetPassword(anyBody(), req)).toEqual({ mfa_required: true }); expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial), rl()).resetPassword(anyBody(), req)).toEqual({ success: true }); @@ -176,25 +176,25 @@ describe('AuthPublicController', () => { expect(requestPasswordReset).toHaveBeenCalledWith('', expect.any(String)); }, 10000); - it('reset-password 429 once the dedicated reset bucket is exhausted', () => { + it('reset-password 429 once the dedicated reset bucket is exhausted', async () => { const s = rl(); const now = Date.now(); for (let i = 0; i < 5; i++) s.check('reset', '9.9.9.9', 5, 15 * 60 * 1000, now); const c = apc(asvc({ resetPassword: vi.fn() } as Partial), s); - expect(thrown(() => c.resetPassword(anyBody(), req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(await thrownAsync(() => c.resetPassword(anyBody(), req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); - it('mfa/verify-login maps a service error', () => { + it('mfa/verify-login maps a service error', async () => { const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial), rl()); - expect(thrown(() => c.verifyMfaLogin(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad code' } }); + expect(await thrownAsync(() => c.verifyMfaLogin(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad code' } }); }); - it('demo-login + register + invite throw 429 when the login bucket is exhausted', () => { + it('demo-login + register + invite throw 429 when the login bucket is exhausted', async () => { const s = rl(); const now = Date.now(); for (let i = 0; i < 10; i++) s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now); const c = apc(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial), s); - expect(thrown(() => c.register(anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(await thrownAsync(() => c.register(anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); @@ -216,7 +216,7 @@ describe('AuthController (authenticated)', () => { }); it('change-password maps error, else audits', async () => { - expect(thrown(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial), rl()).changePassword(user, anyBody(), req, res))).toEqual({ status: 400, body: { error: 'Wrong' } }); + expect(await thrownAsync(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial), rl()).changePassword(user, anyBody(), req, res))).toEqual({ status: 400, body: { error: 'Wrong' } }); expect(await ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial), rl()).changePassword(user, anyBody(), req, res)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' })); }); @@ -284,13 +284,13 @@ describe('AuthController (authenticated)', () => { expect(ac(asvc({}), s, { createResourceToken: vi.fn().mockReturnValue({ token: 'rt2' }) }).resourceToken(other, {})).toEqual({ token: 'rt2' }); }); - it('rate-limited account ops throw 429 once the bucket is exhausted', () => { + it('rate-limited account ops throw 429 once the bucket is exhausted', async () => { const s = rl(); const now = Date.now(); // exhaust the shared 'login' bucket for this ip (max 5) for (let i = 0; i < 5; i++) s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now); const c = ac(asvc({ changePassword: vi.fn() } as Partial), s); - expect(thrown(() => c.changePassword(user, anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(await thrownAsync(() => c.changePassword(user, anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('change-password refreshes this device cookie when the service returns a token', async () => { @@ -314,7 +314,7 @@ describe('AuthController (authenticated)', () => { }); it('delete-account maps error, else audits and succeeds', async () => { - expect(thrown(() => ac(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } }); + expect(await thrownAsync(() => ac(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } }); expect(await ac(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial), rl()).deleteAccount(user, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' })); }); @@ -390,7 +390,7 @@ describe('AuthController (authenticated)', () => { it('app-settings get maps error, else returns data; put maps error, else audits', async () => { expect(thrown(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } }); expect(ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial), rl()).getAppSettings(user)).toEqual({ x: 1 }); - expect(thrown(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } }); + expect(await thrownAsync(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } }); expect(await ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' })); }); @@ -401,8 +401,8 @@ describe('AuthController (authenticated)', () => { }); it('mfa/enable + mfa/disable map errors', async () => { - expect(thrown(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } }); - expect(thrown(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } }); + expect(await thrownAsync(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } }); + expect(await thrownAsync(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } }); const ok = ac(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial), rl()); expect(await ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' })); diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts index 9ff707f3d..a24f65915 100644 --- a/server/tests/unit/nest/dawarich-suggestions.service.test.ts +++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts @@ -286,16 +286,6 @@ function bucketOf(id: number): BucketRow { * the status the controller maps and the code the MCP tool renders, and * `toThrow(AcceptError)` alone would pass on the wrong refusal. */ -function refusalFrom(fn: () => unknown): AcceptError { - try { - fn(); - } catch (err) { - if (err instanceof AcceptError) return err; - throw err; - } - throw new Error('expected the call to throw an AcceptError, but it returned'); -} - async function asyncRefusalFrom(fn: () => Promise): Promise { try { await fn(); @@ -419,13 +409,13 @@ describe('DawarichSuggestionsService — the review list', () => { // ── accept: place ──────────────────────────────────────────────────────────── describe('DawarichSuggestionsService — accepting into a trip', () => { - it('DAWARICH-SUG-005: a trip the caller cannot see is a 404, and nothing is created', () => { + it('DAWARICH-SUG-005: a trip the caller cannot see is a 404, and nothing is created', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const strangerTrip = createTrip(testDb, stranger.id); const id = seedSuggestion({ userId: user.id }); - const err = refusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: strangerTrip.id })); + const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: strangerTrip.id })); expect(err.status).toBe(404); expect(err.code).toBe('not_found'); @@ -433,14 +423,14 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { expect(rowOf(id).state).toBe('new'); }); - it('DAWARICH-SUG-006: a day from another trip is a 400 — trip access is not day access', () => { + it('DAWARICH-SUG-006: a day from another trip is a 400 — trip access is not day access', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const otherTrip = createTrip(testDb, user.id); const foreignDay = createDay(testDb, otherTrip.id, { date: '2026-09-01' }); const id = seedSuggestion({ userId: user.id }); - const err = refusalFrom(() => + const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: foreignDay.id }), ); @@ -494,11 +484,11 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { expect(result.suggestion.sourceChanged).toBe(false); }); - it('DAWARICH-SUG-008: a stay with no trip anywhere is a 400 rather than a place nobody can see', () => { + it('DAWARICH-SUG-008: a stay with no trip anywhere is a 400 rather than a place nobody can see', async () => { const { user } = createUser(testDb); const id = seedSuggestion({ userId: user.id, tripId: null }); - const err = refusalFrom(() => svc.accept(user.id, id, { target: 'place' })); + const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'place' })); expect(err.status).toBe(400); expect(err.code).toBe('trip_required'); @@ -588,13 +578,13 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { ); }); - it("DAWARICH-SUG-009: accepting a stranger's suggestion is a 404 before any target is looked at", () => { + it("DAWARICH-SUG-009: accepting a stranger's suggestion is a 404 before any target is looked at", async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, user.id); const theirs = seedSuggestion({ userId: stranger.id }); - const err = refusalFrom(() => svc.accept(user.id, theirs, { target: 'place', tripId: trip.id })); + const err = await asyncRefusalFrom(() => svc.accept(user.id, theirs, { target: 'place', tripId: trip.id })); expect(err.status).toBe(404); expect(err.code).toBe('not_found'); @@ -605,12 +595,12 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { // ── accept: journal ────────────────────────────────────────────────────────── describe('DawarichSuggestionsService — accepting into a journey', () => { - it('DAWARICH-SUG-010: a journey the caller may not edit refuses before createEntry is reached', () => { + it('DAWARICH-SUG-010: a journey the caller may not edit refuses before createEntry is reached', async () => { const { user } = createUser(testDb); const id = seedSuggestion({ userId: user.id }); journeyStub.canEdit.mockReturnValue(false); - const err = refusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 4242 })); + const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 4242 })); expect(journeyStub.canEdit).toHaveBeenCalledWith(4242, user.id); expect(err.status).toBe(404); @@ -648,11 +638,11 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { expect(row.accepted_journal_entry_id).toBe(CREATED_ENTRY_ID); }); - it('DAWARICH-SUG-012: a journey id missing from the body is a 400, not a guess', () => { + it('DAWARICH-SUG-012: a journey id missing from the body is a 400, not a guess', async () => { const { user } = createUser(testDb); const id = seedSuggestion({ userId: user.id }); - const err = refusalFrom(() => svc.accept(user.id, id, { target: 'journal' })); + const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'journal' })); expect(err.status).toBe(400); expect(err.code).toBe('journal_required'); @@ -713,12 +703,12 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { expect(body.story).toBeUndefined(); }); - it('DAWARICH-SUG-013: a journey domain that returns nothing leaves the suggestion in review', () => { + it('DAWARICH-SUG-013: a journey domain that returns nothing leaves the suggestion in review', async () => { const { user } = createUser(testDb); const id = seedSuggestion({ userId: user.id }); journeyStub.createEntry.mockReturnValue(null); - const err = refusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 12 })); + const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 12 })); expect(err.status).toBe(404); expect(rowOf(id).state).toBe('new'); @@ -728,13 +718,13 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { // ── accept: bucket list ────────────────────────────────────────────────────── describe('DawarichSuggestionsService — ticking off a wish', () => { - it('DAWARICH-SUG-014: a wish owned by somebody else is a 404 and stays untouched', () => { + it('DAWARICH-SUG-014: a wish owned by somebody else is a 404 and stays untouched', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const theirWish = seedBucketItem(stranger.id, { name: 'Their Hallstatt' }); const id = seedSuggestion({ userId: user.id }); - const err = refusalFrom(() => + const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: theirWish }), ); @@ -770,11 +760,11 @@ describe('DawarichSuggestionsService — ticking off a wish', () => { expect(bucketOf(wish).visited_source).toBe('dawarich'); }); - it('DAWARICH-SUG-017: with nothing named and nothing matched it is a 400', () => { + it('DAWARICH-SUG-017: with nothing named and nothing matched it is a 400', async () => { const { user } = createUser(testDb); const id = seedSuggestion({ userId: user.id, matchedBucketListItemId: null }); - const err = refusalFrom(() => svc.accept(user.id, id, { target: 'bucket_list' })); + const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'bucket_list' })); expect(err.status).toBe(400); expect(err.code).toBe('bucket_required'); @@ -792,7 +782,7 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => { const first = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); expect(placesStub.create).toHaveBeenCalledTimes(1); - const err = refusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: trip.id })); + const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: trip.id })); expect(err.status).toBe(409); expect(err.code).toBe('already_accepted'); diff --git a/server/tests/unit/nest/discovery.controller.test.ts b/server/tests/unit/nest/discovery.controller.test.ts index 46b02379d..3bf18465a 100644 --- a/server/tests/unit/nest/discovery.controller.test.ts +++ b/server/tests/unit/nest/discovery.controller.test.ts @@ -93,22 +93,34 @@ describe('DiscoveryMetadataService', () => { }); }); +/** + * createMcpMetadataMiddleware resolves the addon gate inside an async IIFE + * (Express cannot await a middleware), so on a /.well-known path the 404 or the + * router delegation lands a microtask after mw() returns. Drain the microtask + * queue — no timer is involved, so there is nothing to sleep for. + */ +async function flushMicrotasks(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve(); +} + describe('createMcpMetadataMiddleware', () => { - it('DISC-010: 404s a /.well-known path with an empty body when MCP is disabled', () => { + it('DISC-010: 404s a /.well-known path with an empty body when MCP is disabled', async () => { const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), addons(false)); const res = makeRes(); const next = vi.fn(); mw({ path: '/.well-known/oauth-authorization-server' } as never, res as never, next); + await flushMicrotasks(); expect(res.statusCode).toBe(404); expect(res.ended).toBe(true); expect(next).not.toHaveBeenCalled(); expect(h.metaRouter).not.toHaveBeenCalled(); }); - it('DISC-011: delegates a /.well-known path to the SDK router when MCP is enabled', () => { + it('DISC-011: delegates a /.well-known path to the SDK router when MCP is enabled', async () => { const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), addons(true)); const next = vi.fn(); mw({ path: '/.well-known/oauth-authorization-server' } as never, makeRes() as never, next); + await flushMicrotasks(); expect(h.metaRouter).toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/mcp-addon-gate.test.ts b/server/tests/unit/nest/mcp-addon-gate.test.ts index 8a9ae06aa..211e380fe 100644 --- a/server/tests/unit/nest/mcp-addon-gate.test.ts +++ b/server/tests/unit/nest/mcp-addon-gate.test.ts @@ -18,23 +18,35 @@ function makeRes() { return res; } +/** + * createMcpAddonGate decides inside an async IIFE (Express cannot await a + * middleware), so the 404 or the next() lands a microtask after mw() returns. + * Drain the microtask queue — no timer is involved, so there is nothing to sleep + * for. + */ +async function flushMicrotasks(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve(); +} + describe('createMcpAddonGate', () => { - it('GATE-001: 404s with an empty body when the MCP addon is off', () => { + it('GATE-001: 404s with an empty body when the MCP addon is off', async () => { const { addons, mw } = gate(false); const res = makeRes(); const next = vi.fn(); mw({} as never, res as never, next); + await flushMicrotasks(); expect(addons.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.MCP); expect(res.statusCode).toBe(404); expect(res.ended).toBe(true); expect(next).not.toHaveBeenCalled(); }); - it('GATE-002: continues when the addon is on', () => { + it('GATE-002: continues when the addon is on', async () => { const { mw } = gate(true); const res = makeRes(); const next = vi.fn(); mw({} as never, res as never, next); + await flushMicrotasks(); expect(res.statusCode).toBe(200); expect(next).toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index 59eb832f3..8de069007 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -115,11 +115,11 @@ afterAll(() => { // Helper // --------------------------------------------------------------------------- -function makeClient( +async function makeClient( userId: number, overrides: Partial<{ name: string; redirectUris: string[]; scopes: string[] }> = {} ) { - return createOAuthClient( + return await createOAuthClient( userId, overrides.name ?? 'Test Client', overrides.redirectUris ?? ['https://example.com/callback'], @@ -132,137 +132,137 @@ function makeClient( // --------------------------------------------------------------------------- describe('createOAuthClient', () => { - it('creates a client successfully and returns client_secret only on creation', () => { + it('creates a client successfully and returns client_secret only on creation', async () => { const { user } = createUser(testDb); - const result = makeClient(user.id); + const result = await makeClient(user.id); expect(result.error).toBeUndefined(); expect(result.client).toBeDefined(); expect(typeof result.client!.client_secret).toBe('string'); expect((result.client!.client_secret as string).startsWith('trekcs_')).toBe(true); }); - it('client_id is a UUID', () => { + it('client_id is a UUID', async () => { const { user } = createUser(testDb); - const result = makeClient(user.id); + const result = await makeClient(user.id); expect(result.client!.client_id).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ ); }); - it('returns 400 error if name is empty', () => { + it('returns 400 error if name is empty', async () => { const { user } = createUser(testDb); - const result = createOAuthClient(user.id, '', ['https://example.com/cb'], ['trips:read']); + const result = await createOAuthClient(user.id, '', ['https://example.com/cb'], ['trips:read']); expect(result.status).toBe(400); expect(result.error).toContain('Name'); }); - it('returns 400 error if name exceeds 100 characters', () => { + it('returns 400 error if name exceeds 100 characters', async () => { const { user } = createUser(testDb); const longName = 'A'.repeat(101); - const result = createOAuthClient(user.id, longName, ['https://example.com/cb'], ['trips:read']); + const result = await createOAuthClient(user.id, longName, ['https://example.com/cb'], ['trips:read']); expect(result.status).toBe(400); expect(result.error).toContain('100'); }); - it('returns 400 error if no redirect URIs provided', () => { + it('returns 400 error if no redirect URIs provided', async () => { const { user } = createUser(testDb); - const result = createOAuthClient(user.id, 'Test', [], ['trips:read']); + const result = await createOAuthClient(user.id, 'Test', [], ['trips:read']); expect(result.status).toBe(400); expect(result.error).toContain('redirect URI'); }); - it('returns 400 error if more than 10 redirect URIs provided', () => { + it('returns 400 error if more than 10 redirect URIs provided', async () => { const { user } = createUser(testDb); const uris = Array.from({ length: 11 }, (_, i) => `https://example${i}.com/cb`); - const result = createOAuthClient(user.id, 'Test', uris, ['trips:read']); + const result = await createOAuthClient(user.id, 'Test', uris, ['trips:read']); expect(result.status).toBe(400); expect(result.error).toContain('10'); }); - it('returns 400 error for invalid URI format', () => { + it('returns 400 error for invalid URI format', async () => { const { user } = createUser(testDb); - const result = createOAuthClient(user.id, 'Test', ['not-a-url'], ['trips:read']); + const result = await createOAuthClient(user.id, 'Test', ['not-a-url'], ['trips:read']); expect(result.status).toBe(400); expect(result.error).toContain('Invalid redirect URI'); }); - it('returns 400 error for non-https URI (not localhost)', () => { + it('returns 400 error for non-https URI (not localhost)', async () => { const { user } = createUser(testDb); - const result = createOAuthClient(user.id, 'Test', ['http://example.com/cb'], ['trips:read']); + const result = await createOAuthClient(user.id, 'Test', ['http://example.com/cb'], ['trips:read']); expect(result.status).toBe(400); expect(result.error).toContain('HTTPS'); }); - it('allows http://localhost redirect URI', () => { + it('allows http://localhost redirect URI', async () => { const { user } = createUser(testDb); - const result = createOAuthClient(user.id, 'Test', ['http://localhost:3000/callback'], ['trips:read']); + const result = await createOAuthClient(user.id, 'Test', ['http://localhost:3000/callback'], ['trips:read']); expect(result.error).toBeUndefined(); expect(result.client).toBeDefined(); }); - it('allows http://127.0.0.1 redirect URI', () => { + it('allows http://127.0.0.1 redirect URI', async () => { const { user } = createUser(testDb); - const result = createOAuthClient(user.id, 'Test', ['http://127.0.0.1:5000/callback'], ['trips:read']); + const result = await createOAuthClient(user.id, 'Test', ['http://127.0.0.1:5000/callback'], ['trips:read']); expect(result.error).toBeUndefined(); expect(result.client).toBeDefined(); }); - it('allows the http://[::1] loopback redirect URI (#2227)', () => { + it('allows the http://[::1] loopback redirect URI (#2227)', async () => { const { user } = createUser(testDb); - const result = createOAuthClient(user.id, 'Test', ['http://[::1]:8080/callback'], ['trips:read']); + const result = await createOAuthClient(user.id, 'Test', ['http://[::1]:8080/callback'], ['trips:read']); expect(result.error).toBeUndefined(); expect(result.client).toBeDefined(); }); - it('allows a private-use custom scheme (#2227)', () => { + it('allows a private-use custom scheme (#2227)', async () => { const { user } = createUser(testDb); const uri = 'workbuddy://workbuddy/mcp/connector%3A/oauth/callback'; - const result = createOAuthClient(user.id, 'Test', [uri], ['trips:read']); + const result = await createOAuthClient(user.id, 'Test', [uri], ['trips:read']); expect(result.error).toBeUndefined(); // The stored value must round-trip untouched: the whole authorize/token // chain compares it byte for byte. expect(result.client!.redirect_uris).toEqual([uri]); }); - it('rejects dangerous schemes that a localhost host used to smuggle through (#2227)', () => { + it('rejects dangerous schemes that a localhost host used to smuggle through (#2227)', async () => { const { user } = createUser(testDb); for (const uri of ['javascript://localhost/%0aalert(1)', 'blob://localhost/x', 'about://localhost/x']) { - const result = createOAuthClient(user.id, 'Test', [uri], ['trips:read']); + const result = await createOAuthClient(user.id, 'Test', [uri], ['trips:read']); expect(result.status).toBe(400); expect(result.error).toContain('Dangerous'); } }); - it('rejects non-navigable schemes on a localhost host (#2227)', () => { + it('rejects non-navigable schemes on a localhost host (#2227)', async () => { const { user } = createUser(testDb); for (const uri of ['ftp://localhost/x', 'wss://localhost/x']) { - const result = createOAuthClient(user.id, 'Test', [uri], ['trips:read']); + const result = await createOAuthClient(user.id, 'Test', [uri], ['trips:read']); expect(result.status).toBe(400); expect(result.error).toContain('HTTPS'); } }); - it('returns 400 error if no scopes provided', () => { + it('returns 400 error if no scopes provided', async () => { const { user } = createUser(testDb); - const result = createOAuthClient(user.id, 'Test', ['https://example.com/cb'], []); + const result = await createOAuthClient(user.id, 'Test', ['https://example.com/cb'], []); expect(result.status).toBe(400); expect(result.error).toContain('scope'); }); - it('returns 400 error for invalid scopes', () => { + it('returns 400 error for invalid scopes', async () => { const { user } = createUser(testDb); - const result = createOAuthClient(user.id, 'Test', ['https://example.com/cb'], ['invalid:scope']); + const result = await createOAuthClient(user.id, 'Test', ['https://example.com/cb'], ['invalid:scope']); expect(result.status).toBe(400); expect(result.error).toContain('Invalid scopes'); }); - it('enforces max 10 clients per user', () => { + it('enforces max 10 clients per user', async () => { const { user } = createUser(testDb); for (let i = 0; i < 10; i++) { - const r = makeClient(user.id, { name: `Client ${i}` }); + const r = await makeClient(user.id, { name: `Client ${i}` }); expect(r.error).toBeUndefined(); } - const eleventh = makeClient(user.id, { name: 'Eleventh' }); + const eleventh = await makeClient(user.id, { name: 'Eleventh' }); expect(eleventh.status).toBe(400); expect(eleventh.error).toContain('10'); }); @@ -278,9 +278,9 @@ describe('listOAuthClients', () => { expect(listOAuthClients(user.id)).toEqual([]); }); - it('returns created clients with redirect_uris and allowed_scopes as arrays', () => { + it('returns created clients with redirect_uris and allowed_scopes as arrays', async () => { const { user } = createUser(testDb); - makeClient(user.id, { name: 'Client A', redirectUris: ['https://a.com/cb'], scopes: ['trips:read', 'budget:read'] }); + await makeClient(user.id, { name: 'Client A', redirectUris: ['https://a.com/cb'], scopes: ['trips:read', 'budget:read'] }); const clients = listOAuthClients(user.id); expect(clients).toHaveLength(1); expect(clients[0].name).toBe('Client A'); @@ -295,26 +295,26 @@ describe('listOAuthClients', () => { // --------------------------------------------------------------------------- describe('deleteOAuthClient', () => { - it('deletes own client successfully', () => { + it('deletes own client successfully', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientRowId = created.client!.id as string; - const result = deleteOAuthClient(user.id, clientRowId); + const result = await deleteOAuthClient(user.id, clientRowId); expect(result.success).toBe(true); expect(listOAuthClients(user.id)).toHaveLength(0); }); - it('returns 404 for non-existent client', () => { + it('returns 404 for non-existent client', async () => { const { user } = createUser(testDb); - const result = deleteOAuthClient(user.id, 'non-existent-id'); + const result = await deleteOAuthClient(user.id, 'non-existent-id'); expect(result.status).toBe(404); }); - it("returns 404 for another user's client", () => { + it("returns 404 for another user's client", async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); - const created = makeClient(owner.id); - const result = deleteOAuthClient(other.id, created.client!.id as string); + const created = await makeClient(owner.id); + const result = await deleteOAuthClient(other.id, created.client!.id as string); expect(result.status).toBe(404); }); }); @@ -324,31 +324,31 @@ describe('deleteOAuthClient', () => { // --------------------------------------------------------------------------- describe('rotateOAuthClientSecret', () => { - it('rotates secret and returns new client_secret starting with trekcs_', () => { + it('rotates secret and returns new client_secret starting with trekcs_', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const oldSecret = created.client!.client_secret as string; - const result = rotateOAuthClientSecret(user.id, created.client!.id as string); + const result = await rotateOAuthClientSecret(user.id, created.client!.id as string); expect(result.error).toBeUndefined(); expect(result.client_secret).toBeDefined(); expect((result.client_secret as string).startsWith('trekcs_')).toBe(true); expect(result.client_secret).not.toBe(oldSecret); }); - it('returns 404 for non-existent client', () => { + it('returns 404 for non-existent client', async () => { const { user } = createUser(testDb); - const result = rotateOAuthClientSecret(user.id, 'non-existent-id'); + const result = await rotateOAuthClientSecret(user.id, 'non-existent-id'); expect(result.status).toBe(404); }); - it('revokes old tokens after rotation', () => { + it('revokes old tokens after rotation', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const { access_token } = issueTokens(clientId, user.id, ['trips:read']); expect(getUserByAccessToken(access_token)).not.toBeNull(); - rotateOAuthClientSecret(user.id, created.client!.id as string); + await rotateOAuthClientSecret(user.id, created.client!.id as string); expect(getUserByAccessToken(access_token)).toBeNull(); }); @@ -359,9 +359,9 @@ describe('rotateOAuthClientSecret', () => { // --------------------------------------------------------------------------- describe('createAuthCode + consumeAuthCode', () => { - it('create code and consume it once returns the pending entry', () => { + it('create code and consume it once returns the pending entry', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const code = createAuthCode({ @@ -383,9 +383,9 @@ describe('createAuthCode + consumeAuthCode', () => { expect(consumeAuthCode('does-not-exist')).toBeNull(); }); - it('consuming same code twice returns null (one-time use)', () => { + it('consuming same code twice returns null (one-time use)', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const code = createAuthCode({ @@ -407,9 +407,9 @@ describe('createAuthCode + consumeAuthCode', () => { // --------------------------------------------------------------------------- describe('issueTokens + getUserByAccessToken', () => { - it('issues tokens with correct prefixes', () => { + it('issues tokens with correct prefixes', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const tokens = issueTokens(clientId, user.id, ['trips:read']); @@ -419,9 +419,9 @@ describe('issueTokens + getUserByAccessToken', () => { expect(typeof tokens.expires_in).toBe('number'); }); - it('getUserByAccessToken returns user and scopes for a valid token', () => { + it('getUserByAccessToken returns user and scopes for a valid token', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const { access_token } = issueTokens(clientId, user.id, ['trips:read', 'budget:write']); @@ -436,13 +436,13 @@ describe('issueTokens + getUserByAccessToken', () => { expect(getUserByAccessToken('trekoa_unknown')).toBeNull(); }); - it('getUserByAccessToken returns null for revoked token', () => { + it('getUserByAccessToken returns null for revoked token', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const { access_token } = issueTokens(clientId, user.id, ['trips:read']); - revokeToken(access_token, clientId); + await revokeToken(access_token, clientId); expect(getUserByAccessToken(access_token)).toBeNull(); }); }); @@ -452,73 +452,73 @@ describe('issueTokens + getUserByAccessToken', () => { // --------------------------------------------------------------------------- describe('refreshTokens', () => { - it('exchanges a refresh token for a new token pair', () => { + it('exchanges a refresh token for a new token pair', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']); - const result = refreshTokens(refresh_token, clientId, rawSecret); + const result = await refreshTokens(refresh_token, clientId, rawSecret); expect(result.error).toBeUndefined(); expect(result.tokens).toBeDefined(); expect(result.tokens!.access_token.startsWith('trekoa_')).toBe(true); }); - it('old tokens are revoked after refresh (rotation)', () => { + it('old tokens are revoked after refresh (rotation)', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; const { access_token, refresh_token } = issueTokens(clientId, user.id, ['trips:read']); - refreshTokens(refresh_token, clientId, rawSecret); + await refreshTokens(refresh_token, clientId, rawSecret); expect(getUserByAccessToken(access_token)).toBeNull(); }); - it('does not revoke the active MCP session on a normal (non-replayed) refresh (#1475)', () => { + it('does not revoke the active MCP session on a normal (non-replayed) refresh (#1475)', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']); const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length; - const result = refreshTokens(refresh_token, clientId, rawSecret); + const result = await refreshTokens(refresh_token, clientId, rawSecret); expect(result.error).toBeUndefined(); expect(vi.mocked(revokeUserSessionsForClient).mock.calls.length).toBe(callsBefore); }); - it('returns invalid_grant for unknown refresh token', () => { + it('returns invalid_grant for unknown refresh token', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; - const result = refreshTokens('trekrf_unknown', clientId, rawSecret); + const result = await refreshTokens('trekrf_unknown', clientId, rawSecret); expect(result.error).toBe('invalid_grant'); expect(result.status).toBe(400); }); - it('returns invalid_grant for revoked token', () => { + it('returns invalid_grant for revoked token', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; const { access_token, refresh_token } = issueTokens(clientId, user.id, ['trips:read']); - revokeToken(access_token, clientId); - const result = refreshTokens(refresh_token, clientId, rawSecret); + await revokeToken(access_token, clientId); + const result = await refreshTokens(refresh_token, clientId, rawSecret); expect(result.error).toBe('invalid_grant'); }); - it('returns invalid_client for wrong client_secret', () => { + it('returns invalid_client for wrong client_secret', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']); - const result = refreshTokens(refresh_token, clientId, 'wrong-secret'); + const result = await refreshTokens(refresh_token, clientId, 'wrong-secret'); expect(result.error).toBe('invalid_client'); expect(result.status).toBe(401); }); @@ -529,15 +529,15 @@ describe('refreshTokens', () => { // --------------------------------------------------------------------------- describe('revokeToken', () => { - it('after revoking access token, getUserByAccessToken returns null', () => { + it('after revoking access token, getUserByAccessToken returns null', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const { access_token } = issueTokens(clientId, user.id, ['trips:read']); expect(getUserByAccessToken(access_token)).not.toBeNull(); - revokeToken(access_token, clientId); + await revokeToken(access_token, clientId); expect(getUserByAccessToken(access_token)).toBeNull(); }); }); @@ -547,9 +547,9 @@ describe('revokeToken', () => { // --------------------------------------------------------------------------- describe('listOAuthSessions + revokeSession', () => { - it('lists active sessions', () => { + it('lists active sessions', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; issueTokens(clientId, user.id, ['trips:read']); @@ -558,33 +558,33 @@ describe('listOAuthSessions + revokeSession', () => { expect(sessions[0].client_id).toBe(clientId); }); - it('revoked session is not listed', () => { + it('revoked session is not listed', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const { access_token } = issueTokens(clientId, user.id, ['trips:read']); - revokeToken(access_token, clientId); + await revokeToken(access_token, clientId); const sessions = listOAuthSessions(user.id); expect(sessions).toHaveLength(0); }); - it('revokeSession returns 404 for unknown session', () => { + it('revokeSession returns 404 for unknown session', async () => { const { user } = createUser(testDb); - const result = revokeSession(user.id, 99999); + const result = await revokeSession(user.id, 99999); expect(result.status).toBe(404); }); - it('revokeSession by session id removes session from list', () => { + it('revokeSession by session id removes session from list', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; issueTokens(clientId, user.id, ['trips:read']); const sessions = listOAuthSessions(user.id); const sessionId = sessions[0].id as number; - const result = revokeSession(user.id, sessionId); + const result = await revokeSession(user.id, sessionId); expect(result.success).toBe(true); expect(listOAuthSessions(user.id)).toHaveLength(0); }); @@ -617,40 +617,40 @@ describe('validateAuthorizeRequest', () => { }; } - it('returns mcp_disabled when isAddonEnabled returns false', () => { + it('returns mcp_disabled when isAddonEnabled returns false', async () => { vi.mocked(isAddonEnabled).mockReturnValue(false); - const result = validateAuthorizeRequest(makeParams({ client_id: 'x' }), null); + const result = await validateAuthorizeRequest(makeParams({ client_id: 'x' }), null); expect(result.valid).toBe(false); expect(result.error).toBe('mcp_disabled'); }); - it('requires response_type=code', () => { + it('requires response_type=code', async () => { const { user } = createUser(testDb); - const result = validateAuthorizeRequest(makeParams({ response_type: 'token', client_id: 'x' }), user.id); + const result = await validateAuthorizeRequest(makeParams({ response_type: 'token', client_id: 'x' }), user.id); expect(result.valid).toBe(false); expect(result.error).toBe('unsupported_response_type'); }); - it('requires PKCE with S256', () => { + it('requires PKCE with S256', async () => { const { user } = createUser(testDb); - const result = validateAuthorizeRequest(makeParams({ client_id: 'x', code_challenge_method: 'plain' }), user.id); + const result = await validateAuthorizeRequest(makeParams({ client_id: 'x', code_challenge_method: 'plain' }), user.id); expect(result.valid).toBe(false); expect(result.error).toBe('invalid_request'); }); - it('requires valid client_id', () => { + it('requires valid client_id', async () => { const { user } = createUser(testDb); - const result = validateAuthorizeRequest(makeParams({ client_id: 'nonexistent' }), user.id); + const result = await validateAuthorizeRequest(makeParams({ client_id: 'nonexistent' }), user.id); expect(result.valid).toBe(false); expect(result.error).toBe('invalid_client'); }); - it('validates redirect_uri against registered URIs', () => { + it('validates redirect_uri against registered URIs', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id, { redirectUris: ['https://example.com/callback'] }); + const created = await makeClient(user.id, { redirectUris: ['https://example.com/callback'] }); const clientId = created.client!.client_id as string; - const result = validateAuthorizeRequest( + const result = await validateAuthorizeRequest( makeParams({ client_id: clientId, redirect_uri: 'https://evil.com/callback' }), user.id ); @@ -658,24 +658,24 @@ describe('validateAuthorizeRequest', () => { expect(result.error).toBe('invalid_redirect_uri'); }); - it('accepts a loopback redirect on the port the OS handed the client (#2227)', () => { + it('accepts a loopback redirect on the port the OS handed the client (#2227)', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id, { redirectUris: ['http://[::1]:8080/oauth/callback'] }); + const created = await makeClient(user.id, { redirectUris: ['http://[::1]:8080/oauth/callback'] }); const clientId = created.client!.client_id as string; - const result = validateAuthorizeRequest( + const result = await validateAuthorizeRequest( makeParams({ client_id: clientId, redirect_uri: 'http://[::1]:54321/oauth/callback' }), user.id ); expect(result.valid).toBe(true); }); - it('relaxes only the port of a loopback redirect, never the path (#2227)', () => { + it('relaxes only the port of a loopback redirect, never the path (#2227)', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id, { redirectUris: ['http://127.0.0.1:8080/oauth/callback'] }); + const created = await makeClient(user.id, { redirectUris: ['http://127.0.0.1:8080/oauth/callback'] }); const clientId = created.client!.client_id as string; - const result = validateAuthorizeRequest( + const result = await validateAuthorizeRequest( makeParams({ client_id: clientId, redirect_uri: 'http://127.0.0.1:54321/stolen' }), user.id ); @@ -683,12 +683,12 @@ describe('validateAuthorizeRequest', () => { expect(result.error).toBe('invalid_redirect_uri'); }); - it('validates scope against client allowed_scopes', () => { + it('validates scope against client allowed_scopes', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id, { scopes: ['trips:read'] }); + const created = await makeClient(user.id, { scopes: ['trips:read'] }); const clientId = created.client!.client_id as string; - const result = validateAuthorizeRequest( + const result = await validateAuthorizeRequest( makeParams({ client_id: clientId, scope: 'budget:write' }), user.id ); @@ -696,33 +696,33 @@ describe('validateAuthorizeRequest', () => { expect(result.error).toBe('invalid_scope'); }); - it('returns loginRequired when userId is null', () => { + it('returns loginRequired when userId is null', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const result = validateAuthorizeRequest(makeParams({ client_id: clientId }), null); + const result = await validateAuthorizeRequest(makeParams({ client_id: clientId }), null); expect(result.valid).toBe(true); expect(result.loginRequired).toBe(true); }); - it('returns consentRequired=true when consent not yet saved', () => { + it('returns consentRequired=true when consent not yet saved', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const result = validateAuthorizeRequest(makeParams({ client_id: clientId }), user.id); + const result = await validateAuthorizeRequest(makeParams({ client_id: clientId }), user.id); expect(result.valid).toBe(true); expect(result.consentRequired).toBe(true); }); - it('returns consentRequired=false when consent already saved and sufficient', () => { + it('returns consentRequired=false when consent already saved and sufficient', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - saveConsent(clientId, user.id, ['trips:read']); - const result = validateAuthorizeRequest(makeParams({ client_id: clientId }), user.id); + await saveConsent(clientId, user.id, ['trips:read']); + const result = await validateAuthorizeRequest(makeParams({ client_id: clientId }), user.id); expect(result.valid).toBe(true); expect(result.consentRequired).toBe(false); }); @@ -751,9 +751,9 @@ describe('verifyPKCE', () => { // --------------------------------------------------------------------------- describe('authenticateClient', () => { - it('returns client row for correct credentials', () => { + it('returns client row for correct credentials', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; @@ -762,9 +762,9 @@ describe('authenticateClient', () => { expect(client!.client_id).toBe(clientId); }); - it('returns null for wrong secret', () => { + it('returns null for wrong secret', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; expect(authenticateClient(clientId, 'wrong-secret')).toBeNull(); @@ -780,12 +780,12 @@ describe('authenticateClient', () => { // --------------------------------------------------------------------------- describe('saveConsent + getConsent + isConsentSufficient', () => { - it('saves and retrieves consent', () => { + it('saves and retrieves consent', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - saveConsent(clientId, user.id, ['trips:read', 'budget:write']); + await saveConsent(clientId, user.id, ['trips:read', 'budget:write']); const consent = getConsent(clientId, user.id); expect(consent).not.toBeNull(); expect(consent).toContain('trips:read'); @@ -808,27 +808,27 @@ describe('saveConsent + getConsent + isConsentSufficient', () => { // --------------------------------------------------------------------------- describe('saveConsent — scope union (M5)', () => { - it('unioning scopes: approving B after A leaves both in consent', () => { + it('unioning scopes: approving B after A leaves both in consent', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id, { scopes: ['trips:read', 'budget:write'] }); + const created = await makeClient(user.id, { scopes: ['trips:read', 'budget:write'] }); const clientId = created.client!.client_id as string; - saveConsent(clientId, user.id, ['trips:read']); - saveConsent(clientId, user.id, ['budget:write']); + await saveConsent(clientId, user.id, ['trips:read']); + await saveConsent(clientId, user.id, ['budget:write']); const consent = getConsent(clientId, user.id); expect(consent).toContain('trips:read'); expect(consent).toContain('budget:write'); }); - it('re-approving a superset scope still preserves previously-consented scopes', () => { + it('re-approving a superset scope still preserves previously-consented scopes', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id, { scopes: ['trips:read', 'trips:write'] }); + const created = await makeClient(user.id, { scopes: ['trips:read', 'trips:write'] }); const clientId = created.client!.client_id as string; - saveConsent(clientId, user.id, ['trips:read', 'trips:write']); + await saveConsent(clientId, user.id, ['trips:read', 'trips:write']); // approve only trips:read on a later request - saveConsent(clientId, user.id, ['trips:read']); + await saveConsent(clientId, user.id, ['trips:read']); const consent = getConsent(clientId, user.id); // trips:write should NOT be removed (union semantics) @@ -836,13 +836,13 @@ describe('saveConsent — scope union (M5)', () => { expect(consent).toContain('trips:write'); }); - it('consent is sufficient after sequential approvals — no re-prompt needed', () => { + it('consent is sufficient after sequential approvals — no re-prompt needed', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id, { scopes: ['trips:read', 'budget:write'] }); + const created = await makeClient(user.id, { scopes: ['trips:read', 'budget:write'] }); const clientId = created.client!.client_id as string; - saveConsent(clientId, user.id, ['trips:read']); - saveConsent(clientId, user.id, ['budget:write']); + await saveConsent(clientId, user.id, ['trips:read']); + await saveConsent(clientId, user.id, ['budget:write']); // Should not require consent again for either scope expect(isConsentSufficient(getConsent(clientId, user.id)!, ['trips:read'])).toBe(true); @@ -856,9 +856,9 @@ describe('saveConsent — scope union (M5)', () => { // --------------------------------------------------------------------------- describe('getUserByAccessToken — includes clientId (C2)', () => { - it('returns clientId matching the issuing OAuth client', () => { + it('returns clientId matching the issuing OAuth client', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const { access_token } = issueTokens(clientId, user.id, ['trips:read']); @@ -884,15 +884,15 @@ function agePastGrace(rawRefreshToken: string) { } describe('refreshTokens — replay detection (C3)', () => { - it('replaying a revoked refresh token returns invalid_grant', () => { + it('replaying a revoked refresh token returns invalid_grant', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; // Issue tokens, then rotate once (old token becomes revoked) const { refresh_token: firstRefresh } = issueTokens(clientId, user.id, ['trips:read']); - const rotateResult = refreshTokens(firstRefresh, clientId, rawSecret); + const rotateResult = await refreshTokens(firstRefresh, clientId, rawSecret); expect(rotateResult.error).toBeUndefined(); const { refresh_token: secondRefresh } = rotateResult.tokens!; @@ -900,7 +900,7 @@ describe('refreshTokens — replay detection (C3)', () => { // rotation that it cannot be a concurrent refresh. agePastGrace(firstRefresh); const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length; - const replayResult = refreshTokens(firstRefresh, clientId, rawSecret); + const replayResult = await refreshTokens(firstRefresh, clientId, rawSecret); expect(replayResult.error).toBe('invalid_grant'); expect(replayResult.status).toBe(400); // Replay IS a security event — sessions must still be torn down here. @@ -908,50 +908,50 @@ describe('refreshTokens — replay detection (C3)', () => { expect(vi.mocked(revokeUserSessionsForClient)).toHaveBeenLastCalledWith(user.id, clientId); }); - it('replaying a revoked token also revokes the entire rotation chain', () => { + it('replaying a revoked token also revokes the entire rotation chain', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; // Issue → rotate once const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']); - const r1 = refreshTokens(first, clientId, rawSecret); + const r1 = await refreshTokens(first, clientId, rawSecret); const { access_token: access2, refresh_token: second } = r1.tokens!; // Replay first (revoked) refresh token → chain revoke agePastGrace(first); - refreshTokens(first, clientId, rawSecret); + await refreshTokens(first, clientId, rawSecret); // The rotated access token should also be dead now expect(getUserByAccessToken(access2)).toBeNull(); // The second refresh token should also be revoked - const r2 = refreshTokens(second, clientId, rawSecret); + const r2 = await refreshTokens(second, clientId, rawSecret); expect(r2.error).toBe('invalid_grant'); }); - it('new rotation chain after replay is independent', () => { + it('new rotation chain after replay is independent', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const rawSecret = created.client!.client_secret as string; const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']); // Rotate once - const r1 = refreshTokens(first, clientId, rawSecret); + const r1 = await refreshTokens(first, clientId, rawSecret); const { refresh_token: second } = r1.tokens!; // Rotate again on the second token - const r2 = refreshTokens(second, clientId, rawSecret); + const r2 = await refreshTokens(second, clientId, rawSecret); expect(r2.error).toBeUndefined(); const { refresh_token: third } = r2.tokens!; // Replay the first revoked token → revokes chain containing first+second+third agePastGrace(first); - refreshTokens(first, clientId, rawSecret); + await refreshTokens(first, clientId, rawSecret); // third should now be revoked too (it's in the same chain) - const r3 = refreshTokens(third, clientId, rawSecret); + const r3 = await refreshTokens(third, clientId, rawSecret); expect(r3.error).toBe('invalid_grant'); }); }); @@ -961,9 +961,9 @@ describe('refreshTokens — replay detection (C3)', () => { // --------------------------------------------------------------------------- describe('refreshTokens — concurrent rotation grace (#1007)', () => { - const setup = () => { + const setup = async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); return { user, clientId: created.client!.client_id as string, @@ -971,13 +971,13 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => { }; }; - it('OAUTH-GRACE-001: two clients refreshing the same token both get tokens', () => { - const { user, clientId, rawSecret } = setup(); + it('OAUTH-GRACE-001: two clients refreshing the same token both get tokens', async () => { + const { user, clientId, rawSecret } = await setup(); const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); - const first = refreshTokens(shared, clientId, rawSecret); + const first = await refreshTokens(shared, clientId, rawSecret); const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length; - const second = refreshTokens(shared, clientId, rawSecret); + const second = await refreshTokens(shared, clientId, rawSecret); expect(first.error).toBeUndefined(); expect(second.error).toBeUndefined(); @@ -988,52 +988,52 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => { expect(getUserByAccessToken(second.tokens!.access_token)).not.toBeNull(); }); - it('OAUTH-GRACE-002: both successors keep working afterwards', () => { - const { user, clientId, rawSecret } = setup(); + it('OAUTH-GRACE-002: both successors keep working afterwards', async () => { + const { user, clientId, rawSecret } = await setup(); const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); - const a = refreshTokens(shared, clientId, rawSecret).tokens!; - const b = refreshTokens(shared, clientId, rawSecret).tokens!; + const a = (await refreshTokens(shared, clientId, rawSecret)).tokens!; + const b = (await refreshTokens(shared, clientId, rawSecret)).tokens!; - expect(refreshTokens(a.refresh_token, clientId, rawSecret).error).toBeUndefined(); - expect(refreshTokens(b.refresh_token, clientId, rawSecret).error).toBeUndefined(); + expect((await refreshTokens(a.refresh_token, clientId, rawSecret)).error).toBeUndefined(); + expect((await refreshTokens(b.refresh_token, clientId, rawSecret)).error).toBeUndefined(); }); - it('OAUTH-GRACE-003: the same token replayed after the window is still theft', () => { - const { user, clientId, rawSecret } = setup(); + it('OAUTH-GRACE-003: the same token replayed after the window is still theft', async () => { + const { user, clientId, rawSecret } = await setup(); const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); - const rotated = refreshTokens(shared, clientId, rawSecret).tokens!; + const rotated = (await refreshTokens(shared, clientId, rawSecret)).tokens!; agePastGrace(shared); - const replay = refreshTokens(shared, clientId, rawSecret); + const replay = await refreshTokens(shared, clientId, rawSecret); expect(replay.error).toBe('invalid_grant'); - expect(refreshTokens(rotated.refresh_token, clientId, rawSecret).error).toBe('invalid_grant'); + expect((await refreshTokens(rotated.refresh_token, clientId, rawSecret)).error).toBe('invalid_grant'); }); - it('OAUTH-GRACE-004: a token revoked by logout is not re-opened by the window', () => { - const { user, clientId, rawSecret } = setup(); + it('OAUTH-GRACE-004: a token revoked by logout is not re-opened by the window', async () => { + const { user, clientId, rawSecret } = await setup(); const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); // Explicit revocation leaves no successor, so there is nothing to be // concurrent with — the grace must not resurrect it. - revokeToken(shared, clientId, user.id); - const result = refreshTokens(shared, clientId, rawSecret); + await revokeToken(shared, clientId, user.id); + const result = await refreshTokens(shared, clientId, rawSecret); expect(result.error).toBe('invalid_grant'); }); - it('OAUTH-GRACE-005: a chain killed by a real replay stays dead inside the window', () => { - const { user, clientId, rawSecret } = setup(); + it('OAUTH-GRACE-005: a chain killed by a real replay stays dead inside the window', async () => { + const { user, clientId, rawSecret } = await setup(); const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']); - const second = refreshTokens(first, clientId, rawSecret).tokens!; + const second = (await refreshTokens(first, clientId, rawSecret)).tokens!; // Real theft: the first token turns up again once the window has passed. agePastGrace(first); - expect(refreshTokens(first, clientId, rawSecret).error).toBe('invalid_grant'); + expect((await refreshTokens(first, clientId, rawSecret)).error).toBe('invalid_grant'); // The successor was revoked with the chain, so presenting it now must not // find a live child and slip through as "concurrent". - expect(refreshTokens(second.refresh_token, clientId, rawSecret).error).toBe('invalid_grant'); + expect((await refreshTokens(second.refresh_token, clientId, rawSecret)).error).toBe('invalid_grant'); }); }); @@ -1066,12 +1066,12 @@ describe('verifyPKCE — format validation (H1)', () => { }); describe('validateAuthorizeRequest — PKCE format (H1)', () => { - it('returns invalid_request when code_challenge is shorter than 43 chars', () => { + it('returns invalid_request when code_challenge is shorter than 43 chars', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; - const result = validateAuthorizeRequest({ + const result = await validateAuthorizeRequest({ response_type: 'code', client_id: clientId, redirect_uri: 'https://example.com/callback', @@ -1083,14 +1083,14 @@ describe('validateAuthorizeRequest — PKCE format (H1)', () => { expect(result.error).toBe('invalid_request'); }); - it('returns invalid_request when code_challenge contains invalid characters', () => { + it('returns invalid_request when code_challenge contains invalid characters', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; // 43 chars but includes '=' which is not base64url const badChallenge = '='.repeat(43); - const result = validateAuthorizeRequest({ + const result = await validateAuthorizeRequest({ response_type: 'code', client_id: clientId, redirect_uri: 'https://example.com/callback', @@ -1108,13 +1108,13 @@ describe('validateAuthorizeRequest — PKCE format (H1)', () => { // --------------------------------------------------------------------------- describe('validateAuthorizeRequest — unauthenticated strips client info (H3)', () => { - it('loginRequired response does not include client.name or allowed_scopes', () => { + it('loginRequired response does not include client.name or allowed_scopes', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = created.client!.client_id as string; const { challenge } = makePkce(); - const result = validateAuthorizeRequest({ + const result = await validateAuthorizeRequest({ response_type: 'code', client_id: clientId, redirect_uri: 'https://example.com/callback', @@ -1187,9 +1187,9 @@ describe('pending-code store', () => { }); describe('branches the legacy suite could not reach', () => { - it('rejects an expired access token', () => { + it('rejects an expired access token', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = (created.client as { client_id: string }).client_id; const tokens = issueTokens(clientId, user.id, ['trips:read']); testDb.prepare("UPDATE oauth_tokens SET access_token_expires_at = '2000-01-01T00:00:00.000Z'").run(); @@ -1197,40 +1197,40 @@ describe('branches the legacy suite could not reach', () => { expect(getUserByAccessToken(tokens.access_token)).toBeNull(); }); - it('refreshTokens rejects an unknown client before touching the token', () => { - expect(refreshTokens('trekrf_whatever', 'no-such-client', 'secret')).toEqual({ error: 'invalid_client', status: 401 }); + it('refreshTokens rejects an unknown client before touching the token', async () => { + expect(await refreshTokens('trekrf_whatever', 'no-such-client', 'secret')).toEqual({ error: 'invalid_client', status: 401 }); }); - it('refreshTokens skips the secret check for a public client', () => { + it('refreshTokens skips the secret check for a public client', async () => { const { user } = createUser(testDb); - const created = createOAuthClient(user.id, 'Public', ['https://example.com/callback'], ['trips:read'], null, { isPublic: true }); + const created = await createOAuthClient(user.id, 'Public', ['https://example.com/callback'], ['trips:read'], null, { isPublic: true }); const clientId = (created.client as { client_id: string }).client_id; const tokens = issueTokens(clientId, user.id, ['trips:read']); - const result = refreshTokens(tokens.refresh_token, clientId, undefined); + const result = await refreshTokens(tokens.refresh_token, clientId, undefined); expect(result.error).toBeUndefined(); expect(result.tokens?.access_token).toMatch(/^trekoa_/); }); - it('authenticateClient identifies a public client by id alone and rejects a missing secret otherwise', () => { + it('authenticateClient identifies a public client by id alone and rejects a missing secret otherwise', async () => { const { user } = createUser(testDb); - const pub = createOAuthClient(user.id, 'Public', ['https://example.com/callback'], ['trips:read'], null, { isPublic: true }); + const pub = await createOAuthClient(user.id, 'Public', ['https://example.com/callback'], ['trips:read'], null, { isPublic: true }); const pubId = (pub.client as { client_id: string }).client_id; - const conf = makeClient(user.id, { name: 'Confidential' }); + const conf = await makeClient(user.id, { name: 'Confidential' }); const confId = (conf.client as { client_id: string }).client_id; expect(authenticateClient(pubId, undefined)?.client_id).toBe(pubId); expect(authenticateClient(confId, undefined)).toBeNull(); }); - it('validateAuthorizeRequest rejects a resource that is not the MCP endpoint', () => { + it('validateAuthorizeRequest rejects a resource that is not the MCP endpoint', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = (created.client as { client_id: string }).client_id; const { challenge } = makePkce(); - const result = validateAuthorizeRequest({ + const result = await validateAuthorizeRequest({ response_type: 'code', client_id: clientId, redirect_uri: 'https://example.com/callback', @@ -1243,14 +1243,14 @@ describe('branches the legacy suite could not reach', () => { expect(result).toEqual({ valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' }); }); - it('validateAuthorizeRequest accepts the MCP endpoint passed explicitly, trailing slash and all', () => { + it('validateAuthorizeRequest accepts the MCP endpoint passed explicitly, trailing slash and all', async () => { const { user } = createUser(testDb); - const created = makeClient(user.id); + const created = await makeClient(user.id); const clientId = (created.client as { client_id: string }).client_id; const { challenge } = makePkce(); const mcpResource = getMcpSafeUrl().replace(/\/+$/, '') + '/mcp'; - const result = validateAuthorizeRequest({ + const result = await validateAuthorizeRequest({ response_type: 'code', client_id: clientId, redirect_uri: 'https://example.com/callback', diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index e463bcdee..2bd34e547 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -53,9 +53,9 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou }); describe('POST / (create)', () => { - it('400 on an over-long name (length guard before permission)', () => { + it('400 on an over-long name (length guard before permission)', async () => { const canEdit = vi.fn().mockReturnValue(false); // would 403 if reached - expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).create(user, '5', { name: 'x'.repeat(201) }))).toEqual({ + expect(await thrownAsync(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).create(user, '5', { name: 'x'.repeat(201) }))).toEqual({ status: 400, body: { error: 'name must be 200 characters or less' }, }); expect(canEdit).not.toHaveBeenCalled(); @@ -65,7 +65,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou // pins `name`, so the ZodValidationPipe rejects a nameless body before the // handler runs (see the e2e suite for the envelope it produces). it('403 without place_edit, then creates + hooks', async () => { - expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) }), new RuntimeEnvService(), storageStub).create(user, '5', { name: 'Spot' }))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(await thrownAsync(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) }), new RuntimeEnvService(), storageStub).create(user, '5', { name: 'Spot' }))).toEqual({ status: 403, body: { error: 'No permission' } }); const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); const onCreated = vi.fn(); const s = svc({ create, broadcast, onCreated } as Partial); expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).create(user, '5', { name: 'Spot' }, 'sock')).toEqual({ place: { id: 9 } }); @@ -76,16 +76,16 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou describe('POST /import/gpx', () => { const file = { buffer: Buffer.from('gpx'), originalname: 'r.gpx' } as Express.Multer.File; - it('400 without a file', () => { - expect(thrown(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).importGpx(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); + it('400 without a file', async () => { + expect(await thrownAsync(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).importGpx(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); }); - it('400 when all import types are disabled', () => { - expect(thrown(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).importGpx(user, '5', file, { importWaypoints: 'false', importRoutes: 'false', importTracks: 'false' }))).toEqual({ + it('400 when all import types are disabled', async () => { + expect(await thrownAsync(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).importGpx(user, '5', file, { importWaypoints: 'false', importRoutes: 'false', importTracks: 'false' }))).toEqual({ status: 400, body: { error: 'No import types selected' }, }); }); - it('400 when the GPX yields nothing', () => { - expect(thrown(() => new PlacesController(svc({ importGpx: vi.fn().mockReturnValue(null) } as Partial), new RuntimeEnvService(), storageStub).importGpx(user, '5', file, {}))).toEqual({ + it('400 when the GPX yields nothing', async () => { + expect(await thrownAsync(() => new PlacesController(svc({ importGpx: vi.fn().mockReturnValue(null) } as Partial), new RuntimeEnvService(), storageStub).importGpx(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'No matching places found in GPX file' }, }); }); @@ -346,7 +346,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou expect(await thrownAsync(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).update(user, '5', '9', { route_color: 'red' }))).toEqual(err); expect(await thrownAsync(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).update(user, '5', '9', { route_color: '#12345' }))).toEqual(err); expect(await thrownAsync(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).update(user, '5', '9', { route_color: 123 }))).toEqual(err); - expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).create(user, '5', { name: 'T', route_color: 'red' }))).toEqual(err); + expect(await thrownAsync(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).create(user, '5', { name: 'T', route_color: 'red' }))).toEqual(err); expect(canEdit).not.toHaveBeenCalled(); }); @@ -391,7 +391,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou ]) { expect(await thrownAsync(() => ctl({ canEdit }).update(user, '5', '9', { image_url }))).toEqual(imageErr); } - expect(thrown(() => ctl({ canEdit }).create(user, '5', { name: 'T', image_url: 'javascript:alert(1)' }))).toEqual(imageErr); + expect(await thrownAsync(() => ctl({ canEdit }).create(user, '5', { name: 'T', image_url: 'javascript:alert(1)' }))).toEqual(imageErr); expect(canEdit).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/public-api-scopes.test.ts b/server/tests/unit/nest/public-api-scopes.test.ts index e8faa147f..51a9b8c25 100644 --- a/server/tests/unit/nest/public-api-scopes.test.ts +++ b/server/tests/unit/nest/public-api-scopes.test.ts @@ -130,6 +130,17 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { throw new Error('expected the handler to refuse'); } +async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { + try { + await fn(); + } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected the handler to refuse'); +} + function apiController(svc: Partial) { const rl = { check: vi.fn().mockReturnValue(true) } as unknown as RateLimitService; return new PublicApiController(svc as PublicApiService, rl); @@ -291,11 +302,11 @@ describe('PublicApiController — what a narrowed key reaches', () => { expect(listBucketList).not.toHaveBeenCalled(); }); - it('PUBAPI-SCOPE-U023: a key without `trips` cannot reach the list or a single trip', () => { + it('PUBAPI-SCOPE-U023: a key without `trips` cannot reach the list or a single trip', async () => { const listTrips = vi.fn(); const getTrip = vi.fn(); const ctl = apiController({ listTrips, getTrip }); - expect(thrown(() => ctl.listTrips(req(limited('bucket-list')))).status).toBe(403); + expect((await thrownAsync(() => ctl.listTrips(req(limited('bucket-list'))))).status).toBe(403); expect(thrown(() => ctl.getTrip(req(limited('bucket-list')), '12', undefined)).status).toBe(403); expect(listTrips).not.toHaveBeenCalled(); expect(getTrip).not.toHaveBeenCalled(); @@ -352,10 +363,10 @@ describe('PublicApiController — what a narrowed key reaches', () => { }); describe('PublicStatsController — the widest answer on the surface', () => { - it('PUBAPI-SCOPE-U030: a key without `stats` is refused, in the module next door too', () => { + it('PUBAPI-SCOPE-U030: a key without `stats` is refused, in the module next door too', async () => { // The route lives in atlas/ and imports the check from public-api/. If it ever // stops calling it, nothing else in that module would notice. - const res = thrown(() => statsController().stats(req(limited('trips')))); + const res = await thrownAsync(() => statsController().stats(req(limited('trips')))); expect(res).toEqual({ status: 403, body: { @@ -366,15 +377,15 @@ describe('PublicStatsController — the widest answer on the surface', () => { }); }); - it('PUBAPI-SCOPE-U031: a key that was granted stats still gets its numbers', () => { - expect(statsController().stats(req(limited('stats')))).toMatchObject({ + it('PUBAPI-SCOPE-U031: a key that was granted stats still gets its numbers', async () => { + expect(await statsController().stats(req(limited('stats')))).toMatchObject({ total_trips: 1, total_countries: 1, }); }); - it('PUBAPI-SCOPE-U032: an un-narrowed key reads stats, as every key did before', () => { - expect(statsController().stats(req(ALL))).toMatchObject({ total_trips: 1 }); + it('PUBAPI-SCOPE-U032: an un-narrowed key reads stats, as every key did before', async () => { + expect(await statsController().stats(req(ALL))).toMatchObject({ total_trips: 1 }); }); }); diff --git a/server/tests/unit/nest/share.controller.test.ts b/server/tests/unit/nest/share.controller.test.ts index 3d52b19cf..e86e9a87a 100644 --- a/server/tests/unit/nest/share.controller.test.ts +++ b/server/tests/unit/nest/share.controller.test.ts @@ -27,8 +27,8 @@ function res() { return r as unknown as Response & { statusCode: number }; } -function thrown(fn: () => unknown): { status: number; body: unknown } { - try { fn(); } catch (err) { +async function thrown(fn: () => Promise): Promise<{ status: number; body: unknown }> { + try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; @@ -38,10 +38,10 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { beforeEach(() => vi.clearAllMocks()); -describe('TripShareController', () => { - it('POST 404 without access, 403 without share_manage', () => { - expect(thrown(() => new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).create(user, '5', {}, res()))).toEqual({ status: 404, body: { error: 'Trip not found' } }); - expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).create(user, '5', {}, res()))).toEqual({ status: 403, body: { error: 'No permission' } }); +describe('TripShareController', async () => { + it('POST 404 without access, 403 without share_manage', async () => { + expect(await thrown(() => new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).create(user, '5', {}, res()))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).create(user, '5', {}, res()))).toEqual({ status: 403, body: { error: 'No permission' } }); }); it('POST answers 201 on create, 200 on update', async () => { @@ -57,18 +57,18 @@ describe('TripShareController', () => { }); it('GET 404 without access, 403 without share_manage, returns info or a null token', async () => { - expect(thrown(() => new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).get(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await thrown(() => new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).get(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); // Reading returns the token itself, so it needs the same permission as // creating it — a member with plain trip access must not get a copy. const get = vi.fn(); - expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false), get } as Partial)).get(user, '5'))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(await thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false), get } as Partial)).get(user, '5'))).toEqual({ status: 403, body: { error: 'No permission' } }); expect(get).not.toHaveBeenCalled(); expect(await new TripShareController(svc({ get: vi.fn().mockReturnValue({ token: 't' }) } as Partial)).get(user, '5')).toEqual({ token: 't' }); expect(await new TripShareController(svc({ get: vi.fn().mockReturnValue(null) } as Partial)).get(user, '5')).toEqual({ token: null }); }); it('DELETE 403 without share_manage, else removes', async () => { - expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).remove(user, '5'))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(await thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).remove(user, '5'))).toEqual({ status: 403, body: { error: 'No permission' } }); const remove = vi.fn(); expect(await new TripShareController(svc({ remove } as Partial)).remove(user, '5')).toEqual({ success: true }); expect(remove).toHaveBeenCalledWith('5'); @@ -77,7 +77,7 @@ describe('TripShareController', () => { describe('SharedController', () => { it('404 for an invalid token, else returns the snapshot', async () => { - expect(thrown(() => new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue(null) } as Partial), storageStub).read('bad'))).toEqual({ status: 404, body: { error: 'Invalid or expired link' } }); + expect(await thrown(() => new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue(null) } as Partial), storageStub).read('bad'))).toEqual({ status: 404, body: { error: 'Invalid or expired link' } }); expect(await new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue({ trip: { id: 9 } }) } as Partial), storageStub).read('tok')).toEqual({ trip: { id: 9 } }); }); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index b8797af8a..cf858aa93 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -78,7 +78,7 @@ async function thrownAsync(fn: () => Promise): Promise<{ status: number beforeEach(() => vi.clearAllMocks()); -describe('TripsController (parity with the legacy /api/trips route)', () => { +describe('TripsController (parity with the legacy /api/trips route)', async () => { it('GET / lists for the user with the archived flag', () => { const list = vi.fn().mockReturnValue([{ id: 1 }]); expect(tc(svc({ list } as Partial)).list(user, '1')).toEqual({ trips: [{ id: 1 }] }); @@ -148,8 +148,8 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { }); describe('POST / (create)', () => { - it('403 without trip_create; a missing title 400s in the ZodValidationPipe', () => { - expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); + it('403 without trip_create; a missing title 400s in the ZodValidationPipe', async () => { + expect(await thrownAsync(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); // The hand-rolled 'Title is required' 400 moved into the global pipe // (trips DTO ratchet) — the schema rejects a missing/empty title. expect(tripCreateRequestSchema.safeParse({}).success).toBe(false); @@ -162,8 +162,8 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07' })); }); - it('400 when end_date precedes start_date', () => { - expect(thrown(() => tc(svc()).create(user, { title: 'T', start_date: '2026-07-10', end_date: '2026-07-01' }, req))).toEqual({ + it('400 when end_date precedes start_date', async () => { + expect(await thrownAsync(() => tc(svc()).create(user, { title: 'T', start_date: '2026-07-10', end_date: '2026-07-01' }, req))).toEqual({ status: 400, body: { error: 'End date must be after start date' }, }); }); @@ -186,9 +186,9 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: MAX_TRIP_DAYS })); }); - it('maps a ValidationError from create to 400 (range past MAX_TRIP_DAYS)', () => { + it('maps a ValidationError from create to 400 (range past MAX_TRIP_DAYS)', async () => { const create = vi.fn().mockImplementation(() => { throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`); }); - expect(thrown(() => tc(svc({ create } as Partial)).create(user, { title: 'T', start_date: '2026-01-01', end_date: '2036-01-01' }, req))).toEqual({ + expect(await thrownAsync(() => tc(svc({ create } as Partial)).create(user, { title: 'T', start_date: '2026-01-01', end_date: '2036-01-01' }, req))).toEqual({ status: 400, body: { error: `A trip can span at most ${MAX_TRIP_DAYS} days` }, }); }); @@ -280,9 +280,9 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { }); describe('POST /:id/copy', () => { - it('403 without trip_create, 404 without access', () => { - expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', {}, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); - expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + it('403 without trip_create, 404 without access', async () => { + expect(await thrownAsync(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', {}, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } }); + expect(await thrownAsync(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); }); it('copies + returns the new trip', async () => { @@ -292,15 +292,15 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { }); describe('DELETE /:id', () => { - it('404 when no owner, 403 without trip_delete', () => { - expect(thrown(() => tc(svc({ getOwner: vi.fn().mockReturnValue(undefined) } as Partial)).remove(user, '9', req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + it('404 when no owner, 403 without trip_delete', async () => { + expect(await thrownAsync(() => tc(svc({ getOwner: vi.fn().mockReturnValue(undefined) } as Partial)).remove(user, '9', req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 1 }), can: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => tc(s).remove(user, '9', req))).toEqual({ status: 403, body: { error: 'No permission to delete this trip' } }); + expect(await thrownAsync(() => tc(s).remove(user, '9', req))).toEqual({ status: 403, body: { error: 'No permission to delete this trip' } }); }); - it('404s for someone with no access, so the 403 is not an existence oracle', () => { + it('404s for someone with no access, so the 403 is not an existence oracle', async () => { const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 2 }), canAccessTrip: vi.fn().mockReturnValue(null), remove: vi.fn() } as Partial); - expect(thrown(() => tc(s).remove(user, '9', req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await thrownAsync(() => tc(s).remove(user, '9', req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); expect(s.remove).not.toHaveBeenCalled(); }); @@ -422,8 +422,8 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { }); }); - it('POST /:id/copy maps a copy failure to 500', () => { + it('POST /:id/copy maps a copy failure to 500', async () => { const s = svc({ copy: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial); - expect(thrown(() => tc(s).copy(user, '9', {}, req))).toEqual({ status: 500, body: { error: 'Failed to copy trip' } }); + expect(await thrownAsync(() => tc(s).copy(user, '9', {}, req))).toEqual({ status: 500, body: { error: 'Failed to copy trip' } }); }); }); diff --git a/server/tests/unit/nest/unified-memories.service.test.ts b/server/tests/unit/nest/unified-memories.service.test.ts index e0d4fe99c..c6fd861c2 100644 --- a/server/tests/unit/nest/unified-memories.service.test.ts +++ b/server/tests/unit/nest/unified-memories.service.test.ts @@ -94,32 +94,32 @@ afterAll(() => { // ── listTripPhotos ──────────────────────────────────────────────────────────── describe('listTripPhotos', () => { - it('MEM-UNIFIED-001: returns 404 when user cannot access trip', () => { - const result = listTripPhotos('9999', 1); + it('MEM-UNIFIED-001: returns 404 when user cannot access trip', async () => { + const result = await listTripPhotos('9999', 1); expect(result.success).toBe(false); expect((result as any).error.status).toBe(404); }); - it('MEM-UNIFIED-002: returns 400 when no photo providers are enabled', () => { + it('MEM-UNIFIED-002: returns 400 when no photo providers are enabled', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // Disable all providers testDb.prepare('UPDATE photo_providers SET enabled = 0').run(); - const result = listTripPhotos(String(trip.id), user.id); + const result = await listTripPhotos(String(trip.id), user.id); expect(result.success).toBe(false); expect((result as any).error.status).toBe(400); expect((result as any).error.message).toMatch(/no photo providers enabled/i); }); - it('MEM-UNIFIED-013: treats enabled providers as disabled while the journey addon is off', () => { + it('MEM-UNIFIED-013: treats enabled providers as disabled while the journey addon is off', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setAddonEnabled(testDb, ADDON_IDS.JOURNEY, false); - const result = listTripPhotos(String(trip.id), user.id); + const result = await listTripPhotos(String(trip.id), user.id); expect(result.success).toBe(false); expect((result as any).error.status).toBe(400); expect((result as any).error.message).toMatch(/no photo providers enabled/i); @@ -129,19 +129,19 @@ describe('listTripPhotos', () => { // ── listTripAlbumLinks ──────────────────────────────────────────────────────── describe('listTripAlbumLinks', () => { - it('MEM-UNIFIED-003: returns 404 when user cannot access trip', () => { - const result = listTripAlbumLinks('9999', 1); + it('MEM-UNIFIED-003: returns 404 when user cannot access trip', async () => { + const result = await listTripAlbumLinks('9999', 1); expect(result.success).toBe(false); expect((result as any).error.status).toBe(404); }); - it('MEM-UNIFIED-004: returns 400 when no photo providers are enabled', () => { + it('MEM-UNIFIED-004: returns 400 when no photo providers are enabled', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); testDb.prepare('UPDATE photo_providers SET enabled = 0').run(); - const result = listTripAlbumLinks(String(trip.id), user.id); + const result = await listTripAlbumLinks(String(trip.id), user.id); expect(result.success).toBe(false); expect((result as any).error.status).toBe(400); }); @@ -219,13 +219,13 @@ describe('removeTripPhoto', () => { // ── createTripAlbumLink ─────────────────────────────────────────────────────── describe('createTripAlbumLink', () => { - it('MEM-UNIFIED-010: returns 404 when user cannot access trip', () => { - const result = createTripAlbumLink('9999', 1, 'immich', 'album-1', 'My Album'); + it('MEM-UNIFIED-010: returns 404 when user cannot access trip', async () => { + const result = await createTripAlbumLink('9999', 1, 'immich', 'album-1', 'My Album'); expect(result.success).toBe(false); expect((result as any).error.status).toBe(404); }); - it('MEM-UNIFIED-011: returns 400 when provider is disabled', () => { + it('MEM-UNIFIED-011: returns 400 when provider is disabled', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -233,7 +233,7 @@ describe('createTripAlbumLink', () => { 'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)' ).run('disabled-prov2', 'Disabled2', 'desc', 'Image', 0, 100); - const result = createTripAlbumLink(String(trip.id), user.id, 'disabled-prov2', 'album-1', 'My Album'); + const result = await createTripAlbumLink(String(trip.id), user.id, 'disabled-prov2', 'album-1', 'My Album'); expect(result.success).toBe(false); expect((result as any).error.status).toBe(400); });