mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
ci: run E2E only on nightly schedule, skip E2E on PR/push/manual CI, keep manual e2e workflow for on-demand runs, and align matrix/docs accordingly
This commit is contained in:
@@ -343,7 +343,7 @@ jobs:
|
||||
name: Detect E2E Suites
|
||||
runs-on: ubuntu-latest
|
||||
needs: [quality-gate]
|
||||
if: needs.quality-gate.result == 'success'
|
||||
if: always() && needs.quality-gate.result == 'success' && github.event_name == 'schedule'
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
matrix: ${{ steps.build-matrix.outputs.matrix }}
|
||||
@@ -372,7 +372,7 @@ jobs:
|
||||
|
||||
e2e:
|
||||
needs: [e2e-setup]
|
||||
if: needs.e2e-setup.outputs.matrix != '[]'
|
||||
if: github.event_name == 'schedule' && needs.e2e-setup.outputs.matrix != '[]'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
+1
-1
@@ -383,7 +383,7 @@ For the full release runbook, see [`docs/RELEASE_PROCESS.md`](docs/RELEASE_PROCE
|
||||
|
||||
### E2E in CI
|
||||
|
||||
E2E suites are dispatched automatically based on which files changed. On nightly builds and manual dispatches, all suites run. Each suite runs in its own job with a dedicated PostgreSQL service container.
|
||||
E2E suites run only on nightly scheduled CI builds. Each suite runs in its own job with a dedicated PostgreSQL service container. For on-demand E2E runs, use the manual `.github/workflows/e2e.yml` workflow.
|
||||
|
||||
### Reading CI Failures
|
||||
|
||||
|
||||
+2
-3
@@ -313,9 +313,8 @@ report artifacts are retained for 7 days.
|
||||
|
||||
E2E suites are selected by `scripts/e2e/select-matrix.mjs`:
|
||||
|
||||
- **Pull request:** The smoke suite (`guard-mechanics`) always runs. Full-lane suites run only
|
||||
when their `changedPaths` match the PR diff.
|
||||
- **Push to main / nightly / manual dispatch:** All 18 suites run.
|
||||
- **Nightly schedule (`CI`):** All 18 suites run.
|
||||
- **Pull request / push to main / manual `CI` dispatch:** E2E is skipped in `.github/workflows/ci.yml`.
|
||||
|
||||
Each selected suite runs as its own GitHub Actions job via the reusable
|
||||
`.github/workflows/e2e-runner.yml` workflow, which spins up a PostgreSQL 16 + pgvector service
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { getE2ESuite, listE2ESuites } from "./suite-registry.mjs";
|
||||
|
||||
const DEFAULT_E2E_DATABASE_URL = "postgres://projectx:projectx@localhost:5432/projectx_e2e";
|
||||
const DEFAULT_E2E_DATABASE_URL = "postgres://bookorbit:bookorbit@localhost:5432/bookorbit_e2e";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(scriptDir, "..", "..");
|
||||
|
||||
@@ -56,12 +56,12 @@ function toMatrixItems(suites) {
|
||||
function selectSuitesForEvent(eventName, changedFiles) {
|
||||
const suites = listE2ESuites();
|
||||
|
||||
if (eventName === "workflow_dispatch" || eventName === "push" || eventName === "schedule") {
|
||||
if (eventName === "schedule") {
|
||||
return toMatrixItems(suites);
|
||||
}
|
||||
|
||||
if (eventName !== "pull_request") {
|
||||
return toMatrixItems(suites);
|
||||
return [];
|
||||
}
|
||||
|
||||
const smokeSuites = suites.filter((suite) => suite.lane === "smoke");
|
||||
|
||||
@@ -114,6 +114,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
||||
oidcDiscovery as never,
|
||||
{ emit: vi.fn() } as never,
|
||||
magicLinkRepo as never,
|
||||
appSettings as never,
|
||||
db,
|
||||
);
|
||||
|
||||
@@ -391,7 +392,7 @@ describe('AuthService', () => {
|
||||
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
'signed-jwt',
|
||||
expect.objectContaining({ path: '/api', secure: 'auto', sameSite: 'strict' }),
|
||||
expect.objectContaining({ path: '/api', secure: 'auto', sameSite: 'lax' }),
|
||||
);
|
||||
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
|
||||
'refresh_token',
|
||||
|
||||
@@ -38,6 +38,7 @@ import { SetupDto } from './dto/setup.dto';
|
||||
import { OidcDiscoveryService } from './oidc/oidc-discovery.service';
|
||||
import { OidcSessionRepository } from './oidc/oidc-session.repository';
|
||||
import { MagicLinkRepository } from './magic-link.repository';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
|
||||
function parseDurationMs(duration: string): number {
|
||||
const match = duration.match(/^(\d+)([smhd])$/);
|
||||
@@ -76,6 +77,7 @@ export class AuthService {
|
||||
private readonly oidcDiscovery: OidcDiscoveryService,
|
||||
private readonly auditEvents: AuditEventsService,
|
||||
private readonly magicLinkRepo: MagicLinkRepository,
|
||||
private readonly appSettings: AppSettingsService,
|
||||
@Inject(DB) private readonly db: NodePgDatabase<typeof schema>,
|
||||
) {}
|
||||
|
||||
@@ -388,11 +390,18 @@ export class AuthService {
|
||||
|
||||
const oidcLogoutStart = Date.now();
|
||||
try {
|
||||
if (!oidcSession.providerId) return {};
|
||||
const provider = await this.db.query.oidcProviders.findFirst({ where: eq(schema.oidcProviders.id, oidcSession.providerId) });
|
||||
if (!provider?.enabled) return {};
|
||||
let issuerUri: string;
|
||||
if (oidcSession.providerId) {
|
||||
const provider = await this.db.query.oidcProviders.findFirst({ where: eq(schema.oidcProviders.id, oidcSession.providerId) });
|
||||
if (!provider?.enabled) return {};
|
||||
issuerUri = provider.issuerUri;
|
||||
} else {
|
||||
const oidcConfig = await this.appSettings.getOidcConfig();
|
||||
if (!oidcConfig.enabled) return {};
|
||||
issuerUri = oidcSession.oidcIssuer;
|
||||
}
|
||||
|
||||
const disc = await this.oidcDiscovery.getDiscoveryDoc(provider.issuerUri);
|
||||
const disc = await this.oidcDiscovery.getDiscoveryDoc(issuerUri);
|
||||
if (!disc.endSessionEndpoint) return {};
|
||||
|
||||
const origin = req.headers['origin'] ?? req.headers['referer'];
|
||||
@@ -649,7 +658,7 @@ export class AuthService {
|
||||
const ttlSeconds = parseDurationMs(this.config.get<string>('auth.jwtExpiresIn') ?? '15m') / 1000;
|
||||
reply.setCookie('access_token', accessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
sameSite: 'lax',
|
||||
path: '/api',
|
||||
maxAge: ttlSeconds,
|
||||
secure: 'auto',
|
||||
@@ -659,7 +668,7 @@ export class AuthService {
|
||||
private clearAccessCookie(reply: FastifyReply) {
|
||||
reply.setCookie('access_token', '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
sameSite: 'lax',
|
||||
path: '/api',
|
||||
maxAge: 0,
|
||||
secure: 'auto',
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('BookController', () => {
|
||||
expect(bookService.embedAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns 304 when cover etag matches (unversioned → 1h cache)', async () => {
|
||||
it('returns 304 when cover etag matches (unversioned → 24h private cache)', async () => {
|
||||
const { controller, bookService } = makeController();
|
||||
const { reply, headers } = makeReply();
|
||||
bookService.getCoverPath.mockResolvedValue('/tmp/cover.jpg');
|
||||
@@ -187,7 +187,7 @@ describe('BookController', () => {
|
||||
await controller.getCover(7, makeUser(), reply, undefined, '"1234"');
|
||||
|
||||
expect(reply.status).toHaveBeenCalledWith(304);
|
||||
expect(headers['Cache-Control']).toBe('public, max-age=3600');
|
||||
expect(headers['Cache-Control']).toBe('private, max-age=86400');
|
||||
expect(headers['ETag']).toBe('"1234"');
|
||||
expect(reply.send).toHaveBeenCalled();
|
||||
expect(mockCreateReadStream).not.toHaveBeenCalled();
|
||||
@@ -205,7 +205,7 @@ describe('BookController', () => {
|
||||
expect(headers['Cache-Control']).toBe('public, max-age=31536000, immutable');
|
||||
});
|
||||
|
||||
it('streams cover with 1h cache when no ?t= param', async () => {
|
||||
it('streams cover with 24h private cache when no ?t= param', async () => {
|
||||
const { controller, bookService } = makeController();
|
||||
const { reply, headers } = makeReply();
|
||||
bookService.getCoverPath.mockResolvedValue('/tmp/cover.png');
|
||||
@@ -213,7 +213,7 @@ describe('BookController', () => {
|
||||
|
||||
await controller.getCover(7, makeUser(), reply, undefined, undefined);
|
||||
|
||||
expect(headers['Cache-Control']).toBe('public, max-age=3600');
|
||||
expect(headers['Cache-Control']).toBe('private, max-age=86400');
|
||||
expect(headers['ETag']).toBe('"4321"');
|
||||
expect(reply.type).toHaveBeenCalledWith('image/png');
|
||||
expect(mockCreateReadStream).toHaveBeenCalledWith('/tmp/cover.png');
|
||||
@@ -592,11 +592,11 @@ describe('BookController', () => {
|
||||
await controller.getThumbnail(7, makeUser(), first.reply, undefined, '"1000"');
|
||||
expect(first.reply.status).toHaveBeenCalledWith(304);
|
||||
expect(first.headers['ETag']).toBe('"1000"');
|
||||
expect(first.headers['Cache-Control']).toBe('public, max-age=3600');
|
||||
expect(first.headers['Cache-Control']).toBe('private, max-age=86400');
|
||||
|
||||
await controller.getThumbnail(7, makeUser(), second.reply, undefined, undefined);
|
||||
expect(second.reply.type).toHaveBeenCalledWith('image/jpeg');
|
||||
expect(second.headers['Cache-Control']).toBe('public, max-age=3600');
|
||||
expect(second.headers['Cache-Control']).toBe('private, max-age=86400');
|
||||
expect(mockCreateReadStream).toHaveBeenCalledWith('/tmp/thumb.jpg');
|
||||
});
|
||||
|
||||
|
||||
@@ -350,7 +350,7 @@ export class BookController {
|
||||
|
||||
const { mtimeMs } = await stat(coverPath);
|
||||
const etag = `"${Math.floor(mtimeMs)}"`;
|
||||
const cacheControl = t ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
|
||||
const cacheControl = t ? 'public, max-age=31536000, immutable' : 'private, max-age=86400';
|
||||
|
||||
if (ifNoneMatch === etag) {
|
||||
reply.status(304).header('Cache-Control', cacheControl).header('ETag', etag).send();
|
||||
@@ -378,7 +378,7 @@ export class BookController {
|
||||
|
||||
const { mtimeMs } = await stat(thumbnailPath);
|
||||
const etag = `"${Math.floor(mtimeMs)}"`;
|
||||
const cacheControl = t ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
|
||||
const cacheControl = t ? 'public, max-age=31536000, immutable' : 'private, max-age=86400';
|
||||
|
||||
if (ifNoneMatch === etag) {
|
||||
reply.status(304).header('Cache-Control', cacheControl).header('ETag', etag).send();
|
||||
|
||||
@@ -1069,6 +1069,7 @@ export class BookService {
|
||||
if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null;
|
||||
if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null;
|
||||
if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null;
|
||||
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
|
||||
if (dto.audioMetadata) {
|
||||
if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null;
|
||||
if (dto.audioMetadata.abridged !== undefined) scalarFields.abridged = dto.audioMetadata.abridged ?? false;
|
||||
|
||||
@@ -19,6 +19,7 @@ export const BOOK_WRITE_FIELD_KEYS: readonly BookWritePayloadKey[] = [
|
||||
'seriesIndex',
|
||||
'isbn10',
|
||||
'isbn13',
|
||||
'rating',
|
||||
'authors',
|
||||
'genres',
|
||||
'tags',
|
||||
|
||||
@@ -114,6 +114,7 @@ export class FileWriteRepository {
|
||||
hardcoverId: meta.hardcoverId,
|
||||
openLibraryId: meta.openLibraryId,
|
||||
itunesId: meta.itunesId,
|
||||
rating: meta.rating,
|
||||
authors: authorRows,
|
||||
genres: genreRows.map((g) => g.name),
|
||||
tags: tagRows.map((t) => t.name),
|
||||
|
||||
@@ -484,7 +484,7 @@ export class KoboSyncService {
|
||||
seriesName: schema.bookMetadata.seriesName,
|
||||
seriesIndex: schema.bookMetadata.seriesIndex,
|
||||
metadataUpdatedAt: schema.bookMetadata.updatedAt,
|
||||
fileHash: schema.bookFiles.hash,
|
||||
fileHash: schema.bookFiles.fileHash,
|
||||
authorNamesCsv: sql<string>`coalesce(string_agg(${schema.authors.name}, ',' ORDER BY ${schema.bookAuthors.displayOrder}, ${schema.bookAuthors.authorId}), '')`,
|
||||
})
|
||||
.from(schema.books)
|
||||
@@ -499,7 +499,7 @@ export class KoboSyncService {
|
||||
schema.bookMetadata.seriesName,
|
||||
schema.bookMetadata.seriesIndex,
|
||||
schema.bookMetadata.updatedAt,
|
||||
schema.bookFiles.hash,
|
||||
schema.bookFiles.fileHash,
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -534,7 +534,7 @@ export class KoboSyncService {
|
||||
seriesIndex: schema.bookMetadata.seriesIndex,
|
||||
fileFormat: schema.bookFiles.format,
|
||||
fileSizeBytes: schema.bookFiles.sizeBytes,
|
||||
fileHash: schema.bookFiles.hash,
|
||||
fileHash: schema.bookFiles.fileHash,
|
||||
metadataUpdatedAt: schema.bookMetadata.updatedAt,
|
||||
addedAt: schema.books.addedAt,
|
||||
updatedAt: schema.books.updatedAt,
|
||||
|
||||
@@ -534,7 +534,7 @@ export class MigrationImportRepository {
|
||||
.select({
|
||||
id: schema.bookFiles.id,
|
||||
bookId: schema.bookFiles.bookId,
|
||||
hash: schema.bookFiles.hash,
|
||||
hash: schema.bookFiles.fileHash,
|
||||
absolutePath: schema.bookFiles.absolutePath,
|
||||
})
|
||||
.from(schema.bookFiles)
|
||||
|
||||
@@ -207,9 +207,9 @@ export class MatchingService {
|
||||
for (let i = 0; i < allHashes.length; i += chunkSize) {
|
||||
const chunk = allHashes.slice(i, i + chunkSize);
|
||||
const rows = await this.db
|
||||
.select({ bookId: schema.bookFiles.bookId, hash: schema.bookFiles.hash })
|
||||
.select({ bookId: schema.bookFiles.bookId, hash: schema.bookFiles.fileHash })
|
||||
.from(schema.bookFiles)
|
||||
.where(inArray(schema.bookFiles.hash, chunk));
|
||||
.where(inArray(schema.bookFiles.fileHash, chunk));
|
||||
for (const row of rows) {
|
||||
if (!row.hash) continue;
|
||||
const existing = bookIdsByHash.get(row.hash) ?? [];
|
||||
|
||||
@@ -35,6 +35,7 @@ const ADMIN_SETUP_DTO = {
|
||||
|
||||
const SCENARIO_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_ISSUER = 'https://issuer.example';
|
||||
const DEFAULT_SLUG = 'example-oidc';
|
||||
|
||||
interface OidcContractContext {
|
||||
app: NestFastifyApplication;
|
||||
@@ -55,8 +56,9 @@ interface OidcContractContext {
|
||||
}
|
||||
|
||||
type OidcConfigInput = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
enabled: boolean;
|
||||
providerName: string;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
@@ -120,8 +122,9 @@ function buildDiscoveryResponse(issuerUri = DEFAULT_ISSUER) {
|
||||
|
||||
function buildOidcConfig(overrides: Partial<OidcConfigInput> = {}): OidcConfigInput {
|
||||
return {
|
||||
slug: DEFAULT_SLUG,
|
||||
displayName: 'Example OIDC',
|
||||
enabled: true,
|
||||
providerName: 'Example OIDC',
|
||||
issuerUri: DEFAULT_ISSUER,
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
@@ -143,6 +146,12 @@ function buildOidcConfig(overrides: Partial<OidcConfigInput> = {}): OidcConfigIn
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeLogoutToken(claims: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
||||
return `${header}.${payload}.fakesig`;
|
||||
}
|
||||
|
||||
function responseMessage(response: { message?: string | string[] }): string {
|
||||
if (Array.isArray(response.message)) return response.message.join(' ');
|
||||
return String(response.message ?? '');
|
||||
@@ -391,19 +400,28 @@ async function login(app: NestFastifyApplication, username: string, password: st
|
||||
}
|
||||
|
||||
async function putOidcConfig(ctx: OidcContractContext, config: OidcConfigInput): Promise<void> {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/app-settings/oidc',
|
||||
const createResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/app-settings/oidc/providers',
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: config,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
if (createResponse.statusCode === 201) return;
|
||||
|
||||
const { slug, ...updatePayload } = config;
|
||||
const updateResponse = await ctx.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/v1/app-settings/oidc/providers/${slug}`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: updatePayload,
|
||||
});
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
async function issueState(app: NestFastifyApplication): Promise<string> {
|
||||
async function issueState(app: NestFastifyApplication, slug = DEFAULT_SLUG): Promise<string> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/state',
|
||||
url: `/api/v1/auth/oidc/${slug}/state`,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as { state: string };
|
||||
@@ -461,7 +479,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
const limitedUser = await createLocalUser(ctx.db);
|
||||
const limitedSession = await login(ctx.app, limitedUser.username, limitedUser.password);
|
||||
const config = buildOidcConfig({
|
||||
providerName: 'Contract OIDC',
|
||||
displayName: 'Contract OIDC',
|
||||
autoProvision: {
|
||||
enabled: true,
|
||||
allowLocalLinking: true,
|
||||
@@ -473,42 +491,51 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
|
||||
const publicResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-settings/oidc/public',
|
||||
url: '/api/v1/app-settings/oidc/providers/public',
|
||||
});
|
||||
expect(publicResponse.statusCode).toBe(200);
|
||||
expect(publicResponse.json()).toEqual({
|
||||
enabled: true,
|
||||
providerName: 'Contract OIDC',
|
||||
issuerUri: DEFAULT_ISSUER,
|
||||
clientId: 'client-id',
|
||||
scopes: 'openid profile email',
|
||||
});
|
||||
expect(publicResponse.json()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: DEFAULT_SLUG,
|
||||
displayName: 'Contract OIDC',
|
||||
enabled: true,
|
||||
clientId: 'client-id',
|
||||
scopes: 'openid profile email',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const forbiddenResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-settings/oidc',
|
||||
url: `/api/v1/app-settings/oidc/providers/${DEFAULT_SLUG}`,
|
||||
headers: authHeader(limitedSession.accessToken),
|
||||
});
|
||||
expect(forbiddenResponse.statusCode).toBe(403);
|
||||
|
||||
const adminResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-settings/oidc',
|
||||
url: `/api/v1/app-settings/oidc/providers/${DEFAULT_SLUG}`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
});
|
||||
expect(adminResponse.statusCode).toBe(200);
|
||||
expect(adminResponse.json()).toEqual({
|
||||
...config,
|
||||
expect(adminResponse.json()).toMatchObject({
|
||||
slug: DEFAULT_SLUG,
|
||||
displayName: 'Contract OIDC',
|
||||
enabled: true,
|
||||
issuerUri: DEFAULT_ISSUER,
|
||||
clientId: 'client-id',
|
||||
clientSecret: '***',
|
||||
scopes: 'openid profile email',
|
||||
});
|
||||
|
||||
const testResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/app-settings/oidc/test',
|
||||
url: `/api/v1/app-settings/oidc/providers/${DEFAULT_SLUG}/test`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
});
|
||||
expect(testResponse.statusCode).toBe(200);
|
||||
expect(testResponse.json()).toEqual({
|
||||
expect(testResponse.json()).toMatchObject({
|
||||
success: true,
|
||||
issuer: DEFAULT_ISSUER,
|
||||
authorizationEndpoint: `${DEFAULT_ISSUER}/auth`,
|
||||
@@ -548,7 +575,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
picture: 'https://issuer.example/avatars/provisioned.png',
|
||||
});
|
||||
|
||||
const state = await issueState(ctx.app);
|
||||
const state = await issueState(ctx.app, DEFAULT_SLUG);
|
||||
const callbackPayload = {
|
||||
code: 'authorization-code-provisioned',
|
||||
codeVerifier: 'verifier-provisioned',
|
||||
@@ -625,7 +652,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
url: '/api/v1/auth/oidc/callback',
|
||||
payload: callbackPayload,
|
||||
});
|
||||
expectError(reusedState, 401, 'Invalid or expired state');
|
||||
expectError(reusedState, 401, 'Your login session expired');
|
||||
});
|
||||
|
||||
it('links an existing local user and backchannel logout revokes active sessions only for the matched OIDC identity', async () => {
|
||||
@@ -657,7 +684,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
email: linkedUser.email,
|
||||
});
|
||||
|
||||
const state = await issueState(ctx.app);
|
||||
const state = await issueState(ctx.app, DEFAULT_SLUG);
|
||||
const callbackResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/callback',
|
||||
@@ -707,7 +734,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
const unknownLogoutResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/backchannel-logout',
|
||||
payload: { logout_token: 'unknown-logout-token' },
|
||||
payload: { logout_token: makeFakeLogoutToken({ iss: DEFAULT_ISSUER }) },
|
||||
});
|
||||
expect(unknownLogoutResponse.statusCode).toBe(200);
|
||||
expect(await activeSessionCount(ctx.db, linkedUser.userId)).toBe(1);
|
||||
@@ -728,7 +755,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
const matchedLogoutResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/backchannel-logout',
|
||||
payload: { logout_token: 'matched-logout-token' },
|
||||
payload: { logout_token: makeFakeLogoutToken({ iss: DEFAULT_ISSUER }) },
|
||||
});
|
||||
expect(matchedLogoutResponse.statusCode).toBe(200);
|
||||
|
||||
@@ -778,10 +805,10 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
state: `invalid-${randomUUID()}`,
|
||||
},
|
||||
});
|
||||
expectError(invalidStateResponse, 401, 'Invalid or expired state');
|
||||
expectError(invalidStateResponse, 401, 'Your login session expired');
|
||||
expect(ctx.oidcTokenClientMock.exchangeCode).not.toHaveBeenCalled();
|
||||
|
||||
const state = await issueState(ctx.app);
|
||||
const state = await issueState(ctx.app, DEFAULT_SLUG);
|
||||
ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({
|
||||
sid: 'oidc-missing-subject-sid',
|
||||
preferred_username: `missing-subject-${randomUUID()}`,
|
||||
@@ -800,10 +827,25 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
state,
|
||||
},
|
||||
});
|
||||
expectError(missingSubjectResponse, 401, 'Invalid ID token: missing subject claim');
|
||||
expectError(missingSubjectResponse, 401, 'Could not complete sign-in with your provider');
|
||||
});
|
||||
|
||||
it('rejects callback when OIDC is disabled or the linked account is deactivated', async () => {
|
||||
// Generate state while provider is enabled, then disable it so the callback is rejected.
|
||||
await putOidcConfig(
|
||||
ctx,
|
||||
buildOidcConfig({
|
||||
enabled: true,
|
||||
autoProvision: {
|
||||
enabled: true,
|
||||
allowLocalLinking: true,
|
||||
defaultPermissionNames: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const disabledState = await issueState(ctx.app, DEFAULT_SLUG);
|
||||
|
||||
await putOidcConfig(
|
||||
ctx,
|
||||
buildOidcConfig({
|
||||
@@ -816,7 +858,6 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
}),
|
||||
);
|
||||
|
||||
const disabledState = await issueState(ctx.app);
|
||||
const disabledCallbackResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/callback',
|
||||
@@ -828,7 +869,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
state: disabledState,
|
||||
},
|
||||
});
|
||||
expectError(disabledCallbackResponse, 401, 'OIDC is not enabled');
|
||||
expectError(disabledCallbackResponse, 401, 'OIDC provider is not enabled');
|
||||
|
||||
const inactiveUser = await createLocalUser(ctx.db, {
|
||||
username: `oidc-inactive-${randomUUID().replace(/-/g, '')}`,
|
||||
@@ -847,7 +888,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
}),
|
||||
);
|
||||
|
||||
const inactiveState = await issueState(ctx.app);
|
||||
const inactiveState = await issueState(ctx.app, DEFAULT_SLUG);
|
||||
ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({
|
||||
sub: 'oidc-inactive-subject',
|
||||
sid: 'oidc-inactive-sid',
|
||||
@@ -867,7 +908,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
||||
state: inactiveState,
|
||||
},
|
||||
});
|
||||
expectError(inactiveCallbackResponse, 401, 'Account is deactivated');
|
||||
expectError(inactiveCallbackResponse, 401, 'Your account has been deactivated');
|
||||
|
||||
const inactiveUserSession = await ctx.db.query.oidcSessions.findFirst({
|
||||
where: eq(schema.oidcSessions.userId, inactiveUser.userId),
|
||||
|
||||
@@ -416,6 +416,16 @@ describe('Authorization matrix (e2e)', () => {
|
||||
path: '/notifications',
|
||||
token: 'allPerms',
|
||||
},
|
||||
[Permission.KoreaderSync]: {
|
||||
method: 'GET',
|
||||
path: '/koreader/credentials',
|
||||
token: 'allPerms',
|
||||
},
|
||||
[Permission.DemoRestricted]: {
|
||||
method: 'GET',
|
||||
path: '/notifications',
|
||||
token: 'allPerms',
|
||||
},
|
||||
};
|
||||
|
||||
const failures: MatrixFailure[] = [];
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"totalRoutes": 323,
|
||||
"totalRoutes": 353,
|
||||
"byPermission": {
|
||||
"(none)": 161,
|
||||
"Permission.ManageMetadataConfig": 32,
|
||||
"Permission.EmailSend": 30,
|
||||
"Permission.LibraryEditMetadata": 23,
|
||||
"Permission.LibraryEditMetadata": 31,
|
||||
"Permission.BookDockAccess": 17,
|
||||
"Permission.ManageAppSettings": 14,
|
||||
"Permission.ManageAppSettings": 23,
|
||||
"Permission.ManageLibraries": 13,
|
||||
"Permission.ManageUsers": 10,
|
||||
"Permission.ManageEmail": 8,
|
||||
"Permission.KoboSync": 7,
|
||||
"Permission.NotificationAccess": 6,
|
||||
"Permission.DemoRestricted": 6,
|
||||
"Permission.KoreaderSync": 8,
|
||||
"Permission.OpdsAccess": 4,
|
||||
"Permission.ViewAuditLog": 1,
|
||||
"Permission.LibraryDeleteBooks": 1,
|
||||
|
||||
@@ -122,9 +122,9 @@
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "getOidcPublicConfig",
|
||||
"methodName": "getOidcProvidersPublic",
|
||||
"httpMethod": "GET",
|
||||
"path": "/app-settings/oidc/public",
|
||||
"path": "/app-settings/oidc/providers/public",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": true,
|
||||
@@ -134,9 +134,9 @@
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "getOidcConfig",
|
||||
"methodName": "listOidcProviders",
|
||||
"httpMethod": "GET",
|
||||
"path": "/app-settings/oidc",
|
||||
"path": "/app-settings/oidc/providers",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
@@ -146,9 +146,9 @@
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "updateOidcConfig",
|
||||
"httpMethod": "PUT",
|
||||
"path": "/app-settings/oidc",
|
||||
"methodName": "getOidcProvider",
|
||||
"httpMethod": "GET",
|
||||
"path": "/app-settings/oidc/providers/:slug",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
@@ -158,9 +158,105 @@
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "testOidcConnection",
|
||||
"methodName": "createOidcProvider",
|
||||
"httpMethod": "POST",
|
||||
"path": "/app-settings/oidc/test",
|
||||
"path": "/app-settings/oidc/providers",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "updateOidcProvider",
|
||||
"httpMethod": "PUT",
|
||||
"path": "/app-settings/oidc/providers/:slug",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "deleteOidcProvider",
|
||||
"httpMethod": "DELETE",
|
||||
"path": "/app-settings/oidc/providers/:slug",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "testOidcProviderConnection",
|
||||
"httpMethod": "POST",
|
||||
"path": "/app-settings/oidc/providers/:slug/test",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "reorderOidcProviders",
|
||||
"httpMethod": "PUT",
|
||||
"path": "/app-settings/oidc/providers/reorder",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "listProviderGroupMappings",
|
||||
"httpMethod": "GET",
|
||||
"path": "/app-settings/oidc/providers/:slug/group-mappings",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "createProviderGroupMapping",
|
||||
"httpMethod": "POST",
|
||||
"path": "/app-settings/oidc/providers/:slug/group-mappings",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "updateProviderGroupMapping",
|
||||
"httpMethod": "PUT",
|
||||
"path": "/app-settings/oidc/providers/:slug/group-mappings/:id",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/app-settings/app-settings.controller.ts",
|
||||
"className": "AppSettingsController",
|
||||
"methodName": "deleteProviderGroupMapping",
|
||||
"httpMethod": "DELETE",
|
||||
"path": "/app-settings/oidc/providers/:slug/group-mappings/:id",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
@@ -384,12 +480,156 @@
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/authors/authors.controller.ts",
|
||||
"className": "AuthorsController",
|
||||
"methodName": "findDuplicateSuggestions",
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "browse",
|
||||
"httpMethod": "GET",
|
||||
"path": "/authors/suggestions/duplicates",
|
||||
"permissions": [],
|
||||
"path": "/entity-manager/:entityType/browse",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "scanDuplicates",
|
||||
"httpMethod": "GET",
|
||||
"path": "/entity-manager/:entityType/duplicates/scan",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "getDuplicateScanStatus",
|
||||
"httpMethod": "GET",
|
||||
"path": "/entity-manager/:entityType/duplicates/status",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "refreshDuplicates",
|
||||
"httpMethod": "POST",
|
||||
"path": "/entity-manager/:entityType/duplicates/refresh",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "merge",
|
||||
"httpMethod": "POST",
|
||||
"path": "/entity-manager/:entityType/merge",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "rename",
|
||||
"httpMethod": "POST",
|
||||
"path": "/entity-manager/:entityType/rename",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "deleteEntity",
|
||||
"httpMethod": "POST",
|
||||
"path": "/entity-manager/:entityType/delete",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "bulkDelete",
|
||||
"httpMethod": "POST",
|
||||
"path": "/entity-manager/:entityType/bulk-delete",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "split",
|
||||
"httpMethod": "POST",
|
||||
"path": "/entity-manager/:entityType/split",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "dismissPair",
|
||||
"httpMethod": "POST",
|
||||
"path": "/entity-manager/:entityType/duplicates/dismiss",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "undismissPair",
|
||||
"httpMethod": "POST",
|
||||
"path": "/entity-manager/:entityType/duplicates/undismiss",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "getDismissedPairs",
|
||||
"httpMethod": "GET",
|
||||
"path": "/entity-manager/:entityType/duplicates/dismissed",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/entity-manager/entity-manager.controller.ts",
|
||||
"className": "EntityManagerController",
|
||||
"methodName": "getEntityInfo",
|
||||
"httpMethod": "GET",
|
||||
"path": "/entity-manager/:entityType/info/:entityId",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
|
||||
@@ -228,60 +228,168 @@
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/tools/tools.controller.ts",
|
||||
"className": "ToolsController",
|
||||
"methodName": "scanAuthorDuplicates",
|
||||
"httpMethod": "GET",
|
||||
"path": "/tools/authors/duplicates/scan",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/tools/tools.controller.ts",
|
||||
"className": "ToolsController",
|
||||
"methodName": "merge",
|
||||
"file": "server/src/modules/koreader/koreader.controller.ts",
|
||||
"className": "KoreaderController",
|
||||
"methodName": "createCredentials",
|
||||
"httpMethod": "POST",
|
||||
"path": "/tools/authors/duplicates/merge",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"path": "/koreader/credentials",
|
||||
"permissions": ["Permission.KoreaderSync"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/tools/tools.controller.ts",
|
||||
"className": "ToolsController",
|
||||
"methodName": "batchMerge",
|
||||
"httpMethod": "POST",
|
||||
"path": "/tools/authors/duplicates/batch-merge",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"file": "server/src/modules/koreader/koreader.controller.ts",
|
||||
"className": "KoreaderController",
|
||||
"methodName": "updateCredentials",
|
||||
"httpMethod": "PATCH",
|
||||
"path": "/koreader/credentials",
|
||||
"permissions": ["Permission.KoreaderSync"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/tools/tools.controller.ts",
|
||||
"className": "ToolsController",
|
||||
"methodName": "dismiss",
|
||||
"httpMethod": "POST",
|
||||
"path": "/tools/authors/duplicates/dismiss",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/tools/tools.controller.ts",
|
||||
"className": "ToolsController",
|
||||
"methodName": "undismiss",
|
||||
"file": "server/src/modules/koreader/koreader.controller.ts",
|
||||
"className": "KoreaderController",
|
||||
"methodName": "deleteCredentials",
|
||||
"httpMethod": "DELETE",
|
||||
"path": "/tools/authors/duplicates/dismiss",
|
||||
"permissions": ["Permission.LibraryEditMetadata"],
|
||||
"path": "/koreader/credentials",
|
||||
"permissions": ["Permission.KoreaderSync"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/koreader/koreader.controller.ts",
|
||||
"className": "KoreaderController",
|
||||
"methodName": "getCredentials",
|
||||
"httpMethod": "GET",
|
||||
"path": "/koreader/credentials",
|
||||
"permissions": ["Permission.KoreaderSync"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/koreader/koreader.controller.ts",
|
||||
"className": "KoreaderController",
|
||||
"methodName": "getSyncStatus",
|
||||
"httpMethod": "GET",
|
||||
"path": "/koreader/sync-status",
|
||||
"permissions": ["Permission.KoreaderSync"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/koreader/koreader.controller.ts",
|
||||
"className": "KoreaderController",
|
||||
"methodName": "getDevices",
|
||||
"httpMethod": "GET",
|
||||
"path": "/koreader/devices",
|
||||
"permissions": ["Permission.KoreaderSync"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/koreader/koreader.controller.ts",
|
||||
"className": "KoreaderController",
|
||||
"methodName": "getBookProgress",
|
||||
"httpMethod": "GET",
|
||||
"path": "/koreader/books/:bookId/progress",
|
||||
"permissions": ["Permission.KoreaderSync"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/koreader/koreader.controller.ts",
|
||||
"className": "KoreaderController",
|
||||
"methodName": "testConnection",
|
||||
"httpMethod": "POST",
|
||||
"path": "/koreader/test-connection",
|
||||
"permissions": ["Permission.KoreaderSync"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notification/notification.controller.ts",
|
||||
"className": "NotificationController",
|
||||
"methodName": "list",
|
||||
"httpMethod": "GET",
|
||||
"path": "/notifications",
|
||||
"permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notification/notification.controller.ts",
|
||||
"className": "NotificationController",
|
||||
"methodName": "getUnreadCount",
|
||||
"httpMethod": "GET",
|
||||
"path": "/notifications/unread-count",
|
||||
"permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notification/notification.controller.ts",
|
||||
"className": "NotificationController",
|
||||
"methodName": "markAllAsRead",
|
||||
"httpMethod": "PATCH",
|
||||
"path": "/notifications/read-all",
|
||||
"permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notification/notification.controller.ts",
|
||||
"className": "NotificationController",
|
||||
"methodName": "markAsRead",
|
||||
"httpMethod": "PATCH",
|
||||
"path": "/notifications/:id/read",
|
||||
"permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notification/notification.controller.ts",
|
||||
"className": "NotificationController",
|
||||
"methodName": "dismiss",
|
||||
"httpMethod": "DELETE",
|
||||
"path": "/notifications/:id",
|
||||
"permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/notification/notification.controller.ts",
|
||||
"className": "NotificationController",
|
||||
"methodName": "clearAll",
|
||||
"httpMethod": "DELETE",
|
||||
"path": "/notifications",
|
||||
"permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
|
||||
@@ -838,7 +838,7 @@ async function insertTargetBook(ctx: MigrationBookloreE2EContext, input: InsertT
|
||||
ino: nextInode++,
|
||||
sizeBytes: 128,
|
||||
mtime: new Date('2024-01-01T12:00:00Z'),
|
||||
hash: file.hash,
|
||||
fileHash: file.hash,
|
||||
format: file.format,
|
||||
role: 'content',
|
||||
sortOrder: index,
|
||||
|
||||
@@ -208,6 +208,11 @@ export async function createBookCoverArtifacts(
|
||||
|
||||
await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/cover.${coverExtension}`, coverContent);
|
||||
await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/thumbnail.jpg`, thumbnailContent);
|
||||
|
||||
await ctx.db
|
||||
.insert(schema.bookMetadata)
|
||||
.values({ bookId, coverSource: 'custom' })
|
||||
.onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: { coverSource: 'custom' } });
|
||||
}
|
||||
|
||||
export async function createUserAndLogin(
|
||||
|
||||
@@ -521,13 +521,21 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
|
||||
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 } };
|
||||
const updatedProfile = updateProfile.json() as { name: string };
|
||||
expect(updatedProfile.name).toBe('Regular A Updated');
|
||||
expect(updatedProfile.settings.syncReaderPreferences).toBe(true);
|
||||
|
||||
const updateSettings = await ctx.app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/users/me/settings',
|
||||
headers: authHeader(regularA.accessToken),
|
||||
payload: { settings: { syncReaderPreferences: true } },
|
||||
});
|
||||
expect(updateSettings.statusCode).toBe(200);
|
||||
const updatedSettings = updateSettings.json() as { settings: { syncReaderPreferences?: boolean } };
|
||||
expect(updatedSettings.settings.syncReaderPreferences).toBe(true);
|
||||
|
||||
const noFileUpload = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user