Files
2026-10-27 22:44:06 +02:00

27 KiB
Raw Permalink Blame History

Testing and Acceptance

Read this document when writing tests, verifying a milestone, or preparing an integration environment. Required behavior and acceptance criteria remain governed by the specification.

Validation

Use fixtures, temporary directories, and replaceable system adapters by default. Never modify /etc/wireguard, restart a host service, read production private keys, or test recovery against the live VPN during ordinary development. Real integration tests must be explicitly selected for a dedicated disposable environment; installing WireGuard on the development host is not enough.

Before Cargo scaffolding exists, validate documentation links and consistency only; do not report build/test commands as passing. Once the Rust project exists, run the relevant focused tests during development and these completion checks for Rust changes:

cargo fmt --all -- --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
cargo build --locked --release

Use only supported feature combinations; do not assume --all-features is valid. Keep privileged integration tests out of the default test suite. If a check cannot run, report the exact limitation rather than claiming success. Documentation-only edits do not require rebuilding the application.

The unprivileged TUI fixture commands currently available are:

cargo test --locked --test tui_workflow
cargo test --locked tui::

tui_workflow runs setup with a redacted invalid-source anomaly, client creation/rename, Commit, first-file and QR delivery, manual backup/list, and preview/cancel/confirm restore through the serialized application worker. It uses temporary settings/server/source files and a fake statistics reader; it does not invoke service or runtime mutation. The tui:: filter covers forms, rendering, worker responsiveness/staleness, QR fit, and terminal cleanup/error seams. It does not require root or a PTY.

Prioritize meaningful behavioral checks:

  • Configuration round trips preserve untouched bytes, unknown fields, and administrator comments; managed metadata cannot escape its comment block.
  • Commit/restore do not invoke runtime mutation; draft cancellation, saved/applied differences, and address reservation survive the intended transitions.
  • Allocation respects both ranges, occupied/server addresses, exhaustion, and deferred address reuse without IPv6-wide iteration.
  • Import verifies keys, preserves source files, and distinguishes missing counterparts from parameter conflicts.
  • Encryption works without a password; reissue handles correct/incorrect passwords, corruption, and first-delivery retry boundaries without leaking secrets.
  • Backup failures prevent mutation; restoration works without decryption, excludes recursive archives, and rejects unsafe archive entries.
  • Systemd success without a matching interface/client set is not marked applied; timeouts and failed restarts remain visible without automatic rollback.
  • File and QR delivery encode the same configuration; file failures, overwrite confirmation, non-TTY startup, resizing, and terminal cleanup behave correctly.

Test externally observable behavior and high-impact failure paths. Avoid tests that merely repeat implementation details, introduce root requirements, or assert real secrets in snapshots. Broaden testing when failures or new risks justify it.

Terminal smoke coverage

cargo test --locked --test tui_workflow terminal_pty_smoke -- --nocapture launches the terminal loop through script with a synthetic Application and an injected fake unavailable-statistics reader. It enters at 80×24, changes to 40×8 and back to 80×24, sends Ctrl-C (handled as the normal exit request), and asserts the alternate screen and cursor are restored. The setup is not root management startup and does not read host settings or query host WireGuard. The test reports itself incomplete if the script utility is unavailable. In the workflow test, an actual first-delivery QR result is transferred into the disclosure owner and Esc clears it; the renderer fit test separately checks complete quiet-zone dimensions. These checks are synthetic PTY/UI evidence, not phone/decoder interoperability. The PTY test exercises handled interruption and normal terminal restoration; cargo test --locked tui::terminal::tests::lifecycle_failures_restore_terminal_modes_in_isolated_pty -- --nocapture uses script to launch five isolated child probes at 80×24. The probes stop after raw-mode setup, stop after alternate-screen setup, return synthetic render and input I/O errors after full setup, and panic after full setup. Each child checks raw mode is off on return; the parent checks that alternate-screen and cursor enter/restore sequences match the resources reached. The panic case also checks that the panic hook emits its generic internal-failure diagnostic without the synthetic panic payload. These probes use the same private lifecycle guard and setup methods as production, without an environment-controlled production path or application/service adapters. The test reports itself incomplete if script is unavailable. PTY evidence remains synthetic; it does not test terminal-emulator behavior beyond the emitted control sequences.

Integration testing with Alpine Testcontainers

Production access is limited and experimentation on the production VPN is prohibited. Use three complementary levels: ordinary unit/fixture tests, explicitly selected Alpine container integration tests, and a separate disposable Linux VM for real systemd/D-Bus acceptance. No level authorizes using production as a test environment.

Alpine environment

  • Use the approved testcontainers development dependency to start a fresh Alpine server and client on a dedicated internal IPv4 network. The RFC1918 bridge subnet 192.168.253.0/24 is reserved for this isolated underlay and must not overlap the synthetic tunnel ranges used by scenarios. Alpine is not a systemd test environment. The host process owns only resources it creates for the current scenario: uniquely named network, server/client containers, and private temporary fixture files. Run the probe synchronously so cleanup does not race a forcibly stopped Testcontainers request. When Testcontainers returns a container handle, RAII drops that owned container on success and recoverable failure; a final bounded cleanup inspects only the scenario's exact names and removes IDs only when the current process's ownership label matches. If a start request times out before returning an ID, an immediate absent inspection cannot prove the daemon will never finish creation. Report that resource status as uncertain, retain its exact name, and do not broaden cleanup.
  • Pin the Alpine base to a release or digest, record the resolved image identity and package versions, and build/cache the image before repeated suite runs. The runtime image contains only wireguard-tools, iproute2, BusyBox facilities that are actually used, and justified fixture requirements. It contains no systemd, Cargo registry, compiler, production configuration, generated key, or secret-bearing volume. Reuse image layers only; each scenario gets fresh containers and fresh private data directories.
  • The Docker-compatible daemon must be explicitly selected as a disposable Linux environment. The suite first identifies the selected endpoint and requires an explicit test-environment opt-in before making Docker API calls. It must not silently use a configured remote context. The daemon host must already provide kernel WireGuard support: containers share that kernel, and Alpine userspace tools do not supply it. A disposable probe verifies WireGuard interface creation and IPv4 address/route operations inside throwaway containers; it must not load modules, alter host sysctls, or create host interfaces. Missing support fails an explicitly requested run. IPv6 capability and traffic are intentionally not tested by M8.
  • Testcontainers 0.28.0 is built with only its blocking feature; properties-config is disabled. Its Docker client therefore reads DOCKER_HOST from the process environment and cannot select tc.host or docker.host from ~/.testcontainers.properties. The probe requires DOCKER_HOST to exactly equal the explicitly selected local Unix endpoint, and its Docker CLI checks pass that same endpoint with --host. A mismatch fails before either client is used.
  • Give only the server and client containers NET_ADMIN, scoped to their own network namespaces. Run them in separate container network and PID namespaces. Do not use --privileged, SYS_MODULE, host networking, host PID/IPC namespaces, host /etc/wireguard, host D-Bus, Docker socket mounts, or production bind mounts. Use a uniquely named internal IPv4 network and choose its private underlay subnet so it does not overlap the tunnel ranges.
  • The host harness creates and tears down containers/networks and transfers bounded fixture inputs and outputs. Server-side operations run in the server container against its private fixture paths and namespace, using the real Application and runtime::transport::observe_interface path. Never query or mutate the host WireGuard interface as a shortcut. Use Testcontainers' supported synchronous runner (SyncRunner) and lifecycle/image APIs such as ImageExt network configuration only after checking the pinned crate version's API. Fixture commands use the selected endpoint and owned container ID through the bounded Docker CLI transport so a Testcontainers attach stream cannot leave the harness waiting indefinitely. See the testcontainers 0.28.0 API and its ImageExt, Container, and SyncRunner references. Keep those APIs in host orchestration; they do not authorize fixture code to control unrelated containers.
  • Build only the required server-side test driver for x86_64-unknown-linux-musl, or build it in a pinned Alpine/musl builder using the repository's pinned Rust version. A host glibc executable, a target name, or a successful host build is not runtime compatibility evidence. Inspect dynamic requirements and execute a harmless readiness operation in the pinned Alpine image before the suite. Keep the compiler and build cache out of the runtime image. Missing target/linker prerequisites mean the suite cannot run; do not install global tools implicitly.
  • Keep the in-container driver boundary private to tests. Prefer an explicitly selected cfg(test) driver/scenario entry point that invokes the real application and the real Rustix reader; do not add a production apply command or an environment-variable bypass. The fixture apply operation may use explicit ip/wg commands with argument arrays and fixed fixture inputs, or equivalent direct fixture setup, but it must never execute imported hooks or arbitrary configuration text. It is not the production zbus adapter and all resulting reports must say “fixture apply.”
  • Preserve staged application authority in one long-lived in-container driver session for each scenario. That driver owns the Application instance from client creation through Commit and first delivery, retaining the application's in-memory first-issue eligibility. Reopening the saved store or launching a new process between Commit and first delivery is not a valid substitute: first delivery must remain bound to the creation session, and a reopened process must use password-authorized reissue. After first delivery, the driver may perform fixture apply through the private test seam and then verify through the real observation and normal application verification/persistence path. It must not mark state applied or release reservations directly.
  • The driver's control channel carries bounded, typed phase requests and redacted phase results only. Do not put passwords, private keys, complete client configurations, QR payloads, or vault plaintext in arguments, environment variables, image metadata, logs, or captured stdout/stderr. Generate WireGuard keys, vault material, and synthetic server/client configurations inside the private server fixture session. Seed only nonsecret driver binaries and fixture scripts with pre-start image copy configuration. Store secret-bearing files in mode-0600 files under mode-0700 container directories. Send only opaque path/session identifiers over the control channel. Copy the actual first-delivery file from the server container to a host-private mode-0600 temporary file, then seed a fresh client container from those exact bytes before it starts. Configure the client from that file and verify its integrity without rendering it in diagnostics. Do not reconstruct or edit the payload to make the client work. The official files and mounts guide documents ImageExt::with_copy_to, CopyTargetOptions::with_mode, and Container::copy_file_from; the exec guide documents Container::exec. with_copy_to packages pre-start content in a TAR; accept only regular paths and reject symlinks. Confirm the pinned 0.28.0 crate supports the chosen sequence before implementation. Never use a host bind mount for secrets.
  • Generate synthetic keys/configurations per scenario in private fixture storage. Never put real keys/configs or working Backup/ data in the build context. Diagnostics may include scenario, phase, public identity, status, elapsed time, and fixed redacted failure category, but not private payloads, command output, QR matrices, or wg show all dump. Each in-container command runs through /bin/busybox timeout -s KILL 15 PROGRAM ... and uses the bounded Docker CLI transport rather than a Testcontainers attach stream. Each CLI call has a 20-second host deadline, which also bounds a stalled exec response. Stdout and stderr are drained while retaining at most 128 bytes for namespace stdout and 1024 bytes for other stdout/stderr; output overflow fails with a fixed redacted category. The in-container timeout category is In-container fixture command timed out; if the host deadline expires, command completion inside the owned container is reported as uncertain. Testcontainers 0.28.0 configures a 120-second Docker API request timeout. The probe has at most 12 sequential Testcontainers API requests: two container starts (at most five each) and two removals, plus a conservative allowance for cleanup on partial starts. This gives a 24-minute upper bound for those API requests. Eight bounded fixture CLI calls add at most 160 seconds; daemon/image checks and network create/remove add at most 60 seconds; partial-start inspection/removal adds at most 60 seconds; final cleanup has a 40-second budget. The conservative aggregate bound is 27 minutes. No shorter outer deadline interrupts an in-flight Testcontainers request. If a start request fails before returning a resource ID, the harness reports an uncertain resource status even if immediate inspection finds no matching resource. Poll explicit conditions against monotonic deadlines rather than sleeping a fixed duration. A cleanup error must not replace the original test failure.

Required container scenarios

  1. Start an isolated server and existing client, establish a tunnel, and read real peer state through the runtime adapter.
  2. Create a client through the application, Commit, and verify saved files/vault plus unchanged running peer state. Deliver the client configuration and verify that it cannot connect before explicit application. M8 checks IPv4 behavior only.
  3. Use a test-only fixture operation to apply the saved configuration inside the container. Establish the new tunnel, generate IPv4 traffic, and verify handshake and increasing counters without asserting exact byte totals. IPv6 capability and traffic are intentionally untested.
  4. Delete a client and Commit; verify it is removed from saved data but remains in running state until explicit application, and its address remains reserved. After verified application, check removal, loss of tunnel access, and address release.
  5. Exercise backups and restoration against container-local data: restoration requires no vault password and does not mutate running state. Verify that applying the restored configuration returns the expected clients.
  6. Cover mandatory-backup failure and invalid client import with assertions about unchanged protected state and preserved source files.

Any fixture apply/restart adapter is test-only. It must not weaken the production requirement for a separate systemd restart or be reported as testing the production zbus implementation. If application orchestration uses the fixture service adapter, label those results accordingly.

Opt-in contract and command status

The planned suite is an explicitly named ignored integration target, selected with a single-target command so --ignored cannot pick up unrelated privileged tests. The harness must additionally require an explicit environment selection for the disposable daemon before contacting Docker. A missing prerequisite after explicit opt-in is a nonzero test failure with a redacted prerequisite category, never a successful “skipped” test. Ordinary cargo test, --help, and --version must not contact Docker, require root, or inspect host interfaces.

The runtime image recipe is tests/containers/alpine/Dockerfile. Its build context is only tests/containers/alpine/; .dockerignore is an allowlist for the Dockerfile, package lock, and harmless readiness script. It does not include the repository, .git, Cargo data, credentials, Backup/, keys, or configs. The image pins the official Alpine 3.22.7 linux/amd64 manifest sha256:dc92f017beea8e7f43263073d717f7bc011228c95d5312e440deb4d035bb4eec. The official image metadata reports config/image ID sha256:24ab38714e948bd49d24d566e97c1c104b63f85c2e642685e72393b37cfb9862. The Alpine v3.22 x86_64 package indexes currently list wireguard-tools 1.0.20250521-r0 and iproute2 6.15.0-r0. The exact versions of every package in the installed closure are recorded in packages.lock and passed to apk add as constraints. Alpine branch indexes remain mutable; if an indexed version disappears, the build fails instead of silently selecting a different closure. The image installs only the listed runtime package closure; BusyBox is included in that closure.

From the repository root, after selecting the disposable daemon, build and cache the image with:

docker build --pull=false --platform linux/amd64 --tag wireguard-admin-alpine:3.22.7 --file tests/containers/alpine/Dockerfile tests/containers/alpine
docker image inspect --format='{{.Id}} {{.Os}}/{{.Architecture}}' wireguard-admin-alpine:3.22.7
docker run --rm --network none --read-only --cap-drop ALL wireguard-admin-alpine:3.22.7 /usr/local/bin/wireguard-admin-alpine-check
docker run --rm --network none --read-only --cap-drop ALL wireguard-admin-alpine:3.22.7 apk info -v

The first command produces daemon-local image tag wireguard-admin-alpine:3.22.7; the inspect output records the actual image ID. The final command records the complete installed package closure. On the selected daemon, the image built successfully with ID sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5; apk info -v matched the 33 exact constraints in packages.lock, and the networkless, read-only, capability-dropped readiness command exited 0. This validates the shell tools in the image only; no WireGuard interface was created and no Rust driver compatibility is claimed.

M8.4 provides the ignored #[cfg(test)] library test container_driver::alpine_readiness; libtest selects it with the supported --ignored --exact arguments. It checks only that the process is running on Linux x86_64 and does not access or modify an interface. The wrapper invokes only that test with one test thread. M8.4 also provides a test-only fixture command enum for explicit ip link/address/route and wg setconf operations. It passes argument arrays through BusyBox's 15-second timeout, limits interface, address, route and config-file inputs to typed/validated forms, discards child output, and returns a fixed redacted failure category. wg setconf consumes a fixture-local file; imported hooks and arbitrary configuration text are never executed. The application fixture-restart adapter is also cfg(test) and continues through the real Rustix observation, saved-peer verification and journaled persistence path. Its result represents fixture apply, not a systemd job.

M8.5's scenario driver must keep a single Application value alive from creation through Commit and first delivery so the in-memory first-issue authorization remains bound to that creation session. It must not reopen the store between those operations. Build the test harness for musl with:

cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl

The host lacks the musl target. The official rust:1.97.1-alpine3.22 linux/amd64 builder manifest is pinned to sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02. On the selected disposable daemon its image ID is sha256:bc2b74a0f3f942897f678aa466c3cce54048cf1e094893af3596cf43766153c2; it reports Rust/Cargo 1.97.1 and the installed musl target. From the repository root, build without installing host tooling or copying source into the image:

docker run --rm --platform linux/amd64 --mount type=bind,src="$PWD",dst=/src,readonly --mount type=bind,src=/tmp,dst=/out -e CARGO_TARGET_DIR=/out/wga-m8-build-target -w /src rust:1.97.1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02 cargo test --locked --lib --no-run --target x86_64-unknown-linux-musl

The 2026-10-23 build completed in 4m25s and emitted /tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031. The hash may change after source edits; use Cargo's printed executable path. If /tmp has been cleared and an offline rebuild is needed, mount an existing public Cargo registry cache read-only and set CARGO_HOME as shown in the M8.7 evidence. The pinned builder then produced the same driver SHA256 from the unchanged source on 2026-10-25. Missing cached dependencies remain a failed prerequisite until obtained through an approved build setup; an absent driver must never count as a skipped scenario. file identified that artifact as a static PIE x86-64 ELF, and readelf -l showed no dynamic interpreter. A preliminary networkless, read-only, capability-dropped Alpine run selected its readiness test and passed 1/1 using only a read-only bind of this nonsecret binary. The M8.4 host smoke now copies the current binary into a fresh Testcontainers fixture and selects one ignored container_driver::alpine_scenario_driver test through a bounded stdin/stdout channel. It requires bootstrap, Create, Commit, first delivery with activation warning, and verified fixture Apply phase markers. The readiness wrapper executes PATH --ignored --exact container_driver::alpine_readiness --test-threads=1; it does not pass a custom argument to the harness. No fixture key or configuration is copied into the builder image or runtime image build context.

The M8.3 prerequisite and M8.4 Application smoke share one exact ignored host test. After building the current musl artifact, run it on the explicitly selected local daemon with the matching endpoint and daemon ID:

DOCKER_HOST=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ENDPOINT=unix:///var/run/docker.sock WIREGUARD_ADMIN_TEST_DAEMON_ID=1baad80e-d79d-49ab-b19a-fb00fd3662d7 WIREGUARD_ADMIN_TESTCONTAINERS=1 WIREGUARD_ADMIN_TEST_DRIVER=/tmp/wga-m8-build-target/x86_64-unknown-linux-musl/debug/deps/wireguard_admin-dcc9c812241bb031 cargo test --locked --test testcontainers_wireguard selected_daemon_wireguard_and_application_smoke -- --ignored --exact --test-threads=1

The probe uses a uniquely named internal IPv4 bridge and no longer requires Docker IPv6 or IPv6 iptables support. It must pass before M8.4–M8.5 scenarios can be run. On 2026-10-23 the current musl artifact and host smoke passed 1/1 in 4.05 seconds on the selected local daemon, and exact-name Docker checks found no leftover test resources. It proves test-only saved-state creation, Commit, first delivery and fixture Apply with real runtime observation; it does not establish a client tunnel or traffic. IPv6 capability and traffic are intentionally untested; production dual-stack behavior remains specified in SPEC.md. The M8.5 traffic slice transfers the server's actual first-delivery file to a fresh client and passed real IPv4 traffic checks. Deletion, restore, import/reissue, and mandatory-backup-failure slices also passed selected-daemon runs, with their slice findings closed. The evidence report records the known-source backup-failure run. On 2026-10-25 the final integrated host gates and two fresh selected-daemon runs passed on one unchanged source fingerprint; the documented unfiltered opt-in command passed as a third fresh run. See the M8.7 evidence for identities, counts, durations and limits. These Alpine fixture results do not cover actual systemd/D-Bus jobs or the user-excluded IPv6 capability and traffic checks.

Systemd acceptance and test execution

  • Use a disposable Linux VM with WireGuard and systemd to verify the actual zbus service adapter, job completion/timeouts, restart verification, and recovery from a selected backup. Exercise both the system-bus path and the root-only /run/systemd/private path with the system bus endpoint unavailable. Also verify that permission/authentication failures and connection deadlines do not trigger fallback. Current synthetic tests cover transport selection and failure classification; they do not emulate systemd or prove the D-Bus peer protocol. Do not substitute a mounted host D-Bus or claim Alpine tests cover systemd. The private socket is an internal systemd interface and may change between releases.
  • Keep container and VM suites out of the default cargo test execution. Once implemented, document their exact opt-in commands and prerequisites in this document. Do not list a planned command as available before its test target exists.
  • When integration testing is explicitly requested, unavailable Docker, WireGuard support, IPv4 networking, or required capabilities means the suite is not executed or incomplete, not passed. Distinguish prerequisite failures from application failures in the report. IPv6 capability and traffic are intentionally not tested by M8.
  • Report unit/fixture results, Alpine real-WireGuard results, and VM real-systemd results separately. Full acceptance requires the relevant real-system checks, not just mocks or successful container startup.