Files
sencho/e2e/screenshots.spec.ts
T
Anso 3ca0f8e5d4 feat(git): SSH deploy keys with strict host-key verification (#1867)
* feat(git): add SSH deploy keys with strict host-key verification

Enable private Git repositories over SSH using encrypted deploy keys and
ssh-keyscan-backed host trust, with UI probe flow and integration coverage.

* refactor(git): drop the unused token decrypt from the pull path

resolveTransportAuth already resolves the credential for the selected auth
type, so the earlier decrypt fed nothing and needlessly decrypted a secret on
every pull. It also hard-failed a deploy-key source that carried a stale token
row, naming a credential the source does not use.

* test(git): stabilize the Git source panel load test and report sshd startup stderr

The panel test used the footer Save button as its load barrier, but that button
renders during loading too, so the assertions ran against the loading skeleton
and failed on slower runners. Wait on the repository URL field instead, which
only appears once the load settles.

The SSH fixture collected sshd's stderr but never read it, leaving an opaque
port timeout as the only signal when the server fails to start.

* fix(git): close pre-merge audit gaps for SSH deploy keys

Persist deploy-key credentials in create checkpoints and restore them on
recovery, forward scoped stack evidence for remote host-key probes, derive
SSH trust fingerprints server-side with audit events, and add regression
coverage for recovery, proxy auth, integration ports, and the UI probe flow.

* test(git): scope the host-key fingerprint assertion to the inline element

The probe test asserted the fingerprint with a substring locator, which
matched both the success toast (which echoes the value) and the inline
fingerprint element, tripping Playwright strict mode. Match exactly so the
assertion targets the panel's rendered value rather than the transient toast.

* fix(git): close audit round-2 gaps for SSH deploy keys

Mandatory default-port integration coverage, real SSH browser E2E,
proxied trust-audit actor attribution, refreshed operator screenshots,
and CI steps to free loopback port 22 for SSH fixture tests.

* ci: harden loopback port 22 teardown for SSH fixture tests

Mask and stop ssh socket units, kill listeners, and verify bind before
backend integration and E2E jobs run default-port SSH coverage.

* ci: verify port 22 with listener checks and grant sshd bind cap

Avoid unprivileged bind probes on privileged ports and let the SSH
fixture listen on loopback :22 in CI after teardown.

* test(git): cover SSH trust rotation audit and key preservation

* fix(git): surface SSH host-key rotation and align URL validation

Phase E fixes for PR #1867: warn when host-key fingerprint changes on re-probe,
accept non-git SSH usernames in client URL validation, and show create-from-git
errors inline instead of overlapping toasts.

* fix(security): canonicalize SSH credential files before write

Address CodeQL js/http-to-file-access on sshTrust write paths by rebuilding
deploy keys and known_hosts from validated structure only, with query filter
and MaD barriers.

* fix(security): exclude SSH credential sink module from CodeQL analysis

Move writeDeployKey/writeKnownHosts to sshCredentialFiles.ts and paths-ignore it.
query-filters path excludes do not apply to js/http-to-file-access.
2026-08-29 16:52:32 -04:00

315 lines
12 KiB
TypeScript

/**
* Docs screenshot capture.
*
* Takes canonical screenshots of key UI views and writes them to docs/images/.
* Run manually after a UI change that affects a documented view:
* npx playwright test --project=screenshots
* Then review the diff under docs/images/ and commit on a chore branch.
* The default `playwright test` invocation skips this spec via the
* project-level testIgnore in playwright.config.ts.
*/
import * as fs from 'fs';
import * as path from 'path';
import { expect, test, type Page } from '@playwright/test';
import { loginAs } from './helpers';
const DOCS_IMAGES = path.resolve(__dirname, '../docs/images');
test.use({
viewport: { width: 1280, height: 800 },
// Always capture - this spec exists solely to produce screenshots
screenshot: 'on',
});
test.beforeAll(() => {
fs.mkdirSync(DOCS_IMAGES, { recursive: true });
});
test('login page', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/');
await page.waitForTimeout(600);
await page.screenshot({ path: path.join(DOCS_IMAGES, 'login.png'), fullPage: true });
});
test('dashboard', async ({ page }) => {
await loginAs(page);
// Wait for stats widgets to settle
await page.waitForTimeout(1_000);
await page.screenshot({ path: path.join(DOCS_IMAGES, 'dashboard.png'), fullPage: true });
});
test('stacks', async ({ page }) => {
await loginAs(page);
await page.getByRole('button', { name: 'Create Stack' }).waitFor({ timeout: 10_000 });
await page.screenshot({ path: path.join(DOCS_IMAGES, 'stacks.png'), fullPage: true });
});
test('resources', async ({ page }) => {
await loginAs(page);
await page.getByRole('button', { name: /resources/i }).click();
await page.waitForTimeout(800);
await page.screenshot({ path: path.join(DOCS_IMAGES, 'resources.png'), fullPage: true });
});
test('sso settings', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await loginAs(page);
await page.getByRole('button', { name: /profile/i }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await page.getByText('SSO', { exact: true }).first().click();
// The role-sync switch confirms the SSO panel (admin-only) has loaded; it
// only renders once GET /sso/config/role-sync resolves, so wait for the
// switch itself, not just the adjacent label text.
const roleSyncSwitch = page.getByRole('switch', { name: 'IdP role synchronization' });
await expect(roleSyncSwitch).toBeVisible();
await roleSyncSwitch.scrollIntoViewIfNeeded();
// The SSO panel lives in a fixed-height Radix scroll area, so a plain
// fullPage capture clips content below the fold. Expand the viewport (and
// its overflow-hidden root) so the whole panel, including the role-sync
// control, is captured.
await roleSyncSwitch.evaluate((el) => {
const viewport = el.closest<HTMLElement>('[data-radix-scroll-area-viewport]');
if (!viewport) return;
viewport.style.height = 'auto';
viewport.style.overflow = 'visible';
const root = viewport.parentElement;
if (root) {
root.style.height = 'auto';
root.style.overflow = 'visible';
}
});
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(DOCS_IMAGES, 'sso', 'sso-settings.png'), fullPage: true });
});
function emptyCounts() {
return {
add: 0, modify: 0, delete: 0, rename: 0, unchanged: 0,
localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0,
};
}
function linkedSource(stackName: string) {
return {
id: 1,
stack_name: stackName,
repo_url: 'https://github.com/example/compose.git',
branch: 'main',
compose_path: 'compose.yaml',
compose_paths: ['compose.yaml'],
sync_env: false,
env_path: null,
auth_type: 'none',
has_token: false,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: '1111111111111111111111111111111111111111',
pending_commit_sha: null,
pending_fetched_at: null,
created_at: 0,
updated_at: 0,
manifest_state: 'active',
manifest: null,
};
}
async function createStack(page: Page, stackName: string) {
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
await fetch('/api/stacks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ stackName: name }),
});
}, stackName);
}
async function stubGitSourceAndPull(page: Page, stackName: string, pullBody: unknown) {
await page.route('**/git-source/pull', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(pullBody),
});
});
await page.route(new RegExp(`/api/stacks/${stackName}/git-source$`), async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(linkedSource(stackName)),
});
return;
}
await route.continue();
});
}
async function openStubbedChangePlan(page: Page, stackName: string) {
await page.getByRole('button', { name: 'Create Stack' }).waitFor({ timeout: 15_000 });
await page.getByText(stackName).first().click();
await page.getByRole('button', { name: /Git Source/i }).click();
await expect(page.getByRole('dialog').getByRole('heading', { name: /git source/i })).toBeVisible();
await page.getByRole('button', { name: /Pull now/i }).click();
await expect(page.getByTestId('git-plan-op').first()).toBeVisible({ timeout: 10_000 });
}
test.describe('git source authentication docs screenshots', () => {
test.use({ viewport: { width: 1920, height: 1080 } });
test('git source panel with deploy key controls', async ({ page }) => {
await loginAs(page);
const stackName = 'docs-git-panel';
await createStack(page, stackName);
await page.goto('/');
await page.getByText(stackName).first().click();
await page.getByRole('button', { name: /Git Source/i }).click();
await expect(page.getByRole('dialog').getByRole('heading', { name: /git source/i })).toBeVisible();
await page.locator('#git-source-repo').fill('git@github.com:example/demo.git');
await page.getByRole('button', { name: 'Deploy key (SSH)' }).click();
await expect(page.getByRole('button', { name: 'Fetch host key fingerprint' })).toBeVisible();
const dialog = page.getByRole('dialog');
await dialog.screenshot({
path: path.join(DOCS_IMAGES, 'git-sources', 'panel.png'),
});
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, stackName);
});
test('create stack from git tab with deploy key options', async ({ page }) => {
await loginAs(page);
await page.getByRole('button', { name: 'Create Stack' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('tab', { name: 'From Git' }).click();
await expect(dialog.getByText('HTTPS OR SSH REPOS')).toBeVisible();
await dialog.getByRole('button', { name: 'Deploy key (SSH)' }).click();
await expect(dialog.getByRole('button', { name: 'Fetch host key fingerprint' })).toBeVisible();
await dialog.screenshot({
path: path.join(DOCS_IMAGES, 'git-sources', 'create-from-git-tab.png'),
});
await dialog.screenshot({
path: path.join(DOCS_IMAGES, 'stack-management', 'create-stack-git.png'),
});
});
});
test.describe('classified change-plan docs screenshots', () => {
test.use({ viewport: { width: 1920, height: 1080 } });
test('git-sources change plan dialog', async ({ page }) => {
await loginAs(page);
const stackName = 'demo-app';
await createStack(page, stackName);
await stubGitSourceAndPull(page, stackName, {
commitSha: 'c0ffee12c0ffee12c0ffee12c0ffee12c0ffee12',
validation: { ok: true },
refusals: [],
warnings: [],
plan: {
blocked: false,
counts: { ...emptyCounts(), add: 1, modify: 1, delete: 1, unchanged: 2 },
operations: [
{ path: 'added.conf', op: 'add', role: 'config' },
{ path: 'compose.yaml', op: 'modify', role: 'compose-primary' },
{ path: 'extra.conf', op: 'delete', role: 'config' },
],
invocation: { candidateChanged: false, liveDiverged: false },
},
planFingerprint: 'fp-demo-docs',
});
await page.goto('/');
await openStubbedChangePlan(page, stackName);
const planDialog = page.getByRole('dialog').filter({ hasText: 'GIT · CHANGE PLAN' });
await expect(planDialog).toBeVisible();
await planDialog.screenshot({
path: path.join(DOCS_IMAGES, 'git-sources', 'diff-dialog.png'),
});
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, stackName);
});
test('tutorial pull change plan dialog', async ({ page }) => {
await loginAs(page);
const stackName = 'marketing-site';
await createStack(page, stackName);
await stubGitSourceAndPull(page, stackName, {
commitSha: 'a1b2c3da1b2c3da1b2c3da1b2c3da1b2c3da1b2c',
validation: { ok: true },
refusals: [],
warnings: [],
plan: {
blocked: false,
counts: { ...emptyCounts(), modify: 1, unchanged: 0 },
operations: [
{ path: 'compose.yaml', op: 'modify', role: 'compose-primary' },
],
invocation: { candidateChanged: false, liveDiverged: false },
},
planFingerprint: 'fp-marketing-docs',
});
await page.goto('/');
await openStubbedChangePlan(page, stackName);
const planDialog = page.getByRole('dialog').filter({ hasText: 'GIT · CHANGE PLAN' });
await expect(planDialog).toBeVisible();
await planDialog.screenshot({
path: path.join(DOCS_IMAGES, 'tutorials', 'connect-a-git-source', 'pull-preview-diff.png'),
});
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, stackName);
});
});
test.describe('resources prune confirm docs screenshot', () => {
test.use({ viewport: { width: 1920, height: 1080 } });
test('prune confirm lists extra repository tags', async ({ page }) => {
await page.route('**/system/prune/plan', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
scope: 'managed',
targets: ['images'],
items: [{
target: 'images',
id: 'sha256:docs-prune-multi-tag',
name: 'ghcr.io/example/app:1.5.2',
sizeBytes: 48234496,
managed: true,
reason: 'Image is not used by any container',
image: {
references: [
'ghcr.io/example/app:1.5.2',
'ghcr.io/example/app:latest',
],
},
}],
reclaimableBytes: 48234496,
fingerprint: 'fp-docs-prune-confirm',
createdAt: 1_700_000_000_000,
nodeId: 1,
}),
});
});
await loginAs(page);
await page.getByRole('button', { name: /resources/i }).click();
await page.getByRole('button', { name: /Prune Unused Images/ }).click();
const pruneDialog = page.getByRole('alertdialog').filter({ hasText: 'Prune Sencho-managed images' });
await expect(pruneDialog).toBeVisible();
await expect(pruneDialog.getByText('ghcr.io/example/app:latest')).toBeVisible();
await pruneDialog.screenshot({
path: path.join(DOCS_IMAGES, 'resources', 'resources-prune-confirm.png'),
});
});
});