Phase 19: Local Auth (No-OIDC Mode) #23

Merged
luckberg merged 78 commits from gsd/phase-19-local-auth-no-oidc-mode into main 2026-06-18 06:25:00 -04:00
Showing only changes of commit b2c7902e9e - Show all commits
+178 -1
View File
@@ -30,7 +30,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '../../src/db/client.js'; 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. // CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail.
@@ -169,9 +170,12 @@ beforeEach(async () => {
afterEach(async () => { afterEach(async () => {
// Clean up seeded users and credentials between tests // Clean up seeded users and credentials between tests
await db.delete(localCredentials);
await db.delete(memberCredentials); await db.delete(memberCredentials);
await db.delete(calendars); await db.delete(calendars);
await db.delete(users).where(eq(users.oidcIss, 'https://auth.test')); 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'); 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);
});
});