diff --git a/apps/api/tests/routes/admin.test.ts b/apps/api/tests/routes/admin.test.ts new file mode 100644 index 0000000..f081268 --- /dev/null +++ b/apps/api/tests/routes/admin.test.ts @@ -0,0 +1,566 @@ +/** + * Admin + self-service credential surface — integration tests (Plan 10-03, TDD RED→GREEN). + * + * Covers: + * Task 2 (admin routes): + * - T-10-08: GET /api/admin/members returns 403 for non-admin, 200+list for admin + * - T-10-09 (Pitfall 7): POST /api/admin/credentials with invalid password → 400, + * no submitted password string in response body; same shape for createFastmailClient + * throw and for network/PROPFIND failures + * - T-10-09 (Pitfall 7): malformed/bad-email payload → 400 { error: 'Invalid request' } + * - T-10-11: valid credential → 200, stored encrypted (encrypted_password != plaintext) + * - T-10-08: POST /api/admin/credentials as non-admin → 403 + * - ADMIN-02: PUT /api/admin/calendars/:id/shared → exactly one calendar is_shared=1 + * - GET /api/admin/calendars as admin → 200 list; as non-admin → 403 + * + * Task 3 (self-service): + * - T-10-12 (Pitfall 6): POST /api/me/credential with body userId for another user + * → credential written to session user (currentUserId), NOT the body userId + * - POST /api/me/credential with valid credential → 200, stored encrypted + * - POST /api/me/credential with bad credential → 400 generic, no echoed password + * - POST /api/me/credential does NOT require admin (normal member can use it) + * + * Architecture: + * - Tests import `app` (NOT adminRouter directly — Pitfall 9) + * - Real DB integration: dev MariaDB must be running; set DB_HOST=127.0.0.1 + * - CalDAV (createFastmailClient) is mocked to avoid live Fastmail calls + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { db } from '../../src/db/client.js'; +import { users, memberCredentials, calendars } from '../../src/db/schema.js'; + +// --------------------------------------------------------------------------- +// CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail. +// Tests that need PROPFIND-success inject a mock client with fetchCalendars resolving. +// Tests that need PROPFIND-failure inject a mock client that throws on fetchCalendars. +// Tests that need createFastmailClient itself to throw (e.g. bad email format) throw +// before returning a client at all. +// +// Also mock loadClientForUser and triggerTargetedResync from outboxWorker to avoid +// the initial-sync touching the real DB in tests. +// --------------------------------------------------------------------------- + +type FetchCalendarsResult = { url: string; displayName: string }[]; + +let mockFetchCalendars: () => Promise = () => + Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/user/', displayName: 'Test Cal' }]); +let mockCreateClientShouldThrow = false; +let mockCreateClientError: Error | null = null; + +vi.mock('../../src/broker/client.js', () => ({ + createFastmailClient: vi.fn().mockImplementation(async () => { + if (mockCreateClientShouldThrow) { + throw mockCreateClientError ?? new Error('Mock CalDAV client creation error'); + } + return { fetchCalendars: mockFetchCalendars }; + }), +})); + +vi.mock('../../src/broker/outboxWorker.js', () => ({ + loadClientForUser: vi.fn().mockResolvedValue({ + fetchCalendars: () => + Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/', displayName: 'Test' }]), + }), + triggerTargetedResync: vi.fn().mockResolvedValue(undefined), + startOutboxWorker: vi.fn(), + initOutboxTrigger: vi.fn(), + scheduleOutboxDrain: vi.fn(), + runOutboxDrain: vi.fn(), + __resetDrainState: vi.fn(), + assembleRruleString: vi.fn(), + stopOutboxTrigger: vi.fn(), +})); + +// Mock sync.js to avoid actual CalDAV sync during tests +vi.mock('../../src/broker/sync.js', () => ({ + syncCalendar: vi.fn().mockResolvedValue(undefined), +})); + +// --------------------------------------------------------------------------- +// Dev-bypass mock: allows us to simulate different users in tests. +// currentDevUserId controls which user is "logged in" via the bypass. +// --------------------------------------------------------------------------- + +let currentDevUserId = 1; + +vi.mock('../../src/auth/devBypass.js', () => ({ + devAuthBypass: + () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise) => { + c.set('user', { id: currentDevUserId }); + await next(); + }, +})); + +vi.mock('@hono/oidc-auth', () => ({ + oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), + processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), + getAuth: () => null, +})); + +// --------------------------------------------------------------------------- +// Seed helpers +// --------------------------------------------------------------------------- + +async function seedUser(label: string, isAdmin = false): Promise { + const [result] = await db + .insert(users) + .values({ + oidcIss: 'https://auth.test', + oidcSub: `sub-${label}-${randomUUID()}`, + displayName: `User ${label}`, + color: '#4A90D9', + isAdmin, + }) + .$returningId(); + return result.id; +} + +async function seedCalendar(userId: number, label: string, isShared = false): Promise { + const [result] = await db + .insert(calendars) + .values({ + userId, + url: `https://caldav.fastmail.com/cal/${label}-${randomUUID()}/`, + displayName: `Calendar ${label}`, + isShared, + }) + .$returningId(); + return result.id; +} + +// --------------------------------------------------------------------------- +// Request helpers +// --------------------------------------------------------------------------- + +function jsonRequest(method: string, path: string, body?: unknown): Request { + return new Request(`http://localhost${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +// --------------------------------------------------------------------------- +// Import `app` lazily (after mocks are registered) — Pitfall 9: must import +// `app` not `adminRouter` directly so the route mount + guard are exercised. +// --------------------------------------------------------------------------- + +async function getApp() { + const { app } = await import('../../src/index.js'); + return app; +} + +// --------------------------------------------------------------------------- +// Encryption key required for encryptPassword +// --------------------------------------------------------------------------- + +beforeEach(async () => { + process.env.APP_PASSWORD_ENCRYPTION_KEY = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + // Reset CalDAV mock state to default (success path) + mockCreateClientShouldThrow = false; + mockCreateClientError = null; + mockFetchCalendars = () => + Promise.resolve([{ url: 'https://caldav.fastmail.com/cal/user/', displayName: 'Test Cal' }]); +}); + +afterEach(async () => { + // Clean up seeded users and credentials between tests + await db.delete(memberCredentials); + await db.delete(calendars); + await db.delete(users).where(eq(users.oidcIss, 'https://auth.test')); +}); + +// =========================================================================== +// GET /api/admin/members +// =========================================================================== + +describe('GET /api/admin/members', () => { + it('returns 403 for a non-admin authenticated user (Pitfall 9 / T-10-08)', async () => { + const nonAdminId = await seedUser('non-admin', false); + currentDevUserId = nonAdminId; + const app = await getApp(); + + const res = await app.fetch(jsonRequest('GET', '/api/admin/members')); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body).toEqual({ error: 'Forbidden' }); + }); + + it('returns 200 with member list for an admin user', async () => { + const adminId = await seedUser('admin', true); + const memberId = await seedUser('member', false); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch(jsonRequest('GET', '/api/admin/members')); + expect(res.status).toBe(200); + const body = (await res.json()) as { members: unknown[] }; + expect(Array.isArray(body.members)).toBe(true); + // Should include at least the two seeded users + expect(body.members.length).toBeGreaterThanOrEqual(2); + // Each member should have id, displayName, color, hasCredential + const memberRow = (body.members as Array<{ id: number }>).find((m) => m.id === memberId); + expect(memberRow).toBeDefined(); + expect(typeof (memberRow as { hasCredential: boolean }).hasCredential).toBe('boolean'); + }); +}); + +// =========================================================================== +// POST /api/admin/credentials +// =========================================================================== + +describe('POST /api/admin/credentials', () => { + it('returns 403 for a non-admin user (T-10-08)', async () => { + const nonAdminId = await seedUser('non-admin-cred', false); + const targetId = await seedUser('target', false); + currentDevUserId = nonAdminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/admin/credentials', { + userId: targetId, + providerType: 'caldav', + fastmailEmail: 'user@fastmail.com', + appPassword: 'test-secret-pass', + }), + ); + expect(res.status).toBe(403); + }); + + it('returns 400 with no echoed password when PROPFIND/auth fails (Pitfall 7 / T-10-09)', async () => { + const adminId = await seedUser('admin-cred-fail', true); + const targetId = await seedUser('target-fail', false); + currentDevUserId = adminId; + + // Make fetchCalendars throw (simulates PROPFIND/auth failure) + const submittedPassword = 'super-secret-app-password-12345'; + mockFetchCalendars = () => Promise.reject(new Error('Authentication failed')); + + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/admin/credentials', { + userId: targetId, + providerType: 'caldav', + fastmailEmail: 'user@fastmail.com', + appPassword: submittedPassword, + }), + ); + expect(res.status).toBe(400); + const bodyText = await res.text(); + // Pitfall 7: submitted password MUST NOT appear in any 400 response + expect(bodyText).not.toContain(submittedPassword); + // No Zod error fields + expect(bodyText).not.toContain('received'); + expect(bodyText).not.toContain('issues'); + // Should return generic error shape + const body = JSON.parse(bodyText) as { error: string }; + expect(body.error).toBe('Invalid request'); + }); + + it('returns 400 when createFastmailClient itself throws (bad email / malformed input)', async () => { + const adminId = await seedUser('admin-cred-throw', true); + const targetId = await seedUser('target-throw', false); + currentDevUserId = adminId; + + const submittedPassword = 'bad-email-secret-pass-99999'; + mockCreateClientShouldThrow = true; + mockCreateClientError = new Error('Invalid email format'); + + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/admin/credentials', { + userId: targetId, + providerType: 'caldav', + fastmailEmail: 'user@fastmail.com', + appPassword: submittedPassword, + }), + ); + expect(res.status).toBe(400); + const bodyText = await res.text(); + expect(bodyText).not.toContain(submittedPassword); + const body = JSON.parse(bodyText) as { error: string }; + expect(body.error).toBe('Invalid request'); + }); + + it('returns 400 with no echo for a network error (all failures map to same generic 400)', async () => { + const adminId = await seedUser('admin-network-err', true); + const targetId = await seedUser('target-network', false); + currentDevUserId = adminId; + + const submittedPassword = 'network-error-secret-abc123'; + mockFetchCalendars = () => Promise.reject(new Error('Network connection refused')); + + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/admin/credentials', { + userId: targetId, + providerType: 'caldav', + fastmailEmail: 'user@fastmail.com', + appPassword: submittedPassword, + }), + ); + expect(res.status).toBe(400); + const bodyText = await res.text(); + expect(bodyText).not.toContain(submittedPassword); + const body = JSON.parse(bodyText) as { error: string }; + expect(body.error).toBe('Invalid request'); + }); + + it('returns 400 with no Zod echo when schema validation fails (e.g. missing fields)', async () => { + const adminId = await seedUser('admin-schema-fail', true); + currentDevUserId = adminId; + + const submittedPassword = 'schema-fail-secret-zxcvbn'; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/admin/credentials', { + // Missing userId — schema validation should fail + providerType: 'caldav', + fastmailEmail: 'user@fastmail.com', + appPassword: submittedPassword, + }), + ); + expect(res.status).toBe(400); + const bodyText = await res.text(); + // Pitfall 7: even schema validation failure must not echo the password + expect(bodyText).not.toContain(submittedPassword); + expect(bodyText).not.toContain('received'); + expect(bodyText).not.toContain('issues'); + const body = JSON.parse(bodyText) as { error: string }; + expect(body.error).toBe('Invalid request'); + }); + + it('returns 200 on valid credential and stores encrypted password (T-10-11)', async () => { + const adminId = await seedUser('admin-valid', true); + const targetId = await seedUser('target-valid', false); + currentDevUserId = adminId; + + const plainPassword = 'valid-app-password-abcxyz-9876'; + // Mock fetchCalendars to succeed (default) + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/admin/credentials', { + userId: targetId, + providerType: 'caldav', + fastmailEmail: 'target@fastmail.com', + appPassword: plainPassword, + }), + ); + expect(res.status).toBe(200); + + // Verify stored credential is NOT the plaintext (encrypted) + const [stored] = await db + .select({ encryptedPassword: memberCredentials.encryptedPassword }) + .from(memberCredentials) + .where(eq(memberCredentials.userId, targetId)) + .limit(1); + expect(stored).toBeDefined(); + expect(stored.encryptedPassword).not.toBe(plainPassword); + // Should be the JSON-encoded AES-256-GCM structure + const parsed = JSON.parse(stored.encryptedPassword) as Record; + expect(parsed.iv).toBeDefined(); + expect(parsed.ciphertext).toBeDefined(); + + // Response body must not contain the password + const bodyText = JSON.stringify(await res.clone().json()); + expect(bodyText).not.toContain(plainPassword); + }); +}); + +// =========================================================================== +// GET /api/admin/calendars +// =========================================================================== + +describe('GET /api/admin/calendars', () => { + it('returns 403 for non-admin', async () => { + const nonAdminId = await seedUser('non-admin-cal', false); + currentDevUserId = nonAdminId; + const app = await getApp(); + + const res = await app.fetch(jsonRequest('GET', '/api/admin/calendars')); + expect(res.status).toBe(403); + }); + + it('returns 200 with calendar list for admin', async () => { + const adminId = await seedUser('admin-cal-list', true); + await seedCalendar(adminId, 'personal', false); + await seedCalendar(adminId, 'family', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch(jsonRequest('GET', '/api/admin/calendars')); + expect(res.status).toBe(200); + const body = (await res.json()) as { calendars: unknown[] }; + expect(Array.isArray(body.calendars)).toBe(true); + // At least the two seeded calendars + expect(body.calendars.length).toBeGreaterThanOrEqual(2); + }); +}); + +// =========================================================================== +// PUT /api/admin/calendars/:id/shared (ADMIN-02, Pitfall 7-adjacent) +// =========================================================================== + +describe('PUT /api/admin/calendars/:id/shared', () => { + it('returns 403 for non-admin', async () => { + const nonAdminId = await seedUser('non-admin-shared', false); + currentDevUserId = nonAdminId; + const app = await getApp(); + + const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/1/shared')); + expect(res.status).toBe(403); + }); + + it('sets exactly one calendar to is_shared=1 and clears prior shared calendar (ADMIN-02)', async () => { + const adminId = await seedUser('admin-shared', true); + const calA = await seedCalendar(adminId, 'cal-a', true); // initially shared + const calB = await seedCalendar(adminId, 'cal-b', false); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch(jsonRequest('PUT', `/api/admin/calendars/${calB}/shared`)); + expect(res.status).toBe(200); + + // calA should now be is_shared=false, calB should be is_shared=true + const [rowA] = await db + .select({ isShared: calendars.isShared }) + .from(calendars) + .where(eq(calendars.id, calA)) + .limit(1); + const [rowB] = await db + .select({ isShared: calendars.isShared }) + .from(calendars) + .where(eq(calendars.id, calB)) + .limit(1); + + expect(rowA.isShared).toBe(false); + expect(rowB.isShared).toBe(true); + + // Verify exactly ONE calendar has is_shared=true after the update + const sharedRows = await db + .select({ id: calendars.id }) + .from(calendars) + .where(eq(calendars.isShared, true)); + // Only calB should be shared (of the ones we seeded; other pre-existing rows excluded + // by checking only our seeded IDs) + const sharedIds = sharedRows.map((r) => r.id); + expect(sharedIds).toContain(calB); + expect(sharedIds).not.toContain(calA); + }); +}); + +// =========================================================================== +// POST /api/me/credential (Task 3 — member self-service, D-07, T-10-12) +// =========================================================================== + +describe('POST /api/me/credential', () => { + it('ignores body userId and writes only to session user (Pitfall 6 / T-10-12)', async () => { + const userA = await seedUser('self-service-a', false); + const userB = await seedUser('self-service-b', false); + currentDevUserId = userA; // logged in as userA + + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/me/credential', { + userId: userB, // body says userB — must be IGNORED + providerType: 'caldav', + fastmailEmail: 'usera@fastmail.com', + appPassword: 'self-service-pass-abcxyz', + }), + ); + expect(res.status).toBe(200); + + // userA should have a credential + const [credA] = await db + .select({ id: memberCredentials.id }) + .from(memberCredentials) + .where(eq(memberCredentials.userId, userA)) + .limit(1); + expect(credA).toBeDefined(); + + // userB should NOT have a credential + const [credB] = await db + .select({ id: memberCredentials.id }) + .from(memberCredentials) + .where(eq(memberCredentials.userId, userB)) + .limit(1); + expect(credB).toBeUndefined(); + }); + + it('returns 200 and stores encrypted password for valid credential', async () => { + const userId = await seedUser('self-service-valid', false); + currentDevUserId = userId; + + const plainPassword = 'self-service-valid-pass-qwerty9876'; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/me/credential', { + providerType: 'caldav', + fastmailEmail: 'me@fastmail.com', + appPassword: plainPassword, + }), + ); + expect(res.status).toBe(200); + + const [stored] = await db + .select({ encryptedPassword: memberCredentials.encryptedPassword }) + .from(memberCredentials) + .where(eq(memberCredentials.userId, userId)) + .limit(1); + expect(stored).toBeDefined(); + expect(stored.encryptedPassword).not.toBe(plainPassword); + const parsed = JSON.parse(stored.encryptedPassword) as Record; + expect(parsed.iv).toBeDefined(); + }); + + it('returns 400 with no echoed password when credential validation fails (Pitfall 7)', async () => { + const userId = await seedUser('self-service-fail', false); + currentDevUserId = userId; + + const submittedPassword = 'self-service-bad-pass-xyz9999'; + mockFetchCalendars = () => Promise.reject(new Error('PROPFIND auth failure')); + + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/me/credential', { + providerType: 'caldav', + fastmailEmail: 'me@fastmail.com', + appPassword: submittedPassword, + }), + ); + expect(res.status).toBe(400); + const bodyText = await res.text(); + expect(bodyText).not.toContain(submittedPassword); + const body = JSON.parse(bodyText) as { error: string }; + expect(body.error).toBe('Invalid request'); + }); + + it('does NOT require admin — a normal member can set their own credential', async () => { + const userId = await seedUser('non-admin-self-service', false); + currentDevUserId = userId; + + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/me/credential', { + providerType: 'caldav', + fastmailEmail: 'member@fastmail.com', + appPassword: 'member-pass-abcabc123', + }), + ); + // Should succeed (200) — no admin requirement on this endpoint + expect(res.status).toBe(200); + }); +});