Human Test Plan: MM-135 NixOS Module for Relay Deployment#
Generated from test-analyst review of implementation plan docs/implementation-plans/2026-03-09-MM-135/.
Automated coverage: 18/18 acceptance criteria verified by nix eval smoke tests and structural checks.
This plan covers: Manual verification steps for criteria that require a Linux builder, runtime testing, or human code review.
Prerequisites#
- NixOS system or VM available for runtime testing (Linux required for E2E phases)
- Development shell activated:
nix develop --impure --accept-flake-config
- All Phase 3 smoke tests passing (run commands from
docs/implementation-plans/2026-03-09-MM-135/phase_03.md Tasks 1–5)
just nix-check exits 0
Phase 1: TOML Content Verification (Linux Only)#
| Step |
Action |
Expected |
| 1.1 |
On a Linux builder, run: nix eval --impure --accept-flake-config --raw --expr 'let flake = builtins.getFlake (builtins.toString ./.); evalNixpkgs = builtins.getFlake "nixpkgs"; sys = evalNixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ flake.nixosModules.default { services.ezpds.enable = true; services.ezpds.settings.public_url = "https://relay.example.com"; } ]; }; execStart = sys.config.systemd.services.ezpds.serviceConfig.ExecStart; configPath = builtins.elemAt (builtins.match ".* --config (.*)" execStart) 0; in builtins.readFile configPath' |
Output is valid TOML containing exactly: bind_address = "0.0.0.0", data_dir = "/var/lib/ezpds", port = 8080, public_url = "https://relay.example.com". No database_url key. No [blobs], [oauth], or [iroh] sections. |
| 1.2 |
Repeat step 1.1 but add services.ezpds.settings.database_url = "sqlite:///var/lib/ezpds/custom.db"; to the module config |
Output TOML additionally contains database_url = "sqlite:///var/lib/ezpds/custom.db". |
Phase 2: Group and StateDirectory Eval Verification#
| Step |
Action |
Expected |
| 2.1 |
Run: nix eval --impure --accept-flake-config --json --expr 'let flake = builtins.getFlake (builtins.toString ./.); evalNixpkgs = builtins.getFlake "nixpkgs"; sys = evalNixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ flake.nixosModules.default { services.ezpds.enable = true; services.ezpds.settings.public_url = "https://relay.example.com"; } ]; }; in builtins.hasAttr "ezpds" sys.config.users.groups' |
Output: true |
| 2.2 |
Run: nix eval --impure --accept-flake-config --raw --expr 'let flake = builtins.getFlake (builtins.toString ./.); evalNixpkgs = builtins.getFlake "nixpkgs"; sys = evalNixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ flake.nixosModules.default { services.ezpds.enable = true; services.ezpds.settings.public_url = "https://relay.example.com"; } ]; }; in sys.config.systemd.services.ezpds.serviceConfig.StateDirectory' |
Output: ezpds |
Phase 3: Bare Module Success Path#
| Step |
Action |
Expected |
| 3.1 |
Run: nix eval --impure --accept-flake-config --raw --expr 'let evalNixpkgs = builtins.getFlake "nixpkgs"; pkgs = import evalNixpkgs { system = "x86_64-linux"; }; sys = evalNixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ (import ./nix/module.nix) { services.ezpds.enable = true; services.ezpds.package = pkgs.hello; services.ezpds.settings.public_url = "https://relay.example.com"; } ]; }; in sys.config.systemd.services.ezpds.serviceConfig.ExecStart' |
Eval succeeds (exit code 0). Output is a string containing /bin/relay --config /nix/store/...-relay.toml. Confirms the bare module is importable when package is explicitly set. |
Phase 4: Scope Boundaries Code Review#
| Step |
Action |
Expected |
| 4.1 |
Open nix/module.nix and search for blobs, oauth, iroh |
Zero matches. No options for out-of-scope config sections exist. |
| 4.2 |
Review the options.services.ezpds block and confirm it declares exactly: enable, package, configFile, settings.bind_address, settings.port, settings.data_dir, settings.public_url, settings.database_url |
Eight options total. No additional stub options for future sections. |
Phase 5: Systemd Hardening Code Review#
| Step |
Action |
Expected |
| 5.1 |
Open nix/module.nix and review the serviceConfig block |
Block contains: ProtectSystem = "strict", PrivateTmp = true, ProtectHome = true, NoNewPrivileges = true, StateDirectoryMode = "0750", Restart = "on-failure" |
End-to-End: NixOS VM Runtime Test#
Purpose: Validate that the module produces a functioning systemd service on a live NixOS system, covering the gap between eval-time correctness and runtime behavior.
| Step |
Action |
Expected |
| E2E.1 |
Create a NixOS configuration that imports nixosModules.default with services.ezpds.enable = true and services.ezpds.settings.public_url = "https://relay.example.com". Build a VM: nixos-rebuild build-vm --flake .#<config-name> |
VM image builds successfully. |
| E2E.2 |
Boot the VM and run systemctl status ezpds |
Service is loaded and attempted to start. |
| E2E.3 |
Run id ezpds inside the VM |
User exists, is a system user, member of group ezpds. |
| E2E.4 |
Run stat /var/lib/ezpds inside the VM |
Directory exists, owned by ezpds:ezpds, mode 0750. |
| E2E.5 |
Run systemd-analyze security ezpds inside the VM |
Confirms hardening directives active: NoNewPrivileges, ProtectSystem=strict, ProtectHome=yes, PrivateTmp=yes. |
| E2E.6 |
Run cat $(systemctl show ezpds -p ExecStart --value | grep -oP '/nix/store/[^ ]+relay\.toml') inside the VM |
TOML file contents match expected defaults with the configured public_url. |
End-to-End: configFile Escape Hatch Runtime Test#
Purpose: Validate that configFile properly bypasses generated TOML at runtime, critical for secret injection workflows (agenix/sops-nix).
| Step |
Action |
Expected |
| CF.1 |
In a NixOS config, set services.ezpds.configFile = "/etc/ezpds/relay.toml" and create that file with custom contents (e.g., public_url = "https://custom.example.com", port = 9090). |
Config file created at the specified path. |
| CF.2 |
Run systemctl show ezpds -p ExecStart |
ExecStart ends with --config /etc/ezpds/relay.toml (not a Nix store path). |
| CF.3 |
Start the service and check relay logs for the port binding |
Relay attempts to bind on port 9090 (from the custom config), not 8080 (the NixOS module default). |
Traceability#
| Acceptance Criterion |
Automated Test |
Manual Step |
| MM-135.AC1.1 (file exists) |
git ls-files nix/module.nix |
— |
| MM-135.AC1.2 (options declared) |
nix eval --impure --expr 'builtins.typeOf ...' + Phase 3 Task 2 Step 1 |
Phase 4 step 4.2 |
| MM-135.AC1.3 (defaults match) |
Phase 3 Task 2 Step 1 + Task 4 Steps 1–2 |
Phase 1 step 1.1 |
| MM-135.AC1.4 (missing public_url fails) |
Phase 3 Task 3 Step 1 |
— |
| MM-135.AC1.5 (missing package fails) |
Phase 3 Task 3 Step 2 |
— |
| MM-135.AC2.1 (TOML keys present) |
Phase 3 Task 4 Step 2 |
Phase 1 step 1.1 |
| MM-135.AC2.2 (database_url absent when null) |
Phase 3 Task 4 Step 1 |
Phase 1 step 1.1 |
| MM-135.AC2.3 (database_url present when set) |
Phase 3 Task 4 Step 3 |
Phase 1 step 1.2 |
| MM-135.AC2.4 (ExecStart --config) |
Phase 3 Task 2 Step 1 |
E2E.6 |
| MM-135.AC3.1 (configFile overrides) |
Phase 3 Task 5 Step 1 |
CF.2 |
| MM-135.AC3.2 (settings isolation) |
Phase 3 Task 5 Step 2 |
CF.3 |
| MM-135.AC4.1 (system user) |
Phase 3 Task 2 Step 2 |
E2E.3 |
| MM-135.AC4.2 (group defined) |
Structural (source review) |
Phase 2 step 2.1 |
| MM-135.AC4.3 (StateDirectory) |
Structural (source review) |
Phase 2 step 2.2, E2E.4 |
| MM-135.AC5.1 (flake output) |
nix flake check + nix eval .#nixosModules --apply builtins.attrNames |
— |
| MM-135.AC5.2 (package default) |
Phase 3 Task 2 Step 1 |
— |
| MM-135.AC5.3 (bare module importable) |
Phase 3 Task 3 Step 2 (inverse proof) |
Phase 3 step 3.1 |
| MM-135.AC6.1 (no stub sections) |
Structural (grep returns no matches) |
Phase 4 steps 4.1–4.2 |