P5: OSS readiness — docs site, contributor onboarding, e2e harness
P5-01 — Documentation site under docs/book/ rendered with mdBook
(downloaded via Makefile, same static-binary pattern as Tailwind).
Structured chapters: getting started, concepts, operations,
security, reference. `make docs` / `make docs-watch`. Generated
output gitignored.
P5-02 — CONTRIBUTING.md rewritten from placeholder to a full
guide. CODE_OF_CONDUCT.md adapted from Contributor Covenant for a
single-maintainer project. .gitea/issue_template/{bug,feature}.md
and PULL_REQUEST_TEMPLATE.md.
P5-04 — Six README screenshots captured live from a fresh server
bootstrap (login, empty dashboard, add-host, alerts, settings,
audit log). README rewritten to centre the screenshot grid and
link out to the docs site.
P5-05 — SECURITY.md with disclosure policy (3-day ack, 30-day
default window), scope in/out, threat-model summary, operator
hardening checklist. Mirrored as a docs-site chapter.
P5-06 — End-to-end test harness. e2e/compose.e2e.yml brings up
server + sibling Linux agent (alpine + restic) + restic/rest-server.
Agent uses announce-and-approve so Playwright can drive the full
operator flow: bootstrap → login → accept pending → backup →
verify terminal status. Second spec scrapes /metrics to assert
the P6-04 endpoint surface. .gitea/workflows/e2e.yml runs on every
PR; local how-to in docs/e2e.md.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "restic-manager-e2e",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "PWDEBUG=1 playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Single-target Chromium config: the e2e suite is narrow (smoke
|
||||
// the production-shaped flow against the docker-compose stack).
|
||||
// Cross-browser matrix doesn't add signal — what we're verifying is
|
||||
// the server's HTML and the agent's WebSocket handshake, neither of
|
||||
// which depends on browser engine.
|
||||
|
||||
const baseURL = process.env.RM_BASE_URL ?? 'http://127.0.0.1:8080';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// Helpers used by every test. The shape favours the JSON API for
|
||||
// reads + accept/dispatch (deterministic, easy to assert) and the
|
||||
// browser for human-facing surfaces (login form, dashboard render).
|
||||
|
||||
import { APIRequestContext, expect, Page } from '@playwright/test';
|
||||
|
||||
export const baseURL = process.env.RM_BASE_URL ?? 'http://127.0.0.1:8080';
|
||||
|
||||
export interface HostJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
last_backup_status?: string;
|
||||
}
|
||||
|
||||
export async function readBootstrapToken(): Promise<string> {
|
||||
const tok = process.env.RM_BOOTSTRAP_TOKEN;
|
||||
if (!tok) {
|
||||
throw new Error('RM_BOOTSTRAP_TOKEN not set — the harness scrapes it from server logs');
|
||||
}
|
||||
return tok;
|
||||
}
|
||||
|
||||
export async function bootstrapAdmin(
|
||||
request: APIRequestContext,
|
||||
{
|
||||
username = 'admin',
|
||||
password = 'e2e-test-password-1234',
|
||||
}: { username?: string; password?: string } = {},
|
||||
): Promise<{ username: string; password: string }> {
|
||||
const token = await readBootstrapToken();
|
||||
const res = await request.post(`${baseURL}/api/bootstrap`, {
|
||||
data: { token, username, password },
|
||||
});
|
||||
if (!res.ok() && res.status() !== 409 /* already bootstrapped */) {
|
||||
throw new Error(`bootstrap: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
export async function loginViaUI(page: Page, username: string, password: string): Promise<void> {
|
||||
await page.goto(`${baseURL}/login`);
|
||||
await page.locator('#login-username').fill(username);
|
||||
await page.locator('#login-password').fill(password);
|
||||
await Promise.all([
|
||||
page.waitForURL(new RegExp(`^${baseURL}/?$`)),
|
||||
page.locator('form[action="/login"] button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the dashboard until a pending host card is visible, then
|
||||
* extracts its pending-id from the inline accept form's action URL.
|
||||
*/
|
||||
export async function waitForPendingHostID(page: Page): Promise<string> {
|
||||
const formLocator = page.locator('form[action^="/api/pending-hosts/"][action$="/accept"]').first();
|
||||
await expect(formLocator).toBeVisible({ timeout: 60_000 });
|
||||
const action = await formLocator.getAttribute('action');
|
||||
if (!action) throw new Error('pending host form has no action attribute');
|
||||
const m = action.match(/\/api\/pending-hosts\/([^/]+)\/accept/);
|
||||
if (!m) throw new Error(`unexpected action URL: ${action}`);
|
||||
return m[1];
|
||||
}
|
||||
|
||||
export async function acceptPending(
|
||||
request: APIRequestContext,
|
||||
cookie: string,
|
||||
pendingID: string,
|
||||
repo: { url: string; username?: string; password: string },
|
||||
): Promise<void> {
|
||||
const res = await request.post(`${baseURL}/api/pending-hosts/${pendingID}/accept`, {
|
||||
headers: { cookie, 'content-type': 'application/json' },
|
||||
data: {
|
||||
repo_url: repo.url,
|
||||
repo_username: repo.username ?? '',
|
||||
repo_password: repo.password,
|
||||
},
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`accept: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listHosts(request: APIRequestContext, cookie: string): Promise<HostJSON[]> {
|
||||
const res = await request.get(`${baseURL}/api/hosts`, { headers: { cookie } });
|
||||
if (!res.ok()) throw new Error(`list hosts: ${res.status()} ${await res.text()}`);
|
||||
const body = (await res.json()) as { items?: HostJSON[]; hosts?: HostJSON[] };
|
||||
return body.items ?? body.hosts ?? [];
|
||||
}
|
||||
|
||||
export async function waitForHostStatus(
|
||||
request: APIRequestContext,
|
||||
cookie: string,
|
||||
matcher: (h: HostJSON) => boolean,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<HostJSON> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: HostJSON | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
const hosts = await listHosts(request, cookie);
|
||||
const hit = hosts.find(matcher);
|
||||
if (hit) return hit;
|
||||
last = hosts[0];
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
}
|
||||
throw new Error(`waitForHostStatus: timeout. Last seen: ${JSON.stringify(last)}`);
|
||||
}
|
||||
|
||||
export async function getSessionCookie(page: Page): Promise<string> {
|
||||
const cookies = await page.context().cookies();
|
||||
const c = cookies.find((c) => c.name === 'rm_session');
|
||||
if (!c) throw new Error('rm_session cookie not set after login');
|
||||
return `${c.name}=${c.value}`;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// End-to-end smoke: bootstrap → accept pending host → run backup → see succeeded.
|
||||
//
|
||||
// The compose stack stands up a server, a sibling rest-server, and an
|
||||
// agent in announce-and-approve mode. This test drives the operator
|
||||
// path through the UI (login + dashboard) and the API
|
||||
// (accept + run-now + poll for terminal) — UI for the human surfaces,
|
||||
// API for the deterministic ones.
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
baseURL,
|
||||
bootstrapAdmin,
|
||||
loginViaUI,
|
||||
waitForPendingHostID,
|
||||
acceptPending,
|
||||
waitForHostStatus,
|
||||
getSessionCookie,
|
||||
} from './lib/server';
|
||||
|
||||
test.describe('smoke: enrol-via-announce → backup', () => {
|
||||
test('happy path completes in under a minute', async ({ page, request }) => {
|
||||
const { username, password } = await bootstrapAdmin(request);
|
||||
await loginViaUI(page, username, password);
|
||||
|
||||
// Dashboard renders.
|
||||
await expect(page.locator('main')).toContainText(/host|fleet|pending/i, { timeout: 10_000 });
|
||||
|
||||
// Pending host appears (the agent container has been
|
||||
// announcing since startup).
|
||||
const pendingID = await waitForPendingHostID(page);
|
||||
const cookie = await getSessionCookie(page);
|
||||
|
||||
// Accept with the rest-server creds. compose's rest-server runs
|
||||
// --no-auth, so any credentials work; restic still demands a
|
||||
// password to encrypt the repo.
|
||||
await acceptPending(request, cookie, pendingID, {
|
||||
url: 'rest:http://rest-server:8000/',
|
||||
password: 'e2e-repo-password',
|
||||
});
|
||||
|
||||
// Wait for the host to come online + auto-init to land.
|
||||
const onlineHost = await waitForHostStatus(
|
||||
request, cookie,
|
||||
(h) => h.status === 'online',
|
||||
60_000,
|
||||
);
|
||||
expect(onlineHost.id).toBeTruthy();
|
||||
|
||||
// Trigger a backup via the UI form-post (HX-Redirect to /jobs/{id}).
|
||||
await page.goto(`${baseURL}/hosts/${onlineHost.id}`);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/jobs\//),
|
||||
page.locator('form[action$="/run-backup"] button[type="submit"]').first().click(),
|
||||
]);
|
||||
|
||||
// Wait for the host's last_backup_status to flip to 'succeeded'.
|
||||
// The job page itself is harder to assert on (it uses
|
||||
// server-pushed updates and a reload-on-finish pattern); the
|
||||
// host record is the source of truth and is what the dashboard
|
||||
// surfaces.
|
||||
const finishedHost = await waitForHostStatus(
|
||||
request, cookie,
|
||||
(h) => h.id === onlineHost.id && h.last_backup_status === 'succeeded',
|
||||
120_000,
|
||||
);
|
||||
expect(finishedHost.last_backup_status).toBe('succeeded');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('smoke: scrape /metrics', () => {
|
||||
test('metrics endpoint exposes the host gauge', async ({ request }) => {
|
||||
// Compose sets RM_METRICS_TRUSTED_CIDR=0.0.0.0/0 so the
|
||||
// endpoint is open to the test runner.
|
||||
const res = await request.get(`${baseURL}/metrics`);
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain('rm_hosts_total');
|
||||
expect(body).toContain('rm_build_info{');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user