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
126 changes: 120 additions & 6 deletions tests/helpers/panel-hosts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* variables -- the same knobs a non-default APP_NAME would use, so no adapter
* code is bypassed.
*
* WHAT IS DOUBLED, AND WHY. Two external programs the adapters talk to:
* WHAT IS DOUBLED, AND WHY. Three external programs the adapters talk to:
*
* docker a shim that keeps "is the panel container running" in a state
* directory, and on `compose up` loads the .env the way Docker
Expand All @@ -22,15 +22,25 @@
* sqlite3 forwards to Python's sqlite3 module (as the 3X-UI suite does), so
* Rebecca's database is a real SQLite file and every statement the
* adapter runs is executed for real.
* curl denied, and the attempt recorded. These suites are offline; see
* wget NET_DENY_SHIM below for what used to leak in through them.
*
* Everything else -- the adapters, the transaction engine, the library -- is
* the shipping code.
*
* `sqlite: false` means the host has NO sqlite3, and it is enforced rather than
* assumed: the child runs with a PATH from which every directory holding a
* sqlite3 has been removed (pathWithoutSqlite). The adapters gate on
* `command -v sqlite3`, so shadowing it with a shim that fails would still let
* the gate pass -- which is exactly how the runner's own sqlite3 changed the
* outcome of the manual-activation test.
*/

import { spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { chmodSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, readdirSync, existsSync, symlinkSync, rmSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { delimiter, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { build } from '../../tools/build.mjs';
Expand All @@ -54,9 +64,14 @@ export const PYTHON = workingProgram(process.platform === 'win32' ? ['python', '
export function bashRun(lines, { paths = {}, env = {} } = {}) {
const head = Object.entries(paths).map(([k, v]) =>
`${k}="$(cygpath -u ${sq(v)} 2>/dev/null || printf '%s' ${sq(v)})"; export ${k}`);
// A bare string is ONE line, not one line per character: `...lines` on a
// string spreads its characters, which turns the script into a column of
// one-character commands that dies under `set -e` before printing anything.
// Accept either shape, the way the suites' own `run` helpers do.
const body = [].concat(lines);
const script = ['set -Eeuo pipefail',
'unset RT_TEMPLATE RT_RELEASE_URL RT_RELEASE_DIR RT_ASSUME_YES RT_PANEL RT_SMOKE_URL XUI_DB_FOLDER',
...head, ...lines].join('\n');
...head, ...body].join('\n');
const r = spawnSync('bash', ['-c', script], {
cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...env },
});
Expand Down Expand Up @@ -209,6 +224,87 @@ finally:
PYEOF
`;

/* --- no network, and no sqlite3 the host happens to have --------------------
*
* NETWORK. `rt_complete_install` downloads the release pinned to the INSTALLED
* version whenever it judges the install short of what that version ships. The
* panel suites used to build a two-design payload, and RT_TEMPLATES_AVAILABLE
* names seventeen, so the install was ALWAYS short: on a runner with network the
* download succeeded, the store was rebuilt from the PUBLISHED release, and the
* test then compared it against the LOCALLY built artifact. The payloads are
* complete now, so that download is not reached -- these shims are the guard
* rail. curl and wget are on PATH in every fake host, they record the attempt
* and they fail, so a regression fails the suite loudly instead of silently
* validating a published release against the working tree.
*
* SQLITE3. `rt_panel_<panel>_db_ready` gates on `command -v sqlite3`, so a
* sqlite3 that merely FAILS is not enough -- the command must be unreachable.
* `sqlite: false` therefore scrubs every directory that holds a sqlite3 out of
* the child's PATH (see pathWithoutSqlite), rather than trusting that the host
* has none: ubuntu-latest ships a real one, which is what made the manual
* activation test pass on the authoring machine and fail on the runner. */
const NET_DENY_SHIM = `#!/usr/bin/env bash
# Test double: this suite has no network. Record the attempt, then fail.
set -u
if [ -n "\${RT_TEST_NET_LOG:-}" ]; then
printf '%s %s\\n' "\${0##*/}" "$*" >> "$RT_TEST_NET_LOG"
fi
printf 'row-template tests: network access is denied in this suite (%s %s)\\n' "\${0##*/}" "$*" >&2
exit 1
`;

/* A PATH that is the one given, minus every directory that holds a sqlite3
executable. Those directories are replaced by a mirror of themselves -- the
same entries, symlinked, without sqlite3 -- so nothing else on the host stops
resolving. Cached per input, because mirroring /usr/bin is ~1500 links.

A mirror is only trusted once it is stamped. A run killed by a signal (a
timeout, a cancelled job) never reaches the exit hook below, and a half-built
mirror would otherwise be reused on the next run -- and, if `bash` happened to
be the entry that never got linked, fail in a way that has nothing to do with
sqlite3. */
const mirrors = new Map();
function mirrorWithoutSqlite(dir) {
const dest = join(tmpdir(), `row-nosqlite-${sha256(dir).slice(0, 12)}`);
const stamp = join(dest, '.mirror-complete');
if (!existsSync(stamp)) {
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
let n = 0;
for (const entry of readdirSync(dir)) {
if (entry === 'sqlite3') continue;
try { symlinkSync(join(dir, entry), join(dest, entry)); n += 1; } catch { /* unreadable entry: skip it */ }
}
writeFileSync(stamp, `${n}\n`);
process.on('exit', () => rmSync(dest, { recursive: true, force: true }));
}
return dest;
}

export function pathWithoutSqlite(base = process.env.PATH || '') {
const hit = mirrors.get(base);
if (hit) return hit;
const out = [];
const seen = new Set();
for (const dir of base.split(delimiter).filter(Boolean)) {
if (seen.has(dir)) continue;
seen.add(dir);
if (!existsSync(join(dir, 'sqlite3'))) { out.push(dir); continue; }
out.push(mirrorWithoutSqlite(dir));
}
const path = out.join(delimiter);
mirrors.set(base, path);
return path;
}

/* The network attempts a fake host recorded. Empty means the suite never tried:
assert on it, so "this test needs no network" is checked, not assumed. */
export function netAttempts(base) {
const f = join(base, 'net.log');
if (!existsSync(f)) return '';
return readFileSync(f, 'utf8').trim();
}

/* The transaction engine locks with flock and refuses to run without it. Git
Bash on Windows has none, so -- exactly as tests/installer-transaction.test.mjs
does -- a minimal double provides flock's contract (an exclusive lock on the
Expand Down Expand Up @@ -242,6 +338,11 @@ function shims(base, { sqlite = true } = {}) {
mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, 'docker'), DOCKER_SHIM);
chmodSync(join(bin, 'docker'), 0o755);
// Both downloaders rt_fetch_one can pick, denied in every host.
for (const prog of ['curl', 'wget']) {
writeFileSync(join(bin, prog), NET_DENY_SHIM);
chmodSync(join(bin, prog), 0o755);
}
if (!HOST_HAS_FLOCK) {
writeFileSync(join(bin, 'flock'), FLOCK_SHIM);
chmodSync(join(bin, 'flock'), 0o755);
Expand Down Expand Up @@ -318,7 +419,13 @@ export function pasarguardHost(base, { running = true, env = PG_ENV, compose = t
const bin = shims(base, { sqlite: Boolean(db) });
return {
app, dataDir, cliPath, docker, bin, db: dbPath, envFile: join(app, '.env'),
paths: { RT_PG_APP_DIR: app, RT_PG_DATA_DIR: dataDir, RT_PG_CLI: cliPath, RT_TEST_DOCKER: docker, RT_TEST_BIN: bin },
paths: {
RT_PG_APP_DIR: app, RT_PG_DATA_DIR: dataDir, RT_PG_CLI: cliPath,
RT_TEST_DOCKER: docker, RT_TEST_BIN: bin, RT_TEST_NET_LOG: join(base, 'net.log'),
},
// No sqlite3 shim means the host has no sqlite3 as far as the code under
// test is concerned -- including any sqlite3 the RUNNER ships.
env: db ? {} : { PATH: pathWithoutSqlite() },
};
}

Expand Down Expand Up @@ -382,7 +489,14 @@ export function rebeccaHost(base, { running = true, sqlite = true, url, customDi
const bin = shims(base, { sqlite });
return {
app, dataDir, cliPath, docker, bin, db, envFile: join(app, '.env'),
paths: { RT_RB_APP_DIR: app, RT_RB_DATA_DIR: dataDir, RT_RB_CLI: cliPath, RT_TEST_DOCKER: docker, RT_TEST_BIN: bin },
paths: {
RT_RB_APP_DIR: app, RT_RB_DATA_DIR: dataDir, RT_RB_CLI: cliPath,
RT_TEST_DOCKER: docker, RT_TEST_BIN: bin, RT_TEST_NET_LOG: join(base, 'net.log'),
},
// `sqlite: false` must make sqlite3 UNAVAILABLE, not merely broken: the
// adapters gate on `command -v sqlite3`, so the runner's own sqlite3 has to
// be off PATH, not shadowed by a shim that exists and fails.
env: sqlite ? {} : { PATH: pathWithoutSqlite() },
};
}

Expand Down
24 changes: 19 additions & 5 deletions tests/installer-panel-pasarguard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';

import {
bashRun, makePayload, pasarguardHost, HOST_PREAMBLE, sha256, PG_ENV,
bashRun, makePayload, pasarguardHost, HOST_PREAMBLE, sha256, PG_ENV, netAttempts,
} from './helpers/panel-hosts.mjs';

import { availableTemplateIds } from '../tools/templates.mjs';

/* A COMPLETE payload -- every design the release registry names, not a sample.
A payload short of them makes `rt_complete_install` fetch the release pinned
to the INSTALLED version, and on a runner with network that pulled the
PUBLISHED release into the store, which was then compared against this
working tree's artifact. The host denies the network and records any attempt
(tests/helpers/panel-hosts.mjs); the life-cycle test asserts it never tried. */
const PAYLOAD_DIR = mkdtempSync(join(tmpdir(), 'row-pg-payload-'));
const PAYLOAD = makePayload(PAYLOAD_DIR, { ids: ['row', 'editorial'] });
const PAYLOAD = makePayload(PAYLOAD_DIR, { ids: availableTemplateIds() });
process.on('exit', () => rmSync(PAYLOAD_DIR, { recursive: true, force: true }));

/* A Row-Template install on a PasarGuard host, up to a generated page -- the
Expand All @@ -46,8 +53,9 @@ function withHost(opts, fn) {
try {
const host = pasarguardHost(base, opts);
const rt = join(base, 'rt');
const run = (lines) => bashRun([HOST_PREAMBLE, ...[].concat(lines)],
{ paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD } });
const run = (lines, env = {}) => bashRun([HOST_PREAMBLE, ...[].concat(lines)],
{ paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD },
env: { ...host.env, ...env } });
return fn({ base, host, rt, run });
} finally {
rmSync(base, { recursive: true, force: true });
Expand Down Expand Up @@ -404,5 +412,11 @@ test('install, verify, rebrand, switch design, roll back and uninstall on a Pasa
assert.equal(existsSync(rt), false, 'the install root is gone');
assert.equal(existsSync(join(base, 'row-template')), false, 'and the CLI');
assert.equal(existsSync(host.cliPath), true, 'PasarGuard itself is untouched');

/* The whole life cycle ran offline. curl and wget are denied in this host
and every attempt is recorded, so this is a measurement, not a promise:
the payload is complete, rt_complete_install never fires, and nothing the
suite does may depend on a published release. */
assert.equal(netAttempts(base), '', 'the life cycle must not touch the network');
});
});
68 changes: 62 additions & 6 deletions tests/installer-panel-rebecca.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,23 @@

import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { delimiter, join } from 'node:path';

import {
bashRun, makePayload, rebeccaHost, rebeccaRow, HOST_PREAMBLE, posix,
bashRun, makePayload, rebeccaHost, rebeccaRow, HOST_PREAMBLE, posix, netAttempts, pathWithoutSqlite,
} from './helpers/panel-hosts.mjs';

import { availableTemplateIds } from '../tools/templates.mjs';

/* A COMPLETE payload -- every design the release registry names, not a sample.
A payload short of them makes `rt_complete_install` fetch the release pinned
to the INSTALLED version, and on a runner with network that pulled the
PUBLISHED release into the store, which was then compared against this
working tree's artifact. The host denies the network and records any attempt
(tests/helpers/panel-hosts.mjs); the life-cycle test asserts it never tried. */
const PAYLOAD_DIR = mkdtempSync(join(tmpdir(), 'row-rb-payload-'));
const PAYLOAD = makePayload(PAYLOAD_DIR, { ids: ['row', 'editorial'] });
const PAYLOAD = makePayload(PAYLOAD_DIR, { ids: availableTemplateIds() });
process.on('exit', () => rmSync(PAYLOAD_DIR, { recursive: true, force: true }));

const SETUP = [
Expand All @@ -46,7 +53,8 @@ function withHost(opts, fn) {
const host = rebeccaHost(base, opts);
const rt = join(base, 'rt');
const run = (lines, env = {}) => bashRun([HOST_PREAMBLE, ...[].concat(lines)],
{ paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD }, env });
{ paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD },
env: { ...host.env, ...env } });
return fn({ base, host, rt, run });
} finally {
rmSync(base, { recursive: true, force: true });
Expand Down Expand Up @@ -422,6 +430,48 @@ test('a failed panel refresh leaves sub.html and the placed page exactly as they

/* --- manual activation --------------------------------------------------------- */

/* The runner's own sqlite3 must not leak into `sqlite: false`. The adapters
gate on `command -v sqlite3`, so a sqlite3 that merely fails would still open
the automatic path -- and that is exactly what happened: ubuntu-latest ships
a real sqlite3, so activation reported "auto" on the runner while this
authoring host, which has none, reported "manual". */
test('sqlite: false takes every sqlite3 off PATH, a real one included', () => {
const planted = mkdtempSync(join(tmpdir(), 'row-fake-sqlite-'));
try {
/* A stand-in for a runner that ships sqlite3, plus a second tool so the
substituted directory still has to carry everything else it held. */
for (const name of ['sqlite3', 'rowmarker']) {
writeFileSync(join(planted, name), `#!/bin/sh\necho "${name} on the host"\n`);
chmodSync(join(planted, name), 0o755);
}
const scrubbed = pathWithoutSqlite([planted, process.env.PATH].join(delimiter));
assert.equal(scrubbed.split(delimiter).includes(planted), false,
'a directory holding a sqlite3 is not on the child PATH');

/* `rc=0; … || rc=$?` rather than `… ; echo $?`, because bashRun runs under
`set -e` and a bare failing command would end the script before the echo. */
const seen = bashRun([
's=0; command -v sqlite3 >/dev/null 2>&1 || s=$?; echo "sqlite3=$s"',
'm=0; command -v rowmarker >/dev/null 2>&1 || m=$?; echo "rowmarker=$m"',
], { env: { PATH: scrubbed } });
assert.match(seen.out, /sqlite3=1/,
`command -v sqlite3 fails -- the gate the adapters ask\n${seen.code}\n${seen.err}`);
assert.match(seen.out, /rowmarker=0/, 'and the rest of the host still resolves');

// And through the host the suite actually builds.
withHost({ sqlite: false }, ({ run }) => {
const g = run([
's=0; command -v sqlite3 >/dev/null 2>&1 || s=$?; echo "found=$s"',
'rc=0; rt_panel_rebecca_db_ready || rc=$?; echo "db_ready=$rc"',
].join('\n'));
assert.match(g.out, /found=1/, 'the fake host offers no sqlite3 at all');
assert.match(g.out, /db_ready=1/, 'so the panel selection cannot be read here');
});
} finally {
rmSync(planted, { recursive: true, force: true });
}
});

test('without sqlite3, activation places the page and says exactly what to set', () => {
withHost({ sqlite: false }, ({ host, run }) => {
const before = last(host);
Expand Down Expand Up @@ -473,5 +523,11 @@ test('install, verify, rebrand, switch design, roll back and uninstall on a Rebe
assert.equal(existsSync(rt), false);
assert.equal(existsSync(join(base, 'row-template')), false);
assert.ok(readdirSync(host.dataDir).includes('db.sqlite3'), 'the database is still there');

/* The whole life cycle ran offline. curl and wget are denied in this host
and every attempt is recorded, so this is a measurement, not a promise:
the payload is complete, rt_complete_install never fires, and nothing the
suite does may depend on a published release. */
assert.equal(netAttempts(base), '', 'the life cycle must not touch the network');
});
});
Loading