mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
test(users): add lifecycle E2E suite and harden admin contracts
This commit is contained in:
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user