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:
@@ -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