Build and Push Container / build-and-push (push) Successful in 26s
**Phase 01 — final verification pass (all criteria green)** - Verified M-1 fix in `src/terminal.js`: ArrowUp / ArrowDown(`>0`) / ArrowDown-else now route through `updateDisplay()` (`textContent`); no `innerHTML` sink touches `cmdLine`/`commandHistory`. - `grep -n innerHTML src/terminal.js` → 12 hits, all allowed (static prompt literals, `clear`/prank wipes, `outLine.innerHTML = output` gated on the static canned `neofetch` string). **C1 ✓** - XSS regression test at `tests/e2e/02-terminal.spec.js:156` present + passing; re-proved it is a real guard: re-injected the old `innerHTML` recall into `dist/terminal.*.js` → test failed (`#xss-marker` received 1); rebuild → byte-identical to `src/`. **C2 ✓** - `./build.sh` → `dist/` OK; `npm test` → **45 passed (0 failed)**; `bash .agents/validate.sh` → **exit 0** (suite + repo-hygiene gates). **C3 ✓** - Branch coverage of modified code: all 3 recall arms asserted (ArrowUp ×2 specs, ArrowDown `>0` in the existing history spec, ArrowDown-else → empty prompt in the new spec) → >90% gate met; no JS coverage/lint toolchain exists in the locked stack, so lint = `node --check src/*.js tests/e2e/*.js playwright.config.js` + `bash -n build.sh .agents/validate.sh` — clean. - C4 ✓ terminal/vim/achievements/menu specs all green; only the three recall branches changed. - Docs (step 6, full README/AGENTS review): README — run section now states the image already contains the built `dist/` (mount `dist/`, never `src/`); new “Serving & configuration” section for `nginx.conf`, incl. **empirically measured** (built image on 127.0.0.1:8124) that `/`, `/index.html` and hashed assets currently return *only* cache headers — the `add_header` non-merge gap = finding M-4/Phase 03; TLS/HSTS-at-edge rule; Quadlet section fixed (unit is not in this repo; Quadlet generates `homepage.service`, so `… start homepage.service`, and `systemctl enable` is a no-op for transient Quadlet units → `[Install]` + `daemon-reload` + linger); Development gained the Node 20+ floor, `npx playwright install chromium`, and `.agents/validate.sh`. AGENTS.md — added the validate.sh gate, flagged `./scripts/check-headers.sh` as not yet in the repo (Phase 03), and scoped the M-1 rule with the allowed-`innerHTML` list. - Notable: `.agents/PLAN.md` still says “44 tests” (3 places) and its §7 roadmap omits phases 04/05 — left untouched per the no-PLAN-edit rule; flag for the next PLAN review. No live-infra touched (temp podman image built/removed for the header measurement only). - Next pending phase: `02_simulate_fetch_commands`.
278 lines
13 KiB
JavaScript
278 lines
13 KiB
JavaScript
// @ts-check
|
|
const { test, expect } = require('@playwright/test');
|
|
const { openTerminal, typeCommand, currentPromptLine, terminalLines } = require('./helpers');
|
|
|
|
test.describe('Fake terminal — basic commands', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/');
|
|
await openTerminal(page);
|
|
});
|
|
|
|
test('hero click expands the terminal and shifts the hero', async ({ page }) => {
|
|
await expect(page.locator('.terminal-display')).toHaveClass(/grown/);
|
|
await expect(page.locator('.terminal-display')).toHaveClass(/opaque/);
|
|
await expect(page.locator('#hero')).toHaveClass(/shifted/);
|
|
});
|
|
|
|
test('initial boot lines are rendered', async ({ page }) => {
|
|
const lines = terminalLines(page);
|
|
await expect(lines.first()).toHaveText(/uptime/);
|
|
await expect(lines.nth(1)).toHaveText('optional');
|
|
});
|
|
|
|
test('uptime shows the load-average line', async ({ page }) => {
|
|
await typeCommand(page, 'uptime');
|
|
await expect(terminalLines(page).nth(-2)).toContainText('up 42 days');
|
|
});
|
|
|
|
test('ls lists the portfolio files', async ({ page }) => {
|
|
await typeCommand(page, 'ls');
|
|
const out = await terminalLines(page).nth(-2).textContent();
|
|
expect(out).toContain('index.html');
|
|
expect(out).toContain('nginx.conf');
|
|
expect(out).toContain('profile.jpeg');
|
|
});
|
|
|
|
test('pwd / whoami / hostname / id', async ({ page }) => {
|
|
await typeCommand(page, 'pwd');
|
|
await expect(terminalLines(page).nth(-2)).toHaveText(/\/home\/reese\/portfolio/);
|
|
await typeCommand(page, 'whoami');
|
|
await expect(terminalLines(page).nth(-2)).toHaveText('reese');
|
|
await typeCommand(page, 'hostname');
|
|
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
|
|
await typeCommand(page, 'id');
|
|
await expect(terminalLines(page).nth(-2)).toContainText('uid=1000(reese)');
|
|
});
|
|
|
|
test('date shows a plausible current date', async ({ page }) => {
|
|
await typeCommand(page, 'date');
|
|
const out = await terminalLines(page).nth(-2).textContent();
|
|
expect(out).toMatch(/\b(19|20)\d{2}\b/);
|
|
});
|
|
|
|
test('uname and cat /etc/os-release', async ({ page }) => {
|
|
await typeCommand(page, 'uname -a');
|
|
await expect(terminalLines(page).nth(-2)).toContainText('Linux homelab');
|
|
await typeCommand(page, 'cat /etc/os-release');
|
|
const out = await terminalLines(page).nth(-2).textContent();
|
|
expect(out).toContain('Fedora Linux');
|
|
expect(out).toContain('VERSION_ID');
|
|
});
|
|
|
|
test('echo prints its argument as plain text', async ({ page }) => {
|
|
await typeCommand(page, 'echo hello world');
|
|
await expect(terminalLines(page).nth(-2)).toHaveText('hello world');
|
|
});
|
|
|
|
test('ss shows listening ports', async ({ page }) => {
|
|
await typeCommand(page, 'ss');
|
|
const out = await terminalLines(page).nth(-2).textContent();
|
|
expect(out).toContain('0.0.0.0:22');
|
|
expect(out).toContain('0.0.0.0:443');
|
|
});
|
|
|
|
test('docker ps and podman ps', async ({ page }) => {
|
|
await typeCommand(page, 'docker ps');
|
|
const docker = await terminalLines(page).nth(-2).textContent();
|
|
expect(docker).toContain('gitea');
|
|
expect(docker).toContain('Up 42 days');
|
|
await typeCommand(page, 'podman ps');
|
|
const podman = await terminalLines(page).nth(-2).textContent();
|
|
expect(podman).toContain('homepage');
|
|
expect(podman).toContain('llama');
|
|
});
|
|
|
|
test('ps aux and df -h and free -h', async ({ page }) => {
|
|
await typeCommand(page, 'ps aux');
|
|
expect(await terminalLines(page).nth(-2).textContent()).toContain('llama-server');
|
|
await typeCommand(page, 'df -h');
|
|
expect(await terminalLines(page).nth(-2).textContent()).toContain('Filesystem');
|
|
await typeCommand(page, 'free -h');
|
|
expect(await terminalLines(page).nth(-2).textContent()).toContain('Mem:');
|
|
});
|
|
|
|
test('neofetch renders the ASCII logo and colored blocks', async ({ page }) => {
|
|
await typeCommand(page, 'neofetch');
|
|
const line = terminalLines(page).nth(-2);
|
|
await expect(line).toContainText('reese@homelab');
|
|
await expect(line).toContainText('RTX 4090');
|
|
await expect(line.locator('span[style*="color"]')).not.toHaveCount(0);
|
|
});
|
|
|
|
test('sl prints the locomotive', async ({ page }) => {
|
|
await typeCommand(page, 'sl');
|
|
expect(await terminalLines(page).nth(-2).textContent()).toContain('DD');
|
|
});
|
|
|
|
test('help lists the available commands', async ({ page }) => {
|
|
await typeCommand(page, 'help');
|
|
const out = await terminalLines(page).nth(-2).textContent();
|
|
expect(out).toContain('Available commands');
|
|
expect(out).toContain('vim');
|
|
expect(out).toContain('sudo');
|
|
});
|
|
|
|
test('systemctl, ip addr, cat ssh key', async ({ page }) => {
|
|
await typeCommand(page, 'systemctl list-units');
|
|
expect(await terminalLines(page).nth(-2).textContent()).toContain('gitea.service');
|
|
await typeCommand(page, 'ip addr show');
|
|
expect(await terminalLines(page).nth(-2).textContent()).toContain('eth0');
|
|
await typeCommand(page, 'cat ~/.ssh/id_ed25519.pub');
|
|
expect(await terminalLines(page).nth(-2).textContent()).toContain('ssh-ed25519');
|
|
});
|
|
|
|
test('unknown command produces no output and a fresh prompt', async ({ page }) => {
|
|
const before = await terminalLines(page).count();
|
|
await typeCommand(page, 'frobnicate --lots');
|
|
const after = await terminalLines(page).count();
|
|
expect(after).toBe(before + 1); // only the new prompt line
|
|
await expect(currentPromptLine(page)).toContainText(/[\$#]/);
|
|
});
|
|
});
|
|
|
|
test.describe('Fake terminal — state and special commands', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/');
|
|
await openTerminal(page);
|
|
});
|
|
|
|
test('history: ArrowUp/ArrowDown recall previous commands', async ({ page }) => {
|
|
await typeCommand(page, 'whoami');
|
|
await typeCommand(page, 'hostname');
|
|
await page.keyboard.press('ArrowUp');
|
|
await expect(currentPromptLine(page)).toContainText('hostname');
|
|
await page.keyboard.press('ArrowUp');
|
|
await expect(currentPromptLine(page)).toContainText('whoami');
|
|
await page.keyboard.press('ArrowDown');
|
|
await expect(currentPromptLine(page)).toContainText('hostname');
|
|
// executing the recalled command still works
|
|
await page.keyboard.press('Enter');
|
|
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
|
|
});
|
|
|
|
// Regression guard for audit finding M-1: history recall rebuilt the prompt
|
|
// line with innerHTML, so a markup payload came back to life as DOM on
|
|
// ArrowUp. Recall must stay on the textContent path.
|
|
test('history recall renders markup payloads as literal text (no DOM injection)', async ({ page }) => {
|
|
// Prompt text minus the leading '$ '/'# ' and the trailing cursor.
|
|
const typed = async () =>
|
|
(await currentPromptLine(page).textContent()).replace(/^[#$] /, '').trim();
|
|
|
|
// 1) A payload that would create a DOM node if innerHTML-injected.
|
|
await typeCommand(page, '<b id="xss-marker">pwned</b>');
|
|
await page.keyboard.press('ArrowUp');
|
|
await expect(page.locator('#xss-marker')).toHaveCount(0);
|
|
await expect(page.locator('.terminal-content b')).toHaveCount(0);
|
|
expect(await typed()).toBe('<b id="xss-marker">pwned</b>');
|
|
|
|
// 2) An event-handler payload must not execute. `data:,` is an empty
|
|
// data URI so the <img> fails to decode deterministically (no
|
|
// network dependency) and onerror would fire if injected as HTML.
|
|
// ArrowDown first returns to an empty prompt (historyIndex -1).
|
|
await page.evaluate(() => { window.__xssFired = false; });
|
|
await page.keyboard.press('ArrowDown');
|
|
expect(await typed()).toBe('');
|
|
await typeCommand(page, '<img src="data:," onerror="window.__xssFired=true">');
|
|
await page.keyboard.press('ArrowUp');
|
|
await page.waitForTimeout(200); // allow onerror to fire if vulnerable
|
|
expect(await page.evaluate(() => window.__xssFired)).toBe(false);
|
|
await expect(page.locator('img[onerror]')).toHaveCount(0);
|
|
expect(await typed()).toBe('<img src="data:," onerror="window.__xssFired=true">');
|
|
|
|
// 3) Benign recall + execute still works after the payloads: back to an
|
|
// empty prompt, type a real command, run it, recall it, run it again.
|
|
await page.keyboard.press('ArrowDown');
|
|
expect(await typed()).toBe('');
|
|
await typeCommand(page, 'whoami');
|
|
await expect(terminalLines(page).nth(-2)).toHaveText('reese');
|
|
await page.keyboard.press('ArrowUp');
|
|
expect(await typed()).toBe('whoami');
|
|
await page.keyboard.press('Enter');
|
|
await expect(terminalLines(page).nth(-2)).toHaveText('reese');
|
|
await expect(page.locator('#xss-marker')).toHaveCount(0);
|
|
});
|
|
|
|
test('Tab completes a command', async ({ page }) => {
|
|
await page.keyboard.type('host');
|
|
await page.keyboard.press('Tab');
|
|
await expect(currentPromptLine(page)).toContainText('hostname');
|
|
await page.keyboard.press('Enter');
|
|
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
|
|
});
|
|
|
|
test('Tab cycles between multiple matches', async ({ page }) => {
|
|
// strip the leading prompt ("$ "/"# ") from the line text
|
|
const typed = async () =>
|
|
(await currentPromptLine(page).textContent()).replace(/^[#$] /, '').trim();
|
|
|
|
await page.keyboard.type('cat ');
|
|
await page.keyboard.press('Tab');
|
|
const first = await typed();
|
|
await page.keyboard.press('Tab');
|
|
const second = await typed();
|
|
expect(first).not.toEqual(second);
|
|
expect(first).toMatch(/^cat /);
|
|
expect(second).toMatch(/^cat /);
|
|
});
|
|
|
|
test('clear empties the terminal and leaves one prompt line', async ({ page }) => {
|
|
await typeCommand(page, 'uptime');
|
|
expect(await terminalLines(page).count()).toBeGreaterThan(3);
|
|
await typeCommand(page, 'clear');
|
|
await expect(terminalLines(page)).toHaveCount(1);
|
|
await expect(currentPromptLine(page)).toContainText(/[\$#]/);
|
|
});
|
|
|
|
test('sudo su - switches the prompt to root (#)', async ({ page }) => {
|
|
await typeCommand(page, 'sudo su -');
|
|
const out = await terminalLines(page).nth(-2).textContent();
|
|
expect(out).toContain("I hope you know what you're doing");
|
|
const prompt = currentPromptLine(page).locator('.terminal-prompt');
|
|
await expect(prompt).toHaveText('#');
|
|
// root styling: the cursor is the red one, the line is red too
|
|
await expect(currentPromptLine(page).locator('.terminal-cursor')).toHaveClass(/red/);
|
|
await typeCommand(page, 'whoami');
|
|
// whoami still answers "reese" (the fake does not change identity)
|
|
await expect(terminalLines(page).nth(-2)).toHaveText('reese');
|
|
});
|
|
|
|
test('rm -rf / as a normal user is refused', async ({ page }) => {
|
|
await typeCommand(page, 'rm -rf /');
|
|
const out = await terminalLines(page).nth(-2).textContent();
|
|
expect(out).toContain('nice try');
|
|
// page must survive
|
|
await expect(page.locator('#hero')).toBeVisible();
|
|
});
|
|
|
|
test('rm -rf / as root triggers the destruction easter egg', async ({ page }) => {
|
|
await typeCommand(page, 'sudo su -');
|
|
await typeCommand(page, 'rm -rf /');
|
|
await expect(terminalLines(page).nth(-2)).toContainText('System destruction initiated');
|
|
await page.waitForTimeout(700); // destruction setTimeout is 500ms
|
|
await expect.poll(async () =>
|
|
page.evaluate(() => document.body.children.length)
|
|
).toBe(0);
|
|
});
|
|
|
|
test('exit as a normal user swaps the page for a login screen', async ({ page }) => {
|
|
await typeCommand(page, 'exit');
|
|
await expect(page.locator('body')).toContainText('homelab tty1');
|
|
await expect(page.locator('body')).toContainText('homelab login:');
|
|
const login = page.locator('body input');
|
|
await expect(login).toBeVisible();
|
|
await login.fill('reese');
|
|
await login.press('Enter');
|
|
await expect(page.locator('body')).toContainText('Access denied');
|
|
// a fresh login line with a new input appears
|
|
await expect(page.locator('body input')).toHaveCount(1);
|
|
});
|
|
|
|
test('backspace edits the current command', async ({ page }) => {
|
|
await page.keyboard.type('hostnamex');
|
|
await page.keyboard.press('Backspace');
|
|
await expect(currentPromptLine(page)).toContainText('hostname');
|
|
await page.keyboard.press('Enter');
|
|
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
|
|
});
|
|
});
|