test: run the ECIES vectors in CI, and give the oracles their packages
The first real CI run found two things, which is the argument for having turned it on. **The ECIES vector tests were never in the gate.** test/ecies.test.mjs was added with the handoff and never listed in the test-js target, so the six tests pinning the one place a secret crosses the server — Go against JS against the C generator — had never run anywhere but by hand. Now in: `make test-js` goes from 29 tests to 35. **The oracles had no packages on the runner**, so three tests failed with a bare `ModuleNotFoundError: No module named 'numpy'`. The shared box is immutable-boot, so installing them there means a launch-template version and an instance replacement. The repo can do it for itself instead, and that is also the better answer: test/requirements.txt pins the versions, so the oracle cannot drift under us between runs. `make oracles` builds a .venv; test/oracle.mjs resolves the interpreter as $CONTRA_PYTHON, then .venv, then python3 — so a machine whose system python3 already has the packages needs no venv and notices nothing. Neither gate is softened. A missing oracle does NOT skip: a skipped oracle is indistinguishable from a passing one, and these are what stop the QR and PDF writers being checked only against themselves — which is how the reversed format-information bits were caught, a fault a decode-only check would have called "does not scan" and left us guessing. What changed is that a missing package now fails with the command that fixes it instead of a traceback that says nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+123
−20
.amadan/ci.d/35-js-test+17 −6.gitignore+4 −0Makefile+21 −2test/kit.test.mjs+2 −5test/oracle.mjs+60 −0test/qr.test.mjs+2 −7test/requirements.txt+17 −0
CI failed — run details
CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
? github.com/carlosframework/contra/cmd/contra [no test files]
? github.com/carlosframework/contra/internal/contra [no test files]
? github.com/carlosframework/contra/internal/contra/auth [no test files]
ok github.com/carlosframework/contra/internal/contra/crypto (cached)
? github.com/carlosframework/contra/internal/contra/icons [no test files]
? github.com/carlosframework/contra/internal/contra/mail [no test files]
? github.com/carlosframework/contra/internal/contra/model [no test files]
? github.com/carlosframework/contra/internal/contra/store [no test files]
? github.com/carlosframework/contra/internal/contra/vault [no test files]
ok github.com/carlosframework/contra/internal/contratest (cached)
=== js-test ===
python3 -m venv .venv
The virtual environment was not created successfully because ensurepip is not
available. On Debian/Ubuntu systems, you need to install the python3-venv
package using the following command.
apt install python3.12-venv
You may need to use sudo with that command. After installing the python3-venv
package, recreate your virtual environment.
Failing command: /var/lib/amadan-runner/w2/repos/carlos__contra/wt/web__shared-vaults/.venv/bin/python3
make: *** [Makefile:114: oracles] Error 1
amadan: step js-test failed: exit status 2
amadan: job failed at step js-test — its output is above, not at the tail of this log
diff --git a/.amadan/ci.d/35-js-test b/.amadan/ci.d/35-js-test| index adfd98c..38be170 100755 |
| --- a/.amadan/ci.d/35-js-test |
| +++ b/.amadan/ci.d/35-js-test |
| @@ -1,10 +1,21 @@ |
| #!/bin/sh |
| -# One step of CONTRA's CI. It runs a Makefile target rather than its own copy |
| -# of the commands, so CI and the local gate stay ONE definition: `make ci` |
| -# runs these same targets in this same order, and `make test-js` is exactly |
| -# what CI runs here. Debugging CI is running it. |
| +# One step of CONTRA's CI, run by `amadan runner`. |
| # |
| -# Numbered 35 so it sits where `make ci` puts it, after the Go suite: the |
| -# browser crypto only matters once the server's compiles. |
| +# `make oracles` first, then `make test-js`. |
| +# |
| +# The shared runner has python3 and none of the oracle packages, and the box |
| +# is immutable-boot — its user-data is fixed at launch — so installing them |
| +# there is a launch-template version and an instance replacement. The repo |
| +# can do it for itself in a venv instead, which is also the reproducible |
| +# answer: test/requirements.txt pins the versions, so the oracle cannot |
| +# drift under us between runs. |
| +# |
| +# Both are HARD gates and this script softens neither. test-js FAILS when |
| +# node is missing rather than skipping, because a security product must not |
| +# quietly stop checking that the browser's crypto matches the server's; and |
| +# the oracles fail with an instruction rather than skipping, because a |
| +# skipped oracle is indistinguishable from a passing one on the two |
| +# documents a person's whole account depends on being readable. |
| set -eu |
| +make oracles |
| exec make test-js |
diff --git a/.gitignore b/.gitignore| index 8314bab..3868e97 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -10,3 +10,7 @@ |
| /contra.db-wal |
| /contra.db-shm |
| /node_modules/ |
| + |
| +# The oracle venv, built by `make oracles`. Pinned by test/requirements.txt, |
| +# so it is reproducible rather than worth committing. |
| +/.venv/ |
diff --git a/Makefile b/Makefile| index 0133681..ead136b 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -22,7 +22,7 @@ RELEASE_BIN := releases/$(APP)-$(RELEASE_GOOS)-$(RELEASE_GOARCH) |
| # make release VERSION=v0.1.0 |
| VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null) |
| -.PHONY: build release version-check test test-js test-webkit vet fmt-check migration-check ci |
| +.PHONY: build release version-check test test-js test-webkit oracles vet fmt-check migration-check ci |
| # build is the compile check: with more than one package matched, go |
| # build discards the output, so this catches a broken package without |
| @@ -96,7 +96,26 @@ test-js: |
| echo " This gate is not optional -- it is what proves the browser's"; \ |
| echo " crypto matches the server's. Install node, do not skip it."; \ |
| exit 1; } |
| - node --test test/crypto.test.mjs test/items.test.mjs test/qr.test.mjs test/kit.test.mjs |
| + node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs \ |
| + test/qr.test.mjs test/kit.test.mjs |
| + |
| +# The third-party oracles' Python packages, into a local .venv. |
| +# |
| +# Opt-in rather than a dependency of test-js: a machine whose system python3 |
| +# already has them needs nothing, and test/oracle.mjs prefers .venv only when |
| +# it exists. What this exists for is the shared CI runner, which has python3 |
| +# and none of the packages — the box is immutable-boot, so the fix has to be |
| +# something the repo can do for itself rather than an AWS change. |
| +# |
| +# Pinned in test/requirements.txt, because an oracle that drifts is an oracle |
| +# that stops meaning what it said last week. |
| +oracles: |
| + @command -v python3 >/dev/null 2>&1 || { \ |
| + echo "make oracles: python3 is not installed."; exit 1; } |
| + python3 -m venv .venv |
| + .venv/bin/pip install --quiet --upgrade pip |
| + .venv/bin/pip install --quiet -r test/requirements.txt |
| + @echo "oracles ready: .venv/bin/python3" |
| # The browser crypto in WebKit, which is the engine this product can least |
| # afford to be wrong about and the one the Chromium rig never touches. |
diff --git a/test/kit.test.mjs b/test/kit.test.mjs| index 24d8cf4..90550a9 100644 |
| --- a/test/kit.test.mjs |
| +++ b/test/kit.test.mjs |
| @@ -12,9 +12,9 @@ |
| import { test } from "node:test"; |
| import assert from "node:assert/strict"; |
| -import { execFileSync } from "node:child_process"; |
| import { fileURLToPath } from "node:url"; |
| import { dirname, join } from "node:path"; |
| +import { run as runOracle } from "./oracle.mjs"; |
| const here = dirname(fileURLToPath(import.meta.url)); |
| const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name); |
| @@ -30,10 +30,7 @@ const RECOVERY = fromHex("70da5ffc983510bba4672030946b9eed"); |
| const WHEN = new Date(Date.UTC(2026, 8, 2)); |
| function ask(cases) { |
| - const out = execFileSync("python3", [join(here, "pdforacle.py")], { |
| - input: JSON.stringify(cases), encoding: "utf8", maxBuffer: 1 << 28, |
| - }); |
| - return JSON.parse(out); |
| + return JSON.parse(runOracle("pdforacle.py", cases)); |
| } |
| const b64 = (bytes) => Buffer.from(bytes).toString("base64"); |
diff --git a/test/oracle.mjs b/test/oracle.mjs| new file mode 100644 |
| index 0000000..6f7426e |
| --- /dev/null |
| +++ b/test/oracle.mjs |
| @@ -0,0 +1,60 @@ |
| +// Finding the Python the third-party oracles run under. |
| +// |
| +// The oracles are what stop the QR writer and the PDF writer being checked |
| +// only against themselves: zxing-cpp decodes what we drew, python-qrcode |
| +// draws what we drew and we compare matrices, PyMuPDF and pypdf read the |
| +// documents back. An encoder tested against its own decoder proves the two |
| +// agree and nothing else. |
| +// |
| +// They need python3 with numpy, zxing-cpp, qrcode, pillow, pymupdf and |
| +// pypdf. A machine — or a shared CI runner — may not have those, and the |
| +// failure was a raw ModuleNotFoundError traceback that said nothing about |
| +// what to do. This resolves the interpreter and turns a missing dependency |
| +// into an instruction. |
| +// |
| +// Order: $CONTRA_PYTHON, then .venv/bin/python3 if `make oracles` has been |
| +// run, then python3. So a developer whose system already has the packages |
| +// needs no venv, and CI gets a reproducible one without changing the box. |
| + |
| +import { execFileSync } from "node:child_process"; |
| +import { existsSync } from "node:fs"; |
| +import { fileURLToPath } from "node:url"; |
| +import { dirname, join } from "node:path"; |
| + |
| +const here = dirname(fileURLToPath(import.meta.url)); |
| +const repo = join(here, ".."); |
| + |
| +export function oraclePython() { |
| + if (process.env.CONTRA_PYTHON) return process.env.CONTRA_PYTHON; |
| + const venv = join(repo, ".venv", "bin", "python3"); |
| + if (existsSync(venv)) return venv; |
| + return "python3"; |
| +} |
| + |
| +// run executes one oracle and returns its stdout. |
| +// |
| +// It does NOT skip when the packages are missing. A skipped oracle is |
| +// indistinguishable from a passing one, and these guard the two documents a |
| +// person's whole account depends on being able to read back — that is |
| +// exactly the class of check this repo refuses to let degrade silently. |
| +// What it does is fail with the command that fixes it. |
| +export function run(script, input) { |
| + const python = oraclePython(); |
| + try { |
| + return execFileSync(python, [join(here, script)], { |
| + input: JSON.stringify(input), |
| + maxBuffer: 64 << 20, |
| + }).toString(); |
| + } catch (err) { |
| + const text = String(err.stderr || err.message || ""); |
| + if (/ModuleNotFoundError|No module named|ENOENT/.test(text)) { |
| + throw new Error( |
| + `the ${script} oracle cannot run: its Python packages are missing.\n` + |
| + ` Interpreter tried: ${python}\n` + |
| + ` Fix it with: make oracles\n` + |
| + ` (or set CONTRA_PYTHON to an interpreter that has numpy, zxing-cpp,\n` + |
| + ` qrcode, pillow, pymupdf and pypdf)\n\n` + text); |
| + } |
| + throw err; |
| + } |
| +} |
diff --git a/test/qr.test.mjs b/test/qr.test.mjs| index 1c777b6..af333e6 100644 |
| --- a/test/qr.test.mjs |
| +++ b/test/qr.test.mjs |
| @@ -24,9 +24,9 @@ |
| import { test } from "node:test"; |
| import assert from "node:assert/strict"; |
| -import { execFileSync } from "node:child_process"; |
| import { fileURLToPath } from "node:url"; |
| import { dirname, join } from "node:path"; |
| +import { run as runOracle } from "./oracle.mjs"; |
| const here = dirname(fileURLToPath(import.meta.url)); |
| const { encode, smallestVersion } = await import( |
| @@ -46,12 +46,7 @@ const PAYLOADS = [ |
| ]; |
| function ask(cases) { |
| - const out = execFileSync("python3", [join(here, "qroracle.py")], { |
| - input: JSON.stringify(cases), |
| - encoding: "utf8", |
| - maxBuffer: 1 << 28, |
| - }); |
| - return JSON.parse(out); |
| + return JSON.parse(runOracle("qroracle.py", cases)); |
| } |
| test("every symbol scans, and is the symbol another encoder would have made", () => { |
diff --git a/test/requirements.txt b/test/requirements.txt| new file mode 100644 |
| index 0000000..adbeb6c |
| --- /dev/null |
| +++ b/test/requirements.txt |
| @@ -0,0 +1,17 @@ |
| +# The third-party oracles' Python packages. |
| +# |
| +# These are what stop the QR writer and the PDF writer being checked only |
| +# against themselves. zxing-cpp decodes the symbols we drew; python-qrcode |
| +# draws the same payload independently so the two matrices can be compared |
| +# module by module — which is how the reversed format-information bits were |
| +# found, a fault a decode-only check would have reported as "does not scan" |
| +# and left us guessing. PyMuPDF and pypdf read the two documents back. |
| +# |
| +# Pinned, because an oracle that drifts is an oracle that stops meaning what |
| +# it said last week. Install with `make oracles`. |
| +numpy==2.1.3 |
| +pillow==11.0.0 |
| +pypdf==5.1.0 |
| +pymupdf==1.24.14 |
| +qrcode==8.0 |
| +zxing-cpp==2.2.0 |