test(users): add lifecycle E2E suite and harden admin contracts

This commit is contained in:
neon
2026-04-02 04:05:00 -06:00
parent 6115656153
commit 00ecdc34cd
15 changed files with 1321 additions and 33 deletions
@@ -0,0 +1,52 @@
name: Users Admin Lifecycle E2E
run-name: ${{ format('Users Admin Lifecycle E2E - {0}', github.ref_name) }}
on:
workflow_dispatch:
push:
paths:
- "server/src/modules/user/**"
- "server/src/modules/auth/**"
- "server/src/common/guards/**"
- "server/src/common/decorators/**"
- "server/src/common/types/**"
- "server/src/db/schema/**"
- "server/test/users-admin-lifecycle.e2e-spec.ts"
- "server/test/e2e/users-admin-lifecycle/**"
- "packages/types/src/auth.ts"
- "packages/types/src/permissions.ts"
- "scripts/test/**"
- "scripts/db/**"
- ".github/workflows/e2e-runner.yml"
pull_request:
paths:
- "server/src/modules/user/**"
- "server/src/modules/auth/**"
- "server/src/common/guards/**"
- "server/src/common/decorators/**"
- "server/src/common/types/**"
- "server/src/db/schema/**"
- "server/test/users-admin-lifecycle.e2e-spec.ts"
- "server/test/e2e/users-admin-lifecycle/**"
- "packages/types/src/auth.ts"
- "packages/types/src/permissions.ts"
- "scripts/test/**"
- "scripts/db/**"
- ".github/workflows/e2e-runner.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
checks: write
jobs:
users-admin-lifecycle-e2e:
uses: ./.github/workflows/e2e-runner.yml
with:
suite-id: users-admin-lifecycle
check-name: Server Users Admin Lifecycle E2E
artifact-name: users-admin-lifecycle-e2e-report
job-timeout-minutes: 50
+12 -1
View File
@@ -122,10 +122,12 @@ pnpm run test:e2e -- staging-ingest-finalize # staging ingest/finalize e2e suit
pnpm run test:e2e -- opds-auth-catalog # OPDS auth/catalog e2e suite (dedicated projectx_e2e DB)
pnpm run test:e2e -- reader-state-isolation # reader state isolation e2e suite (dedicated projectx_e2e DB)
pnpm run test:e2e -- email-lifecycle # email lifecycle e2e suite (dedicated projectx_e2e DB)
pnpm run test:e2e -- users-admin-lifecycle # users admin lifecycle e2e suite (dedicated projectx_e2e DB)
pnpm run test:e2e:all # alias for "pnpm run test:e2e -- all"
pnpm run test:e2e:opds-auth-catalog # alias for "pnpm run test:e2e -- opds-auth-catalog"
pnpm run test:e2e:reader-state-isolation # alias for "pnpm run test:e2e -- reader-state-isolation"
pnpm run test:e2e:email-lifecycle # alias for "pnpm run test:e2e -- email-lifecycle"
pnpm run test:e2e:users-admin-lifecycle # alias for "pnpm run test:e2e -- users-admin-lifecycle"
pnpm run test:e2e:list # list all supported e2e suite ids
```
@@ -197,6 +199,14 @@ pnpm run test:e2e -- scanner --testNamePattern=book-per-folder-disc-folder-flatt
- `email-lifecycle-e2e-junit.xml`
- `email-lifecycle-e2e-scenarios.json`
### Users admin lifecycle e2e details
- `pnpm run test:e2e -- users-admin-lifecycle` prepares and migrates the dedicated e2e database (`projectx_e2e`) before running.
- Local runs auto-start PostgreSQL with `docker-compose.dev.yml` if needed.
- Results are written to `test-results/server/`:
- `users-admin-lifecycle-e2e-junit.xml`
- `users-admin-lifecycle-e2e-scenarios.json`
### E2E in CI (how to trigger)
All suite workflows call the reusable `E2E Runner (reusable)` workflow, which runs:
@@ -213,11 +223,12 @@ pnpm run test:e2e -- <suite-id>
| OPDS Auth Catalog E2E | `opds-auth-catalog` | `workflow_dispatch`, `push` (opds-related paths), `pull_request` (same paths) |
| Reader State Isolation E2E | `reader-state-isolation` | `workflow_dispatch`, `push` (reader-state-related paths), `pull_request` (same paths) |
| Email Lifecycle E2E | `email-lifecycle` | `workflow_dispatch`, `push` (email-related paths), `pull_request` (same paths) |
| Users Admin Lifecycle E2E | `users-admin-lifecycle` | `workflow_dispatch`, `push` (users/auth-related paths), `pull_request` (same paths) |
Manual trigger steps:
1. Open GitHub Actions.
2. Select **Scanner E2E**, **Scanner File Ops E2E**, **Staging Ingest Finalize E2E**, **OPDS Auth Catalog E2E**, **Reader State Isolation E2E**, or **Email Lifecycle E2E**.
2. Select **Scanner E2E**, **Scanner File Ops E2E**, **Staging Ingest Finalize E2E**, **OPDS Auth Catalog E2E**, **Reader State Isolation E2E**, **Email Lifecycle E2E**, or **Users Admin Lifecycle E2E**.
3. Click **Run workflow**.
Each run uploads `test-results/server/` as an artifact and publishes JUnit annotations from `test-results/server/*-e2e-junit.xml`.
+7 -1
View File
@@ -154,12 +154,18 @@ function onSaved(newResetUrl?: string) {
<TooltipTrigger as-child>
<button
@click="handleResetPassword(user.id)"
:disabled="user.provisioningMethod === 'oidc'"
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
:class="
user.provisioningMethod === 'oidc' ? 'cursor-not-allowed opacity-50 hover:text-muted-foreground hover:bg-transparent' : ''
"
>
<KeyRound :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Reset password</TooltipContent>
<TooltipContent>
{{ user.provisioningMethod === 'oidc' ? 'OIDC users reset passwords in their identity provider' : 'Reset password' }}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
+1
View File
@@ -30,6 +30,7 @@
"test:e2e:metadata-write:concurrency": "node ./scripts/test/run-e2e-suite.mjs metadata-write --testNamePattern=\"concurrency hardening\"",
"test:e2e:reader-state-isolation": "node ./scripts/test/run-e2e-suite.mjs reader-state-isolation",
"test:e2e:email-lifecycle": "node ./scripts/test/run-e2e-suite.mjs email-lifecycle",
"test:e2e:users-admin-lifecycle": "node ./scripts/test/run-e2e-suite.mjs users-admin-lifecycle",
"test:e2e:list": "node ./scripts/test/run-e2e-suite.mjs --list",
"coverage": "pnpm run coverage:server && pnpm run coverage:client",
"coverage:server": "pnpm --filter server test:cov",
+8
View File
@@ -89,6 +89,14 @@ export const E2E_SUITES = Object.freeze({
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
},
"users-admin-lifecycle": {
id: "users-admin-lifecycle",
description: "Users admin lifecycle suite",
vitestTarget: "test/users-admin-lifecycle.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/users-admin-lifecycle-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
},
});
export function listE2ESuites() {
@@ -1,4 +1,5 @@
import { IsArray, IsEmail, IsInt, IsOptional, IsString, MinLength } from 'class-validator';
import { Permission } from '@projectx/types';
import { IsArray, IsEmail, IsEnum, IsInt, IsOptional, IsString, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@@ -13,9 +14,9 @@ export class CreateUserDto {
email: string;
@IsArray()
@IsString({ each: true })
@IsEnum(Permission, { each: true })
@IsOptional()
permissionNames?: string[];
permissionNames?: Permission[];
@IsArray()
@IsInt({ each: true })
@@ -1,7 +1,8 @@
import { IsArray, IsString } from 'class-validator';
import { Permission } from '@projectx/types';
import { IsArray, IsEnum } from 'class-validator';
export class SetPermissionsDto {
@IsArray()
@IsString({ each: true })
permissionNames: string[];
@IsEnum(Permission, { each: true })
permissionNames: Permission[];
}
+5 -4
View File
@@ -2,6 +2,7 @@ import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { Permission } from '@projectx/types';
import { CreateUserDto } from './dto/create-user.dto';
import { SetPermissionsDto } from './dto/set-permissions.dto';
@@ -13,7 +14,7 @@ async function hasErrors(dto: object): Promise<boolean> {
}
describe('User DTO validation', () => {
it('CreateUserDto requires email and enforces username minimum length and string permission names', async () => {
it('CreateUserDto requires email, enforces username minimum length, and validates permission enums', async () => {
const bad = plainToInstance(CreateUserDto, { username: 'ab', name: 'n', permissionNames: [1, 2] });
expect(await hasErrors(bad)).toBe(true);
@@ -21,14 +22,14 @@ describe('User DTO validation', () => {
username: 'alice',
name: 'Alice',
email: 'alice@example.com',
permissionNames: ['library_download'],
permissionNames: [Permission.LibraryDownload],
});
expect(await hasErrors(good)).toBe(false);
});
it('SetPermissionsDto requires an array of strings', async () => {
it('SetPermissionsDto requires an array of permission enums', async () => {
expect(await hasErrors(plainToInstance(SetPermissionsDto, { permissionNames: [1, 2] }))).toBe(true);
expect(await hasErrors(plainToInstance(SetPermissionsDto, { permissionNames: ['library_download'] }))).toBe(false);
expect(await hasErrors(plainToInstance(SetPermissionsDto, { permissionNames: [Permission.LibraryDownload] }))).toBe(false);
expect(await hasErrors(plainToInstance(SetPermissionsDto, { permissionNames: [] }))).toBe(false);
});
@@ -124,6 +124,7 @@ describe('UserRepository', () => {
active: true,
isSuperuser: false,
isDefaultPassword: false,
provisioningMethod: 'local',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
permissionName: 'library_download',
},
@@ -135,6 +136,7 @@ describe('UserRepository', () => {
active: true,
isSuperuser: false,
isDefaultPassword: false,
provisioningMethod: 'local',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
permissionName: 'kobo_sync',
},
@@ -152,6 +154,7 @@ describe('UserRepository', () => {
expect(result.users[0]).toMatchObject({
id: 10,
username: 'alice',
provisioningMethod: 'local',
permissions: ['library_download', 'kobo_sync'],
});
});
@@ -248,6 +251,14 @@ describe('UserRepository', () => {
await expect(repo.countOtherSuperusers(7)).resolves.toBe(3);
});
it('findExistingLibraryIds returns known IDs only', async () => {
const where = vi.fn().mockResolvedValue([{ id: 3 }, { id: 7 }]);
const from = vi.fn().mockReturnValue({ where });
select.mockReturnValue({ from });
await expect(repo.findExistingLibraryIds([3, 7, 9])).resolves.toEqual([3, 7]);
});
it('generateResetToken revokes previous active tokens and inserts a new hashed token in one transaction', async () => {
const tx = {
update: vi.fn().mockReturnThis(),
@@ -36,6 +36,7 @@ export class UserRepository {
active: schema.users.active,
isSuperuser: schema.users.isSuperuser,
isDefaultPassword: schema.users.isDefaultPassword,
provisioningMethod: schema.users.provisioningMethod,
createdAt: schema.users.createdAt,
permissionName: schema.userPermissions.permissionName,
})
@@ -52,6 +53,7 @@ export class UserRepository {
active: boolean;
isSuperuser: boolean;
isDefaultPassword: boolean;
provisioningMethod: string;
createdAt: Date;
permissions: Permission[];
};
@@ -67,6 +69,7 @@ export class UserRepository {
active: row.active,
isSuperuser: row.isSuperuser,
isDefaultPassword: row.isDefaultPassword,
provisioningMethod: row.provisioningMethod,
createdAt: row.createdAt,
permissions: [],
});
@@ -291,6 +294,12 @@ export class UserRepository {
return rows.map((r) => r.libraryId);
}
async findExistingLibraryIds(libraryIds: number[]): Promise<number[]> {
if (libraryIds.length === 0) return [];
const rows = await this.db.select({ id: schema.libraries.id }).from(schema.libraries).where(inArray(schema.libraries.id, libraryIds));
return rows.map((r) => r.id);
}
async replaceViewerLibraries(userId: number, libraryIds: number[]): Promise<void> {
await this.db.transaction(async (tx) => {
await tx.delete(schema.userLibraryAccess).where(eq(schema.userLibraryAccess.userId, userId));
+106 -13
View File
@@ -1,7 +1,7 @@
vi.mock('bcryptjs', () => ({ hash: vi.fn() }));
vi.mock('crypto', () => ({ randomBytes: vi.fn() }));
import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { hash } from 'bcryptjs';
import { randomBytes } from 'crypto';
import { Permission } from '@projectx/types';
@@ -33,31 +33,33 @@ describe('UserService', () => {
findByIdWithPermissions: vi.fn(),
create: vi.fn(),
findAll: vi.fn(),
findAssignable: vi.fn(),
update: vi.fn(),
countOtherSuperusers: vi.fn(),
delete: vi.fn(),
setSuperuser: vi.fn(),
assignViewerLibraries: vi.fn(),
findLibraryIdsByUserId: vi.fn(),
replaceViewerLibraries: vi.fn(),
findExistingLibraryIds: vi.fn(),
};
const config = { get: vi.fn() };
const db = {
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
};
let service: UserService;
beforeEach(() => {
vi.resetAllMocks();
service = new UserService(userRepo as any, config as any, db as any);
service = new UserService(userRepo as any, config as any);
mockHash.mockResolvedValue('hashed-secret');
mockRandomBytes.mockReturnValue(Buffer.from('abcd', 'hex'));
config.get.mockReturnValue('https://app.example.com');
userRepo.create.mockResolvedValue({ id: 10, username: 'newuser', name: 'New User' });
userRepo.findByEmail.mockResolvedValue(null);
userRepo.generateResetToken.mockResolvedValue('reset-token');
userRepo.findExistingLibraryIds.mockImplementation((ids: number[]) => Promise.resolve(ids));
});
it('createUser rejects duplicate usernames', async () => {
@@ -77,14 +79,15 @@ describe('UserService', () => {
);
});
it('createUser creates user, assigns requested permissions, and returns reset URL', async () => {
it('createUser creates user, deduplicates permission/library lists, and returns reset URL', async () => {
userRepo.findByUsername.mockResolvedValue(null);
const result = await service.createUser({
username: 'newuser',
name: 'New User',
email: 'x@y.com',
permissionNames: [Permission.LibraryDownload, Permission.KoboSync],
permissionNames: [Permission.LibraryDownload, Permission.KoboSync, Permission.LibraryDownload],
libraryIds: [3, 5, 3],
} as any);
expect(userRepo.create).toHaveBeenCalledWith(
@@ -97,9 +100,24 @@ describe('UserService', () => {
}),
);
expect(userRepo.setPermissions).toHaveBeenCalledWith(10, [Permission.LibraryDownload, Permission.KoboSync]);
expect(userRepo.assignViewerLibraries).toHaveBeenCalledWith(10, [3, 5]);
expect(result).toEqual({ id: 10, username: 'newuser', name: 'New User', resetUrl: 'https://app.example.com/reset-password?token=reset-token' });
});
it('createUser rejects unknown library IDs', async () => {
userRepo.findByUsername.mockResolvedValue(null);
userRepo.findExistingLibraryIds.mockResolvedValue([7]);
await expect(
service.createUser({
username: 'newuser',
name: 'New User',
email: 'x@y.com',
libraryIds: [7, 9],
} as any),
).rejects.toBeInstanceOf(BadRequestException);
});
it('updateUser blocks self-deactivation', async () => {
await expect(service.updateUser(1, { active: false }, reqUser({ id: 1 }))).rejects.toBeInstanceOf(ConflictException);
});
@@ -117,6 +135,26 @@ describe('UserService', () => {
await expect(service.updateUser(2, { active: false }, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException);
});
it('updateUser rejects duplicate email conflicts explicitly', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false });
userRepo.findByEmail.mockResolvedValue({ id: 9 });
await expect(service.updateUser(2, { email: 'dup@example.com' }, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException);
});
it('updateMe rejects duplicate email conflicts explicitly', async () => {
userRepo.findByEmail.mockResolvedValue({ id: 9 });
await expect(service.updateMe(2, { email: 'dup@example.com' })).rejects.toBeInstanceOf(ConflictException);
});
it('deleteUser throws when target user does not exist', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue(null);
userRepo.countOtherSuperusers.mockResolvedValue(0);
await expect(service.deleteUser(88, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(NotFoundException);
});
it('setPermissions blocks modifying own permissions', async () => {
await expect(service.setPermissions(1, { permissionNames: [] }, reqUser({ id: 1 }))).rejects.toBeInstanceOf(ConflictException);
});
@@ -127,12 +165,16 @@ describe('UserService', () => {
await expect(service.setPermissions(2, { permissionNames: [Permission.LibraryDownload] }, reqUser())).rejects.toBeInstanceOf(ForbiddenException);
});
it('setPermissions succeeds for superuser modifying another user', async () => {
it('setPermissions deduplicates permission names', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false });
await service.setPermissions(2, { permissionNames: [Permission.LibraryDownload] }, reqUser({ isSuperuser: true }));
await service.setPermissions(
2,
{ permissionNames: [Permission.LibraryDownload, Permission.KoboSync, Permission.LibraryDownload] },
reqUser({ isSuperuser: true }),
);
expect(userRepo.setPermissions).toHaveBeenCalledWith(2, [Permission.LibraryDownload]);
expect(userRepo.setPermissions).toHaveBeenCalledWith(2, [Permission.LibraryDownload, Permission.KoboSync]);
});
it('setSuperuser blocks non-superuser from changing superuser status', async () => {
@@ -143,14 +185,59 @@ describe('UserService', () => {
await expect(service.setSuperuser(1, false, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException);
});
it('setSuperuser throws when target user does not exist', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue(null);
await expect(service.setSuperuser(22, false, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(NotFoundException);
});
it('setSuperuser prevents removing the last administrator', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true });
userRepo.countOtherSuperusers.mockResolvedValue(0);
await expect(service.setSuperuser(2, false, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException);
});
it('setSuperuser skips last-admin check when target is already non-superuser', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false });
await service.setSuperuser(2, false, reqUser({ isSuperuser: true }));
expect(userRepo.countOtherSuperusers).not.toHaveBeenCalled();
expect(userRepo.setSuperuser).toHaveBeenCalledWith(2, false);
});
it('getLibraryIds throws when target user does not exist', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue(null);
await expect(service.getLibraryIds(4)).rejects.toBeInstanceOf(NotFoundException);
});
it('getLibraryIds delegates to repository when user exists', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 4, isSuperuser: false });
userRepo.findLibraryIdsByUserId.mockResolvedValue([11, 12]);
await expect(service.getLibraryIds(4)).resolves.toEqual([11, 12]);
});
it('setLibraries rejects unknown libraries', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false });
userRepo.findExistingLibraryIds.mockResolvedValue([3]);
await expect(service.setLibraries(2, [3, 7], reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(BadRequestException);
});
it('setLibraries deduplicates IDs before replacing access rows', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false });
userRepo.findExistingLibraryIds.mockResolvedValue([3, 7]);
await service.setLibraries(2, [3, 7, 3], reqUser({ isSuperuser: true }));
expect(userRepo.replaceViewerLibraries).toHaveBeenCalledWith(2, [3, 7]);
});
it('adminResetPassword forbids non-superuser reset of superuser account', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true });
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true, provisioningMethod: 'local' });
await expect(service.adminResetPassword(2, reqUser())).rejects.toBeInstanceOf(ForbiddenException);
});
@@ -160,4 +247,10 @@ describe('UserService', () => {
await expect(service.adminResetPassword(9, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(NotFoundException);
});
it('adminResetPassword rejects OIDC users', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false, provisioningMethod: 'oidc' });
await expect(service.adminResetPassword(2, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(BadRequestException);
});
});
+54 -8
View File
@@ -1,4 +1,4 @@
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { hash } from 'bcryptjs';
import { randomBytes } from 'crypto';
@@ -83,12 +83,15 @@ export class UserService {
isDefaultPassword: true,
});
if (dto.permissionNames?.length) {
await this.userRepo.setPermissions(user.id, dto.permissionNames as Permission[]);
const permissionNames = this.uniquePermissions(dto.permissionNames ?? []);
if (permissionNames.length > 0) {
await this.userRepo.setPermissions(user.id, permissionNames);
}
if (dto.libraryIds?.length) {
await this.userRepo.assignViewerLibraries(user.id, dto.libraryIds);
const libraryIds = this.uniqueIds(dto.libraryIds ?? []);
if (libraryIds.length > 0) {
await this.assertKnownLibraryIds(libraryIds);
await this.userRepo.assignViewerLibraries(user.id, libraryIds);
}
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
@@ -110,6 +113,10 @@ export class UserService {
throw new ForbiddenException('Only administrators can edit administrator accounts');
}
if (dto.email !== undefined && dto.email !== null) {
await this.assertEmailAvailable(dto.email, id);
}
if (dto.active === false && target.isSuperuser) {
const otherSuperusers = await this.userRepo.countOtherSuperusers(id);
if (otherSuperusers === 0) {
@@ -123,6 +130,9 @@ export class UserService {
}
async updateMe(userId: number, dto: UpdateMeDto) {
if (dto.email !== undefined && dto.email !== null) {
await this.assertEmailAvailable(dto.email, userId);
}
const user = await this.userRepo.update(userId, dto);
if (!user) throw new NotFoundException('User not found');
return user;
@@ -133,6 +143,7 @@ export class UserService {
throw new ConflictException('You cannot delete your own account');
}
const [target, otherSuperusers] = await Promise.all([this.userRepo.findByIdWithPermissions(id), this.userRepo.countOtherSuperusers(id)]);
if (!target) throw new NotFoundException('User not found');
if (target?.isSuperuser) {
if (!requestingUser.isSuperuser) throw new ForbiddenException('Only administrators can delete administrator accounts');
if (otherSuperusers === 0) throw new ConflictException('Cannot delete the last administrator');
@@ -155,7 +166,8 @@ export class UserService {
throw new ForbiddenException('Only administrators can modify administrator permissions');
}
await this.userRepo.setPermissions(targetUserId, dto.permissionNames as Permission[]);
const permissionNames = this.uniquePermissions(dto.permissionNames);
await this.userRepo.setPermissions(targetUserId, permissionNames);
}
async setSuperuser(targetUserId: number, isSuperuser: boolean, requestingUser: RequestUser) {
@@ -165,7 +177,9 @@ export class UserService {
if (targetUserId === requestingUser.id) {
throw new ConflictException('You cannot change your own superuser status');
}
if (!isSuperuser) {
const target = await this.userRepo.findByIdWithPermissions(targetUserId);
if (!target) throw new NotFoundException('User not found');
if (!isSuperuser && target.isSuperuser) {
const otherSuperusers = await this.userRepo.countOtherSuperusers(targetUserId);
if (otherSuperusers === 0) {
throw new ConflictException('Cannot remove the last administrator');
@@ -175,6 +189,8 @@ export class UserService {
}
async getLibraryIds(userId: number): Promise<number[]> {
const user = await this.userRepo.findByIdWithPermissions(userId);
if (!user) throw new NotFoundException('User not found');
return this.userRepo.findLibraryIdsByUserId(userId);
}
@@ -185,7 +201,9 @@ export class UserService {
throw new ForbiddenException('Only administrators can edit administrator accounts');
}
await this.userRepo.replaceViewerLibraries(targetUserId, libraryIds);
const normalizedLibraryIds = this.uniqueIds(libraryIds);
await this.assertKnownLibraryIds(normalizedLibraryIds);
await this.userRepo.replaceViewerLibraries(targetUserId, normalizedLibraryIds);
}
async adminResetPassword(targetUserId: number, requestingUser: RequestUser) {
@@ -194,8 +212,36 @@ export class UserService {
if (target.isSuperuser && !requestingUser.isSuperuser) {
throw new ForbiddenException('Only administrators can reset administrator passwords');
}
if (target.provisioningMethod === 'oidc') {
throw new BadRequestException('OIDC accounts cannot reset their password here');
}
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
const rawToken = await this.userRepo.generateResetToken(targetUserId);
return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` };
}
private uniquePermissions(permissionNames: Permission[]): Permission[] {
return Array.from(new Set(permissionNames));
}
private uniqueIds(ids: number[]): number[] {
return Array.from(new Set(ids));
}
private async assertEmailAvailable(email: string, targetUserId: number): Promise<void> {
const existing = await this.userRepo.findByEmail(email);
if (existing && existing.id !== targetUserId) {
throw new ConflictException('Email already in use');
}
}
private async assertKnownLibraryIds(libraryIds: number[]): Promise<void> {
if (libraryIds.length === 0) return;
const existingIds = await this.userRepo.findExistingLibraryIds(libraryIds);
const existingSet = new Set(existingIds);
const missing = libraryIds.filter((id) => !existingSet.has(id));
if (missing.length > 0) {
throw new BadRequestException(`Unknown library IDs: ${missing.join(', ')}`);
}
}
}
@@ -0,0 +1,26 @@
import { mkdir, mkdtemp, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
export interface UsersAdminLifecycleFixtureRoot {
rootPath: string;
booksPath: string;
stagingPath: string;
cleanup: () => Promise<void>;
}
export async function createUsersAdminLifecycleFixtureRoot(prefix = 'users-admin-lifecycle-e2e-'): Promise<UsersAdminLifecycleFixtureRoot> {
const rootPath = await mkdtemp(join(tmpdir(), prefix));
const booksPath = join(rootPath, 'books');
const stagingPath = join(booksPath, 'staging');
await mkdir(stagingPath, { recursive: true });
return {
rootPath,
booksPath,
stagingPath,
cleanup: async () => {
await rm(rootPath, { recursive: true, force: true });
},
};
}
@@ -0,0 +1,325 @@
import { randomUUID } from 'crypto';
import { hash } from 'bcryptjs';
import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
import { ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { mkdir } from 'fs/promises';
import { eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { Permission } from '@projectx/types';
import { AppModule } from '../../../src/app.module';
import { DB } from '../../../src/db';
import * as schema from '../../../src/db/schema';
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service';
import { seedLibrary } from '../app-harness';
import { createUsersAdminLifecycleFixtureRoot, type UsersAdminLifecycleFixtureRoot } from './users-admin-lifecycle-fixture-builder';
type Db = NodePgDatabase<typeof schema>;
const ADMIN_SETUP_DTO = {
username: 'users-admin-lifecycle-e2e-admin',
name: 'Users Admin Lifecycle E2E Admin',
email: 'users-admin-lifecycle-e2e-admin@example.com',
password: 'UsersAdminLifecycleE2E123',
};
const MAX_MULTIPART_BYTES = 20 * 1024 * 1024;
interface EnvSnapshot {
booksPath: string | undefined;
stagingPath: string | undefined;
}
export interface UsersAdminLifecycleE2EContext {
app: NestFastifyApplication;
db: Db;
adminToken: string;
fixture: UsersAdminLifecycleFixtureRoot;
envSnapshot: EnvSnapshot;
}
export interface CreatedLibrary {
libraryId: number;
libraryFolderId: number;
folderPath: string;
}
export interface TestUserSession {
userId: number;
username: string;
password: string;
accessToken: string;
}
export interface OidcUserSeed {
userId: number;
username: string;
email: string;
}
export function authHeader(token: string): Record<string, string> {
return { authorization: `Bearer ${token}` };
}
export async function createUsersAdminLifecycleE2EContext(): Promise<UsersAdminLifecycleE2EContext> {
const fixture = await createUsersAdminLifecycleFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
stagingPath: process.env.STAGING_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.STAGING_PATH = fixture.stagingPath;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(MetadataService)
.useValue(makeMetadataNoopMock())
.compile();
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.register(fastifyCookie as never);
await app.register(fastifyMultipart as never, { limits: { fileSize: MAX_MULTIPART_BYTES } });
await app.init();
await app.getHttpAdapter().getInstance().ready();
await stopStagingWatcher(app);
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app, db);
return {
app,
db,
adminToken,
fixture,
envSnapshot,
};
}
export async function closeUsersAdminLifecycleE2EContext(ctx: UsersAdminLifecycleE2EContext): Promise<void> {
await ctx.app.close();
await ctx.fixture.cleanup();
restoreEnv(ctx.envSnapshot);
}
export async function createLibraryWithFolder(
ctx: UsersAdminLifecycleE2EContext,
options: {
mode?: 'book_per_file' | 'book_per_folder';
allowedFormats?: string[];
name?: string;
} = {},
): Promise<CreatedLibrary> {
const folderPath = `${ctx.fixture.booksPath}/library-${randomUUID()}`;
await mkdir(folderPath, { recursive: true });
const { libraryId, libraryFolderId } = await seedLibrary(ctx.db, {
rootPath: folderPath,
mode: options.mode ?? 'book_per_file',
allowedFormats: options.allowedFormats ?? [],
watch: false,
name: options.name,
});
return {
libraryId,
libraryFolderId,
folderPath,
};
}
export async function createUserAndLogin(
ctx: UsersAdminLifecycleE2EContext,
options: {
permissions?: Permission[];
isSuperuser?: boolean;
isDefaultPassword?: boolean;
active?: boolean;
username?: string;
password?: string;
email?: string;
} = {},
): Promise<TestUserSession> {
const suffix = randomUUID().replaceAll('-', '');
const username = options.username ?? `users-admin-lifecycle-${suffix}`;
const password = options.password ?? 'UsersAdminLifecycleUser123';
const email = options.email ?? `${username}@example.com`;
const passwordHash = await hash(password, 12);
const [created] = await ctx.db
.insert(schema.users)
.values({
username,
name: `Users Admin Lifecycle ${suffix}`,
email,
passwordHash,
active: options.active ?? true,
isSuperuser: options.isSuperuser ?? false,
isDefaultPassword: options.isDefaultPassword ?? false,
provisioningMethod: 'local',
})
.returning({ id: schema.users.id });
const permissions = options.permissions ?? [];
if (permissions.length > 0) {
await ctx.db.insert(schema.userPermissions).values(permissions.map((permissionName) => ({ userId: created.id, permissionName })));
}
const accessToken = await loginForToken(ctx.app, username, password);
if (!accessToken) {
throw new Error(`Login failed for ${username}`);
}
return {
userId: created.id,
username,
password,
accessToken,
};
}
export async function createOidcUser(
ctx: UsersAdminLifecycleE2EContext,
options: {
username?: string;
email?: string;
} = {},
): Promise<OidcUserSeed> {
const suffix = randomUUID().replaceAll('-', '');
const username = options.username ?? `users-admin-lifecycle-oidc-${suffix}`;
const email = options.email ?? `${username}@example.com`;
const passwordHash = await hash(`OIDC_${suffix}`, 12);
const [created] = await ctx.db
.insert(schema.users)
.values({
username,
name: `Users Admin Lifecycle OIDC ${suffix}`,
email,
passwordHash,
active: true,
isSuperuser: false,
isDefaultPassword: false,
provisioningMethod: 'oidc',
oidcSubject: `oidc-subject-${suffix}`,
oidcIssuer: 'https://oidc.example.com',
})
.returning({ id: schema.users.id });
return {
userId: created.id,
username,
email,
};
}
export async function grantLibraryAccess(
ctx: UsersAdminLifecycleE2EContext,
userId: number,
libraryId: number,
accessLevel: 'viewer' | 'editor' | 'owner' = 'viewer',
): Promise<void> {
await ctx.db
.insert(schema.userLibraryAccess)
.values({ userId, libraryId, accessLevel })
.onConflictDoUpdate({
target: [schema.userLibraryAccess.userId, schema.userLibraryAccess.libraryId],
set: { accessLevel },
});
}
export async function setUserActive(ctx: UsersAdminLifecycleE2EContext, userId: number, active: boolean): Promise<void> {
await ctx.db.update(schema.users).set({ active }).where(eq(schema.users.id, userId));
}
async function stopStagingWatcher(app: NestFastifyApplication): Promise<void> {
const watcher = app.get(StagingWatcherService);
await watcher.onModuleDestroy();
}
async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<string> {
const response = await app.inject({
method: 'POST',
url: '/api/v1/auth/setup',
payload: ADMIN_SETUP_DTO,
});
if (response.statusCode === 409) {
const existingToken = await loginForToken(app, ADMIN_SETUP_DTO.username, ADMIN_SETUP_DTO.password);
if (existingToken) return existingToken;
const suffix = randomUUID().replaceAll('-', '');
const fallbackUsername = `users-admin-lifecycle-admin-${suffix}`;
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 12);
await db.insert(schema.users).values({
username: fallbackUsername,
name: 'Users Admin Lifecycle E2E Admin',
email: `${fallbackUsername}@example.com`,
passwordHash,
isSuperuser: true,
isDefaultPassword: false,
provisioningMethod: 'local',
});
const fallbackToken = await loginForToken(app, fallbackUsername, ADMIN_SETUP_DTO.password);
if (fallbackToken) return fallbackToken;
throw new Error('Setup is already complete and fallback admin login failed');
}
if (response.statusCode !== 201) {
throw new Error(`Unable to complete setup: ${response.statusCode} ${response.body}`);
}
const body = response.json() as { accessToken?: string };
if (!body.accessToken) {
throw new Error('Setup succeeded but accessToken is missing');
}
return body.accessToken;
}
async function loginForToken(app: NestFastifyApplication, username: string, password: string): Promise<string | null> {
const response = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { username, password },
});
if (response.statusCode !== 200) return null;
const body = response.json() as { accessToken?: string };
return body.accessToken ?? null;
}
function makeMetadataNoopMock(): Pick<
MetadataService,
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
> {
return {
extractAndSave: () => Promise.resolve(undefined),
refreshCoverForBook: () => Promise.resolve(false),
extractAudioFileDuration: () => Promise.resolve(undefined),
aggregateAudioDuration: () => Promise.resolve(undefined),
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
};
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH;
else process.env.STAGING_PATH = snapshot.stagingPath;
}
@@ -0,0 +1,697 @@
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import type { Permission } from '@projectx/types';
import { Permission as PermissionEnum } from '@projectx/types';
import {
authHeader,
closeUsersAdminLifecycleE2EContext,
createLibraryWithFolder,
createOidcUser,
createUserAndLogin,
createUsersAdminLifecycleE2EContext,
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);
}
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 = `----projectx-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('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+lmL8AAAAASUVORK5CYII=', '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).toEqual([PermissionEnum.LibraryDownload, PermissionEnum.KoboSync]);
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');
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),
);
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');
});
});
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('allows superuser toggling for positive superuser transfer flows', async () => {
const candidate = await createUserAndLogin(ctx);
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);
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 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);
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);
});
});
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');
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, 409, 'Email already in use');
});
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',
settings: { syncReaderPreferences: true },
},
});
expect(updateProfile.statusCode).toBe(200);
const updatedProfile = updateProfile.json() as { name: string; settings: { syncReaderPreferences?: boolean } };
expect(updatedProfile.name).toBe('Regular A Updated');
expect(updatedProfile.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([200, 201]).toContain(secondResetResponse.statusCode);
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);
});
});
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,
});
}
});