Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions web/tests/e2e/fixtures/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -731,6 +733,85 @@ export async function deleteServerE2e(sid: number): Promise<{ sid: number; delet
}
}

async function runBanDemoShim(payload: Record<string, unknown>): Promise<unknown> {
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<void>((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<void> {
await runBanDemoShim({ bid, remove: true });
}

async function runAnnouncementsHelper(
stdin: string | null,
extraArgs: string[],
Expand Down
149 changes: 149 additions & 0 deletions web/tests/e2e/scripts/seed-ban-demo-e2e.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php
// SourceBans++ (c) 2014-2026 SourceBans++ Dev Team
// Licensed under the Elastic License 2.0.
// See LICENSE.txt for the full license text and THIRD-PARTY-NOTICES.txt for attributions.
/**
* E2E ban-demo seeder.
*
* Writes an opaque payload under `SB_DEMOS` and inserts the matching
* `:prefix_demos` row (`demtype = 'B'`) so `getdemo.php?type=B&id=<bid>`
* 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");
90 changes: 90 additions & 0 deletions web/tests/e2e/specs/flows/ban-demo-download.spec.ts
Original file line number Diff line number Diff line change
@@ -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=<bid>`, 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);
}
});
});
Loading
Loading