- Add isAdmin field to GET /members select and mapped member object (D-02)
- Add updateMemberSchema (displayName optional string, isAdmin optional boolean)
- Register adminRouter.patch('/members/:id') with noEchoHook and requireAdmin (inherited)
- Handler: parsePositiveIntParam id validation (400), existence check (404),
D-03 last-admin guard via COUNT(*) query (409), partial set() update (200)
- Fix Test D: switch to adminId2 for GET after self-demotion (adminId1 no longer admin)
- All 44 tests green including 7 new PATCH/isAdmin tests
1256 lines
50 KiB
TypeScript
1256 lines
50 KiB
TypeScript
/**
|
|
* 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,
|
|
appConfig,
|
|
localCredentials,
|
|
} from '../../src/db/schema.js';
|
|
import { verifyPassword } from '../../src/auth/localCredentials.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<FetchCalendarsResult> = () =>
|
|
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<void>) => {
|
|
c.set('user', { id: currentDevUserId });
|
|
await next();
|
|
},
|
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests (cookie not needed)
|
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
}));
|
|
|
|
vi.mock('@hono/oidc-auth', () => ({
|
|
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
|
getAuth: () => null,
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Seed helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function seedUser(label: string, isAdmin = false): Promise<number> {
|
|
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<number> {
|
|
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(localCredentials);
|
|
await db.delete(memberCredentials);
|
|
await db.delete(calendars);
|
|
await db.delete(users).where(eq(users.oidcIss, 'https://auth.test'));
|
|
// Also clean up users created by POST /api/admin/members (no oidcIss)
|
|
await db.delete(users).where(eq(users.oidcIss, ''));
|
|
});
|
|
|
|
// ===========================================================================
|
|
// 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<string, unknown>;
|
|
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);
|
|
});
|
|
|
|
it('returns 404 for a non-existent target and does NOT clear the existing shared calendar (CR-01)', async () => {
|
|
const adminId = await seedUser('admin-shared-missing', true);
|
|
const calA = await seedCalendar(adminId, 'cal-a-keep', true); // currently the shared family lane
|
|
currentDevUserId = adminId;
|
|
const app = await getApp();
|
|
|
|
// PUT a target id that does not exist. The handler must verify the target
|
|
// exists BEFORE clearing the current shared lane, so a bad/stale id can
|
|
// never silently wipe the family's shared calendar (BLOCKER CR-01).
|
|
const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/99999999/shared'));
|
|
expect(res.status).toBe(404);
|
|
|
|
// calA must STILL be shared — the no-op target must not have cleared it.
|
|
const [rowA] = await db
|
|
.select({ isShared: calendars.isShared })
|
|
.from(calendars)
|
|
.where(eq(calendars.id, calA))
|
|
.limit(1);
|
|
expect(rowA.isShared).toBe(true);
|
|
});
|
|
|
|
it('WR-07: rejects a calendar id with trailing garbage (e.g. "1abc") with 400', async () => {
|
|
const adminId = await seedUser('admin-shared-badid', true);
|
|
currentDevUserId = adminId;
|
|
const app = await getApp();
|
|
|
|
// parseInt('1abc', 10) === 1 would have silently accepted this; the strict
|
|
// Number.isInteger parse must reject it as a malformed id.
|
|
const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/1abc/shared'));
|
|
expect(res.status).toBe(400);
|
|
const body = (await res.json()) as { error: string };
|
|
expect(body.error).toBe('Invalid calendar id');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// 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<string, unknown>;
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// 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)
|
|
// -------------------------------------------------------------------------
|
|
|
|
// -------------------------------------------------------------------------
|
|
// POST /api/admin/config/timezone/seed — access control (WR-02)
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('POST seed returns 403 for a non-admin authenticated user (WR-02 access control)', async () => {
|
|
const nonAdminId = await seedUser('tz-non-admin-seed', false);
|
|
currentDevUserId = nonAdminId;
|
|
const app = await getApp();
|
|
|
|
const res = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'America/Chicago' }),
|
|
);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// POST /api/admin/config/timezone/seed — seeds when unset (D-02)
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('POST seed when unset stores the value and returns { ok: true, seeded: true } (D-02, WR-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);
|
|
const body = (await res.json()) as { ok: boolean; seeded: boolean };
|
|
// WR-02: seeded flag must be true when this request performed the seed
|
|
expect(body.ok).toBe(true);
|
|
expect(body.seeded).toBe(true);
|
|
|
|
// 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');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// POST /api/admin/config/timezone/seed — idempotent under concurrent race (WR-02)
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('POST seed when value already exists returns 200 with seeded:false and does NOT throw (WR-02 idempotent race)', async () => {
|
|
const adminId = await seedUser('tz-admin-seed-race', true);
|
|
currentDevUserId = adminId;
|
|
const app = await getApp();
|
|
|
|
// First seed establishes the value (simulates the "winner" of the race)
|
|
const firstRes = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'America/New_York' }),
|
|
);
|
|
expect(firstRes.status).toBe(200);
|
|
const firstBody = (await firstRes.json()) as { ok: boolean; seeded: boolean };
|
|
expect(firstBody.seeded).toBe(true);
|
|
|
|
// Second seed with a different value — simulates the "loser" of the race that
|
|
// arrives after the row already exists. Pre-fix this would throw a 500 due to
|
|
// the PK constraint. Post-fix it must return 200 with seeded:false and preserve
|
|
// the original value (D-03 no-overwrite).
|
|
const secondRes = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Europe/Paris' }),
|
|
);
|
|
expect(secondRes.status).toBe(200);
|
|
const secondBody = (await secondRes.json()) as { ok: boolean; seeded: boolean };
|
|
expect(secondBody.ok).toBe(true);
|
|
expect(secondBody.seeded).toBe(false);
|
|
|
|
// The stored value must still be America/New_York (winner's value preserved, 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/New_York');
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// POST seed — `seeded` flag is derived from what the DB actually did (WR-02)
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('POST seed reports seeded:false when a row was pre-inserted directly, not via the endpoint (WR-02 accurate flag)', async () => {
|
|
const adminId = await seedUser('tz-admin-seed-derived', true);
|
|
currentDevUserId = adminId;
|
|
const app = await getApp();
|
|
|
|
// Insert the row directly (bypassing the seed endpoint) so no request-scoped
|
|
// pre-flight SELECT could have observed "unset". A correct implementation must
|
|
// derive seeded from the INSERT result (affectedRows), so this returns false.
|
|
await db.insert(appConfig).values({ key: 'household_timezone', value: 'America/Denver' });
|
|
|
|
const res = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Asia/Tokyo' }),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean; seeded: boolean };
|
|
expect(body.ok).toBe(true);
|
|
expect(body.seeded).toBe(false);
|
|
|
|
// D-03 preserved: the directly-inserted value is untouched.
|
|
const [row] = await db
|
|
.select({ value: appConfig.value })
|
|
.from(appConfig)
|
|
.where(eq(appConfig.key, 'household_timezone'))
|
|
.limit(1);
|
|
expect(row?.value).toBe('America/Denver');
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// POST /api/admin/members — admin create local member (AUTH-LOCAL-07, T-19-05, T-19-06, T-19-10)
|
|
// ===========================================================================
|
|
|
|
describe('POST /api/admin/members', () => {
|
|
it('Test 1: creates a users row + local_credentials row, hash verifies against initialPassword', async () => {
|
|
const adminId = await seedUser('admin-create-member', true);
|
|
currentDevUserId = adminId;
|
|
const app = await getApp();
|
|
|
|
const initialPassword = 'correct-horse-battery-staple1!';
|
|
const res = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/members', {
|
|
displayName: 'New Member',
|
|
username: `newmember-${randomUUID()}`,
|
|
initialPassword,
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as { id: number };
|
|
expect(typeof body.id).toBe('number');
|
|
|
|
// Verify users row was created
|
|
const [userRow] = await db
|
|
.select({ id: users.id, displayName: users.displayName })
|
|
.from(users)
|
|
.where(eq(users.id, body.id))
|
|
.limit(1);
|
|
expect(userRow).toBeDefined();
|
|
expect(userRow.displayName).toBe('New Member');
|
|
|
|
// Verify local_credentials row was created with a verifiable hash
|
|
const [credRow] = await db
|
|
.select({ passwordHash: localCredentials.passwordHash })
|
|
.from(localCredentials)
|
|
.where(eq(localCredentials.userId, body.id))
|
|
.limit(1);
|
|
expect(credRow).toBeDefined();
|
|
expect(await verifyPassword(credRow.passwordHash, initialPassword)).toBe(true);
|
|
});
|
|
|
|
it('Test 2: duplicate username returns 409 — transaction rolls back (no orphaned users row)', async () => {
|
|
const adminId = await seedUser('admin-dup-username', true);
|
|
currentDevUserId = adminId;
|
|
const app = await getApp();
|
|
|
|
const uniqueUsername = `dupuser-${randomUUID()}`;
|
|
// Create the first member successfully
|
|
const firstRes = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/members', {
|
|
displayName: 'First Member',
|
|
username: uniqueUsername,
|
|
initialPassword: 'first-password-abc123',
|
|
}),
|
|
);
|
|
expect(firstRes.status).toBe(201);
|
|
const firstBody = (await firstRes.json()) as { id: number };
|
|
const countBefore = (await db.select({ id: users.id }).from(users)).length;
|
|
|
|
// Try to create a second member with the same username
|
|
const dupRes = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/members', {
|
|
displayName: 'Duplicate Member',
|
|
username: uniqueUsername,
|
|
initialPassword: 'second-password-xyz789',
|
|
}),
|
|
);
|
|
expect(dupRes.status).toBe(409);
|
|
|
|
// No new users row should have been created (transaction rolled back)
|
|
const countAfter = (await db.select({ id: users.id }).from(users)).length;
|
|
expect(countAfter).toBe(countBefore);
|
|
|
|
// The first member's local_credentials must still exist
|
|
const [credRow] = await db
|
|
.select({ id: localCredentials.id })
|
|
.from(localCredentials)
|
|
.where(eq(localCredentials.userId, firstBody.id))
|
|
.limit(1);
|
|
expect(credRow).toBeDefined();
|
|
});
|
|
|
|
it('Test 3: admin can reset any member password without knowing the current one', async () => {
|
|
const adminId = await seedUser('admin-reset-pw', true);
|
|
// Seeded for DB-state parity; this test creates its own member via the admin API below.
|
|
await seedUser('member-reset-target', false);
|
|
currentDevUserId = adminId;
|
|
const app = await getApp();
|
|
|
|
// First create a local_credentials row for the member
|
|
const createRes = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/members', {
|
|
displayName: 'Reset Target',
|
|
username: `reset-target-${randomUUID()}`,
|
|
initialPassword: 'old-password-123',
|
|
}),
|
|
);
|
|
expect(createRes.status).toBe(201);
|
|
const { id: newMemberId } = (await createRes.json()) as { id: number };
|
|
|
|
// Admin resets the password
|
|
const newPassword = 'new-password-xyz789-secure';
|
|
const resetRes = await app.fetch(
|
|
jsonRequest('POST', `/api/admin/members/${newMemberId}/password`, {
|
|
newPassword,
|
|
}),
|
|
);
|
|
expect(resetRes.status).toBe(200);
|
|
|
|
// Verify the stored hash now verifies against the new password
|
|
const [credRow] = await db
|
|
.select({ passwordHash: localCredentials.passwordHash })
|
|
.from(localCredentials)
|
|
.where(eq(localCredentials.userId, newMemberId))
|
|
.limit(1);
|
|
expect(credRow).toBeDefined();
|
|
expect(await verifyPassword(credRow.passwordHash, newPassword)).toBe(true);
|
|
expect(await verifyPassword(credRow.passwordHash, 'old-password-123')).toBe(false);
|
|
});
|
|
|
|
it('Test 4: non-admin gets 403 on POST /members and POST /members/:id/password', async () => {
|
|
const nonAdminId = await seedUser('non-admin-member-create', false);
|
|
currentDevUserId = nonAdminId;
|
|
const app = await getApp();
|
|
|
|
const createRes = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/members', {
|
|
displayName: 'Should Fail',
|
|
username: `fail-${randomUUID()}`,
|
|
initialPassword: 'password-fail-123',
|
|
}),
|
|
);
|
|
expect(createRes.status).toBe(403);
|
|
|
|
const resetRes = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/members/1/password', {
|
|
newPassword: 'fail-new-password',
|
|
}),
|
|
);
|
|
expect(resetRes.status).toBe(403);
|
|
});
|
|
|
|
it('Test 5: GET /api/admin/members returns hasLocalCredential:true for member with local_credentials row', async () => {
|
|
const adminId = await seedUser('admin-haslocalcred', true);
|
|
currentDevUserId = adminId;
|
|
const app = await getApp();
|
|
|
|
// Create a member via the API (which creates a local_credentials row)
|
|
const createRes = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/members', {
|
|
displayName: 'Has Local Cred',
|
|
username: `has-cred-${randomUUID()}`,
|
|
initialPassword: 'has-cred-password-123',
|
|
}),
|
|
);
|
|
expect(createRes.status).toBe(201);
|
|
const { id: newMemberId } = (await createRes.json()) as { id: number };
|
|
|
|
// GET /members should show hasLocalCredential:true for this member
|
|
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
|
expect(getRes.status).toBe(200);
|
|
const body = (await getRes.json()) as {
|
|
members: Array<{ id: number; hasLocalCredential: boolean }>;
|
|
};
|
|
|
|
const memberRow = body.members.find((m) => m.id === newMemberId);
|
|
expect(memberRow).toBeDefined();
|
|
expect(memberRow!.hasLocalCredential).toBe(true);
|
|
|
|
// The admin user (no local_credentials row) should have hasLocalCredential:false
|
|
const adminRow = body.members.find((m) => m.id === adminId);
|
|
expect(adminRow).toBeDefined();
|
|
expect(adminRow!.hasLocalCredential).toBe(false);
|
|
});
|
|
|
|
it('WR-05 (no-echo): malformed create-member body never echoes the submitted password or Zod received field', async () => {
|
|
const adminId = await seedUser('admin-create-noecho', true);
|
|
currentDevUserId = adminId;
|
|
const app = await getApp();
|
|
|
|
// initialPassword too short (< 8) → Zod rejects. The noEchoHook must return only
|
|
// { error: 'Invalid request' } and NEVER leak the submitted password or Zod's
|
|
// issues[].received field (T-19-06 / the T-19-14 leak this guards against).
|
|
const submittedPassword = 'shortpw-secret';
|
|
const res = await app.fetch(
|
|
jsonRequest('POST', '/api/admin/members', {
|
|
displayName: 'No Echo',
|
|
username: `noecho-${randomUUID()}`,
|
|
initialPassword: submittedPassword.slice(0, 3), // 3 chars — fails min(8)
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const bodyText = await res.text();
|
|
expect(bodyText).not.toContain('received');
|
|
expect(bodyText).not.toContain('issues');
|
|
expect(bodyText).not.toContain(submittedPassword.slice(0, 3));
|
|
const parsed = JSON.parse(bodyText) as { error: string };
|
|
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);
|
|
// Log in as adminId1 to perform the self-demotion
|
|
currentDevUserId = adminId1;
|
|
const app = await getApp();
|
|
|
|
const res = await app.fetch(
|
|
jsonRequest('PATCH', `/api/admin/members/${adminId1}`, { isAdmin: false }),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
// Switch to adminId2 to verify the outcome — adminId1 is now non-admin
|
|
// and can no longer call GET /members (would 403).
|
|
currentDevUserId = adminId2;
|
|
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);
|
|
});
|
|
});
|