tests: exercise both nginx and caddy proxy backends

Parameterizes tests/common.nix and tests/default.nix on proxyServer
(default "nginx"), giving flake.nix two nixosConfigurations
(test-nginx/test-caddy) and `just build-vm [proxy]` a way to pick
between them for interactive testing.

The automated nixosTest now boots one node per proxy backend and runs
the full assertion suite (factored into check_invoiceninja()) against
each, so a regression in either nginx or caddy's config gets caught.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 14:33:50 -04:00
co-authored by Claude Sonnet 5
parent fef9406b63
commit 4c97391a33
6 changed files with 82 additions and 60 deletions
+20 -13
View File
@@ -16,7 +16,8 @@ editing Nix expressions.
This project uses `just` (see `justfile`) and Nix directly. This project uses `just` (see `justfile`) and Nix directly.
- `just build-vm` — cleans previous artifacts and builds the test VM (`nixosConfigurations.test`) via `nom build`. - `just build-vm [proxy]` — cleans previous artifacts and builds the test VM (`nixosConfigurations.test-<proxy>`)
via `nom build`. `proxy` is `nginx` (default) or `caddy`.
- `just boot-vm` — boots the built VM (`result/bin/run-nixos-vm`) in the current terminal. Serves Invoice Ninja on - `just boot-vm` — boots the built VM (`result/bin/run-nixos-vm`) in the current terminal. Serves Invoice Ninja on
`localhost:8080` (see `QEMU_NET_OPTS` in the `justfile`) with root login `toor`. `localhost:8080` (see `QEMU_NET_OPTS` in the `justfile`) with root login `toor`.
- `just clean` — removes `result`/`nixos.qcow2` and kills any running `qemu` process. - `just clean` — removes `result`/`nixos.qcow2` and kills any running `qemu` process.
@@ -28,10 +29,12 @@ This project uses `just` (see `justfile`) and Nix directly.
### `tests/` ### `tests/`
- `common.nix` — the shared `services.invoiceninja` config used by both the interactive VM and the automated test: - `common.nix` — the shared `services.invoiceninja` config used by both the interactive VM and the automated test:
imports the module, enables nginx, and points at the checked-in test fixtures (`invoice_ninja_test_password`, imports the module and points at the checked-in test fixtures (`invoice_ninja_test_password`, `test.env`). It's a
`test.env`). function taking `{ proxyServer ? "nginx" }` so callers can pick which proxy backend to test.
- `default.nix` — the interactive VM config (`nixosConfigurations.test` in `flake.nix`), used for manual, - `default.nix` — the interactive VM config (`nixosConfigurations.test-nginx`/`test-caddy` in `flake.nix`), used
click-around verification via `just build-vm` / `just boot-vm`. Adds the qemu-guest profile, a root login, and for manual, click-around verification via `just build-vm [proxy]` / `just boot-vm`. It's also a function taking
`{ proxyServer ? "nginx" }`, forwarded straight through to `common.nix`, so `flake.nix` can instantiate one
`nixosConfigurations` entry per proxy backend. Adds the qemu-guest profile, a root login, and
`nixpkgs.config.allowUnfree = true` (required to build `pkgs.chromium`). It also sets a dummy `fileSystems."/"` `nixpkgs.config.allowUnfree = true` (required to build `pkgs.chromium`). It also sets a dummy `fileSystems."/"`
and disables `boot.loader.grub` purely to satisfy NixOS's built-in assertions when `nix flake check` evaluates and disables `boot.loader.grub` purely to satisfy NixOS's built-in assertions when `nix flake check` evaluates
`config.system.build.toplevel` directly — the qemu-vm module overrides both with a tmpfs root and direct kernel `config.system.build.toplevel` directly — the qemu-vm module overrides both with a tmpfs root and direct kernel
@@ -39,12 +42,15 @@ This project uses `just` (see `justfile`) and Nix directly.
unused. unused.
- `nixos-test.nix` — the automated test (`pkgs.testers.nixosTest`, exposed as `checks.<system>.invoiceninja` in - `nixos-test.nix` — the automated test (`pkgs.testers.nixosTest`, exposed as `checks.<system>.invoiceninja` in
`flake.nix`). Builds its own `pkgs` (with `allowUnfree` baked in — `nixosTest` forbids setting `nixpkgs.config` `flake.nix`). Builds its own `pkgs` (with `allowUnfree` baked in — `nixosTest` forbids setting `nixpkgs.config`
on a node once `pkgs` is externally supplied) and boots a VM with `common.nix`, then: waits for on a node once `pkgs` is externally supplied) and boots two nodes from `common.nix` — one with
mysql/redis/phpfpm/queue-worker/nginx to be active, checks the `invoiceninja-data-setup` marker files `proxyServer = "nginx"`, one with `proxyServer = "caddy"` — so both proxy backends get exercised. The shared
(`.initial-migration`, `.db-seeded`, `.admin-created`) to confirm the one-time setup actually completed (not just assertion suite (factored into a `check_invoiceninja(machine, proxy_unit)` function in the test script) runs
that the oneshot unit exited), curls `/` for the app shell, exercises the `invoiceninja-manage` wrapper, and logs against each node: waits for mysql/redis/phpfpm/queue-worker/proxy to be active, checks the
in via `/api/v1/login` using the test admin credentials as an end-to-end check that migrations/seeding/account `invoiceninja-data-setup` marker files (`.initial-migration`, `.db-seeded`, `.admin-created`) to confirm the
creation all worked. This is the only automated test in the repo — there is no separate unit-test suite. one-time setup actually completed (not just that the oneshot unit exited), curls `/` for the app shell, exercises
the `invoiceninja-manage` wrapper, and logs in via `/api/v1/login` using the test admin credentials as an
end-to-end check that migrations/seeding/account creation all worked. This is the only automated test in the
repo — there is no separate unit-test suite.
## Architecture ## Architecture
@@ -108,8 +114,9 @@ expect Nix to report the correct hash on a failed build (or use `nix-prefetch`/`
### `flake.nix` ### `flake.nix`
Standard flake shape: `overlays.default` registers the package, `packages.<system>.invoiceninja` exposes it, Standard flake shape: `overlays.default` registers the package, `packages.<system>.invoiceninja` exposes it,
`nixosModules` re-exports `nixos-module/default.nix`, and `nixosConfigurations.test` builds the VM defined in `nixosModules` re-exports `nixos-module/default.nix`, and `nixosConfigurations.test-nginx`/`test-caddy` each build
`tests/`. Only `x86_64-linux` is currently supported. the VM defined in `tests/default.nix` with the corresponding `proxyServer`. Only `x86_64-linux` is currently
supported.
## Conventions ## Conventions
+9 -3
View File
@@ -20,9 +20,15 @@
); );
in in
rec { rec {
nixosConfigurations.test = nixpkgs.lib.nixosSystem { nixosConfigurations = {
system = "x86_64-linux"; test-nginx = nixpkgs.lib.nixosSystem {
modules = [ ./tests ]; system = "x86_64-linux";
modules = [ (import ./tests/default.nix { proxyServer = "nginx"; }) ];
};
test-caddy = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ (import ./tests/default.nix { proxyServer = "caddy"; }) ];
};
}; };
overlays.default = final: prev: { invoiceninja = final.callPackage ./package.nix { }; }; overlays.default = final: prev: { invoiceninja = final.callPackage ./package.nix { }; };
+3 -3
View File
@@ -18,10 +18,10 @@ clean:
pkill qemu pkill qemu
exit 0 exit 0
[doc('Build virtual machine for testing')] [doc('Build virtual machine for testing (proxy: nginx or caddy)')]
[group('main')] [group('main')]
build-vm: clean build-vm proxy="nginx": clean
nom build ".#nixosConfigurations.test.config.system.build.vm" nom build ".#nixosConfigurations.test-{{proxy}}.config.system.build.vm"
[doc('Run virtual machine in current terminal')] [doc('Run virtual machine in current terminal')]
[group('main')] [group('main')]
+2 -1
View File
@@ -1,3 +1,4 @@
{ proxyServer ? "nginx" }:
{ ... }: { ... }:
{ {
@@ -5,7 +6,7 @@
services.invoiceninja = { services.invoiceninja = {
enable = true; enable = true;
proxy.server = "nginx"; proxy.server = proxyServer;
adminAccount.passwordFile = ./invoice_ninja_test_password; adminAccount.passwordFile = ./invoice_ninja_test_password;
secretFile = ./test.env; secretFile = ./test.env;
settings.APP_DEBUG = true; settings.APP_DEBUG = true;
+2 -1
View File
@@ -1,9 +1,10 @@
{ proxyServer ? "nginx" }:
{ pkgs, modulesPath, ... }: { pkgs, modulesPath, ... }:
{ {
imports = [ imports = [
(modulesPath + "/profiles/qemu-guest.nix") (modulesPath + "/profiles/qemu-guest.nix")
./common.nix (import ./common.nix { inherit proxyServer; })
]; ];
# Required to build pkgs.chromium, which the module pulls in for PDF generation. # Required to build pkgs.chromium, which the module pulls in for PDF generation.
+46 -39
View File
@@ -15,54 +15,61 @@ in
pkgs.testers.nixosTest { pkgs.testers.nixosTest {
name = "invoiceninja"; name = "invoiceninja";
nodes.machine = { nodes = {
imports = [ ./common.nix ]; nginx.imports = [ (import ./common.nix { proxyServer = "nginx"; }) ];
caddy.imports = [ (import ./common.nix { proxyServer = "caddy"; }) ];
}; };
testScript = '' testScript = ''
machine.start() def check_invoiceninja(machine, proxy_unit):
machine.wait_for_unit("mysql.service")
machine.wait_for_unit("redis-invoiceninja.service")
machine.wait_for_unit("phpfpm-invoiceninja.service")
machine.wait_for_unit("invoiceninja-queue-worker.service")
machine.wait_for_unit(proxy_unit)
machine.wait_for_open_port(80)
machine.wait_for_unit("mysql.service") # phpfpm-invoiceninja/invoiceninja-queue-worker both Require= invoiceninja-data-setup,
machine.wait_for_unit("redis-invoiceninja.service") # so systemd already waited for it to finish above. Confirm the one-time setup steps
machine.wait_for_unit("phpfpm-invoiceninja.service") # it guards actually completed, rather than just that the unit exited.
machine.wait_for_unit("invoiceninja-queue-worker.service") with subtest("one-time setup (migrate, seed, admin account) completed"):
machine.wait_for_unit("nginx.service") machine.succeed("test -f /var/lib/invoiceninja/.initial-migration")
machine.wait_for_open_port(80) machine.succeed("test -f /var/lib/invoiceninja/.db-seeded")
machine.succeed("test -f /var/lib/invoiceninja/.admin-created")
# phpfpm-invoiceninja/invoiceninja-queue-worker both Require= invoiceninja-data-setup, with subtest("application shell is served over HTTP"):
# so systemd already waited for it to finish above. Confirm the one-time setup steps response = machine.succeed("curl -sf http://localhost/")
# it guards actually completed, rather than just that the unit exited. assert "Invoice Ninja" in response, f"unexpected response from /: {response}"
with subtest("one-time setup (migrate, seed, admin account) completed"):
machine.succeed("test -f /var/lib/invoiceninja/.initial-migration")
machine.succeed("test -f /var/lib/invoiceninja/.db-seeded")
machine.succeed("test -f /var/lib/invoiceninja/.admin-created")
with subtest("application shell is served over HTTP"): with subtest("web UI login page is served"):
response = machine.succeed("curl -sf http://localhost/") # The UI is a client-side-routed React SPA, so curl (no JS) only sees the same
assert "Invoice Ninja" in response, f"unexpected response from /: {response}" # index.html shell as "/" - this confirms the proxy/Laravel resolve the /login route
# to that shell instead of 404ing, which is what a browser needs before it can
# render the actual login form.
login_page = machine.succeed("curl -sf http://localhost/login")
assert "Invoice Ninja" in login_page, f"unexpected response from /login: {login_page}"
with subtest("web UI login page is served"): with subtest("invoiceninja-manage wrapper can run artisan commands"):
# The UI is a client-side-routed React SPA, so curl (no JS) only sees the same machine.succeed("invoiceninja-manage migrate:status")
# index.html shell as "/" - this confirms nginx/Laravel resolve the /login route
# to that shell instead of 404ing, which is what a browser needs before it can
# render the actual login form.
login_page = machine.succeed("curl -sf http://localhost/login")
assert "Invoice Ninja" in login_page, f"unexpected response from /login: {login_page}"
with subtest("invoiceninja-manage wrapper can run artisan commands"): with subtest("scheduler timer is enabled and schedule:run succeeds"):
machine.succeed("invoiceninja-manage migrate:status") # Without this, the app's dashboard reports "cron jobs are not enabled".
machine.succeed("systemctl is-active invoiceninja-scheduler.timer")
machine.succeed("systemctl start --wait invoiceninja-scheduler.service")
with subtest("scheduler timer is enabled and schedule:run succeeds"): with subtest("admin account can log in via the API"):
# Without this, the app's dashboard reports "cron jobs are not enabled". login_response = machine.succeed(
machine.succeed("systemctl is-active invoiceninja-scheduler.timer") "curl -sf -X POST http://localhost/api/v1/login "
machine.succeed("systemctl start --wait invoiceninja-scheduler.service") + "-H 'Content-Type: application/json' "
+ "-d '{\"email\":\"${adminEmail}\",\"password\":\"${adminPassword}\"}'"
)
assert "${adminEmail}" in login_response, f"login did not succeed: {login_response}"
with subtest("admin account can log in via the API"):
login_response = machine.succeed( nginx.start()
"curl -sf -X POST http://localhost/api/v1/login " caddy.start()
+ "-H 'Content-Type: application/json' "
+ "-d '{\"email\":\"${adminEmail}\",\"password\":\"${adminPassword}\"}'" check_invoiceninja(nginx, "nginx.service")
) check_invoiceninja(caddy, "caddy.service")
assert "${adminEmail}" in login_response, f"login did not succeed: {login_response}"
''; '';
} }