diff --git a/apps/api/tests/routes/admin.test.ts b/apps/api/tests/routes/admin.test.ts index 9c4bfd9..9bd1062 100644 --- a/apps/api/tests/routes/admin.test.ts +++ b/apps/api/tests/routes/admin.test.ts @@ -1071,3 +1071,182 @@ describe('POST /api/admin/members', () => { expect(parsed.error).toBe('Invalid request'); }); }); + +// =========================================================================== +// PATCH /api/admin/members/:id — member-profile update + last-admin guard (Plan 20-01) +// =========================================================================== + +describe('PATCH /api/admin/members/:id', () => { + // Test A: happy path — update displayName only + it('Test A (happy path displayName): PATCH with { displayName } as admin returns 200; GET reflects new name', async () => { + const adminId = await seedUser('admin-patch-name', true); + const memberId = await seedUser('member-patch-target', false); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PATCH', `/api/admin/members/${memberId}`, { displayName: 'New Name' }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + + // GET /members should reflect the updated displayName + const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members')); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { members: Array<{ id: number; displayName: string }> }; + const updated = getBody.members.find((m) => m.id === memberId); + expect(updated).toBeDefined(); + expect(updated!.displayName).toBe('New Name'); + }); + + // Test B: happy path — promote non-admin to admin + it('Test B (happy path isAdmin promote): PATCH with { isAdmin: true } returns 200; GET shows isAdmin true', async () => { + const adminId = await seedUser('admin-patch-promote', true); + const memberId = await seedUser('member-patch-promote', false); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PATCH', `/api/admin/members/${memberId}`, { isAdmin: true }), + ); + expect(res.status).toBe(200); + + const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members')); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { + members: Array<{ id: number; isAdmin: boolean }>; + }; + const promoted = getBody.members.find((m) => m.id === memberId); + expect(promoted).toBeDefined(); + expect(promoted!.isAdmin).toBe(true); + }); + + // Test C: last-admin guard — only admin cannot demote themselves + it('Test C (last-admin guard): with exactly one admin, PATCH { isAdmin: false } returns 409; member stays admin', async () => { + const adminId = await seedUser('admin-last-admin', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PATCH', `/api/admin/members/${adminId}`, { isAdmin: false }), + ); + expect(res.status).toBe(409); + const body = (await res.json()) as { error: string }; + expect(typeof body.error).toBe('string'); + expect(body.error.length).toBeGreaterThan(0); + + // The admin flag must still be true after the rejected demotion + const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members')); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { + members: Array<{ id: number; isAdmin: boolean }>; + }; + const adminRow = getBody.members.find((m) => m.id === adminId); + expect(adminRow).toBeDefined(); + expect(adminRow!.isAdmin).toBe(true); + }); + + // Test D: self-demotion allowed when another admin exists + it('Test D (self-demotion allowed): with two admins, PATCH { isAdmin: false } returns 200; one admin remains', async () => { + const adminId1 = await seedUser('admin-demote-1', true); + const adminId2 = await seedUser('admin-demote-2', true); + currentDevUserId = adminId1; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PATCH', `/api/admin/members/${adminId1}`, { isAdmin: false }), + ); + expect(res.status).toBe(200); + + // Only adminId2 should remain as admin + const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members')); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { + members: Array<{ id: number; isAdmin: boolean }>; + }; + const row1 = getBody.members.find((m) => m.id === adminId1); + const row2 = getBody.members.find((m) => m.id === adminId2); + expect(row1!.isAdmin).toBe(false); + expect(row2!.isAdmin).toBe(true); + }); + + // Test E: auth boundary — non-admin gets 403 + it('Test E (auth boundary): non-admin PATCH returns 403', async () => { + const adminId = await seedUser('admin-patch-auth', true); + const nonAdminId = await seedUser('non-admin-patch', false); + currentDevUserId = nonAdminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PATCH', `/api/admin/members/${adminId}`, { displayName: 'Hacked' }), + ); + expect(res.status).toBe(403); + }); + + // Test F: validation — wrong type and malformed id + it('Test F (validation): PATCH with { isAdmin: "yes" } returns 400 { error: "Invalid request" }', async () => { + const adminId = await seedUser('admin-patch-validation', true); + const memberId = await seedUser('member-patch-validation', false); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PATCH', `/api/admin/members/${memberId}`, { isAdmin: 'yes' }), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Invalid request'); + }); + + it('Test F (malformed id): PATCH with malformed :id (e.g. "1abc") returns 400', async () => { + const adminId = await seedUser('admin-patch-badid', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PATCH', '/api/admin/members/1abc', { displayName: 'Test' }), + ); + expect(res.status).toBe(400); + }); + + // Test G: not found — non-existent member id + it('Test G (not found): PATCH non-existent member id returns 404', async () => { + const adminId = await seedUser('admin-patch-notfound', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PATCH', '/api/admin/members/99999999', { displayName: 'Ghost' }), + ); + expect(res.status).toBe(404); + }); +}); + +// =========================================================================== +// GET /api/admin/members — isAdmin field (Plan 20-01) +// =========================================================================== + +describe('GET /api/admin/members — isAdmin field', () => { + it('Test H (GET isAdmin field): each member object includes a boolean isAdmin field', async () => { + const adminId = await seedUser('admin-isadmin-field', true); + const memberId = await seedUser('member-isadmin-field', 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: Array<{ id: number; isAdmin: boolean }>; + }; + // Both seeded users should have a boolean isAdmin field + const adminRow = body.members.find((m) => m.id === adminId); + const memberRow = body.members.find((m) => m.id === memberId); + expect(adminRow).toBeDefined(); + expect(typeof adminRow!.isAdmin).toBe('boolean'); + expect(adminRow!.isAdmin).toBe(true); + expect(memberRow).toBeDefined(); + expect(typeof memberRow!.isAdmin).toBe('boolean'); + expect(memberRow!.isAdmin).toBe(false); + }); +});