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:
neon
2026-05-14 15:52:35 +00:00
parent 102360116b
commit db2b3f9696
23 changed files with 553 additions and 126 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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, "..", "..");
+2 -2
View File
@@ -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");
+2 -1
View File
@@ -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',
+15 -6
View File
@@ -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');
});
+2 -2
View File
@@ -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();
+1
View File
@@ -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,
+5
View File
@@ -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(
+11 -3
View File
@@ -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',