diff --git a/apps/api/tests/routes/admin.test.ts b/apps/api/tests/routes/admin.test.ts index 81d5248..979e92e 100644 --- a/apps/api/tests/routes/admin.test.ts +++ b/apps/api/tests/routes/admin.test.ts @@ -30,7 +30,7 @@ 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'; +import { users, memberCredentials, calendars, appConfig } from '../../src/db/schema.js'; // --------------------------------------------------------------------------- // CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail. @@ -585,3 +585,171 @@ describe('POST /api/me/credential', () => { expect(res.status).toBe(200); }); }); + +// =========================================================================== +// admin timezone config (Plan 18-02: D-01, D-02, D-03, D-04) +// =========================================================================== + +describe('admin timezone config', () => { + // Clean up household_timezone between tests to avoid bleed + afterEach(async () => { + await db.delete(appConfig).where(eq(appConfig.key, 'household_timezone')); + }); + + // ------------------------------------------------------------------------- + // GET /api/admin/config/timezone — access control + // ------------------------------------------------------------------------- + + it('GET returns 403 for a non-admin authenticated user (T-18-03)', async () => { + const nonAdminId = await seedUser('tz-non-admin-get', false); + currentDevUserId = nonAdminId; + const app = await getApp(); + + const res = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone')); + expect(res.status).toBe(403); + }); + + // ------------------------------------------------------------------------- + // PUT /api/admin/config/timezone — access control + // ------------------------------------------------------------------------- + + it('PUT returns 403 for a non-admin authenticated user (T-18-03)', async () => { + const nonAdminId = await seedUser('tz-non-admin-put', false); + currentDevUserId = nonAdminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }), + ); + expect(res.status).toBe(403); + }); + + // ------------------------------------------------------------------------- + // GET /api/admin/config/timezone — admin reads when no row is stored + // ------------------------------------------------------------------------- + + it('GET as admin with no stored row returns 200 with isExplicitlySet: false and a non-empty timezone', async () => { + const adminId = await seedUser('tz-admin-get-default', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone')); + expect(res.status).toBe(200); + const body = (await res.json()) as { timezone: string; isExplicitlySet: boolean }; + expect(typeof body.timezone).toBe('string'); + expect(body.timezone.length).toBeGreaterThan(0); + expect(body.isExplicitlySet).toBe(false); + }); + + // ------------------------------------------------------------------------- + // PUT then GET round-trip + // ------------------------------------------------------------------------- + + it('PUT America/Chicago then GET returns that value with isExplicitlySet: true', async () => { + const adminId = await seedUser('tz-admin-roundtrip', true); + currentDevUserId = adminId; + const app = await getApp(); + + const putRes = await app.fetch( + jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }), + ); + expect(putRes.status).toBe(200); + + const getRes = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone')); + expect(getRes.status).toBe(200); + const body = (await getRes.json()) as { timezone: string; isExplicitlySet: boolean }; + expect(body.timezone).toBe('America/Chicago'); + expect(body.isExplicitlySet).toBe(true); + }); + + // ------------------------------------------------------------------------- + // PUT with UTC must succeed (Pitfall 2) + // ------------------------------------------------------------------------- + + it('PUT UTC returns 200 (Pitfall 2 — UTC must be accepted)', async () => { + const adminId = await seedUser('tz-admin-utc', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'UTC' }), + ); + expect(res.status).toBe(200); + }); + + // ------------------------------------------------------------------------- + // PUT with invalid IANA string returns 400 and writes nothing (T-18-04) + // ------------------------------------------------------------------------- + + it('PUT Not/AZone returns 400 and does not store a value (T-18-04)', async () => { + const adminId = await seedUser('tz-admin-invalid', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'Not/AZone' }), + ); + expect(res.status).toBe(400); + + // Verify no row was written to app_config + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + expect(row).toBeUndefined(); + }); + + // ------------------------------------------------------------------------- + // POST /api/admin/config/timezone/seed — seeds when unset (D-02) + // ------------------------------------------------------------------------- + + it('POST seed when unset stores the value and returns ok (D-02)', async () => { + const adminId = await seedUser('tz-admin-seed-unset', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Europe/London' }), + ); + expect(res.status).toBe(200); + + // Verify the value was stored + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + expect(row?.value).toBe('Europe/London'); + }); + + // ------------------------------------------------------------------------- + // POST /api/admin/config/timezone/seed — no-overwrite when already set (D-03) + // ------------------------------------------------------------------------- + + it('POST seed when already set does NOT overwrite the existing value (D-03)', async () => { + const adminId = await seedUser('tz-admin-seed-overwrite', true); + currentDevUserId = adminId; + const app = await getApp(); + + // First set a value via PUT + const putRes = await app.fetch( + jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }), + ); + expect(putRes.status).toBe(200); + + // Now attempt to seed a different value + const seedRes = await app.fetch( + jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Asia/Tokyo' }), + ); + expect(seedRes.status).toBe(200); + + // The stored value must still be America/Chicago (no overwrite, D-03) + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + expect(row?.value).toBe('America/Chicago'); + }); +});