mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
59e3f36f5e
Library access roles had no effect on metadata editing: only the global Edit metadata permission was checked, so a Viewer could edit every library they could see and an Editor could not be limited to one library. Require Editor or Owner on the book's library for every metadata write, including bulk edits, locks, covers, file renames, entity manager changes and author edits, and hide the matching actions in the web client. Keep existing access levels when an admin saves a user's library list. The old replace reset every row to Viewer, which would now silently revoke editing on each user save. Existing grants are Viewer, so after upgrading, admins must set Editor on each library where a non-superuser should keep editing metadata. The released iOS app already gates editing on Editor. Closes #1778
956 lines
37 KiB
TypeScript
956 lines
37 KiB
TypeScript
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<void> {
|
|
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<ReturnType<UsersAdminLifecycleE2EContext['app']['inject']>>, 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<void> {
|
|
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<string, unknown>;
|
|
}> = [
|
|
{ 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<string, unknown>;
|
|
}> = [
|
|
{ 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<string, unknown>;
|
|
}> = [
|
|
{ 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<Record<string, unknown>> = [{ 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,
|
|
});
|
|
}
|
|
});
|