Phase 19: Local Auth (No-OIDC Mode) #23
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user