From 38ec3c76f7f2f7fa7ccb031e7e9bf89d1f663835 Mon Sep 17 00:00:00 2001 From: Andrew Bryant Date: Sun, 27 Sep 2026 17:03:42 -0400 Subject: [PATCH] Initial import of HortusFox Nix flake packaging Packages HortusFox from source via php.buildComposerProject and provides a NixOS module (services.hortusfox-web) to deploy it as a full service (mariadb + phpfpm + nginx/caddy + ACME + cron timers). Co-Authored-By: Claude Sonnet 5 --- .editorconfig | 15 + .gitignore | 2 + CLAUDE.md | 56 ++++ flake.lock | 77 +++++ flake.nix | 66 ++++ justfile | 40 +++ nixos-module/default.nix | 485 ++++++++++++++++++++++++++++++ package.nix | 30 ++ tests/default.nix | 49 +++ tests/hortusfox_web_test_password | 1 + tests/test.env | 2 + tests/vm-test.nix | 96 ++++++ 12 files changed, 919 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 justfile create mode 100644 nixos-module/default.nix create mode 100644 package.nix create mode 100644 tests/default.nix create mode 100644 tests/hortusfox_web_test_password create mode 100644 tests/test.env create mode 100644 tests/vm-test.nix diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2b1303e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# nix files +[*.nix] +indent_style = space +indent_size = 2 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7714107 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/result +/nixos.qcow2 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..401f75c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A Nix flake that packages [HortusFox](https://github.com/danielbrendel/hortusfox-web) (a self-hosted, PHP/Laravel-style collaborative plant-management app) from source, and provides a NixOS module to deploy it as a full service (mariadb + phpfpm + nginx/caddy + ACME + cron timers). + +- `flake.nix` — flake entry point: exposes `packages.hortusfox-web`, `overlays.default`, `nixosModules.default`, `devShells.default`, `checks..{build,vm-test,vm-test-caddy}`, and a throwaway `nixosConfigurations.test` VM for manual testing. Only `packages`/`devShells`/`checks` are produced per-system via `utils.lib.eachSystem`; `overlays.default`, `nixosModules.default`, and `nixosConfigurations.test` are merged in afterwards with `//` since those output types are keyed by name, not by system — don't move them back inside the `eachSystem` callback. +- `package.nix` — the derivation. Uses `php.buildComposerProject` to fetch `danielbrendel/hortusfox-web` from GitHub and vendor its Composer deps. Installs to `$out/share/php/hortusfox-web/`. Both `src.hash` and `vendorHash` are fixed-output hashes pinned to a specific upstream `version`/`rev` — bumping the app version means updating both. +- `nixos-module/default.nix` — the `services.hortusfox-web` NixOS module (see Architecture below). +- `tests/default.nix` — a minimal NixOS system config used as the `nixosConfigurations.test` VM (built/booted via `just build-vm`/`just boot-vm`), plus a secrets file (`tests/test.env`) and an admin password file (`tests/hortusfox_web_test_password`, currently `password`) consumed by the module. +- `tests/vm-test.nix` — `{ pkgs, nixosModule, proxyServer }: pkgs.testers.nixosTest { ... }`, the actual automated boot test. `flake.nix` calls it once per proxy backend (`checks.vm-test` for nginx, `checks.vm-test-caddy` for caddy) rather than inlining the test twice. +- `justfile` — VM dev-loop wrapper around `nix build`/QEMU. + +There is no application source code in this repo — HortusFox itself is fetched from upstream at build time. This repo is purely packaging/deployment glue. + +## Commands + +```bash +just build-vm # nix build the test VM (nixosConfigurations.test), via `nom build` +just boot-vm # run the built VM in-terminal (QEMU, serial console, host 127.0.0.1:8080 -> guest 80) +just clean # kill this project's own qemu instance, remove ./result and ./nixos.qcow2 + +nix build .#hortusfox-web # build just the HortusFox package +nix build # same (default package) +nix develop # dev shell: matching PHP+extensions build, composer, just, nix-output-monitor +nix flake show # list outputs +nix flake check # evaluate all outputs; builds the package + runs both nixosTest VM checks +nix flake check -L # same, with full build/VM logs streamed (slow: boots two real VMs) +``` + +`justfile`'s QEMU networking is bound to `127.0.0.1` explicitly (`hostfwd=tcp:127.0.0.1:8080-:80`) — a bare `hostfwd=tcp::8080-:80` (empty host address) binds `0.0.0.0`, exposing this throwaway VM (root password `toor`, firewall disabled, seeded test credentials) to the whole network. Likewise `just clean` kills only this project's own qemu process (`pkill -f "$(pwd)/nixos.qcow2"`), not `pkill qemu` generally, which would kill any unrelated QEMU VM on the host. + +`checks..vm-test`/`vm-test-caddy` (`tests/vm-test.nix`) boot the module with `proxy.ssl = false` (no ACME/internet in the sandbox), `settings.APP_DEBUG = true` (required for migrations, see below), and a real `adminAccount.passwordFile`, then assert: the backend's own unit + phpfpm + mysql all come up; port 80 responds; the response contains no `"Asatru PHP - Exception"` marker (Asatru renders uncaught exceptions as HTML without failing the HTTP status — see below); a real static asset (`/img/pattern.jpg`) is served with no `Permission denied` in the proxy's own log; and — the strongest assertion — a full cookie-jar login as the seeded admin actually lands away from `/auth` rather than bouncing back to it. Each of these exists because a weaker check was previously fooled by a real bug during development (see the architecture notes below); don't weaken or remove any of them without understanding why. There's no other test suite; there's no lint/format command configured. `just --list` shows the same recipes as above. + +## Architecture (NixOS module) + +The module (`nixos-module/default.nix`, `config.services.hortusfox-web`) wires HortusFox into a full host: + +- **Read-only store vs. writable state, reconciled at boot.** The Composer-built package (`cfg.package`, defaulting to `pkgs.callPackage ../package.nix { }`) lives read-only in the Nix store (`appRoot`). A `hortusfox-setup` oneshot systemd service (before phpfpm starts) mirrors it into a runtime tree (`runDir = /run/hortusfox/www`, tmpfs) via a real, full `cp -r` — **not** `cp -rs` (per-file symlinks back into the store). This was a real, previously-shipped bug: PHP resolves `__DIR__`/`__FILE__` *through* a symlink to its real target, so a symlinked file under `runDir` reports its own location as the read-only store, not `runDir`. HortusFox computes its whole app root (and from there, `.env` discovery) via `__DIR__`-relative paths, so every symlinked file silently looked for config in the store instead of `runDir` — breaking DB/session config on every real web request while looking fine on `curl`. Verified by a minimal local repro before fixing; don't reintroduce per-file symlinking here. The tradeoff is real: this duplicates the app into tmpfs on every boot instead of deduping via the store, which is an acceptable cost for a small PHP app. +- **`cp` carries the store's read-only mode onto the copy.** Every directory `cp -r` creates inherits the store's `555` (no write) mode, so the script reopens them for writing (`find "${runDir}" -type d -exec chmod u+w {} +`, directories only, never individual files) before it can `rm`/relink anything under them. +- **Writable subdirs are seeded, not just replaced.** `cache`, `app/logs`, `public/img`, `public/attachments`, `public/backup`, `public/themes` get swapped for symlinks into persistent state (`stateDir = /var/lib/hortusfox`). Two of these (`public/img`, `public/themes`) ship real default assets in the package itself (login background, default theme) — naively replacing them with an empty persistent dir on first boot silently deletes those defaults and breaks page rendering (`Exception: Resource "img/pattern.jpg" not found`). The fix: before symlinking, `cp -rn` (no-clobber) the package's copy of each writable dir into `stateDir/` first, so shipped defaults get seeded in (and any *new* ones added by a future upstream bump get picked up too) without ever touching a file the user/app has already written. +- **Nested writable dirs need their own tmpfiles rule for the parent, too.** `systemd.tmpfiles.rules` declares a `d` line per entry in `writableDirs`, but `app/logs` and the four `public/*` entries are nested under `app`/`public`, which have no rule of their own. `systemd-tmpfiles` auto-vivifies a missing parent for a nested `d` line, but does so with default `root:root 0755` ownership and does **not** reliably go on to create the requested leaf — so `app/logs` (and all of `public/*`) silently never got created, and the hortusfox-setup service's own `mkdir -p "${stateDir}/${d}"` would fail with `Permission denied` trying to create the leaf inside a root-owned, not-writable-by-`cfg.user` parent. Fixed by computing `writableDirParents` (the unique set of intermediate dirs, e.g. `app`, `public`) and giving each its own explicit tmpfiles `d` rule, ordered before the leaves. If you add a new *nested* entry to `writableDirs`, make sure its parent is covered by this too. +- **`RuntimeDirectoryMode` and `stateDir`'s tmpfiles rules must both stay world-readable (`0755`).** nginx/caddy run as their own user, not `cfg.user`, and need to `stat`/read everything under `runDir` *and* into `stateDir`'s writable subdirs (`public/img` etc., reached through symlinks from `runDir`) to serve `public/` directly. At `0750` the proxy can't even traverse the directory, `tryFiles`/`file_server` stats fail with `Permission denied`, and — this is the trap — the site can still *appear* to work, because PHP-FPM (running as `cfg.user`) serves pages fine over fastcgi regardless of the proxy's own filesystem access; only requests for *static* files (not routed through PHP) actually exercise this path. A plain `curl` on `/` won't catch it; the VM tests fetch a real static asset and grep the proxy's own log for `Permission denied` specifically because of it. Nothing secret is exposed by the world-readable mode: `.env` is a symlink to `stateDir/.env`, which stays `600` independently. +- **`.env` rendering.** A base attrset of env vars (`envVars`) is merged with the freeform `cfg.settings` attrset (`envVars // cfg.settings`, so `settings` always wins) and serialized to KEY=VALUE lines via `pkgs.writeText`. `envVars` isn't just our curated options (`domain`/`timezone`/`database.name`/`smtp.*`) — it mirrors the *entire* default set from upstream's `.env.example` (app metadata, session duration, photo-resize factors, `SMTP_AUTH`, `APP_BASEDIR`, ...). Leaving any of these out isn't cosmetic: several app code paths read `$_ENV[...]` directly with no `isset()` guard (`APP_BASEDIR` in particular), so an omitted key produces a `PHP Warning: Undefined array key` on *every single request*, not just a missing feature. `APP_VERSION` derives from `package.version` rather than being duplicated by hand, so it can't drift from `package.nix` on a version bump. Secrets are handled separately: if `cfg.secretFile` is set, its contents are `cat`'d verbatim onto the end of the rendered file at runtime — it is *not* parsed or filtered, so it can carry arbitrary keys (`APP_KEY`, `SMTP_PASSWORD`, custom API keys, …). The rendered file lands in `stateDir/.env`, then is symlinked into `runDir`. +- **`settings.APP_DEBUG` matters more than it looks.** Upstream's `asatru` CLI (`app root`/`asatru` file) refuses to run at all unless `APP_DEBUG` is true — it just prints a warning and exits 0. That means the `hortusfox-setup` service's `php asatru migrate:fresh`/`migrate:upgrade` calls are silent no-ops unless `settings.APP_DEBUG = true` (or it's set via `secretFile`). The module's own default is `APP_DEBUG = false`, so **migrations do not run out of the box** — this is why `tests/default.nix` and both `vm-test`s explicitly set `settings.APP_DEBUG = true`. +- **First-boot vs. upgrade migrations.** `stateDir/.installed` is the marker: absent → `php asatru migrate:fresh` (destructive schema create) runs and then creates the marker; present → `php asatru migrate:upgrade || true` runs instead. This is the guard against re-running a destructive migration against real data — don't remove it without understanding why it's there. +- **`adminAccount.passwordFile` actually seeds an admin account.** This was previously a documented no-op; it isn't anymore, because there is no other way to log in at all. `migrate:fresh` creates schema only, no rows. HortusFox has no CLI/seed hook of its own, and its only "create user" flow lives in the admin panel — which requires already being logged in as an admin. So on first boot only (same `.installed` guard as migrations), if this is set, the setup script bcrypt-hashes the file's contents via `php -r '...password_hash(...)...'` (matching `UserModel`'s own hashing) and `INSERT`s a row directly into the `UserModel` table (name `admin`, email `admin@`, admin flag set), bypassing the app's normal create-then-promote two-step flow since neither step is reachable yet. Both `vm-test`s exercise a full cookie-jar login against this seeded account, including scraping the CSRF token from `/auth` and checking the final URL isn't `/auth` again — page content alone isn't reliable here, since the dashboard's own (hidden) change-password form makes "does the response contain a password field" a false-positive-prone check. +- **Serving.** `proxy.server` selects the reverse-proxy backend: `"nginx"` (default) or `"caddy"`, each gated by `lib.mkIf (cfg.proxy.server == "...")`. `proxy.ssl` (default `true`) toggles HTTPS/ACME on the vhost — nginx via `forceSSL`/`enableACME`, caddy by prefixing the site address with `http://` (which disables its automatic HTTPS for that vhost specifically) — set it to `false` for local/test deployments with no real domain or internet access to reach Let's Encrypt (both `vm-test`s and `tests/default.nix` do this). `services.phpfpm.pools.hortusfox` runs as `cfg.user`/`cfg.group` regardless of proxy backend, but its fastcgi socket's `listen.owner`/`listen.group` must match whichever proxy is actually enabled (`config.services.caddy.user`/`.group` vs `config.services.nginx.user`/`.group`) — this was hardcoded to nginx before caddy support existed. nginx uses explicit `tryFiles`/location blocks (an Asatru-framework-style rewrite-to-`index.php` rule, mirroring upstream's `.htaccess`); caddy's `php_fastcgi` directive covers the same rewrite-then-serve behavior natively. Dotfiles are denied on both. When `proxy.ssl = true`, the deploying host config must set nginx's `security.acme.{acceptTerms,defaults.email}` or caddy's `services.caddy.email` globally (see comment at the bottom of the module). +- **Cron.** HortusFox's cron endpoints (task overdue/tomorrow/recurring reminders, calendar reminders, auto-backup) are driven by `curl`ing the app's own HTTP cron routes from systemd timers (`hortusfox-cron-` service+timer pairs), rather than an in-process scheduler. These live in the *same* `systemd.services` attrset as `hortusfox-setup`/`phpfpm-hortusfox` (merged in with `//`, generated via `lib.mapAttrs'` over `cronjobs`) — keep it that way; splitting the cron services into a second top-level `systemd.services = ...;` binding is a Nix duplicate-attribute error (partial-path assignments like `systemd.services.hortusfox-setup = ...` can't coexist with a later full `systemd.services = ...` reassignment in the same module). + +## Current state / known issues + +- `nix flake check` passes cleanly for all outputs (`packages`, `checks.build`, `checks.vm-test`, `checks.vm-test-caddy`, `overlays`, `nixosModules`, `nixosConfigurations.test`), and the VM actually serves a working, loggable-into site end-to-end for both proxy backends (verified via the automated `vm-test`s and by booting `nixosConfigurations.test` manually and logging in over HTTP with `tests/hortusfox_web_test_password`). Note `tests/default.nix` carries a `fileSystems."/"`/`boot.loader.grub.devices`/`security.acme` stanza purely so `config.system.build.toplevel` (what `nix flake check` builds) evaluates — the VM is actually built and run via `.config.system.build.vm` (see `justfile`'s `build-vm`/`boot-vm`), which doesn't use any of that. +- Upstream's own templates still emit a `PHP Deprecated: htmlspecialchars(): Passing null to parameter #1` notice on some pages (PHP 8.1+ deprecation for `htmlspecialchars(null)`). Harmless and not something this repo can fix — it's app code, not packaging. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3bf95df --- /dev/null +++ b/flake.lock @@ -0,0 +1,77 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1786599213, + "narHash": "sha256-yNJd40f11EzXBjSByCB7IPpeFFAdeoSKKM67dGkfFoU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "systems": "systems", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..0d3ce8b --- /dev/null +++ b/flake.nix @@ -0,0 +1,66 @@ +{ + description = "HortusFox — self-hosted plant manager, from source"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs.utils.url = "github:numtide/flake-utils"; + inputs.systems.url = "github:nix-systems/default"; + + outputs = + { self, nixpkgs, utils, systems, ... }: + utils.lib.eachSystem (import systems) (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { packages = { + hortusfox-web = pkgs.callPackage ./package.nix { }; + default = pkgs.callPackage ./package.nix { }; + }; + + # Matches the PHP build used by services.hortusfox-web (see + # nixos-module/default.nix's phpPackage) plus composer, for working + # on package.nix itself (bumping version/hashes, testing the + # Composer install) or poking at the app's PHP directly -- `just`/ + # `nom` are what the justfile recipes assume are on PATH. + devShells.default = pkgs.mkShell { + packages = [ + pkgs.just + pkgs.nix-output-monitor + (pkgs.php83.buildEnv { + extensions = ({ enabled, all }: + enabled ++ (with all; [ pdo_mysql mysqli gd exif fileinfo mbstring curl zip openssl ])); + }) + pkgs.php83Packages.composer + ]; + }; + + # `nix flake check` builds the package to catch breakage early. + checks = { + build = pkgs.callPackage ./package.nix { }; + + vm-test = import ./tests/vm-test.nix { + inherit pkgs; + nixosModule = self.nixosModules.default; + proxyServer = "nginx"; + }; + + vm-test-caddy = import ./tests/vm-test.nix { + inherit pkgs; + nixosModule = self.nixosModules.default; + proxyServer = "caddy"; + }; + }; + }) // { + # Not per-system: an overlay is keyed by name, not by system. + overlays.default = final: prev: { hortusfox-web = final.callPackage ./package.nix { }; }; + + # Not per-system: a module is keyed by name, not by system. + nixosModules.default = import ./nixos-module; + + # Throwaway VM for manual testing (`just build-vm` / `just boot-vm`). + # Not per-system: nixosConfigurations is keyed by config name, not system. + nixosConfigurations.test = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ ./tests ]; + }; + }; +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..da90cb6 --- /dev/null +++ b/justfile @@ -0,0 +1,40 @@ +set quiet := true + +# Passed to QEMU in boot-vm +export QEMU_KERNEL_PARAMS := "console=ttyS0" +# Bound to 127.0.0.1 explicitly: a bare "hostfwd=tcp::8080-:80" (empty host +# address) binds 0.0.0.0, exposing this throwaway VM (root password "toor", +# firewall disabled, test credentials) to the whole network, not just this +# machine. +export QEMU_NET_OPTS := "hostfwd=tcp:127.0.0.1:8080-:80" +export QEMU_OPTS := "-nographic" + +[private] +_default: + just --list + +[doc('Clean build artifacts and shutdown running virtual machines')] +[group('maintenance')] +clean: + #!/usr/bin/env bash + rm result > /dev/null 2>&1 + # Scoped to this project's own disk image, not `pkill qemu` -- a bare + # match on the process name would kill *any* QEMU VM on the host (this + # one's forwarded port is the only reason we need to kill anything). + pkill -f "$(pwd)/nixos.qcow2" > /dev/null 2>&1 + rm nixos.qcow2 > /dev/null 2>&1 + exit 0 + +[doc('Build virtual machine for testing')] +[group('main')] +build-vm: clean + nom build ".#nixosConfigurations.test.config.system.build.vm" + +[doc('Run virtual machine in current terminal')] +[group('main')] +boot-vm: + #!/usr/bin/env bash + [ -x result/bin/run-nixos-vm ] && \ + ./result/bin/run-nixos-vm + reset + diff --git a/nixos-module/default.nix b/nixos-module/default.nix new file mode 100644 index 0000000..95f2ce7 --- /dev/null +++ b/nixos-module/default.nix @@ -0,0 +1,485 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services."hortusfox-web"; + + # The app's own canonical URL (used for login/session redirects and cron + # requests) must track proxy.ssl, or a plain-HTTP deployment (proxy.ssl = + # false, e.g. the test VM) redirects users to an https:// URL nothing is + # listening on. + scheme = if cfg.proxy.ssl then "https" else "http"; + portSuffix = if cfg.port == null then "" else ":${toString cfg.port}"; + appUrl = "${scheme}://${cfg.domain}${portSuffix}"; + + # PHP with the extensions HortusFox needs (GD for photo resize, pdo_mysql, etc.) + phpPackage = cfg.phpPackage.buildEnv { + extensions = ({ enabled, all }: + enabled ++ (with all; [ pdo_mysql mysqli gd exif fileinfo mbstring curl zip openssl ])); + }; + + package = cfg.package; + appRoot = "${package}/share/php/hortusfox-web"; + + runDir = "/run/hortusfox/www"; # ephemeral symlink tree (tmpfs) + stateDir = "/var/lib/hortusfox"; # persistent writable data + + # Writable subdirs that must survive reboots. + writableDirs = [ + "cache" "app/logs" + "public/img" "public/attachments" "public/backup" "public/themes" + ]; + + # Intermediate parent dirs of the above (e.g. "app", "public") that need + # their own explicit tmpfiles rule too: systemd-tmpfiles auto-vivifies a + # missing parent when processing a nested `d` line, but does so with + # default (root:root 0755) ownership and does NOT reliably go on to + # create the requested leaf itself -- so a nested writableDirs entry + # whose parent has no rule of its own silently never gets created. + writableDirParents = lib.unique (builtins.filter (p: p != null) (map + (d: + let parts = lib.splitString "/" d; + in if builtins.length parts > 1 then lib.concatStringsSep "/" (lib.init parts) else null) + writableDirs)); + + # Render one KEY=VALUE .env line. Booleans/ints/floats are left bare so + # Asatru's datatype auto-detection picks them up as such rather than string. + mkEnvLine = name: value: + let + rendered = + if builtins.isBool value then (if value then "true" else "false") + else if builtins.isInt value then toString value + else if builtins.isFloat value then toString value + else ''"${toString value}"''; + in + "${name}=${rendered}"; + + # Base .env content; `settings` can override or extend any of this. Beyond + # our own curated options (domain/timezone/db/smtp), this also seeds every + # other key from upstream's .env.example that app code reads directly from + # $_ENV without an isset() guard -- e.g. APP_BASEDIR, omitted here before, + # produced a "PHP Warning: Undefined array key" on every single request. + envVars = { + APP_NAME = "HortusFox"; + APP_VERSION = package.version or "unknown"; + APP_AUTHOR = "Daniel Brendel"; + APP_CONTACT = "dbrendel1988@gmail.com"; + APP_DEBUG = false; + APP_BASEDIR = ""; + APP_LANG = "en"; + APP_WORKSPACE = "My workspace"; + APP_OVERDUETASK_HOURS = 10; + APP_CRONJOB_MAILLIMIT = 5; + APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web"; + APP_SERVICE_URL = "https://www.hortusfox.com"; + APP_GITHUB_SPONSOR = "https://github.com/sponsors/danielbrendel"; + APP_DONATION_KOFI = "https://ko-fi.com/danielbrendel"; + APP_URL = appUrl; + APP_TIMEZONE = cfg.timezone; + + SESSION_ENABLE = true; + SESSION_DURATION = 32532000; + # SESSION_NAME deliberately omitted: the app only uses it behind an + # isset() guard, so "unset" (fall back to PHP's own default) is safe. + + PHOTO_RESIZE_FACTOR_DEFAULT = 1.0; + PHOTO_RESIZE_FACTOR_1 = 0.5; + PHOTO_RESIZE_FACTOR_2 = 0.4; + PHOTO_RESIZE_FACTOR_3 = 0.4; + PHOTO_RESIZE_FACTOR_4 = 0.3; + PHOTO_RESIZE_FACTOR_5 = 0.2; + + DB_ENABLE = true; + DB_DRIVER = "mysql"; + DB_HOST = "localhost"; + DB_PORT = 3306; + DB_DATABASE = cfg.database.name; + DB_USER = cfg.user; + DB_PASSWORD = ""; + DB_CHARSET = "utf8mb4"; + + SMTP_AUTH = true; + SMTP_FROMNAME = cfg.smtp.fromName; + SMTP_FROMADDRESS = cfg.smtp.fromAddress; + SMTP_HOST = cfg.smtp.host; + SMTP_PORT = cfg.smtp.port; + SMTP_USERNAME = cfg.smtp.username; + SMTP_ENCRYPTION = cfg.smtp.encryption; + + LOG_ENABLE = true; + } // cfg.settings; + + envFile = pkgs.writeText "hortusfox.env" ( + lib.concatStringsSep "\n" (lib.mapAttrsToList mkEnvLine envVars) + "\n" + ); + + # curl'd cron endpoints -> (name, path, calendar spec) + cronjobs = { + "tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; }; + "tasks-tomorrow" = { path = "/cronjob/tasks/tomorrow"; when = "*-*-* 06,18:00:00"; }; + "tasks-recurring" = { path = "/cronjob/tasks/recurring"; when = "hourly"; }; + "calendar-reminder"= { path = "/cronjob/calendar/reminder";when = "*-*-* 07:00:00"; }; + "backup-auto" = { path = "/cronjob/backup/auto"; when = "*-*-* 03:00:00"; }; + }; +in { + options.services."hortusfox-web" = { + enable = lib.mkEnableOption "HortusFox plant management system"; + + domain = lib.mkOption { + type = lib.types.str; + default = "localhost"; + example = "plants.example.com"; + description = "FQDN served over HTTPS (used for the nginx vhost + ACME)."; + }; + + port = lib.mkOption { + type = lib.types.nullOr lib.types.port; + default = null; + example = 8080; + description = '' + Port to append to the app's own canonical URL (APP_URL, used for + login/session redirects and cron requests). Leave unset for a normal + deployment reachable on the standard 80/443. Set this when the proxy + is only reachable through a non-standard or forwarded port -- e.g. + the QEMU test VM's hostfwd (see justfile) -- otherwise redirects + drop the port and land somewhere unreachable. This does not change + what port nginx/caddy actually listens on. + ''; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ../package.nix { }; + description = "HortusFox package to deploy."; + }; + + proxy.server = lib.mkOption { + type = lib.types.enum [ "nginx" "caddy" ]; + default = "nginx"; + description = "Reverse proxy/webserver to front phpfpm."; + }; + + proxy.ssl = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to force HTTPS and request an ACME certificate for the + vhost. Disable for local/test deployments that have no real domain + or internet access to reach Let's Encrypt. + ''; + }; + + phpPackage = lib.mkOption { + type = lib.types.package; + default = pkgs.php83; + description = "Base PHP used to build the phpfpm environment."; + }; + + user = lib.mkOption { type = lib.types.str; default = "hortusfox"; }; + group = lib.mkOption { type = lib.types.str; default = "hortusfox"; }; + + timezone = lib.mkOption { type = lib.types.str; default = "UTC"; }; + + database.name = lib.mkOption { type = lib.types.str; default = "hortusfox"; }; + + secretFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/agenix/hortusfox.env"; + description = '' + File with secret KEY=VALUE lines (APP_KEY, SMTP_PASSWORD, API keys, + ...) appended verbatim to the generated .env at runtime. Keep it out + of the Nix store in production (e.g. agenix/sops-nix); a store path + is fine for throwaway test configs. + ''; + }; + + adminAccount.passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + File containing the initial admin account's password (plaintext, + no trailing newline needed). HortusFox has no CLI/seed hook of its + own for this -- its only "create user" flow lives in the admin + panel, which itself requires already being logged in as an admin, + and `migrate:fresh` creates no user rows at all. Without this set, + a fresh deployment has no way to log in at all. Only used on first + boot (guarded by the same stateDir/.installed marker as + migrate:fresh); changing it afterwards has no effect on an + existing account. + + Seeds a user named "admin", email "admin@''${domain}", bcrypt-hashed + via `password_hash(..., PASSWORD_BCRYPT)` (matching UserModel's own + hashing) and inserted directly into the UserModel table with the + admin flag set, bypassing the app's normal two-step + create-then-promote admin-panel flow. + ''; + }; + + settings = lib.mkOption { + type = lib.types.attrsOf (lib.types.oneOf [ lib.types.str lib.types.int lib.types.bool ]); + default = { }; + example = { APP_DEBUG = true; APP_OVERDUETASK_HOURS = 6; }; + description = '' + Extra/overriding .env entries, merged over the generated defaults. + Note that the `php asatru` CLI (used here for migrations) only runs + when APP_DEBUG is true, so migrations silently no-op unless that's + set here or in secretFile. + ''; + }; + + smtp = { + host = lib.mkOption { type = lib.types.str; default = ""; }; + port = lib.mkOption { type = lib.types.port; default = 587; }; + username = lib.mkOption { type = lib.types.str; default = ""; }; + fromName = lib.mkOption { type = lib.types.str; default = "HortusFox"; }; + fromAddress = lib.mkOption { type = lib.types.str; default = "hortusfox@localhost"; }; + encryption = lib.mkOption { type = lib.types.enum [ "tls" "ssl" "" ]; default = "tls"; }; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = stateDir; + }; + users.groups.${cfg.group} = { }; + + # --- Database: unix-socket auth, no password needed ------------------ + services.mysql = { + enable = true; + package = pkgs.mariadb_114; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [{ + name = cfg.user; + ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; + }]; + }; + + # --- Persistent writable dirs --------------------------------------- + # World-readable (0755), same reasoning as RuntimeDirectoryMode above: + # some of these (public/*) are nginx's webroot, and nginx (a different + # user) needs to traverse them directly. Nothing secret lives at the + # directory level -- .env (the one real secret under stateDir) is a + # separate file installed at mode 600, unaffected by this. + systemd.tmpfiles.rules = + [ "d ${stateDir} 0755 ${cfg.user} ${cfg.group} - -" ] + ++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirParents + ++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirs; + + # Kept as one attrset: hortusfox-setup/phpfpm-hortusfox plus the + # cron-generated services all live under systemd.services, and a + # separate full reassignment (as the cron block used to be) would + # conflict with these partial-path definitions. + systemd.services = { + hortusfox-setup = { + description = "Prepare HortusFox runtime tree and run migrations"; + after = [ "mysql.service" "network.target" ]; + requires = [ "mysql.service" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = cfg.user; + Group = cfg.group; + StateDirectory = "hortusfox"; + # World-readable: nginx (a different user) needs to stat/read + # everything under here to serve `public/` directly. Nothing + # secret lives in this tree -- .env is a symlink to stateDir/.env, + # which stays mode 600 regardless of this. + RuntimeDirectory = "hortusfox"; + RuntimeDirectoryMode = "0755"; + }; + + path = [ phpPackage config.services.mysql.package pkgs.coreutils pkgs.gnused ]; + + script = '' + set -euo pipefail + + # 1. Mirror the read-only store package into a writable-structured + # tree with a real (non-symlinked) copy of every file. This + # duplicates the app into tmpfs on every boot -- NOT `cp -rs` + # (per-file symlinks back into the store): PHP resolves + # __DIR__/__FILE__ through symlinks to their real target, so + # any symlinked file here would see itself as living in the + # read-only store rather than under runDir, breaking the + # app's own self-relative path lookups (its .env discovery + # included). `cp` carries the store's read-only directory + # mode (555) onto the new directories, so reopen them for + # writing -- only directories, since we never need to touch + # the copied files' own permissions. + rm -rf "${runDir}" + mkdir -p "${runDir}" + cp -r "${appRoot}/." "${runDir}/" + find "${runDir}" -type d -exec chmod u+w {} + + + # 2. Swap the writable subdirs for persistent state dirs. Some of + # these (public/img, public/themes) ship real default assets + # (backgrounds, the default theme) in the package, not just + # user-generated content -- seed the persistent copy from the + # package on every boot, without ever clobbering what's + # already there, before replacing the dir with a symlink to + # it. Harmless no-op for dirs that ship nothing but the app's + # own .gitignore. + ${lib.concatMapStringsSep "\n" (d: '' + mkdir -p "${stateDir}/${d}" + cp -rn "${appRoot}/${d}/." "${stateDir}/${d}/" || true + find "${stateDir}/${d}" -type d -exec chmod u+w {} + + rm -rf "${runDir}/${d}" + ln -sfn "${stateDir}/${d}" "${runDir}/${d}" + '') writableDirs} + + # 3. Render .env: static body + secrets from secretFile (if any), + # appended verbatim. + install -m600 /dev/null "${stateDir}/.env" + cat ${envFile} > "${stateDir}/.env" + ${lib.optionalString (cfg.secretFile != null) '' + { + echo "" + cat "${cfg.secretFile}" + } >> "${stateDir}/.env" + ''} + ln -sfn "${stateDir}/.env" "${runDir}/.env" + + # 4. Migrate. First boot builds the schema (destructive fresh); later + # boots only upgrade. The marker guards you from nuking your data. + cd "${runDir}" + if [ ! -e "${stateDir}/.installed" ]; then + php asatru migrate:fresh + + # 5. Seed the initial admin account. migrate:fresh creates no + # user rows at all, and the app's only "create user" flow + # lives behind the admin panel -- without this, a fresh + # deployment has no way to log in, ever. Insert directly + # (name/email hardcoded, bypassing the app's normal + # create-then-promote-to-admin two-step flow) rather than + # going through any app-level API, since none exists yet at + # this point. + ${lib.optionalString (cfg.adminAccount.passwordFile != null) '' + admin_hash="$(php -r 'echo password_hash($argv[1], PASSWORD_BCRYPT);' "$(cat "${cfg.adminAccount.passwordFile}")")" + mariadb "${cfg.database.name}" -e "INSERT INTO UserModel (name, email, password, admin) VALUES ('admin', 'admin@${cfg.domain}', '$admin_hash', 1);" + ''} + + touch "${stateDir}/.installed" + else + php asatru migrate:upgrade || true + fi + ''; + }; + + phpfpm-hortusfox = { + after = [ "hortusfox-setup.service" ]; + requires = [ "hortusfox-setup.service" ]; + }; + } // (lib.mapAttrs' (name: job: + lib.nameValuePair "hortusfox-cron-${name}" { + description = "HortusFox cronjob: ${name}"; + after = [ "nginx.service" ]; + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 " + + "${appUrl}${job.path}"; + }; + }) cronjobs); + + systemd.timers = lib.mapAttrs' (name: job: + lib.nameValuePair "hortusfox-cron-${name}" { + description = "Timer for HortusFox cronjob: ${name}"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = job.when; + Persistent = true; + }; + }) cronjobs; + + # --- phpfpm pool ------------------------------------------------------ + services.phpfpm.pools.hortusfox = { + user = cfg.user; + group = cfg.group; + phpPackage = phpPackage; + settings = { + "listen.owner" = if cfg.proxy.server == "caddy" then config.services.caddy.user else config.services.nginx.user; + "listen.group" = if cfg.proxy.server == "caddy" then config.services.caddy.group else config.services.nginx.group; + "pm" = "dynamic"; + "pm.max_children" = 16; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 1; + "pm.max_spare_servers" = 4; + "catch_workers_output" = true; + }; + }; + + # --- nginx + ACME ------------------------------------------------------ + services.nginx = lib.mkIf (cfg.proxy.server == "nginx") { + enable = true; + recommendedTlsSettings = true; + recommendedGzipSettings = true; + recommendedOptimisation = true; + recommendedProxySettings = true; + + virtualHosts.${cfg.domain} = { + forceSSL = cfg.proxy.ssl; + enableACME = cfg.proxy.ssl; + + root = "${runDir}/public"; + + # Asatru front controller: everything that isn't a real file + # rewrites to index.php (mirrors the upstream .htaccess). + locations."/" = { + index = "index.php"; + tryFiles = "$uri $uri/ /index.php?$query_string"; + }; + + locations."~ \\.php$".extraConfig = '' + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${config.services.phpfpm.pools.hortusfox.socket}; + fastcgi_index index.php; + include ${config.services.nginx.package}/conf/fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PATH_INFO $fastcgi_path_info; + ''; + + # Don't let anyone fetch the rendered .env or app internals. + locations."~ /\\.".extraConfig = "deny all;"; + + extraConfig = '' + client_max_body_size 64M; + ''; + }; + }; + + # --- caddy --------------------------------------------------------- + # `proxy.ssl = false` prefixes the site address with "http://", which + # disables Caddy's automatic HTTPS/ACME for this vhost specifically + # (rather than needing a separate flag like nginx's forceSSL/enableACME). + services.caddy = lib.mkIf (cfg.proxy.server == "caddy") { + enable = true; + + virtualHosts.${if cfg.proxy.ssl then cfg.domain else "http://${cfg.domain}"}.extraConfig = '' + root * ${runDir}/public + encode gzip + + # Don't let anyone fetch the rendered .env or app internals. + @dotfiles path /.* + respond @dotfiles 403 + + request_body { + max_size 64MB + } + + # php_fastcgi already covers the Asatru front-controller rewrite + # (try real file, then index.php) and static file serving. + php_fastcgi unix/${config.services.phpfpm.pools.hortusfox.socket} + file_server + ''; + }; + + # ACME needs an email; set this once globally in your host config: + # nginx: security.acme = { acceptTerms = true; defaults.email = "you@example.com"; }; + # caddy: services.caddy.email = "you@example.com"; + + networking.firewall.allowedTCPPorts = [ 80 443 ]; + }; +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..8aa60d0 --- /dev/null +++ b/package.nix @@ -0,0 +1,30 @@ +{ lib, php, fetchFromGitHub, ... }: + +# buildComposerProject installs the project to +# $out/share/php// (with vendor/ populated) +php.buildComposerProject (finalAttrs: { + pname = "hortusfox-web"; + version = "5.9"; + + src = fetchFromGitHub { + owner = "danielbrendel"; + repo = "hortusfox-web"; + rev = "v${finalAttrs.version}"; + hash = "sha256-O4I25YntVL+UwWATRiI5h9MYkVeN7JEmrRLt0eVwMTs="; + }; + + # composer.lock is committed upstream, so a fixed vendorHash works. + vendorHash = "sha256-b9t5S12TJoiup7fkQ4bo2xH+CvEt6MqOrQxQ/gJ4Deg="; + + # No console entrypoint to expose on PATH; we call `php asatru` ourselves. + composerNoDev = true; + + composerStrictValidation = false; + + meta = with lib; { + description = "Self-hosted collaborative plant management system"; + homepage = "https://github.com/danielbrendel/hortusfox-web"; + license = licenses.mit; + platforms = platforms.linux; + }; +}) diff --git a/tests/default.nix b/tests/default.nix new file mode 100644 index 0000000..0251d0d --- /dev/null +++ b/tests/default.nix @@ -0,0 +1,49 @@ +{ pkgs, modulesPath, ... }: + +{ + imports = [ + (modulesPath + "/profiles/qemu-guest.nix") + ../nixos-module + ]; + + #nixpkgs.config.allowUnfree = true; + + # `nix flake check` builds config.system.build.toplevel (a fully bootable + # system), even though this config is only ever meant to be built as + # `.config.system.build.vm` (see justfile). These satisfy its assertions + # without affecting the VM build itself. + fileSystems."/" = { + device = "/dev/disk/by-label/nixos"; + fsType = "ext4"; + }; + boot.loader.grub.devices = [ "/dev/vda" ]; + + # Required by services.hortusfox-web's forceSSL+enableACME nginx vhost. + security.acme = { + acceptTerms = true; + defaults.email = "test@example.invalid"; + }; + + users.users.root.initialPassword = "toor"; + + services.hortusfox-web = { + enable = true; + proxy.server = "nginx"; + # No real domain/internet-reachable cert here, so serve plain HTTP -- + # otherwise forceSSL+enableACME breaks the VM (see justfile's hostfwd + # to test over http://localhost:8080/). + proxy.ssl = false; + # Matches justfile's hostfwd (host 127.0.0.1:8080 -> guest 80), so + # login/session redirects land back on the forwarded port instead of + # the guest's own (unforwarded) port 80. + port = 8080; + adminAccount.passwordFile = ./hortusfox_web_test_password; + secretFile = ./test.env; + settings.APP_DEBUG = true; + }; + + networking.firewall.enable = false; + + services.resolved.enable = true; +} + diff --git a/tests/hortusfox_web_test_password b/tests/hortusfox_web_test_password new file mode 100644 index 0000000..f3097ab --- /dev/null +++ b/tests/hortusfox_web_test_password @@ -0,0 +1 @@ +password 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 diff --git a/tests/vm-test.nix b/tests/vm-test.nix new file mode 100644 index 0000000..19dfffb --- /dev/null +++ b/tests/vm-test.nix @@ -0,0 +1,96 @@ +# One nixosTest per proxy backend: boots the service, waits for the +# backend-specific web server unit, and exercises both a dynamic (PHP-FPM) +# and a static (nginx/caddy's own) request path. Called once per backend +# from flake.nix's `checks`. +{ pkgs, nixosModule, proxyServer }: + +let + # Known plaintext, embedded directly into the curl login POST below -- + # this is a throwaway test VM's password, not a real secret. + adminPassword = "correct-horse-battery-staple"; +in + +pkgs.testers.nixosTest { + name = "hortusfox-web-${proxyServer}"; + + nodes.machine = { + imports = [ nixosModule ]; + + services.hortusfox-web = { + enable = true; + proxy.server = proxyServer; + proxy.ssl = false; # no internet/ACME inside the test VM + settings.APP_DEBUG = true; # required for `php asatru migrate:*` to run + adminAccount.passwordFile = pkgs.writeText "hortusfox-test-admin-password" adminPassword; + }; + }; + + testScript = '' + machine.wait_for_unit("mysql.service") + machine.wait_for_unit("hortusfox-setup.service") + machine.wait_for_unit("phpfpm-hortusfox.service") + machine.wait_for_unit("${proxyServer}.service") + machine.wait_for_open_port(80) + # -L: "/" redirects to the login page (/auth) for a logged-out + # user -- following it is what actually exercises the page that + # broke before (a bare "/" 302 response has no exception text + # to catch). + response = machine.succeed("curl -sfL http://localhost/") + + # A <400 response alone isn't enough -- Asatru renders uncaught + # exceptions as HTML without failing the HTTP status, so a plain + # `curl -sf` above would happily "succeed" on a broken app (this + # is exactly how the runtime-tree symlink-vs-copy bug slipped + # past this check before). Fail loudly on that page instead. + assert "Asatru PHP - Exception" not in response, ( + f"got an Asatru exception page instead of the real site:\n{response}" + ) + + # Also fetch a real static asset directly (the proxy's own job, not + # PHP-FPM's) -- the login page's background image, which ships in + # the package itself. This is what actually exercises the proxy + # reading into stateDir's writable subdirs (public/img here), as + # opposed to the runtime tree checked above. + machine.succeed("curl -sf http://localhost/img/pattern.jpg -o /dev/null") + + # PHP-FPM runs as the hortusfox user and can serve pages even when + # the proxy (a different user) can't read the runtime tree at all -- + # so a passing response above doesn't rule this out on its own. Check + # the proxy's own log directly, and only after every request made so + # far, so a permission error on any of them (runtime tree or + # persistent state dir) gets caught here. + machine.fail("journalctl -u ${proxyServer} --no-pager | grep -qi 'permission denied'") + + # Exercise the actual point of adminAccount.passwordFile: log in as the + # seeded admin, not just confirm a login form renders. migrate:fresh + # alone creates no user rows, so this is the only way any account gets + # to exist at all -- worth proving it actually works end to end. + machine.succeed( + "curl -sc /tmp/cookies -o /tmp/auth.html -L http://localhost/" + ) + csrf_token = machine.succeed( + "sed -n 's/.*name=\"csrf_token\" value=\"\([^\"]*\)\".*/\\1/p' /tmp/auth.html | head -1" + ).strip() + assert csrf_token, "could not find a csrf_token on the /auth page" + + # -w appends the final URL after a marker, so we can tell *where* we + # landed -- the dashboard itself also has a (normally hidden) change- + # password form in its markup, so checking for a password input isn't + # a reliable way to tell it apart from the login page. + raw = machine.succeed( + "curl -sb /tmp/cookies -c /tmp/cookies -L -w '\\n===URL===%{url_effective}' " + "--data-urlencode email=admin@localhost " + "--data-urlencode password=${adminPassword} " + f"--data-urlencode csrf_token={csrf_token} " + "--data-urlencode redirect=/ " + "http://localhost/login" + ) + login_response, final_url = raw.rsplit("\n===URL===", 1) + assert "Asatru PHP - Exception" not in login_response, ( + f"login crashed instead of succeeding:\n{login_response}" + ) + assert "/auth" not in final_url, ( + f"login didn't redirect away from /auth -- login failed (landed on {final_url}):\n{login_response}" + ) + ''; +}