mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
test(server): suites await the methods Task 1's forced edits made async
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<unknown>): 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<AdminService>)).listUsers()).toEqual({ users: [{ id: 1 }] });
|
||||
expect(thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AdminService>)).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<AdminService>)).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<AdminService>));
|
||||
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<AdminService>)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } });
|
||||
expect(thrown(() => adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial<AdminService>)).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<AdminService>)).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<AdminService>)).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<AdminService>)).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<AdminService>)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } });
|
||||
expect(await adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial<AdminService>)).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<AdminService>)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } });
|
||||
expect(await thrown(() => adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial<AdminService>)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } });
|
||||
expect(await adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).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<AdminService>)).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<AdminService>)).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<unknown>, 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 } }));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).resetPassword(anyBody(), req)).toEqual({ mfa_required: true });
|
||||
expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).changePassword(user, anyBody(), req, res))).toEqual({ status: 400, body: { error: 'Wrong' } });
|
||||
expect(await ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } });
|
||||
expect(await ac(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial<AuthService>), 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<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
|
||||
expect(ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
|
||||
expect(thrown(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial<AuthService>), 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<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
|
||||
expect(await ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), 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<AuthService>), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } });
|
||||
const ok = ac(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial<AuthService>), rl());
|
||||
expect(await ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' }));
|
||||
|
||||
@@ -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<unknown>): Promise<AcceptError> {
|
||||
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');
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<PlacesService>);
|
||||
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<PlacesService>), 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<PlacesService>), 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<unknown>): 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<PublicApiService>) {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<unknown>): 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<ShareService>)).get(user, '5'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(await thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false), get } as Partial<ShareService>)).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<ShareService>)).get(user, '5')).toEqual({ token: 't' });
|
||||
expect(await new TripShareController(svc({ get: vi.fn().mockReturnValue(null) } as Partial<ShareService>)).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<ShareService>)).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<ShareService>), 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<ShareService>), 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<ShareService>), storageStub).read('tok')).toEqual({ trip: { id: 9 } });
|
||||
});
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ async function thrownAsync(fn: () => Promise<unknown>): 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<TripsService>)).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<TripsService>)).create(user, { title: 'T', start_date: '2026-01-01', end_date: '2036-01-01' }, req))).toEqual({
|
||||
expect(await thrownAsync(() => tc(svc({ create } as Partial<TripsService>)).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<TripsService>)).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<TripsService>)).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<TripsService>);
|
||||
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<TripsService>);
|
||||
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<TripsService>);
|
||||
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' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user