diff --git a/web/tests/e2e/fixtures/db.ts b/web/tests/e2e/fixtures/db.ts index c1ad17706..d19d0023b 100644 --- a/web/tests/e2e/fixtures/db.ts +++ b/web/tests/e2e/fixtures/db.ts @@ -49,6 +49,8 @@ const SEED_SERVER_GROUP_INSIDE_CONTAINER = '/var/www/html/web/tests/e2e/scripts/seed-server-group-e2e.php'; const DELETE_SERVER_INSIDE_CONTAINER = '/var/www/html/web/tests/e2e/scripts/delete-server-e2e.php'; +const SEED_BAN_DEMO_INSIDE_CONTAINER = + '/var/www/html/web/tests/e2e/scripts/seed-ban-demo-e2e.php'; const CLEAR_TEST_EMAIL_THROTTLE_INSIDE_CONTAINER = '/var/www/html/web/tests/e2e/scripts/clear-test-email-throttle-e2e.php'; const SEED_SYSTEM_LOG_INSIDE_CONTAINER = @@ -731,6 +733,85 @@ export async function deleteServerE2e(sid: number): Promise<{ sid: number; delet } } +async function runBanDemoShim(payload: Record): Promise { + const inContainer = process.env.E2E_IN_CONTAINER === '1'; + const cmd = inContainer ? 'php' : 'docker'; + const cmdArgs = inContainer + ? [SEED_BAN_DEMO_INSIDE_CONTAINER] + : ['compose', 'exec', '-T', 'web', 'php', SEED_BAN_DEMO_INSIDE_CONTAINER]; + + const child = execFile(cmd, cmdArgs, { + maxBuffer: 8 * 1024 * 1024, + cwd: inContainer ? undefined : process.cwd(), + }); + + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); }); + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); + + child.stdin?.write(JSON.stringify(payload)); + child.stdin?.end(); + + await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error( + `seed-ban-demo-e2e.php exited ${code}\n` + + `stdout:\n${stdout}\nstderr:\n${stderr}`, + )); + }); + }); + + const trimmed = stdout.trim(); + if (trimmed === '') { + throw new Error(`seed-ban-demo-e2e.php: empty stdout\nstderr:\n${stderr}`); + } + try { + return JSON.parse(trimmed); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `seed-ban-demo-e2e.php: malformed stdout (${msg})\nstdout:\n${trimmed}\nstderr:\n${stderr}`, + ); + } +} + +/** + * Attach a demo to an already-seeded ban via the `seed-ban-demo-e2e.php` + * shim: writes an opaque payload under `SB_DEMOS` and inserts the + * matching `:prefix_demos` row (`demtype = 'B'`) so `getdemo.php` and + * the banlist row / drawer "Download demo" affordances have something + * real to serve. Pair with `removeBanDemoE2e` in a `finally`. + */ +export async function seedBanDemoE2e( + bid: number, + filename: string, + origname: string, +): Promise<{ bid: number; filename: string; origname: string }> { + const parsed = await runBanDemoShim({ bid, filename, origname }) as { + bid?: unknown; filename?: unknown; origname: string; + }; + if (typeof parsed.bid !== 'number' || typeof parsed.filename !== 'string') { + throw new Error(`seed-ban-demo-e2e.php: missing bid/filename keys in ${JSON.stringify(parsed)}`); + } + return parsed as { bid: number; filename: string; origname: string }; +} + +/** + * Remove a demo attached by `seedBanDemoE2e` (file + `:prefix_demos` + * row). Goes through the shim, not `bans.remove_demo`: the web server + * usually can't unlink files the CLI shim wrote into a bind-mounted + * `web/demos/`, so the JSON action fails there. + */ +export async function removeBanDemoE2e(bid: number): Promise { + await runBanDemoShim({ bid, remove: true }); +} + async function runAnnouncementsHelper( stdin: string | null, extraArgs: string[], diff --git a/web/tests/e2e/scripts/seed-ban-demo-e2e.php b/web/tests/e2e/scripts/seed-ban-demo-e2e.php new file mode 100644 index 000000000..800357cf3 --- /dev/null +++ b/web/tests/e2e/scripts/seed-ban-demo-e2e.php @@ -0,0 +1,149 @@ +` + * and the banlist row / drawer "Download demo" affordances have + * something real to serve. Mirrors `Sbpp\Tests\Synthesizer`'s demo + * insert shape (`INSERT INTO :prefix_demos (demid, demtype, filename, + * origname)`), but scoped to a single caller-supplied bid instead of + * a whole synthetic dataset. + * + * Why a PHP shim instead of writing the file straight from Playwright + * (as upstream sbpp/sourcebans-pp's `ban-demo-download.spec.ts` does + * via `node:fs/promises` against `resolve(process.cwd(), '../../demos')`): + * this suite runs in two modes (`E2E_IN_CONTAINER=1` inside the web + * container, or host-side through `docker compose exec`), and only + * the PHP side reliably resolves `SB_DEMOS` the same way `getdemo.php` + * does in both modes. A Node-side relative path guess would silently + * diverge from the real serving directory under host-side execution. + * + * Same e2e-only guardrail as the sibling shims: refuses any DB other + * than the e2e schema (default `sourcebans_e2e`). + * + * Usage (inside the web container): + * + * echo '{"bid":42,"filename":"<32-hex-or-any-basename>","origname":"evidence.dem"}' | php seed-ban-demo-e2e.php + * + * `filename` is the on-disk basename (caller picks it — tests use a + * deterministic per-worker/per-retry value so parallel runs and + * retries never collide on the same file); `origname` is what the + * browser should see as the downloaded filename. Both are basename()'d + * server-side before touching the filesystem, mirroring the same + * defensiveness `getdemo.php` / `UploadHandler` apply to a DB-sourced + * filename. + * + * Caller responsibility: the bid must already exist (seed the ban via + * `seedBanViaApi` first). + * + * Cleanup: pipe `{"bid":42,"remove":true}` to the same shim. It unlinks + * the on-disk file and deletes the `:prefix_demos` row. Cleanup runs + * here rather than through `bans.remove_demo` because this shim writes + * the file as the CLI user, while the web server (www-data) usually + * can't unlink from a bind-mounted `web/demos/` owned by the host user, + * so the JSON action fails with "Unable to delete demo file from disk." + * + * Output on stdout (single JSON line): + * + * {"bid":42,"filename":"...","origname":"evidence.dem"} + * {"bid":42,"removed":true} (remove mode) + */ + +declare(strict_types=1); + +if (PHP_SAPI !== 'cli') { + fwrite(STDERR, "seed-ban-demo-e2e.php must run on the CLI.\n"); + exit(2); +} + +if (!getenv('DB_NAME')) { + putenv('DB_NAME=sourcebans_e2e'); + $_ENV['DB_NAME'] = 'sourcebans_e2e'; + $_SERVER['DB_NAME'] = 'sourcebans_e2e'; +} + +if (getenv('DB_NAME') === 'sourcebans_test' || getenv('DB_NAME') === 'sourcebans') { + fwrite(STDERR, "refusing to seed a ban demo against DB_NAME=" . getenv('DB_NAME') + . ": this script must target a dedicated e2e DB (default sourcebans_e2e).\n"); + exit(2); +} + +require __DIR__ . '/../../bootstrap.php'; + +if (!isset($GLOBALS['PDO'])) { + $GLOBALS['PDO'] = new \Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); +} + +$payload = stream_get_contents(STDIN); +if ($payload === false || trim($payload) === '') { + fwrite(STDERR, "seed-ban-demo-e2e.php: empty stdin payload.\n"); + exit(2); +} + +$decoded = json_decode($payload, true); +if (!is_array($decoded)) { + fwrite(STDERR, "seed-ban-demo-e2e.php: stdin is not a JSON object.\n"); + exit(2); +} + +$bid = (int) ($decoded['bid'] ?? 0); +if ($bid <= 0) { + fwrite(STDERR, "seed-ban-demo-e2e.php: missing or invalid `bid` in payload.\n"); + exit(2); +} + +if (!empty($decoded['remove'])) { + $row = $GLOBALS['PDO'] + ->query("SELECT `filename` FROM `:prefix_demos` WHERE `demid` = ? AND `demtype` = 'B'") + ->single([$bid]); + if ($row) { + $onDisk = basename((string) $row['filename']); + $path = SB_DEMOS . DIRECTORY_SEPARATOR . $onDisk; + if ($onDisk !== '' && is_file($path) && !unlink($path)) { + fwrite(STDERR, "seed-ban-demo-e2e.php: failed to unlink $path.\n"); + exit(2); + } + $GLOBALS['PDO']->query("DELETE FROM `:prefix_demos` WHERE `demid` = ? AND `demtype` = 'B'"); + $GLOBALS['PDO']->execute([$bid]); + } + fwrite(STDOUT, json_encode(['bid' => $bid, 'removed' => (bool) $row]) . "\n"); + exit(0); +} + +$filename = basename((string) ($decoded['filename'] ?? '')); +if ($filename === '') { + fwrite(STDERR, "seed-ban-demo-e2e.php: missing or invalid `filename` in payload.\n"); + exit(2); +} + +$origname = (string) ($decoded['origname'] ?? $filename); + +if (!is_dir(SB_DEMOS)) { + if (!@mkdir(SB_DEMOS, 0775, true) && !is_dir(SB_DEMOS)) { + fwrite(STDERR, "seed-ban-demo-e2e.php: SB_DEMOS (" . SB_DEMOS . ") does not exist and could not be created.\n"); + exit(2); + } +} + +$path = SB_DEMOS . DIRECTORY_SEPARATOR . $filename; +if (file_put_contents($path, "sourcebans++ e2e demo payload\n") === false) { + fwrite(STDERR, "seed-ban-demo-e2e.php: failed to write demo payload to $path.\n"); + exit(2); +} + +$GLOBALS['PDO']->query( + 'REPLACE INTO `:prefix_demos` (`demid`, `demtype`, `filename`, `origname`) VALUES (?, ?, ?, ?)' +); +$GLOBALS['PDO']->execute([$bid, 'B', $filename, $origname]); + +$result = [ + 'bid' => $bid, + 'filename' => $filename, + 'origname' => $origname, +]; + +fwrite(STDOUT, json_encode($result, JSON_UNESCAPED_SLASHES) . "\n"); diff --git a/web/tests/e2e/specs/flows/ban-demo-download.spec.ts b/web/tests/e2e/specs/flows/ban-demo-download.spec.ts new file mode 100644 index 000000000..99944e2d5 --- /dev/null +++ b/web/tests/e2e/specs/flows/ban-demo-download.spec.ts @@ -0,0 +1,90 @@ +/** + * Port of upstream sbpp/sourcebans-pp's `ban-demo-download.spec.ts` + * (issue #1554, already carried on this fork by `fix/issue-1554` / + * PR #26 — the drawer's "Download demo" affordance itself). Upstream + * added E2E coverage for that surface which this fork didn't have a + * matching spec for; this ports it, adapted to seed the demo through + * the `seed-ban-demo-e2e.php` shim (`seedBanDemoE2e`) instead of + * writing straight into `../../demos` from Node — this suite runs in + * two modes (`E2E_IN_CONTAINER=1` inside the web container, or + * host-side via `docker compose exec`), and only the PHP side + * reliably resolves `SB_DEMOS` the same way `getdemo.php` does in + * both modes. + * + * What this locks in + * ------------------ + * Ban evidence downloads must be reachable from BOTH the banlist row + * (`[data-testid="row-action-demo-download"]` desktop / + * `-mobile` on narrow viewports) AND the modern player drawer + * (`[data-testid="drawer-demo-download"]`) once a ban has an attached + * demo — both hrefs point at `getdemo.php?type=B&id=`, and + * actually clicking the drawer's link must produce a real browser + * download carrying the demo's `origname`. + */ + +import { createHash } from 'node:crypto'; + +import { expect, test } from '../../fixtures/auth.ts'; +import { expectNoCriticalA11y } from '../../fixtures/axe.ts'; +import { removeBanDemoE2e, seedBanDemoE2e } from '../../fixtures/db.ts'; +import { seedBanViaApi } from '../../fixtures/seeds.ts'; + +test.describe('flow: ban demo download (#1554)', () => { + test('row and drawer expose the attached demo download', async ({ page, isMobile }, testInfo) => { + const uniq = `${testInfo.workerIndex}${testInfo.retry}${Date.now()}`; + // 32-hex basename, same shape as UploadHandler's renameToHash + // output: if the finally-block cleanup never runs (the page died + // before the panel JS loaded), `./sbpp.sh db-reset`'s MD5-name + // sweep of web/demos/ still removes the orphan. + const filename = createHash('md5').update(`e2e-demo-1554-${uniq}`).digest('hex'); + const originalName = 'evidence-1554.dem'; + + const seeded = await seedBanViaApi(page, { + nickname: `e2e-demo-1554-w${testInfo.workerIndex}-r${testInfo.retry}`, + steam: `STEAM_0:1:${6_554_000 + testInfo.workerIndex * 10 + testInfo.retry}`, + reason: 'e2e demo download', + }); + + await seedBanDemoE2e(seeded.bid, filename, originalName); + + try { + await page.goto('/index.php?p=banlist'); + + const rowTestId = isMobile ? 'ban-card' : 'ban-row'; + const downloadTestId = isMobile + ? 'row-action-demo-download-mobile' + : 'row-action-demo-download'; + const row = page.locator(`[data-testid="${rowTestId}"][data-id="${seeded.bid}"]`); + const rowDownload = row.locator(`[data-testid="${downloadTestId}"]`); + await expect(rowDownload).toBeVisible(); + await expect(rowDownload).toHaveAttribute( + 'href', + `getdemo.php?type=B&id=${seeded.bid}`, + ); + + await row.locator('[data-testid="drawer-trigger"]').click(); + + const drawer = page.locator('#drawer-root'); + await expect(drawer).toHaveAttribute('data-drawer-open', 'true'); + await expect(drawer).not.toHaveAttribute('data-loading', /.+/); + + const drawerDownload = drawer.locator('[data-testid="drawer-demo-download"]'); + await expect(drawerDownload).toBeVisible(); + await expect(drawerDownload).toHaveAttribute( + 'href', + `getdemo.php?type=B&id=${seeded.bid}`, + ); + await expectNoCriticalA11y(page, testInfo, { include: ['#drawer-root'] }); + + const downloadPromise = page.waitForEvent('download'); + await drawerDownload.click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(originalName); + } finally { + // Through the shim, not bans.remove_demo: www-data usually + // can't unlink the CLI-written file from a bind-mounted + // web/demos/, so the JSON action would fail (silently here). + await removeBanDemoE2e(seeded.bid); + } + }); +}); diff --git a/web/tests/e2e/specs/flows/punishment-comment-actions.spec.ts b/web/tests/e2e/specs/flows/punishment-comment-actions.spec.ts new file mode 100644 index 000000000..c632570d9 --- /dev/null +++ b/web/tests/e2e/specs/flows/punishment-comment-actions.spec.ts @@ -0,0 +1,167 @@ +/** + * Port of upstream sbpp/sourcebans-pp's `punishment-comment-actions.spec.ts` + * (issue #1544 — every ban and comm block exposes Add comment, while + * each existing comment exposes the author/owner-appropriate Edit / + * Delete actions in both the desktop disclosure and the player + * drawer), adapted to this fork's architecture. + * + * Upstream's version drives a dedicated `#banlist-comment-form` PAGE + * (`row-action-comment-add` navigates to a standalone editor surface + * built from their extracted `punishment-comment-editor.tpl` partial). + * This fork never grew that partial — per `page_bans.tpl`'s own + * docblock ("Add / Edit comments open the player drawer + * (data-comment-compose); there is no `?comment=` editor on this + * page"), comment add/edit is entirely drawer-driven: + * + * - The banlist/commslist row's inline disclosure carries an + * "Add Comment" button (`[data-testid="ban-comment-add"]` / + * `[data-testid="comm-comment-add"]`) with `data-drawer-bid` / + * `data-drawer-cid` + `data-comment-compose="add"`. Clicking it + * opens the player drawer AND pre-opens the comment composer + * form in one step (`loadDrawer(key, {mode: 'add'})` -> + * `openCommentComposer()` in theme.js). + * - The composer is `[data-testid="drawer-comment-form"]` with a + * `textarea[name="ctext"]` and a `button[type="submit"]`; submit + * calls `bans.add_comment` / `bans.edit_comment` then reloads the + * drawer, which in turn patches the row's inline disclosure in + * place (no page navigation either way). + * - Existing comments in the disclosure carry an edit trigger + * (`[data-comment-compose="edit"][data-comment-cid=""]`, + * gated on `can_edit`) and a delete trigger + * (`[data-action="comment-delete"][data-cid=""]`, gated on + * `can_delete`) — both wired through the shared + * `comment-actions.js` dispatcher. The drawer's own comment list + * mirrors the same delete trigger plus a `[data-comment-edit]` + * attribute for its edit button. + * + * The contract this locks in is the same as upstream's: Add comment + * is reachable from the row, the new comment round-trips into BOTH + * the inline disclosure and the drawer, and both surfaces expose + * Edit + Delete for a comment the logged-in admin (Owner storage + * state) is allowed to act on. + */ + +import { expect, test } from '../../fixtures/auth.ts'; +import { expectNoCriticalA11y } from '../../fixtures/axe.ts'; +import { seedBanViaApi, seedCommViaApi } from '../../fixtures/seeds.ts'; + +test.describe('flow: punishment comment actions (#1544)', () => { + test('ban Add comment flow restores per-comment Edit and Delete', async ({ page, isMobile }, testInfo) => { + test.skip(isMobile, 'inline disclosure assertions are desktop-only, matching page_bans.tpl'); + + const seeded = await seedBanViaApi(page, { + nickname: `e2e-ban-comment-actions-w${testInfo.workerIndex}-r${testInfo.retry}`, + steam: `STEAM_0:1:${6_544_000 + testInfo.workerIndex * 100 + testInfo.retry}`, + reason: 'e2e comment actions', + }); + const comment = `ban comment action ${testInfo.workerIndex}`; + + await page.goto('/index.php?p=banlist'); + const row = page.locator(`[data-testid="ban-row"][data-id="${seeded.bid}"]`); + const disclosure = row.locator('[data-testid="ban-comments-inline"]'); + await disclosure.locator('[data-testid="ban-comments-toggle"]').click(); + + const addBtn = disclosure.locator('[data-testid="ban-comment-add"]'); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + + const drawer = page.locator('#drawer-root'); + await expect(drawer).toHaveAttribute('data-drawer-open', 'true'); + await expect(drawer).not.toHaveAttribute('data-loading', /.+/); + + const composer = drawer.locator('[data-testid="drawer-comment-form"]'); + await expect(composer).toBeVisible(); + await expectNoCriticalA11y(page, testInfo, { include: ['#drawer-root'] }); + await composer.locator('textarea[name="ctext"]').fill(comment); + + const addResponsePromise = page.waitForResponse( + (response) => + response.url().includes('api.php') + && response.request().method() === 'POST' + && response.status() === 200 + && (response.request().postData() ?? '').includes('"bans.add_comment"'), + ); + await composer.locator('button[type="submit"]').click(); + const addEnvelope = await (await addResponsePromise).json(); + expect(addEnvelope.ok, `bans.add_comment must succeed: ${JSON.stringify(addEnvelope)}`).toBe( + true, + ); + + // The drawer reload patches the row's inline disclosure in place — + // no navigation, so re-query the same locators rather than reload. + const inlineText = disclosure.locator('[data-testid="ban-comment-text"]'); + await expect(inlineText).toContainText(comment); + const inlineItem = disclosure.locator('[data-testid="ban-comment-item"]').filter({ hasText: comment }); + await expect(inlineItem.locator('[data-comment-compose="edit"]')).toBeVisible(); + await expect(inlineItem.locator('[data-action="comment-delete"]')).toBeVisible(); + + const drawerComments = drawer.locator('[data-testid="drawer-comments"]'); + await expect(drawerComments).toContainText(comment); + const drawerItem = drawerComments.locator('li').filter({ hasText: comment }); + await expect(drawerItem.locator('[data-comment-edit]')).toBeVisible(); + await expect(drawerItem.locator('[data-action="comment-delete"]')).toBeVisible(); + await expectNoCriticalA11y(page, testInfo, { include: ['#drawer-root'] }); + }); + + test('comm-block Add comment uses the shared drawer composer and action set', async ({ page, isMobile }, testInfo) => { + test.skip(isMobile, 'inline disclosure assertions are desktop-only, matching page_comms.tpl'); + + const seeded = await seedCommViaApi(page, { + nickname: `e2e-comm-comment-actions-w${testInfo.workerIndex}-r${testInfo.retry}`, + steam: `STEAM_0:0:${6_544_100 + testInfo.workerIndex * 100 + testInfo.retry}`, + reason: 'e2e comm comment actions', + type: 1, + }); + const comment = `comm comment action ${testInfo.workerIndex}`; + + await page.goto('/index.php?p=commslist'); + const row = page.locator('[data-testid="comm-row"]').filter({ hasText: seeded.steam }); + const cid = Number(await row.getAttribute('data-id')); + expect(cid).toBeGreaterThan(0); + + const disclosure = row.locator('[data-testid="comm-comments-inline"]'); + await disclosure.locator('[data-testid="comm-comments-toggle"]').click(); + + const addBtn = disclosure.locator('[data-testid="comm-comment-add"]'); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + + const drawer = page.locator('#drawer-root'); + await expect(drawer).toHaveAttribute('data-drawer-open', 'true'); + await expect(drawer).not.toHaveAttribute('data-loading', /.+/); + + const composer = drawer.locator('[data-testid="drawer-comment-form"]'); + await expect(composer).toBeVisible(); + await expectNoCriticalA11y(page, testInfo, { include: ['#drawer-root'] }); + await composer.locator('textarea[name="ctext"]').fill(comment); + + const addResponsePromise = page.waitForResponse( + (response) => + response.url().includes('api.php') + && response.request().method() === 'POST' + && response.status() === 200 + && (response.request().postData() ?? '').includes('"bans.add_comment"'), + ); + await composer.locator('button[type="submit"]').click(); + const addEnvelope = await (await addResponsePromise).json(); + // Same bans.add_comment action as the ban-focal test above — the + // drawer composer reuses it for comm-block comments too, keyed by + // the `ctype: 'C'` param (see submitCommentForm in theme.js). + expect(addEnvelope.ok, `bans.add_comment (ctype=C) must succeed: ${JSON.stringify(addEnvelope)}`).toBe( + true, + ); + + const inlineText = disclosure.locator('[data-testid="comm-comment-text"]'); + await expect(inlineText).toContainText(comment); + const inlineItem = disclosure.locator('[data-testid="comm-comment-item"]').filter({ hasText: comment }); + await expect(inlineItem.locator('[data-comment-compose="edit"]')).toBeVisible(); + await expect(inlineItem.locator('[data-action="comment-delete"]')).toBeVisible(); + + const drawerComments = drawer.locator('[data-testid="drawer-comments"]'); + await expect(drawerComments).toContainText(comment); + const drawerItem = drawerComments.locator('li').filter({ hasText: comment }); + await expect(drawerItem.locator('[data-comment-edit]')).toBeVisible(); + await expect(drawerItem.locator('[data-action="comment-delete"]')).toBeVisible(); + await expectNoCriticalA11y(page, testInfo, { include: ['#drawer-root'] }); + }); +});