From b2c7902e9e29c54de64c50705f91cc4bf787888c Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:29:43 -0400 Subject: [PATCH] test(19-02): add failing tests for admin create-member, reset-password, hasLocalCredential RED phase for Task 1: - Test 1: POST /api/admin/members creates users row + local_credentials, hash verifies - Test 2: duplicate username returns 409, transaction rolled back (no orphaned user row) - Test 3: admin reset password updates hash, old password no longer verifies - Test 4: non-admin gets 403 on both POST /members and POST /members/:id/password - Test 5: GET /api/admin/members returns hasLocalCredential:true/false per local cred existence --- apps/api/tests/routes/admin.test.ts | 179 +++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) diff --git a/apps/api/tests/routes/admin.test.ts b/apps/api/tests/routes/admin.test.ts index d972e05..a71076f 100644 --- a/apps/api/tests/routes/admin.test.ts +++ b/apps/api/tests/routes/admin.test.ts @@ -30,7 +30,8 @@ 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 } from '../../src/db/schema.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. @@ -169,9 +170,12 @@ beforeEach(async () => { 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, '')); }); // =========================================================================== @@ -845,3 +849,176 @@ describe('admin timezone config', () => { 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(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); + const memberId = 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(verifyPassword(credRow.passwordHash, newPassword)).toBe(true); + expect(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); + }); +});