import { randomUUID } from 'crypto'; import { mkdir, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; import type { Permission } from '@bookorbit/types'; import { AuditAction, Permission as PermissionEnum } from '@bookorbit/types'; import { and, count, eq, inArray } from 'drizzle-orm'; import * as schema from '../src/db/schema'; import { UserRepository } from '../src/modules/user/user.repository'; import { authHeader, closeUsersAdminLifecycleE2EContext, createLibraryWithFolder, createOidcUser, createUserAndLogin, createUsersAdminLifecycleE2EContext, loginForToken, setUserActive, type CreatedLibrary, type OidcUserSeed, type TestUserSession, type UsersAdminLifecycleE2EContext, } from './e2e/users-admin-lifecycle/users-admin-lifecycle-harness'; interface ScenarioRunResult { id: string; status: 'passed' | 'failed'; durationMs: number; error?: string; } async function writeScenarioReport(results: ScenarioRunResult[]): Promise { const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server'); await mkdir(reportDir, { recursive: true }); const reportPath = join(reportDir, 'users-admin-lifecycle-e2e-scenarios.json'); await writeFile( reportPath, JSON.stringify( { generatedAt: new Date().toISOString(), total: results.length, passed: results.filter((result) => result.status === 'passed').length, failed: results.filter((result) => result.status === 'failed').length, results, }, null, 2, ), ); } function responseMessage(response: { message?: string | string[] }): string { if (Array.isArray(response.message)) return response.message.join(' '); return String(response.message ?? ''); } function expectError(response: Awaited>, status: number, messageFragment: string): void { expect(response.statusCode).toBe(status); const message = responseMessage(response.json() as { message?: string | string[] }); expect(message).toContain(messageFragment); } async function waitForAuditAction(ctx: UsersAdminLifecycleE2EContext, resourceId: number, action: AuditAction): Promise { for (let attempt = 0; attempt < 100; attempt++) { const row = await ctx.db.query.auditLog.findFirst({ where: and(eq(schema.auditLog.resourceId, resourceId), eq(schema.auditLog.action, action)), }); if (row) return; await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error(`Audit action ${action} was not recorded for user ${resourceId}`); } function parseTokenFromResetUrl(resetUrl: string): string { const token = new URL(resetUrl).searchParams.get('token'); if (!token) { throw new Error(`Reset URL is missing token: ${resetUrl}`); } return token; } function buildMultipartBody(fileName: string, content: Buffer, contentType: string): { body: Buffer; boundary: string } { const boundary = `----bookorbit-users-admin-${randomUUID()}`; const preamble = Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`, 'utf8', ); const closing = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'); return { body: Buffer.concat([preamble, content, closing]), boundary }; } const onePixelPng = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVQImWM4YWT0HwAFHgIsBxDy5AAAAABJRU5ErkJggg==', 'base64', ); describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => { let ctx!: UsersAdminLifecycleE2EContext; const scenarioResults: ScenarioRunResult[] = []; let scenarioStartedAt = 0; let libraryA!: CreatedLibrary; let libraryB!: CreatedLibrary; let manageUsersAdmin!: TestUserSession; let manageLibrariesAdmin!: TestUserSession; let regularA!: TestUserSession; let regularB!: TestUserSession; let defaultPasswordUser!: TestUserSession; let superuserTarget!: TestUserSession; let inactiveAssignable!: TestUserSession; let oidcUser!: OidcUserSeed; beforeAll(async () => { ctx = await createUsersAdminLifecycleE2EContext(); libraryA = await createLibraryWithFolder(ctx, { name: `users-admin-library-a-${randomUUID()}` }); libraryB = await createLibraryWithFolder(ctx, { name: `users-admin-library-b-${randomUUID()}` }); manageUsersAdmin = await createUserAndLogin(ctx, { permissions: [PermissionEnum.ManageUsers] }); manageLibrariesAdmin = await createUserAndLogin(ctx, { permissions: [PermissionEnum.ManageLibraries] }); regularA = await createUserAndLogin(ctx); regularB = await createUserAndLogin(ctx); defaultPasswordUser = await createUserAndLogin(ctx, { password: 'DefaultPass123', isDefaultPassword: true, }); superuserTarget = await createUserAndLogin(ctx, { isSuperuser: true }); inactiveAssignable = await createUserAndLogin(ctx); await setUserActive(ctx, inactiveAssignable.userId, false); oidcUser = await createOidcUser(ctx); }, 180_000); beforeEach(() => { scenarioStartedAt = Date.now(); }); afterEach((taskContext) => { const result = taskContext.task.result; if (!result) return; const state = result.state === 'pass' ? 'passed' : 'failed'; const error = result.errors?.[0]?.message; scenarioResults.push({ id: taskContext.task.name, status: state, durationMs: Math.max(0, Date.now() - scenarioStartedAt), ...(error ? { error } : {}), }); }); afterAll(async () => { await writeScenarioReport(scenarioResults); if (ctx) { await closeUsersAdminLifecycleE2EContext(ctx); } }); describe('admin route contracts', () => { it('returns 404 user not found consistently across target-user admin routes', async () => { const missingUserId = 9_999_991; const cases: Array<{ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; url: string; payload?: Record; }> = [ { method: 'GET', url: `/api/v1/users/${missingUserId}` }, { method: 'PATCH', url: `/api/v1/users/${missingUserId}`, payload: { name: 'ghost' } }, { method: 'DELETE', url: `/api/v1/users/${missingUserId}` }, { method: 'PUT', url: `/api/v1/users/${missingUserId}/permissions`, payload: { permissionNames: [PermissionEnum.ManageLibraries] } }, { method: 'PUT', url: `/api/v1/users/${missingUserId}/superuser`, payload: { isSuperuser: true } }, { method: 'GET', url: `/api/v1/users/${missingUserId}/libraries` }, { method: 'PUT', url: `/api/v1/users/${missingUserId}/libraries`, payload: { libraryIds: [libraryA.libraryId] } }, { method: 'POST', url: `/api/v1/users/${missingUserId}/reset-password` }, ]; for (const entry of cases) { const response = await ctx.app.inject({ method: entry.method, url: entry.url, headers: authHeader(ctx.adminToken), ...(entry.payload ? { payload: entry.payload } : {}), }); expectError(response, 404, 'User not found'); } }); it('validates permission enum inputs and unknown library IDs on create', async () => { const invalidPermissionCreate = await ctx.app.inject({ method: 'POST', url: '/api/v1/users', headers: authHeader(manageUsersAdmin.accessToken), payload: { username: `invalid-perm-${randomUUID().slice(0, 8)}`, name: 'Invalid Permission User', email: `invalid-perm-${randomUUID().slice(0, 8)}@example.com`, permissionNames: ['not_a_real_permission'], }, }); expectError(invalidPermissionCreate, 400, 'permissionNames'); const unknownLibraryCreate = await ctx.app.inject({ method: 'POST', url: '/api/v1/users', headers: authHeader(manageUsersAdmin.accessToken), payload: { username: `unknown-lib-${randomUUID().slice(0, 8)}`, name: 'Unknown Library User', email: `unknown-lib-${randomUUID().slice(0, 8)}@example.com`, libraryIds: [libraryA.libraryId, 9_999_123], }, }); expectError(unknownLibraryCreate, 400, 'Unknown library IDs'); }); it('covers user create/update/delete, permission assignment, and library assignment lifecycle', async () => { const username = `lifecycle-${randomUUID().slice(0, 8)}`; const createResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/users', headers: authHeader(manageUsersAdmin.accessToken), payload: { username, name: 'Lifecycle User', email: `${username}@example.com`, permissionNames: [PermissionEnum.ManageLibraries, PermissionEnum.ManageLibraries], libraryIds: [libraryA.libraryId, libraryA.libraryId, libraryB.libraryId], }, }); expect(createResponse.statusCode).toBe(201); const created = createResponse.json() as { id: number; resetUrl: string }; expect(created.resetUrl).toContain('/reset-password?token='); const readCreated = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${created.id}`, headers: authHeader(manageUsersAdmin.accessToken), }); expect(readCreated.statusCode).toBe(200); const createdBody = readCreated.json() as { permissions: Permission[] }; expect(createdBody.permissions).toEqual([PermissionEnum.ManageLibraries]); const createdLibraries = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${created.id}/libraries`, headers: authHeader(manageUsersAdmin.accessToken), }); expect(createdLibraries.statusCode).toBe(200); expect((createdLibraries.json() as number[]).toSorted((a, b) => a - b)).toEqual( [libraryA.libraryId, libraryB.libraryId].toSorted((a, b) => a - b), ); const updateResponse = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/users/${created.id}`, headers: authHeader(manageUsersAdmin.accessToken), payload: { name: 'Lifecycle User Updated', email: `updated-${username}@example.com`, active: true, }, }); expect(updateResponse.statusCode).toBe(200); const updated = updateResponse.json() as { name: string; email: string }; expect(updated).toMatchObject({ name: 'Lifecycle User Updated', email: `updated-${username}@example.com` }); const permissionUpdate = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${created.id}/permissions`, headers: authHeader(manageUsersAdmin.accessToken), payload: { permissionNames: [PermissionEnum.LibraryDownload, PermissionEnum.LibraryDownload, PermissionEnum.KoboSync], }, }); expect(permissionUpdate.statusCode).toBe(204); const readAfterPermissionUpdate = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${created.id}`, headers: authHeader(manageUsersAdmin.accessToken), }); expect(readAfterPermissionUpdate.statusCode).toBe(200); const afterPermissionUpdateBody = readAfterPermissionUpdate.json() as { permissions: Permission[] }; expect(afterPermissionUpdateBody.permissions.toSorted()).toEqual([PermissionEnum.LibraryDownload, PermissionEnum.KoboSync].toSorted()); const invalidPermissionUpdate = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${created.id}/permissions`, headers: authHeader(manageUsersAdmin.accessToken), payload: { permissionNames: ['invalid_permission'], }, }); expectError(invalidPermissionUpdate, 400, 'permissionNames'); await ctx.db .update(schema.userLibraryAccess) .set({ accessLevel: 'editor' }) .where(and(eq(schema.userLibraryAccess.userId, created.id), eq(schema.userLibraryAccess.libraryId, libraryA.libraryId))); const readAccessLevels = async () => Object.fromEntries( ( await ctx.db .select({ libraryId: schema.userLibraryAccess.libraryId, accessLevel: schema.userLibraryAccess.accessLevel }) .from(schema.userLibraryAccess) .where(eq(schema.userLibraryAccess.userId, created.id)) ).map((row) => [row.libraryId, row.accessLevel]), ); const libraryUpdate = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${created.id}/libraries`, headers: authHeader(manageUsersAdmin.accessToken), payload: { libraryIds: [libraryB.libraryId, libraryA.libraryId, libraryA.libraryId], }, }); expect(libraryUpdate.statusCode).toBe(204); const readLibrariesAfterUpdate = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${created.id}/libraries`, headers: authHeader(manageUsersAdmin.accessToken), }); expect(readLibrariesAfterUpdate.statusCode).toBe(200); expect((readLibrariesAfterUpdate.json() as number[]).toSorted((a, b) => a - b)).toEqual( [libraryA.libraryId, libraryB.libraryId].toSorted((a, b) => a - b), ); expect(await readAccessLevels()).toEqual({ [libraryA.libraryId]: 'editor', [libraryB.libraryId]: 'viewer' }); const narrowedLibraryUpdate = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${created.id}/libraries`, headers: authHeader(manageUsersAdmin.accessToken), payload: { libraryIds: [libraryA.libraryId] }, }); expect(narrowedLibraryUpdate.statusCode).toBe(204); expect(await readAccessLevels()).toEqual({ [libraryA.libraryId]: 'editor' }); const restoredLibraryUpdate = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${created.id}/libraries`, headers: authHeader(manageUsersAdmin.accessToken), payload: { libraryIds: [libraryA.libraryId, libraryB.libraryId] }, }); expect(restoredLibraryUpdate.statusCode).toBe(204); expect(await readAccessLevels()).toEqual({ [libraryA.libraryId]: 'editor', [libraryB.libraryId]: 'viewer' }); const deleteResponse = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/users/${created.id}`, headers: authHeader(manageUsersAdmin.accessToken), }); expect(deleteResponse.statusCode).toBe(204); const readAfterDelete = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${created.id}`, headers: authHeader(manageUsersAdmin.accessToken), }); expectError(readAfterDelete, 404, 'User not found'); }); it('rejects duplicate username on create', async () => { const response = await ctx.app.inject({ method: 'POST', url: '/api/v1/users', headers: authHeader(manageUsersAdmin.accessToken), payload: { username: regularA.username, name: 'Duplicate Username User', email: `dup-username-${randomUUID().slice(0, 8)}@example.com`, }, }); expectError(response, 409, 'Username already taken'); }); it('returns 401 for unauthenticated requests to admin routes', async () => { const cases: Array<{ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; url: string; payload?: Record; }> = [ { method: 'GET', url: '/api/v1/users' }, { method: 'POST', url: '/api/v1/users', payload: { username: 'noauth', name: 'No Auth', email: 'noauth@test.com' } }, { method: 'GET', url: `/api/v1/users/${regularA.userId}` }, { method: 'PATCH', url: `/api/v1/users/${regularA.userId}`, payload: { name: 'hacked' } }, { method: 'DELETE', url: `/api/v1/users/${regularA.userId}` }, ]; for (const entry of cases) { const response = await ctx.app.inject({ method: entry.method, url: entry.url, ...(entry.payload ? { payload: entry.payload } : {}), }); expect(response.statusCode).toBe(401); } }); it('returns 403 for authenticated users lacking manage_users permission', async () => { const cases: Array<{ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; url: string; payload?: Record; }> = [ { method: 'GET', url: '/api/v1/users' }, { method: 'POST', url: '/api/v1/users', payload: { username: 'noperm', name: 'No Perm', email: 'noperm@test.com' } }, { method: 'GET', url: `/api/v1/users/${regularA.userId}` }, { method: 'PATCH', url: `/api/v1/users/${regularA.userId}`, payload: { name: 'hacked' } }, { method: 'DELETE', url: `/api/v1/users/${regularA.userId}` }, ]; for (const entry of cases) { const response = await ctx.app.inject({ method: entry.method, url: entry.url, headers: authHeader(regularB.accessToken), ...(entry.payload ? { payload: entry.payload } : {}), }); expectError(response, 403, 'Missing permission: manage_users'); } }); }); describe('protection rules', () => { it('blocks self-delete, self-deactivate, and self-permission changes', async () => { const deleteSelf = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/users/${manageUsersAdmin.userId}`, headers: authHeader(manageUsersAdmin.accessToken), }); expectError(deleteSelf, 409, 'You cannot delete your own account'); const deactivateSelf = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/users/${manageUsersAdmin.userId}`, headers: authHeader(manageUsersAdmin.accessToken), payload: { active: false }, }); expectError(deactivateSelf, 409, 'You cannot deactivate your own account'); const setOwnPermissions = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${manageUsersAdmin.userId}/permissions`, headers: authHeader(manageUsersAdmin.accessToken), payload: { permissionNames: [PermissionEnum.ManageLibraries] }, }); expectError(setOwnPermissions, 409, 'You cannot modify your own permissions'); }); it('prevents non-superusers from managing superuser accounts', async () => { const editSuperuser = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/users/${superuserTarget.userId}`, headers: authHeader(manageUsersAdmin.accessToken), payload: { name: 'Attempted Rename' }, }); expectError(editSuperuser, 403, 'Only administrators can edit administrator accounts'); const deleteSuperuser = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/users/${superuserTarget.userId}`, headers: authHeader(manageUsersAdmin.accessToken), }); expectError(deleteSuperuser, 403, 'Only administrators can delete administrator accounts'); const setSuperuserPermissions = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${superuserTarget.userId}/permissions`, headers: authHeader(manageUsersAdmin.accessToken), payload: { permissionNames: [PermissionEnum.ManageLibraries] }, }); expectError(setSuperuserPermissions, 403, 'Only administrators can modify administrator permissions'); const setSuperuserLibraries = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${superuserTarget.userId}/libraries`, headers: authHeader(manageUsersAdmin.accessToken), payload: { libraryIds: [libraryA.libraryId] }, }); expectError(setSuperuserLibraries, 403, 'Only administrators can edit administrator accounts'); const resetSuperuserPassword = await ctx.app.inject({ method: 'POST', url: `/api/v1/users/${superuserTarget.userId}/reset-password`, headers: authHeader(manageUsersAdmin.accessToken), }); expectError(resetSuperuserPassword, 403, 'Only administrators can reset administrator passwords'); const nonSuperSetSuperuser = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${regularA.userId}/superuser`, headers: authHeader(manageUsersAdmin.accessToken), payload: { isSuperuser: true }, }); expectError(nonSuperSetSuperuser, 403, 'Only administrators can change superuser status'); }); it('strictly validates superuser transition payloads', async () => { const candidate = await createUserAndLogin(ctx); const invalidPayloads: Array> = [{ isSuperuser: 'true' }, {}, { isSuperuser: true, unexpected: true }]; for (const payload of invalidPayloads) { const response = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${candidate.userId}/superuser`, headers: authHeader(ctx.adminToken), payload, }); expect(response.statusCode).toBe(400); } }); it('rejects promotion of shared accounts', async () => { const suffix = randomUUID().replaceAll('-', ''); const createShared = await ctx.app.inject({ method: 'POST', url: '/api/v1/users/shared', headers: authHeader(ctx.adminToken), payload: { username: `users-admin-lifecycle-shared-${suffix}`, name: `Users Admin Lifecycle Shared ${suffix}`, }, }); expect(createShared.statusCode).toBe(201); const sharedUserId = (createShared.json() as { id: number }).id; const promote = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${sharedUserId}/superuser`, headers: authHeader(ctx.adminToken), payload: { isSuperuser: true }, }); expectError(promote, 400, 'Shared accounts cannot be made superuser'); }); it('toggles superuser access, invalidates existing sessions, and records distinct audit actions', async () => { const candidate = await createUserAndLogin(ctx); const createResetLink = await ctx.app.inject({ method: 'POST', url: `/api/v1/users/${candidate.userId}/reset-password`, headers: authHeader(manageUsersAdmin.accessToken), }); expect(createResetLink.statusCode).toBe(201); const prePromotionResetToken = parseTokenFromResetUrl((createResetLink.json() as { resetUrl: string }).resetUrl); const promote = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${candidate.userId}/superuser`, headers: authHeader(ctx.adminToken), payload: { isSuperuser: true }, }); expect(promote.statusCode).toBe(204); await waitForAuditAction(ctx, candidate.userId, AuditAction.UserSuperuserEnable); const oldSessionAfterPromote = await ctx.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: authHeader(candidate.accessToken), }); expect(oldSessionAfterPromote.statusCode).toBe(401); const resetAfterPromote = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: prePromotionResetToken, newPassword: 'ShouldNotReplacePassword123', }, }); expectError(resetAfterPromote, 400, 'Invalid or expired reset token'); const readAfterPromote = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${candidate.userId}`, headers: authHeader(ctx.adminToken), }); expect(readAfterPromote.statusCode).toBe(200); expect((readAfterPromote.json() as { isSuperuser: boolean }).isSuperuser).toBe(true); const promotedAccessToken = await loginForToken(ctx.app, candidate.username, candidate.password); expect(promotedAccessToken).not.toBeNull(); const demote = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${candidate.userId}/superuser`, headers: authHeader(ctx.adminToken), payload: { isSuperuser: false }, }); expect(demote.statusCode).toBe(204); await waitForAuditAction(ctx, candidate.userId, AuditAction.UserSuperuserDisable); const promotedSessionAfterDemote = await ctx.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: authHeader(promotedAccessToken!), }); expect(promotedSessionAfterDemote.statusCode).toBe(401); const readAfterDemote = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${candidate.userId}`, headers: authHeader(ctx.adminToken), }); expect(readAfterDemote.statusCode).toBe(200); expect((readAfterDemote.json() as { isSuperuser: boolean }).isSuperuser).toBe(false); }); it('preserves one active superuser during concurrent cross-demotion attempts', async () => { const originalSuperusers = await ctx.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.isSuperuser, true)); const first = await createUserAndLogin(ctx, { isSuperuser: true }); const second = await createUserAndLogin(ctx, { isSuperuser: true }); const userRepository = ctx.app.get(UserRepository); try { await ctx.db.update(schema.users).set({ isSuperuser: false }); await ctx.db .update(schema.users) .set({ isSuperuser: true }) .where(inArray(schema.users.id, [first.userId, second.userId])); const statuses = await Promise.all([ userRepository.setSuperuser(first.userId, second.userId, false), userRepository.setSuperuser(second.userId, first.userId, false), ]); expect(statuses.toSorted()).toEqual(['requester_not_superuser', 'updated']); const [{ total }] = await ctx.db .select({ total: count() }) .from(schema.users) .where(and(eq(schema.users.active, true), eq(schema.users.isSuperuser, true))); expect(Number(total)).toBe(1); } finally { await ctx.db.update(schema.users).set({ isSuperuser: false }); if (originalSuperusers.length > 0) { await ctx.db .update(schema.users) .set({ isSuperuser: true }) .where( inArray( schema.users.id, originalSuperusers.map((user) => user.id), ), ); } } }); it('blocks self-superuser toggle by a superuser', async () => { const selfToggle = await ctx.app.inject({ method: 'PUT', url: `/api/v1/users/${superuserTarget.userId}/superuser`, headers: authHeader(superuserTarget.accessToken), payload: { isSuperuser: false }, }); expectError(selfToggle, 409, 'You cannot change your own superuser status'); }); }); describe('profile and avatar behavior', () => { it('enforces duplicate email conflicts for admin update and self-update', async () => { const adminDuplicateEmail = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/users/${regularB.userId}`, headers: authHeader(manageUsersAdmin.accessToken), payload: { email: regularA.username + '@example.com' }, }); expectError(adminDuplicateEmail, 409, 'Email already in use'); // Self-service email change is disabled (SEC-030). The field is not in UpdateMeDto // so the global forbidNonWhitelisted pipe rejects it with 400. const selfDuplicateEmail = await ctx.app.inject({ method: 'PATCH', url: '/api/v1/users/me', headers: authHeader(regularB.accessToken), payload: { email: regularA.username + '@example.com' }, }); expectError(selfDuplicateEmail, 400, 'should not exist'); }); it('updates profile settings and enforces avatar upload/read/delete contracts', async () => { const updateProfile = await ctx.app.inject({ method: 'PATCH', url: '/api/v1/users/me', headers: authHeader(regularA.accessToken), payload: { name: 'Regular A Updated', }, }); expect(updateProfile.statusCode).toBe(200); const updatedProfile = updateProfile.json() as { name: string }; expect(updatedProfile.name).toBe('Regular A Updated'); const updateSettings = await ctx.app.inject({ method: 'PATCH', url: '/api/v1/users/me/settings', headers: authHeader(regularA.accessToken), payload: { settings: { syncReaderPreferences: true } }, }); expect(updateSettings.statusCode).toBe(200); const updatedSettings = updateSettings.json() as { settings: { syncReaderPreferences?: boolean } }; expect(updatedSettings.settings.syncReaderPreferences).toBe(true); const noFileUpload = await ctx.app.inject({ method: 'POST', url: '/api/v1/users/me/avatar', headers: authHeader(regularA.accessToken), }); expect(noFileUpload.statusCode).toBe(406); const noFileBody = noFileUpload.json() as { message?: string | string[] }; expect(responseMessage(noFileBody).toLowerCase()).toContain('multipart'); const nonImageUpload = await uploadAvatar(regularA.accessToken, 'avatar.txt', Buffer.from('hello', 'utf8'), 'text/plain'); expectError(nonImageUpload, 400, 'File must be an image'); const invalidImageUpload = await uploadAvatar(regularA.accessToken, 'avatar.png', Buffer.from('invalid-image', 'utf8'), 'image/png'); expectError(invalidImageUpload, 400, 'Invalid image file'); const oversizedUpload = await uploadAvatar(regularA.accessToken, 'avatar.jpg', Buffer.alloc(5 * 1024 * 1024 + 1, 1), 'image/jpeg'); expectError(oversizedUpload, 400, 'Image exceeds 5 MB limit'); const validUpload = await uploadAvatar(regularA.accessToken, 'avatar.png', onePixelPng, 'image/png'); expect(validUpload.statusCode).toBe(201); const validUploadBody = validUpload.json() as { avatarUrl: string | null }; expect(validUploadBody.avatarUrl).toContain(`/api/v1/users/${regularA.userId}/avatar?v=`); const readOwnAvatar = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${regularA.userId}/avatar`, headers: authHeader(regularA.accessToken), }); expect(readOwnAvatar.statusCode).toBe(200); const etag = String(readOwnAvatar.headers.etag ?? ''); expect(etag.length).toBeGreaterThan(0); const readOwnAvatarNotModified = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${regularA.userId}/avatar`, headers: { ...authHeader(regularA.accessToken), 'if-none-match': etag }, }); expect(readOwnAvatarNotModified.statusCode).toBe(304); const forbiddenOtherUserRead = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${regularA.userId}/avatar`, headers: authHeader(regularB.accessToken), }); expectError(forbiddenOtherUserRead, 403, 'Insufficient permissions to access this avatar'); const manageUsersRead = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${regularA.userId}/avatar`, headers: authHeader(manageUsersAdmin.accessToken), }); expect(manageUsersRead.statusCode).toBe(200); const deleteOwnAvatar = await ctx.app.inject({ method: 'DELETE', url: '/api/v1/users/me/avatar', headers: authHeader(regularA.accessToken), }); expect(deleteOwnAvatar.statusCode).toBe(200); const readAfterDelete = await ctx.app.inject({ method: 'GET', url: `/api/v1/users/${regularA.userId}/avatar`, headers: authHeader(regularA.accessToken), }); expect(readAfterDelete.statusCode).toBe(404); }); }); describe('password reset and default-password behavior', () => { it('enforces admin reset lifecycle with consumable tokens and OIDC rejection', async () => { const username = `reset-local-${randomUUID().slice(0, 8)}`; const createUserResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/users', headers: authHeader(manageUsersAdmin.accessToken), payload: { username, name: 'Reset Flow User', email: `${username}@example.com`, }, }); expect(createUserResponse.statusCode).toBe(201); const created = createUserResponse.json() as { id: number; resetUrl: string }; const firstToken = parseTokenFromResetUrl(created.resetUrl); const firstReset = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: firstToken, newPassword: 'ResetPass123', }, }); expect(firstReset.statusCode).toBe(204); const firstLogin = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username, password: 'ResetPass123', }, }); expect(firstLogin.statusCode).toBe(200); const secondResetResponse = await ctx.app.inject({ method: 'POST', url: `/api/v1/users/${created.id}/reset-password`, headers: authHeader(manageUsersAdmin.accessToken), }); expect(secondResetResponse.statusCode).toBe(201); const secondToken = parseTokenFromResetUrl((secondResetResponse.json() as { resetUrl: string }).resetUrl); const reuseFirstToken = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: firstToken, newPassword: 'ResetPass456', }, }); expectError(reuseFirstToken, 400, 'Invalid or expired reset token'); const useSecondToken = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/reset-password', payload: { token: secondToken, newPassword: 'ResetPass789', }, }); expect(useSecondToken.statusCode).toBe(204); const secondLogin = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username, password: 'ResetPass789', }, }); expect(secondLogin.statusCode).toBe(200); const oidcReset = await ctx.app.inject({ method: 'POST', url: `/api/v1/users/${oidcUser.userId}/reset-password`, headers: authHeader(ctx.adminToken), }); expectError(oidcReset, 400, 'OIDC accounts cannot reset their password here'); }); it('enforces default-password guard except allow-listed auth endpoints', async () => { const blockedSessions = await ctx.app.inject({ method: 'GET', url: '/api/v1/auth/sessions', headers: authHeader(defaultPasswordUser.accessToken), }); expectError(blockedSessions, 403, 'Password change required'); const allowedMe = await ctx.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: authHeader(defaultPasswordUser.accessToken), }); expect(allowedMe.statusCode).toBe(200); const changePassword = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/change-password', headers: authHeader(defaultPasswordUser.accessToken), payload: { currentPassword: defaultPasswordUser.password, newPassword: 'UpdatedDefaultPass123', }, }); expect(changePassword.statusCode).toBe(204); const loginAfterChange = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: defaultPasswordUser.username, password: 'UpdatedDefaultPass123', }, }); expect(loginAfterChange.statusCode).toBe(200); const newAccessToken = (loginAfterChange.json() as { accessToken: string }).accessToken; const sessionsAfterChange = await ctx.app.inject({ method: 'GET', url: '/api/v1/auth/sessions', headers: authHeader(newAccessToken), }); expect(sessionsAfterChange.statusCode).toBe(200); }); it('rejects login for inactive users', async () => { const inactiveLogin = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username: inactiveAssignable.username, password: inactiveAssignable.password, }, }); expect(inactiveLogin.statusCode).toBe(401); }); }); describe('assignable users contract', () => { it('requires manage_libraries and returns active non-superusers only', async () => { const unauthorized = await ctx.app.inject({ method: 'GET', url: '/api/v1/users/assignable', headers: authHeader(regularA.accessToken), }); expectError(unauthorized, 403, 'Missing permission: manage_libraries'); const authorized = await ctx.app.inject({ method: 'GET', url: '/api/v1/users/assignable', headers: authHeader(manageLibrariesAdmin.accessToken), }); expect(authorized.statusCode).toBe(200); const users = authorized.json() as Array<{ id: number }>; const returnedIds = new Set(users.map((entry) => entry.id)); expect(returnedIds.has(regularA.userId)).toBe(true); expect(returnedIds.has(manageUsersAdmin.userId)).toBe(true); expect(returnedIds.has(inactiveAssignable.userId)).toBe(false); expect(returnedIds.has(superuserTarget.userId)).toBe(false); }); }); async function uploadAvatar(token: string, fileName: string, content: Buffer, contentType: string) { const { body, boundary } = buildMultipartBody(fileName, content, contentType); return ctx.app.inject({ method: 'POST', url: '/api/v1/users/me/avatar', headers: { ...authHeader(token), 'content-type': `multipart/form-data; boundary=${boundary}`, 'content-length': String(body.length), }, payload: body, }); } });