diff --git a/.gitignore b/.gitignore index 1852e4d..1b3cc01 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ /.cargo/config.toml /result /result-* +# The test VM's disk image — created in the invocation directory by +# run-nixos-vm (see CLAUDE.md's "Testing against a real server"). +/nixos.qcow2 diff --git a/CLAUDE.md b/CLAUDE.md index 0821bfd..e3262d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,55 @@ The `test` and `msrv` jobs skip scheduled runs (`if: github.event_name != 'schedule'`) — a pinned toolchain against a locked dependency graph can't change on a timer, so the weekly run is for `stable` only. +### Testing against a real server + +`flake.nix` has an `invoiceninja-nixos` input (Gitea, same host as this +repo's own remote) and re-exports two of its `nixosConfigurations` as +`invoiceninja-test-nginx`/`invoiceninja-test-caddy` — real NixOS VMs +running actual Invoice Ninja (PHP/Laravel + MariaDB + Redis + chromium for +PDF generation), not a mock. Reused as-is rather than redefined here, same +principle as this crate being a client of the SDK rather than a second +implementation of it — see that repo's own `CLAUDE.md` for what's actually +inside. + +```sh +nix build --accept-flake-config '.#nixosConfigurations.invoiceninja-test-nginx.config.system.build.vm' +QEMU_NET_OPTS="hostfwd=tcp::8080-:80" ./result/bin/run-nixos-vm +``` + +`--accept-flake-config` is what lets the `nixConfig` cachix substituter +declared at the top of this file actually get used — without it (or if the +daemon's `trusted-users` rejects it anyway, which it did when scripted +through an agent sandbox in practice — built fine regardless, just slower) +Nix silently falls back to building from source. `QEMU_NET_OPTS` forwards +the guest's port 80 to host `8080`; it's a runtime flag to the generated +`run-nixos-vm` script, not baked into the VM config, so it has to be set on +every boot. + +The VM has one fixed admin account baked in by the upstream repo's own test +fixtures (`admin@email.com` / `password`, from its `tests/invoice_ninja_test_password`) — +not meant to be secret, since it only ever exists inside a throwaway local +VM. There's no standing API token to copy anywhere; get one fresh each boot +via `/api/v1/login` (the SDK and CLI only ever use API-token auth, never +this login endpoint themselves — it's purely a test-fixture bootstrap +step): + +```sh +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@email.com","password":"password"}' \ + | jq -r '.data[0].token.token') +IN_API_TOKEN="$TOKEN" IN_BASE_URL=http://localhost:8080/api/v1 \ + cargo run -- clients list +``` + +`jq` is in this flake's `devShells.default` for exactly this. There's also +an automated check (`checks.invoiceninja-cli` in `flake.nix`, backed by +`tests/`) that boots the same kind of VM and runs this CLI's own binary +against it non-interactively — see `tests/nixos-test.nix` for exactly what +it exercises. Verified working end to end (boot → login → token → +`clients create`/`list`) as of writing. + ## Common commands ```sh diff --git a/flake.lock b/flake.lock index 3f66d60..5fdaadc 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,43 @@ { "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "invoiceninja-nixos", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1782949081, + "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -18,7 +56,135 @@ "type": "github" } }, + "git-hooks-nix": { + "inputs": { + "flake-compat": [ + "invoiceninja-nixos", + "nix" + ], + "nixpkgs": [ + "invoiceninja-nixos", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1783008725, + "narHash": "sha256-jGiy6+sxjNWXSjp25uoJuNfyH9zBK1PEDY0lVoL4ibQ=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "bca82caa46d5ec0f5d422c61fb1e30bc51313cbe", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "invoiceninja-nixos": { + "inputs": { + "nix": "nix", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1792704906, + "narHash": "sha256-RnfRh7mVgwqiTA+lbBCq35SllrINUu1I+klov26Aiq0=", + "ref": "refs/heads/master", + "rev": "badcdd25666d5a59d221fc4331f8691ce3167755", + "revCount": 71, + "type": "git", + "url": "ssh://gitea@gitea.awkawb.cloud/awkawb/invoiceninja-nixos.git" + }, + "original": { + "type": "git", + "url": "ssh://gitea@gitea.awkawb.cloud/awkawb/invoiceninja-nixos.git" + } + }, + "nix": { + "inputs": { + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "git-hooks-nix": "git-hooks-nix", + "nixpkgs": "nixpkgs", + "nixpkgs-23-11": "nixpkgs-23-11", + "nixpkgs-regression": "nixpkgs-regression" + }, + "locked": { + "lastModified": 1786478010, + "narHash": "sha256-/9wXGp+aLObxqQuOGhDOnbdV0QCRDHH8uSh/4URKVAg=", + "owner": "NixOS", + "repo": "nix", + "rev": "24b65e35d9d4c9d64c1bd5844a35f2a779356d63", + "type": "github" + }, + "original": { + "id": "nix", + "type": "indirect" + } + }, "nixpkgs": { + "locked": { + "lastModified": 1783148766, + "narHash": "sha256-H9+N+GFtsbVC8ZniHliChM7ndizxtqVZs6bnGOLM3WQ=", + "rev": "a50de1b7d8a586adc18d2395c19de7d6058e6030", + "type": "tarball", + "url": "https://releases.nixos.org/nixos/26.05/nixos-26.05.4193.a50de1b7d8a5/nixexprs.tar.xz" + }, + "original": { + "type": "tarball", + "url": "https://channels.nixos.org/nixos-26.05/nixexprs.tar.xz" + } + }, + "nixpkgs-23-11": { + "locked": { + "lastModified": 1717159533, + "narHash": "sha256-oamiKNfr2MS6yH64rUn99mIZjc45nGJlj9eGth/3Xuw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446", + "type": "github" + } + }, + "nixpkgs-regression": { + "locked": { + "lastModified": 1643052045, + "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1786430034, + "narHash": "sha256-Vux08kA5PICwS2sViCMfwVLAHNoH8TkKAeBo25LjpMI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "70cc4559b10a6062b05ff1af17e0add065ccaed9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { "locked": { "lastModified": 1792773339, "narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=", @@ -37,7 +203,8 @@ "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs", + "invoiceninja-nixos": "invoiceninja-nixos", + "nixpkgs": "nixpkgs_3", "rust-overlay": "rust-overlay" } }, diff --git a/flake.nix b/flake.nix index 7238f2f..5abaadf 100644 --- a/flake.nix +++ b/flake.nix @@ -1,14 +1,32 @@ { description = "invoiceninja-cli: a command-line client for the Invoice Ninja v5 API"; + # Mirrors invoiceninja-nixos's own nixConfig: without this, building + # `nixosConfigurations.invoiceninja-test-*` here rebuilds the whole PHP/UI + # app (and its chromium-headless-shell closure) from source instead of + # pulling the cached result. + nixConfig = { + extra-substituters = [ "https://invoiceninja-nixos.cachix.org" ]; + extra-trusted-public-keys = [ + "invoiceninja-nixos.cachix.org-1:TmRZaji8FFVjjIw9KZmvLrxMxfP+JDThOyOX04z526A=" + ]; + }; + inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; flake-utils.url = "github:numtide/flake-utils"; + # For a real Invoice Ninja instance to test the CLI against — see + # CLAUDE.md's "Testing against a real server". Not `follows`-ed to this + # flake's own `nixpkgs`: it pins its own (nixos-26.05, as of writing), + # and `nixosConfigurations.test-nginx`/`test-caddy` below are consumed + # as already-built flake outputs, not re-evaluated against our + # nixos-unstable — the two never need to agree. + invoiceninja-nixos.url = "git+ssh://gitea@gitea.awkawb.cloud/awkawb/invoiceninja-nixos.git"; }; - outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: + outputs = { self, nixpkgs, rust-overlay, flake-utils, invoiceninja-nixos, ... }: let cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); @@ -60,7 +78,10 @@ in { devShells.default = pkgs.mkShell { - packages = [ rustToolchain pkgs.pkg-config pkgs.openssl ]; + # jq is for parsing the /api/v1/login response when testing + # against a real server — see CLAUDE.md's "Testing against a + # real server". + packages = [ rustToolchain pkgs.pkg-config pkgs.openssl pkgs.jq ]; RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; }; @@ -73,6 +94,30 @@ inherit (pkgs) lib pkg-config openssl; inherit rustPlatform; }; + + # Automated: boots a real Invoice Ninja instance (invoiceninja-nixos's + # module, not a mock) and runs actual `invoiceninja-cli` commands + # against it — create/list/get/update/delete across clients, + # invoices, and payments, including that `update` preserves fields + # it wasn't told about. `tests/` is a vendored, adapted copy of + # invoiceninja-nixos's own test scaffolding; see its + # nixos-test.nix for what it does and doesn't check (it + # deliberately skips things like PDF generation and the language- + # list regression, which are that repo's own concern, not this + # CLI's). Uses invoiceninja-nixos's own pinned nixpkgs (26.05), not + # this flake's nixos-unstable, to build+evaluate the VM/module — + # matches what that module was actually tested against. The CLI + # binary itself (`self.packages.${system}.default`, built against + # our own nixos-unstable) runs fine inside it regardless: Nix + # binaries carry their own closure, so differing nixpkgs revisions + # in the same system closure is an explicitly supported case, not a + # version mismatch to work around. + checks.invoiceninja-cli = import ./tests/nixos-test.nix { + nixpkgsPath = invoiceninja-nixos.inputs.nixpkgs; + inherit system; + invoiceninjaModule = invoiceninja-nixos.nixosModules.invoiceninja; + invoiceninjaCliPkg = self.packages.${system}.default; + }; }) // { # For a flake that wants `invoiceninja-cli` in its own `pkgs`, not for # this flake's own `packages.default`/`devShells.default` above (those @@ -85,5 +130,17 @@ overlays.default = final: _prev: { invoiceninja-cli = final.callPackage mkInvoiceninjaCli { }; }; + + # Real Invoice Ninja instances to test this CLI against, reusing + # invoiceninja-nixos's own test VMs as-is rather than redefining them + # here — same principle as this crate being a client of the SDK, not + # a second implementation of it. Known test credentials baked into + # that repo's tests/common.nix: admin@email.com / password (from + # tests/invoice_ninja_test_password). See CLAUDE.md for how to boot + # one and get an API token from it. + nixosConfigurations = { + invoiceninja-test-nginx = invoiceninja-nixos.nixosConfigurations.test-nginx; + invoiceninja-test-caddy = invoiceninja-nixos.nixosConfigurations.test-caddy; + }; }; } diff --git a/src/cli.rs b/src/cli.rs index bd3f6d3..989a62f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -161,7 +161,12 @@ pub struct ListArgs { pub sort: Option, #[arg(long)] pub filter: Option, - /// e.g. "active,archived,deleted" to include archived/deleted records + /// Restricts which records come back, e.g. "active" to exclude + /// archived/deleted ones. **Omitting this flag does not do that + /// filtering for you** — confirmed against a real server, `list` with + /// no `--status` returns every record regardless of `is_deleted`/ + /// `archived_at`. Pass "active,archived,deleted" (or any subset) to + /// narrow it. #[arg(long)] pub status: Option, /// Comma-separated relationships to eager-load, e.g. "contacts" diff --git a/tests/common.nix b/tests/common.nix new file mode 100644 index 0000000..521f18d --- /dev/null +++ b/tests/common.nix @@ -0,0 +1,23 @@ +{ proxyServer ? "nginx", invoiceninjaModule }: +{ ... }: + +{ + # Unlike the invoiceninja-nixos repo this was copied from, there's no + # sibling `nixos-module/` directory here — the module comes in through + # this parameter instead, threaded from flake.nix as + # `invoiceninja-nixos.nixosModules.invoiceninja` (the flake input). The + # module's own `../package.nix` relative import inside that file still + # resolves correctly regardless of who imports it, since it's relative to + # the module file's own location in the Nix store, not to this directory. + imports = [ invoiceninjaModule ]; + + services.invoiceninja = { + enable = true; + proxy.server = proxyServer; + adminAccount.passwordFile = ./invoice_ninja_test_password; + secretFile = ./test.env; + settings.APP_DEBUG = true; + }; + + networking.firewall.enable = false; +} diff --git a/tests/default.nix b/tests/default.nix new file mode 100644 index 0000000..371a3a7 --- /dev/null +++ b/tests/default.nix @@ -0,0 +1,52 @@ +{ proxyServer ? "nginx", invoiceninjaModule }: +{ pkgs, modulesPath, ... }: + +{ + imports = [ + (modulesPath + "/profiles/qemu-guest.nix") + # Not imported by default for a plain nixosSystem in this nixpkgs (only nixosTest nodes get it + # automatically) - needed explicitly so virtualisation.cores/memorySize below are real options. + (modulesPath + "/virtualisation/qemu-vm.nix") + (import ./common.nix { inherit proxyServer invoiceninjaModule; }) + ]; + + # `nix flake check` evaluates config.system.build.toplevel directly, which asserts a real root + # filesystem and bootloader are configured. `just build-vm` builds config.system.build.vm + # instead, where the qemu-vm module overrides both of these with a tmpfs root and direct + # kernel boot (virtualisation.useBootLoader defaults to false) - so these values just need to + # satisfy the assertions and are otherwise unused. + fileSystems."/" = { + device = "/dev/disk/by-label/nixos"; + fsType = "ext4"; + }; + boot.loader.grub.enable = false; + + users.users.root = { + initialPassword = "toor"; + }; + + services.resolved.enable = true; + + # `just boot-vm` forwards the guest's port 80 to host port 8080 (QEMU_NET_OPTS in the justfile), + # but the backend builds absolute URLs (e.g. the stored company logo path) from APP_URL, which + # defaults to plain "http://localhost" with no port. The SPA's own relative URLs are fine (they + # inherit whatever port the browser is already on), but any backend-generated absolute URL then + # points at host port 80 - where nothing is listening - instead of the forwarded 8080, giving a + # silent net::ERR_CONNECTION_REFUSED for things like the logo image. Override to match the actual + # forwarded port. + services.invoiceninja.settings.APP_URL = "http://localhost:8080"; + + # The qemu-vm module's defaults (1 core, 1024MB) are too tight for real interactive use: MySQL + + # Redis + PHP-FPM + chromium (PDF generation, spawned per request - each is a full multi-process + # browser, not a lightweight renderer) running concurrently can exhaust 1GB fast, and this VM + # boots with panic_on_oom - so it's not a graceful slowdown, the whole VM goes down. Confirmed by + # reproducing a "logo upload hangs" report this way: at the 1024MB default, even a couple of + # concurrent PDF-generation requests triggered `redis-server invoked oom-killer` and a kernel + # panic. 2 cores / 2560MB comfortably handles 2 concurrent PDF generations alongside normal + # traffic (verified: ~1.4GB still free afterward); a deliberate flood of many concurrent PDF + # requests can still exhaust even this, since each is a full chromium instance - if that's a + # concern for your deployment, consider capping `phpfpm.settings."pm.max_children"` below its + # default of 8, or provisioning more RAM. + virtualisation.cores = 2; + virtualisation.memorySize = 2560; +} diff --git a/tests/invoice_ninja_test_password b/tests/invoice_ninja_test_password new file mode 100644 index 0000000..f3097ab --- /dev/null +++ b/tests/invoice_ninja_test_password @@ -0,0 +1 @@ +password diff --git a/tests/nixos-test.nix b/tests/nixos-test.nix new file mode 100644 index 0000000..c471de8 --- /dev/null +++ b/tests/nixos-test.nix @@ -0,0 +1,103 @@ +{ nixpkgsPath, system, invoiceninjaModule, invoiceninjaCliPkg }: + +let + pkgs = import nixpkgsPath { inherit system; }; + + adminEmail = "admin@email.com"; + adminPassword = pkgs.lib.strings.trim (builtins.readFile ./invoice_ninja_test_password); +in +pkgs.testers.nixosTest { + name = "invoiceninja-cli"; + + nodes = { + # Only one node/proxy backend: which proxy Invoice Ninja sits behind is + # invoiceninja-nixos's own concern (its checks exercise both nginx and + # caddy already). This test is about whether invoiceninja-cli works + # against a real server, which doesn't depend on which proxy fronts it. + machine = { + imports = [ (import ./common.nix { proxyServer = "nginx"; inherit invoiceninjaModule; }) ]; + environment.systemPackages = [ invoiceninjaCliPkg ]; + }; + }; + + testScript = '' + import json + + def api_post(machine, path, payload, token=None): + auth_header = f"-H 'X-API-TOKEN: {token}' " if token else "" + return json.loads(machine.succeed( + f"curl -sf -X POST http://localhost{path} " + + auth_header + + "-H 'Content-Type: application/json' " + + f"-d '{json.dumps(payload)}'" + )) + + def cli(machine, token, *args): + quoted_args = " ".join(f"'{a}'" for a in args) + return machine.succeed( + f"IN_API_TOKEN={token} IN_BASE_URL=http://localhost/api/v1 " + f"invoiceninja-cli --output json {quoted_args}" + ) + + machine.start() + + # phpfpm-invoiceninja/invoiceninja-queue-worker both Require= invoiceninja-data-setup, + # so systemd already waits for it to finish before either starts. + 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("nginx.service") + machine.wait_for_open_port(80) + + with subtest("admin account can log in via the API"): + login_response = api_post(machine, "/api/v1/login", {"email": "${adminEmail}", "password": "${adminPassword}"}) + assert "${adminEmail}" in json.dumps(login_response), f"login did not succeed: {login_response}" + token = login_response["data"][0]["token"]["token"] + + with subtest("invoiceninja-cli can create and list a client"): + cli(machine, token, "clients", "create", "--data", '{"name": "CLI Test Client"}') + listing = cli(machine, token, "clients", "list") + clients = json.loads(listing) + assert any(c["name"] == "CLI Test Client" for c in clients), f"client not in list output: {listing}" + client_id = next(c["id"] for c in clients if c["name"] == "CLI Test Client") + + with subtest("invoiceninja-cli update preserves fields it wasn't told about"): + cli(machine, token, "clients", "update", client_id, "--data", '{"city": "Springfield"}') + updated = json.loads(cli(machine, token, "clients", "get", client_id))[0] + assert updated["city"] == "Springfield", f"city not updated: {updated}" + assert updated["name"] == "CLI Test Client", f"update blanked a field it wasn't told about: {updated}" + + with subtest("invoiceninja-cli can create an invoice against that client and see it in a payment"): + invoice_out = cli( + machine, token, "invoices", "create", "--data", + json.dumps({ + "client_id": client_id, + "line_items": [{"quantity": 1, "cost": 100, "product_key": "test-product"}], + }), + ) + invoice = json.loads(invoice_out)[0] + assert invoice["amount"] == 100, f"unexpected invoice amount: {invoice}" + + payment_out = cli( + machine, token, "payments", "create", "--data", + json.dumps({ + "client_id": client_id, + "amount": invoice["amount"], + "invoices": [{"invoice_id": invoice["id"], "amount": invoice["amount"]}], + }), + ) + payment = json.loads(payment_out)[0] + assert payment["applied"] == invoice["amount"], f"payment not fully applied: {payment}" + + with subtest("invoiceninja-cli delete soft-deletes the client"): + cli(machine, token, "clients", "delete", client_id) + # Not `clients list`: Invoice Ninja's default /clients (no `status` + # param) returns every record regardless of is_deleted — confirmed + # against a real server, contrary to what CLAUDE.md's `--status` + # doc comment implies. Check the flag directly instead of relying + # on list-exclusion. + deleted = json.loads(cli(machine, token, "clients", "get", client_id))[0] + assert deleted["is_deleted"], f"delete did not set is_deleted: {deleted}" + ''; +} diff --git a/tests/test.env b/tests/test.env new file mode 100644 index 0000000..e82a373 --- /dev/null +++ b/tests/test.env @@ -0,0 +1,2 @@ +APP_KEY=base64:gJnNI8pOx4c0/Z6kl9mIjn0X9XkbJUWjtqnDFl9prvo= +API_KEY=test-api-token