Separate configuration monitoring from application updates #53
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@directory/web': patch
|
||||
---
|
||||
|
||||
Separate configuration monitoring from application updates, retaining last-good configuration and restart-only authentication settings. Detect same-sized edits that preserve modification time.
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { Session } from '@auth/sveltekit';
|
||||
import type { DirectoryContent } from './lib/display/types.js';
|
||||
import type { Authentication } from './lib/server/auth/index.js';
|
||||
import type { PreparedSnapshot } from './lib/server/config/snapshot.js';
|
||||
import type { PreparedSnapshot } from './lib/server/directory/snapshot.js';
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
|
||||
@@ -7,8 +7,7 @@ import { requireSignIn } from './lib/server/auth/access.js';
|
||||
import { readMountedImage } from './lib/server/images/read-mounted-image.js';
|
||||
import { GET as mountedImage } from './routes/(protected)/(endpoints)/public/[...file]/+server.js';
|
||||
import { GET as bookmarkLogo } from './routes/(protected)/(endpoints)/bookmarks/[id]/logo/+server.js';
|
||||
import type { Application } from './lib/server/lifecycle/application.js';
|
||||
import type { Directory } from './lib/server/config/load-directory.js';
|
||||
import type { Application } from './lib/server/application.js';
|
||||
|
||||
const adapterEvents: NodeJS.EventEmitter = process;
|
||||
|
||||
@@ -20,7 +19,7 @@ const mocks = vi.hoisted(() => ({
|
||||
report: vi.fn<typeof import('./lib/server/logger.js').reportStartupFailure>(),
|
||||
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
|
||||
path: vi.fn<typeof import('@directory/config').resolveConfigPath>(),
|
||||
prepare: vi.fn<typeof import('./lib/server/config/snapshot.js').prepareSnapshot>()
|
||||
prepare: vi.fn<typeof import('./lib/server/directory/snapshot.js').prepareSnapshot>()
|
||||
}));
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({ env: mocks.env }));
|
||||
@@ -60,10 +59,10 @@ vi.mock('@directory/config', async (importOriginal) => ({
|
||||
loadConfigFile: mocks.load,
|
||||
resolveConfigPath: mocks.path
|
||||
}));
|
||||
vi.mock('./lib/server/config/snapshot.js', () => ({ prepareSnapshot: mocks.prepare }));
|
||||
vi.mock('./lib/server/directory/snapshot.js', () => ({ prepareSnapshot: mocks.prepare }));
|
||||
|
||||
const realSnapshot = await vi.importActual<typeof import('./lib/server/config/snapshot.js')>(
|
||||
'./lib/server/config/snapshot.js'
|
||||
const realSnapshot = await vi.importActual<typeof import('./lib/server/directory/snapshot.js')>(
|
||||
'./lib/server/directory/snapshot.js'
|
||||
);
|
||||
const sentinel = 'PRIVATE-config-password-not-for-browser';
|
||||
type Resolve = Parameters<Handle>[0]['resolve'];
|
||||
@@ -98,11 +97,8 @@ function event(): RequestEvent {
|
||||
|
||||
function application(title: string, authentication?: Authentication) {
|
||||
return {
|
||||
directory: {
|
||||
currentSnapshot: vi.fn().mockReturnValue(snapshot(title)),
|
||||
reload: vi.fn<Directory['reload']>(),
|
||||
stop: vi.fn<Directory['stop']>()
|
||||
},
|
||||
currentSnapshot: vi.fn<Application['currentSnapshot']>().mockReturnValue(snapshot(title)),
|
||||
onConfigChange: vi.fn<Application['onConfigChange']>().mockReturnValue(true),
|
||||
publicDir: '/mounted/public',
|
||||
authentication,
|
||||
stop: vi.fn<Application['stop']>()
|
||||
@@ -169,7 +165,7 @@ describe('server hooks', () => {
|
||||
expect(unavailable.headers.get('referrer-policy')).toBe('strict-origin-when-cross-origin');
|
||||
expect(await unavailable.text()).toBe('Service unavailable');
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(ready.directory.currentSnapshot).not.toHaveBeenCalled();
|
||||
expect(ready.currentSnapshot).not.toHaveBeenCalled();
|
||||
pending.resolve(ready);
|
||||
await startup;
|
||||
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env);
|
||||
@@ -264,9 +260,7 @@ describe('server hooks', () => {
|
||||
const authentication: Authentication = {
|
||||
strategy,
|
||||
handle: vi.fn<Handle>().mockImplementation(async ({ event }) => {
|
||||
expect(event.locals.snapshot).toBe(
|
||||
ready.directory.currentSnapshot.mock.results[0]?.value
|
||||
);
|
||||
expect(event.locals.snapshot).toBe(ready.currentSnapshot.mock.results[0]?.value);
|
||||
expect(event.locals.publicDir).toBe(ready.publicDir);
|
||||
expect(event.locals.authentication).toBe(authentication);
|
||||
expect(event.locals.session).toBeNull();
|
||||
@@ -290,7 +284,7 @@ describe('server hooks', () => {
|
||||
event: expect.objectContaining({ locals: request.locals }),
|
||||
resolve: expect.any(Function)
|
||||
});
|
||||
expect(ready.directory.currentSnapshot).toHaveBeenCalledOnce();
|
||||
expect(ready.currentSnapshot).toHaveBeenCalledOnce();
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(authentication.signIn).not.toHaveBeenCalled();
|
||||
expect(authentication.signOut).not.toHaveBeenCalled();
|
||||
@@ -409,8 +403,8 @@ describe('server hooks', () => {
|
||||
};
|
||||
}
|
||||
const ready = application('Initial', authentication);
|
||||
const captured = ready.directory.currentSnapshot();
|
||||
ready.directory.currentSnapshot.mockClear();
|
||||
const captured = ready.currentSnapshot();
|
||||
ready.currentSnapshot.mockClear();
|
||||
const before = JSON.stringify(captured);
|
||||
const replacement = snapshot('Replacement', '#abc', '#def');
|
||||
mocks.boot.mockResolvedValueOnce(ready);
|
||||
@@ -420,7 +414,7 @@ describe('server hooks', () => {
|
||||
const resolve = vi.fn<Resolve>().mockImplementation(async (event, options) => {
|
||||
expect(event.locals).toBe(request.locals);
|
||||
expect(event.locals.snapshot).toBe(captured);
|
||||
ready.directory.currentSnapshot.mockReturnValue(replacement);
|
||||
ready.currentSnapshot.mockReturnValue(replacement);
|
||||
const transform = options?.transformPageChunk;
|
||||
if (transform === undefined) throw new Error('Missing theme transform');
|
||||
if (strategy !== 'none') {
|
||||
@@ -446,7 +440,7 @@ describe('server hooks', () => {
|
||||
expect(html).not.toContain('Replacement');
|
||||
expect(html).not.toContain('#abc');
|
||||
expect(html).not.toContain('--directory-');
|
||||
expect(ready.directory.currentSnapshot).toHaveBeenCalledOnce();
|
||||
expect(ready.currentSnapshot).toHaveBeenCalledOnce();
|
||||
expect(request.locals.snapshot).toBe(captured);
|
||||
expect(JSON.stringify(captured)).toBe(before);
|
||||
expect(mocks.load).not.toHaveBeenCalled();
|
||||
@@ -465,13 +459,9 @@ describe('server hooks', () => {
|
||||
const initial = snapshot('Initial');
|
||||
const replacement = snapshot('Replacement');
|
||||
let published = initial;
|
||||
const directory = {
|
||||
currentSnapshot: vi.fn(() => published),
|
||||
reload: vi.fn<Directory['reload']>(),
|
||||
stop: vi.fn()
|
||||
};
|
||||
const app = {
|
||||
directory,
|
||||
currentSnapshot: vi.fn<Application['currentSnapshot']>(() => published),
|
||||
onConfigChange: vi.fn<Application['onConfigChange']>().mockReturnValue(true),
|
||||
publicDir: '/mounted/public',
|
||||
authentication: undefined,
|
||||
stop: vi.fn<Application['stop']>()
|
||||
@@ -494,7 +484,7 @@ describe('server hooks', () => {
|
||||
});
|
||||
const firstEvent = event();
|
||||
const first = await hooks.handle({ event: firstEvent, resolve });
|
||||
expect(directory.currentSnapshot).toHaveBeenCalledOnce();
|
||||
expect(app.currentSnapshot).toHaveBeenCalledOnce();
|
||||
expect(firstEvent.locals.snapshot).toBe(initial);
|
||||
expect(firstEvent.locals.authentication).toBeUndefined();
|
||||
expect(firstEvent.locals.session).toBeNull();
|
||||
@@ -509,7 +499,7 @@ describe('server hooks', () => {
|
||||
expect(capturedPaths).toEqual([app.publicDir, app.publicDir]);
|
||||
const secondEvent = event();
|
||||
const second = await hooks.handle({ event: secondEvent, resolve });
|
||||
expect(directory.currentSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(app.currentSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(secondEvent.locals.snapshot).toBe(replacement);
|
||||
expect(await second.json()).toEqual({ first: 'Replacement', second: 'Replacement' });
|
||||
expect(capturedPaths).toEqual(Array<string>(4).fill(app.publicDir));
|
||||
@@ -517,6 +507,6 @@ describe('server hooks', () => {
|
||||
expect(mocks.path).not.toHaveBeenCalled();
|
||||
expect(mocks.load).not.toHaveBeenCalled();
|
||||
expect(mocks.prepare).not.toHaveBeenCalled();
|
||||
expect(directory.reload).not.toHaveBeenCalled();
|
||||
expect(app.onConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { env } from '$env/dynamic/private';
|
||||
import type { Handle, ServerInit } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { themeStyleTag } from './lib/server/appearance.js';
|
||||
import type { ServerState } from './lib/server/lifecycle/application.js';
|
||||
import type { ServerState } from './lib/server/application.js';
|
||||
import { startDevelopment } from './lib/server/lifecycle/development.js';
|
||||
import { startProduction } from './lib/server/lifecycle/production.js';
|
||||
import { reportStartupFailure } from './lib/server/logger.js';
|
||||
@@ -30,7 +30,7 @@ const applicationHandle: Handle = ({ event, resolve }) => {
|
||||
headers: { 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
event.locals.snapshot = application.directory.currentSnapshot();
|
||||
event.locals.snapshot = application.currentSnapshot();
|
||||
event.locals.publicDir = application.publicDir;
|
||||
event.locals.authentication = application.authentication;
|
||||
event.locals.session = null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { themeStyleTag } from './appearance.js';
|
||||
import { prepareSnapshot } from './config/snapshot.js';
|
||||
import { prepareSnapshot } from './directory/snapshot.js';
|
||||
|
||||
function snapshot(light: string, dark: string) {
|
||||
return prepareSnapshot(
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { inspect } from 'node:util';
|
||||
import { validateConfig } from '@directory/config';
|
||||
import type { DirectoryConfiguration } from '@directory/config/types';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApplication } from './application.js';
|
||||
import type { Authentication } from './auth/index.js';
|
||||
import { DirectoryError } from './directory-error.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
prepare: vi.fn<typeof import('./directory/snapshot.js').prepareSnapshot>(),
|
||||
authenticate: vi.fn<typeof import('./auth/index.js').createAuthentication>(),
|
||||
report: vi.fn<typeof import('./logger.js').reportDirectoryIssue>()
|
||||
}));
|
||||
vi.mock('./directory/snapshot.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./directory/snapshot.js')>()),
|
||||
prepareSnapshot: mocks.prepare
|
||||
}));
|
||||
vi.mock('./auth/index.js', () => ({ createAuthentication: mocks.authenticate }));
|
||||
vi.mock('./logger.js', () => ({ reportDirectoryIssue: mocks.report }));
|
||||
|
||||
const realSnapshot =
|
||||
await vi.importActual<typeof import('./directory/snapshot.js')>('./directory/snapshot.js');
|
||||
const sentinel = 'PRIVATE-password-env-filename.yml';
|
||||
const credentials = {
|
||||
origin: 'https://directory.example.com',
|
||||
credentials: { username: 'family', password: sentinel }
|
||||
};
|
||||
const authentication: Authentication = {
|
||||
strategy: 'credentials',
|
||||
handle: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
};
|
||||
|
||||
function configuration(
|
||||
title = 'boot',
|
||||
auth?: DirectoryConfiguration['auth']
|
||||
): DirectoryConfiguration {
|
||||
return validateConfig(
|
||||
{
|
||||
site: { title, logo_path: `/public/${title}.svg` },
|
||||
...(auth === undefined ? {} : { auth }),
|
||||
links: {
|
||||
photos: {
|
||||
title: `${title} photos`,
|
||||
url: 'https://photos.example.com',
|
||||
logo_path: `/public/${title}-photos.svg`,
|
||||
tags: [' photos ', ' backup ']
|
||||
}
|
||||
}
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
}
|
||||
|
||||
function expectRedacted(value: unknown): void {
|
||||
for (const text of [String(value), JSON.stringify(value), inspect(value)]) {
|
||||
expect(text).not.toContain(sentinel);
|
||||
}
|
||||
if (value instanceof Error) expect(value.stack).not.toContain(sentinel);
|
||||
expect(value).not.toHaveProperty('cause');
|
||||
expect(value).not.toHaveProperty('value');
|
||||
expect(value).not.toHaveProperty('path');
|
||||
}
|
||||
|
||||
function captureDirectoryError(action: () => unknown): DirectoryError {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expectRedacted(error);
|
||||
return error as DirectoryError;
|
||||
}
|
||||
throw new Error('Expected a synchronous DirectoryError');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.prepare.mockReset().mockImplementation(realSnapshot.prepareSnapshot);
|
||||
mocks.authenticate.mockReset().mockReturnValue(authentication);
|
||||
mocks.report.mockReset();
|
||||
});
|
||||
|
||||
describe('application startup', () => {
|
||||
it('synchronously prepares private configuration and constructs authentication from that snapshot', () => {
|
||||
const initial = configuration('boot', credentials);
|
||||
const app = createApplication(initial);
|
||||
const snapshot = app.currentSnapshot();
|
||||
expect(mocks.prepare).toHaveBeenCalledExactlyOnceWith(initial);
|
||||
expect(snapshot).toBe(mocks.prepare.mock.results[0]!.value);
|
||||
expect(mocks.authenticate).toHaveBeenCalledExactlyOnceWith(snapshot.configuration.auth);
|
||||
expect(mocks.authenticate.mock.calls[0]![0]).toBe(snapshot.configuration.auth);
|
||||
expect(app.authentication).toBe(authentication);
|
||||
expect(snapshot.configuration.auth?.credentials?.password).toBe(sentinel);
|
||||
expect(snapshot.configuration.auth).not.toHaveProperty('secret');
|
||||
expect(snapshot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']);
|
||||
expect(snapshot.assets.referencedPaths).toEqual([
|
||||
'/public/boot.svg',
|
||||
'/public/boot-photos.svg'
|
||||
]);
|
||||
expectRedacted(snapshot.display);
|
||||
expect(mocks.report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not construct authentication when it is absent', () => {
|
||||
expect(createApplication(configuration()).authentication).toBeUndefined();
|
||||
expect(mocks.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['prepare_failed', 'auth_failed'] as const)('redacts %s at startup', (code) => {
|
||||
const fail = () => {
|
||||
throw Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel });
|
||||
};
|
||||
if (code === 'prepare_failed') mocks.prepare.mockImplementationOnce(fail);
|
||||
else mocks.authenticate.mockImplementationOnce(fail);
|
||||
const error = captureDirectoryError(() =>
|
||||
createApplication(configuration('boot', credentials))
|
||||
);
|
||||
expect(error.issue).toEqual({ code });
|
||||
if (code === 'prepare_failed') expect(mocks.authenticate).not.toHaveBeenCalled();
|
||||
expect(mocks.report).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('application config policy', () => {
|
||||
it('replaces all snapshot views together, preserving old captures and the original authentication', () => {
|
||||
const initial = configuration('boot', credentials);
|
||||
const app = createApplication(initial);
|
||||
const captured = app.currentSnapshot();
|
||||
const next = configuration('next', credentials);
|
||||
mocks.prepare.mockImplementationOnce((config) => {
|
||||
expect(app.currentSnapshot()).toBe(captured);
|
||||
return realSnapshot.prepareSnapshot(config);
|
||||
});
|
||||
expect(app.onConfigChange(next, initial)).toBe(true);
|
||||
const current = app.currentSnapshot();
|
||||
expect(current).not.toBe(captured);
|
||||
expect(current.configuration.site.title).toBe('next');
|
||||
expect(current.display.site.title).toBe('next');
|
||||
expect(current.display.groups[0]?.links[0]?.title).toBe('next photos');
|
||||
expect(current.assets.referencedPaths).toEqual(['/public/next.svg', '/public/next-photos.svg']);
|
||||
expect(current.assets.bookmarkLogos.photos).not.toBe(captured.assets.bookmarkLogos.photos);
|
||||
expect(current.display.snapshotId).not.toBe(captured.display.snapshotId);
|
||||
expect(captured.configuration.site.title).toBe('boot');
|
||||
expect(captured.display.site.title).toBe('boot');
|
||||
expect(captured.display.groups[0]?.links[0]?.title).toBe('boot photos');
|
||||
expect(captured.assets.referencedPaths).toEqual([
|
||||
'/public/boot.svg',
|
||||
'/public/boot-photos.svg'
|
||||
]);
|
||||
expectRedacted(current.display);
|
||||
expect(app.authentication).toBe(authentication);
|
||||
expect(mocks.authenticate).toHaveBeenCalledOnce();
|
||||
expect(mocks.report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts equivalent normalized values and mapping-key order without preparing or publishing', () => {
|
||||
const initial = configuration('boot', credentials);
|
||||
const app = createApplication(initial);
|
||||
const accepted = configuration('accepted', credentials);
|
||||
expect(app.onConfigChange(accepted, initial)).toBe(true);
|
||||
const current = app.currentSnapshot();
|
||||
// Source formatting is gone by this boundary; independently loaded values matter.
|
||||
const equivalent = configuration('accepted', credentials);
|
||||
equivalent.site = Object.fromEntries(
|
||||
Object.entries(equivalent.site).reverse()
|
||||
) as DirectoryConfiguration['site'];
|
||||
equivalent.auth = {
|
||||
credentials: { password: sentinel, username: 'family' },
|
||||
origin: credentials.origin
|
||||
};
|
||||
expect(app.onConfigChange(equivalent, accepted)).toBe(true);
|
||||
expect(app.onConfigChange(configuration('accepted', credentials), equivalent)).toBe(true);
|
||||
expect(app.currentSnapshot()).toBe(current);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.authenticate).toHaveBeenCalledOnce();
|
||||
expect(mocks.report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('publishes bookmark declaration order even when mapping values are equal', () => {
|
||||
const initial = validateConfig({
|
||||
links: {
|
||||
zebra: { title: 'Zebra', url: 'https://zebra.example.com', group: 'Tools' },
|
||||
alpha: { title: 'Alpha', url: 'https://alpha.example.com', group: 'Family' },
|
||||
beta: { title: 'Beta', url: 'https://beta.example.com', group: 'Tools' }
|
||||
}
|
||||
});
|
||||
const app = createApplication(initial);
|
||||
const captured = app.currentSnapshot();
|
||||
const reordered = {
|
||||
...initial,
|
||||
links: { alpha: initial.links.alpha!, beta: initial.links.beta!, zebra: initial.links.zebra! }
|
||||
};
|
||||
expect(reordered).toEqual(initial);
|
||||
expect(app.onConfigChange(reordered, initial)).toBe(true);
|
||||
const current = app.currentSnapshot();
|
||||
expect(current.display.snapshotId).not.toBe(captured.display.snapshotId);
|
||||
expect(current.display.groups.map((group) => group.label)).toEqual(['Family', 'Tools']);
|
||||
expect(current.display.groups.map((group) => group.links.map((link) => link.id))).toEqual([
|
||||
['alpha'],
|
||||
['beta', 'zebra']
|
||||
]);
|
||||
expect(captured.display.groups.map((group) => group.links.map((link) => link.id))).toEqual([
|
||||
['zebra', 'beta'],
|
||||
['alpha']
|
||||
]);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ change: 'enable auth', before: undefined, after: credentials },
|
||||
{ change: 'disable auth', before: credentials, after: undefined },
|
||||
{
|
||||
change: 'origin',
|
||||
before: credentials,
|
||||
after: { ...credentials, origin: 'https://other.example.com' }
|
||||
},
|
||||
{
|
||||
change: 'password',
|
||||
before: credentials,
|
||||
after: { ...credentials, credentials: { ...credentials.credentials, password: 'changed' } }
|
||||
},
|
||||
{
|
||||
change: 'session secret',
|
||||
before: { ...credentials, secret: `${sentinel}-session` },
|
||||
after: { ...credentials, secret: `${sentinel}-replacement` }
|
||||
}
|
||||
])('rejects the whole config for $change until auth is restored', ({ before, after }) => {
|
||||
const initial = configuration('boot', before);
|
||||
const app = createApplication(initial);
|
||||
const accepted = configuration('accepted', before);
|
||||
expect(app.onConfigChange(accepted, initial)).toBe(true);
|
||||
const captured = app.currentSnapshot();
|
||||
expect(app.onConfigChange(configuration('rejected', after), accepted)).toBe(false);
|
||||
expect(app.currentSnapshot()).toBe(captured);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' });
|
||||
expectRedacted(mocks.report.mock.calls);
|
||||
const restored = configuration('restored', before);
|
||||
expect(app.onConfigChange(restored, accepted)).toBe(true);
|
||||
expect(app.currentSnapshot().display.site.title).toBe('restored');
|
||||
expect(app.currentSnapshot().configuration.auth).toEqual(before);
|
||||
expect(app.authentication).toBe(before === undefined ? undefined : authentication);
|
||||
expect(mocks.authenticate).toHaveBeenCalledTimes(before === undefined ? 0 : 1);
|
||||
});
|
||||
|
||||
it('throws a safe preparation failure, retains last good, and recovers using the accepted baseline', () => {
|
||||
const initial = configuration();
|
||||
const app = createApplication(initial);
|
||||
const accepted = configuration('accepted');
|
||||
expect(app.onConfigChange(accepted, initial)).toBe(true);
|
||||
const captured = app.currentSnapshot();
|
||||
mocks.prepare.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel });
|
||||
});
|
||||
const error = captureDirectoryError(() =>
|
||||
app.onConfigChange(configuration('failed'), accepted)
|
||||
);
|
||||
expect(error.issue).toEqual({ code: 'prepare_failed' });
|
||||
expect(app.currentSnapshot()).toBe(captured);
|
||||
// The generic monitor, not this callback, reports thrown failures.
|
||||
expect(mocks.report).not.toHaveBeenCalled();
|
||||
expect(app.onConfigChange(configuration('accepted'), accepted)).toBe(true);
|
||||
expect(app.currentSnapshot()).toBe(captured);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(3);
|
||||
expect(app.onConfigChange(configuration('recovered'), accepted)).toBe(true);
|
||||
expect(app.currentSnapshot().display.site.title).toBe('recovered');
|
||||
expect(captured.display.site.title).toBe('accepted');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { isSameConfiguration } from '@directory/config';
|
||||
import type { DirectoryConfiguration } from '@directory/config/types';
|
||||
import { createAuthentication, type Authentication } from './auth/index.js';
|
||||
import { DirectoryError } from './directory-error.js';
|
||||
import { prepareSnapshot } from './directory/snapshot.js';
|
||||
import { reportDirectoryIssue } from './logger.js';
|
||||
|
||||
export type Application = ReturnType<typeof createApplication> & {
|
||||
readonly publicDir: string;
|
||||
stop(): void;
|
||||
};
|
||||
|
||||
export interface ServerState {
|
||||
application: Application | undefined;
|
||||
}
|
||||
|
||||
export function createApplication(initial: DirectoryConfiguration) {
|
||||
let snapshot = prepare(initial);
|
||||
let authentication: Authentication | undefined;
|
||||
try {
|
||||
const auth = snapshot.configuration.auth;
|
||||
if (auth !== undefined) authentication = createAuthentication(auth);
|
||||
} catch {
|
||||
throw new DirectoryError({ code: 'auth_failed' });
|
||||
}
|
||||
|
||||
return {
|
||||
currentSnapshot: () => snapshot,
|
||||
authentication,
|
||||
onConfigChange(next: DirectoryConfiguration, previous: DirectoryConfiguration): boolean {
|
||||
if (!isDeepStrictEqual(next.auth, previous.auth)) {
|
||||
reportDirectoryIssue({ code: 'restart_required' });
|
||||
return false;
|
||||
}
|
||||
if (isSameConfiguration(next, previous)) return true;
|
||||
snapshot = prepare(next);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(config: DirectoryConfiguration) {
|
||||
try {
|
||||
return prepareSnapshot(config);
|
||||
} catch {
|
||||
throw new DirectoryError({ code: 'prepare_failed' });
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { isHttpError, isRedirect, redirect, type Handle, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { Session, SvelteKitAuthConfig } from '@auth/sveltekit';
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { prepareSnapshot } from '../config/snapshot.js';
|
||||
import { prepareSnapshot } from '../directory/snapshot.js';
|
||||
import { GET as mountedImage } from '../../../routes/(protected)/(endpoints)/public/[...file]/+server.js';
|
||||
import { createAuthentication } from './index.js';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdtemp, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest';
|
||||
@@ -232,20 +232,33 @@ it('keeps application environment references and fallbacks stable across bootstr
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
});
|
||||
|
||||
it('polls atomic saves, retains last good across invalid/deleted files, and shuts down cleanly', async () => {
|
||||
it('polls atomic and ctime-only saves, retains last good across invalid/deleted files, and shuts down cleanly', async () => {
|
||||
const server = await launchServer(source('first'));
|
||||
const origin = await server.ready();
|
||||
await expectHealthy(origin);
|
||||
await expectPage(origin, 'first');
|
||||
|
||||
const replacement = `${server.path}.replacement`;
|
||||
const fixedTime = new Date('2020-01-01T00:00:00Z');
|
||||
await writeFile(replacement, source('second'));
|
||||
await utimes(replacement, fixedTime, fixedTime);
|
||||
await rename(replacement, server.path);
|
||||
await vi.waitFor(() => expectPage(origin, 'second'), { timeout: 5000, interval: 50 });
|
||||
|
||||
// The HTTP response confirms the watcher observed the atomic save's fixed-mtime baseline.
|
||||
const beforeEdit = await stat(server.path);
|
||||
await writeFile(server.path, source('edited'));
|
||||
await utimes(server.path, fixedTime, fixedTime);
|
||||
const afterEdit = await stat(server.path);
|
||||
expect(afterEdit.ino).toBe(beforeEdit.ino);
|
||||
expect(afterEdit.size).toBe(beforeEdit.size);
|
||||
expect(afterEdit.mtimeMs).toBe(beforeEdit.mtimeMs);
|
||||
expect(afterEdit.ctimeMs).not.toBe(beforeEdit.ctimeMs);
|
||||
await vi.waitFor(() => expectPage(origin, 'edited'), { timeout: 5000, interval: 50 });
|
||||
|
||||
await writeFile(server.path, `links: [\n${sentinel}`);
|
||||
await vi.waitFor(() => expect(server.output()).toContain('load_failed'), { timeout: 5000 });
|
||||
await expectPage(origin, 'second');
|
||||
await expectPage(origin, 'edited');
|
||||
await expectHealthy(origin);
|
||||
|
||||
const beforeDeletion = server.output().split('load_failed').length;
|
||||
@@ -254,7 +267,7 @@ it('polls atomic saves, retains last good across invalid/deleted files, and shut
|
||||
() => expect(server.output().split('load_failed').length).toBeGreaterThan(beforeDeletion),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
await expectPage(origin, 'second');
|
||||
await expectPage(origin, 'edited');
|
||||
await expectHealthy(origin);
|
||||
|
||||
await writeFile(server.path, source('third'));
|
||||
|
||||
@@ -1,459 +1,208 @@
|
||||
import { inspect } from 'node:util';
|
||||
import { resolve } from 'node:path';
|
||||
import { ConfigError, validateConfig } from '@directory/config';
|
||||
import type { DirectoryConfiguration } from '@directory/config/types';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { reportConfigError } from './logger.js';
|
||||
import type { Authentication } from './auth/index.js';
|
||||
import { bootApp } from './boot.js';
|
||||
import type { Directory } from './config/load-directory.js';
|
||||
import { DirectoryError } from './directory-error.js';
|
||||
import { prepareSnapshot } from './config/snapshot.js';
|
||||
import { logger, reportDirectoryIssue, reportStartupFailure } from './logger.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
auth: vi.fn<typeof import('./auth/index.js').createAuthentication>(),
|
||||
path: vi.fn<typeof import('@directory/config').resolveConfigPath>(),
|
||||
publicDir: vi.fn<typeof import('@directory/config').resolvePublicDir>(),
|
||||
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
|
||||
directory: vi.fn<typeof import('./config/load-directory.js').loadDirectory>(),
|
||||
watch: vi.fn<typeof import('./config/watch.js').watchConfig>(),
|
||||
stopWatching: vi.fn<() => void>()
|
||||
monitor: vi.fn<typeof import('@directory/config').startConfigReloading>(),
|
||||
auth: vi.fn<typeof import('./auth/index.js').createAuthentication>(),
|
||||
stop: vi.fn<() => void>()
|
||||
}));
|
||||
|
||||
vi.mock('@directory/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@directory/config')>()),
|
||||
resolveConfigPath: mocks.path,
|
||||
resolvePublicDir: mocks.publicDir,
|
||||
loadConfigFile: mocks.load
|
||||
loadConfigFile: mocks.load,
|
||||
startConfigReloading: mocks.monitor
|
||||
}));
|
||||
vi.mock('./config/load-directory.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./config/load-directory.js')>()),
|
||||
loadDirectory: mocks.directory
|
||||
}));
|
||||
vi.mock('./config/watch.js', () => ({ watchConfig: mocks.watch }));
|
||||
vi.mock('./auth/index.js', () => ({ createAuthentication: mocks.auth }));
|
||||
|
||||
const realDirectory = await vi.importActual<typeof import('./config/load-directory.js')>(
|
||||
'./config/load-directory.js'
|
||||
);
|
||||
const realConfig = await vi.importActual<typeof import('@directory/config')>('@directory/config');
|
||||
const sentinel = 'PRIVATE-environment-password-filename.yaml';
|
||||
const path = '/config/directory.yaml';
|
||||
const sentinel = 'PRIVATE-config-credential';
|
||||
const origin = 'https://directory.example.com';
|
||||
const frameworkEnv = { ORIGIN: origin, AUTH_URL: origin };
|
||||
const authCases = [
|
||||
{
|
||||
strategy: 'credentials',
|
||||
auth: { origin, credentials: { username: sentinel, password: sentinel } }
|
||||
},
|
||||
{
|
||||
strategy: 'oidc',
|
||||
auth: {
|
||||
origin,
|
||||
oidc: {
|
||||
issuer: 'https://identity.example.com',
|
||||
client_id: sentinel,
|
||||
client_secret: sentinel
|
||||
}
|
||||
}
|
||||
}
|
||||
] satisfies { strategy: Authentication['strategy']; auth: DirectoryConfiguration['auth'] }[];
|
||||
const auth = { origin, credentials: { username: 'family', password: sentinel } };
|
||||
const authentication: Authentication = {
|
||||
strategy: 'credentials',
|
||||
handle: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
};
|
||||
|
||||
function authentication(strategy: Authentication['strategy']): Authentication {
|
||||
return {
|
||||
strategy,
|
||||
handle: vi.fn<Authentication['handle']>().mockResolvedValue(new Response()),
|
||||
signIn: vi.fn<Authentication['signIn']>().mockResolvedValue(undefined),
|
||||
signOut: vi.fn<Authentication['signOut']>().mockResolvedValue(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function configuration(auth?: DirectoryConfiguration['auth']): DirectoryConfiguration {
|
||||
const input: Record<string, unknown> = {
|
||||
site: { title: 'Family', description: 'Welcome' },
|
||||
links: { photos: { title: 'Photos', url: 'https://photos.example.com' } }
|
||||
};
|
||||
if (auth !== undefined) input.auth = auth;
|
||||
return validateConfig(input, { env: {} });
|
||||
}
|
||||
|
||||
function loadedDirectory(config = configuration()) {
|
||||
return {
|
||||
currentSnapshot: vi.fn().mockReturnValue(prepareSnapshot(config)),
|
||||
reload: vi.fn<Directory['reload']>().mockResolvedValue(undefined),
|
||||
stop: vi.fn<Directory['stop']>()
|
||||
};
|
||||
}
|
||||
|
||||
function expectRedacted(error: unknown): void {
|
||||
for (const text of [String(error), inspect(error), JSON.stringify(error)]) {
|
||||
expect(text).not.toContain(sentinel);
|
||||
}
|
||||
if (error instanceof Error) expect(error.stack).not.toContain(sentinel);
|
||||
expect(error).not.toHaveProperty('cause');
|
||||
function configuration(title = 'Home', enabled = false): DirectoryConfiguration {
|
||||
return validateConfig({ site: { title }, links: {}, ...(enabled ? { auth } : {}) });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv('ORIGIN', undefined);
|
||||
vi.stubEnv('AUTH_URL', undefined);
|
||||
mocks.auth.mockReset();
|
||||
mocks.path.mockReset().mockReturnValue(`/config/${sentinel}`);
|
||||
mocks.publicDir.mockReset().mockReturnValue('/mounted/public');
|
||||
mocks.load.mockReset().mockResolvedValue(configuration());
|
||||
mocks.directory.mockReset().mockResolvedValue(loadedDirectory());
|
||||
mocks.watch.mockReset().mockReturnValue(mocks.stopWatching);
|
||||
mocks.path.mockReturnValue(path);
|
||||
mocks.publicDir.mockReturnValue('/mounted/public');
|
||||
mocks.load.mockResolvedValue(configuration());
|
||||
mocks.monitor.mockResolvedValue(mocks.stop);
|
||||
mocks.auth.mockReturnValue(authentication);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe('bootApp', () => {
|
||||
it('waits for valid startup, pins one path for loading and watching, then closes the save gap', async () => {
|
||||
const pending = Promise.withResolvers<Directory>();
|
||||
const directory = loadedDirectory();
|
||||
mocks.directory.mockReturnValueOnce(pending.promise);
|
||||
const env = {
|
||||
CONFIG_PATH: `/config/${sentinel}`,
|
||||
DIRECTORY_PUBLIC_DIR: '/mounted/public',
|
||||
SECRET: sentinel
|
||||
};
|
||||
const boot = bootApp(env);
|
||||
describe('application boot wiring', () => {
|
||||
it('resolves paths once and connects the loader, application reaction and diagnostics', async () => {
|
||||
const initial = configuration();
|
||||
const pending = Promise.withResolvers<DirectoryConfiguration>();
|
||||
mocks.load.mockReturnValueOnce(pending.promise);
|
||||
const env = { CONFIG_PATH: path, DIRECTORY_PUBLIC_DIR: '/mounted/public' };
|
||||
const startup = bootApp(env);
|
||||
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env });
|
||||
expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, { env });
|
||||
expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, {
|
||||
env,
|
||||
report: reportDirectoryIssue
|
||||
});
|
||||
expect(mocks.watch).not.toHaveBeenCalled();
|
||||
expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(path, { env });
|
||||
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env });
|
||||
expect(mocks.monitor).not.toHaveBeenCalled();
|
||||
|
||||
env.CONFIG_PATH = '/config/changed.yaml';
|
||||
env.DIRECTORY_PUBLIC_DIR = '/mounted/changed';
|
||||
pending.resolve(directory);
|
||||
const app = await boot;
|
||||
expect(mocks.path).toHaveBeenCalledTimes(1);
|
||||
expect(directory.currentSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.watch).toHaveBeenCalledExactlyOnceWith(
|
||||
`/config/${sentinel}`,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mocks.watch.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
directory.reload.mock.invocationCallOrder[0]!
|
||||
);
|
||||
expect(directory.reload).toHaveBeenCalledTimes(1);
|
||||
expect(app.directory).toBe(directory);
|
||||
pending.resolve(initial);
|
||||
const app = await startup;
|
||||
expect(mocks.monitor).toHaveBeenCalledExactlyOnceWith({
|
||||
path,
|
||||
initial,
|
||||
load: expect.any(Function),
|
||||
onChange: app.onConfigChange,
|
||||
onError: reportConfigError
|
||||
});
|
||||
expect(app.publicDir).toBe('/mounted/public');
|
||||
expect(mocks.publicDir).toHaveBeenCalledTimes(1);
|
||||
expect(app.authentication).toBeUndefined();
|
||||
expect(mocks.auth).not.toHaveBeenCalled();
|
||||
expect(app.directory.currentSnapshot()).toBe(directory.currentSnapshot.mock.results[0]?.value);
|
||||
await mocks.watch.mock.calls[0]![1]();
|
||||
await app.directory.reload();
|
||||
expect(directory.reload).toHaveBeenCalledTimes(3);
|
||||
expect(app.currentSnapshot().display.site.title).toBe('Home');
|
||||
expect(mocks.path).toHaveBeenCalledOnce();
|
||||
expect(mocks.publicDir).toHaveBeenCalledOnce();
|
||||
|
||||
const options = mocks.monitor.mock.calls[0]![0];
|
||||
mocks.load.mockResolvedValueOnce(configuration('Updated'));
|
||||
const next = await options.load(options.path);
|
||||
expect(mocks.load).toHaveBeenLastCalledWith(path, { env });
|
||||
expect(options.onChange(next, options.initial)).toBe(true);
|
||||
expect(app.currentSnapshot().display.site.title).toBe('Updated');
|
||||
expect(app.stop).toBe(mocks.stop);
|
||||
app.stop();
|
||||
expect(mocks.stopWatching).toHaveBeenCalledOnce();
|
||||
expect(directory.stop).toHaveBeenCalledOnce();
|
||||
expect(mocks.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, '/deployment/public'],
|
||||
[{ DIRECTORY_PUBLIC_DIR: 'mounted/images' }, resolve('mounted/images')],
|
||||
[{ DIRECTORY_PUBLIC_DIR: '/mounted/images' }, '/mounted/images']
|
||||
])(
|
||||
'pins the resolved public directory without requiring it to exist: %j',
|
||||
async (env, expected) => {
|
||||
mocks.path.mockReturnValue('/deployment/config/directory.yaml');
|
||||
mocks.publicDir.mockImplementation(realConfig.resolvePublicDir);
|
||||
const app = await bootApp(env);
|
||||
expect(app.publicDir).toBe(expected);
|
||||
expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith('/deployment/config/directory.yaml', {
|
||||
env
|
||||
});
|
||||
app.stop();
|
||||
}
|
||||
);
|
||||
|
||||
it('passes the same default options to both path resolvers', async () => {
|
||||
const app = await bootApp();
|
||||
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: process.env });
|
||||
expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, {
|
||||
env: process.env,
|
||||
report: reportDirectoryIssue
|
||||
});
|
||||
expect(mocks.directory.mock.calls[0]![1]!.env).toBe(process.env);
|
||||
expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, {
|
||||
env: process.env
|
||||
});
|
||||
app.stop();
|
||||
});
|
||||
|
||||
it('does not resolve boot until the post-watch reload finishes', async () => {
|
||||
const pending = Promise.withResolvers<void>();
|
||||
const reloading = Promise.withResolvers<void>();
|
||||
const directory = loadedDirectory();
|
||||
directory.reload.mockImplementationOnce(() => {
|
||||
reloading.resolve();
|
||||
it('waits for monitor reconciliation before returning the application', async () => {
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const pending = Promise.withResolvers<() => void>();
|
||||
mocks.monitor.mockImplementationOnce(() => {
|
||||
entered.resolve();
|
||||
return pending.promise;
|
||||
});
|
||||
mocks.directory.mockResolvedValueOnce(directory);
|
||||
let ready = false;
|
||||
const boot = bootApp().then((app) => {
|
||||
ready = true;
|
||||
return app;
|
||||
});
|
||||
await reloading.promise;
|
||||
expect(directory.reload).toHaveBeenCalledOnce();
|
||||
expect(ready).toBe(false);
|
||||
pending.resolve();
|
||||
const app = await boot;
|
||||
expect(ready).toBe(true);
|
||||
app.stop();
|
||||
const ready = vi.fn();
|
||||
const startup = bootApp().then(ready);
|
||||
await entered.promise;
|
||||
expect(ready).not.toHaveBeenCalled();
|
||||
pending.resolve(mocks.stop);
|
||||
await startup;
|
||||
expect(ready).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('propagates redacted invalid startup without starting a watcher', async () => {
|
||||
const error = new DirectoryError({
|
||||
code: 'load_failed',
|
||||
issues: new ConfigError([{ code: 'missing_environment', path: `/links/${sentinel}/url` }])
|
||||
.issues
|
||||
});
|
||||
mocks.directory.mockRejectedValueOnce(error);
|
||||
await expect(bootApp()).rejects.toBe(error);
|
||||
expectRedacted(error);
|
||||
expect(mocks.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts unknown startup errors', async () => {
|
||||
mocks.directory.mockRejectedValueOnce(new Error(sentinel));
|
||||
const error: unknown = await bootApp().catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' });
|
||||
expectRedacted(error);
|
||||
expect(mocks.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts path resolution errors before loading or watching', async () => {
|
||||
mocks.path.mockImplementationOnce(() => {
|
||||
throw new Error(sentinel);
|
||||
});
|
||||
const error: unknown = await bootApp().catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expectRedacted(error);
|
||||
expect(mocks.directory).not.toHaveBeenCalled();
|
||||
expect(mocks.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts public directory resolution errors before loading or watching', async () => {
|
||||
mocks.publicDir.mockImplementationOnce(() => {
|
||||
throw new Error(sentinel);
|
||||
});
|
||||
const error: unknown = await bootApp({}).catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' });
|
||||
expectRedacted(error);
|
||||
expect(mocks.directory).not.toHaveBeenCalled();
|
||||
expect(mocks.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(authCases)(
|
||||
'creates $strategy authentication once before watching, retaining it across the post-watch reload',
|
||||
async ({ strategy, auth }) => {
|
||||
vi.stubEnv('ORIGIN', origin);
|
||||
const config = configuration(auth);
|
||||
const replacement = validateConfig(
|
||||
{ ...config, site: { ...config.site, title: 'Replacement' } },
|
||||
{ env: {} }
|
||||
);
|
||||
const ready = authentication(strategy);
|
||||
const env = {
|
||||
AUTH_SECRET: sentinel.repeat(2),
|
||||
ORIGIN: 'https://directory.example.com',
|
||||
AUTH_URL: 'https://directory.example.com'
|
||||
};
|
||||
mocks.load.mockResolvedValueOnce(config).mockResolvedValue(replacement);
|
||||
mocks.directory.mockImplementationOnce(realDirectory.loadDirectory);
|
||||
mocks.auth.mockImplementationOnce((input) => {
|
||||
expect(input).toEqual(config.auth);
|
||||
expect(input).not.toBe(config.auth);
|
||||
expect(Object.isFrozen(input)).toBe(true);
|
||||
expect(Object.isFrozen('credentials' in input ? input.credentials : input.oidc)).toBe(true);
|
||||
expect(mocks.watch).not.toHaveBeenCalled();
|
||||
return ready;
|
||||
});
|
||||
const app = await bootApp(env, frameworkEnv);
|
||||
expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(expect.any(Object));
|
||||
expect(mocks.auth.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.watch.mock.invocationCallOrder[0]!
|
||||
);
|
||||
expect(mocks.load).toHaveBeenCalledTimes(2);
|
||||
expect(app.directory.currentSnapshot().display.site.title).toBe('Replacement');
|
||||
expect(app.authentication).toBe(ready);
|
||||
await app.directory.reload();
|
||||
expect(app.authentication).toBe(ready);
|
||||
expect(mocks.auth).toHaveBeenCalledOnce();
|
||||
app.stop();
|
||||
expect(mocks.stopWatching).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: 'auth enabled after preparation', auth: authCases[0]!.auth, env: {} },
|
||||
{ name: 'auth disabled after preparation', auth: undefined, env: frameworkEnv },
|
||||
{
|
||||
name: 'origin changed after preparation',
|
||||
auth: authCases[0]!.auth,
|
||||
env: {
|
||||
ORIGIN: `https://${sentinel}.example.com`,
|
||||
AUTH_URL: `https://${sentinel}.example.com`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Kit private ORIGIN mismatch',
|
||||
auth: authCases[0]!.auth,
|
||||
env: { ORIGIN: sentinel, AUTH_URL: origin }
|
||||
},
|
||||
{
|
||||
name: 'Kit private AUTH_URL mismatch',
|
||||
auth: authCases[0]!.auth,
|
||||
env: { ORIGIN: origin, AUTH_URL: sentinel }
|
||||
}
|
||||
])('rejects $name before authentication, watching or reloading', async ({ auth, env }) => {
|
||||
vi.stubEnv('ORIGIN', auth?.origin);
|
||||
const directory = loadedDirectory(configuration(auth));
|
||||
mocks.directory.mockResolvedValueOnce(directory);
|
||||
const error: unknown = await bootApp({}, env).catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' });
|
||||
expectRedacted(error);
|
||||
expect(mocks.auth).not.toHaveBeenCalled();
|
||||
expect(directory.stop).toHaveBeenCalledOnce();
|
||||
expect(directory.reload).not.toHaveBeenCalled();
|
||||
expect(mocks.watch).not.toHaveBeenCalled();
|
||||
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
|
||||
try {
|
||||
reportStartupFailure(error);
|
||||
expect(output).toHaveBeenCalledExactlyOnceWith('Application startup failed (auth_failed).');
|
||||
} finally {
|
||||
output.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts empty framework origins when authentication is absent', async () => {
|
||||
const app = await bootApp({}, { ORIGIN: '', AUTH_URL: '' });
|
||||
expect(app.authentication).toBeUndefined();
|
||||
expect(mocks.auth).not.toHaveBeenCalled();
|
||||
app.stop();
|
||||
});
|
||||
|
||||
it('passes explicit source env to the loader separately from prepared process and Kit private origins', async () => {
|
||||
const source = { ORIGIN: sentinel, AUTH_URL: sentinel, PASSWORD: sentinel };
|
||||
it('constructs auth before monitoring and uses Kit private URLs, not interpolation values', async () => {
|
||||
vi.stubEnv('ORIGIN', origin);
|
||||
vi.stubEnv('AUTH_URL', 'https://different.example.com');
|
||||
const directory = loadedDirectory(configuration(authCases[0]!.auth));
|
||||
mocks.directory.mockResolvedValueOnce(directory);
|
||||
const ready = authentication('credentials');
|
||||
mocks.auth.mockReturnValueOnce(ready);
|
||||
const app = await bootApp(source, frameworkEnv);
|
||||
expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, {
|
||||
env: source,
|
||||
report: reportDirectoryIssue
|
||||
});
|
||||
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: source });
|
||||
expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(
|
||||
directory.currentSnapshot().configuration.auth
|
||||
vi.stubEnv('AUTH_URL', 'https://not-kit.example.com');
|
||||
mocks.load.mockResolvedValueOnce(configuration('Home', true));
|
||||
const env = { ORIGIN: sentinel, AUTH_URL: sentinel };
|
||||
const app = await bootApp(env, frameworkEnv);
|
||||
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env });
|
||||
expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(auth);
|
||||
expect(mocks.auth.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.monitor.mock.invocationCallOrder[0]!
|
||||
);
|
||||
expect(app.authentication).toBe(ready);
|
||||
app.stop();
|
||||
expect(app.authentication).toBe(authentication);
|
||||
});
|
||||
|
||||
it('rejects a process origin mismatch even when Kit private env matches YAML', async () => {
|
||||
vi.stubEnv('ORIGIN', `https://${sentinel}.example.com`);
|
||||
const directory = loadedDirectory(configuration(authCases[0]!.auth));
|
||||
mocks.directory.mockResolvedValueOnce(directory);
|
||||
const error: unknown = await bootApp({}, frameworkEnv).catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' });
|
||||
expectRedacted(error);
|
||||
expect(directory.stop).toHaveBeenCalledOnce();
|
||||
expect(directory.reload).not.toHaveBeenCalled();
|
||||
expect(mocks.auth).not.toHaveBeenCalled();
|
||||
expect(mocks.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks process.env by default rather than source interpolation origins', async () => {
|
||||
// Keep the adapter origin valid so only the default SDK environment can reject this boot.
|
||||
vi.stubEnv('ORIGIN', origin);
|
||||
const directory = loadedDirectory(configuration(authCases[0]!.auth));
|
||||
mocks.directory.mockResolvedValueOnce(directory);
|
||||
const error: unknown = await bootApp(frameworkEnv).catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' });
|
||||
expectRedacted(error);
|
||||
expect(directory.stop).toHaveBeenCalledOnce();
|
||||
expect(mocks.auth).not.toHaveBeenCalled();
|
||||
expect(mocks.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(authCases)(
|
||||
'redacts unknown $strategy provider initialization failures before watching or reloading',
|
||||
async ({ auth }) => {
|
||||
vi.stubEnv('ORIGIN', origin);
|
||||
const directory = loadedDirectory(configuration(auth));
|
||||
mocks.directory.mockResolvedValueOnce(directory);
|
||||
mocks.auth.mockImplementationOnce(() => {
|
||||
throw new Error(sentinel, { cause: sentinel });
|
||||
it.each([
|
||||
{ name: 'auth added', enabled: true, prepared: undefined, privateEnv: {} },
|
||||
{ name: 'auth removed', enabled: false, prepared: origin, privateEnv: frameworkEnv },
|
||||
{
|
||||
name: 'prepared process origin changed',
|
||||
enabled: true,
|
||||
prepared: 'https://other.example.com',
|
||||
privateEnv: frameworkEnv
|
||||
},
|
||||
{
|
||||
name: 'Kit private ORIGIN differs',
|
||||
enabled: true,
|
||||
prepared: origin,
|
||||
privateEnv: { ...frameworkEnv, ORIGIN: sentinel }
|
||||
},
|
||||
{
|
||||
name: 'Kit private AUTH_URL differs',
|
||||
enabled: true,
|
||||
prepared: origin,
|
||||
privateEnv: { ...frameworkEnv, AUTH_URL: sentinel }
|
||||
}
|
||||
])(
|
||||
'fails closed when $name before auth or monitoring',
|
||||
async ({ enabled, prepared, privateEnv }) => {
|
||||
vi.stubEnv('ORIGIN', prepared);
|
||||
mocks.load.mockResolvedValueOnce(configuration('Home', enabled));
|
||||
await expect(bootApp({}, privateEnv)).rejects.toMatchObject({
|
||||
issue: { code: 'auth_failed' }
|
||||
});
|
||||
const error: unknown = await bootApp({}, frameworkEnv).catch((error: unknown) => error);
|
||||
expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(
|
||||
directory.currentSnapshot.mock.results[0]?.value.configuration.auth
|
||||
);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' });
|
||||
expectRedacted(error);
|
||||
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
|
||||
try {
|
||||
reportStartupFailure(error);
|
||||
expect(output).toHaveBeenCalledExactlyOnceWith('Application startup failed (auth_failed).');
|
||||
} finally {
|
||||
output.mockRestore();
|
||||
}
|
||||
expect(directory.stop).toHaveBeenCalledOnce();
|
||||
expect(directory.reload).not.toHaveBeenCalled();
|
||||
expect(mocks.watch).not.toHaveBeenCalled();
|
||||
expect(mocks.auth).not.toHaveBeenCalled();
|
||||
expect(mocks.monitor).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('stops the loaded directory and redacts a watcher setup failure', async () => {
|
||||
const directory = loadedDirectory();
|
||||
mocks.directory.mockResolvedValueOnce(directory);
|
||||
mocks.watch.mockImplementationOnce(() => {
|
||||
throw new Error(sentinel);
|
||||
});
|
||||
const error: unknown = await bootApp().catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect((error as DirectoryError).issue).toEqual({ code: 'watch_failed' });
|
||||
expectRedacted(error);
|
||||
expect(directory.stop).toHaveBeenCalledOnce();
|
||||
expect(directory.reload).not.toHaveBeenCalled();
|
||||
it('uses the process environment when no environments are injected', async () => {
|
||||
await bootApp();
|
||||
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: process.env });
|
||||
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env: process.env });
|
||||
});
|
||||
|
||||
it('stops watching and prevents a pending real directory reload from publishing', async () => {
|
||||
mocks.directory.mockImplementationOnce(realDirectory.loadDirectory);
|
||||
const app = await bootApp({});
|
||||
expect(app.authentication).toBeUndefined();
|
||||
expect(mocks.auth).not.toHaveBeenCalled();
|
||||
const original = app.directory.currentSnapshot();
|
||||
const pending = Promise.withResolvers<DirectoryConfiguration>();
|
||||
const loading = Promise.withResolvers<void>();
|
||||
mocks.load.mockImplementationOnce(() => {
|
||||
loading.resolve();
|
||||
return pending.promise;
|
||||
it('retains safe loader diagnostics without starting monitoring', async () => {
|
||||
const failure = new ConfigError([
|
||||
{ code: 'missing_environment', path: `/links/${sentinel}/url` }
|
||||
]);
|
||||
mocks.load.mockRejectedValueOnce(failure);
|
||||
const error: unknown = await bootApp().catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect(error).toMatchObject({ issue: { code: 'load_failed', issues: failure.issues } });
|
||||
expect(JSON.stringify(error)).not.toContain(sentinel);
|
||||
expect(mocks.monitor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['path', 'publicDir', 'load'] as const)(
|
||||
'redacts unexpected %s failures',
|
||||
async (stage) => {
|
||||
mocks[stage].mockImplementationOnce(() => {
|
||||
throw new Error(sentinel);
|
||||
});
|
||||
const error: unknown = await bootApp().catch((error: unknown) => error);
|
||||
expect(error).toMatchObject({ issue: { code: 'load_failed' } });
|
||||
expect(String(error)).not.toContain(sentinel);
|
||||
expect(error).not.toHaveProperty('cause');
|
||||
expect(mocks.monitor).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not start monitoring when authentication initialization fails', async () => {
|
||||
vi.stubEnv('ORIGIN', origin);
|
||||
mocks.load.mockResolvedValueOnce(configuration('Home', true));
|
||||
mocks.auth.mockImplementationOnce(() => {
|
||||
throw new Error(sentinel);
|
||||
});
|
||||
const reload = mocks.watch.mock.calls[0]![1]();
|
||||
await loading.promise;
|
||||
app.stop();
|
||||
pending.resolve(validateConfig({ site: { title: 'Replacement' }, links: {} }, { env: {} }));
|
||||
await reload;
|
||||
expect(mocks.stopWatching).toHaveBeenCalledOnce();
|
||||
expect(app.directory.currentSnapshot()).toBe(original);
|
||||
expect(original.display.site.title).toBe('Family');
|
||||
await app.directory.reload();
|
||||
expect(mocks.load).toHaveBeenCalledTimes(3);
|
||||
await expect(bootApp({}, frameworkEnv)).rejects.toMatchObject({
|
||||
issue: { code: 'auth_failed' }
|
||||
});
|
||||
expect(mocks.monitor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts monitor setup errors at the application boundary', async () => {
|
||||
mocks.monitor.mockRejectedValueOnce(new Error(sentinel));
|
||||
const error: unknown = await bootApp().catch((error: unknown) => error);
|
||||
expect(error).toMatchObject({ issue: { code: 'watch_failed' } });
|
||||
expect(String(error)).not.toContain(sentinel);
|
||||
expect(error).not.toHaveProperty('cause');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import { resolveConfigPath, resolvePublicDir } from '@directory/config';
|
||||
import type { ConfigEnvironment } from '@directory/config/types';
|
||||
import { createAuthentication, type Authentication } from './auth/index.js';
|
||||
import { loadDirectory, type Directory } from './config/load-directory.js';
|
||||
import {
|
||||
ConfigError,
|
||||
loadConfigFile,
|
||||
resolveConfigPath,
|
||||
resolvePublicDir,
|
||||
startConfigReloading
|
||||
} from '@directory/config';
|
||||
import type { ConfigEnvironment, DirectoryConfiguration } from '@directory/config/types';
|
||||
import { createApplication, type Application } from './application.js';
|
||||
import { DirectoryError } from './directory-error.js';
|
||||
import type { Application } from './lifecycle/application.js';
|
||||
import { watchConfig } from './config/watch.js';
|
||||
import { reportDirectoryIssue } from './logger.js';
|
||||
import { reportConfigError } from './logger.js';
|
||||
|
||||
export async function bootApp(
|
||||
env: ConfigEnvironment = process.env,
|
||||
@@ -14,50 +17,39 @@ export async function bootApp(
|
||||
const options = { env };
|
||||
let path: string;
|
||||
let publicDir: string;
|
||||
let directory: Directory;
|
||||
let config: DirectoryConfiguration;
|
||||
try {
|
||||
path = resolveConfigPath(options);
|
||||
publicDir = resolvePublicDir(path, options);
|
||||
directory = await loadDirectory(path, { ...options, report: reportDirectoryIssue });
|
||||
config = await loadConfigFile(path, options);
|
||||
} catch (error) {
|
||||
if (error instanceof DirectoryError) throw error;
|
||||
throw new DirectoryError({ code: 'load_failed' });
|
||||
throw new DirectoryError({
|
||||
code: 'load_failed',
|
||||
...(error instanceof ConfigError ? { issues: error.issues } : {})
|
||||
});
|
||||
}
|
||||
|
||||
let authentication: Authentication | undefined;
|
||||
try {
|
||||
const auth = directory.currentSnapshot().configuration.auth;
|
||||
// Reject edits between URL preparation and Kit startup, including enabling/disabling auth.
|
||||
if (
|
||||
auth?.origin !== (process.env.ORIGIN || undefined) ||
|
||||
auth?.origin !== (frameworkEnv.ORIGIN || undefined) ||
|
||||
auth?.origin !== (frameworkEnv.AUTH_URL || undefined)
|
||||
) {
|
||||
throw new DirectoryError({ code: 'auth_failed' });
|
||||
}
|
||||
if (auth !== undefined) authentication = createAuthentication(auth);
|
||||
} catch {
|
||||
directory.stop();
|
||||
// Reject changes between framework URL preparation and the initial application load.
|
||||
if (
|
||||
config.auth?.origin !== (process.env.ORIGIN || undefined) ||
|
||||
config.auth?.origin !== (frameworkEnv.ORIGIN || undefined) ||
|
||||
config.auth?.origin !== (frameworkEnv.AUTH_URL || undefined)
|
||||
) {
|
||||
throw new DirectoryError({ code: 'auth_failed' });
|
||||
}
|
||||
|
||||
let stopWatching: () => void;
|
||||
const application = createApplication(config);
|
||||
let stop: () => void;
|
||||
try {
|
||||
stopWatching = watchConfig(path, () => directory.reload());
|
||||
stop = await startConfigReloading({
|
||||
path,
|
||||
initial: config,
|
||||
load: (path) => loadConfigFile(path, options),
|
||||
onChange: application.onConfigChange,
|
||||
onError: reportConfigError
|
||||
});
|
||||
} catch {
|
||||
directory.stop();
|
||||
throw new DirectoryError({ code: 'watch_failed' });
|
||||
}
|
||||
|
||||
// Catch a save between the initial read and installation of the path watcher.
|
||||
await directory.reload();
|
||||
return {
|
||||
directory,
|
||||
publicDir,
|
||||
authentication,
|
||||
stop() {
|
||||
stopWatching();
|
||||
directory.stop();
|
||||
}
|
||||
};
|
||||
return { ...application, publicDir, stop };
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { inspect } from 'node:util';
|
||||
import { ConfigError, validateConfig } from '@directory/config';
|
||||
import type { DirectoryConfiguration } from '@directory/config/types';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DirectoryError, type DirectoryIssue } from '../directory-error.js';
|
||||
import { loadDirectory } from './load-directory.js';
|
||||
import type { PreparedSnapshot } from './snapshot.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
|
||||
prepare: vi.fn<(config: DirectoryConfiguration) => PreparedSnapshot | Promise<PreparedSnapshot>>()
|
||||
}));
|
||||
|
||||
vi.mock('@directory/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@directory/config')>()),
|
||||
loadConfigFile: mocks.load
|
||||
}));
|
||||
vi.mock('./snapshot.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./snapshot.js')>()),
|
||||
prepareSnapshot: mocks.prepare
|
||||
}));
|
||||
|
||||
const realSnapshot = await vi.importActual<typeof import('./snapshot.js')>('./snapshot.js');
|
||||
const sentinel = 'PRIVATE-password-env-filename.yml';
|
||||
const credentials = {
|
||||
origin: 'https://directory.example.com',
|
||||
credentials: { username: 'family', password: sentinel }
|
||||
};
|
||||
const oidc = {
|
||||
origin: 'https://directory.example.com',
|
||||
oidc: {
|
||||
issuer: 'https://identity.example.com',
|
||||
client_id: 'directory',
|
||||
client_secret: sentinel
|
||||
}
|
||||
};
|
||||
|
||||
function configuration(
|
||||
title = 'boot',
|
||||
auth?: DirectoryConfiguration['auth']
|
||||
): DirectoryConfiguration {
|
||||
return validateConfig(
|
||||
{
|
||||
site: { title, logo_path: `/public/${title}.svg` },
|
||||
...(auth === undefined ? {} : { auth }),
|
||||
links: {
|
||||
photos: {
|
||||
title: `${title} photos`,
|
||||
url: 'https://photos.example.com',
|
||||
logo_path: `/public/${title}-photos.svg`,
|
||||
tags: [' photos ', ' backup ']
|
||||
}
|
||||
}
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
}
|
||||
|
||||
function expectRedacted(value: unknown): void {
|
||||
for (const text of [String(value), JSON.stringify(value), inspect(value)]) {
|
||||
expect(text).not.toContain(sentinel);
|
||||
}
|
||||
if (value instanceof Error) expect(value.stack).not.toContain(sentinel);
|
||||
expect(value).not.toHaveProperty('cause');
|
||||
expect(value).not.toHaveProperty('value');
|
||||
expect(value).not.toHaveProperty('path');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.load.mockReset().mockResolvedValue(configuration());
|
||||
mocks.prepare.mockReset().mockImplementation(realSnapshot.prepareSnapshot);
|
||||
});
|
||||
|
||||
describe('directory startup', () => {
|
||||
it('returns a directory only after loading and preparation have completed', async () => {
|
||||
const loaded = Promise.withResolvers<DirectoryConfiguration>();
|
||||
const prepared = Promise.withResolvers<PreparedSnapshot>();
|
||||
const preparing = Promise.withResolvers<void>();
|
||||
mocks.load.mockReturnValueOnce(loaded.promise);
|
||||
mocks.prepare.mockImplementationOnce(() => {
|
||||
preparing.resolve();
|
||||
return prepared.promise;
|
||||
});
|
||||
let returned = false;
|
||||
const startup = loadDirectory().then((directory) => {
|
||||
returned = true;
|
||||
return directory;
|
||||
});
|
||||
expect(returned).toBe(false);
|
||||
expect(mocks.prepare).not.toHaveBeenCalled();
|
||||
const config = configuration();
|
||||
loaded.resolve(config);
|
||||
await preparing.promise;
|
||||
expect(returned).toBe(false);
|
||||
const snapshot = realSnapshot.prepareSnapshot(config);
|
||||
prepared.resolve(snapshot);
|
||||
const directory = await startup;
|
||||
const current: PreparedSnapshot = directory.currentSnapshot();
|
||||
expect(current).toBe(snapshot);
|
||||
expect(current.display.site.title).toBe('boot');
|
||||
expect(directory).not.toHaveProperty('start');
|
||||
});
|
||||
|
||||
it('rejects invalid startup load with only safe, frozen loader diagnostics', async () => {
|
||||
const report = vi.fn();
|
||||
const failure = new ConfigError([
|
||||
{ code: 'missing_environment', path: `/links/${sentinel}/url` }
|
||||
]);
|
||||
mocks.load.mockRejectedValueOnce(failure);
|
||||
const error: unknown = await loadDirectory(undefined, { report }).catch(
|
||||
(error: unknown) => error
|
||||
);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
const issue = (error as DirectoryError).issue;
|
||||
expect(issue).toEqual({ code: 'load_failed', issues: failure.issues });
|
||||
expect(Object.isFrozen(issue)).toBe(true);
|
||||
expect(Object.isFrozen(issue.issues)).toBe(true);
|
||||
expect(Object.isFrozen(issue.issues?.[0])).toBe(true);
|
||||
expectRedacted(error);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
expect(mocks.prepare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts an unknown read failure at startup', async () => {
|
||||
const failure = Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel });
|
||||
mocks.load.mockRejectedValueOnce(failure);
|
||||
const error: unknown = await loadDirectory(sentinel).catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' });
|
||||
expectRedacted(error);
|
||||
});
|
||||
|
||||
it('redacts a preparation failure at startup', async () => {
|
||||
mocks.prepare.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel });
|
||||
});
|
||||
const error: unknown = await loadDirectory().catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect((error as DirectoryError).issue).toEqual({ code: 'prepare_failed' });
|
||||
expectRedacted(error);
|
||||
});
|
||||
|
||||
it('redacts path resolution failure before attempting a read', async () => {
|
||||
const cwd = vi.spyOn(process, 'cwd').mockImplementationOnce(() => {
|
||||
throw new Error(sentinel);
|
||||
});
|
||||
const startup = loadDirectory();
|
||||
cwd.mockRestore();
|
||||
const error: unknown = await startup.catch((error: unknown) => error);
|
||||
expect(error).toBeInstanceOf(DirectoryError);
|
||||
expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' });
|
||||
expectRedacted(error);
|
||||
expect(mocks.load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves explicit and default paths once and forwards the selected environment', async () => {
|
||||
const report = vi.fn();
|
||||
const env = { CONFIG_PATH: 'config/first.yaml', PASSWORD: sentinel };
|
||||
const directory = await loadDirectory(undefined, { env, report });
|
||||
const pinned = resolve('config/first.yaml');
|
||||
expect(mocks.load).toHaveBeenLastCalledWith(pinned, { env });
|
||||
env.CONFIG_PATH = 'config/second.yaml';
|
||||
await directory.reload();
|
||||
expect(mocks.load).toHaveBeenLastCalledWith(pinned, { env });
|
||||
expect(mocks.load.mock.calls[1]?.[1]?.env).toBe(env);
|
||||
const explicit = await loadDirectory('custom/directory.yaml', { env, report });
|
||||
await explicit.reload();
|
||||
expect(mocks.load).toHaveBeenLastCalledWith(resolve('custom/directory.yaml'), { env });
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filesystem integration', () => {
|
||||
it('loads disposable YAML with env and defaults, retains last good after an invalid edit, and recovers', async ({
|
||||
onTestFinished
|
||||
}) => {
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'directory-directory-'));
|
||||
onTestFinished(() => rm(temporary, { recursive: true, force: true }));
|
||||
const configPath = join(temporary, sentinel);
|
||||
const source = `
|
||||
site:
|
||||
title: \${DIRECTORY_TITLE}
|
||||
logo_path: /public/boot.svg
|
||||
auth:
|
||||
origin: https://directory.example.com
|
||||
credentials:
|
||||
username: family
|
||||
password: \${DIRECTORY_PASSWORD}
|
||||
links:
|
||||
photos:
|
||||
title: Photos
|
||||
url: https://photos.example.com
|
||||
tags: [photos, backup]
|
||||
`;
|
||||
await writeFile(configPath, source);
|
||||
const realConfig =
|
||||
await vi.importActual<typeof import('@directory/config')>('@directory/config');
|
||||
mocks.load.mockImplementation(realConfig.loadConfigFile);
|
||||
const report = vi.fn<(issue: DirectoryIssue) => void>();
|
||||
const env = {
|
||||
CONFIG_PATH: configPath,
|
||||
DIRECTORY_TITLE: 'Filesystem boot',
|
||||
DIRECTORY_PASSWORD: sentinel
|
||||
};
|
||||
const directory = await loadDirectory(undefined, { env, report });
|
||||
onTestFinished(() => directory.stop());
|
||||
const boot = directory.currentSnapshot();
|
||||
expect(boot.configuration.site.title).toBe('Filesystem boot');
|
||||
expect(boot.configuration.auth?.credentials?.password).toBe(sentinel);
|
||||
expect(boot.configuration.auth).not.toHaveProperty('secret');
|
||||
expect(boot.display.site).toEqual({
|
||||
title: 'Filesystem boot',
|
||||
description: '',
|
||||
footer: '',
|
||||
logo_path: '/public/boot.svg',
|
||||
favicon_path: '/favicon.svg',
|
||||
open_links_in_new_tab: true
|
||||
});
|
||||
expect(boot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']);
|
||||
expect(boot.assets.brandingPaths).toEqual(['/public/boot.svg']);
|
||||
expect(JSON.stringify(boot.display)).not.toContain(sentinel);
|
||||
|
||||
await writeFile(
|
||||
configPath,
|
||||
'# Same values, different formatting.\n' +
|
||||
source.replace('tags: [photos, backup]', 'tags:\n - photos\n - backup')
|
||||
);
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(boot);
|
||||
expect(mocks.prepare).toHaveBeenCalledOnce();
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
|
||||
await writeFile(configPath, `site: [\n${sentinel}`);
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(boot);
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith({
|
||||
code: 'load_failed',
|
||||
issues: [{ code: 'invalid_yaml', path: '/', message: 'Invalid YAML document.' }]
|
||||
});
|
||||
expectRedacted(report.mock.calls[0]?.[0]);
|
||||
|
||||
env.CONFIG_PATH = join(temporary, 'does-not-exist.yaml');
|
||||
env.DIRECTORY_TITLE = 'Filesystem recovered';
|
||||
await writeFile(configPath, source.replace('boot.svg', 'recovered.svg'));
|
||||
await directory.reload();
|
||||
const recovered = directory.currentSnapshot();
|
||||
expect(recovered).not.toBe(boot);
|
||||
expect(recovered.configuration.site.title).toBe('Filesystem recovered');
|
||||
expect(recovered.configuration.auth).toEqual(boot.configuration.auth);
|
||||
expect(recovered.configuration.auth).not.toHaveProperty('secret');
|
||||
expect(recovered.display.site.title).toBe('Filesystem recovered');
|
||||
expect(recovered.assets.referencedPaths).toEqual(['/public/recovered.svg']);
|
||||
expect(recovered.display.snapshotId).not.toBe(boot.display.snapshotId);
|
||||
expect(boot.display.site.title).toBe('Filesystem boot');
|
||||
expect(boot.assets.referencedPaths).toEqual(['/public/boot.svg']);
|
||||
expect(report).toHaveBeenCalledTimes(1);
|
||||
|
||||
const explicit = await loadDirectory(configPath, { env, report });
|
||||
onTestFinished(() => explicit.stop());
|
||||
expect(explicit.currentSnapshot().display.site.title).toBe('Filesystem recovered');
|
||||
expect(report).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('owned snapshot reloads', () => {
|
||||
it('keeps the snapshot and revision when independently loaded values are unchanged', async () => {
|
||||
const report = vi.fn();
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
const current = directory.currentSnapshot();
|
||||
mocks.load.mockResolvedValueOnce(configuration());
|
||||
await directory.reload();
|
||||
expect(mocks.load).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.prepare).toHaveBeenCalledOnce();
|
||||
expect(directory.currentSnapshot()).toBe(current);
|
||||
expect(directory.currentSnapshot().display.snapshotId).toBe(current.display.snapshotId);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('compares unchanged values with the latest accepted snapshot, not just boot', async () => {
|
||||
const directory = await loadDirectory();
|
||||
mocks.load.mockResolvedValueOnce(configuration('next'));
|
||||
await directory.reload();
|
||||
const current = directory.currentSnapshot();
|
||||
mocks.load.mockResolvedValueOnce(configuration('next'));
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(current);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ignores site mapping-key order when values are unchanged', async () => {
|
||||
const directory = await loadDirectory();
|
||||
const current = directory.currentSnapshot();
|
||||
const reordered = configuration();
|
||||
reordered.site = Object.fromEntries(
|
||||
Object.entries(reordered.site).reverse()
|
||||
) as DirectoryConfiguration['site'];
|
||||
mocks.load.mockResolvedValueOnce(reordered);
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(current);
|
||||
expect(mocks.prepare).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('publishes a flat bookmark reorder even when all field values are equal', async () => {
|
||||
const initial = validateConfig(
|
||||
{
|
||||
links: {
|
||||
zebra: { title: 'Zebra', url: 'https://zebra.example.com' },
|
||||
alpha: { title: 'Alpha', url: 'https://alpha.example.com' }
|
||||
}
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
mocks.load.mockResolvedValueOnce(initial);
|
||||
const directory = await loadDirectory();
|
||||
const current = directory.currentSnapshot();
|
||||
const reordered = {
|
||||
...initial,
|
||||
links: { alpha: initial.links.alpha!, zebra: initial.links.zebra! }
|
||||
};
|
||||
expect(reordered).toEqual(initial);
|
||||
mocks.load.mockResolvedValueOnce(reordered);
|
||||
await directory.reload();
|
||||
const next = directory.currentSnapshot();
|
||||
expect(next).not.toBe(current);
|
||||
expect(next.display.snapshotId).not.toBe(current.display.snapshotId);
|
||||
expect(next.display.groups[0]!.links.map((link) => link.id)).toEqual(['alpha', 'zebra']);
|
||||
expect(current.display.groups[0]!.links.map((link) => link.id)).toEqual(['zebra', 'alpha']);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('publishes group and within-group order changes derived from bookmark declarations', async () => {
|
||||
const initial = validateConfig(
|
||||
{
|
||||
links: {
|
||||
zebra: { title: 'Zebra', url: 'https://zebra.example.com', group: 'Tools' },
|
||||
alpha: { title: 'Alpha', url: 'https://alpha.example.com', group: 'Family' },
|
||||
beta: { title: 'Beta', url: 'https://beta.example.com', group: 'Tools' }
|
||||
}
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
mocks.load.mockResolvedValueOnce(initial);
|
||||
const directory = await loadDirectory();
|
||||
const current = directory.currentSnapshot();
|
||||
const reordered = {
|
||||
...initial,
|
||||
links: {
|
||||
alpha: initial.links.alpha!,
|
||||
beta: initial.links.beta!,
|
||||
zebra: initial.links.zebra!
|
||||
}
|
||||
};
|
||||
expect(reordered).toEqual(initial);
|
||||
mocks.load.mockResolvedValueOnce(reordered);
|
||||
await directory.reload();
|
||||
const next = directory.currentSnapshot();
|
||||
expect(next).not.toBe(current);
|
||||
expect(next.display.groups.map((group) => group.label)).toEqual(['Family', 'Tools']);
|
||||
expect(next.display.groups.map((group) => group.links.map((link) => link.id))).toEqual([
|
||||
['alpha'],
|
||||
['beta', 'zebra']
|
||||
]);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retains the last-good revision when a rejected edit is restored to the same values', async () => {
|
||||
const report = vi.fn();
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
const current = directory.currentSnapshot();
|
||||
mocks.load.mockRejectedValueOnce(new ConfigError([{ code: 'invalid_yaml', path: '/' }]));
|
||||
await directory.reload();
|
||||
mocks.load.mockResolvedValueOnce(configuration());
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(current);
|
||||
expect(mocks.prepare).toHaveBeenCalledOnce();
|
||||
expect(report).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('serializes load and preparation, coalescing bursts into one pending follow-up', async () => {
|
||||
const directory = await loadDirectory();
|
||||
const boot = directory.currentSnapshot();
|
||||
const firstLoad = Promise.withResolvers<DirectoryConfiguration>();
|
||||
const firstPrepare = Promise.withResolvers<void>();
|
||||
const preparing = Promise.withResolvers<void>();
|
||||
const secondLoad = Promise.withResolvers<DirectoryConfiguration>();
|
||||
const loadingAgain = Promise.withResolvers<void>();
|
||||
mocks.load.mockReturnValueOnce(firstLoad.promise).mockImplementationOnce(() => {
|
||||
loadingAgain.resolve();
|
||||
return secondLoad.promise;
|
||||
});
|
||||
mocks.prepare.mockImplementationOnce((config) => {
|
||||
preparing.resolve();
|
||||
return firstPrepare.promise.then(() => realSnapshot.prepareSnapshot(config));
|
||||
});
|
||||
const run = directory.reload();
|
||||
await Promise.resolve();
|
||||
for (let i = 0; i < 10; i++) expect(directory.reload()).toBe(run);
|
||||
expect(mocks.load).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(1);
|
||||
firstLoad.resolve(configuration('one'));
|
||||
await preparing.promise;
|
||||
for (let i = 0; i < 10; i++) expect(directory.reload()).toBe(run);
|
||||
expect(mocks.load).toHaveBeenCalledTimes(2);
|
||||
expect(directory.currentSnapshot()).toBe(boot);
|
||||
firstPrepare.resolve();
|
||||
await loadingAgain.promise;
|
||||
expect(directory.currentSnapshot().display.site.title).toBe('one');
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(2);
|
||||
secondLoad.resolve(configuration('two'));
|
||||
await run;
|
||||
expect(mocks.load).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(3);
|
||||
expect(directory.currentSnapshot().display.site.title).toBe('two');
|
||||
});
|
||||
|
||||
it('processes a queued unchanged follow-up without preparing or publishing again', async () => {
|
||||
const directory = await loadDirectory();
|
||||
const firstLoad = Promise.withResolvers<DirectoryConfiguration>();
|
||||
const secondLoad = Promise.withResolvers<DirectoryConfiguration>();
|
||||
const loadingAgain = Promise.withResolvers<void>();
|
||||
mocks.load.mockReturnValueOnce(firstLoad.promise).mockImplementationOnce(() => {
|
||||
loadingAgain.resolve();
|
||||
return secondLoad.promise;
|
||||
});
|
||||
const run = directory.reload();
|
||||
await Promise.resolve();
|
||||
expect(directory.reload()).toBe(run);
|
||||
firstLoad.resolve(configuration('next'));
|
||||
await loadingAgain.promise;
|
||||
const current = directory.currentSnapshot();
|
||||
expect(current.display.site.title).toBe('next');
|
||||
secondLoad.resolve(configuration('next'));
|
||||
await run;
|
||||
expect(directory.currentSnapshot()).toBe(current);
|
||||
expect(mocks.load).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps complete old captures while replacing private configuration, display and assets together', async () => {
|
||||
mocks.load.mockResolvedValueOnce(configuration('boot', credentials));
|
||||
const directory = await loadDirectory();
|
||||
const captured = directory.currentSnapshot();
|
||||
mocks.load.mockResolvedValueOnce(configuration('next', credentials));
|
||||
await directory.reload();
|
||||
const current = directory.currentSnapshot();
|
||||
expect(current).not.toBe(captured);
|
||||
expect(current.configuration.site.title).toBe('next');
|
||||
expect(current.display.site.title).toBe('next');
|
||||
expect(current.display.groups[0]?.links[0]?.title).toBe('next photos');
|
||||
expect(current.assets).toEqual({
|
||||
brandingPaths: ['/public/next.svg'],
|
||||
referencedPaths: ['/public/next.svg', '/public/next-photos.svg'],
|
||||
bookmarkLogos: realSnapshot.prepareSnapshot(configuration('next', credentials)).assets
|
||||
.bookmarkLogos
|
||||
});
|
||||
expect(current.display.snapshotId).not.toBe(captured.display.snapshotId);
|
||||
expect(captured.configuration.site.title).toBe('boot');
|
||||
expect(captured.display.site.title).toBe('boot');
|
||||
expect(captured.assets.bookmarkLogos.photos).toBe(
|
||||
realSnapshot.prepareSnapshot(configuration('boot', credentials)).assets.bookmarkLogos.photos
|
||||
);
|
||||
expect(current.assets.bookmarkLogos.photos).not.toBe(captured.assets.bookmarkLogos.photos);
|
||||
expect(captured.assets.referencedPaths).toEqual([
|
||||
'/public/boot.svg',
|
||||
'/public/boot-photos.svg'
|
||||
]);
|
||||
expect(JSON.stringify(current.display)).not.toContain(sentinel);
|
||||
expect(JSON.stringify(current.configuration)).toContain(sentinel);
|
||||
expect(Object.isFrozen(current)).toBe(true);
|
||||
});
|
||||
|
||||
it('retains last good on load and preparation failures, then recovers', async () => {
|
||||
const report = vi.fn<(issue: DirectoryIssue) => void>();
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
const captured = directory.currentSnapshot();
|
||||
const invalid = new ConfigError([{ code: 'invalid_yaml', path: '/' }]);
|
||||
mocks.load.mockRejectedValueOnce(invalid);
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(captured);
|
||||
expect(report).toHaveBeenLastCalledWith({ code: 'load_failed', issues: invalid.issues });
|
||||
mocks.load.mockResolvedValueOnce(configuration('preparation-fails'));
|
||||
mocks.prepare.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error(sentinel), { filename: sentinel, value: sentinel });
|
||||
});
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(captured);
|
||||
expect(report).toHaveBeenLastCalledWith({ code: 'prepare_failed' });
|
||||
for (const [issue] of report.mock.calls) {
|
||||
expect(Object.isFrozen(issue)).toBe(true);
|
||||
expectRedacted(issue);
|
||||
}
|
||||
mocks.load.mockResolvedValueOnce(configuration('recovered'));
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot().display.site.title).toBe('recovered');
|
||||
expect(directory.currentSnapshot()).not.toBe(captured);
|
||||
});
|
||||
|
||||
it('runs a queued follow-up after failure even when synchronous reporting throws', async () => {
|
||||
const report = vi.fn(() => {
|
||||
throw new Error(sentinel);
|
||||
});
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
const pending = Promise.withResolvers<DirectoryConfiguration>();
|
||||
mocks.load.mockReturnValueOnce(pending.promise).mockResolvedValue(configuration('recovered'));
|
||||
const run = directory.reload();
|
||||
await Promise.resolve();
|
||||
expect(directory.reload()).toBe(run);
|
||||
pending.reject(new Error(sentinel));
|
||||
await expect(run).resolves.toBeUndefined();
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'load_failed' });
|
||||
expect(directory.currentSnapshot().display.site.title).toBe('recovered');
|
||||
await directory.reload();
|
||||
expect(mocks.load).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boot authentication', () => {
|
||||
it.each([
|
||||
{
|
||||
change: 'origin change',
|
||||
bootAuth: credentials,
|
||||
nextAuth: { ...credentials, origin: 'https://other.example.com' }
|
||||
},
|
||||
{
|
||||
change: 'secret rotation',
|
||||
bootAuth: { ...oidc, secret: `${sentinel}-session` },
|
||||
nextAuth: { ...oidc, secret: `${sentinel}-replacement` }
|
||||
}
|
||||
])(
|
||||
'requires restart for $change and retains the entire last-good snapshot',
|
||||
async ({ bootAuth, nextAuth }) => {
|
||||
const report = vi.fn();
|
||||
mocks.load.mockResolvedValueOnce(configuration('boot', bootAuth));
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
mocks.load.mockResolvedValueOnce(configuration('accepted', bootAuth));
|
||||
await directory.reload();
|
||||
const accepted = directory.currentSnapshot();
|
||||
const captured = structuredClone(accepted);
|
||||
|
||||
mocks.load.mockResolvedValueOnce(configuration('rejected', nextAuth));
|
||||
await directory.reload();
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' });
|
||||
expectRedacted(report.mock.calls);
|
||||
expect(directory.currentSnapshot()).toBe(accepted);
|
||||
expect(directory.currentSnapshot()).toEqual(captured);
|
||||
|
||||
mocks.load.mockResolvedValueOnce(configuration('restored', bootAuth));
|
||||
await directory.reload();
|
||||
const restored = directory.currentSnapshot();
|
||||
expect(restored).not.toBe(accepted);
|
||||
expect(restored.configuration.auth).toEqual(bootAuth);
|
||||
expect(restored.display.site.title).toBe('restored');
|
||||
expect(restored.display.groups[0]?.links[0]?.title).toBe('restored photos');
|
||||
expect(restored.assets.referencedPaths).toEqual([
|
||||
'/public/restored.svg',
|
||||
'/public/restored-photos.svg'
|
||||
]);
|
||||
expect(report).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects enabling auth on an unauthenticated boot, then accepts restored absent auth', async () => {
|
||||
const report = vi.fn();
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
const boot = directory.currentSnapshot();
|
||||
mocks.load.mockResolvedValueOnce(configuration('rejected', credentials));
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(boot);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' });
|
||||
mocks.load.mockResolvedValueOnce(configuration('restored'));
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot().display.site.title).toBe('restored');
|
||||
});
|
||||
|
||||
it('rejects changed or disabled boot auth as a whole and accepts structural restoration in a different key order', async () => {
|
||||
const report = vi.fn();
|
||||
mocks.load.mockResolvedValueOnce(configuration('boot', oidc));
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
const boot = directory.currentSnapshot();
|
||||
mocks.load.mockResolvedValueOnce(
|
||||
configuration('changed', { ...oidc, oidc: { ...oidc.oidc, client_secret: 'changed' } })
|
||||
);
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(boot);
|
||||
mocks.load.mockResolvedValueOnce(configuration('disabled'));
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(boot);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(report.mock.calls).toEqual([
|
||||
[{ code: 'restart_required' }],
|
||||
[{ code: 'restart_required' }]
|
||||
]);
|
||||
expectRedacted(report.mock.calls);
|
||||
const restored = configuration('restored', oidc);
|
||||
restored.auth = {
|
||||
oidc: {
|
||||
client_secret: sentinel,
|
||||
client_id: 'directory',
|
||||
issuer: 'https://identity.example.com'
|
||||
},
|
||||
origin: oidc.origin
|
||||
};
|
||||
mocks.load.mockResolvedValueOnce(restored);
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot().display.site.title).toBe('restored');
|
||||
expect(directory.currentSnapshot().assets.brandingPaths).toEqual(['/public/restored.svg']);
|
||||
});
|
||||
|
||||
it('owns frozen boot auth and prepared data independently of mutable loader results', async () => {
|
||||
const input = configuration('boot', credentials);
|
||||
mocks.load.mockResolvedValue(input);
|
||||
const report = vi.fn();
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
const boot = directory.currentSnapshot();
|
||||
input.site.title = 'mutated';
|
||||
input.site.logo_path = '/public/mutated.svg';
|
||||
input.links.photos!.tags.push('mutated');
|
||||
input.auth!.credentials!.password = 'mutated';
|
||||
expect(Object.isFrozen(input)).toBe(false);
|
||||
expect(Object.isFrozen(boot.configuration.auth?.credentials)).toBe(true);
|
||||
expect(Object.isFrozen(boot.configuration.links.photos!.tags)).toBe(true);
|
||||
expect(boot.configuration.auth?.credentials?.password).toBe(sentinel);
|
||||
expect(boot.configuration.links.photos!.tags).toEqual([' photos ', ' backup ']);
|
||||
expect(boot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']);
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(boot);
|
||||
expect(boot.display.site.title).toBe('boot');
|
||||
expect(boot.assets.brandingPaths).toEqual(['/public/boot.svg']);
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' });
|
||||
mocks.load.mockResolvedValueOnce(configuration('restored', credentials));
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot().display.site.title).toBe('restored');
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal stop', () => {
|
||||
it('discards a completed load, suppressing preparation and queued work after stop', async () => {
|
||||
const report = vi.fn();
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
const boot = directory.currentSnapshot();
|
||||
const pending = Promise.withResolvers<DirectoryConfiguration>();
|
||||
mocks.load.mockReturnValueOnce(pending.promise);
|
||||
const run = directory.reload();
|
||||
await Promise.resolve();
|
||||
expect(directory.reload()).toBe(run);
|
||||
directory.stop();
|
||||
directory.stop();
|
||||
pending.resolve(configuration('discarded', credentials));
|
||||
await run;
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(boot);
|
||||
expect(mocks.load).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('discards completed preparation and queued work after stop', async () => {
|
||||
const report = vi.fn();
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
const boot = directory.currentSnapshot();
|
||||
const pending = Promise.withResolvers<PreparedSnapshot>();
|
||||
const entered = Promise.withResolvers<void>();
|
||||
mocks.load.mockResolvedValueOnce(configuration('pending'));
|
||||
mocks.prepare.mockImplementationOnce(() => {
|
||||
entered.resolve();
|
||||
return pending.promise;
|
||||
});
|
||||
const run = directory.reload();
|
||||
await entered.promise;
|
||||
expect(directory.reload()).toBe(run);
|
||||
directory.stop();
|
||||
pending.resolve(realSnapshot.prepareSnapshot(configuration('discarded')));
|
||||
await run;
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(boot);
|
||||
expect(mocks.load).toHaveBeenCalledTimes(2);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['load', 'prepare'] as const)(
|
||||
'suppresses late %s rejection reports after stop',
|
||||
async (stage) => {
|
||||
const report = vi.fn();
|
||||
const directory = await loadDirectory(undefined, { report });
|
||||
const boot = directory.currentSnapshot();
|
||||
const pending = Promise.withResolvers<never>();
|
||||
const entered = Promise.withResolvers<void>();
|
||||
if (stage === 'prepare') mocks.load.mockResolvedValueOnce(configuration('pending'));
|
||||
mocks[stage].mockImplementationOnce(() => {
|
||||
entered.resolve();
|
||||
return pending.promise;
|
||||
});
|
||||
const run = directory.reload();
|
||||
await entered.promise;
|
||||
expect(directory.reload()).toBe(run);
|
||||
directory.stop();
|
||||
pending.reject(new Error(sentinel));
|
||||
await expect(run).resolves.toBeUndefined();
|
||||
await directory.reload();
|
||||
expect(directory.currentSnapshot()).toBe(boot);
|
||||
expect(mocks.load).toHaveBeenCalledTimes(2);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not enter a scheduled reload stopped before work begins', async () => {
|
||||
const directory = await loadDirectory();
|
||||
const run = directory.reload();
|
||||
directory.stop();
|
||||
await run;
|
||||
await directory.reload();
|
||||
expect(mocks.load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { ConfigError, loadConfigFile, resolveConfigPath } from '@directory/config';
|
||||
import type { ConfigLoadOptions } from '@directory/config/types';
|
||||
import { DirectoryError, type DirectoryIssue } from '../directory-error.js';
|
||||
import { prepareSnapshot, type PreparedSnapshot } from './snapshot.js';
|
||||
|
||||
export interface LoadDirectoryOptions extends ConfigLoadOptions {
|
||||
/** Called synchronously with safe diagnostics for rejected reloads. */
|
||||
report?: (issue: DirectoryIssue) => void;
|
||||
}
|
||||
|
||||
export interface Directory {
|
||||
currentSnapshot(): PreparedSnapshot;
|
||||
reload(): Promise<void>;
|
||||
/** Stops future work and publication without cancelling in-flight I/O. */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
interface DirectoryState {
|
||||
currentSnapshot: PreparedSnapshot;
|
||||
readonly bootAuth: PreparedSnapshot['configuration']['auth'];
|
||||
readonly path: string;
|
||||
readonly loadOptions: ConfigLoadOptions;
|
||||
readonly report: LoadDirectoryOptions['report'];
|
||||
hasPendingReload: boolean;
|
||||
activeRunPromise: Promise<void> | undefined;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
export async function loadDirectory(
|
||||
path?: string,
|
||||
options: LoadDirectoryOptions = {}
|
||||
): Promise<Directory> {
|
||||
const loadOptions: ConfigLoadOptions = options.env === undefined ? {} : { env: options.env };
|
||||
let configPath: string;
|
||||
let initial: PreparedSnapshot;
|
||||
let code: DirectoryIssue['code'] = 'load_failed';
|
||||
try {
|
||||
configPath = path === undefined ? resolveConfigPath(loadOptions) : resolve(path);
|
||||
const config = await loadConfigFile(configPath, loadOptions);
|
||||
code = 'prepare_failed';
|
||||
initial = await prepareSnapshot(config);
|
||||
} catch (error) {
|
||||
throw new DirectoryError(safeIssue(code, error));
|
||||
}
|
||||
|
||||
const state: DirectoryState = {
|
||||
currentSnapshot: initial,
|
||||
// The initial prepared configuration is already an owned, deeply frozen copy.
|
||||
bootAuth: initial.configuration.auth,
|
||||
path: configPath,
|
||||
loadOptions,
|
||||
report: options.report,
|
||||
hasPendingReload: false,
|
||||
activeRunPromise: undefined,
|
||||
stopped: false
|
||||
};
|
||||
return {
|
||||
currentSnapshot: () => state.currentSnapshot,
|
||||
reload() {
|
||||
if (state.stopped) return Promise.resolve();
|
||||
state.hasPendingReload = true;
|
||||
// Defer the run until its promise has been recorded.
|
||||
state.activeRunPromise ??= Promise.resolve().then(() => runPendingReloads(state));
|
||||
return state.activeRunPromise;
|
||||
},
|
||||
stop() {
|
||||
state.stopped = true;
|
||||
state.hasPendingReload = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function runPendingReloads(state: DirectoryState): Promise<void> {
|
||||
try {
|
||||
while (!state.stopped && state.hasPendingReload) {
|
||||
state.hasPendingReload = false;
|
||||
await reloadSnapshot(state);
|
||||
}
|
||||
} finally {
|
||||
state.activeRunPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadSnapshot(state: DirectoryState): Promise<void> {
|
||||
let code: DirectoryIssue['code'] = 'load_failed';
|
||||
try {
|
||||
const config = await loadConfigFile(state.path, state.loadOptions);
|
||||
if (state.stopped) return;
|
||||
if (!isDeepStrictEqual(config.auth, state.bootAuth)) {
|
||||
reportIssue(state, Object.freeze({ code: 'restart_required' }));
|
||||
return;
|
||||
}
|
||||
if (isSameConfiguration(config, state.currentSnapshot.configuration)) return;
|
||||
code = 'prepare_failed';
|
||||
const snapshot = await prepareSnapshot(config);
|
||||
if (state.stopped) return;
|
||||
state.currentSnapshot = snapshot;
|
||||
} catch (error) {
|
||||
if (!state.stopped) reportIssue(state, safeIssue(code, error));
|
||||
}
|
||||
}
|
||||
|
||||
function isSameConfiguration(
|
||||
next: PreparedSnapshot['configuration'],
|
||||
current: PreparedSnapshot['configuration']
|
||||
): boolean {
|
||||
const sameValues = isDeepStrictEqual(next, current);
|
||||
const sameLinkOrder = isDeepStrictEqual(Object.keys(next.links), Object.keys(current.links));
|
||||
return sameValues && sameLinkOrder;
|
||||
}
|
||||
|
||||
function safeIssue(code: DirectoryIssue['code'], error: unknown): DirectoryIssue {
|
||||
if (code === 'load_failed' && error instanceof ConfigError) {
|
||||
return Object.freeze({ code, issues: error.issues });
|
||||
}
|
||||
return Object.freeze({ code });
|
||||
}
|
||||
|
||||
function reportIssue(state: DirectoryState, issue: DirectoryIssue): void {
|
||||
if (state.stopped) return;
|
||||
try {
|
||||
state.report?.(issue);
|
||||
} catch {
|
||||
// A synchronous diagnostic failure must not poison subsequent reloads.
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { Stats } from 'node:fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { logger } from '../logger.js';
|
||||
import { watchConfig } from './watch.js';
|
||||
|
||||
type Listener = (current: Stats, previous: Stats) => Promise<void>;
|
||||
const mocks = vi.hoisted(() => ({
|
||||
watchFile: vi.fn<(path: string, options: object, listener: Listener) => void>(),
|
||||
unwatchFile: vi.fn()
|
||||
}));
|
||||
vi.mock('node:fs', () => mocks);
|
||||
|
||||
function metadata(values: Partial<Stats> = {}): Stats {
|
||||
return { ino: 10, mtimeMs: 100, size: 50, ...values } as Stats;
|
||||
}
|
||||
|
||||
function listener(index = 0): Listener {
|
||||
return mocks.watchFile.mock.calls[index]![2];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('watchConfig', () => {
|
||||
it('registers one non-persistent one-second path poll without an initial reload', () => {
|
||||
const onChange = vi.fn<() => Promise<void>>();
|
||||
watchConfig('/config/directory.yaml', onChange);
|
||||
expect(mocks.watchFile).toHaveBeenCalledExactlyOnceWith(
|
||||
'/config/directory.yaml',
|
||||
{ interval: 1000, persistent: false },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores equal metadata and differences in unrelated stat fields', async () => {
|
||||
const onChange = vi.fn<() => Promise<void>>();
|
||||
watchConfig('/config/directory.yaml', onChange);
|
||||
await listener()(metadata(), metadata());
|
||||
await listener()(metadata({ atimeMs: 200, ctimeMs: 300 }), metadata());
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['inode (atomic replacement)', { ino: 11 }],
|
||||
['modification time', { mtimeMs: 101 }],
|
||||
['size', { size: 51 }]
|
||||
])('reloads when only %s changes', async (_name, changed) => {
|
||||
const onChange = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
||||
watchConfig('/config/directory.yaml', onChange);
|
||||
await listener()(metadata(changed), metadata());
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith();
|
||||
});
|
||||
|
||||
it('reloads on deletion and recreation, even with the original metadata', async () => {
|
||||
const onChange = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
||||
watchConfig('/config/directory.yaml', onChange);
|
||||
const missing = metadata({ ino: 0, mtimeMs: 0, size: 0 });
|
||||
await listener()(missing, metadata());
|
||||
await listener()(metadata(), missing);
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops once, removes only its exact listener, and ignores stale events', async () => {
|
||||
const onChange = vi.fn<() => Promise<void>>();
|
||||
const stop = watchConfig('/config/directory.yaml', onChange);
|
||||
const registered = listener();
|
||||
stop();
|
||||
stop();
|
||||
expect(mocks.unwatchFile).toHaveBeenCalledExactlyOnceWith('/config/directory.yaml', registered);
|
||||
await registered(metadata({ ino: 11 }), metadata());
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps registrations independent, including for the same path', async () => {
|
||||
const first = vi.fn<() => Promise<void>>();
|
||||
const second = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
||||
const stopFirst = watchConfig('/config/directory.yaml', first);
|
||||
const stopSecond = watchConfig('/config/directory.yaml', second);
|
||||
expect(listener(0)).not.toBe(listener(1));
|
||||
stopFirst();
|
||||
await listener(0)(metadata({ ino: 11 }), metadata());
|
||||
await listener(1)(metadata({ ino: 11 }), metadata());
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
stopSecond();
|
||||
expect(mocks.unwatchFile.mock.calls).toEqual([
|
||||
['/config/directory.yaml', listener(0)],
|
||||
['/config/directory.yaml', listener(1)]
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['reject', 'throw'])(
|
||||
'contains an unexpected callback %s with a fixed diagnostic',
|
||||
async (mode) => {
|
||||
const error = vi.spyOn(logger, 'error').mockReturnValue(undefined);
|
||||
const sentinel = 'PRIVATE-password-file.yaml';
|
||||
const onChange = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
||||
if (mode === 'reject') onChange.mockRejectedValueOnce(new Error(sentinel));
|
||||
else
|
||||
onChange.mockImplementationOnce(() => {
|
||||
throw new Error(sentinel);
|
||||
});
|
||||
watchConfig('/config/directory.yaml', onChange);
|
||||
await expect(listener()(metadata({ ino: 11 }), metadata())).resolves.toBeUndefined();
|
||||
expect(error).toHaveBeenCalledExactlyOnceWith('Configuration reload callback failed.');
|
||||
await listener()(metadata({ ino: 12 }), metadata({ ino: 11 }));
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(error).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['resolve', 'reject'])(
|
||||
'suppresses stale in-flight %s completion after stop',
|
||||
async (mode) => {
|
||||
const error = vi.spyOn(logger, 'error').mockReturnValue(undefined);
|
||||
const pending = Promise.withResolvers<void>();
|
||||
const onChange = vi.fn<() => Promise<void>>().mockReturnValue(pending.promise);
|
||||
const stop = watchConfig('/config/directory.yaml', onChange);
|
||||
const active = listener()(metadata({ ino: 11 }), metadata());
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
stop();
|
||||
if (mode === 'resolve') pending.resolve();
|
||||
else pending.reject(new Error('PRIVATE-password-file.yaml'));
|
||||
await expect(active).resolves.toBeUndefined();
|
||||
await listener()(metadata({ ino: 12 }), metadata({ ino: 11 }));
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { watchFile, unwatchFile, type Stats } from 'node:fs';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export function watchConfig(path: string, onChange: () => Promise<void>): () => void {
|
||||
let stopped = false;
|
||||
async function listener(current: Stats, previous: Stats): Promise<void> {
|
||||
if (
|
||||
stopped ||
|
||||
(current.ino === previous.ino &&
|
||||
current.mtimeMs === previous.mtimeMs &&
|
||||
current.size === previous.size)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onChange();
|
||||
} catch {
|
||||
if (!stopped) logger.error('Configuration reload callback failed.');
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the path so atomic saves and deletion/recreation do not detach the watcher.
|
||||
watchFile(path, { interval: 1000, persistent: false }, listener);
|
||||
return () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
unwatchFile(path, listener);
|
||||
};
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -2,7 +2,7 @@ import { validateConfig } from '@directory/config';
|
||||
import type { DirectoryConfiguration } from '@directory/config/types';
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest';
|
||||
import type { DisplaySite } from '../../display/types.js';
|
||||
import { getSite, prepareDirectoryContent } from './directory-content.js';
|
||||
import { getSite, prepareDirectoryContent } from './content.js';
|
||||
|
||||
const destination = { title: 'Photos', url: 'https://photos.example.com' };
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { DirectoryConfiguration } from '@directory/config/types';
|
||||
import type { DirectoryContent } from '../../display/types.js';
|
||||
import { freezeData, type Immutable } from './immutable.js';
|
||||
import { prepareDirectoryContent } from './directory-content.js';
|
||||
import { prepareDirectoryContent } from './content.js';
|
||||
import { renderBookmarkLogos } from '../images/render-bookmark-logos.js';
|
||||
|
||||
export interface PreparedSnapshot {
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { Authentication } from '../auth/index.js';
|
||||
import type { Directory } from '../config/load-directory.js';
|
||||
|
||||
export interface Application {
|
||||
readonly directory: Directory;
|
||||
readonly publicDir: string;
|
||||
readonly authentication: Authentication | undefined;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface ServerState {
|
||||
application: Application | undefined;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
import type { Directory } from '../config/load-directory.js';
|
||||
import { prepareSnapshot } from '../config/snapshot.js';
|
||||
import type { Application, ServerState } from './application.js';
|
||||
import { prepareSnapshot } from '../directory/snapshot.js';
|
||||
import type { Application, ServerState } from '../application.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
boot: vi.fn<typeof import('../boot.js').bootApp>()
|
||||
@@ -11,15 +10,12 @@ vi.mock('../boot.js', () => ({ bootApp: mocks.boot }));
|
||||
|
||||
function application(title: string) {
|
||||
return {
|
||||
directory: {
|
||||
currentSnapshot: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} }))
|
||||
),
|
||||
reload: vi.fn<Directory['reload']>(),
|
||||
stop: vi.fn<Directory['stop']>()
|
||||
},
|
||||
currentSnapshot: vi
|
||||
.fn<Application['currentSnapshot']>()
|
||||
.mockReturnValue(
|
||||
prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} }))
|
||||
),
|
||||
onConfigChange: vi.fn<Application['onConfigChange']>().mockReturnValue(true),
|
||||
publicDir: '/mounted/public',
|
||||
authentication: undefined,
|
||||
stop: vi.fn<Application['stop']>()
|
||||
@@ -134,7 +130,7 @@ describe('development lifecycle', () => {
|
||||
await startup;
|
||||
shutdown();
|
||||
expect(stopped.stop).toHaveBeenCalledOnce();
|
||||
expect(stopped.directory.currentSnapshot).not.toHaveBeenCalled();
|
||||
expect(stopped.currentSnapshot).not.toHaveBeenCalled();
|
||||
expect(server.application).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -154,7 +150,7 @@ describe('development lifecycle', () => {
|
||||
expect(first.stop).toHaveBeenCalledOnce();
|
||||
expect(second.stop).not.toHaveBeenCalled();
|
||||
expect(server.application).toBe(second);
|
||||
expect(server.application?.directory.currentSnapshot().display.site.title).toBe('Current');
|
||||
expect(server.application?.currentSnapshot().display.site.title).toBe('Current');
|
||||
process.emit('directory:dev-stop');
|
||||
expect(second.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConfigEnvironment } from '@directory/config/types';
|
||||
import { bootApp } from '../boot.js';
|
||||
import type { Application, ServerState } from './application.js';
|
||||
import type { Application, ServerState } from '../application.js';
|
||||
|
||||
interface DevelopmentAttempt {
|
||||
readonly server: ServerState;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
import type { Directory } from '../config/load-directory.js';
|
||||
import { prepareSnapshot } from '../config/snapshot.js';
|
||||
import type { Application, ServerState } from './application.js';
|
||||
import { prepareSnapshot } from '../directory/snapshot.js';
|
||||
import type { Application, ServerState } from '../application.js';
|
||||
import { startProduction } from './production.js';
|
||||
|
||||
const adapterEvents: NodeJS.EventEmitter = process;
|
||||
@@ -14,15 +13,12 @@ vi.mock('../boot.js', () => ({ bootApp: mocks.boot }));
|
||||
|
||||
function application(title: string) {
|
||||
return {
|
||||
directory: {
|
||||
currentSnapshot: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} }))
|
||||
),
|
||||
reload: vi.fn<Directory['reload']>(),
|
||||
stop: vi.fn<Directory['stop']>()
|
||||
},
|
||||
currentSnapshot: vi
|
||||
.fn<Application['currentSnapshot']>()
|
||||
.mockReturnValue(
|
||||
prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} }))
|
||||
),
|
||||
onConfigChange: vi.fn<Application['onConfigChange']>().mockReturnValue(true),
|
||||
publicDir: '/mounted/public',
|
||||
authentication: undefined,
|
||||
stop: vi.fn<Application['stop']>()
|
||||
@@ -72,7 +68,7 @@ describe('production lifecycle', () => {
|
||||
await startup;
|
||||
expect(server.application).toBe(ready);
|
||||
expect(ready.stop).not.toHaveBeenCalled();
|
||||
expect(ready.directory.currentSnapshot).not.toHaveBeenCalled();
|
||||
expect(ready.currentSnapshot).not.toHaveBeenCalled();
|
||||
adapterEvents.emit('sveltekit:shutdown');
|
||||
expect(ready.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConfigEnvironment } from '@directory/config/types';
|
||||
import { bootApp } from '../boot.js';
|
||||
import type { Application, ServerState } from './application.js';
|
||||
import type { Application, ServerState } from '../application.js';
|
||||
|
||||
export async function startProduction(server: ServerState, env?: ConfigEnvironment): Promise<void> {
|
||||
const application = await bootApp(undefined, env);
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
import { ConfigError } from '@directory/config';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DirectoryError } from './directory-error.js';
|
||||
import { logger, reportDirectoryIssue, reportStartupFailure } from './logger.js';
|
||||
import { logger, reportConfigError, reportDirectoryIssue, reportStartupFailure } from './logger.js';
|
||||
|
||||
const sentinel = 'PRIVATE-secret-environment-name-filename.yaml';
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('safe server reporting', () => {
|
||||
it('reports application reload failures without relabeling them as load failures', () => {
|
||||
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
|
||||
reportConfigError(new DirectoryError({ code: 'prepare_failed' }));
|
||||
expect(output).toHaveBeenCalledExactlyOnceWith(
|
||||
'Configuration update rejected (prepare_failed).'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports config load failures without private link identifiers', () => {
|
||||
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
|
||||
reportConfigError(
|
||||
new ConfigError([{ code: 'missing_environment', path: `/links/${sentinel}/url` }])
|
||||
);
|
||||
expect(output.mock.calls).toEqual([
|
||||
['Configuration update rejected (load_failed).'],
|
||||
[' /links/*/url (missing_environment)']
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([new Error(sentinel), sentinel, { cause: sentinel, value: sentinel }])(
|
||||
'never inspects or prints unknown reload errors',
|
||||
(error) => {
|
||||
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
|
||||
reportConfigError(error);
|
||||
expect(output).toHaveBeenCalledExactlyOnceWith(
|
||||
'Configuration update rejected (load_failed).'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('reports only diagnostic codes and redacted issue paths, never whole errors or messages', () => {
|
||||
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
|
||||
const issue = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ConfigError } from '@directory/config';
|
||||
import { Logger } from 'tslog';
|
||||
import { DirectoryError, type DirectoryIssue } from './directory-error.js';
|
||||
|
||||
@@ -15,6 +16,17 @@ export function reportDirectoryIssue(issue: DirectoryIssue): void {
|
||||
reportIssueDetails(issue);
|
||||
}
|
||||
|
||||
export function reportConfigError(error: unknown): void {
|
||||
if (error instanceof DirectoryError) {
|
||||
reportDirectoryIssue(error.issue);
|
||||
} else {
|
||||
reportDirectoryIssue({
|
||||
code: 'load_failed',
|
||||
...(error instanceof ConfigError ? { issues: error.issues } : {})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function reportStartupFailure(error: unknown): void {
|
||||
if (error instanceof DirectoryError) {
|
||||
logger.error(`Application startup failed (${error.issue.code}).`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { prepareSnapshot } from '../../../../../lib/server/config/snapshot.js';
|
||||
import { prepareSnapshot } from '../../../../../lib/server/directory/snapshot.js';
|
||||
import { GET } from './+server.js';
|
||||
|
||||
const sentinel = 'PRIVATE-version-endpoint-config';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Authentication } from '../../lib/server/auth/index.js';
|
||||
import { prepareSnapshot } from '../../lib/server/config/snapshot.js';
|
||||
import { prepareSnapshot } from '../../lib/server/directory/snapshot.js';
|
||||
import { actions, load } from './+page.server.js';
|
||||
|
||||
function eventFor(query = '', withAuth = false) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readMountedImage } from '../../../lib/server/images/read-mounted-image.js';
|
||||
import { prepareSnapshot } from '../../../lib/server/config/snapshot.js';
|
||||
import { prepareSnapshot } from '../../../lib/server/directory/snapshot.js';
|
||||
import { GET, HEAD } from './+server.js';
|
||||
|
||||
vi.mock('../../../lib/server/images/read-mounted-image.js', () => ({ readMountedImage: vi.fn() }));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readMountedImage } from '../../../lib/server/images/read-mounted-image.js';
|
||||
import { prepareSnapshot } from '../../../lib/server/config/snapshot.js';
|
||||
import { prepareSnapshot } from '../../../lib/server/directory/snapshot.js';
|
||||
import { GET, HEAD } from './+server.js';
|
||||
|
||||
vi.mock('../../../lib/server/images/read-mounted-image.js', () => ({ readMountedImage: vi.fn() }));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Authentication } from '../../../lib/server/auth/index.js';
|
||||
import { prepareSnapshot } from '../../../lib/server/config/snapshot.js';
|
||||
import { prepareSnapshot } from '../../../lib/server/directory/snapshot.js';
|
||||
import { logger } from '../../../lib/server/logger.js';
|
||||
import { actions, load } from './+page.server.js';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { prepareSnapshot } from '../lib/server/config/snapshot.js';
|
||||
import { prepareSnapshot } from '../lib/server/directory/snapshot.js';
|
||||
import { load } from './+layout.server.js';
|
||||
|
||||
function event(site: Record<string, string> = {}) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# @directory/config
|
||||
|
||||
Shared server-side YAML loading, validation and path resolution for Directory.
|
||||
Shared server-side YAML loading, validation, path resolution and change monitoring
|
||||
for Directory.
|
||||
|
||||
- [Configuration](../../docs/config.md): fields, defaults and safe errors.
|
||||
- [Schema](src/config.schema.json).
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isSameConfiguration, validateConfig } from './index.js';
|
||||
|
||||
function configuration() {
|
||||
return validateConfig({
|
||||
site: { title: 'Home', description: 'Household apps' },
|
||||
links: {
|
||||
photos: { title: 'Photos', url: 'https://photos.example.com' },
|
||||
music: { title: 'Music', url: 'https://music.example.com' }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('isSameConfiguration', () => {
|
||||
it('compares independently loaded values without treating ordinary mapping order as a change', () => {
|
||||
const previous = configuration();
|
||||
const next = validateConfig({
|
||||
...previous,
|
||||
site: { description: previous.site.description, title: previous.site.title }
|
||||
});
|
||||
expect(Object.keys(next.site)).not.toEqual(Object.keys(previous.site));
|
||||
expect(isSameConfiguration(next, previous)).toBe(true);
|
||||
next.site.title = 'Changed';
|
||||
expect(isSameConfiguration(next, previous)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats bookmark declaration order as a change even when values match', () => {
|
||||
const previous = configuration();
|
||||
const next = configuration();
|
||||
next.links = { music: next.links.music!, photos: next.links.photos! };
|
||||
expect(next).toEqual(previous);
|
||||
expect(isSameConfiguration(next, previous)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import type { DirectoryConfiguration } from './types.js';
|
||||
|
||||
export function isSameConfiguration(
|
||||
next: DirectoryConfiguration,
|
||||
previous: DirectoryConfiguration
|
||||
): boolean {
|
||||
const sameValues = isDeepStrictEqual(next, previous);
|
||||
// Bookmark declaration order determines display order, unlike other mapping keys.
|
||||
const sameLinkOrder = isDeepStrictEqual(Object.keys(next.links), Object.keys(previous.links));
|
||||
return sameValues && sameLinkOrder;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export { configurationVersion } from './version.js';
|
||||
export { DirectoryConfigurationSchema, configurationReferences } from './schema.js';
|
||||
export { loadConfigFile, validateConfig } from './load.js';
|
||||
export { startConfigReloading } from './monitor.js';
|
||||
export { isSameConfiguration } from './compare.js';
|
||||
export { ConfigError } from './errors.js';
|
||||
export type { ConfigIssue, ConfigIssueCode } from './errors.js';
|
||||
export { resolveConfigPath, resolveDeploymentRoot, resolvePublicDir } from './paths.js';
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { Stats } from 'node:fs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { startConfigReloading } from './index.js';
|
||||
|
||||
type Listener = (current: Stats, previous: Stats) => Promise<void>;
|
||||
const mocks = vi.hoisted(() => ({
|
||||
watchFile: vi.fn<(path: string, options: object, listener: Listener) => void>(),
|
||||
unwatchFile: vi.fn()
|
||||
}));
|
||||
vi.mock('node:fs', () => mocks);
|
||||
|
||||
const path = '/config/settings.json';
|
||||
function options() {
|
||||
return {
|
||||
path,
|
||||
initial: 'initial',
|
||||
load: vi.fn<(path: string) => Promise<string>>().mockResolvedValue('reconciled'),
|
||||
onChange: vi.fn<(next: string, previous: string) => boolean>().mockReturnValue(true),
|
||||
onError: vi.fn<(error: unknown) => void>()
|
||||
};
|
||||
}
|
||||
|
||||
function metadata(values: Partial<Stats> = {}): Stats {
|
||||
return { ino: 10, mtimeMs: 100, ctimeMs: 100, size: 50, ...values } as Stats;
|
||||
}
|
||||
function listener(index = 0): Listener {
|
||||
return mocks.watchFile.mock.calls[index]![2];
|
||||
}
|
||||
function change(index = 0): Promise<void> {
|
||||
return listener(index)(metadata({ ctimeMs: 101 }), metadata());
|
||||
}
|
||||
|
||||
beforeEach(() => vi.resetAllMocks());
|
||||
|
||||
describe('startConfigReloading', () => {
|
||||
it('subscribes before reconciliation and waits for it, using initial as the baseline', async () => {
|
||||
const input = options();
|
||||
const read = Promise.withResolvers<string>();
|
||||
input.load.mockReturnValueOnce(read.promise);
|
||||
const ready = vi.fn();
|
||||
const startup = startConfigReloading(input).then((stop) => {
|
||||
ready();
|
||||
return stop;
|
||||
});
|
||||
expect(mocks.watchFile).toHaveBeenCalledExactlyOnceWith(
|
||||
path,
|
||||
{ interval: 1000, persistent: false },
|
||||
expect.any(Function)
|
||||
);
|
||||
// Work starts in a microtask, after the active promise has been assigned.
|
||||
expect(input.load).not.toHaveBeenCalled();
|
||||
await Promise.resolve();
|
||||
expect(input.load).toHaveBeenCalledExactlyOnceWith(path);
|
||||
expect(ready).not.toHaveBeenCalled();
|
||||
expect(input.onChange).not.toHaveBeenCalled();
|
||||
read.resolve('saved during startup');
|
||||
const stop = await startup;
|
||||
expect(input.onChange).toHaveBeenCalledExactlyOnceWith('saved during startup', 'initial');
|
||||
stop();
|
||||
});
|
||||
|
||||
it('ignores unchanged metadata and access-time-only changes', async () => {
|
||||
const input = options();
|
||||
const stop = await startConfigReloading(input);
|
||||
await listener()(metadata(), metadata());
|
||||
await listener()(metadata({ atimeMs: 200 }), metadata());
|
||||
expect(input.load).toHaveBeenCalledOnce();
|
||||
stop();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['inode', { ino: 11 }],
|
||||
['mtime', { mtimeMs: 101 }],
|
||||
['ctime with unchanged size and mtime', { ctimeMs: 101 }],
|
||||
['size', { size: 51 }]
|
||||
])('reloads for changed %s', async (_name, changed) => {
|
||||
const input = options();
|
||||
const stop = await startConfigReloading(input);
|
||||
await listener()(metadata(changed), metadata());
|
||||
expect(input.load).toHaveBeenCalledTimes(2);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('retains the last accepted value after rejection, callback throws and read failure, then recovers', async () => {
|
||||
const input = options();
|
||||
const stop = await startConfigReloading(input);
|
||||
input.load.mockResolvedValueOnce('rejected');
|
||||
input.onChange.mockReturnValueOnce(false);
|
||||
await change();
|
||||
expect(input.onError).not.toHaveBeenCalled();
|
||||
|
||||
const callbackFailure = new Error('callback failed');
|
||||
input.load.mockResolvedValueOnce('throwing');
|
||||
input.onChange.mockImplementationOnce(() => {
|
||||
throw callbackFailure;
|
||||
});
|
||||
await change();
|
||||
const readFailure = new Error('read failed');
|
||||
input.load.mockRejectedValueOnce(readFailure);
|
||||
await change();
|
||||
input.load.mockResolvedValueOnce('recovered').mockResolvedValueOnce('latest');
|
||||
await change();
|
||||
await change();
|
||||
|
||||
expect(input.onChange.mock.calls).toEqual([
|
||||
['reconciled', 'initial'],
|
||||
['rejected', 'reconciled'],
|
||||
['throwing', 'reconciled'],
|
||||
['recovered', 'reconciled'],
|
||||
['latest', 'recovered']
|
||||
]);
|
||||
expect(input.onError.mock.calls).toEqual([[callbackFailure], [readFailure]]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('reports failed reconciliation without losing the initial baseline or future monitoring', async () => {
|
||||
const input = options();
|
||||
const failure = new Error('initial reconciliation failed');
|
||||
input.load.mockRejectedValueOnce(failure);
|
||||
const stop = await startConfigReloading(input);
|
||||
expect(input.onError).toHaveBeenCalledExactlyOnceWith(failure);
|
||||
expect(input.onChange).not.toHaveBeenCalled();
|
||||
await change();
|
||||
expect(input.onChange).toHaveBeenCalledExactlyOnceWith('reconciled', 'initial');
|
||||
stop();
|
||||
});
|
||||
|
||||
it('serializes slow reads and coalesces events during reconciliation into one follow-up', async () => {
|
||||
const input = options();
|
||||
const first = Promise.withResolvers<string>();
|
||||
const second = Promise.withResolvers<string>();
|
||||
const readingAgain = Promise.withResolvers<void>();
|
||||
input.load.mockReturnValueOnce(first.promise).mockImplementationOnce(() => {
|
||||
readingAgain.resolve();
|
||||
return second.promise;
|
||||
});
|
||||
const ready = vi.fn();
|
||||
const startup = startConfigReloading(input).then((stop) => {
|
||||
ready();
|
||||
return stop;
|
||||
});
|
||||
await Promise.resolve();
|
||||
const active = change();
|
||||
for (let i = 0; i < 10; i++) void change();
|
||||
expect(input.load).toHaveBeenCalledOnce();
|
||||
first.resolve('first');
|
||||
await readingAgain.promise;
|
||||
expect(input.onChange).toHaveBeenCalledExactlyOnceWith('first', 'initial');
|
||||
expect(ready).not.toHaveBeenCalled();
|
||||
second.resolve('second');
|
||||
const stop = await startup;
|
||||
await active;
|
||||
expect(input.load).toHaveBeenCalledTimes(2);
|
||||
expect(input.onChange).toHaveBeenLastCalledWith('second', 'first');
|
||||
stop();
|
||||
});
|
||||
|
||||
it('keeps monitoring after a throwing reporter', async () => {
|
||||
const input = options();
|
||||
const stop = await startConfigReloading(input);
|
||||
const failure = new Error('read failed');
|
||||
input.load.mockRejectedValueOnce(failure).mockResolvedValue('recovered');
|
||||
input.onError.mockImplementation(() => {
|
||||
throw new Error('diagnostic failure');
|
||||
});
|
||||
await change();
|
||||
await change();
|
||||
expect(input.load).toHaveBeenCalledTimes(3);
|
||||
expect(input.onError).toHaveBeenCalledExactlyOnceWith(failure);
|
||||
expect(input.onChange).toHaveBeenLastCalledWith('recovered', 'reconciled');
|
||||
stop();
|
||||
});
|
||||
|
||||
it.each(['resolve', 'reject'])(
|
||||
'stops a pending read before its late %s and clears queued work',
|
||||
async (mode) => {
|
||||
const input = options();
|
||||
const stop = await startConfigReloading(input);
|
||||
const read = Promise.withResolvers<string>();
|
||||
input.load.mockReturnValueOnce(read.promise);
|
||||
const active = change();
|
||||
await Promise.resolve();
|
||||
expect(input.load).toHaveBeenCalledTimes(2);
|
||||
void change();
|
||||
stop();
|
||||
stop();
|
||||
expect(mocks.unwatchFile).toHaveBeenCalledExactlyOnceWith(path, listener());
|
||||
if (mode === 'resolve') read.resolve('too late');
|
||||
else read.reject(new Error('too late'));
|
||||
await active;
|
||||
await change();
|
||||
expect(input.load).toHaveBeenCalledTimes(2);
|
||||
expect(input.onChange).toHaveBeenCalledOnce();
|
||||
expect(input.onError).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('stops scheduled work and removes only its own listener on a shared path', async () => {
|
||||
const first = options();
|
||||
const second = options();
|
||||
const stopFirst = await startConfigReloading(first);
|
||||
const stopSecond = await startConfigReloading(second);
|
||||
const scheduled = change(0);
|
||||
stopFirst();
|
||||
await scheduled;
|
||||
await change(0);
|
||||
await change(1);
|
||||
expect(first.load).toHaveBeenCalledOnce();
|
||||
expect(second.load).toHaveBeenCalledTimes(2);
|
||||
expect(listener(0)).not.toBe(listener(1));
|
||||
expect(mocks.unwatchFile).toHaveBeenCalledExactlyOnceWith(path, listener(0));
|
||||
stopSecond();
|
||||
expect(mocks.unwatchFile.mock.calls).toEqual([
|
||||
[path, listener(0)],
|
||||
[path, listener(1)]
|
||||
]);
|
||||
});
|
||||
|
||||
it('propagates registration failure without starting a load', async () => {
|
||||
const input = options();
|
||||
const failure = new Error('watch registration failed');
|
||||
mocks.watchFile.mockImplementationOnce(() => {
|
||||
throw failure;
|
||||
});
|
||||
await expect(startConfigReloading(input)).rejects.toBe(failure);
|
||||
expect(input.load).not.toHaveBeenCalled();
|
||||
expect(input.onChange).not.toHaveBeenCalled();
|
||||
expect(input.onError).not.toHaveBeenCalled();
|
||||
expect(mocks.unwatchFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { watchFile, unwatchFile, type Stats } from 'node:fs';
|
||||
|
||||
/** Watches a path and reconciles it against the caller's last accepted configuration. */
|
||||
export async function startConfigReloading<T>({
|
||||
path,
|
||||
initial,
|
||||
load,
|
||||
onChange,
|
||||
onError
|
||||
}: {
|
||||
path: string;
|
||||
initial: T;
|
||||
load: (path: string) => Promise<T>;
|
||||
/** Synchronously accept or reject the loaded value. */
|
||||
onChange: (next: T, previous: T) => boolean;
|
||||
/** Receives raw failures; the caller is responsible for safe diagnostics. */
|
||||
onError: (error: unknown) => void;
|
||||
}): Promise<() => void> {
|
||||
let accepted = initial;
|
||||
let stopped = false;
|
||||
let pending = false;
|
||||
let active: Promise<void> | undefined;
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
while (!stopped && pending) {
|
||||
pending = false;
|
||||
try {
|
||||
const next = await load(path);
|
||||
if (stopped) return;
|
||||
if (onChange(next, accepted) && !stopped) accepted = next;
|
||||
} catch (error) {
|
||||
if (stopped) return;
|
||||
try {
|
||||
onError(error);
|
||||
} catch {
|
||||
// Diagnostic failures must not poison subsequent reloads.
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
active = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function reload(): Promise<void> {
|
||||
if (stopped) return Promise.resolve();
|
||||
pending = true;
|
||||
// Defer the read so same-turn events coalesce and stop can suppress scheduled work.
|
||||
active ??= Promise.resolve().then(run);
|
||||
return active;
|
||||
}
|
||||
|
||||
function listener(current: Stats, previous: Stats): Promise<void> {
|
||||
if (
|
||||
stopped ||
|
||||
(current.ino === previous.ino &&
|
||||
current.mtimeMs === previous.mtimeMs &&
|
||||
current.ctimeMs === previous.ctimeMs &&
|
||||
current.size === previous.size)
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return reload();
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
pending = false;
|
||||
unwatchFile(path, listener);
|
||||
}
|
||||
|
||||
// Poll the path so atomic saves and deletion/recreation remain observable.
|
||||
watchFile(path, { interval: 1000, persistent: false }, listener);
|
||||
// Close the gap between the caller's initial read and watcher registration.
|
||||
await reload();
|
||||
return stop;
|
||||
}
|
||||
Reference in new issue
Block a user