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
@@ -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',