feat(config): separate root responses and link defaults #26
No files matched your search
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
+22
-5
@@ -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,
|
||||
|
||||
+11
-1
@@ -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>
|
||||
|
||||
+23
-5
@@ -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
@@ -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
|
||||
|
||||
@@ -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" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -14,7 +14,8 @@ export {
|
||||
type GotoConfiguration,
|
||||
type LinkConfiguration,
|
||||
type DomainConfiguration,
|
||||
type RedirectStatus
|
||||
type RedirectStatus,
|
||||
type RedirectResponse
|
||||
} from './schema.js';
|
||||
export type {
|
||||
ConfigEnvironment,
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user