feat(config): separate root responses and link defaults #26

Merged
eleith merged 1 commits from feat/config-root-responses into main 2026-11-13 16:55:39 +00:00
36 changed files with 650 additions and 645 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@goto/web': minor
'@goto/worker': minor
'@goto/cli': minor
---
Separate domain `root` responses from named links and use `link_defaults` consistently at global and domain levels. Root and fallback responses require a destination URL and support explicit redirect status and query forwarding. Domain fallbacks replace global responses as whole objects; 404 is only the default when no fallback exists. Reject `/` link entries, old defaults and scalar fallback syntax directly.
+9
View File
@@ -16,8 +16,17 @@ auth:
credentials:
username: admin
password: \${DUMMY_PASSWORD}
link_defaults:
status_code: 302
forward_query: true
fallback:
url: \${DUMMY_DESTINATION}
domains:
go.example.test:
link_defaults:
status_code: 307
root:
url: \${DUMMY_DESTINATION}
links:
/docs:
url: \${DUMMY_DESTINATION}
-185
View File
@@ -1,185 +0,0 @@
import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { createServer as createNetServer } from 'node:net';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const webDirectory = fileURLToPath(new URL('..', import.meta.url));
const require = createRequire(join(webDirectory, 'package.json'));
let directory;
let vite;
let ready = false;
let closing;
async function choosePort() {
// Vite 8 treats port 0 as its default port.
const reservation = createNetServer();
await new Promise((resolve, reject) => {
reservation.once('error', reject);
reservation.listen(0, '127.0.0.1', resolve);
});
const address = reservation.address();
if (!address || typeof address === 'string') throw new Error('Missing reserved port');
await new Promise((resolve, reject) => {
reservation.close((error) => (error ? reject(error) : resolve()));
});
return address.port;
}
async function prepareDependencies() {
const sourceModules = join(webDirectory, 'node_modules');
const modules = join(directory, 'node_modules');
await mkdir(join(modules, '@goto'), { recursive: true });
for (const name of await readdir(sourceModules)) {
if (name !== '@goto') await symlink(join(sourceModules, name), join(modules, name));
}
for (const name of await readdir(join(sourceModules, '@goto'))) {
if (name !== 'db') {
await symlink(join(sourceModules, '@goto', name), join(modules, '@goto', name));
}
}
const sourceDatabase = dirname(dirname(require.resolve('@goto/db/visit-worker')));
const database = join(directory, '.packages/db');
await mkdir(database, { recursive: true });
for (const path of ['src', 'dist', 'package.json', 'tsconfig.json', 'tsconfig.build.json']) {
await cp(join(sourceDatabase, path), join(database, path), { recursive: true });
}
await cp(join(sourceDatabase, '../../tsconfig.base.json'), join(directory, 'tsconfig.base.json'));
await symlink(join(sourceDatabase, 'node_modules'), join(database, 'node_modules'), 'dir');
await symlink(database, join(modules, '@goto/db'), 'dir');
}
async function prepareProject() {
const runsDirectory = join(webDirectory, '.e2e');
await mkdir(runsDirectory, { recursive: true });
directory = await mkdtemp(join(runsDirectory, 'hmr-'));
for (const path of ['src', 'server', 'package.json', 'vite.config.ts', 'tsconfig.json']) {
await cp(join(webDirectory, path), join(directory, path), { recursive: true });
}
await prepareDependencies();
const applicationPath = join(directory, 'src/lib/server/application.ts');
const source = await readFile(applicationPath, 'utf8');
if (source.split('return application;').length !== 2) {
throw new Error('Expected one prepared application return for lifetime instrumentation');
}
await writeFile(
applicationPath,
`import { randomUUID as fixtureRandomUUID } from 'node:crypto';\n` +
source.replace(
'return application;',
`const testId = fixtureRandomUUID();
const timer = setInterval(() => undefined, 1000);
const originalClose = Reflect.get(application, 'close') as (() => void | Promise<void>) | undefined;
let closing: Promise<void> | undefined;
process.send?.({ type: 'application-start', testId });
return Object.assign(application, {
testId,
close() {
return (closing ??= (async () => {
await originalClose?.call(application);
clearInterval(timer);
process.send?.({ type: 'application-close', testId });
})());
}
});`
)
);
const routeDirectory = join(directory, 'src/routes/(admin)/(public)/__hmr');
await mkdir(join(routeDirectory, 'runtime'), { recursive: true });
await writeFile(
join(routeDirectory, '+page.svelte'),
`<script lang="ts">
import { onMount } from 'svelte';
import Revision from './Revision.svelte';
let count = $state(0);
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
</script>
<button disabled={!hydrated} onclick={() => count += 1}>count {count}</button>
<Revision />
`
);
await writeFile(join(routeDirectory, 'Revision.svelte'), '<p>revision one</p>\n');
await writeFile(
join(routeDirectory, 'runtime/+server.ts'),
`import type { RequestEvent } from '@sveltejs/kit';
export function GET({ locals }: RequestEvent) {
return Response.json(
{ testId: Reflect.get(locals.application, 'testId'), revision: 'leaf one' },
{ headers: { 'cache-control': 'no-store' } }
);
}
`
);
}
async function start() {
process.env.NODE_ENV = 'development';
await prepareProject();
process.chdir(directory);
const { createServer } = await import(pathToFileURL(require.resolve('vite')).href);
vite = await createServer({
root: directory,
clearScreen: false,
server: { host: '127.0.0.1', port: await choosePort(), strictPort: true }
});
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === 'string') throw new Error('Missing development address');
const origin = `http://localhost:${address.port}`;
await mkdir(join(directory, 'config'), { recursive: true });
await writeFile(
join(directory, 'config/goto.yaml'),
JSON.stringify({
url: { app: origin },
auth: { credentials: { username: 'hmr-test', password: 'hmr-test-only-password' } },
domains: {
[`go.localhost:${address.port}`]: {
links: { '/': { url: 'https://example.org/hmr-test' } }
}
}
})
);
ready = true;
process.send?.({ type: 'ready', origin, directory });
}
const startup = start();
function shutdown() {
return (closing ??= (async () => {
await startup.catch(() => undefined);
try {
await vite?.close();
} finally {
if (!ready && directory) await rm(directory, { recursive: true, force: true });
if (process.connected) process.disconnect();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
}));
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('disconnect', shutdown);
process.on('message', (message) => {
if (message?.type === 'shutdown') void shutdown();
});
try {
await startup;
} catch (error) {
console.error(error);
process.exitCode = 1;
await shutdown();
}
-144
View File
@@ -1,144 +0,0 @@
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { expect, request, test } from '@playwright/test';
import { startDevelopment } from './support/development.js';
type RuntimeProbe = { testId: string; revision: string };
async function replaceSource(path: string, before: string, after: string) {
const source = await readFile(path, 'utf8');
expect(source.split(before)).toHaveLength(2);
await writeFile(path, source.replace(before, after));
}
test('client HMR preserves the application while worker and core edits restart it cleanly', async ({
browser
}, testInfo) => {
test.setTimeout(120_000);
const development = await startDevelopment(testInfo);
const { origin, directory, events, live } = development;
let context: Awaited<ReturnType<typeof browser.newContext>> | undefined;
let client: Awaited<ReturnType<typeof request.newContext>> | undefined;
try {
context = await browser.newContext({ extraHTTPHeaders: {} });
client = await request.newContext({ baseURL: origin, extraHTTPHeaders: {}, timeout: 2_000 });
const page = await context.newPage();
await page.goto(`${origin}/__hmr`);
await page.getByRole('button', { name: 'count 0', exact: true }).click();
await expect(page.getByRole('button', { name: 'count 1', exact: true })).toBeVisible();
await expect(page.getByText('revision one', { exact: true })).toBeVisible();
async function readRuntime(): Promise<RuntimeProbe | undefined> {
try {
const response = await client!.get('/__hmr/runtime');
if (!response.ok()) return undefined;
return (await response.json()) as RuntimeProbe;
} catch {
return undefined;
}
}
await expect.poll(readRuntime, { timeout: 20_000 }).toEqual({
testId: expect.any(String),
revision: 'leaf one'
});
const initial = (await readRuntime())!;
await expect
.poll(() => events)
.toEqual([{ type: 'application-start', testId: initial.testId }]);
const routeDirectory = join(directory, 'src/routes/(admin)/(public)/__hmr');
await replaceSource(join(routeDirectory, 'Revision.svelte'), 'revision one', 'revision two');
await expect(page.getByText('revision two', { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole('button', { name: 'count 1', exact: true })).toBeVisible();
expect(await readRuntime()).toEqual(initial);
await expect
.poll(() => events)
.toEqual([{ type: 'application-start', testId: initial.testId }]);
await replaceSource(join(routeDirectory, 'runtime/+server.ts'), 'leaf one', 'leaf two');
await expect.poll(readRuntime, { timeout: 20_000 }).toEqual({
testId: initial.testId,
revision: 'leaf two'
});
await expect
.poll(() => events)
.toEqual([{ type: 'application-start', testId: initial.testId }]);
await replaceSource(
join(directory, '.packages/db/src/visit-worker.ts'),
'export function ready(): void {}',
"export function ready(): void { console.info('writer revision two'); }"
);
await expect
.poll(
async () => {
const current = await readRuntime();
return current?.testId === initial.testId ? undefined : current;
},
{ timeout: 30_000 }
)
.toEqual({ testId: expect.any(String), revision: 'leaf two' });
const workerReplacement = (await readRuntime())!;
await expect.poll(development.logs, { timeout: 15_000 }).toContain('writer revision two');
await expect
.poll(() => events)
.toEqual([
{ type: 'application-start', testId: initial.testId },
{ type: 'application-close', testId: initial.testId },
{ type: 'application-start', testId: workerReplacement.testId }
]);
const applicationPath = join(directory, 'src/lib/server/application.ts');
await writeFile(
applicationPath,
(await readFile(applicationPath, 'utf8')) + '\nexport const hmrCoreRevision = 2;\n'
);
await expect
.poll(
async () => {
const current = await readRuntime();
return current?.testId === workerReplacement.testId ? undefined : current;
},
{ timeout: 30_000 }
)
.toEqual({ testId: expect.any(String), revision: 'leaf two' });
const replacement = (await readRuntime())!;
await expect
.poll(() => events)
.toEqual([
{ type: 'application-start', testId: initial.testId },
{ type: 'application-close', testId: initial.testId },
{ type: 'application-start', testId: workerReplacement.testId },
{ type: 'application-close', testId: workerReplacement.testId },
{ type: 'application-start', testId: replacement.testId }
]);
expect(development.getpeak()).toBe(1);
expect([...live]).toEqual([replacement.testId]);
expect(development.logs()).not.toContain('Visit tracking unavailable');
await context.close();
context = undefined;
await client.dispose();
client = undefined;
await development.stop();
expect(development.getexit()).toEqual({ code: 0, signal: null });
expect(events).toEqual([
{ type: 'application-start', testId: initial.testId },
{ type: 'application-close', testId: initial.testId },
{ type: 'application-start', testId: workerReplacement.testId },
{ type: 'application-close', testId: workerReplacement.testId },
{ type: 'application-start', testId: replacement.testId },
{ type: 'application-close', testId: replacement.testId }
]);
expect(live.size).toBe(0);
expect(development.getpeak()).toBe(1);
} finally {
try {
await context?.close();
await client?.dispose();
} finally {
await development.dispose();
}
}
});
File renamed without changes.
-134
View File
@@ -1,134 +0,0 @@
import { fork } from 'node:child_process';
import { rm } from 'node:fs/promises';
import { expect, type TestInfo } from '@playwright/test';
type Ready = { type: 'ready'; origin: string; directory: string };
type LifetimeEvent = { type: 'application-start' | 'application-close'; testId: string };
type Exit = { code: number | null; signal: NodeJS.Signals | null };
export async function startDevelopment(testInfo: TestInfo) {
const child = fork(new URL('../dev-server.mjs', import.meta.url), [], {
env: { PATH: process.env.PATH, NODE_ENV: 'development', TZ: 'UTC' },
execArgv: [],
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
});
let ready: Ready | undefined;
let exit: Exit | undefined;
let childError: Error | undefined;
let output = '';
const events: LifetimeEvent[] = [];
const live = new Set<string>();
let peak = 0;
const exited = new Promise<void>((resolve) => {
child.once('exit', (code, signal) => {
exit = { code, signal };
resolve();
});
});
child.once('error', (error) => {
childError = error;
});
child.stdout!.on('data', captureOutput);
child.stderr!.on('data', captureOutput);
child.on('message', recordMessage);
function captureOutput(chunk: Buffer) {
output = (output + chunk.toString()).slice(-50_000);
}
function recordMessage(message: unknown) {
if (typeof message !== 'object' || message === null || !('type' in message)) return;
if (
message.type === 'ready' &&
'origin' in message &&
typeof message.origin === 'string' &&
'directory' in message &&
typeof message.directory === 'string'
) {
ready = { type: 'ready', origin: message.origin, directory: message.directory };
} else if (
(message.type === 'application-start' || message.type === 'application-close') &&
'testId' in message &&
typeof message.testId === 'string'
) {
events.push({ type: message.type, testId: message.testId });
if (message.type === 'application-start') live.add(message.testId);
else live.delete(message.testId);
peak = Math.max(peak, live.size);
}
}
async function waitForExit(timeout: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
exited.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), timeout);
})
]);
} finally {
clearTimeout(timer);
}
}
async function stop() {
if (!exit && child.connected) child.send({ type: 'shutdown' });
else if (!exit) child.kill('SIGTERM');
if (!(await waitForExit(10_000))) {
throw new Error('Development server did not exit within 10 seconds');
}
expect(child.connected).toBe(false);
}
async function dispose() {
try {
if (child.pid && !exit) {
child.kill('SIGTERM');
if (!(await waitForExit(6_000))) {
child.kill('SIGKILL');
if (!(await waitForExit(2_000))) {
throw new Error('Development server did not exit after SIGKILL');
}
}
}
} finally {
try {
await testInfo.attach('hmr-server.log', { body: output, contentType: 'text/plain' });
await testInfo.attach('hmr-lifetime.json', {
body: JSON.stringify({ events, peak, live: [...live], exit }),
contentType: 'application/json'
});
} finally {
if (ready) await rm(ready.directory, { recursive: true, force: true });
}
}
}
try {
await expect
.poll(
() => {
if (childError) throw childError;
if (exit) throw new Error(`Development server exited: ${JSON.stringify(exit)}`);
return ready;
},
{ timeout: 45_000 }
)
.toBeDefined();
return {
origin: ready!.origin,
directory: ready!.directory,
events,
live,
getpeak: () => peak,
logs: () => output,
getexit: () => exit,
stop,
dispose
};
} catch (error) {
await dispose();
throw error;
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ export const test = base.extend<{
appServer: async ({ withWorker, retentionMonths }, use, testInfo) => {
const directory = process.env.GOTO_E2E_BUILD_DIR;
if (!directory) throw new Error('Missing E2E build directory');
const child = fork(new URL('../server.mjs', import.meta.url), [], {
const child = fork(new URL('../server.js', import.meta.url), [], {
env: {
...serverEnvironment(),
GOTO_E2E_BUILD_DIR: directory,
@@ -1,5 +1,6 @@
<script lang="ts">
import type { VisitCountsByTimeframe, VisitTimeframe } from '@goto/analytics/daily';
import type { LinkState } from '#lib/server/link-state.js';
import { formatCount } from './format.js';
let {
@@ -13,7 +14,7 @@
path: string;
href: string;
url: string | null;
state: string;
state: LinkState;
countsByTimeframe: VisitCountsByTimeframe;
}>;
timeframe: VisitTimeframe;
@@ -41,7 +42,12 @@
>
<span class="admin-inventory-primary"><code>{link.path}</code></span>
<span class="admin-inventory-secondary">{link.url ?? '404 Not found'}</span>
<span class="admin-inventory-state">{link.state}</span>
<span class="admin-inventory-state">
{#if link.state === 'active'}Active
{:else if link.state === 'disabled'}Disabled
{:else if link.state === 'scheduled'}Scheduled
{:else if link.state === 'expired'}Expired{/if}
</span>
{#if collectionEnabled}
<span class="admin-inventory-count">
<span class="admin-inventory-label">Visits</span>
+5 -1
View File
@@ -133,7 +133,11 @@ test('captures invalid decoded paths even when the fallback redirects', () => {
...snapshot.domains,
'go.example.test': {
...snapshot.domains['go.example.test']!,
fallback: 'https://fallback.example.test/'
fallback: {
url: 'https://fallback.example.test/',
status_code: 302 as const,
forward_query: false
}
}
}
};
+7 -5
View File
@@ -1,9 +1,11 @@
import type { PreparedLink } from '@goto/config';
import type { Immutable } from './snapshot.js';
export function currentLinkState(link: Immutable<PreparedLink>, now: number): string {
if (!link.enabled) return 'Disabled';
if (link.valid_from !== undefined && now < Date.parse(link.valid_from)) return 'Scheduled';
if (link.valid_until !== undefined && now >= Date.parse(link.valid_until)) return 'Expired';
return 'Active';
export type LinkState = 'active' | 'disabled' | 'scheduled' | 'expired';
export function currentLinkState(link: Immutable<PreparedLink>, now: number): LinkState {
if (!link.enabled) return 'disabled';
if (link.valid_from !== undefined && now < Date.parse(link.valid_from)) return 'scheduled';
if (link.valid_until !== undefined && now >= Date.parse(link.valid_until)) return 'expired';
return 'active';
}
+123 -31
View File
@@ -1,10 +1,15 @@
import type { PreparedDomain, PreparedLink, RedirectStatus } from '@goto/config';
import type { PreparedDomain, PreparedLink, RedirectResponse, RedirectStatus } from '@goto/config';
import { describe, expect, test, vi } from 'vitest';
import { resolveRedirect } from './redirect.js';
const host = 'go.example.test';
const destination = 'https://destination.example.test/page';
const fallback = 'https://fallback.example.test/landing?owned=one#section';
const fallbackUrl = 'https://fallback.example.test/landing?owned=one#section';
const fallback = preparedResponse({ url: fallbackUrl });
function preparedResponse(overrides: Partial<RedirectResponse> = {}): RedirectResponse {
return { url: destination, status_code: 302, forward_query: false, ...overrides };
}
function preparedLink(overrides: Partial<PreparedLink> = {}): PreparedLink {
return {
@@ -21,20 +26,23 @@ function preparedLink(overrides: Partial<PreparedLink> = {}): PreparedLink {
function preparedDomain(
links: Record<string, PreparedLink> = {},
fallback: PreparedDomain['fallback'] = 404
fallback: PreparedDomain['fallback'] = null,
root: PreparedDomain['root'] = null
): PreparedDomain {
return { links, fallback, provenance: { fallback: 'builtin' } };
return { root, links, fallback, provenance: { fallback: 'builtin' } };
}
describe('exact single-decode lookup', () => {
const paths = ['/', '/docs', '/Docs', '/a/b', '/a//b', '/café', '/a+b', '/a%2Fb', '/100%'];
const paths = ['/docs', '/Docs', '/a/b', '/a//b', '/café', '/a+b', '/a%2Fb', '/100%'];
const domain = preparedDomain(
Object.fromEntries(
paths.map((path, index) => [
path,
preparedLink({ url: `https://destination.example.test/${index}` })
])
)
),
null,
preparedResponse()
);
test.each([
@@ -58,7 +66,8 @@ describe('exact single-decode lookup', () => {
const { response, visit } = resolveRedirect(domain, host, target, 'GET');
expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe(domain.links[path]?.url);
const expectedUrl = path === '/' ? domain.root?.url : domain.links[path]?.url;
expect(response.headers.get('location')).toBe(expectedUrl);
expect(visit).toMatchObject({ path, outcome: 'valid' });
});
@@ -174,14 +183,35 @@ describe('HTTP responses', () => {
});
test.each([
{ target: '/docs', fallback: 404, status: 308 },
{ target: '/missing', fallback, status: 302 },
{ target: '/missing', fallback: 404, status: 404 },
{ target: '/%', fallback, status: 400 }
] satisfies { target: string; fallback: PreparedDomain['fallback']; status: number }[])(
{ target: '/docs', fallback: null, root: null, status: 308 },
{
target: '/?incoming=one',
fallback,
root: preparedResponse({ status_code: 301, forward_query: true }),
status: 301
},
{ target: '/?owned=changed', fallback, root: null, status: 302 },
{
target: '/missing?owned=changed',
fallback: preparedResponse({ url: fallbackUrl, status_code: 307, forward_query: true }),
root: null,
status: 307
},
{ target: '/missing', fallback: null, root: null, status: 404 },
{ target: '/%', fallback, root: null, status: 400 }
] satisfies {
target: string;
fallback: PreparedDomain['fallback'];
root: PreparedDomain['root'];
status: number;
}[])(
'HEAD matches GET status/headers with an empty body: $target $status',
async ({ target, fallback, status }) => {
const domain = preparedDomain({ '/docs': preparedLink({ status_code: 308 }) }, fallback);
async ({ target, fallback, root, status }) => {
const domain = preparedDomain(
{ '/docs': preparedLink({ status_code: 308 }) },
fallback,
root
);
const { response: get } = resolveRedirect(domain, host, target, 'GET');
const { response: head, visit } = resolveRedirect(domain, host, target, 'HEAD');
@@ -202,12 +232,12 @@ describe('validity and fallback', () => {
const end = Date.parse(until);
test.each([
{ enabled: true, now: start - 1, status: 302, location: fallback, outcome: 'invalid' },
{ enabled: true, now: start - 1, status: 302, location: fallbackUrl, outcome: 'invalid' },
{ enabled: true, now: start, status: 307, location: destination, outcome: 'valid' },
{ enabled: true, now: end - 1, status: 307, location: destination, outcome: 'valid' },
{ enabled: true, now: end, status: 302, location: fallback, outcome: 'invalid' },
{ enabled: true, now: end + 1, status: 302, location: fallback, outcome: 'invalid' },
{ enabled: false, now: start, status: 302, location: fallback, outcome: 'invalid' }
{ enabled: true, now: end, status: 302, location: fallbackUrl, outcome: 'invalid' },
{ enabled: true, now: end + 1, status: 302, location: fallbackUrl, outcome: 'invalid' },
{ enabled: false, now: start, status: 302, location: fallbackUrl, outcome: 'invalid' }
])('enabled=$enabled at $now returns $status', ({ enabled, now, status, location, outcome }) => {
const domain = preparedDomain(
{
@@ -264,12 +294,34 @@ describe('validity and fallback', () => {
}
);
test('a root link takes precedence over URL fallback', () => {
const domain = preparedDomain({ '/': preparedLink() }, fallback);
test('a configured root uses its own response options and only matches the root path', () => {
const root = preparedResponse({
url: 'https://destination.example.test/home',
status_code: 308,
forward_query: true
});
const domain = preparedDomain({}, fallback, root);
const { response, visit } = resolveRedirect(domain, host, '/?incoming=ignored', 'GET');
expect(response.headers.get('location')).toBe(destination);
const { response, visit } = resolveRedirect(domain, host, '/?from=root', 'GET');
expect(response.status).toBe(308);
expect(response.headers.get('location')).toBe(
'https://destination.example.test/home?from=root'
);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(visit).toMatchObject({ path: '/', outcome: 'valid' });
const missing = resolveRedirect(domain, host, '/home', 'GET');
expect(missing.response.headers.get('location')).toBe(fallbackUrl);
expect(missing.visit).toMatchObject({ path: '/home', outcome: 'invalid' });
});
test('an unconfigured root uses the fallback response but remains an invalid base visit', () => {
const domain = preparedDomain({}, fallback);
const { response, visit } = resolveRedirect(domain, host, '/?incoming=ignored', 'GET');
expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe(fallbackUrl);
expect(visit).toMatchObject({ path: '/', outcome: 'invalid' });
});
test('an unconfigured root remains a base visit with an invalid outcome', () => {
@@ -279,7 +331,7 @@ describe('validity and fallback', () => {
expect(visit).toMatchObject({ path: '/', outcome: 'invalid' });
});
test('URL fallback is always 302 and never appends the incoming path or query', () => {
test('default fallback options preserve the URL without appending the incoming path or query', () => {
const domain = preparedDomain({}, fallback);
const { response, visit } = resolveRedirect(
domain,
@@ -290,9 +342,29 @@ describe('validity and fallback', () => {
expect(response.status).toBe(302);
expect(visit).toMatchObject({ path: '/missing/nested', outcome: 'invalid' });
expect(response.headers.get('location')).toBe(fallback);
expect(response.headers.get('location')).toBe(fallbackUrl);
expect(response.headers.get('cache-control')).toBe('no-store');
});
test('fallback honors its own response options for an inactive named link', () => {
const configuredFallback = preparedResponse({
url: fallbackUrl,
status_code: 307,
forward_query: true
});
const domain = preparedDomain(
{ '/disabled': preparedLink({ enabled: false }) },
configuredFallback
);
const { response, visit } = resolveRedirect(domain, host, '/disabled?incoming=present', 'GET');
expect(response.status).toBe(307);
expect(response.headers.get('location')).toBe(
'https://fallback.example.test/landing?owned=one&incoming=present#section'
);
expect(visit).toMatchObject({ path: '/disabled', outcome: 'invalid' });
});
});
describe('query forwarding', () => {
@@ -354,24 +426,44 @@ describe('direct self-loops', () => {
['/a%2Fb', 'https://go.example.test/a/b#fragment'],
['/docs', 'http://go.example.test:80/docs'],
['/docs', 'https:///go.example.test/docs']
])('runtime blocks a direct fallback loop: %s -> %s', (target, fallback) => {
const { response } = resolveRedirect(preparedDomain({}, fallback), host, target, 'GET');
])('runtime blocks a direct fallback loop: %s -> %s', (target, url) => {
const domain = preparedDomain({}, preparedResponse({ url, forward_query: true }));
const { response, visit } = resolveRedirect(domain, host, target, 'GET');
expect(response.status).toBe(404);
expect(visit?.outcome).toBe('invalid');
expect(response.headers.has('location')).toBe(false);
expect(response.headers.get('cache-control')).toBe('no-store');
});
test('runtime also guards a direct link loop if startup validation was skipped', () => {
const domain = preparedDomain({
'/docs': preparedLink({ url: 'https://go.example.test/docs' })
test('a direct root loop returns an invalid 404 without trying fallback', () => {
const root = preparedResponse({
url: 'https://GO.EXAMPLE.TEST:443/?owned=old#section',
forward_query: true
});
const domain = preparedDomain({}, fallback, root);
const { response, visit } = resolveRedirect(domain, host, '/?owned=new', 'GET');
expect(resolveRedirect(domain, host, '/docs', 'GET').response.status).toBe(404);
expect(response.status).toBe(404);
expect(response.headers.has('location')).toBe(false);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(visit).toMatchObject({ path: '/', outcome: 'invalid' });
});
test('a direct link loop returns an invalid 404 without trying fallback', () => {
const domain = preparedDomain(
{ '/docs': preparedLink({ url: 'https://go.example.test/docs' }) },
fallback
);
const { response, visit } = resolveRedirect(domain, host, '/docs', 'GET');
expect(response.status).toBe(404);
expect(response.headers.has('location')).toBe(false);
expect(visit?.outcome).toBe('invalid');
});
test('allows fallback to a different local path without graph analysis', () => {
const domain = preparedDomain({}, 'https://go.example.test/landing');
const domain = preparedDomain({}, preparedResponse({ url: 'https://go.example.test/landing' }));
expect(resolveRedirect(domain, host, '/missing', 'GET').response.headers.get('location')).toBe(
'https://go.example.test/landing'
+24 -19
View File
@@ -85,37 +85,42 @@ function findActiveLink(
if (started && notExpired) return link;
}
function fallbackResponse(
fallback: PreparedDomain['fallback'],
host: string,
path: string
): Response {
if (fallback === 404) return emptyResponse(404);
if (isSelfRedirect(fallback, host, path)) return emptyResponse(404);
return redirectResponse(fallback, 302);
}
function resolveVisitResponse(
domain: Immutable<PreparedDomain>,
host: string,
target: ParsedTarget,
now: number
): ResolvedResponse {
const link = findActiveLink(domain, target.path, now);
if (!link) {
const response = fallbackResponse(domain.fallback, host, target.path);
return { response, outcome: 'invalid' };
let redirect = domain.fallback;
let outcome: VisitOutcome = 'invalid';
if (target.path === '/') {
if (domain.root !== null) {
redirect = domain.root;
outcome = 'valid';
}
} else {
const link = findActiveLink(domain, target.path, now);
if (link) {
redirect = link;
outcome = 'valid';
}
}
let destination = link.url;
if (link.forward_query) destination = forwardQuery(link.url, target.query);
if (isSelfRedirect(destination, host, target.path)) {
if (redirect === null) {
const response = emptyResponse(404);
return { response, outcome: 'invalid' };
}
const response = redirectResponse(destination, link.status_code);
return { response, outcome: 'valid' };
let destination = redirect.url;
if (redirect.forward_query) destination = forwardQuery(destination, target.query);
const selfRedirect = isSelfRedirect(destination, host, target.path);
if (selfRedirect) {
const response = emptyResponse(404);
return { response, outcome: 'invalid' };
}
const response = redirectResponse(destination, redirect.status_code);
return { response, outcome };
}
export function resolveRedirect(
+5 -1
View File
@@ -6,9 +6,11 @@ test('publishes a deep-frozen copy without changing the loaded configuration', (
const prepared = validateConfig(
{
auth: { credentials: { username: 'example', password: 'test-only-password' } },
defaults: { forward_query: true },
link_defaults: { forward_query: true },
fallback: { url: 'https://example.org/missing' },
domains: {
'go.example.test': {
root: { url: 'https://example.org/start' },
links: {
'/docs': { url: 'https://example.org/docs', tags: ['work'], forward_query: false }
}
@@ -24,6 +26,8 @@ test('publishes a deep-frozen copy without changing the loaded configuration', (
expect(snapshot).not.toBe(prepared);
expect(Object.isFrozen(snapshot)).toBe(true);
expect(Object.isFrozen(snapshot.configuration.auth)).toBe(true);
expect(Object.isFrozen(snapshot.domains['go.example.test']!.root)).toBe(true);
expect(Object.isFrozen(snapshot.domains['go.example.test']!.fallback)).toBe(true);
expect(Object.isFrozen(link.tags)).toBe(true);
expect(Reflect.set(link, 'enabled', false)).toBe(false);
expect(link.forward_query).toBe(false);
@@ -58,12 +58,12 @@ export const load = (async ({ locals, params }) => {
href: linkHref(domain, path)
};
});
const root = prepared.links['/'];
let baseUrl = prepared.fallback === 404 ? null : prepared.fallback;
let baseState = 'Fallback';
if (root && currentLinkState(root, now) === 'Active') {
baseUrl = root.url;
baseState = 'Active';
const hasRoot = prepared.root !== null;
let baseUrl: string | null = null;
if (prepared.root !== null) {
baseUrl = prepared.root.url;
} else if (prepared.fallback !== null) {
baseUrl = prepared.fallback.url;
}
const asOf = domainDetails ? Date.parse(domainDetails.period.asOf) : null;
const chart = domainDetails ? { daily: domainDetails.daily, period: domainDetails.period } : null;
@@ -81,7 +81,7 @@ export const load = (async ({ locals, params }) => {
chart,
base: {
url: baseUrl,
state: baseState,
hasRoot,
countsByTimeframe: baseCountsByTimeframe,
href: linkHref(domain, '/')
},
@@ -67,7 +67,9 @@
{:else}404 Not found{/if}
</dd>
<dt>Base status</dt>
<dd>{data.base.state}</dd>
<dd>
{#if data.base.hasRoot}Active{:else}Fallback{/if}
</dd>
</dl>
</section>
{#if data.chart && window}
@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit';
import { countBrowserOsVisitsByTimeframe } from '@goto/analytics';
import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily';
import { currentLinkState } from '#lib/server/link-state.js';
import { currentLinkState, type LinkState } from '#lib/server/link-state.js';
import { readVisitDetails } from '#lib/server/visit-reports.js';
import type { PageServerLoad } from './$types';
@@ -13,7 +13,25 @@ export const load = (async ({ locals, params }) => {
// Kit has decoded route parameters; preserve literal percent paths as-is.
const path = `/${params.path ?? ''}`;
if (path !== '/' && !Object.hasOwn(prepared.links, path)) throw error(404, 'Link not configured');
const link = prepared.links[path];
let url: string | null = null;
let state: LinkState | 'fallback';
if (path === '/') {
const root = prepared.root;
if (root !== null) {
url = root.url;
state = 'active';
} else {
state = 'fallback';
}
} else {
const link = prepared.links[path]!;
url = link.url;
state = currentLinkState(link, Date.now());
}
let fallback: string | 404 = 404;
if (prepared.fallback !== null) {
fallback = prepared.fallback.url;
}
const configuration = snapshot.configuration;
const retentionMonths = configuration.analytics.retention_months;
const collectionEnabled = retentionMonths !== 0;
@@ -25,7 +43,6 @@ export const load = (async ({ locals, params }) => {
: [];
const countsByTimeframe = countVisitWindows(details?.daily, windows);
const browserOsVisitsByTimeframe = countBrowserOsVisitsByTimeframe(details ?? undefined, windows);
const state = link ? currentLinkState(link, Date.now()) : 'Fallback';
const asOf = details ? Date.parse(details.period.asOf) : null;
const chart = details ? { daily: details.daily, period: details.period } : null;
@@ -33,8 +50,8 @@ export const load = (async ({ locals, params }) => {
domain,
path,
domainHref: `/admin/domains/${encodeURIComponent(domain)}`,
url: link?.url ?? null,
fallback: prepared.fallback,
url,
fallback,
state,
retentionMonths,
collectionEnabled,
@@ -43,7 +43,17 @@
{#if data.collectionEnabled}
<StatCard label="Visits" value={visits} />
{/if}
<StatCard label="Status" value={data.state} />
{#if data.state === 'active'}
<StatCard label="Status" value="Active" />
{:else if data.state === 'disabled'}
<StatCard label="Status" value="Disabled" />
{:else if data.state === 'scheduled'}
<StatCard label="Status" value="Scheduled" />
{:else if data.state === 'expired'}
<StatCard label="Status" value="Expired" />
{:else}
<StatCard label="Status" value="Fallback" />
{/if}
</div>
<section class="admin-info" aria-labelledby="link-details-heading">
<h2 id="link-details-heading">Link details</h2>
@@ -9,13 +9,14 @@ vi.mock('#lib/server/visit-reports.js', () => ({ readVisitDetails: vi.fn() }));
beforeEach(() => vi.resetAllMocks());
const domain = 'go.example.test';
function request(path = '/docs', host = domain, retention_months = 3) {
function request(path = '/docs', host = domain, retention_months = 3, rootUrl?: string) {
const prepared = validateConfig(
{
auth: { credentials: { username: 'test', password: 'link-test-password' } },
analytics: { retention_months },
domains: {
[domain]: {
...(rootUrl ? { root: { url: rootUrl } } : {}),
links: {
'/docs': { url: 'https://example.test/docs' },
'/a/b': { url: 'https://example.test/nested' },
@@ -72,7 +73,7 @@ test('projects only the requested link series and compact window counts and brea
domainHref: '/admin/domains/go.example.test',
url: 'https://example.test/docs',
fallback: 404,
state: 'Active',
state: 'active',
retentionMonths: 3,
collectionEnabled: true,
available: true,
@@ -111,7 +112,7 @@ test('keeps decoded nested and literal-percent identities distinct and supports
const root = await load(request('/'));
expect(nested.path).toBe('/a/b');
expect(percent.path).toBe('/a%2Fb');
expect(percent.state).toBe('Disabled');
expect(percent.state).toBe('disabled');
expect(percent.countsByTimeframe.all?.validVisits).toBe(15);
expect(readVisitDetails).toHaveBeenNthCalledWith(1, expect.any(Object), {
kind: 'link',
@@ -129,12 +130,29 @@ test('keeps decoded nested and literal-percent identities distinct and supports
path: '/'
});
expect(root.path).toBe('/');
expect(root.state).toBe('Fallback');
expect(root.state).toBe('fallback');
expect(root.url).toBeNull();
expect(root.fallback).toBe(404);
expect(root.countsByTimeframe.all?.validVisits).toBe(15);
});
test('shows the configured root response while preserving its historical analytics scope', async () => {
vi.mocked(readVisitDetails).mockResolvedValue({
...details,
scope: { kind: 'link', domain, path: '/' }
});
const data = await load(request('/', domain, 3, 'https://example.test/start'));
expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), {
kind: 'link',
domain,
path: '/'
});
expect(data.path).toBe('/');
expect(data.state).toBe('active');
expect(data.url).toBe('https://example.test/start');
expect(data.countsByTimeframe.all).toEqual({ validVisits: 15, invalidVisits: 5 });
});
test('rejects unknown scopes before reading and keeps unavailable counts absent', async () => {
await expect(load(request('/docs', 'unknown.test'))).rejects.toMatchObject({ status: 404 });
await expect(load(request('/not-configured'))).rejects.toMatchObject({ status: 404 });
@@ -161,5 +179,5 @@ test('disabled analytics skips the report read and leaves link details usable',
expect(data.chart).toBeNull();
expect(data.countsByTimeframe).toEqual({});
expect(data.url).toBe('https://example.test/docs');
expect(data.state).toBe('Active');
expect(data.state).toBe('active');
});
@@ -17,12 +17,11 @@ function request(host = domain, rootUrl?: string, retentionMonths = 3) {
'/a/b': { url: 'https://example.test/nested' },
'/a%2Fb': { url: 'https://example.test/percent', enabled: false }
};
if (rootUrl) links['/'] = { url: rootUrl };
const prepared = validateConfig(
{
auth: { credentials: { username: 'test', password: 'domain-test-password' } },
analytics: { retention_months: retentionMonths },
domains: { [domain]: { links } }
domains: { [domain]: { ...(rootUrl ? { root: { url: rootUrl } } : {}), links } }
},
{ env: {} }
);
@@ -136,7 +135,7 @@ test('projects domain counts and browser/OS visits without exposing link series
chart: { daily: domainDetails.daily, period },
base: {
url: null,
state: 'Fallback',
hasRoot: false,
countsByTimeframe: baseCounts,
href: '/admin/domains/go.example.test/links'
},
@@ -144,7 +143,7 @@ test('projects domain counts and browser/OS visits without exposing link series
{
path: '/docs',
url: 'https://example.test/docs',
state: 'Active',
state: 'active',
countsByTimeframe: {
'7d': { validVisits: 3, invalidVisits: 0 },
'30d': { validVisits: 3, invalidVisits: 0 },
@@ -155,14 +154,14 @@ test('projects domain counts and browser/OS visits without exposing link series
{
path: '/a/b',
url: 'https://example.test/nested',
state: 'Active',
state: 'active',
countsByTimeframe: {},
href: '/admin/domains/go.example.test/links/a/b'
},
{
path: '/a%2Fb',
url: 'https://example.test/percent',
state: 'Disabled',
state: 'disabled',
countsByTimeframe: {
'7d': { validVisits: 0, invalidVisits: 0 },
'30d': { validVisits: 0, invalidVisits: 0 },
@@ -178,11 +177,12 @@ test('shows an active root destination without reclassifying retained outcomes o
vi.mocked(readDomainDetails).mockResolvedValue(report);
const data = await load(request(domain, 'https://example.test/start'));
expect(data.base.url).toBe('https://example.test/start');
expect(data.base.state).toBe('Active');
expect(data.base.hasRoot).toBe(true);
expect(data.base.countsByTimeframe).toEqual(baseCounts);
expect(data.countsByTimeframe.all?.validVisits).toBe(15);
expect(data.invalidClicksByTimeframe.all).toBe(10);
expect(data.linkCount).toBe(4);
expect(data.linkCount).toBe(3);
expect(data.links.map(({ path }) => path)).toEqual(['/docs', '/a/b', '/a%2Fb']);
});
test('missing base details leave base and non-base invalid counts unavailable, not zero', async () => {
@@ -220,6 +220,6 @@ test('disabled collection reads no reports and preserves current inventory', asy
expect(data.chart).toBeNull();
expect(data.countsByTimeframe).toEqual({});
expect(data.invalidClicksByTimeframe).toEqual({});
expect(data.base.state).toBe('Fallback');
expect(data.base.hasRoot).toBe(false);
expect(data.linkCount).toBe(3);
});
@@ -21,9 +21,9 @@ export const load = (async ({ locals }) => {
retentionMonths,
reportDataAsOf,
reportDelayed,
redirectStatusCode: configuration.defaults.status_code,
forwardQuery: configuration.defaults.forward_query,
fallbackDestination: configuration.defaults.fallback,
redirectStatusCode: configuration.link_defaults.status_code,
forwardQuery: configuration.link_defaults.forward_query,
fallbackDestination: configuration.fallback?.url ?? 404,
workerUrl: configuration.url.worker
};
}) satisfies PageServerLoad;
@@ -88,7 +88,8 @@ test('projects OIDC mode and configured redirect defaults without exposing the p
client_secret: 'private-client-secret'
}
},
defaults: { status_code: 307, forward_query: true, fallback: 'https://example.test/missing' }
link_defaults: { status_code: 307, forward_query: true },
fallback: { url: 'https://example.test/missing' }
})
);
@@ -30,7 +30,7 @@ function request(retention_months = 3) {
'/disabled': { url: 'https://example.test/disabled', enabled: false }
}
},
[emptyDomain]: { links: { '/': { url: 'https://example.test/' } } },
[emptyDomain]: { root: { url: 'https://example.test/' }, links: {} },
[newDomain]: { links: { '/new': { url: 'https://example.test/new' } } }
}
},
@@ -108,7 +108,7 @@ test('projects compact global window counts while inventory stays current and hi
retentionMonths: 3,
asOf: Date.parse(period.asOf),
domainCount: 3,
linkCount: 4,
linkCount: 3,
timeframes: ['7d', '30d', 'all'],
countsByTimeframe: {
'7d': { validVisits: 3, invalidVisits: 7 },
@@ -129,7 +129,7 @@ test('projects compact global window counts while inventory stays current and hi
},
{
domain: emptyDomain,
linkCount: 1,
linkCount: 0,
countsByTimeframe: {
'7d': { validVisits: 0, invalidVisits: 7 },
'30d': { validVisits: 0, invalidVisits: 30 },
@@ -177,7 +177,7 @@ test('missing preparation keeps inventory usable without substituting zero count
expect(data.collectionEnabled).toBe(true);
expect(data.retentionMonths).toBe(3);
expect(data.domainCount).toBe(3);
expect(data.linkCount).toBe(4);
expect(data.linkCount).toBe(3);
expect(data.asOf).toBeNull();
expect(data.chart).toBeNull();
expect(data.countsByTimeframe).toEqual({});
@@ -190,7 +190,7 @@ test('disabled collection reads no analytics and exposes only inventory with emp
expect(data.collectionEnabled).toBe(false);
expect(data.available).toBe(false);
expect(data.domainCount).toBe(3);
expect(data.linkCount).toBe(4);
expect(data.linkCount).toBe(3);
expect(data.timeframes).toEqual([]);
expect(data.countsByTimeframe).toEqual({});
expect(data.chart).toBeNull();
@@ -106,8 +106,9 @@ test('refreshes report inventory after an accepted domain/link change', async ()
const initial = configuration();
const next = configuration();
next.domains['go.example.test'] = {
root: null,
links: {},
fallback: 404,
fallback: null,
provenance: { fallback: 'builtin' }
};
const ctx = context();
+12 -6
View File
@@ -26,20 +26,26 @@ database:
analytics:
retention_months: 3
defaults:
link_defaults:
status_code: 302
forward_query: false
fallback: 404
fallback:
url: https://example.net/unavailable
status_code: 307
forward_query: true
domains:
go.example.org:
defaults:
link_defaults:
status_code: 307
forward_query: true
fallback: https://example.org
root:
url: https://example.org
status_code: 308
fallback:
url: https://example.org/unavailable
links:
/:
url: https://example.org
/docs:
url: https://example.org/documentation
title: Documentation
+23 -13
View File
@@ -11,7 +11,8 @@
"auth": { "$ref": "#/$defs/Auth" },
"database": { "default": {}, "$ref": "#/$defs/Database" },
"analytics": { "default": {}, "$ref": "#/$defs/Analytics" },
"defaults": { "default": {}, "$ref": "#/$defs/Defaults" },
"link_defaults": { "default": {}, "$ref": "#/$defs/LinkDefaults" },
"fallback": { "$ref": "#/$defs/RedirectResponse" },
"domains": {
"additionalProperties": false,
"type": "object",
@@ -150,7 +151,16 @@
{ "const": 308, "type": "number" }
]
},
"Fallback": { "anyOf": [{ "const": 404, "type": "number" }, { "$ref": "#/$defs/LinkUrl" }] },
"RedirectResponse": {
"additionalProperties": false,
"type": "object",
"required": ["url"],
"properties": {
"url": { "$ref": "#/$defs/LinkUrl" },
"status_code": { "default": 302, "$ref": "#/$defs/RedirectStatus" },
"forward_query": { "default": false, "type": "boolean" }
}
},
"Url": {
"additionalProperties": false,
"type": "object",
@@ -323,28 +333,26 @@
}
}
},
"Defaults": {
"LinkDefaults": {
"additionalProperties": false,
"type": "object",
"properties": {
"status_code": { "default": 302, "$ref": "#/$defs/RedirectStatus" },
"forward_query": { "default": false, "type": "boolean" },
"fallback": { "default": 404, "$ref": "#/$defs/Fallback" }
"forward_query": { "default": false, "type": "boolean" }
}
},
"DomainDefaults": {
"DomainLinkDefaults": {
"additionalProperties": false,
"description": "Omitted settings inherit global defaults. Explicit false overrides true.",
"description": "Omitted settings inherit global link_defaults. Explicit false overrides true.",
"type": "object",
"properties": {
"status_code": { "$ref": "#/$defs/RedirectStatus" },
"forward_query": { "type": "boolean" },
"fallback": { "$ref": "#/$defs/Fallback" }
"forward_query": { "type": "boolean" }
}
},
"LinkConfiguration": {
"additionalProperties": false,
"description": "Omitted status_code and forward_query inherit domain defaults, then global defaults.",
"description": "Omitted status_code and forward_query inherit domain link_defaults, then global link_defaults.",
"type": "object",
"required": ["url"],
"properties": {
@@ -373,12 +381,14 @@
"type": "object",
"required": ["links"],
"properties": {
"defaults": { "default": {}, "$ref": "#/$defs/DomainDefaults" },
"link_defaults": { "default": {}, "$ref": "#/$defs/DomainLinkDefaults" },
"root": { "$ref": "#/$defs/RedirectResponse" },
"fallback": { "$ref": "#/$defs/RedirectResponse" },
"links": {
"additionalProperties": false,
"description": "Logical, case-sensitive paths. Keys are never interpolated or percent-decoded. Use readable canonical paths without spaces, trailing slashes, controls, backslashes, dot segments or :?#[]@. The root path / is allowed.",
"description": "Logical, case-sensitive named paths. Keys are never interpolated or percent-decoded. Use readable canonical paths without spaces, trailing slashes, controls, backslashes, dot segments or :?#[]@. Configure / with the separate root response.",
"type": "object",
"patternProperties": { "^(.*)$": { "default": {}, "$ref": "#/$defs/LinkConfiguration" } }
"patternProperties": { "^/.+": { "default": {}, "$ref": "#/$defs/LinkConfiguration" } }
}
}
}
+37
View File
@@ -112,6 +112,43 @@ describe('configuration diagnostics', () => {
}
});
it('keeps response diagnostics secret-safe and redacts their domain keys', () => {
const error = captureConfigError(() =>
validateConfig(
{
...configurationInput(),
fallback: { url: `https://user:${privateValue}@example.com` },
domains: {
'private-domain-marker.example.com': {
root: { url: `https://user:${privateValue}@example.com` },
fallback: { url: 'https://example.com', [privateKey]: privateValue },
links: {}
}
}
},
options
)
);
expect(error.message).toBe('Invalid configuration');
expect(error.issues).toContainEqual({
path: '/fallback/url',
message: "Expected string to match 'goto-link-url' format"
});
expect(error.issues).toContainEqual({
path: '/domains/*/root/url',
message: "Expected string to match 'goto-link-url' format"
});
expect(error.issues).toContainEqual({
path: '/domains/*/fallback',
message: 'Unexpected property. Allowed fields: url, status_code, forward_query'
});
for (const output of [JSON.stringify(error), formatConfigError(error)]) {
expect(output).not.toContain('private-domain-marker');
expect(output).not.toContain('private-authored-key-marker');
expect(output).not.toContain(privateValue);
}
});
it('reports YAML codes and line positions without parser snippets or authored keys', () => {
const source = `${privateKey}: ${privateValue}\n${privateKey}: ${privateValue}`;
const error = captureConfigError(() => parseConfig(source, options));
+43 -8
View File
@@ -22,7 +22,7 @@ describe('Goto editor schema', () => {
it('relaxes defaulted fields while keeping authentication, domain links and destinations required', () => {
const editor = createEditorSchema();
expect(editor.required).toEqual(['auth', 'domains']);
for (const name of ['Url', 'Site', 'Appearance', 'Database', 'Analytics', 'Defaults']) {
for (const name of ['Url', 'Site', 'Appearance', 'Database', 'Analytics', 'LinkDefaults']) {
expect(definition(editor, name).required).toBeUndefined();
}
expect(property(editor, 'site')).toEqual({ $ref: '#/$defs/Site', default: {} });
@@ -74,22 +74,57 @@ describe('Goto editor schema', () => {
{ $ref: `#/$defs/${target}`, default: {} }
]);
}
for (const name of ['DomainDefaults', 'LinkConfiguration']) {
for (const name of ['DomainLinkDefaults', 'LinkConfiguration']) {
const schema = definition(editor, name);
for (const field of ['status_code', 'forward_query']) {
expect(property(schema, field)).not.toHaveProperty('default');
expect(schema.required ?? []).not.toContain(field);
}
}
expect(property(definition(editor, 'DomainDefaults'), 'fallback')).not.toHaveProperty(
'default'
);
expect(property(definition(editor, 'Defaults'), 'status_code').default).toBe(302);
expect(property(definition(editor, 'Defaults'), 'forward_query')).toEqual({
expect(property(definition(editor, 'LinkDefaults'), 'status_code').default).toBe(302);
expect(property(definition(editor, 'LinkDefaults'), 'forward_query')).toEqual({
type: 'boolean',
default: false
});
expect(property(definition(editor, 'Defaults'), 'fallback').default).toBe(404);
expect(object(definition(editor, 'LinkDefaults').properties)).not.toHaveProperty('fallback');
expect(object(definition(editor, 'DomainLinkDefaults').properties)).not.toHaveProperty(
'fallback'
);
});
it('keeps root and fallbacks optional with required redirect destinations and no root link key', () => {
const editor = createEditorSchema();
const domain = definition(editor, 'DomainConfiguration');
const response = definition(editor, 'RedirectResponse');
expect(response.required).toEqual(['url']);
expect(response.additionalProperties).toBe(false);
expect(Object.keys(object(response.properties))).toEqual([
'url',
'status_code',
'forward_query'
]);
expect(property(response, 'url')).toEqual({ $ref: '#/$defs/LinkUrl' });
expect(property(response, 'status_code')).toEqual({
$ref: '#/$defs/RedirectStatus',
default: 302
});
expect(property(response, 'forward_query')).toEqual({ type: 'boolean', default: false });
for (const schema of [
property(editor, 'fallback'),
property(domain, 'fallback'),
property(domain, 'root')
]) {
expect(schema).toEqual({ $ref: '#/$defs/RedirectResponse' });
}
expect(branches(definition(editor, 'RedirectStatus')).map((branch) => branch.const)).toEqual([
301, 302, 307, 308
]);
const links = property(domain, 'links');
const patterns = Object.keys(object(links.patternProperties));
expect(patterns.some((pattern) => new RegExp(pattern).test('/'))).toBe(false);
expect(patterns.some((pattern) => new RegExp(pattern).test('/docs'))).toBe(true);
expect(object(editor.properties)).not.toHaveProperty('defaults');
expect(object(domain.properties)).not.toHaveProperty('defaults');
});
it('maps runtime formats to editor formats and keeps literal constraints separate from tokens', () => {
+2 -1
View File
@@ -14,7 +14,8 @@ export {
type GotoConfiguration,
type LinkConfiguration,
type DomainConfiguration,
type RedirectStatus
type RedirectStatus,
type RedirectResponse
} from './schema.js';
export type {
ConfigEnvironment,
+84 -3
View File
@@ -12,7 +12,7 @@ const yaml = 'auth: {credentials: {username: admin, password: test-password}}\nd
const authoredKeyMarker = 'dummy-authored-key-marker';
describe('configuration loading', () => {
it('parses YAML and applies root defaults without mutating the input', () => {
it('parses YAML and applies configuration defaults without mutating the input', () => {
expect(parseConfig(`${yaml}\nsite: {title: yes}`, options).configuration.site.title).toBe(
'yes'
);
@@ -25,7 +25,7 @@ describe('configuration loading', () => {
site: { title: 'Goto' },
database: { path: './data/goto.sqlite', worker_path: './data/worker.sqlite' },
analytics: { retention_months: 3 },
defaults: { status_code: 302, forward_query: false, fallback: 404 }
link_defaults: { status_code: 302, forward_query: false }
});
expect(input).toEqual(original);
});
@@ -190,6 +190,78 @@ describe('configuration loading', () => {
);
});
it('normalizes root and fallback responses independently of link defaults', () => {
const input = {
...configurationInput(),
link_defaults: { status_code: 308, forward_query: true },
fallback: { url: '${FALLBACK}' },
domains: {
'go.example.com': {
link_defaults: { status_code: 307, forward_query: true },
root: { url: '${ROOT}' },
links: {}
}
}
};
const original = structuredClone(input);
const { configuration, domains } = validateConfig(input, {
env: { ROOT: 'https://example.com', FALLBACK: 'https://example.com/missing' }
});
expect(configuration.fallback).toEqual({
url: 'https://example.com/missing',
status_code: 302,
forward_query: false
});
expect(domains['go.example.com']?.root).toEqual({
url: 'https://example.com',
status_code: 302,
forward_query: false
});
expect(input).toEqual(original);
});
it.each([
404,
'https://example.com/missing',
false,
null,
{},
{ url: 'https://example.com', status_code: 404 },
{ url: 'https://example.com', forward_query: 'false' },
{ url: 'https://example.com', action: 'not_found' }
])('rejects non-response fallback values %j', (fallback) => {
expect(() => validateConfig({ ...configurationInput(), fallback }, options)).toThrow(
ConfigError
);
});
it('restricts root and domain fallback to redirect responses and rejects old defaults', () => {
const input = configurationInput();
for (const domain of [
{ root: { url: 'https://example.com', enabled: false } },
{ root: { url: 'https://example.com', valid_from: '2027-01-01T00:00:00Z' } },
{ fallback: 404 },
{ defaults: { status_code: 307 } },
{ link_defaults: { fallback: 'https://example.com' } }
]) {
expect(() =>
validateConfig(
{
...input,
domains: { 'go.example.com': { ...domain, links: {} } }
},
options
)
).toThrow(ConfigError);
}
expect(() => validateConfig({ ...input, defaults: { status_code: 307 } }, options)).toThrow(
ConfigError
);
expect(() => validateConfig({ ...input, link_defaults: { fallback: 404 } }, options)).toThrow(
ConfigError
);
});
it.each([
{ unknown: 'private-value' },
{ site: null },
@@ -206,7 +278,7 @@ describe('configuration loading', () => {
},
{ auth: { ...configurationInput().auth, secret: 'too-short-private-secret' } },
{ url: { app: 'https://ADMIN.example.com/' } },
{ defaults: { forward_query: null } },
{ link_defaults: { forward_query: null } },
{ domains: [] },
{ domains: { 'go.example.com': { links: [] } } },
{
@@ -323,6 +395,15 @@ describe('configuration loading', () => {
}
});
expect(prepared.configuration.analytics.retention_months).toBe(3);
expect(prepared.domains['go.example.org']).toMatchObject({
root: { url: 'https://example.org', status_code: 308, forward_query: false },
fallback: { url: 'https://example.org/unavailable', status_code: 302, forward_query: false }
});
expect(prepared.domains['tiny.example.com']?.fallback).toEqual({
url: 'https://example.net/unavailable',
status_code: 307,
forward_query: true
});
expect(prepared.domains['go.example.org']?.links['/docs']).toMatchObject({
forward_query: false,
provenance: { forward_query: 'link' }
+3 -3
View File
@@ -82,9 +82,9 @@ export function validateConfig(raw: unknown, options: ConfigOptions = {}): Prepa
throw new ConfigError(issues);
}
const configuration = defaulted;
const authoredDefaults =
typeof raw.defaults === 'object' && raw.defaults !== null ? raw.defaults : {};
return { configuration, domains: prepareDomains(configuration, authoredDefaults) };
const authoredLinkDefaults =
typeof raw.link_defaults === 'object' && raw.link_defaults !== null ? raw.link_defaults : {};
return { configuration, domains: prepareDomains(configuration, authoredLinkDefaults) };
} catch (error) {
if (error instanceof ConfigError) throw error;
throw new ConfigError();
+91 -10
View File
@@ -16,7 +16,6 @@ describe('direct self-loop validation', () => {
{ host: redirectHost, path: '/docs', url: 'http://go.example.test/%64ocs' },
{ host: redirectHost, path: '/a/b', url: 'https://go.example.test/a%2Fb' },
{ host: redirectHost, path: '/a%2Fb', url: 'https://go.example.test/a%252Fb' },
{ host: 'GO.EXAMPLE.TEST:8443', path: '/', url: 'https://go.example.test:8443/' },
{ host: 'go.example.test:80', path: '/docs', url: 'http://go.example.test:80/docs' },
{ host: 'GO.EXAMPLE.TEST', path: '/docs', url: 'http://go.example.test:80/docs' },
{ host: redirectHost, path: '/docs', url: 'https:///go.example.test/docs' }
@@ -28,6 +27,34 @@ describe('direct self-loop validation', () => {
).toThrowError(new ConfigError());
});
it('rejects a direct root self-loop and allows a different path on the same domain', () => {
const input = configurationInput();
expect(() =>
validateConfig(
{
...input,
domains: {
'GO.EXAMPLE.TEST:8443': {
root: { url: 'https://go.example.test:8443/?private=dummy-value#fragment' },
links: {}
}
}
},
options
)
).toThrowError(new ConfigError());
const { domains } = validateConfig(
{
...input,
domains: {
[redirectHost]: { root: { url: 'https://go.example.test/landing' }, links: {} }
}
},
options
);
expect(domains[redirectHost]?.root?.url).toBe('https://go.example.test/landing');
});
it('keeps errors generic even for disabled links', () => {
const input = configurationInput();
const url = 'https://go.example.test/docs?private=dummy-value';
@@ -62,16 +89,20 @@ describe('direct self-loop validation', () => {
it('allows local fallback URLs without graph analysis', () => {
const input = configurationInput();
const fallback = 'https://go.example.test/landing';
const fallback = { url: 'https://go.example.test/landing' };
const { domains } = validateConfig(
{
...input,
domains: { [redirectHost]: { defaults: { fallback }, links: {} } }
domains: { [redirectHost]: { fallback, links: {} } }
},
options
);
expect(domains[redirectHost]?.fallback).toBe(fallback);
expect(domains[redirectHost]?.fallback).toEqual({
...fallback,
status_code: 302,
forward_query: false
});
});
});
@@ -79,11 +110,12 @@ describe('effective domains', () => {
it('keeps omissions in configuration and records built-in defaults separately', () => {
const { configuration, domains } = validateConfig(configurationInput(), options);
expect(configuration.domains['go.example.com']).toEqual({
defaults: {},
link_defaults: {},
links: { '/docs': { url: 'https://example.com/docs', tags: [], enabled: true } }
});
expect(domains['go.example.com']).toEqual({
fallback: 404,
root: null,
fallback: null,
provenance: { fallback: 'builtin' },
links: {
'/docs': {
@@ -98,6 +130,9 @@ describe('effective domains', () => {
}
});
expect(configuration.auth).not.toHaveProperty('secret');
expect(configuration).not.toHaveProperty('fallback');
expect(configuration.domains['go.example.com']).not.toHaveProperty('root');
expect(configuration.domains['go.example.com']).not.toHaveProperty('fallback');
});
it('resolves global, domain and link overrides, including explicit false', () => {
@@ -108,11 +143,13 @@ describe('effective domains', () => {
const { domains } = validateConfig(
{
...input,
defaults: { status_code: 301, forward_query: true, fallback: 'https://example.com' },
link_defaults: { status_code: 301, forward_query: true },
fallback: { url: 'https://example.com' },
domains: {
global: input.domains['go.example.com'],
domain: {
defaults: { status_code: 307, forward_query: false, fallback: 404 },
link_defaults: { status_code: 307, forward_query: false },
fallback: { url: 'https://example.com/domain-missing' },
links: input.domains['go.example.com'].links
}
}
@@ -129,7 +166,7 @@ describe('effective domains', () => {
provenance: { forward_query: 'link' }
});
expect(domains.global).toMatchObject({
fallback: 'https://example.com',
fallback: { url: 'https://example.com', status_code: 302, forward_query: false },
provenance: { fallback: 'global' }
});
expect(domains.domain?.links['/inherit']).toMatchObject({
@@ -137,7 +174,14 @@ describe('effective domains', () => {
forward_query: false,
provenance: { status_code: 'domain', forward_query: 'domain' }
});
expect(domains.domain).toMatchObject({ fallback: 404, provenance: { fallback: 'domain' } });
expect(domains.domain).toMatchObject({
fallback: {
url: 'https://example.com/domain-missing',
status_code: 302,
forward_query: false
},
provenance: { fallback: 'domain' }
});
expect(domains.domain?.links['/override']).toMatchObject({
status_code: 308,
forward_query: false,
@@ -146,6 +190,43 @@ describe('effective domains', () => {
expect(domains.domain?.links['/override']).not.toHaveProperty('fallback');
});
it('replaces a global fallback as a whole and keeps root response options independent', () => {
const { domains } = validateConfig(
{
...configurationInput(),
link_defaults: { status_code: 308, forward_query: true },
fallback: { url: 'https://example.com/global', status_code: 307, forward_query: true },
domains: {
inherited: { links: {} },
replaced: {
root: { url: 'https://example.com/root', status_code: 301, forward_query: true },
fallback: { url: 'https://example.com/domain' },
links: {}
}
}
},
options
);
expect(domains.inherited).toEqual({
root: null,
fallback: { url: 'https://example.com/global', status_code: 307, forward_query: true },
provenance: { fallback: 'global' },
links: {}
});
expect(domains.replaced).toEqual({
root: { url: 'https://example.com/root', status_code: 301, forward_query: true },
fallback: { url: 'https://example.com/domain', status_code: 302, forward_query: false },
provenance: { fallback: 'domain' },
links: {}
});
});
it('rejects the root as a named link', () => {
expect(() =>
validateConfig(configurationInput({ '/': { url: 'https://example.com' } }), options)
).toThrow(ConfigError);
});
it('normalizes domain identities while keeping their path namespaces independent', () => {
const input = configurationInput();
const { domains } = validateConfig(
+21 -12
View File
@@ -4,14 +4,14 @@ import { isSelfRedirect } from './redirect.js';
import type { DomainConfiguration, GotoConfiguration, LinkConfiguration } from './schema.js';
import type { PreparedDomain, PreparedLink, SettingSource } from './types.js';
function settingSource(
key: keyof GotoConfiguration['defaults'],
function linkSettingSource(
key: keyof GotoConfiguration['link_defaults'],
global: object,
domain: DomainConfiguration,
link?: LinkConfiguration
): SettingSource {
if (link && Object.hasOwn(link, key)) return 'link';
if (Object.hasOwn(domain.defaults, key)) return 'domain';
if (Object.hasOwn(domain.link_defaults, key)) return 'domain';
return Object.hasOwn(global, key) ? 'global' : 'builtin';
}
@@ -22,7 +22,7 @@ function prepareLink(
configuration: GotoConfiguration,
global: object
): PreparedLink {
if (!isCanonicalPath(path)) throw new ConfigError();
if (path === '/' || !isCanonicalPath(path)) throw new ConfigError();
if (
link.valid_from !== undefined &&
link.valid_until !== undefined &&
@@ -34,19 +34,23 @@ function prepareLink(
...link,
title: link.title ?? path,
status_code:
link.status_code ?? domain.defaults.status_code ?? configuration.defaults.status_code,
link.status_code ??
domain.link_defaults.status_code ??
configuration.link_defaults.status_code,
forward_query:
link.forward_query ?? domain.defaults.forward_query ?? configuration.defaults.forward_query,
link.forward_query ??
domain.link_defaults.forward_query ??
configuration.link_defaults.forward_query,
provenance: {
status_code: settingSource('status_code', global, domain, link),
forward_query: settingSource('forward_query', global, domain, link)
status_code: linkSettingSource('status_code', global, domain, link),
forward_query: linkSettingSource('forward_query', global, domain, link)
}
};
}
export function prepareDomains(
configuration: GotoConfiguration,
authoredDefaults: object
authoredLinkDefaults: object
): Record<string, PreparedDomain> {
const domains: Record<string, PreparedDomain> = Object.create(null);
const adminHost = normalizeHost(new URL(configuration.url.app).host);
@@ -55,14 +59,19 @@ export function prepareDomains(
if (!host || host === adminHost || Object.hasOwn(domains, host)) {
throw new ConfigError();
}
if (domain.root && isSelfRedirect(domain.root.url, host, '/')) throw new ConfigError();
const links: Record<string, PreparedLink> = Object.create(null);
for (const [path, link] of Object.entries(domain.links)) {
if (isSelfRedirect(link.url, host, path)) throw new ConfigError();
links[path] = prepareLink(path, link, domain, configuration, authoredDefaults);
links[path] = prepareLink(path, link, domain, configuration, authoredLinkDefaults);
}
let fallbackSource: SettingSource = 'builtin';
if (domain.fallback) fallbackSource = 'domain';
else if (configuration.fallback) fallbackSource = 'global';
domains[host] = {
fallback: domain.defaults.fallback ?? configuration.defaults.fallback,
provenance: { fallback: settingSource('fallback', authoredDefaults, domain) },
root: domain.root ?? null,
fallback: domain.fallback ?? configuration.fallback ?? null,
provenance: { fallback: fallbackSource },
links
};
}
+34 -23
View File
@@ -133,9 +133,14 @@ const RedirectStatusSchema = Type.Union(
{ $id: 'RedirectStatus' }
);
const FallbackSchema = Type.Union([Type.Literal(404), ref(LinkUrlSchema)], {
$id: 'Fallback'
});
const RedirectResponseSchema = Type.Object(
{
url: ref(LinkUrlSchema),
status_code: ref(RedirectStatusSchema, { default: 302 }),
forward_query: Type.Boolean({ default: false })
},
{ $id: 'RedirectResponse', additionalProperties: false }
);
const UrlSchema = Type.Object(
{
@@ -224,25 +229,23 @@ const AnalyticsSchema = Type.Object(
{ $id: 'Analytics', additionalProperties: false }
);
const DefaultsSchema = Type.Object(
const LinkDefaultsSchema = Type.Object(
{
status_code: ref(RedirectStatusSchema, { default: 302 }),
forward_query: Type.Boolean({ default: false }),
fallback: ref(FallbackSchema, { default: 404 })
forward_query: Type.Boolean({ default: false })
},
{ $id: 'Defaults', additionalProperties: false }
{ $id: 'LinkDefaults', additionalProperties: false }
);
const DomainDefaultsSchema = Type.Object(
const DomainLinkDefaultsSchema = Type.Object(
{
status_code: Type.Optional(ref(RedirectStatusSchema)),
forward_query: Type.Optional(Type.Boolean()),
fallback: Type.Optional(ref(FallbackSchema))
forward_query: Type.Optional(Type.Boolean())
},
{
$id: 'DomainDefaults',
$id: 'DomainLinkDefaults',
additionalProperties: false,
description: 'Omitted settings inherit global defaults. Explicit false overrides true.'
description: 'Omitted settings inherit global link_defaults. Explicit false overrides true.'
}
);
@@ -262,18 +265,24 @@ const LinkConfigurationSchema = Type.Object(
$id: 'LinkConfiguration',
additionalProperties: false,
description:
'Omitted status_code and forward_query inherit domain defaults, then global defaults.'
'Omitted status_code and forward_query inherit domain link_defaults, then global link_defaults.'
}
);
const DomainConfigurationSchema = Type.Object(
{
defaults: ref(DomainDefaultsSchema, { default: {} }),
links: Type.Record(Type.String(), ref(LinkConfigurationSchema, { default: {} }), {
additionalProperties: false,
description:
'Logical, case-sensitive paths. Keys are never interpolated or percent-decoded. Use readable canonical paths without spaces, trailing slashes, controls, backslashes, dot segments or :?#[]@. The root path / is allowed.'
})
link_defaults: ref(DomainLinkDefaultsSchema, { default: {} }),
root: Type.Optional(ref(RedirectResponseSchema)),
fallback: Type.Optional(ref(RedirectResponseSchema)),
links: Type.Record(
Type.String({ pattern: '^/.+' }),
ref(LinkConfigurationSchema, { default: {} }),
{
additionalProperties: false,
description:
'Logical, case-sensitive named paths. Keys are never interpolated or percent-decoded. Use readable canonical paths without spaces, trailing slashes, controls, backslashes, dot segments or :?#[]@. Configure / with the separate root response.'
}
)
},
{ $id: 'DomainConfiguration', additionalProperties: false }
);
@@ -287,7 +296,8 @@ export const GotoConfigurationSchema = Type.Object(
auth: ref(AuthSchema),
database: ref(DatabaseSchema, { default: {} }),
analytics: ref(AnalyticsSchema, { default: {} }),
defaults: ref(DefaultsSchema, { default: {} }),
link_defaults: ref(LinkDefaultsSchema, { default: {} }),
fallback: Type.Optional(ref(RedirectResponseSchema)),
domains: Type.Record(Type.String(), ref(DomainConfigurationSchema, { default: {} }), {
additionalProperties: false
})
@@ -306,7 +316,7 @@ export const configurationReferences: TSchema[] = [
HexColorSchema,
TagsSchema,
RedirectStatusSchema,
FallbackSchema,
RedirectResponseSchema,
UrlSchema,
SiteSchema,
AppearanceSchema,
@@ -315,8 +325,8 @@ export const configurationReferences: TSchema[] = [
AuthSchema,
DatabaseSchema,
AnalyticsSchema,
DefaultsSchema,
DomainDefaultsSchema,
LinkDefaultsSchema,
DomainLinkDefaultsSchema,
LinkConfigurationSchema,
DomainConfigurationSchema
];
@@ -325,3 +335,4 @@ export type GotoConfiguration = Static<typeof GotoConfigurationSchema>;
export type LinkConfiguration = Static<typeof LinkConfigurationSchema>;
export type DomainConfiguration = Static<typeof DomainConfigurationSchema>;
export type RedirectStatus = Static<typeof RedirectStatusSchema>;
export type RedirectResponse = Static<typeof RedirectResponseSchema>;
+8 -2
View File
@@ -1,4 +1,9 @@
import type { GotoConfiguration, LinkConfiguration, RedirectStatus } from './schema.js';
import type {
GotoConfiguration,
LinkConfiguration,
RedirectResponse,
RedirectStatus
} from './schema.js';
export type ConfigEnvironment = Readonly<Record<string, string | undefined>>;
@@ -20,7 +25,8 @@ export type PreparedLink = Omit<LinkConfiguration, 'title' | 'status_code' | 'fo
};
export interface PreparedDomain {
fallback: GotoConfiguration['defaults']['fallback'];
root: RedirectResponse | null;
fallback: RedirectResponse | null;
provenance: { fallback: SettingSource };
links: Record<string, PreparedLink>;
}
+4 -2
View File
@@ -83,7 +83,7 @@ export function* generateVisits({ domains, visitsPerDomain, since, asOf, seed })
yield {
domain,
path,
outcome: visitOutcome(prepared.links[path], visitedAt),
outcome: visitOutcome(prepared, path, visitedAt),
visited_at: visitedAt,
ip: '192.0.2.1',
user_agent: `${profile.userAgent} GotoSeed/${seed}`,
@@ -112,7 +112,9 @@ function requestPaths(links) {
return paths;
}
function visitOutcome(link, visitedAt) {
function visitOutcome(prepared, path, visitedAt) {
if (path === '/') return prepared.root ? 'valid' : 'invalid';
const link = prepared.links[path];
if (!link || !link.enabled) return 'invalid';
if (link.valid_from !== undefined && visitedAt < Date.parse(link.valid_from)) return 'invalid';
if (link.valid_until !== undefined && visitedAt >= Date.parse(link.valid_until)) return 'invalid';
+12 -1
View File
@@ -46,8 +46,13 @@ test('calendar window clamps month ends and keeps UTC time', () => {
test('fixed inputs produce repeatable retained traffic with request-time outcomes and consistent profiles', () => {
const source = structuredClone(fixture);
source.fallback = { url: 'https://example.org/missing' };
source.domains['one.example.test'].root = {
url: 'https://example.org/',
status_code: 307,
forward_query: true
};
source.domains['one.example.test'].links = {
'/': { url: 'https://example.org/' },
'/docs': {
url: 'https://example.org/docs',
valid_from: '2026-03-31T00:00:00Z',
@@ -85,8 +90,14 @@ test('fixed inputs produce repeatable retained traffic with request-time outcome
assert.equal(visit.ip, '192.0.2.1');
assert(visit.user_agent.endsWith(' GotoSeed/42'));
assert.deepEqual(classifyUserAgent(visit.user_agent), { browser: visit.browser, os: visit.os });
if (visit.path === '/') {
assert.equal(visit.outcome, visit.domain === 'one.example.test' ? 'valid' : 'invalid');
}
if (visit.path === '/future') assert.equal(visit.outcome, 'invalid');
}
for (const domain of Object.keys(domains)) {
assert(visits.some((visit) => visit.domain === domain && visit.path === '/'));
}
});
test('preview opens no SQLite file and applying refuses to create a missing database', async () => {