// @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, 'pwned'); 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('pwned'); // 2) An event-handler payload must not execute. `data:,` is an empty // data URI so the 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, ''); 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(''); // 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'); }); });