Files
homepage/tests/e2e/02-terminal.spec.js
T
ducoterra bcfaa1cac6
Build and Push Container / build-and-push (push) Successful in 26s
phase: 02_simulate_fetch_commands
All green. Final report:

**Phase 02 — final verification pass** (both tasks already in `.agents/phases/complete/02_simulate_fetch_commands/`)
- Verified: no `fetch(` remains in `src/` (grep exit 1) or in built `dist/*.js`
- Verified: `simulatedCurlOutput()` / `simulatedWgetOutput()` in `src/terminal-commands.js` (lines 63/73), wired into `src/terminal.js` curl/wget branches
- Verified: regression test `curl/wget are simulated — no real network request leaves the origin` in `tests/e2e/02-terminal.spec.js` — asserts simulated bodies, `web_navigator` toast + localStorage persistence, and zero non-`baseURL` requests
- Tests: `./build.sh` → OK; `npm test` → **46 passed, 0 failed** (≥46 required); `bash .agents/validate.sh` → **exit 0** (E2E + repo-hygiene gates)
- No linter configured beyond validate.sh (vanilla JS, LOCKED no-toolchain)
- Completion criteria: ①no `fetch(` ✅ ②simulators present ✅ ③regression tests present & passing ✅ ④build+test 46 green ✅ ⑤no behavior change (page/terminal/vim/achievements/menu suites all green) ✅
- Documentation: full README/AGENTS.md review — both already updated in working tree and consistent with the project (simulated curl/wget, validate.sh gate, Podman build flow vs Dockerfile, Node ≥20 matches `@playwright/test` engines, M-4 header gap correctly documented as pending Phase 03) — **verified consistent, no changes needed**
- Note: `PLAN.md` still says "44 tests" (now 46) but PLAN is locked — flagged for the harness/user, not edited
- Next pending phase: `03_nginx_security_headers`
2026-10-22 23:14:49 -04:00

311 lines
15 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);
});
// Regression guard for audit finding M-2: curl/wget used to `fetch()` the
// URL typed by the visitor — a client-side request-forgery primitive.
// They must render simulated output and touch the network zero times.
test('curl/wget are simulated — no real network request leaves the origin', async ({ page }, testInfo) => {
// Track every request that is NOT aimed at the test origin. The origin
// is read from `baseURL` (playwright.config.js) so the two cannot drift.
const ORIGIN = testInfo.project.use.baseURL || 'http://127.0.0.1:8123';
const external = [];
page.on('request', (r) => {
if (!r.url().startsWith(ORIGIN)) external.push(r.url());
});
await typeCommand(page, 'curl https://example.com/hello');
// achievement fires on the first curl/wget (toasts auto-remove ~4.3 s)
await expect(page.locator('.toast').filter({ hasText: 'Web Navigator' })).toBeVisible();
const curlLine = terminalLines(page).nth(-2);
await expect(curlLine).toContainText('simulated body for https://example.com/hello');
await typeCommand(page, 'wget https://example.org/file.txt');
const wgetLine = terminalLines(page).nth(-2);
await expect(wgetLine).toContainText('No real network request was made');
await expect(wgetLine).toContainText('https://example.org/file.txt');
// Let the simulated 400 ms response delay (and any pre-fix fetch) settle
// before asserting that nothing ever left the origin.
await page.waitForTimeout(800);
expect(external, `requests left the origin: ${external.join(', ')}`).toEqual([]);
// The achievement itself is persisted, not just toasted.
const stored = await page.evaluate(k => JSON.parse(localStorage.getItem(k) || '[]'), 'reese-terminal-achievements');
expect(stored).toContain('web_navigator');
});
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');
});
});