From 00ecdc34cd3589ad2c7217a9191697736e1efd6a Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 2 Apr 2026 04:05:00 -0600 Subject: [PATCH] test(users): add lifecycle E2E suite and harden admin contracts --- .../workflows/e2e-users-admin-lifecycle.yml | 52 ++ DEVELOPMENT.md | 13 +- client/src/features/admin/UsersPage.vue | 8 +- package.json | 1 + scripts/test/e2e-suites.mjs | 8 + .../src/modules/user/dto/create-user.dto.ts | 7 +- .../modules/user/dto/set-permissions.dto.ts | 7 +- server/src/modules/user/user.dto.test.ts | 9 +- .../src/modules/user/user.repository.test.ts | 11 + server/src/modules/user/user.repository.ts | 9 + server/src/modules/user/user.service.test.ts | 119 ++- server/src/modules/user/user.service.ts | 62 +- .../users-admin-lifecycle-fixture-builder.ts | 26 + .../users-admin-lifecycle-harness.ts | 325 ++++++++ server/test/users-admin-lifecycle.e2e-spec.ts | 697 ++++++++++++++++++ 15 files changed, 1321 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/e2e-users-admin-lifecycle.yml create mode 100644 server/test/e2e/users-admin-lifecycle/users-admin-lifecycle-fixture-builder.ts create mode 100644 server/test/e2e/users-admin-lifecycle/users-admin-lifecycle-harness.ts create mode 100644 server/test/users-admin-lifecycle.e2e-spec.ts diff --git a/.github/workflows/e2e-users-admin-lifecycle.yml b/.github/workflows/e2e-users-admin-lifecycle.yml new file mode 100644 index 000000000..0e8025d08 --- /dev/null +++ b/.github/workflows/e2e-users-admin-lifecycle.yml @@ -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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 44adaebb9..bebf57fba 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 -- | 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`. diff --git a/client/src/features/admin/UsersPage.vue b/client/src/features/admin/UsersPage.vue index a24f6eb61..a089dd600 100644 --- a/client/src/features/admin/UsersPage.vue +++ b/client/src/features/admin/UsersPage.vue @@ -154,12 +154,18 @@ function onSaved(newResetUrl?: string) { - Reset password + + {{ user.provisioningMethod === 'oidc' ? 'OIDC users reset passwords in their identity provider' : 'Reset password' }} + diff --git a/package.json b/package.json index 20f6a98e0..4050b1cbd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/test/e2e-suites.mjs b/scripts/test/e2e-suites.mjs index 775aceeb5..20a42fa7d 100644 --- a/scripts/test/e2e-suites.mjs +++ b/scripts/test/e2e-suites.mjs @@ -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() { diff --git a/server/src/modules/user/dto/create-user.dto.ts b/server/src/modules/user/dto/create-user.dto.ts index 77de29723..7a0cf777a 100644 --- a/server/src/modules/user/dto/create-user.dto.ts +++ b/server/src/modules/user/dto/create-user.dto.ts @@ -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 }) diff --git a/server/src/modules/user/dto/set-permissions.dto.ts b/server/src/modules/user/dto/set-permissions.dto.ts index 36c699365..14063e6b0 100644 --- a/server/src/modules/user/dto/set-permissions.dto.ts +++ b/server/src/modules/user/dto/set-permissions.dto.ts @@ -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[]; } diff --git a/server/src/modules/user/user.dto.test.ts b/server/src/modules/user/user.dto.test.ts index 84c613780..3f589baac 100644 --- a/server/src/modules/user/user.dto.test.ts +++ b/server/src/modules/user/user.dto.test.ts @@ -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 { } 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); }); diff --git a/server/src/modules/user/user.repository.test.ts b/server/src/modules/user/user.repository.test.ts index cc191c3ca..9d76df20f 100644 --- a/server/src/modules/user/user.repository.test.ts +++ b/server/src/modules/user/user.repository.test.ts @@ -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(), diff --git a/server/src/modules/user/user.repository.ts b/server/src/modules/user/user.repository.ts index 421378ab2..dbe22409c 100644 --- a/server/src/modules/user/user.repository.ts +++ b/server/src/modules/user/user.repository.ts @@ -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 { + 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 { await this.db.transaction(async (tx) => { await tx.delete(schema.userLibraryAccess).where(eq(schema.userLibraryAccess.userId, userId)); diff --git a/server/src/modules/user/user.service.test.ts b/server/src/modules/user/user.service.test.ts index 89b4ec81b..b5707c455 100644 --- a/server/src/modules/user/user.service.test.ts +++ b/server/src/modules/user/user.service.test.ts @@ -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); + }); }); diff --git a/server/src/modules/user/user.service.ts b/server/src/modules/user/user.service.ts index 97c1d3ed2..17545d22a 100644 --- a/server/src/modules/user/user.service.ts +++ b/server/src/modules/user/user.service.ts @@ -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('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 { + 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('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 { + 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 { + 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(', ')}`); + } + } } diff --git a/server/test/e2e/users-admin-lifecycle/users-admin-lifecycle-fixture-builder.ts b/server/test/e2e/users-admin-lifecycle/users-admin-lifecycle-fixture-builder.ts new file mode 100644 index 000000000..747b914fa --- /dev/null +++ b/server/test/e2e/users-admin-lifecycle/users-admin-lifecycle-fixture-builder.ts @@ -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; +} + +export async function createUsersAdminLifecycleFixtureRoot(prefix = 'users-admin-lifecycle-e2e-'): Promise { + 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 }); + }, + }; +} diff --git a/server/test/e2e/users-admin-lifecycle/users-admin-lifecycle-harness.ts b/server/test/e2e/users-admin-lifecycle/users-admin-lifecycle-harness.ts new file mode 100644 index 000000000..34a72a51c --- /dev/null +++ b/server/test/e2e/users-admin-lifecycle/users-admin-lifecycle-harness.ts @@ -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; + +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 { + return { authorization: `Bearer ${token}` }; +} + +export async function createUsersAdminLifecycleE2EContext(): Promise { + 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(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); + const adminToken = await getAdminToken(app, db); + + return { + app, + db, + adminToken, + fixture, + envSnapshot, + }; +} + +export async function closeUsersAdminLifecycleE2EContext(ctx: UsersAdminLifecycleE2EContext): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await ctx.db.update(schema.users).set({ active }).where(eq(schema.users.id, userId)); +} + +async function stopStagingWatcher(app: NestFastifyApplication): Promise { + const watcher = app.get(StagingWatcherService); + await watcher.onModuleDestroy(); +} + +async function getAdminToken(app: NestFastifyApplication, db: Db): Promise { + 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 { + 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; +} diff --git a/server/test/users-admin-lifecycle.e2e-spec.ts b/server/test/users-admin-lifecycle.e2e-spec.ts new file mode 100644 index 000000000..e9219b6dd --- /dev/null +++ b/server/test/users-admin-lifecycle.e2e-spec.ts @@ -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 { + const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server'); + await mkdir(reportDir, { recursive: true }); + const reportPath = join(reportDir, 'users-admin-lifecycle-e2e-scenarios.json'); + await writeFile( + reportPath, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + total: results.length, + passed: results.filter((result) => result.status === 'passed').length, + failed: results.filter((result) => result.status === 'failed').length, + results, + }, + null, + 2, + ), + ); +} + +function responseMessage(response: { message?: string | string[] }): string { + if (Array.isArray(response.message)) return response.message.join(' '); + return String(response.message ?? ''); +} + +function expectError(response: Awaited>, status: number, messageFragment: string): void { + expect(response.statusCode).toBe(status); + const message = responseMessage(response.json() as { message?: string | string[] }); + expect(message).toContain(messageFragment); +} + +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; + }> = [ + { 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, + }); + } +});