|
| 1 | +/** |
| 2 | + * Tests for POST /api/people/:slug/account-level (administrator-only). |
| 3 | + * |
| 4 | + * Spec: specs/api/people.md → POST /api/people/:slug/account-level |
| 5 | + * |
| 6 | + * Covers: |
| 7 | + * - anonymous → 401 |
| 8 | + * - regular user caller → 403 |
| 9 | + * - staff (non-admin) caller → 403 |
| 10 | + * - invalid level → 422 (schema validation) |
| 11 | + * - admin promotes user → 200, level reflected |
| 12 | + * - admin demotes staff → 200 |
| 13 | + * - idempotent no-op (same level) → 200 |
| 14 | + * - last-administrator self-demotion → 422 |
| 15 | + * - demoting an admin while another admin exists → 200 |
| 16 | + * - audit trail: commit carries Action + Previous/New-Account-Level trailers |
| 17 | + */ |
| 18 | +import { execFileSync } from 'node:child_process'; |
| 19 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 20 | +import type { FastifyInstance } from 'fastify'; |
| 21 | + |
| 22 | +import { buildApp } from '../src/app.js'; |
| 23 | +import { mintSessionFor } from '../src/auth/issue.js'; |
| 24 | +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; |
| 25 | +import { seedRawToml } from './helpers/seed-fixtures.js'; |
| 26 | + |
| 27 | +const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; |
| 28 | + |
| 29 | +const IDS = { |
| 30 | + alice: '01951a3d-0000-7000-8000-a0000000cafe', |
| 31 | + bob: '01951a3d-0000-7000-8000-b0000000cafe', |
| 32 | + staff: '01951a3d-0000-7000-8000-c0000000cafe', |
| 33 | + admin: '01951a3d-0000-7000-8000-d0000000cafe', |
| 34 | +}; |
| 35 | + |
| 36 | +async function mintCookies( |
| 37 | + personId: string, |
| 38 | + level: 'user' | 'staff' | 'administrator' = 'user', |
| 39 | +): Promise<string> { |
| 40 | + const { accessToken } = await mintSessionFor(personId, level, JWT_KEY); |
| 41 | + return `cfp_session=${accessToken}`; |
| 42 | +} |
| 43 | + |
| 44 | +interface PersonDetailResponse { |
| 45 | + success: boolean; |
| 46 | + data: { slug: string; accountLevel?: string }; |
| 47 | +} |
| 48 | + |
| 49 | +describe('POST /api/people/:slug/account-level', () => { |
| 50 | + let dataRepo: { path: string; cleanup: () => Promise<void> }; |
| 51 | + let privateStore: { path: string; cleanup: () => Promise<void> }; |
| 52 | + let app: FastifyInstance; |
| 53 | + |
| 54 | + beforeAll(async () => { |
| 55 | + dataRepo = await createFullDataRepo(); |
| 56 | + privateStore = await createPrivateStorageDir(); |
| 57 | + |
| 58 | + for (const [slug, id, level] of [ |
| 59 | + ['alice', IDS.alice, 'user'], |
| 60 | + ['bob', IDS.bob, 'user'], |
| 61 | + ['staff-user', IDS.staff, 'staff'], |
| 62 | + ['admin-user', IDS.admin, 'administrator'], |
| 63 | + ] as const) { |
| 64 | + const toml = [ |
| 65 | + `id = "${id}"`, |
| 66 | + `slug = "${slug}"`, |
| 67 | + `fullName = "Test ${slug}"`, |
| 68 | + `accountLevel = "${level}"`, |
| 69 | + `createdAt = "2026-05-01T00:00:00Z"`, |
| 70 | + `updatedAt = "2026-05-01T00:00:00Z"`, |
| 71 | + ].join('\n'); |
| 72 | + await seedRawToml(dataRepo.path, `people/${slug}.toml`, toml, `seed ${slug}`); |
| 73 | + } |
| 74 | + |
| 75 | + app = await buildApp({ |
| 76 | + serverOptions: { logger: false }, |
| 77 | + overrideEnv: { |
| 78 | + CFP_DATA_REPO_PATH: dataRepo.path, |
| 79 | + STORAGE_BACKEND: 'filesystem', |
| 80 | + CFP_PRIVATE_STORAGE_PATH: privateStore.path, |
| 81 | + CFP_JWT_SIGNING_KEY: JWT_KEY, |
| 82 | + NODE_ENV: 'test', |
| 83 | + }, |
| 84 | + }); |
| 85 | + }, 60_000); |
| 86 | + |
| 87 | + afterAll(async () => { |
| 88 | + await app.close(); |
| 89 | + await dataRepo.cleanup(); |
| 90 | + await privateStore.cleanup(); |
| 91 | + }); |
| 92 | + |
| 93 | + const post = (slug: string, body: unknown, cookie?: string) => |
| 94 | + app.inject({ |
| 95 | + method: 'POST', |
| 96 | + url: `/api/people/${slug}/account-level`, |
| 97 | + headers: cookie ? { cookie } : {}, |
| 98 | + payload: body as object, |
| 99 | + }); |
| 100 | + |
| 101 | + it('anonymous → 401', async () => { |
| 102 | + const res = await post('alice', { level: 'staff' }); |
| 103 | + expect(res.statusCode).toBe(401); |
| 104 | + }); |
| 105 | + |
| 106 | + it('regular user caller → 403', async () => { |
| 107 | + const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.bob)); |
| 108 | + expect(res.statusCode).toBe(403); |
| 109 | + }); |
| 110 | + |
| 111 | + it('staff (non-admin) caller → 403', async () => { |
| 112 | + const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.staff, 'staff')); |
| 113 | + expect(res.statusCode).toBe(403); |
| 114 | + }); |
| 115 | + |
| 116 | + it('invalid level → 422 (schema validation)', async () => { |
| 117 | + const res = await post('alice', { level: 'superuser' }, await mintCookies(IDS.admin, 'administrator')); |
| 118 | + expect(res.statusCode).toBe(422); |
| 119 | + }); |
| 120 | + |
| 121 | + it('admin promotes a user to staff → 200, level reflected', async () => { |
| 122 | + const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.admin, 'administrator')); |
| 123 | + expect(res.statusCode).toBe(200); |
| 124 | + const body = res.json<PersonDetailResponse>(); |
| 125 | + expect(body.success).toBe(true); |
| 126 | + // accountLevel is visible to the admin (staff-level) caller. |
| 127 | + expect(body.data.accountLevel).toBe('staff'); |
| 128 | + }); |
| 129 | + |
| 130 | + it('admin demotes staff to user → 200', async () => { |
| 131 | + const res = await post('staff-user', { level: 'user' }, await mintCookies(IDS.admin, 'administrator')); |
| 132 | + expect(res.statusCode).toBe(200); |
| 133 | + expect(res.json<PersonDetailResponse>().data.accountLevel).toBe('user'); |
| 134 | + }); |
| 135 | + |
| 136 | + it('idempotent: setting the same level → 200 no-op', async () => { |
| 137 | + // alice is now 'staff' from the promote test. |
| 138 | + const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.admin, 'administrator')); |
| 139 | + expect(res.statusCode).toBe(200); |
| 140 | + expect(res.json<PersonDetailResponse>().data.accountLevel).toBe('staff'); |
| 141 | + }); |
| 142 | + |
| 143 | + it('last administrator self-demotion → 422', async () => { |
| 144 | + // admin-user is the sole administrator. |
| 145 | + const res = await post('admin-user', { level: 'user' }, await mintCookies(IDS.admin, 'administrator')); |
| 146 | + expect(res.statusCode).toBe(422); |
| 147 | + }); |
| 148 | + |
| 149 | + it('can demote an administrator when another administrator exists → 200', async () => { |
| 150 | + const adminCookie = await mintCookies(IDS.admin, 'administrator'); |
| 151 | + // Promote bob to administrator first (now two admins). |
| 152 | + const promote = await post('bob', { level: 'administrator' }, adminCookie); |
| 153 | + expect(promote.statusCode).toBe(200); |
| 154 | + // Now admin-user can be demoted — bob remains. |
| 155 | + const demote = await post('admin-user', { level: 'staff' }, adminCookie); |
| 156 | + expect(demote.statusCode).toBe(200); |
| 157 | + expect(demote.json<PersonDetailResponse>().data.accountLevel).toBe('staff'); |
| 158 | + }); |
| 159 | + |
| 160 | + it('audit trail: commit carries Action + Previous/New-Account-Level trailers', async () => { |
| 161 | + // Make a fresh change and inspect the resulting commit on the bare repo. |
| 162 | + await post('staff-user', { level: 'staff' }, await mintCookies(IDS.admin, 'administrator')); |
| 163 | + const msg = execFileSync('git', ['-C', dataRepo.path, 'log', '-1', '--format=%B', 'main'], { |
| 164 | + encoding: 'utf8', |
| 165 | + }); |
| 166 | + expect(msg).toContain('Action: account-level.change'); |
| 167 | + expect(msg).toContain('Previous-Account-Level: user'); |
| 168 | + expect(msg).toContain('New-Account-Level: staff'); |
| 169 | + }); |
| 170 | +}); |
0 commit comments