mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): localize the read-aloud interface
Replace the hardcoded English in the TTS and narration components with message keys, label every icon-only control, and add settings.common.nav.tts, which the settings sidebar rendered as a raw key. Stop requiring PODCAST_ENCRYPTION_KEY while APP_FEATURES.podcasts is off, and skip podcast routes and permissions in the authorization matrix for the same reason. Pass the direct-download settle() helper the SETTLE_MS budget the file already defines: vi.waitFor's one-second default cannot cover six DNS-resolved redirect hops on a cold CI resolver.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { APP_FEATURES } from '@bookorbit/types';
|
||||
|
||||
import { validateEnv } from './env.validation';
|
||||
|
||||
const BASE_ENV = {
|
||||
@@ -200,15 +202,22 @@ describe('validateEnv', () => {
|
||||
expect(() => validateEnv({ ...BASE_ENV, NODE_ENV: 'test' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires a dedicated podcast encryption key in production', () => {
|
||||
const productionEnv = {
|
||||
...BASE_ENV,
|
||||
NODE_ENV: 'production',
|
||||
SETUP_BOOTSTRAP_TOKEN: '1234567890abcdef',
|
||||
};
|
||||
const PRODUCTION_ENV = {
|
||||
...BASE_ENV,
|
||||
NODE_ENV: 'production',
|
||||
SETUP_BOOTSTRAP_TOKEN: '1234567890abcdef',
|
||||
};
|
||||
|
||||
expect(() => validateEnv(productionEnv)).toThrow('PODCAST_ENCRYPTION_KEY is required in production');
|
||||
expect(() => validateEnv({ ...productionEnv, PODCAST_ENCRYPTION_KEY: '1234567890abcdef' })).not.toThrow();
|
||||
it.runIf(APP_FEATURES.podcasts)('requires a dedicated podcast encryption key in production while podcasts are enabled', () => {
|
||||
expect(() => validateEnv(PRODUCTION_ENV)).toThrow('PODCAST_ENCRYPTION_KEY is required in production');
|
||||
});
|
||||
|
||||
it.skipIf(APP_FEATURES.podcasts)('starts in production without a podcast encryption key while podcasts are disabled', () => {
|
||||
expect(() => validateEnv(PRODUCTION_ENV)).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a dedicated podcast encryption key in production either way', () => {
|
||||
expect(() => validateEnv({ ...PRODUCTION_ENV, PODCAST_ENCRYPTION_KEY: '1234567890abcdef' })).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -3,6 +3,8 @@ import { isAbsolute } from 'node:path';
|
||||
import { isIP } from 'node:net';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { APP_FEATURES } from '@bookorbit/types';
|
||||
|
||||
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
|
||||
const TRUST_PROXY_BOOLEAN_VALUES = ['true', 'false', 'yes', 'no', 'on', 'off'];
|
||||
|
||||
@@ -146,7 +148,10 @@ export function validateEnv(config: Record<string, unknown>) {
|
||||
if (result.data.NODE_ENV === 'production' && !result.data.SETUP_BOOTSTRAP_TOKEN?.trim()) {
|
||||
throw new Error('Environment validation failed:\n SETUP_BOOTSTRAP_TOKEN: SETUP_BOOTSTRAP_TOKEN is required in production');
|
||||
}
|
||||
if (result.data.NODE_ENV === 'production' && !result.data.PODCAST_ENCRYPTION_KEY) {
|
||||
// Only gate startup on the key once podcast controllers are actually registered. Requiring it
|
||||
// while the feature is off turns every existing deployment's upgrade into a boot failure for a
|
||||
// feature its users cannot reach, and podcastConfig already falls back when it is absent.
|
||||
if (APP_FEATURES.podcasts && result.data.NODE_ENV === 'production' && !result.data.PODCAST_ENCRYPTION_KEY) {
|
||||
throw new Error('Environment validation failed:\n PODCAST_ENCRYPTION_KEY: PODCAST_ENCRYPTION_KEY is required in production');
|
||||
}
|
||||
return result.data;
|
||||
|
||||
@@ -45,13 +45,22 @@ describe('DirectDownloadService', () => {
|
||||
return new Response(body, { headers: { 'content-type': 'application/epub+zip', ...headers } });
|
||||
}
|
||||
|
||||
/** Waits for the fire-and-forget download, which `add` deliberately does not await. */
|
||||
/**
|
||||
* Waits for the fire-and-forget download, which `add` deliberately does not await.
|
||||
*
|
||||
* The budget has to be passed: `vi.waitFor` defaults to one second, and a redirect chain pays a
|
||||
* real `dns.lookup` per hop through `ensureSafeUrl`, so six hops on a slow resolver outlast it
|
||||
* and the case fails in CI while passing on a warm cache.
|
||||
*/
|
||||
async function settle() {
|
||||
return vi.waitFor(async () => {
|
||||
const [status] = await service.status([HASH]);
|
||||
expect(status.state === 'completed' || status.state === 'failed').toBe(true);
|
||||
return status;
|
||||
});
|
||||
return vi.waitFor(
|
||||
async () => {
|
||||
const [status] = await service.status([HASH]);
|
||||
expect(status.state === 'completed' || status.state === 'failed').toBe(true);
|
||||
return status;
|
||||
},
|
||||
{ timeout: SETTLE_MS },
|
||||
);
|
||||
}
|
||||
|
||||
it('writes the file into its staging directory and reports where it landed', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { APP_FEATURES, Permission } from '@bookorbit/types';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
@@ -99,6 +99,16 @@ interface Personas {
|
||||
}
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const PODCAST_MODULE_DIR = 'server/src/modules/podcast/';
|
||||
|
||||
/**
|
||||
* Podcast permissions are only ever carried by PodcastModule's guards, so while
|
||||
* APP_FEATURES.podcasts is off no registered route can prove them and the inventory holds none.
|
||||
*/
|
||||
function isFeatureGatedPermission(permission: Permission): boolean {
|
||||
return !APP_FEATURES.podcasts && permission.startsWith('podcast_');
|
||||
}
|
||||
|
||||
const routeInventory = loadRouteInventory();
|
||||
const uncoveredRoutes = loadUncoveredRoutes();
|
||||
|
||||
@@ -111,12 +121,17 @@ function isSupportedMethod(method: string): method is SupportedHttpMethod {
|
||||
function loadRouteInventory(): RouteInventory {
|
||||
const inventoryDir = join(currentDir, 'e2e/authorization-matrix/route-inventory');
|
||||
const manifest = JSON.parse(readFileSync(join(inventoryDir, 'manifest.json'), 'utf8')) as RouteInventoryManifest;
|
||||
const routes = manifest.routeChunks.flatMap(
|
||||
const inventoried = manifest.routeChunks.flatMap(
|
||||
(chunkFile) => JSON.parse(readFileSync(join(inventoryDir, chunkFile), 'utf8')) as RouteInventoryRoute[],
|
||||
);
|
||||
|
||||
// PodcastModule registers its controllers only when APP_FEATURES.podcasts is on, so with the
|
||||
// feature off Nest never mounts these paths and every probe below would read the resulting 404
|
||||
// as a missing guard. The inventory keeps the entries so they return with the feature.
|
||||
const routes = APP_FEATURES.podcasts ? inventoried : inventoried.filter((route) => !route.file.startsWith(PODCAST_MODULE_DIR));
|
||||
|
||||
return {
|
||||
totalRoutes: manifest.totalRoutes,
|
||||
totalRoutes: manifest.totalRoutes - (inventoried.length - routes.length),
|
||||
byPermission: manifest.byPermission,
|
||||
byLibraryAccess: manifest.byLibraryAccess,
|
||||
routes,
|
||||
@@ -660,7 +675,9 @@ describe('Authorization matrix (e2e)', () => {
|
||||
// so they legitimately never appear on a guard. Every entry needs a service-level test
|
||||
// proving the capability is actually enforced; keep this list as short as it can be.
|
||||
const serviceEnforcedPermissions: Permission[] = [Permission.BookRequestAutoApprove];
|
||||
const enumPermissions = [...Object.values(Permission)].filter((permission) => !serviceEnforcedPermissions.includes(permission)).sort();
|
||||
const enumPermissions = [...Object.values(Permission)]
|
||||
.filter((permission) => !serviceEnforcedPermissions.includes(permission) && !isFeatureGatedPermission(permission))
|
||||
.sort();
|
||||
expect(inventoryPermissions).toEqual(enumPermissions);
|
||||
|
||||
const probes: Record<
|
||||
@@ -845,7 +862,7 @@ describe('Authorization matrix (e2e)', () => {
|
||||
const failures: MatrixFailure[] = [];
|
||||
|
||||
for (const permission of Object.values(Permission)) {
|
||||
if (serviceEnforcedPermissions.includes(permission)) continue;
|
||||
if (serviceEnforcedPermissions.includes(permission) || isFeatureGatedPermission(permission)) continue;
|
||||
const probe = probes[permission];
|
||||
const inventoryRoute = routeInventory.routes.find((route) => route.httpMethod === probe.method && route.path === probe.path);
|
||||
expect(inventoryRoute).toBeTruthy();
|
||||
@@ -885,6 +902,25 @@ describe('Authorization matrix (e2e)', () => {
|
||||
const guardedRoutes = routeInventory.routes.filter(
|
||||
(route) => !route.isPublic && route.libraryAccess.length > 0 && isSupportedMethod(route.httpMethod),
|
||||
);
|
||||
const podcastGuardedRoutes = [
|
||||
'GET /podcast-libraries/:libraryId/podcasts',
|
||||
'GET /podcast-libraries/:libraryId/episodes',
|
||||
'POST /podcast-libraries/:libraryId/queue-episodes',
|
||||
'POST /podcast-libraries/:libraryId/feed-preview',
|
||||
'POST /podcast-libraries/:libraryId/podcasts',
|
||||
'POST /podcast-libraries/:libraryId/opml/import',
|
||||
'POST /podcast-libraries/:libraryId/import-scan',
|
||||
'GET /podcast-libraries/:libraryId/import-scan/latest',
|
||||
'POST /podcast-libraries/:libraryId/shows/bulk-purge-preview',
|
||||
'POST /podcast-libraries/:libraryId/shows/bulk-delete',
|
||||
'GET /podcast-libraries/:libraryId/opml/export',
|
||||
'GET /podcast-libraries/:libraryId/settings',
|
||||
'PATCH /podcast-libraries/:libraryId/settings',
|
||||
'GET /podcast-libraries/:libraryId/health',
|
||||
'GET /podcast-libraries/:libraryId/activity',
|
||||
'GET /podcast-libraries/:libraryId/jobs',
|
||||
'GET /podcast-libraries/:libraryId/jobs/:jobId',
|
||||
];
|
||||
expect(guardedRoutes.map(routeLabel).sort()).toEqual(
|
||||
[
|
||||
'GET /libraries/:id',
|
||||
@@ -898,23 +934,7 @@ describe('Authorization matrix (e2e)', () => {
|
||||
'GET /libraries/:id/bulk-rename/preview',
|
||||
'GET /libraries/:id/bulk-rename/status',
|
||||
'POST /libraries/:id/bulk-rename/execute',
|
||||
'GET /podcast-libraries/:libraryId/podcasts',
|
||||
'GET /podcast-libraries/:libraryId/episodes',
|
||||
'POST /podcast-libraries/:libraryId/queue-episodes',
|
||||
'POST /podcast-libraries/:libraryId/feed-preview',
|
||||
'POST /podcast-libraries/:libraryId/podcasts',
|
||||
'POST /podcast-libraries/:libraryId/opml/import',
|
||||
'POST /podcast-libraries/:libraryId/import-scan',
|
||||
'GET /podcast-libraries/:libraryId/import-scan/latest',
|
||||
'POST /podcast-libraries/:libraryId/shows/bulk-purge-preview',
|
||||
'POST /podcast-libraries/:libraryId/shows/bulk-delete',
|
||||
'GET /podcast-libraries/:libraryId/opml/export',
|
||||
'GET /podcast-libraries/:libraryId/settings',
|
||||
'PATCH /podcast-libraries/:libraryId/settings',
|
||||
'GET /podcast-libraries/:libraryId/health',
|
||||
'GET /podcast-libraries/:libraryId/activity',
|
||||
'GET /podcast-libraries/:libraryId/jobs',
|
||||
'GET /podcast-libraries/:libraryId/jobs/:jobId',
|
||||
...(APP_FEATURES.podcasts ? podcastGuardedRoutes : []),
|
||||
].sort(),
|
||||
);
|
||||
|
||||
@@ -952,12 +972,14 @@ describe('Authorization matrix (e2e)', () => {
|
||||
});
|
||||
expect(bypassResponse.statusCode).toBe(200);
|
||||
|
||||
const podcastBypassResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/podcast-libraries/${podcastLibrary.libraryId}/settings`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
});
|
||||
expect(podcastBypassResponse.statusCode).toBe(200);
|
||||
if (APP_FEATURES.podcasts) {
|
||||
const podcastBypassResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/podcast-libraries/${podcastLibrary.libraryId}/settings`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
});
|
||||
expect(podcastBypassResponse.statusCode).toBe(200);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it('enforces default-password lock with allow-list exceptions', async () => {
|
||||
@@ -989,7 +1011,7 @@ describe('Authorization matrix (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom public guards - podcast stream tickets', () => {
|
||||
describe.skipIf(!APP_FEATURES.podcasts)('custom public guards - podcast stream tickets', () => {
|
||||
it('rejects an unauthenticated stream request that carries no ticket', async () => {
|
||||
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/podcast-episodes/1/stream' });
|
||||
expect(response.statusCode).toBe(401);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"totalRoutes": 606,
|
||||
"totalRoutes": 612,
|
||||
"byPermission": {
|
||||
"(none)": 259,
|
||||
"(none)": 260,
|
||||
"Permission.ManageMetadataConfig": 34,
|
||||
"Permission.EmailSend": 30,
|
||||
"Permission.LibraryEditMetadata": 35,
|
||||
"Permission.BookDockAccess": 17,
|
||||
"Permission.ManageAppSettings": 57,
|
||||
"Permission.ManageAppSettings": 62,
|
||||
"Permission.ManageLibraries": 22,
|
||||
"Permission.ManageUsers": 13,
|
||||
"Permission.ManageEmail": 8,
|
||||
|
||||
@@ -792,6 +792,66 @@
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
|
||||
"className": "IndexerController",
|
||||
"methodName": "listPluginUpdates",
|
||||
"httpMethod": "GET",
|
||||
"path": "/admin/request-indexers/plugins/updates",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
|
||||
"className": "IndexerController",
|
||||
"methodName": "checkPluginUpdates",
|
||||
"httpMethod": "POST",
|
||||
"path": "/admin/request-indexers/plugins/updates/check",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
|
||||
"className": "IndexerController",
|
||||
"methodName": "inspectPluginUpdate",
|
||||
"httpMethod": "POST",
|
||||
"path": "/admin/request-indexers/plugins/:type/update/inspect",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
|
||||
"className": "IndexerController",
|
||||
"methodName": "installPluginUpdate",
|
||||
"httpMethod": "POST",
|
||||
"path": "/admin/request-indexers/plugins/:type/update",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
|
||||
"className": "IndexerController",
|
||||
"methodName": "setPluginAutomaticUpdate",
|
||||
"httpMethod": "PUT",
|
||||
"path": "/admin/request-indexers/plugins/:type/auto-update",
|
||||
"permissions": ["Permission.ManageAppSettings"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/indexers/indexer-manager.controller.ts",
|
||||
"className": "IndexerManagerController",
|
||||
|
||||
Reference in New Issue
Block a user