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 <noreply@anthropic.com>
This commit is contained in:
@@ -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}"
|
||||
)
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user