feat(admin): add read-only health page #24
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@goto/web': minor
|
||||
'@goto/worker': minor
|
||||
'@goto/cli': minor
|
||||
---
|
||||
|
||||
Add a read-only Health page with safe active settings, report data cutoff and a manual bounded check of the existing worker health endpoint. Use the configured worker base URL without adding a startup dependency. Make the worker check and its result prominent with a bordered action button, checking indicator and status banner. Warn when enabled analytics reports are unavailable or more than 75 minutes old.
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from './support/test.ts';
|
||||
|
||||
test.use({ withWorker: true });
|
||||
|
||||
test('shared credentials protect overview, domain and link pages and sign-out removes access', async ({
|
||||
test('shared credentials protect admin pages and sign-out removes access', async ({
|
||||
page,
|
||||
appServer
|
||||
}) => {
|
||||
@@ -140,7 +140,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
|
||||
await expect(browsers.getByRole('rowheader', { name: 'Other', exact: true })).toBeVisible();
|
||||
const overviewLink = page
|
||||
.getByRole('navigation', { name: 'Administration', exact: true })
|
||||
.getByRole('link');
|
||||
.getByRole('link', { name: 'Goto', exact: true });
|
||||
await expect(overviewLink).toHaveAttribute('href', '/admin?period=all');
|
||||
await expect(page.getByRole('link', { name: 'Overview', exact: true })).toHaveCount(0);
|
||||
await expect(graph).toHaveAttribute('aria-busy', 'false');
|
||||
@@ -293,6 +293,24 @@ test('shared credentials protect overview, domain and link pages and sign-out re
|
||||
);
|
||||
await expect(visitsValue).toHaveText('2');
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Administration', exact: true })
|
||||
.getByRole('link', { name: 'Health', exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(`${appServer.origin}/admin/health`);
|
||||
await expect(page.getByRole('heading', { name: 'Health', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('region', { name: 'Application', exact: true })).toContainText(
|
||||
'Shared credentials'
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Analytics', exact: true }).getByRole('alert')
|
||||
).toHaveCount(0);
|
||||
const workerHealth = page.getByRole('region', { name: 'Worker', exact: true });
|
||||
await expect(workerHealth.getByRole('status')).toHaveText('Not checked');
|
||||
await workerHealth.getByRole('button', { name: 'Test worker', exact: true }).click();
|
||||
await expect(workerHealth.getByRole('alert')).toContainText('Worker is reachable.');
|
||||
await expect(workerHealth.locator('time')).toBeVisible();
|
||||
|
||||
const html = await page.content();
|
||||
await expect(html).not.toContain('e2e-only-password');
|
||||
await page.getByRole('button', { name: 'Sign out', exact: true }).click();
|
||||
@@ -305,7 +323,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
|
||||
test.describe('analytics disabled', () => {
|
||||
test.use({ withWorker: false, retentionMonths: 0 });
|
||||
|
||||
test('overview remains inventory-only without analytics controls or columns', async ({
|
||||
test('disabled analytics keeps inventory and settings usable without traffic controls', async ({
|
||||
page,
|
||||
appServer
|
||||
}) => {
|
||||
@@ -351,5 +369,18 @@ test.describe('analytics disabled', () => {
|
||||
await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(
|
||||
0
|
||||
);
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Administration', exact: true })
|
||||
.getByRole('link', { name: 'Health', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Analytics', exact: true }).locator('dd')
|
||||
).toHaveText(['Disabled', 'Disabled', 'Disabled']);
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Analytics', exact: true }).getByRole('alert')
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Worker', exact: true }).getByRole('status')
|
||||
).toHaveText('Not checked');
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ await new Promise((resolve, reject) =>
|
||||
);
|
||||
process.env.PORT = String(address.port);
|
||||
const configuration = {
|
||||
url: { app: `http://localhost:${address.port}` },
|
||||
url: { app: `http://localhost:${address.port}`, worker: 'http://goto-worker:9000' },
|
||||
auth: { credentials: { username: 'example', password: 'e2e-only-password' } },
|
||||
analytics: { retention_months: Number(process.env.GOTO_E2E_RETENTION_MONTHS ?? '3') },
|
||||
domains: {
|
||||
@@ -118,4 +118,6 @@ async function startWorker() {
|
||||
});
|
||||
});
|
||||
if (ready.type !== 'ready') throw new Error('Invalid worker readiness');
|
||||
configuration.url.worker = `http://127.0.0.1:${ready.port}`;
|
||||
await writeFile(join(runtimeDirectory, 'config/goto.yaml'), JSON.stringify(configuration));
|
||||
}
|
||||
@@ -52,6 +52,10 @@
|
||||
<a href={overviewHref} aria-current={page.url.pathname === '/admin' ? 'page' : undefined}
|
||||
>{title}</a
|
||||
>
|
||||
<a
|
||||
href="/admin/health"
|
||||
aria-current={page.url.pathname === '/admin/health' ? 'page' : undefined}>Health</a
|
||||
>
|
||||
</nav>
|
||||
<div class="nav-actions">
|
||||
<ThemeToggle />
|
||||
@@ -96,6 +100,13 @@
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { isWorkerReachable } from './worker-health.js';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
test('checks the configured worker base with a bounded health request', async () => {
|
||||
const signal = new AbortController().signal;
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(signal);
|
||||
const request = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok'));
|
||||
|
||||
expect(await isWorkerReachable('http://goto-worker:9000/private/')).toBe(true);
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith('http://goto-worker:9000/private/healthz', {
|
||||
signal
|
||||
});
|
||||
expect(timeout).toHaveBeenCalledExactlyOnceWith(3_000);
|
||||
});
|
||||
|
||||
test('an unsuccessful health response is unreachable', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('unavailable', { status: 503 }));
|
||||
expect(await isWorkerReachable('http://goto-worker:9000')).toBe(false);
|
||||
});
|
||||
|
||||
test('transport and timeout failures are unreachable without exposing errors', async () => {
|
||||
const request = vi.spyOn(globalThis, 'fetch');
|
||||
request.mockRejectedValueOnce(new Error('private connection detail'));
|
||||
expect(await isWorkerReachable('http://goto-worker:9000')).toBe(false);
|
||||
request.mockRejectedValueOnce(new DOMException('Timed out', 'TimeoutError'));
|
||||
expect(await isWorkerReachable('http://goto-worker:9000')).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const HEALTH_TIMEOUT_MS = 3_000;
|
||||
|
||||
export async function isWorkerReachable(workerBaseUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${workerBaseUrl.replace(/\/+$/, '')}/healthz`, {
|
||||
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
|
||||
});
|
||||
await response.body?.cancel();
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@
|
||||
--radius-control: 0.5rem;
|
||||
--radius-card: 0.75rem;
|
||||
--shadow-card: 0 1px 6px light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 40%));
|
||||
--color-success: light-dark(#059669, #34d399);
|
||||
--color-success-bg: light-dark(#ecfdf5, #022c22);
|
||||
--color-success-border: light-dark(#a7f3d0, #064e3b);
|
||||
--color-danger: light-dark(#991b1b, #ffb4b4);
|
||||
--color-danger-bg: light-dark(#fef2f2, #450a0a);
|
||||
--color-danger-border: light-dark(#fca5a5, #a34242);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import packageJson from '../../../../../../package.json' with { type: 'json' };
|
||||
import { readOverviewDetails } from '#lib/server/visit-reports.js';
|
||||
import { isWorkerReachable } from '#lib/server/worker-health.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const MAX_REPORT_AGE_MS = 75 * 60 * 1000;
|
||||
|
||||
export const load = (async ({ locals }) => {
|
||||
const configuration = locals.snapshot.configuration;
|
||||
const retentionMonths = configuration.analytics.retention_months;
|
||||
const report = retentionMonths === 0 ? null : await readOverviewDetails(configuration, []);
|
||||
const reportDataAsOf = report?.overview.period.asOf ?? null;
|
||||
const reportDelayed =
|
||||
reportDataAsOf !== null && Date.now() - Date.parse(reportDataAsOf) > MAX_REPORT_AGE_MS;
|
||||
const authMode = configuration.auth.oidc ? ('oidc' as const) : ('credentials' as const);
|
||||
|
||||
return {
|
||||
appVersion: packageJson.version,
|
||||
appUrl: configuration.url.app,
|
||||
authMode,
|
||||
retentionMonths,
|
||||
reportDataAsOf,
|
||||
reportDelayed,
|
||||
redirectStatusCode: configuration.defaults.status_code,
|
||||
forwardQuery: configuration.defaults.forward_query,
|
||||
fallbackDestination: configuration.defaults.fallback,
|
||||
workerUrl: configuration.url.worker
|
||||
};
|
||||
}) satisfies PageServerLoad;
|
||||
|
||||
export const actions = {
|
||||
checkWorker: async ({ locals }) => {
|
||||
const reachable = await isWorkerReachable(locals.snapshot.configuration.url.worker);
|
||||
return { workerCheck: { reachable, checkedAt: new Date().toISOString() } };
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import IconCheckCircle from 'virtual:icons/ph/check-circle';
|
||||
import IconWarningCircle from 'virtual:icons/ph/warning-circle';
|
||||
import IconSpinner from 'virtual:icons/ph/spinner';
|
||||
import AdminPage from '#lib/components/admin/AdminPage.svelte';
|
||||
import type { ActionData, PageData, SubmitFunction } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData | null } = $props();
|
||||
let pending = $state(false);
|
||||
const utcFormatter = new Intl.DateTimeFormat('en', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
|
||||
function formatUtc(value: string): string {
|
||||
return `${utcFormatter.format(new Date(value))} UTC`;
|
||||
}
|
||||
|
||||
const checkWorker: SubmitFunction = () => {
|
||||
pending = true;
|
||||
return async ({ update }) => {
|
||||
try {
|
||||
await update({ reset: false });
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<AdminPage siteTitle={data.title} title="Health">
|
||||
<section class="admin-info" aria-labelledby="application-heading">
|
||||
<h2 id="application-heading">Application</h2>
|
||||
<dl>
|
||||
<dt>Version</dt>
|
||||
<dd>{data.appVersion}</dd>
|
||||
<dt>App URL</dt>
|
||||
<dd>{data.appUrl}</dd>
|
||||
<dt>Authentication</dt>
|
||||
<dd>{data.authMode === 'oidc' ? 'OIDC' : 'Shared credentials'}</dd>
|
||||
<dt>Redirect status code</dt>
|
||||
<dd>{data.redirectStatusCode}</dd>
|
||||
<dt>Forward query</dt>
|
||||
<dd>{data.forwardQuery ? 'Yes' : 'No'}</dd>
|
||||
<dt>Fallback destination</dt>
|
||||
<dd>{data.fallbackDestination === 404 ? '404 Not found' : data.fallbackDestination}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="admin-info health-card" aria-labelledby="analytics-heading">
|
||||
{#if data.retentionMonths !== 0 && (data.reportDelayed || data.reportDataAsOf === null)}
|
||||
<aside class="health-result health-result-error" role="alert">
|
||||
<span class="health-result-icon" aria-hidden="true"><IconWarningCircle /></span>
|
||||
<div>
|
||||
<p class="health-result-title">
|
||||
{data.reportDelayed
|
||||
? 'Analytics data is delayed.'
|
||||
: 'Analytics reports are unavailable.'}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
<div class="health-card-body">
|
||||
<h2 id="analytics-heading">Analytics</h2>
|
||||
<dl>
|
||||
<dt>Collection</dt>
|
||||
<dd>{data.retentionMonths === 0 ? 'Disabled' : 'Enabled'}</dd>
|
||||
<dt>Retention</dt>
|
||||
<dd>{data.retentionMonths === 0 ? 'Disabled' : `${data.retentionMonths} months`}</dd>
|
||||
<dt>Report data as of</dt>
|
||||
<dd>
|
||||
{#if data.retentionMonths === 0}
|
||||
Disabled
|
||||
{:else if data.reportDataAsOf}
|
||||
<time datetime={data.reportDataAsOf}>{formatUtc(data.reportDataAsOf)}</time>
|
||||
{:else}
|
||||
Unavailable
|
||||
{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
<section class="admin-info health-card" aria-labelledby="worker-heading">
|
||||
{#if !pending && form?.workerCheck}
|
||||
<aside
|
||||
class="health-result"
|
||||
class:health-result-error={!form.workerCheck.reachable}
|
||||
role="alert"
|
||||
>
|
||||
<span class="health-result-icon" aria-hidden="true">
|
||||
{#if form.workerCheck.reachable}<IconCheckCircle />{:else}<IconWarningCircle />{/if}
|
||||
</span>
|
||||
<div>
|
||||
<p class="health-result-title">
|
||||
{form.workerCheck.reachable ? 'Worker is reachable.' : 'Worker could not be reached.'}
|
||||
</p>
|
||||
<p class="health-result-time">
|
||||
Checked <time datetime={form.workerCheck.checkedAt}
|
||||
>{formatUtc(form.workerCheck.checkedAt)}</time
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
<div class="health-card-body">
|
||||
<h2 id="worker-heading">Worker</h2>
|
||||
<dl>
|
||||
<dt>Worker URL</dt>
|
||||
<dd>{data.workerUrl}</dd>
|
||||
</dl>
|
||||
{#if pending || !form?.workerCheck}
|
||||
<p class="worker-note" role="status">{pending ? 'Checking worker…' : 'Not checked'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="worker-actions">
|
||||
<form
|
||||
class="worker-form"
|
||||
method="POST"
|
||||
action="?/checkWorker"
|
||||
use:enhance={checkWorker}
|
||||
aria-busy={pending}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="ui-button worker-test-button"
|
||||
class:busy={pending}
|
||||
disabled={pending}
|
||||
aria-label={pending ? 'Checking worker' : 'Test worker'}
|
||||
>
|
||||
<span class="button-label">Test worker</span>
|
||||
{#if pending}<span class="worker-spinner" aria-hidden="true"><IconSpinner /></span>{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</AdminPage>
|
||||
|
||||
<style>
|
||||
.health-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.health-card-body {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.worker-note {
|
||||
margin-top: var(--space-5);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.health-result {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
border-bottom: var(--border-width) solid var(--color-success-border);
|
||||
background: var(--color-success-bg);
|
||||
}
|
||||
|
||||
.health-result > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.health-result-error {
|
||||
border-color: var(--color-danger-border);
|
||||
background: var(--color-danger-bg);
|
||||
}
|
||||
|
||||
.health-result-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.health-result-error .health-result-icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.health-result-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.health-result-time {
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.worker-actions {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.worker-form {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.worker-test-button {
|
||||
display: inline-grid;
|
||||
grid-template-areas: 'content';
|
||||
place-items: center;
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
background: var(--color-surface-card);
|
||||
}
|
||||
|
||||
.worker-test-button > * {
|
||||
grid-area: content;
|
||||
}
|
||||
|
||||
.busy .button-label {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.worker-spinner {
|
||||
display: inline-flex;
|
||||
animation: worker-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes worker-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.worker-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
import packageJson from '../../../../../../package.json' with { type: 'json' };
|
||||
import { createVisitPeriod, visitDays, type VisitDetails } from '@goto/analytics';
|
||||
import { validateConfig } from '@goto/config';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import { prepareSnapshot } from '#lib/server/snapshot.js';
|
||||
import { readOverviewDetails } from '#lib/server/visit-reports.js';
|
||||
import { isWorkerReachable } from '#lib/server/worker-health.js';
|
||||
import { actions, load } from './+page.server.js';
|
||||
|
||||
vi.mock('#lib/server/visit-reports.js', () => ({ readOverviewDetails: vi.fn() }));
|
||||
vi.mock('#lib/server/worker-health.js', () => ({ isWorkerReachable: vi.fn() }));
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-06-15T13:00:00.000Z'));
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function request(overrides: Record<string, unknown> = {}) {
|
||||
const configuration = validateConfig(
|
||||
{
|
||||
url: { app: 'https://admin.example.test', worker: 'http://worker.example.test:9000/base' },
|
||||
auth: {
|
||||
secret: 'health-test-auth-secret-not-for-page-data',
|
||||
credentials: { username: 'health-admin', password: 'private-health-password' }
|
||||
},
|
||||
database: { path: './private-visits.sqlite', worker_path: './private-worker.sqlite' },
|
||||
domains: {},
|
||||
...overrides
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
return {
|
||||
locals: { snapshot: prepareSnapshot(configuration) },
|
||||
request: new Request('https://admin.example.test/admin/health?/checkWorker', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ workerUrl: 'https://untrusted.example.test/forged' })
|
||||
})
|
||||
} as Parameters<typeof load>[0];
|
||||
}
|
||||
|
||||
function emptyOverview(): VisitDetails {
|
||||
const period = createVisitPeriod(3, Date.parse('2026-06-15T12:34:56.000Z'));
|
||||
return {
|
||||
version: 2,
|
||||
scope: { kind: 'overview' },
|
||||
period,
|
||||
daily: visitDays(period).map((day) => ({ ...day, validVisits: 0, invalidVisits: 0 })),
|
||||
browsers: [],
|
||||
operatingSystems: []
|
||||
};
|
||||
}
|
||||
|
||||
test('returns only allowlisted settings and the prepared report cutoff, without probing', async () => {
|
||||
const overview = emptyOverview();
|
||||
vi.mocked(readOverviewDetails).mockResolvedValue({ overview, domains: [] });
|
||||
const event = request();
|
||||
const data = await load(event);
|
||||
|
||||
expect(data).toEqual({
|
||||
appVersion: packageJson.version,
|
||||
appUrl: 'https://admin.example.test',
|
||||
authMode: 'credentials',
|
||||
retentionMonths: 3,
|
||||
reportDataAsOf: overview.period.asOf,
|
||||
reportDelayed: false,
|
||||
redirectStatusCode: 302,
|
||||
forwardQuery: false,
|
||||
fallbackDestination: 404,
|
||||
workerUrl: 'http://worker.example.test:9000/base'
|
||||
});
|
||||
expect(readOverviewDetails).toHaveBeenCalledExactlyOnceWith(
|
||||
event.locals.snapshot.configuration,
|
||||
[]
|
||||
);
|
||||
expect(isWorkerReachable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('projects OIDC mode and configured redirect defaults without exposing the provider settings', async () => {
|
||||
vi.mocked(readOverviewDetails).mockResolvedValue(null);
|
||||
const data = await load(
|
||||
request({
|
||||
auth: {
|
||||
secret: 'health-test-auth-secret-not-for-page-data',
|
||||
oidc: {
|
||||
issuer: 'https://identity.example.test',
|
||||
client_id: 'private-client-id',
|
||||
client_secret: 'private-client-secret'
|
||||
}
|
||||
},
|
||||
defaults: { status_code: 307, forward_query: true, fallback: 'https://example.test/missing' }
|
||||
})
|
||||
);
|
||||
|
||||
expect(data).toEqual({
|
||||
appVersion: packageJson.version,
|
||||
appUrl: 'https://admin.example.test',
|
||||
authMode: 'oidc',
|
||||
retentionMonths: 3,
|
||||
reportDataAsOf: null,
|
||||
reportDelayed: false,
|
||||
redirectStatusCode: 307,
|
||||
forwardQuery: true,
|
||||
fallbackDestination: 'https://example.test/missing',
|
||||
workerUrl: 'http://worker.example.test:9000/base'
|
||||
});
|
||||
});
|
||||
|
||||
test('marks reports delayed only after the hourly refresh grace period', async () => {
|
||||
const overview = emptyOverview();
|
||||
vi.mocked(readOverviewDetails).mockResolvedValue({ overview, domains: [] });
|
||||
const graceDeadline = Date.parse(overview.period.asOf) + 75 * 60 * 1000;
|
||||
vi.setSystemTime(new Date(graceDeadline));
|
||||
expect((await load(request())).reportDelayed).toBe(false);
|
||||
vi.setSystemTime(new Date(graceDeadline + 1));
|
||||
expect((await load(request())).reportDelayed).toBe(true);
|
||||
});
|
||||
|
||||
test('zero retention skips the report read and remains a normal read-only page', async () => {
|
||||
const data = await load(request({ analytics: { retention_months: 0 } }));
|
||||
expect(data.retentionMonths).toBe(0);
|
||||
expect(data.reportDataAsOf).toBeNull();
|
||||
expect(data.reportDelayed).toBe(false);
|
||||
expect(data.appUrl).toBe('https://admin.example.test');
|
||||
expect(readOverviewDetails).not.toHaveBeenCalled();
|
||||
expect(isWorkerReachable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('unavailable prepared analytics do not prevent viewing settings', async () => {
|
||||
vi.mocked(readOverviewDetails).mockResolvedValue(null);
|
||||
const data = await load(request());
|
||||
expect(data.retentionMonths).toBe(3);
|
||||
expect(data.reportDataAsOf).toBeNull();
|
||||
expect(data.reportDelayed).toBe(false);
|
||||
expect(data.workerUrl).toBe('http://worker.example.test:9000/base');
|
||||
expect(isWorkerReachable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('manual worker check uses the current configured address, ignoring submitted targets', async () => {
|
||||
vi.mocked(readOverviewDetails).mockResolvedValue(null);
|
||||
await load(request());
|
||||
const event = request({
|
||||
url: { app: 'https://admin.example.test', worker: 'http://current-worker.example.test:9001' }
|
||||
}) as Parameters<typeof actions.checkWorker>[0];
|
||||
vi.mocked(isWorkerReachable).mockResolvedValue(true);
|
||||
const before = Date.now();
|
||||
const result = await actions.checkWorker(event);
|
||||
|
||||
expect(isWorkerReachable).toHaveBeenCalledExactlyOnceWith(
|
||||
'http://current-worker.example.test:9001'
|
||||
);
|
||||
expect(result).toEqual({ workerCheck: { reachable: true, checkedAt: expect.any(String) } });
|
||||
expect(new Date(result.workerCheck.checkedAt).toISOString()).toBe(result.workerCheck.checkedAt);
|
||||
expect(Date.parse(result.workerCheck.checkedAt)).toBeGreaterThanOrEqual(before);
|
||||
expect(Date.parse(result.workerCheck.checkedAt)).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
test('an unreachable worker returns only the negative result and completion timestamp', async () => {
|
||||
vi.mocked(isWorkerReachable).mockResolvedValue(false);
|
||||
const result = await actions.checkWorker(request() as Parameters<typeof actions.checkWorker>[0]);
|
||||
expect(result).toEqual({ workerCheck: { reachable: false, checkedAt: expect.any(String) } });
|
||||
expect(new Date(result.workerCheck.checkedAt).toISOString()).toBe(result.workerCheck.checkedAt);
|
||||
expect(readOverviewDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
Reference in new issue
Block a user