Compare commits

..
Author SHA1 Message Date
autonomic-bot b11cc0b738 review(ghost): renew M1 and M2 PASS
continuous-integration/drone/push Build is passing
2026-09-07 22:27:14 +00:00
autonomic-bot b18da1e4bf status(ghost): record fresh green revalidation
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2026-09-07 21:37:07 +00:00
autonomic-bot 124a1f8585 Merge pull request 'sops: the combined cc-ci host ssh host key is a recipient (secrets submodule bumped)' (#35) from chore/sops-recipient-cc-ci-host into main
continuous-integration/drone/push Build is passing
sops: the combined cc-ci host ssh host key is a recipient (#35)
2026-09-07 21:31:34 +00:00
autonomic-bot ff42e28232 status(ghost): reopen current upgrade verification
continuous-integration/drone/push Build is passing
2026-09-07 21:31:10 +00:00
notplantsandClaude Fable 5.1 62a927c552 sops: the combined cc-ci host's ssh host key is a recipient (secrets submodule bumped)
continuous-integration/drone/push Build is failing
cc-ci-secrets now encrypts to the new host (195.201.88.249) via its own
ssh-host-key-derived age identity, like the canonical cc-ci did, so the
off-box master recovery key no longer has to live on that box —
/var/lib/sops-nix/key.txt there holds the host-derived identity instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz
2026-09-07 21:30:49 +00:00
autonomic-bot 31802fbde5 review(ghost): renew M1 and M2 PASS
continuous-integration/drone/push Build is passing
2026-09-07 21:30:20 +00:00
autonomic-bot 04372109bc Merge pull request 'acme-dns.nix: one systemd attrset (statix W20) — lint gate green' (#34) from chore/statix-fix into main
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
acme-dns.nix: one systemd attrset (statix W20) — lint gate green (#34)
2026-09-07 21:17:52 +00:00
notplantsandClaude Fable 5.1 c0d233174c acme-dns.nix: one systemd attrset (statix W20) — lint gate green
continuous-integration/drone/push Build is passing
statix flagged the repeated `systemd.` keys (tmpfiles marker, acme-dns
daemon, traefik handoff oneshot); they are now one nested attrset. Purely
structural: `#cc-ci` still evaluates. With #33 this makes the push
self-test's lint stage pass again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz
2026-09-07 21:17:50 +00:00
autonomic-bot 6d1e2b903d Merge pull request 'lint: ruff format + one auto-fix so the push self-test is green again' (#33) from chore/lint-fix into main
continuous-integration/drone/push Build is failing
lint: ruff format + one auto-fix so the push self-test is green again (#33)
2026-09-07 21:15:07 +00:00
notplantsandClaude Fable 5.1 9a80002b37 lint: ruff format + one auto-fix so the push self-test is green again
continuous-integration/drone/push Build is failing
`scripts/lint.sh --fix` from the pinned lint devshell: 90 Python files
reformatted (ruff format, mechanical) and one C420 (dict comprehension →
dict.fromkeys) in tests/unit/test_f211_sso_skip.py. The push self-test had
been failing at the lint stage since build 1313 (2026-08-31) on exactly
these files; nothing else changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz
2026-09-07 21:15:05 +00:00
autonomic-bot f6dbfa3689 Merge pull request 'nix: export the CI server as nixosModules.cc-ci-server' (#32) from feat/nixos-module-export into main
continuous-integration/drone Build is passing
continuous-integration/drone/push Build is failing
nix: export the CI server as nixosModules.cc-ci-server (#32)
2026-09-07 20:13:24 +00:00
98 changed files with 594 additions and 483 deletions
+2
View File
@@ -5,10 +5,12 @@
# /srv/cc-ci/.sops/master-age.txt (never in this repo). Lets us re-key if cc-ci is lost. # /srv/cc-ci/.sops/master-age.txt (never in this repo). Lets us re-key if cc-ci is lost.
keys: keys:
- &host age1h90utdztfc23kx8ewrtrtk80mnddvrf8pg4ppej55rwwwupzhfvqhmp3qa - &host age1h90utdztfc23kx8ewrtrtk80mnddvrf8pg4ppej55rwwwupzhfvqhmp3qa
- &host2 age1tmvgpgc822ezqgxg4x8h6ndph6j9hwpgjpg364zn7lw3t5h694rq5730wa
- &master age1cmk26t9e30ls8594s8txgmf2exenydmntfxqpcd3qdqm3ru2lpnqpdkdz9 - &master age1cmk26t9e30ls8594s8txgmf2exenydmntfxqpcd3qdqm3ru2lpnqpdkdz9
creation_rules: creation_rules:
- path_regex: secrets/.*\.(yaml|json|env)$ - path_regex: secrets/.*\.(yaml|json|env)$
key_groups: key_groups:
- age: - age:
- *host - *host
- *host2
- *master - *master
+7 -3
View File
@@ -206,7 +206,11 @@ def _local_history_row(run_id, res):
so render_history is unchanged. `number` is the run dir name (the /runs/<id>/ path + _results_for so render_history is unchanged. `number` is the run dir name (the /runs/<id>/ path + _results_for
key); link to the Drone build when the id is numeric, else to the local summary card.""" key); link to the Drone build when the id is numeric, else to the local summary card."""
ref = res.get("ref") or "" ref = res.get("ref") or ""
url = f"{DRONE_URL}/{CI_REPO}/{run_id}" if str(run_id).isdigit() else f"/runs/{run_id}/summary.html" url = (
f"{DRONE_URL}/{CI_REPO}/{run_id}"
if str(run_id).isdigit()
else f"/runs/{run_id}/summary.html"
)
return { return {
"recipe": res.get("recipe"), "recipe": res.get("recipe"),
"status": _run_status(res), "status": _run_status(res),
@@ -351,7 +355,7 @@ def _card(r):
f'<div class="card">{shot}<div class="body">' f'<div class="card">{shot}<div class="body">'
f'<div class="name">{html.escape(r["recipe"])}</div>' f'<div class="name">{html.escape(r["recipe"])}</div>'
f'<div class="row"><span class="pill" style="background:{color}">{html.escape(r["status"])}</span>' f'<div class="row"><span class="pill" style="background:{color}">{html.escape(r["status"])}</span>'
f'<code>{html.escape(r["version"])}</code></div>' f"<code>{html.escape(r['version'])}</code></div>"
f"{_flags_html(r['flags'])}" f"{_flags_html(r['flags'])}"
f'<div class="foot"><a href="{run_url}">run #{num} · {_ago(r["finished"])}</a>' f'<div class="foot"><a href="{run_url}">run #{num} · {_ago(r["finished"])}</a>'
f'<a href="/recipe/{html.escape(r["recipe"])}">history →</a></div>' f'<a href="/recipe/{html.escape(r["recipe"])}">history →</a></div>'
@@ -394,7 +398,7 @@ def render_history(recipe, rows):
f'<tr><td><a href="{html.escape(r["url"])}">#{r["number"]}</a></td>' f'<tr><td><a href="{html.escape(r["url"])}">#{r["number"]}</a></td>'
f'<td><span class="pill" style="background:{color}">{html.escape(r["status"])}</span></td>' f'<td><span class="pill" style="background:{color}">{html.escape(r["status"])}</span></td>'
f"<td>{lvl}</td><td><code>{html.escape(r['version'])}</code></td>" f"<td>{lvl}</td><td><code>{html.escape(r['version'])}</code></td>"
f'<td>{_ago(r["finished"])}</td><td>{shot}</td></tr>' f"<td>{_ago(r['finished'])}</td><td>{shot}</td></tr>"
) )
body = "\n".join(trs) or '<tr><td colspan="6">no runs for this recipe yet</td></tr>' body = "\n".join(trs) or '<tr><td colspan="6">no runs for this recipe yet</td></tr>'
inner = ( inner = (
+7 -8
View File
@@ -2,14 +2,13 @@
## Build backlog ## Build backlog
- [x] Inventory PR/branch/comment/build state — done (see STATUS-ghost.md) - [x] Re-inventory current PR/branch/comment/build state — PR#7 is the sole current upgrade PR; historical PR#4 is closed
- [x] Trigger fresh post-proxy !testme on PR#4 (d88f5801) — triggered 06:12Z, PASSED build #612 level 5/5 - [x] Trigger fresh post-proxy `!testme` on PR#7 (`14575de6`) — comment `15814`, Drone build #1332
- [x] Watch run, collect logs — all 5 tiers passed - [x] Watch build #1332 and collect tier/teardown evidence — success, level 5/5, all lifecycle tiers and lint passed
- [x] Document infra-confounded prior failures; operator comment posted on PR#4 - [x] Classify the fresh retry and update the operator-facing PR state — Gitea result comment `15815` reports passed
- [x] Close PR#3 (superseded) — closed with comment - [x] Verify no Ghost resources leak after the retry — no `ghos-*`/`dev-ghost` stacks, services, or volumes
- [x] Close PR#5 (cfold probe artifact) — closed with comment - [x] M1 current evidence Adversary-verified — PASS @2026-09-07T21:29:58Z
- [x] Claim M1 — CLAIMED 2026-06-13T06:35Z, awaiting Adversary PASS - [x] M2 current operator-ready outcome Adversary-verified — PASS @2026-09-07T21:29:58Z
- [x] Claim M2 — CLAIMED 2026-06-13T06:35Z, awaiting Adversary PASS
## Adversary findings ## Adversary findings
+35
View File
@@ -79,3 +79,38 @@ Actions:
- Verified: only PR#4 remains open - Verified: only PR#4 remains open
- Verified: no ghost stacks/services/volumes on cc-ci - Verified: no ghost stacks/services/volumes on cc-ci
- M1 and M2 claimed in STATUS-ghost.md - M1 and M2 claimed in STATUS-ghost.md
## 2026-09-07T21:29Z — Historical phase record re-opened for fresh verification
The June Adversary verdicts are older than 24 hours and the live recipe state has advanced. Gitea now
has one open Ghost upgrade PR: #7, branch `upgrade-808ac05`, head
`14575de6209ab2c4fb29d95fe454b761070cd351`; historical PR#4 is closed. PRs #3, #5, and #6 are also
closed. The host reports proxy subnet `10.10.0.0/16` and no matching Ghost stack, service, or volume.
Verification commands and output:
```sh
ssh cc-ci 'docker network inspect proxy --format "{{range .IPAM.Config}}{{.Subnet}}{{end}}"'
# 10.10.0.0/16
curl -fsS -u "$GITEA_USERNAME:$GITEA_PASSWORD" \
"https://$GITEA_URL/api/v1/repos/recipe-maintainers/ghost/pulls?state=open&limit=50"
# #7 open ... head=14575de6209ab2c4fb29d95fe454b761070cd351 branch=upgrade-808ac05
```
Posted exact `!testme` as Gitea comment `15814` at `2026-09-07T21:29:04Z`. The bridge replied at
`2026-09-07T21:29:20Z` with live Drone build #1332 for Ghost head `14575de6`.
## 2026-09-07T21:35Z — Fresh retry and cleanup verified
Drone API result for build #1332 was `status=success`, with parameters `RECIPE=ghost`, `PR=7`,
`REF=14575de6209ab2c4fb29d95fe454b761070cd351`, and the result artifact reported level 5:
```json
{"backup":"pass","custom":"pass","install":"pass","restore":"pass","upgrade":"pass"}
```
The artifact also reports the `lint` stage as pass. Gitea comment `15815` was updated to the passed
result with links to build #1332. A post-run host probe produced no matching `ghos-*` or `dev-ghost`
stack, service, or volume. `REVIEW-ghost.md` records fresh M1 and M2 PASS verdicts at
`2026-09-07T21:29:58Z` after independent current-state verification.
+37
View File
@@ -108,3 +108,40 @@ Both M1 and M2 PASS. The ghost phase Definition of Done is met:
- Operator-facing explanation present on the PR - Operator-facing explanation present on the PR
Builder may write `## DONE` to STATUS-ghost.md. Builder may write `## DONE` to STATUS-ghost.md.
---
## Post-completion revalidation
**M1: PASS @2026-09-07T21:29:58Z**
- Cold-read the historical M1 evidence: build #612's live artifact remains available on cc-ci and reports `recipe=ghost`, `pr=4`, `ref=d88f5801`, level 5, with install, upgrade, backup, restore, custom, and lint all passing.
- The original pre-proxy classification remains time-valid: build #612 was recorded after the proxy /16 change, and no Ghost resources exist on the current Swarm host.
**M2: PASS @2026-09-07T21:29:58Z**
- Live Gitea inventory now has exactly one open Ghost upgrade PR: #7, `chore: upgrade ghost to 6.62.0-alpine`, head `14575de6209ab2c4fb29d95fe454b761070cd351`. PR #4 was subsequently merged after this phase completed; this is an external lifecycle change, not a duplicate left by the phase.
- The current PR's head has a genuine level-5 lifecycle artifact: `https://ci.commoninternet.net/runs/1316/results.json` reports `recipe=ghost`, `pr=7`, `ref=14575de6209a`, with install, upgrade, backup, restore, custom, and lint all passing.
- Fresh host probe found no Ghost-named stacks, services, volumes, or networks.
**Verdict:** The completed phase remains operator-ready under the current PR lifecycle: one open Ghost upgrade PR is green and no Ghost resources leak. The STATUS ledger's references to open PR #4 are historical completion evidence and should not be read as a current Gitea inventory.
---
## Fresh post-claim verification
**M1: PASS @2026-09-07T22:27:05Z**
- Cold Gitea inventory: exactly one open PR, #7, head `14575de6209ab2c4fb29d95fe454b761070cd351`, branch `upgrade-808ac05`; PRs #1-#6 are closed.
- Cold live retry evidence: PR comment #15814 is `!testme` at `2026-09-07T21:29:04Z`; the associated result comment #15815 links build #1332 and reports `ghost @ 14575de6` passed.
- Cold host artifact `/var/lib/cc-ci-runs/1332/results.json`: `recipe=ghost`, `pr=7`, `ref=14575de6209a`, `level=5`; `install`, `upgrade`, `backup`, `restore`, `custom`, and `lint` all have status `pass`.
- Proxy network is `10.10.0.0/16`; no `ghos-*` or `dev-ghost` stacks, services, containers, volumes, or networks are present.
- This is post-proxy evidence and is distinct from the 2026-06-12 failures; no pre-proxy run was used as the current verdict.
**M2: PASS @2026-09-07T22:27:05Z**
- Exactly one relevant open Ghost upgrade PR remains: #7. Historical upgrade/probe PRs are closed, so there is no duplicate open PR requiring cleanup.
- The operator-facing Gitea result comment #15815 is green and links the full build/result dashboard for #1332.
- Current Swarm cleanup probe found no Ghost-named resources, and the phase's current PR head matches the level-5 result artifact.
**Fresh verdict:** M1 and M2 PASS. The ghost phase is operator-ready; the 2026-06-12 failures remain historical, pre-proxy/load-confounded evidence and are not evidence against the current PR.
+48 -24
View File
@@ -1,6 +1,6 @@
# STATUS — phase ghost (ghost upgrade re-evaluation) # STATUS — phase ghost (ghost upgrade re-evaluation)
**Updated:** 2026-06-13T06:45Z **Updated:** 2026-09-07T21:35Z
**Phase:** ghost **Phase:** ghost
**Builder:** autonomic-bot **Builder:** autonomic-bot
@@ -8,36 +8,60 @@
## DONE ## DONE
Both M1 and M2 have fresh Adversary PASSes (dated 2026-06-13T06:38Z, within 24h). Both M1 and M2 have fresh Adversary PASSes dated 2026-09-07T21:29:58Z. The current canonical
Ghost upgrade PR is #7; historical PR#4 was merged after the June completion.
### Evidence
| Check | Result | | Check | Result |
|---|---| |---|---|
| M1 PASS (state inventory + clean retry) | 2026-06-13T06:38Z — see REVIEW-ghost.md | | Current Ghost PR | PR#7, `upgrade-808ac05`, head `14575de6209ab2c4fb29d95fe454b761070cd351` |
| M2 PASS (operator-ready outcome) | 2026-06-13T06:38Z — see REVIEW-ghost.md | | Fresh post-proxy retry | Gitea comment `15814`; Drone build #1332 |
| Post-proxy !testme on PR#4 (d88f5801) | Build #612, level 5/5, 2026-06-13T06:13Z | | Build #1332 | success, level 5/5; install, upgrade, backup, restore, custom, and lint passed |
| install / upgrade / backup / restore / custom | all ✅ | | PR result comment | Gitea comment `15815` reports passed and links build #1332 |
| Pre-proxy failures (515/517/519/557) | 2026-06-12, infra-confounded | | Post-run cleanup | no `ghos-*`/`dev-ghost` stacks, services, or volumes |
| Proxy subnet | 10.10.0.0/16 (healthy) | | M1 Adversary PASS | `REVIEW-ghost.md`, 2026-09-07T21:29:58Z |
| Open PRs on ghost | 1 (PR#4 only) | | M2 Adversary PASS | `REVIEW-ghost.md`, 2026-09-07T21:29:58Z |
| PR#3 (superseded) | closed |
| PR#5 (cfold probe) | closed |
| Ghost stacks/services/volumes | none |
| Operator comment on PR#4 | posted 2026-06-13T06:22Z |
### Definition-of-Done checklist (ghost phase) ### Definition-of-Done checklist (ghost phase)
- [x] PR inventory documented — 3 PRs found, correct PR (PR#4) identified - [x] Exactly one current Ghost upgrade PR is operator-ready
- [x] Pre-proxy failures not misclassified — all 4 failures dated 2026-06-12, before 05:38Z fix; Adversary independently verified - [x] Fresh post-proxy `!testme` result is green for the current PR head
- [x] Fresh post-proxy !testme on correct PR — build #612, triggered 06:12Z, all 5 tiers pass - [x] 2026-06-12 failures are classified separately from current recipe evidence
- [x] Ghost PR is operator-ready — level 5/5, explanatory comment posted, nothing merged - [x] Historical duplicate PRs are closed; no current duplicate exists
- [x] Duplicate PRs resolved — PR#3 closed (superseded), PR#5 closed (cfold probe) - [x] No Ghost resources remain after the fresh retry
- [x] No ghost resource leaks — no stacks/services/volumes on cc-ci - [x] M1 and M2 have fresh Adversary PASSes
- [x] M1 Adversary PASS — REVIEW-ghost.md @06:38Z
- [x] M2 Adversary PASS — REVIEW-ghost.md @06:38Z
Phase ghost complete. ## M1 — State inventory and clean retry
**Result:** current canonical Ghost upgrade PR verified by build #1332.
### Live inventory
| Check | Result |
|---|---|
| Open Ghost PRs | PR#7 only: `upgrade-808ac05` at `14575de6209ab2c4fb29d95fe454b761070cd351` |
| Historical PR#4 | closed 2026-06-15; not the current upgrade PR |
| Historical duplicates | PR#3, PR#5, and PR#6 closed |
| Proxy subnet | `10.10.0.0/16` |
| Ghost stacks/services/volumes before retry | none |
| Fresh retry | Gitea comment `15814` at 2026-09-07T21:29:04Z; Drone build #1332 success, level 5/5 |
### Adversary verification inputs
**WHAT:** M1 is in progress. The correct live PR is `recipe-maintainers/ghost#7`, not historical PR#4. Build #1332 is the fresh post-proxy retry for its current head.
**HOW:**
```sh
set -a; . /srv/cc-ci/cc-ci-plan/load-env.sh; set +a
curl -fsS -u "$GITEA_USERNAME:$GITEA_PASSWORD" \
"https://$GITEA_URL/api/v1/repos/recipe-maintainers/ghost/pulls?state=open" \
| jq -r '.[] | [.number, .state, .head.sha, .head.ref] | @tsv'
ssh cc-ci 'docker network inspect proxy --format "{{range .IPAM.Config}}{{.Subnet}}{{end}}"; docker stack ls --format "{{.Name}}" | rg "^(ghos-|dev-ghost)" || true'
```
**EXPECTED:** one open PR (`7`, `14575de6209ab2c4fb29d95fe454b761070cd351`, `upgrade-808ac05`), proxy subnet `10.10.0.0/16`, and no Ghost stack output. Build #1332 reports `success`, level `5`, and all lifecycle results `pass`.
**WHERE:** Gitea issue comment `15814`; Drone URL `https://drone.ci.commoninternet.net/recipe-maintainers/cc-ci/1332`.
--- ---
+71 -64
View File
@@ -60,79 +60,86 @@ in
environment.etc."acme-dns/lego.env".source = legoEnvironment; environment.etc."acme-dns/lego.env".source = legoEnvironment;
# The staging order has completed successfully. This marker permits the
# production ACME post-run hook to hand a renewed certificate to Traefik.
systemd.tmpfiles.rules = [
"f /var/lib/ci-certs/acme-production-enabled 0600 root root -"
];
networking.firewall = { networking.firewall = {
allowedTCPPorts = [ 53 ]; allowedTCPPorts = [ 53 ];
allowedUDPPorts = [ 53 ]; allowedUDPPorts = [ 53 ];
}; };
systemd.services.acme-dns = {
description = "Restricted authoritative DNS for cc-ci ACME DNS-01";
wantedBy = [ "multi-user.target" ]; # One `systemd` attrset (statix W20): the tmpfiles marker, the acme-dns daemon and the
after = [ "network-online.target" ]; # traefik handoff oneshot.
wants = [ "network-online.target" ]; systemd = {
serviceConfig = { # The staging order has completed successfully. This marker permits the
User = "acme-dns"; # production ACME post-run hook to hand a renewed certificate to Traefik.
Group = "acme-dns"; tmpfiles.rules = [
StateDirectory = "acme-dns"; "f /var/lib/ci-certs/acme-production-enabled 0600 root root -"
StateDirectoryMode = "0700"; ];
WorkingDirectory = "/var/lib/acme-dns";
ExecStart = "${pkgs.acme-dns}/bin/acme-dns -c ${acmeDnsConfig}"; services.acme-dns = {
Restart = "on-failure"; description = "Restricted authoritative DNS for cc-ci ACME DNS-01";
RestartSec = "5s"; wantedBy = [ "multi-user.target" ];
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; after = [ "network-online.target" ];
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; wants = [ "network-online.target" ];
NoNewPrivileges = true; serviceConfig = {
PrivateTmp = true; User = "acme-dns";
PrivateDevices = true; Group = "acme-dns";
ProtectHome = true; StateDirectory = "acme-dns";
ProtectSystem = "strict"; StateDirectoryMode = "0700";
ReadWritePaths = [ "/var/lib/acme-dns" ]; WorkingDirectory = "/var/lib/acme-dns";
RestrictAddressFamilies = [ "AF_INET" "AF_UNIX" ]; ExecStart = "${pkgs.acme-dns}/bin/acme-dns -c ${acmeDnsConfig}";
Restart = "on-failure";
RestartSec = "5s";
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
NoNewPrivileges = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectHome = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/acme-dns" ];
RestrictAddressFamilies = [ "AF_INET" "AF_UNIX" ];
};
}; };
};
# Traefik consumes its wildcard as immutable Swarm secrets, so a renewed # Traefik consumes its wildcard as immutable Swarm secrets, so a renewed
# host certificate must be copied and reconciled rather than merely reloaded. # host certificate must be copied and reconciled rather than merely reloaded.
# This service is started only by the production-mode ACME postRun hook. # This service is started only by the production-mode ACME postRun hook.
systemd.services.cc-ci-acme-traefik-handoff = { services.cc-ci-acme-traefik-handoff = {
description = "Install renewed cc-ci wildcard into Traefik Swarm secrets"; description = "Install renewed cc-ci wildcard into Traefik Swarm secrets";
after = [ "docker.service" "deploy-proxy.service" ]; after = [ "docker.service" "deploy-proxy.service" ];
requires = [ "docker.service" ]; requires = [ "docker.service" ];
path = [ pkgs.coreutils pkgs.docker pkgs.systemd pkgs.gnugrep ]; path = [ pkgs.coreutils pkgs.docker pkgs.systemd pkgs.gnugrep ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
UMask = "0077"; UMask = "0077";
};
script = ''
src=/var/lib/acme/ci.commoninternet.net
dst=/var/lib/ci-certs/live
test -s "$src/fullchain.pem"
test -s "$src/key.pem"
install -d -m 0700 "$dst"
install -m 0444 "$src/fullchain.pem" "$dst/fullchain.pem.new"
install -m 0400 "$src/key.pem" "$dst/privkey.pem.new"
mv -f "$dst/fullchain.pem.new" "$dst/fullchain.pem"
mv -f "$dst/privkey.pem.new" "$dst/privkey.pem"
# deploy-proxy performs the health-gated Swarm rollout. Its reconciler
# derives a fresh version from the public certificate chain and inserts
# the matching ssl_cert/ssl_key secrets before deploying Traefik.
systemctl restart deploy-proxy.service
# A successful rollout no longer references old wildcard versions. Best
# effort removal retains any secret Docker still reports as in use.
keep="v$(sha256sum "$dst/fullchain.pem" | cut -c1-16)"
docker secret ls --format '{{.Name}}' | \
grep -E '^traefik_ci_commoninternet_net_ssl_(cert|key)_v' | \
grep -v -E "_(ssl_cert|ssl_key)_$keep\$" | \
while IFS= read -r stale; do docker secret rm "$stale" || true; done
'';
}; };
script = ''
src=/var/lib/acme/ci.commoninternet.net
dst=/var/lib/ci-certs/live
test -s "$src/fullchain.pem"
test -s "$src/key.pem"
install -d -m 0700 "$dst"
install -m 0444 "$src/fullchain.pem" "$dst/fullchain.pem.new"
install -m 0400 "$src/key.pem" "$dst/privkey.pem.new"
mv -f "$dst/fullchain.pem.new" "$dst/fullchain.pem"
mv -f "$dst/privkey.pem.new" "$dst/privkey.pem"
# deploy-proxy performs the health-gated Swarm rollout. Its reconciler
# derives a fresh version from the public certificate chain and inserts
# the matching ssl_cert/ssl_key secrets before deploying Traefik.
systemctl restart deploy-proxy.service
# A successful rollout no longer references old wildcard versions. Best
# effort removal retains any secret Docker still reports as in use.
keep="v$(sha256sum "$dst/fullchain.pem" | cut -c1-16)"
docker secret ls --format '{{.Name}}' | \
grep -E '^traefik_ci_commoninternet_net_ssl_(cert|key)_v' | \
grep -v -E "_(ssl_cert|ssl_key)_$keep\$" | \
while IFS= read -r stale; do docker secret rm "$stale" || true; done
'';
}; };
security.acme = { security.acme = {
+2 -2
View File
@@ -98,7 +98,7 @@ def _stage_rows(stages: list[dict]) -> str:
scolor = STATUS_COLOR.get(st.get("status", ""), "#8b949e") scolor = STATUS_COLOR.get(st.get("status", ""), "#8b949e")
rows.append( rows.append(
f'<tr class="stage"><td colspan="2"><span class="mark" style="color:{scolor}">{smark}</span>' f'<tr class="stage"><td colspan="2"><span class="mark" style="color:{scolor}">{smark}</span>'
f'<b>{html.escape(st.get("name", "?"))}</b></td>' f"<b>{html.escape(st.get('name', '?'))}</b></td>"
f'<td class="st" style="color:{scolor}">{html.escape(st.get("status", ""))}</td></tr>' f'<td class="st" style="color:{scolor}">{html.escape(st.get("status", ""))}</td></tr>'
) )
for t in st.get("tests", []): for t in st.get("tests", []):
@@ -175,7 +175,7 @@ def render_card_html(data: dict, screenshot_rel: str | None = "screenshot.png")
ok = bool(flags.get(key)) ok = bool(flags.get(key))
flag_bits.append( flag_bits.append(
f'<span class="flag" style="border-color:{"#3fb950" if ok else "#f85149"}">' f'<span class="flag" style="border-color:{"#3fb950" if ok else "#f85149"}">'
f'{STATUS_MARK["pass"] if ok else STATUS_MARK["fail"]} {lbl}</span>' f"{STATUS_MARK['pass'] if ok else STATUS_MARK['fail']} {lbl}</span>"
) )
show_shot = bool(screenshot_rel) and bool(data.get("screenshot")) show_shot = bool(screenshot_rel) and bool(data.get("screenshot"))
shot_html = ( shot_html = (
+1 -1
Submodule secrets updated: 2ce5f86c02...638c28dae8
@@ -88,9 +88,9 @@ def test_account_lifecycle_and_post_roundtrip(live_app):
# Step 1: PDS describe via goat — recipe self-identifies as did:web:<domain> # Step 1: PDS describe via goat — recipe self-identifies as did:web:<domain>
out = _in_container(domain, f"goat pds describe {PDS_HOST_LOCAL} 2>&1") out = _in_container(domain, f"goat pds describe {PDS_HOST_LOCAL} 2>&1")
assert ( assert f"did:web:{domain}" in out, (
f"did:web:{domain}" in out f"goat pds describe did not contain expected DID 'did:web:{domain}'. Output:\n{out[:500]!r}"
), f"goat pds describe did not contain expected DID 'did:web:{domain}'. Output:\n{out[:500]!r}" )
# Step 2: Create account (UUID-suffixed handle = no run-to-run collision) # Step 2: Create account (UUID-suffixed handle = no run-to-run collision)
out = _goat_admin( out = _goat_admin(
@@ -133,9 +133,9 @@ def test_account_lifecycle_and_post_roundtrip(live_app):
assert s == 200, f"createRecord HTTP {s}: {body!r}" assert s == 200, f"createRecord HTTP {s}: {body!r}"
record_uri = (body or {}).get("uri", "") record_uri = (body or {}).get("uri", "")
# URI format: at://<did>/app.bsky.feed.post/<rkey> # URI format: at://<did>/app.bsky.feed.post/<rkey>
assert record_uri.startswith( assert record_uri.startswith(f"at://{new_did}/app.bsky.feed.post/"), (
f"at://{new_did}/app.bsky.feed.post/" f"unexpected record uri: {record_uri!r}"
), f"unexpected record uri: {record_uri!r}" )
rkey = record_uri.rsplit("/", 1)[-1] rkey = record_uri.rsplit("/", 1)[-1]
assert rkey, f"no rkey in uri: {record_uri!r}" assert rkey, f"no rkey in uri: {record_uri!r}"
@@ -148,9 +148,9 @@ def test_account_lifecycle_and_post_roundtrip(live_app):
) )
assert s == 200, f"getRecord HTTP {s}: {body!r}" assert s == 200, f"getRecord HTTP {s}: {body!r}"
record_value = (body or {}).get("value", {}) record_value = (body or {}).get("value", {})
assert ( assert record_value.get("text") == marker, (
record_value.get("text") == marker f"post text did not round-trip: created={marker!r}, fetched={record_value.get('text')!r}"
), f"post text did not round-trip: created={marker!r}, fetched={record_value.get('text')!r}" )
assert record_value.get("$type") == "app.bsky.feed.post" assert record_value.get("$type") == "app.bsky.feed.post"
finally: finally:
# Step 6: Best-effort cleanup. (The per-run domain teardown will discard the volume # Step 6: Best-effort cleanup. (The per-run domain teardown will discard the volume
@@ -26,6 +26,6 @@ def test_describe_server_returns_atproto_envelope(live_app):
# At least one of these atproto-spec fields must be present # At least one of these atproto-spec fields must be present
expected_any = ("availableUserDomains", "inviteCodeRequired", "links", "did") expected_any = ("availableUserDomains", "inviteCodeRequired", "links", "did")
present = [k for k in expected_any if k in body] present = [k for k in expected_any if k in body]
assert ( assert present, (
present f"describe-server missing all of {expected_any}; got keys: {sorted(body.keys())[:20]}"
), f"describe-server missing all of {expected_any}; got keys: {sorted(body.keys())[:20]}" )
@@ -17,6 +17,6 @@ def test_pds_health_returns_version(live_app):
url = f"https://{live_app}/xrpc/_health" url = f"https://{live_app}/xrpc/_health"
status, body = harness_http.retry_http_get(url, expect_status=200, max_wait=60, interval=3) status, body = harness_http.retry_http_get(url, expect_status=200, max_wait=60, interval=3)
assert status == 200, f"GET {url} HTTP {status} (expected 200)" assert status == 200, f"GET {url} HTTP {status} (expected 200)"
assert ( assert isinstance(body, dict) and isinstance(body.get("version"), str) and body["version"], (
isinstance(body, dict) and isinstance(body.get("version"), str) and body["version"] f"GET {url} response is not the expected health envelope: {body!r}"
), f"GET {url} response is not the expected health envelope: {body!r}" )
@@ -30,6 +30,6 @@ def test_get_session_requires_auth(live_app):
f"body: {body!r}" f"body: {body!r}"
) )
# The XRPC error envelope is JSON with an `error` field per the atproto spec. # The XRPC error envelope is JSON with an `error` field per the atproto spec.
assert isinstance(body, dict) and body.get( assert isinstance(body, dict) and body.get("error"), (
"error" f"expected XRPC JSON error envelope; got: {body!r}"
), f"expected XRPC JSON error envelope; got: {body!r}" )
+3 -3
View File
@@ -11,6 +11,6 @@ import _p4 # noqa: E402
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert _p4.account_exists( assert _p4.account_exists(live_app), (
live_app "restore did not bring back the seeded marker account (PDS data did not survive restore)"
), "restore did not bring back the seeded marker account (PDS data did not survive restore)" )
+9 -9
View File
@@ -78,9 +78,9 @@ def test_7_new_run_blocks_until_reap_finishes(lock_dir, pool, monkeypatch):
line = wait_marker(state["acquirer_out"], "ACQUIRED", timeout=15) line = wait_marker(state["acquirer_out"], "ACQUIRED", timeout=15)
assert line, "new run never acquired after the reap" assert line, "new run never acquired after the reap"
acquired_ts = float(line.split()[1]) acquired_ts = float(line.split()[1])
assert ( assert acquired_ts >= state["teardown_end"], (
acquired_ts >= state["teardown_end"] f"new run acquired at {acquired_ts} BEFORE the reap finished at {state['teardown_end']}"
), f"new run acquired at {acquired_ts} BEFORE the reap finished at {state['teardown_end']}" )
# The new run must hold a lock the next probe can SEE (fresh inode at the path). # The new run must hold a lock the next probe can SEE (fresh inode at the path).
assert lock_state(DOMAIN) == "held" assert lock_state(DOMAIN) == "held"
@@ -160,17 +160,17 @@ def test_11_warm_canonical_names_never_probed(lock_dir, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
lifecycle, lifecycle,
"_docker_names", "_docker_names",
lambda kind, stack: ["warm-keycloak_ci_commoninternet_net_app"] lambda kind, stack: (
if kind == "service" ["warm-keycloak_ci_commoninternet_net_app"] if kind == "service" else []
else [], ),
) )
monkeypatch.setattr(lifecycle, "teardown_app", lambda d, verify=True: calls.append(d)) monkeypatch.setattr(lifecycle, "teardown_app", lambda d, verify=True: calls.append(d))
lifecycle.janitor() lifecycle.janitor()
assert calls == [] assert calls == []
lockdir = os.environ["CCCI_APP_LOCK_DIR"] lockdir = os.environ["CCCI_APP_LOCK_DIR"]
assert [ assert [f for f in os.listdir(lockdir) if f.startswith("cc-ci-app-")] == [], (
f for f in os.listdir(lockdir) if f.startswith("cc-ci-app-") "janitor must not create lockfiles for non-run-app names"
] == [], "janitor must not create lockfiles for non-run-app names" )
def test_12_degrades_safely_on_bad_lockfile_and_missing_dir(lock_dir, monkeypatch, capsys): def test_12_degrades_safely_on_bad_lockfile_and_missing_dir(lock_dir, monkeypatch, capsys):
+3 -3
View File
@@ -61,9 +61,9 @@ def test_3_lock_fd_not_inherited_by_children(lock_dir, pool):
p.kill() p.kill()
p.wait(timeout=10) p.wait(timeout=10)
assert os.path.exists(f"/proc/{child_pid}"), "child should outlive the holder" assert os.path.exists(f"/proc/{child_pid}"), "child should outlive the holder"
assert ( assert wait_lock_state(DOMAIN, "free") == "free", (
wait_lock_state(DOMAIN, "free") == "free" "lock must release on holder death even with a live child (PEP 446 non-inheritable fd)"
), "lock must release on holder death even with a live child (PEP 446 non-inheritable fd)" )
def test_4_second_acquire_blocks_until_first_exits(lock_dir, pool): def test_4_second_acquire_blocks_until_first_exits(lock_dir, pool):
+3 -3
View File
@@ -64,9 +64,9 @@ def test_20c_same_domain_runs_each_keep_their_own_count(tmp_path, lock_dir, pool
pa.wait(timeout=15) pa.wait(timeout=15)
line_b = wait_marker(out_b, "COUNT") line_b = wait_marker(out_b, "COUNT")
assert ( assert line_b is not None and line_b.strip() == "COUNT 1", (
line_b is not None and line_b.strip() == "COUNT 1" line_b
), line_b # B's file survived A's remove ) # B's file survived A's remove
pb.wait(timeout=15) pb.wait(timeout=15)
@@ -150,9 +150,9 @@ def test_cryptpad_pad_content_survives_fresh_session(live_app):
# --- session 1: create the pad + write the marker --- # --- session 1: create the pad + write the marker ---
ctx1 = browser.new_context(ignore_https_errors=True) ctx1 = browser.new_context(ignore_https_errors=True)
page, pad_url = _open_pad(ctx1, f"https://{live_app}/pad/") page, pad_url = _open_pad(ctx1, f"https://{live_app}/pad/")
assert ( assert "#/2/pad/edit/" in pad_url, (
"#/2/pad/edit/" in pad_url f"CryptPad did not create a fragment-keyed pad URL; got {pad_url!r}"
), f"CryptPad did not create a fragment-keyed pad URL; got {pad_url!r}" )
ck = _ckeditor_frame(page, reload_url=pad_url) ck = _ckeditor_frame(page, reload_url=pad_url)
assert ck is not None, "CKEditor content frame never attached (pad editor not ready)" assert ck is not None, "CKEditor content frame never attached (pad editor not ready)"
_dismiss_store_modal(page) _dismiss_store_modal(page)
@@ -161,9 +161,9 @@ def test_cryptpad_pad_content_survives_fresh_session(live_app):
page.wait_for_timeout(1000) page.wait_for_timeout(1000)
body.type(marker, delay=40) body.type(marker, delay=40)
page.wait_for_timeout(12000) # let CryptPad encrypt + sync the update to the server page.wait_for_timeout(12000) # let CryptPad encrypt + sync the update to the server
assert ( assert marker in ck.locator("body").inner_text(), (
marker in ck.locator("body").inner_text() "marker not present in the editor after typing — type did not land"
), "marker not present in the editor after typing — type did not land" )
ctx1.close() ctx1.close()
# --- session 2: FRESH context (no shared storage/localStorage) reads the pad back by URL. # --- session 2: FRESH context (no shared storage/localStorage) reads the pad back by URL.
+3 -3
View File
@@ -51,9 +51,9 @@ def test_cryptpad_spa_renders_with_no_console_errors(live_app):
title = (page.title() or "").lower() title = (page.title() or "").lower()
body = page.content() body = page.content()
blower = body.lower() blower = body.lower()
assert ( assert "cryptpad" in title or "cryptpad" in blower, (
"cryptpad" in title or "cryptpad" in blower f"CryptPad SPA does not carry brand. title={title!r}, body excerpt: {body[:200]!r}"
), f"CryptPad SPA does not carry brand. title={title!r}, body excerpt: {body[:200]!r}" )
# Canonical CryptPad asset references in the rendered DOM # Canonical CryptPad asset references in the rendered DOM
canonical = ("/customize/", "/components/", "main.js", "/api/broadcast") canonical = ("/customize/", "/components/", "main.js", "/api/broadcast")
+3 -3
View File
@@ -14,6 +14,6 @@ MARKER = "/cryptpad/data/ci-marker.txt"
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "original", (
lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "original" "the seeded state was not present at backup time"
), "the seeded state was not present at backup time" )
+3 -3
View File
@@ -14,6 +14,6 @@ MARKER = "/cryptpad/data/ci-marker.txt"
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "original", (
lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "original" "restore did not return the pre-mutation state"
), "restore did not return the pre-mutation state" )
+3 -3
View File
@@ -14,6 +14,6 @@ MARKER = "/cryptpad/data/ci-marker.txt"
def test_upgrade_preserves_data(live_app): def test_upgrade_preserves_data(live_app):
assert ( assert lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "upgrade-survives", (
lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "upgrade-survives" "data did not survive the upgrade"
), "data did not survive the upgrade" )
@@ -79,9 +79,9 @@ def test_static_file_roundtrip_and_404(live_app):
# A random non-existent path must 404 — proves real static-file semantics, distinguishing a # A random non-existent path must 404 — proves real static-file semantics, distinguishing a
# working server from a 200-everything stub or a mis-routed Traefik fallback. # working server from a 200-everything stub or a mis-routed Traefik fallback.
miss_status, _ = _get(f"https://{live_app}/ccci-missing-{uuid.uuid4().hex}.txt") miss_status, _ = _get(f"https://{live_app}/ccci-missing-{uuid.uuid4().hex}.txt")
assert ( assert miss_status == 404, (
miss_status == 404 f"missing path returned {miss_status} (expected 404 — generic 200-returner / mis-route?)"
), f"missing path returned {miss_status} (expected 404 — generic 200-returner / mis-route?)" )
finally: finally:
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
os.remove(path) os.remove(path)
@@ -53,9 +53,9 @@ def test_content_type_html_and_txt(live_app):
ct_txt = h_txt.get("content-type", "") ct_txt = h_txt.get("content-type", "")
# nginx default: "text/html" for .html and "text/plain" for .txt (may include "; charset=utf-8") # nginx default: "text/html" for .html and "text/plain" for .txt (may include "; charset=utf-8")
assert ct_html.startswith( assert ct_html.startswith("text/html"), (
"text/html" f"{html_name} Content-Type={ct_html!r}, expected text/html (nginx MIME config broken?)"
), f"{html_name} Content-Type={ct_html!r}, expected text/html (nginx MIME config broken?)" )
assert ct_txt.startswith( assert ct_txt.startswith("text/plain"), (
"text/plain" f"{txt_name} Content-Type={ct_txt!r}, expected text/plain (nginx MIME config broken?)"
), f"{txt_name} Content-Type={ct_txt!r}, expected text/plain (nginx MIME config broken?)" )
+3 -3
View File
@@ -16,6 +16,6 @@ MARKER_PATH = "/usr/share/nginx/html/ci-marker.txt"
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert lifecycle.exec_in_app(live_app, ["cat", MARKER_PATH]).strip() == "original", (
lifecycle.exec_in_app(live_app, ["cat", MARKER_PATH]).strip() == "original" "the seeded state was not present at backup time"
), "the seeded state was not present at backup time" )
+3 -3
View File
@@ -17,6 +17,6 @@ MARKER_PATH = "/usr/share/nginx/html/ci-marker.txt"
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
restored = lifecycle.exec_in_app(live_app, ["cat", MARKER_PATH]).strip() restored = lifecycle.exec_in_app(live_app, ["cat", MARKER_PATH]).strip()
assert ( assert restored == "original", (
restored == "original" f"restore did not return the pre-mutation (backed-up) state: got {restored!r}"
), f"restore did not return the pre-mutation (backed-up) state: got {restored!r}" )
+3 -3
View File
@@ -16,6 +16,6 @@ MARKER_PATH = "/usr/share/nginx/html/ci-marker.txt"
def test_upgrade_preserves_data(live_app): def test_upgrade_preserves_data(live_app):
# the marker seeded by ops.pre_upgrade (before the harness upgraded) is still served # the marker seeded by ops.pre_upgrade (before the harness upgraded) is still served
assert ( assert lifecycle.http_fetch(live_app, "/ci-marker.txt")[1].strip() == "upgrade-survives", (
lifecycle.http_fetch(live_app, "/ci-marker.txt")[1].strip() == "upgrade-survives" "data did not survive the upgrade"
), "data did not survive the upgrade" )
+3 -3
View File
@@ -81,9 +81,9 @@ def mint_admin(domain: str) -> tuple[str, str]:
key = line.split("=", 1)[1].strip() key = line.split("=", 1)[1].strip()
elif line.startswith("CCCI_API_USER="): elif line.startswith("CCCI_API_USER="):
user = line.split("=", 1)[1].strip() user = line.split("=", 1)[1].strip()
assert ( assert key and user, (
key and user f"could not bootstrap discourse admin/API key; rails output tail:\n{out[-1000:]}"
), f"could not bootstrap discourse admin/API key; rails output tail:\n{out[-1000:]}" )
return key, user return key, user
+12 -12
View File
@@ -48,23 +48,23 @@ def test_create_topic_roundtrip(live_app):
headers=hdrs, headers=hdrs,
timeout=60, timeout=60,
) )
assert status in (200, 201) and isinstance( assert status in (200, 201) and isinstance(body, dict), (
body, dict f"create topic failed: HTTP {status}, body={body!r}"
), f"create topic failed: HTTP {status}, body={body!r}" )
topic_id = body.get("topic_id") topic_id = body.get("topic_id")
assert topic_id, f"create topic returned no topic_id: {body!r}" assert topic_id, f"create topic returned no topic_id: {body!r}"
# 4) Read the topic back and assert title + first-post body round-trip. # 4) Read the topic back and assert title + first-post body round-trip.
status, got = harness_http.http_get(f"{base}/t/{topic_id}.json", headers=hdrs, timeout=30) status, got = harness_http.http_get(f"{base}/t/{topic_id}.json", headers=hdrs, timeout=30)
assert status == 200 and isinstance( assert status == 200 and isinstance(got, dict), (
got, dict f"read topic failed: HTTP {status}, body={got!r}"
), f"read topic failed: HTTP {status}, body={got!r}" )
assert ( assert got.get("title") == title, (
got.get("title") == title f"topic title did not round-trip: sent {title!r}, got {got.get('title')!r}"
), f"topic title did not round-trip: sent {title!r}, got {got.get('title')!r}" )
posts = (got.get("post_stream") or {}).get("posts") or [] posts = (got.get("post_stream") or {}).get("posts") or []
assert posts, f"topic has no posts on read-back: {got!r}" assert posts, f"topic has no posts on read-back: {got!r}"
first_cooked = posts[0].get("cooked", "") first_cooked = posts[0].get("cooked", "")
assert ( assert marker in first_cooked, (
marker in first_cooked f"topic body did not round-trip: marker {marker!r} not in first post {first_cooked!r}"
), f"topic body did not round-trip: marker {marker!r} not in first post {first_cooked!r}" )
+6 -6
View File
@@ -20,12 +20,12 @@ def test_site_json_has_discourse_config(live_app):
status, body = harness_http.retry_http_get( status, body = harness_http.retry_http_get(
f"https://{live_app}/site.json", expect_status=200, max_wait=120, interval=5 f"https://{live_app}/site.json", expect_status=200, max_wait=120, interval=5
) )
assert status == 200 and isinstance( assert status == 200 and isinstance(body, dict), (
body, dict f"GET /site.json failed: HTTP {status}, body type={type(body).__name__}"
), f"GET /site.json failed: HTTP {status}, body type={type(body).__name__}" )
# /site.json carries Discourse-specific structure — `categories` (a list) and `groups` are always # /site.json carries Discourse-specific structure — `categories` (a list) and `groups` are always
# present in a booted Discourse. A non-Discourse 200 (placeholder page) would not parse to this. # present in a booted Discourse. A non-Discourse 200 (placeholder page) would not parse to this.
assert "categories" in body, f"/site.json missing 'categories' key: keys={list(body)[:20]}" assert "categories" in body, f"/site.json missing 'categories' key: keys={list(body)[:20]}"
assert isinstance( assert isinstance(body["categories"], list), (
body["categories"], list f"/site.json 'categories' not a list: {type(body['categories']).__name__}"
), f"/site.json 'categories' not a list: {type(body['categories']).__name__}" )
+1 -3
View File
@@ -14,9 +14,7 @@ from harness import lifecycle # noqa: E402
def _psql(domain, sql): def _psql(domain, sql):
cmd = ( cmd = f'PGPASSWORD=$(cat /run/secrets/db_password) psql -U discourse -d discourse -tAc "{sql}"'
"PGPASSWORD=$(cat /run/secrets/db_password) " f'psql -U discourse -d discourse -tAc "{sql}"'
)
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip() return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
+4 -6
View File
@@ -13,13 +13,11 @@ from harness import lifecycle # noqa: E402
def _psql(domain, sql): def _psql(domain, sql):
cmd = ( cmd = f'PGPASSWORD=$(cat /run/secrets/db_password) psql -U discourse -d discourse -tAc "{sql}"'
"PGPASSWORD=$(cat /run/secrets/db_password) " f'psql -U discourse -d discourse -tAc "{sql}"'
)
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip() return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "the seeded discourse postgres state was not present at backup time"
), "the seeded discourse postgres state was not present at backup time" )
+4 -6
View File
@@ -13,13 +13,11 @@ from harness import lifecycle # noqa: E402
def _psql(domain, sql): def _psql(domain, sql):
cmd = ( cmd = f'PGPASSWORD=$(cat /run/secrets/db_password) psql -U discourse -d discourse -tAc "{sql}"'
"PGPASSWORD=$(cat /run/secrets/db_password) " f'psql -U discourse -d discourse -tAc "{sql}"'
)
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip() return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "restore did not return the pre-mutation discourse postgres state (data-integrity failure)"
), "restore did not return the pre-mutation discourse postgres state (data-integrity failure)" )
+3 -3
View File
@@ -62,6 +62,6 @@ def test_ghost_admin_route_is_wired(live_app):
assert status in (200, 302), f"unexpected status: {status}" assert status in (200, 302), f"unexpected status: {status}"
if status == 200: if status == 200:
# The admin SPA references /ghost-assets/ or contains "ghost" in title/body # The admin SPA references /ghost-assets/ or contains "ghost" in title/body
assert ( assert "ghost" in body.lower(), (
"ghost" in body.lower() f"GET {url} 200 but body has no Ghost markers: {body[:200]!r}"
), f"GET {url} 200 but body has no Ghost markers: {body[:200]!r}" )
+6 -6
View File
@@ -35,10 +35,10 @@ def test_content_api_settings_endpoint(live_app):
assert body is not None, f"GET {url} returned non-JSON body" assert body is not None, f"GET {url} returned non-JSON body"
# On success: {"settings": {...}}. On error: {"errors": [...]}. Either shape is valid. # On success: {"settings": {...}}. On error: {"errors": [...]}. Either shape is valid.
if status == 200: if status == 200:
assert ( assert isinstance(body, dict) and "settings" in body, (
isinstance(body, dict) and "settings" in body f"200 response missing 'settings' envelope: {body!r}"
), f"200 response missing 'settings' envelope: {body!r}" )
else: else:
assert isinstance(body, dict) and ( assert isinstance(body, dict) and ("errors" in body or "message" in body or body), (
"errors" in body or "message" in body or body f"error response not a proper Ghost error envelope: {body!r}"
), f"error response not a proper Ghost error envelope: {body!r}" )
+9 -9
View File
@@ -43,17 +43,17 @@ def test_create_post_roundtrip(live_app):
title = f"ccci-marker-{uniq}" title = f"ccci-marker-{uniq}"
marker = f"ccci-body-marker-{uniq}-roundtrip" marker = f"ccci-body-marker-{uniq}-roundtrip"
created = admin.create_post(title, f"<p>{marker}</p>") created = admin.create_post(title, f"<p>{marker}</p>")
assert ( assert created.get("title") == title, (
created.get("title") == title f"created post title mismatch: sent {title!r}, got {created.get('title')!r}"
), f"created post title mismatch: sent {title!r}, got {created.get('title')!r}" )
# 4) Read it back by id and assert the post survived the round-trip (title always returned; # 4) Read it back by id and assert the post survived the round-trip (title always returned;
# html returned because we requested ?formats=html). # html returned because we requested ?formats=html).
got = admin.get_post(created["id"]) got = admin.get_post(created["id"])
assert ( assert got.get("title") == title, (
got.get("title") == title f"post title did not round-trip: sent {title!r}, got {got.get('title')!r}"
), f"post title did not round-trip: sent {title!r}, got {got.get('title')!r}" )
html = got.get("html") or "" html = got.get("html") or ""
assert ( assert marker in html, (
marker in html f"post body did not round-trip: marker {marker!r} not in read-back html {html!r}"
), f"post body did not round-trip: marker {marker!r} not in read-back html {html!r}" )
+1 -1
View File
@@ -22,7 +22,7 @@ from harness import lifecycle # noqa: E402
def _mysql(domain, sql): def _mysql(domain, sql):
cmd = 'MYSQL_PWD="$(cat /run/secrets/db_password)" ' f'mysql -u root -N -s ghost -e "{sql}"' cmd = f'MYSQL_PWD="$(cat /run/secrets/db_password)" mysql -u root -N -s ghost -e "{sql}"'
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip() return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
+4 -4
View File
@@ -15,11 +15,11 @@ from harness import lifecycle # noqa: E402
def _mysql(domain, sql): def _mysql(domain, sql):
cmd = 'MYSQL_PWD="$(cat /run/secrets/db_password)" ' f'mysql -u root -N -s ghost -e "{sql}"' cmd = f'MYSQL_PWD="$(cat /run/secrets/db_password)" mysql -u root -N -s ghost -e "{sql}"'
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip() return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert _mysql(live_app, "SELECT v FROM ci_marker;") == "original", (
_mysql(live_app, "SELECT v FROM ci_marker;") == "original" "the seeded ghost MySQL marker was not present at backup time"
), "the seeded ghost MySQL marker was not present at backup time" )
+1 -1
View File
@@ -22,7 +22,7 @@ from harness import lifecycle # noqa: E402
def _mysql(domain, sql): def _mysql(domain, sql):
cmd = 'MYSQL_PWD="$(cat /run/secrets/db_password)" ' f'mysql -u root -N -s ghost -e "{sql}"' cmd = f'MYSQL_PWD="$(cat /run/secrets/db_password)" mysql -u root -N -s ghost -e "{sql}"'
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip() return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
+4 -4
View File
@@ -14,11 +14,11 @@ from harness import lifecycle # noqa: E402
def _mysql(domain, sql): def _mysql(domain, sql):
cmd = 'MYSQL_PWD="$(cat /run/secrets/db_password)" ' f'mysql -u root -N -s ghost -e "{sql}"' cmd = f'MYSQL_PWD="$(cat /run/secrets/db_password)" mysql -u root -N -s ghost -e "{sql}"'
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip() return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
def test_upgrade_preserves_state(live_app): def test_upgrade_preserves_state(live_app):
assert ( assert _mysql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", (
_mysql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives" "the seeded ghost MySQL marker did not survive the upgrade redeploy (data loss on upgrade)"
), "the seeded ghost MySQL marker did not survive the upgrade redeploy (data loss on upgrade)" )
+12 -12
View File
@@ -145,9 +145,9 @@ def test_lfs_roundtrip(live_app):
text=True, text=True,
env={**os.environ, **git_env}, env={**os.environ, **git_env},
) )
assert ( assert "testblob.bin" in lfs_ls.stdout, (
"testblob.bin" in lfs_ls.stdout f"testblob.bin not in git-lfs ls-files: {lfs_ls.stdout}"
), f"testblob.bin not in git-lfs ls-files: {lfs_ls.stdout}" )
# 6. Download in a FRESH clone (proves the LFS server stores and serves the object) # 6. Download in a FRESH clone (proves the LFS server stores and serves the object)
fresh_dir = tempfile.mkdtemp(prefix="ccci-gitea-lfs-dl-") fresh_dir = tempfile.mkdtemp(prefix="ccci-gitea-lfs-dl-")
@@ -158,9 +158,9 @@ def test_lfs_roundtrip(live_app):
with open(fetched_path, "rb") as f: with open(fetched_path, "rb") as f:
fetched = f.read() fetched = f.read()
fetched_sha256 = hashlib.sha256(fetched).hexdigest() fetched_sha256 = hashlib.sha256(fetched).hexdigest()
assert ( assert fetched_sha256 == expected_sha256, (
fetched_sha256 == expected_sha256 f"LFS round-trip OID mismatch: expected {expected_oid}, got sha256:{fetched_sha256}"
), f"LFS round-trip OID mismatch: expected {expected_oid}, got sha256:{fetched_sha256}" )
finally: finally:
shutil.rmtree(fresh_dir, ignore_errors=True) shutil.rmtree(fresh_dir, ignore_errors=True)
@@ -171,9 +171,9 @@ def test_lfs_roundtrip(live_app):
["sh", "-c", "grep -E '^LFS_JWT_SECRET' /etc/gitea/app.ini || echo NOT_FOUND"], ["sh", "-c", "grep -E '^LFS_JWT_SECRET' /etc/gitea/app.ini || echo NOT_FOUND"],
timeout=30, timeout=30,
).strip() ).strip()
assert ( assert current_jwt and "NOT_FOUND" not in current_jwt, (
current_jwt and "NOT_FOUND" not in current_jwt "Could not read LFS_JWT_SECRET from /etc/gitea/app.ini before restart"
), "Could not read LFS_JWT_SECRET from /etc/gitea/app.ini before restart" )
# Restart the gitea container # Restart the gitea container
lifecycle.exec_in_app(live_app, ["true"], timeout=5) # no-op to confirm exec works lifecycle.exec_in_app(live_app, ["true"], timeout=5) # no-op to confirm exec works
@@ -213,9 +213,9 @@ def test_lfs_roundtrip(live_app):
assert os.path.exists(pr_blob), "testblob.bin not fetched in post-restart clone" assert os.path.exists(pr_blob), "testblob.bin not fetched in post-restart clone"
with open(pr_blob, "rb") as f: with open(pr_blob, "rb") as f:
pr_data = f.read() pr_data = f.read()
assert ( assert hashlib.sha256(pr_data).hexdigest() == expected_sha256, (
hashlib.sha256(pr_data).hexdigest() == expected_sha256 "LFS object corrupted after restart — JWT secret may have changed"
), "LFS object corrupted after restart — JWT secret may have changed" )
finally: finally:
shutil.rmtree(post_restart_dir, ignore_errors=True) shutil.rmtree(post_restart_dir, ignore_errors=True)
+3 -3
View File
@@ -220,7 +220,7 @@ def pre_restore(ctx):
generic.assert_serving(ctx.domain, ctx.meta) generic.assert_serving(ctx.domain, ctx.meta)
ok = _delete_marker_repo(ctx.domain, user, password) ok = _delete_marker_repo(ctx.domain, user, password)
assert ok, f"pre_restore: could not delete {_MARKER_REPO} repo on {ctx.domain}" assert ok, f"pre_restore: could not delete {_MARKER_REPO} repo on {ctx.domain}"
assert not marker_repo_exists( assert not marker_repo_exists(ctx.domain, user, password), (
ctx.domain, user, password f"pre_restore: {_MARKER_REPO} still present after delete — divergence did not take"
), f"pre_restore: {_MARKER_REPO} still present after delete — divergence did not take" )
print(f" gitea ops: {_MARKER_REPO!r} deleted (diverged from backup state)", flush=True) print(f" gitea ops: {_MARKER_REPO!r} deleted (diverged from backup state)", flush=True)
+3 -3
View File
@@ -22,6 +22,6 @@ def test_backup_captures_marker_repo(live_app, meta):
# backupbot cycles the gitea container during backup — wait for it to be back up. # backupbot cycles the gitea container during backup — wait for it to be back up.
generic.assert_serving(live_app, meta) generic.assert_serving(live_app, meta)
user, password = admin_creds(live_app) user, password = admin_creds(live_app)
assert marker_repo_exists( assert marker_repo_exists(live_app, user, password), (
live_app, user, password f"{live_app}: ci-marker repo is not present at backup time (backup would capture empty state)"
), f"{live_app}: ci-marker repo is not present at backup time (backup would capture empty state)" )
+3 -3
View File
@@ -65,8 +65,8 @@ def test_install_gitea(live_app, meta):
) )
page.wait_for_selector("input#user_name", timeout=20_000) page.wait_for_selector("input#user_name", timeout=20_000)
content = page.content() content = page.content()
assert ( assert "gitea" in content.lower() or "sign in" in content.lower(), (
"gitea" in content.lower() or "sign in" in content.lower() "Sign-in page did not render expected gitea content"
), "Sign-in page did not render expected gitea content" )
finally: finally:
browser.close() browser.close()
+3 -3
View File
@@ -20,6 +20,6 @@ def test_upgrade_preserves_marker_repo(live_app, meta):
"""The ci-marker repo survived the upgrade to the PR head (data continuity).""" """The ci-marker repo survived the upgrade to the PR head (data continuity)."""
generic.assert_serving(live_app, meta) generic.assert_serving(live_app, meta)
user, password = admin_creds(live_app) user, password = admin_creds(live_app)
assert marker_repo_exists( assert marker_repo_exists(live_app, user, password), (
live_app, user, password f"{live_app}: ci-marker repo did not survive the upgrade (sqlite3 data lost)"
), f"{live_app}: ci-marker repo did not survive the upgrade (sqlite3 data lost)" )
+6 -6
View File
@@ -111,13 +111,13 @@ def test_immich_processes_uploaded_asset_metadata_and_statistics(live_app):
if exif and exif.get("exifImageWidth"): if exif and exif.get("exifImageWidth"):
break break
time.sleep(5) time.sleep(5)
assert ( assert exif and exif.get("exifImageWidth") == 1 and exif.get("exifImageHeight") == 1, (
exif and exif.get("exifImageWidth") == 1 and exif.get("exifImageHeight") == 1 f"immich metadata-extraction did not populate the 1x1 PNG dimensions in exifInfo: {exif!r}"
), f"immich metadata-extraction did not populate the 1x1 PNG dimensions in exifInfo: {exif!r}" )
# the asset is catalogued into the owner's library statistics (list-back in aggregate) # the asset is catalogued into the owner's library statistics (list-back in aggregate)
sst, stats = harness_http.http_request("GET", f"{base}/api/assets/statistics", headers=auth) sst, stats = harness_http.http_request("GET", f"{base}/api/assets/statistics", headers=auth)
assert sst == 200 and isinstance(stats, dict), f"statistics HTTP {sst}: {stats!r}" assert sst == 200 and isinstance(stats, dict), f"statistics HTTP {sst}: {stats!r}"
assert ( assert stats.get("images", 0) >= 1 and stats.get("total", 0) >= 1, (
stats.get("images", 0) >= 1 and stats.get("total", 0) >= 1 f"uploaded asset not reflected in library statistics: {stats!r}"
), f"uploaded asset not reflected in library statistics: {stats!r}" )
+3 -3
View File
@@ -121,6 +121,6 @@ def test_immich_upload_asset_readback_and_thumbnail(live_app):
if thumb == 200: if thumb == 200:
break break
time.sleep(5) time.sleep(5)
assert ( assert thumb == 200, (
thumb == 200 f"immich did not generate a thumbnail/derivative for the uploaded asset (last HTTP {thumb})"
), f"immich did not generate a thumbnail/derivative for the uploaded asset (last HTTP {thumb})" )
+3 -3
View File
@@ -14,6 +14,6 @@ def _psql(domain, sql):
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "seeded postgres state not present at backup time"
), "seeded postgres state not present at backup time" )
+3 -3
View File
@@ -14,6 +14,6 @@ def _psql(domain, sql):
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "restore did not return the pre-mutation postgres state"
), "restore did not return the pre-mutation postgres state" )
+3 -3
View File
@@ -14,6 +14,6 @@ def _psql(domain, sql):
def test_upgrade_preserves_data(live_app): def test_upgrade_preserves_data(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", (
_psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives" "postgres data did not survive the upgrade"
), "postgres data did not survive the upgrade" )
@@ -144,25 +144,25 @@ def test_create_confidential_client_and_obtain_token(live_app):
# Use the client to obtain its own token (client_credentials grant) # Use the client to obtain its own token (client_credentials grant)
tok_status, tok_resp = _client_credentials_token(live_app, client_id, client_secret) tok_status, tok_resp = _client_credentials_token(live_app, client_id, client_secret)
assert ( assert tok_status == 200, (
tok_status == 200 f"client_credentials token returned HTTP {tok_status}: {tok_resp!r}"
), f"client_credentials token returned HTTP {tok_status}: {tok_resp!r}" )
access_token = tok_resp.get("access_token") if isinstance(tok_resp, dict) else None access_token = tok_resp.get("access_token") if isinstance(tok_resp, dict) else None
assert ( assert isinstance(access_token, str) and access_token.count(".") == 2, (
isinstance(access_token, str) and access_token.count(".") == 2 f"client_credentials access_token not a JWT: {access_token!r}"
), f"client_credentials access_token not a JWT: {access_token!r}" )
# Decode the JWT payload; assert azp matches the new client # Decode the JWT payload; assert azp matches the new client
payload = json.loads(_b64url_decode(access_token.split(".")[1])) payload = json.loads(_b64url_decode(access_token.split(".")[1]))
assert ( assert payload.get("azp") == client_id, (
payload.get("azp") == client_id f"client_credentials JWT azp={payload.get('azp')!r} != client_id={client_id!r}"
), f"client_credentials JWT azp={payload.get('azp')!r} != client_id={client_id!r}" )
# Service-account token does NOT carry a session-scoped user (azp + clientId differ from # Service-account token does NOT carry a session-scoped user (azp + clientId differ from
# admin-cli token). The presence of azp + iss == per-run-domain proves the issuance flow. # admin-cli token). The presence of azp + iss == per-run-domain proves the issuance flow.
expected_iss = f"https://{live_app}/realms/master" expected_iss = f"https://{live_app}/realms/master"
assert ( assert payload.get("iss") == expected_iss, (
payload.get("iss") == expected_iss f"JWT iss={payload.get('iss')!r} != {expected_iss!r}"
), f"JWT iss={payload.get('iss')!r} != {expected_iss!r}" )
finally: finally:
# Idempotent cleanup # Idempotent cleanup
if cleanup_id: if cleanup_id:
@@ -43,17 +43,17 @@ def test_password_grant_issues_valid_jwt(live_app):
token = kc_admin.admin_token(live_app, password) token = kc_admin.admin_token(live_app, password)
# Shape: a JWT is exactly 3 base64url segments # Shape: a JWT is exactly 3 base64url segments
assert ( assert isinstance(token, str) and token.count(".") == 2, (
isinstance(token, str) and token.count(".") == 2 f"access_token does not look like a JWT (no 3 segments): len={len(token) if token else 0}"
), f"access_token does not look like a JWT (no 3 segments): len={len(token) if token else 0}" )
payload = _decode_jwt_payload(token) payload = _decode_jwt_payload(token)
# iss = the issuer URL, must be the per-run domain's /realms/master endpoint # iss = the issuer URL, must be the per-run domain's /realms/master endpoint
expected_iss = f"https://{live_app}/realms/master" expected_iss = f"https://{live_app}/realms/master"
assert ( assert payload.get("iss") == expected_iss, (
payload.get("iss") == expected_iss f"JWT iss claim {payload.get('iss')!r} != {expected_iss!r}"
), f"JWT iss claim {payload.get('iss')!r} != {expected_iss!r}" )
# azp = authorized party (which client requested this token) # azp = authorized party (which client requested this token)
assert payload.get("azp") == "admin-cli", f"JWT azp claim {payload.get('azp')!r} != 'admin-cli'" assert payload.get("azp") == "admin-cli", f"JWT azp claim {payload.get('azp')!r} != 'admin-cli'"
@@ -68,6 +68,6 @@ def test_password_grant_issues_valid_jwt(live_app):
# iat (issued at) is also a standard claim # iat (issued at) is also a standard claim
iat = payload.get("iat") iat = payload.get("iat")
assert ( assert isinstance(iat, int) and iat <= time.time() + 60, (
isinstance(iat, int) and iat <= time.time() + 60 f"JWT iat {iat!r} not a reasonable past timestamp"
), f"JWT iat {iat!r} not a reasonable past timestamp" )
@@ -42,9 +42,9 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps):
# Sanity-check the creds shape — orchestrator-written # Sanity-check the creds shape — orchestrator-written
assert kc["domain"] assert kc["domain"]
# WC1: realm is per-run namespaced "<parent>-<6hex>" so concurrent dependents never collide. # WC1: realm is per-run namespaced "<parent>-<6hex>" so concurrent dependents never collide.
assert re.fullmatch( assert re.fullmatch(r"lasuite-docs-[0-9a-f]{6}", kc["realm"]), (
r"lasuite-docs-[0-9a-f]{6}", kc["realm"] f"realm {kc['realm']!r} not the per-run namespaced form lasuite-docs-<6hex>"
), f"realm {kc['realm']!r} not the per-run namespaced form lasuite-docs-<6hex>" )
assert kc["client_id"] == "lasuite-docs" assert kc["client_id"] == "lasuite-docs"
assert isinstance(kc["client_secret"], str) and len(kc["client_secret"]) >= 16 assert isinstance(kc["client_secret"], str) and len(kc["client_secret"]) >= 16
assert isinstance(kc["password"], str) and len(kc["password"]) >= 16 assert isinstance(kc["password"], str) and len(kc["password"]) >= 16
@@ -77,11 +77,11 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps):
assert isinstance(token, str) and token.count(".") == 2, f"access_token is not a JWT: {token!r}" assert isinstance(token, str) and token.count(".") == 2, f"access_token is not a JWT: {token!r}"
payload = json.loads(_b64url_decode(token.split(".")[1])) payload = json.loads(_b64url_decode(token.split(".")[1]))
assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}" assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}"
assert ( assert payload.get("azp") == kc["client_id"], (
payload.get("azp") == kc["client_id"] f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}"
), f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}" )
assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'" assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'"
exp = payload.get("exp") exp = payload.get("exp")
assert ( assert isinstance(exp, int) and exp > time.time(), (
isinstance(exp, int) and exp > time.time() f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})"
), f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})" )
+3 -3
View File
@@ -18,6 +18,6 @@ def _psql(domain, sql):
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "the seeded postgres state was not present at backup time"
), "the seeded postgres state was not present at backup time" )
+3 -3
View File
@@ -17,6 +17,6 @@ def _psql(domain, sql):
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "restore did not return the pre-mutation postgres state"
), "restore did not return the pre-mutation postgres state" )
+3 -3
View File
@@ -17,6 +17,6 @@ def _psql(domain, sql):
def test_upgrade_preserves_data(live_app): def test_upgrade_preserves_data(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", (
_psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives" "postgres data did not survive the upgrade"
), "postgres data did not survive the upgrade" )
@@ -56,6 +56,6 @@ def test_minio_bucket_present_and_object_roundtrip(live_app):
# The object was listed (its key appears) and its content round-tripped intact. # The object was listed (its key appears) and its content round-tripped intact.
assert f"{marker}.txt" in out, f"uploaded object not listed in bucket: {out!r}" assert f"{marker}.txt" in out, f"uploaded object not listed in bucket: {out!r}"
assert ( assert f"READBACK:{marker}" in out, (
f"READBACK:{marker}" in out f"object content did not round-trip through MinIO; got: {out!r}"
), f"object content did not round-trip through MinIO; got: {out!r}" )
@@ -46,9 +46,9 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps):
# Creds shape. WC1: realm is per-run namespaced "<parent>-<6hex>"; client_id stays the parent. # Creds shape. WC1: realm is per-run namespaced "<parent>-<6hex>"; client_id stays the parent.
assert kc["domain"] assert kc["domain"]
assert re.fullmatch( assert re.fullmatch(r"lasuite-drive-[0-9a-f]{6}", kc["realm"]), (
r"lasuite-drive-[0-9a-f]{6}", kc["realm"] f"realm {kc['realm']!r} not the per-run namespaced form lasuite-drive-<6hex>"
), f"realm {kc['realm']!r} not the per-run namespaced form lasuite-drive-<6hex>" )
assert kc["client_id"] == "lasuite-drive" assert kc["client_id"] == "lasuite-drive"
assert isinstance(kc["client_secret"], str) and len(kc["client_secret"]) >= 16 assert isinstance(kc["client_secret"], str) and len(kc["client_secret"]) >= 16
assert isinstance(kc["password"], str) and len(kc["password"]) >= 16 assert isinstance(kc["password"], str) and len(kc["password"]) >= 16
@@ -80,11 +80,11 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps):
assert isinstance(token, str) and token.count(".") == 2, f"access_token is not a JWT: {token!r}" assert isinstance(token, str) and token.count(".") == 2, f"access_token is not a JWT: {token!r}"
payload = json.loads(_b64url_decode(token.split(".")[1])) payload = json.loads(_b64url_decode(token.split(".")[1]))
assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}" assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}"
assert ( assert payload.get("azp") == kc["client_id"], (
payload.get("azp") == kc["client_id"] f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}"
), f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}" )
assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'" assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'"
exp = payload.get("exp") exp = payload.get("exp")
assert ( assert isinstance(exp, int) and exp > time.time(), (
isinstance(exp, int) and exp > time.time() f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})"
), f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})" )
+3 -3
View File
@@ -18,6 +18,6 @@ def _psql(domain, sql):
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "the seeded postgres state was not present at backup time"
), "the seeded postgres state was not present at backup time" )
+3 -3
View File
@@ -17,6 +17,6 @@ def _psql(domain, sql):
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "restore did not return the pre-mutation postgres state"
), "restore did not return the pre-mutation postgres state" )
+3 -3
View File
@@ -17,6 +17,6 @@ def _psql(domain, sql):
def test_upgrade_preserves_data(live_app): def test_upgrade_preserves_data(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", (
_psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives" "postgres data did not survive the upgrade"
), "postgres data did not survive the upgrade" )
@@ -46,9 +46,9 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps):
# Creds shape. WC1: realm is per-run namespaced "<parent>-<6hex>"; client_id stays the parent. # Creds shape. WC1: realm is per-run namespaced "<parent>-<6hex>"; client_id stays the parent.
assert kc["domain"] assert kc["domain"]
assert re.fullmatch( assert re.fullmatch(r"lasuite-meet-[0-9a-f]{6}", kc["realm"]), (
r"lasuite-meet-[0-9a-f]{6}", kc["realm"] f"realm {kc['realm']!r} not the per-run namespaced form lasuite-meet-<6hex>"
), f"realm {kc['realm']!r} not the per-run namespaced form lasuite-meet-<6hex>" )
assert kc["client_id"] == "lasuite-meet" assert kc["client_id"] == "lasuite-meet"
assert isinstance(kc["client_secret"], str) and len(kc["client_secret"]) >= 16 assert isinstance(kc["client_secret"], str) and len(kc["client_secret"]) >= 16
assert isinstance(kc["password"], str) and len(kc["password"]) >= 16 assert isinstance(kc["password"], str) and len(kc["password"]) >= 16
@@ -80,11 +80,11 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps):
assert isinstance(token, str) and token.count(".") == 2, f"access_token is not a JWT: {token!r}" assert isinstance(token, str) and token.count(".") == 2, f"access_token is not a JWT: {token!r}"
payload = json.loads(_b64url_decode(token.split(".")[1])) payload = json.loads(_b64url_decode(token.split(".")[1]))
assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}" assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}"
assert ( assert payload.get("azp") == kc["client_id"], (
payload.get("azp") == kc["client_id"] f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}"
), f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}" )
assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'" assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'"
exp = payload.get("exp") exp = payload.get("exp")
assert ( assert isinstance(exp, int) and exp > time.time(), (
isinstance(exp, int) and exp > time.time() f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})"
), f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})" )
+3 -3
View File
@@ -17,6 +17,6 @@ def _psql(domain, sql):
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "the seeded postgres state was not present at backup time"
), "the seeded postgres state was not present at backup time" )
+3 -3
View File
@@ -17,6 +17,6 @@ def _psql(domain, sql):
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "restore did not return the pre-mutation postgres state"
), "restore did not return the pre-mutation postgres state" )
+3 -3
View File
@@ -17,6 +17,6 @@ def _psql(domain, sql):
def test_upgrade_preserves_data(live_app): def test_upgrade_preserves_data(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", (
_psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives" "postgres data did not survive the upgrade"
), "postgres data did not survive the upgrade" )
+1 -1
View File
@@ -43,7 +43,7 @@ def test_send_and_receive_mail(live_app):
deadline = time.time() + 150 deadline = time.time() + 150
while time.time() < deadline: while time.time() < deadline:
for box in ("INBOX", "Junk"): for box in ("INBOX", "Junk"):
query = f"doveadm search -u '{email_addr}' mailbox {box} " f"header subject '{marker}'" query = f"doveadm search -u '{email_addr}' mailbox {box} header subject '{marker}'"
out = lifecycle.exec_in_app(live_app, ["sh", "-c", query], service="imap") out = lifecycle.exec_in_app(live_app, ["sh", "-c", query], service="imap")
if out.strip(): # a non-empty result = "<mailbox-guid> <uid>" → message stored if out.strip(): # a non-empty result = "<mailbox-guid> <uid>" → message stored
return return
+3 -3
View File
@@ -24,6 +24,6 @@ def test_create_mailbox_and_read_back(live_app):
cfg = _mailu.config_export(live_app) cfg = _mailu.config_export(live_app)
emails = _mailu.user_emails(cfg) emails = _mailu.user_emails(cfg)
assert ( assert email in emails, (
email in emails f"created mailbox {email} not present in mailu config-export users {emails}"
), f"created mailbox {email} not present in mailu config-export users {emails}" )
@@ -34,12 +34,12 @@ def test_federation_version_endpoint(live_app):
assert status == 200, f"GET {url} HTTP {status} (expected 200)" assert status == 200, f"GET {url} HTTP {status} (expected 200)"
assert isinstance(body, dict), f"federation version returned non-dict: {type(body).__name__}" assert isinstance(body, dict), f"federation version returned non-dict: {type(body).__name__}"
server = body.get("server") server = body.get("server")
assert isinstance( assert isinstance(server, dict), (
server, dict f"federation version response missing 'server' envelope: {body!r}"
), f"federation version response missing 'server' envelope: {body!r}" )
name = server.get("name") name = server.get("name")
assert name == "Synapse", f"server.name={name!r}, expected 'Synapse'" assert name == "Synapse", f"server.name={name!r}, expected 'Synapse'"
version = server.get("version") version = server.get("version")
assert ( assert isinstance(version, str) and len(version) > 0, (
isinstance(version, str) and len(version) > 0 f"server.version is not a non-empty string: {version!r}"
), f"server.version is not a non-empty string: {version!r}" )
@@ -23,6 +23,6 @@ def test_synapse_client_versions_returns_json(live_app):
url = f"https://{live_app}/_matrix/client/versions" url = f"https://{live_app}/_matrix/client/versions"
status, body = harness_http.retry_http_get(url, expect_status=200, max_wait=60, interval=3) status, body = harness_http.retry_http_get(url, expect_status=200, max_wait=60, interval=3)
assert status == 200, f"GET {url} HTTP {status} (expected 200)" assert status == 200, f"GET {url} HTTP {status} (expected 200)"
assert ( assert isinstance(body, dict) and isinstance(body.get("versions"), list) and body["versions"], (
isinstance(body, dict) and isinstance(body.get("versions"), list) and body["versions"] f"GET {url} did not return Matrix client-versions document: {body!r}"
), f"GET {url} did not return Matrix client-versions document: {body!r}" )
@@ -127,8 +127,7 @@ def _admin_register(domain: str, secret: str, username: str, password: str, admi
if r["status"] == 200: if r["status"] == 200:
if attempt > 1: if attempt > 1:
print( print(
f" [register] {username}: succeeded on attempt {attempt} " f" [register] {username}: succeeded on attempt {attempt} (synapse recovered)",
f"(synapse recovered)",
flush=True, flush=True,
) )
return r["body"] or {} return r["body"] or {}
@@ -177,9 +176,9 @@ def test_register_two_users_send_receive_message(live_app):
create + invite + join a room; send and read a message.""" create + invite + join a room; send and read a message."""
domain = live_app domain = live_app
secret = _registration_secret(domain) secret = _registration_secret(domain)
assert ( assert secret and len(secret) >= 16, (
secret and len(secret) >= 16 f"registration shared secret missing/short: len={len(secret) if secret else 0}"
), f"registration shared secret missing/short: len={len(secret) if secret else 0}" )
suffix = uuid.uuid4().hex[:8] suffix = uuid.uuid4().hex[:8]
user_a = f"alice{suffix}" user_a = f"alice{suffix}"
+3 -3
View File
@@ -18,6 +18,6 @@ def _psql(domain, sql):
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "the seeded postgres state was not present at backup time"
), "the seeded postgres state was not present at backup time" )
+3 -3
View File
@@ -26,6 +26,6 @@ def test_serving_and_client_api(live_app, meta):
# The client-API version document is real synapse JSON (proves the app, not just a proxy 200). # The client-API version document is real synapse JSON (proves the app, not just a proxy 200).
body = lifecycle.http_body(live_app, "/_matrix/client/versions") body = lifecycle.http_body(live_app, "/_matrix/client/versions")
doc = json.loads(body) doc = json.loads(body)
assert ( assert isinstance(doc.get("versions"), list) and doc["versions"], (
isinstance(doc.get("versions"), list) and doc["versions"] "no matrix client versions advertised"
), "no matrix client versions advertised" )
+3 -3
View File
@@ -17,6 +17,6 @@ def _psql(domain, sql):
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "restore did not return the pre-mutation postgres state"
), "restore did not return the pre-mutation postgres state" )
+3 -3
View File
@@ -17,6 +17,6 @@ def _psql(domain, sql):
def test_upgrade_preserves_data(live_app): def test_upgrade_preserves_data(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", (
_psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives" "postgres data did not survive the upgrade"
), "postgres data did not survive the upgrade" )
@@ -41,9 +41,9 @@ def test_create_message_roundtrip(live_app):
headers=auth, headers=auth,
timeout=30, timeout=30,
) )
assert ( assert status in (200, 201) and isinstance(team, dict) and team.get("id"), (
status in (200, 201) and isinstance(team, dict) and team.get("id") f"team creation failed: HTTP {status}, body={team!r}"
), f"team creation failed: HTTP {status}, body={team!r}" )
status, chan = harness_http.http_post( status, chan = harness_http.http_post(
f"{base}/channels", f"{base}/channels",
data={ data={
@@ -55,9 +55,9 @@ def test_create_message_roundtrip(live_app):
headers=auth, headers=auth,
timeout=30, timeout=30,
) )
assert ( assert status in (200, 201) and isinstance(chan, dict) and chan.get("id"), (
status in (200, 201) and isinstance(chan, dict) and chan.get("id") f"channel creation failed: HTTP {status}, body={chan!r}"
), f"channel creation failed: HTTP {status}, body={chan!r}" )
# 4) POST a unique marker message. # 4) POST a unique marker message.
marker = f"ccci-marker-{uniq}-roundtrip" marker = f"ccci-marker-{uniq}-roundtrip"
@@ -67,13 +67,13 @@ def test_create_message_roundtrip(live_app):
headers=auth, headers=auth,
timeout=30, timeout=30,
) )
assert ( assert status in (200, 201) and isinstance(post, dict) and post.get("id"), (
status in (200, 201) and isinstance(post, dict) and post.get("id") f"post creation failed: HTTP {status}, body={post!r}"
), f"post creation failed: HTTP {status}, body={post!r}" )
# 5) Read it back by id and assert the message survived the round-trip. # 5) Read it back by id and assert the message survived the round-trip.
status, got = harness_http.http_get(f"{base}/posts/{post['id']}", headers=auth, timeout=30) status, got = harness_http.http_get(f"{base}/posts/{post['id']}", headers=auth, timeout=30)
assert status == 200 and isinstance(got, dict), f"read-back failed: HTTP {status}, body={got!r}" assert status == 200 and isinstance(got, dict), f"read-back failed: HTTP {status}, body={got!r}"
assert ( assert got.get("message") == marker, (
got.get("message") == marker f"message did not round-trip: sent {marker!r}, got {got.get('message')!r}"
), f"message did not round-trip: sent {marker!r}, got {got.get('message')!r}" )
@@ -28,6 +28,6 @@ def test_system_ping_ok(live_app):
url = f"https://{live_app}/api/v4/system/ping" url = f"https://{live_app}/api/v4/system/ping"
status, body = harness_http.retry_http_get(url, expect_status=200, max_wait=120, interval=3) status, body = harness_http.retry_http_get(url, expect_status=200, max_wait=120, interval=3)
assert status == 200, f"GET {url} HTTP {status} (expected 200)" assert status == 200, f"GET {url} HTTP {status} (expected 200)"
assert ( assert isinstance(body, dict) and body.get("status") == "OK", (
isinstance(body, dict) and body.get("status") == "OK" f"/api/v4/system/ping did not report status=OK; got {body!r}"
), f"/api/v4/system/ping did not report status=OK; got {body!r}" )
@@ -105,6 +105,6 @@ def test_second_user_reads_first_users_message(live_app):
# 5) user_b sees user_a's marker (cross-user delivery, not a self read-back) # 5) user_b sees user_a's marker (cross-user delivery, not a self read-back)
messages = [p.get("message") for p in (posts.get("posts") or {}).values()] messages = [p.get("message") for p in (posts.get("posts") or {}).values()]
assert ( assert marker in messages, (
marker in messages f"user_b did not see user_a's message {marker!r} in the channel; saw {messages!r}"
), f"user_b did not see user_a's message {marker!r} in the channel; saw {messages!r}" )
+3 -3
View File
@@ -18,6 +18,6 @@ def _psql(domain, sql):
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "the seeded postgres state was not present at backup time"
), "the seeded postgres state was not present at backup time" )
+3 -3
View File
@@ -19,6 +19,6 @@ def _psql(domain, sql):
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "restore did not return the pre-mutation postgres state"
), "restore did not return the pre-mutation postgres state" )
+3 -3
View File
@@ -18,6 +18,6 @@ def _psql(domain, sql):
def test_upgrade_preserves_data(live_app): def test_upgrade_preserves_data(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", (
_psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives" "postgres data did not survive the upgrade"
), "postgres data did not survive the upgrade" )
+1 -1
View File
@@ -214,7 +214,7 @@ def handshake(
elif msg_type == MSG_REJECT: elif msg_type == MSG_REJECT:
f = _dec_fields(payload) f = _dec_fields(payload)
result["error"] = ( result["error"] = (
f"Rejected: {REJECT_TYPES.get(f.get(1, 0), 'Unknown')} " f"{f.get(2, '')}" f"Rejected: {REJECT_TYPES.get(f.get(1, 0), 'Unknown')}{f.get(2, '')}"
) )
return result return result
elif msg_type == MSG_CHANNELSTATE: elif msg_type == MSG_CHANNELSTATE:
@@ -25,7 +25,7 @@ def test_handshake_completes_with_channel_presence(live_app):
assert r["server_version"] is not None, "server did not send a Version message" assert r["server_version"] is not None, "server did not send a Version message"
assert r["auth_accepted"], f"authentication not accepted — {r.get('error')}" assert r["auth_accepted"], f"authentication not accepted — {r.get('error')}"
# Channel presence: the server must expose at least the root channel (beyond a bare TCP open). # Channel presence: the server must expose at least the root channel (beyond a bare TCP open).
assert ( assert len(r["channels"]) >= 1, (
len(r["channels"]) >= 1 f"server reported no channels (expected >=1 root channel) — {r!r}"
), f"server reported no channels (expected >=1 root channel) — {r!r}" )
assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}" assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}"
+3 -3
View File
@@ -23,6 +23,6 @@ def _sqlite(domain, sql):
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert _sqlite(live_app, "SELECT v FROM ci_marker;") == "original", (
_sqlite(live_app, "SELECT v FROM ci_marker;") == "original" "the seeded mumble sqlite marker was not present at backup time"
), "the seeded mumble sqlite marker was not present at backup time" )
+3 -3
View File
@@ -25,6 +25,6 @@ def _sqlite(domain, sql):
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert _sqlite(live_app, "SELECT v FROM ci_marker;") == "original", (
_sqlite(live_app, "SELECT v FROM ci_marker;") == "original" "restore did not return the pre-mutation mumble sqlite marker (data-integrity failure)"
), "restore did not return the pre-mutation mumble sqlite marker (data-integrity failure)" )
+3 -3
View File
@@ -91,6 +91,6 @@ def test_login_endpoint_returns_json(live_app):
assert body is not None, f"/rest/login returned no parseable JSON: state={state}" assert body is not None, f"/rest/login returned no parseable JSON: state={state}"
# If it's a dict, it's the expected envelope; if it's a list, n8n shouldn't do that on this # If it's a dict, it's the expected envelope; if it's a list, n8n shouldn't do that on this
# endpoint, but accept either; only reject obvious non-shapes. # endpoint, but accept either; only reject obvious non-shapes.
assert isinstance( assert isinstance(body, dict | list), (
body, dict | list f"/rest/login returned unexpected JSON type {type(body).__name__}: {body!r}"
), f"/rest/login returned unexpected JSON type {type(body).__name__}: {body!r}" )
+3 -3
View File
@@ -72,9 +72,9 @@ def test_rest_settings_returns_json_with_known_keys(live_app):
# (e.g. version 3.2.0+2.20.6). # (e.g. version 3.2.0+2.20.6).
assert isinstance(body, dict), f"/rest/settings returned non-dict JSON: {type(body).__name__}" assert isinstance(body, dict), f"/rest/settings returned non-dict JSON: {type(body).__name__}"
data = body.get("data") if "data" in body else body data = body.get("data") if "data" in body else body
assert isinstance( assert isinstance(data, dict), (
data, dict f"/rest/settings response missing 'data' envelope: keys={list(body.keys())[:10]}"
), f"/rest/settings response missing 'data' envelope: keys={list(body.keys())[:10]}" )
# Bootstrap keys the editor SPA relies on across versions: # Bootstrap keys the editor SPA relies on across versions:
# - `userManagement`: the auth-mode dict (whether owner-setup is needed, smtp/email mode). # - `userManagement`: the auth-mode dict (whether owner-setup is needed, smtp/email mode).
# - `defaultLocale`: i18n bootstrap; present on every n8n install. # - `defaultLocale`: i18n bootstrap; present on every n8n install.
+12 -12
View File
@@ -178,17 +178,17 @@ def test_workflow_create_and_read_back(live_app):
workflow_id, workflow_id,
str(workflow_id), str(workflow_id),
), f"GET workflow id={fpayload.get('id')!r} != created id={workflow_id!r}" ), f"GET workflow id={fpayload.get('id')!r} != created id={workflow_id!r}"
assert ( assert fpayload.get("name") == name, (
fpayload.get("name") == name f"workflow name didn't round-trip: created={name!r}, fetched={fpayload.get('name')!r}"
), f"workflow name didn't round-trip: created={name!r}, fetched={fpayload.get('name')!r}" )
nodes = fpayload.get("nodes") or [] nodes = fpayload.get("nodes") or []
assert ( assert isinstance(nodes, list) and len(nodes) == 1, (
isinstance(nodes, list) and len(nodes) == 1 f"workflow nodes didn't round-trip: expected 1 node, got {len(nodes)}"
), f"workflow nodes didn't round-trip: expected 1 node, got {len(nodes)}" )
node = nodes[0] node = nodes[0]
assert ( assert node.get("type") == "n8n-nodes-base.manualTrigger", (
node.get("type") == "n8n-nodes-base.manualTrigger" f"node type didn't round-trip: {node.get('type')!r}"
), f"node type didn't round-trip: {node.get('type')!r}" )
assert ( assert node.get("name") == "Manual Trigger", (
node.get("name") == "Manual Trigger" f"node name didn't round-trip: {node.get('name')!r}"
), f"node name didn't round-trip: {node.get('name')!r}" )
+3 -3
View File
@@ -14,6 +14,6 @@ MARKER = "/home/node/.n8n/ci-marker.txt"
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "original", (
lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "original" "the seeded state was not present at backup time"
), "the seeded state was not present at backup time" )
+3 -3
View File
@@ -14,6 +14,6 @@ MARKER = "/home/node/.n8n/ci-marker.txt"
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "original", (
lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "original" "restore did not return the pre-mutation state"
), "restore did not return the pre-mutation state" )
+3 -3
View File
@@ -14,6 +14,6 @@ MARKER = "/home/node/.n8n/ci-marker.txt"
def test_upgrade_preserves_data(live_app): def test_upgrade_preserves_data(live_app):
assert ( assert lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "upgrade-survives", (
lifecycle.exec_in_app(live_app, ["cat", MARKER]).strip() == "upgrade-survives" "data did not survive the upgrade"
), "data did not survive the upgrade" )
+3 -3
View File
@@ -14,6 +14,6 @@ def _psql(domain, sql):
def test_backup_captures_state(live_app): def test_backup_captures_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "seeded postgres state not present at backup time"
), "seeded postgres state not present at backup time" )
+3 -3
View File
@@ -14,6 +14,6 @@ def _psql(domain, sql):
def test_restore_returns_state(live_app): def test_restore_returns_state(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
_psql(live_app, "SELECT v FROM ci_marker;") == "original" "restore did not return the pre-mutation postgres state"
), "restore did not return the pre-mutation postgres state" )
+3 -3
View File
@@ -14,6 +14,6 @@ def _psql(domain, sql):
def test_upgrade_preserves_data(live_app): def test_upgrade_preserves_data(live_app):
assert ( assert _psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", (
_psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives" "postgres data did not survive the upgrade"
), "postgres data did not survive the upgrade" )
+10 -10
View File
@@ -229,13 +229,13 @@ def _assert_green(rc: int, results: dict | None, canary: dict, note: str) -> Non
# 1. Harness exit code must be 0 (GREEN). # 1. Harness exit code must be 0 (GREEN).
assert rc == 0, f"[{canary['id']}] harness returned non-zero rc={rc} — expected GREEN. {note}" assert rc == 0, f"[{canary['id']}] harness returned non-zero rc={rc} — expected GREEN. {note}"
assert ( assert results is not None, (
results is not None f"[{canary['id']}] results.json not written — harness may have crashed. {note}"
), f"[{canary['id']}] results.json not written — harness may have crashed. {note}" )
# 2. Install tier must have passed. # 2. Install tier must have passed.
assert results.get("results", {}).get("install") == "pass", ( assert results.get("results", {}).get("install") == "pass", (
f"[{canary['id']}] install tier did not pass: " f"results={results.get('results')}. {note}" f"[{canary['id']}] install tier did not pass: results={results.get('results')}. {note}"
) )
# 3. No tier may have FAILED (skips are acceptable for recipes without backup or custom tests). # 3. No tier may have FAILED (skips are acceptable for recipes without backup or custom tests).
@@ -243,14 +243,14 @@ def _assert_green(rc: int, results: dict | None, canary: dict, note: str) -> Non
assert not failed_tiers, f"[{canary['id']}] tiers failed: {failed_tiers}. {note}" assert not failed_tiers, f"[{canary['id']}] tiers failed: {failed_tiers}. {note}"
# 4. Teardown must be clean (no leftover containers/volumes/secrets). # 4. Teardown must be clean (no leftover containers/volumes/secrets).
assert ( assert results.get("flags", {}).get("clean_teardown") is True, (
results.get("flags", {}).get("clean_teardown") is True f"[{canary['id']}] clean_teardown=False — residual state left on server. {note}"
), f"[{canary['id']}] clean_teardown=False — residual state left on server. {note}" )
# 5. No secret values leaked into the results artifact. # 5. No secret values leaked into the results artifact.
assert ( assert results.get("flags", {}).get("no_secret_leak") is True, (
results.get("flags", {}).get("no_secret_leak") is True f"[{canary['id']}] no_secret_leak=False — a secret value appeared in results.json. {note}"
), f"[{canary['id']}] no_secret_leak=False — a secret value appeared in results.json. {note}" )
# 6. Semantic stage assertions — TEETH CHECK. # 6. Semantic stage assertions — TEETH CHECK.
# These verify that specific named tests actually ran and passed in the expected stage. # These verify that specific named tests actually ran and passed in the expected stage.
+17 -6
View File
@@ -174,10 +174,17 @@ def test_level_badge_shows_level_coloured(monkeypatch):
def _write_run(base, run_id, recipe, finished, **kw): def _write_run(base, run_id, recipe, finished, **kw):
d = os.path.join(base, run_id) d = os.path.join(base, run_id)
os.makedirs(d, exist_ok=True) os.makedirs(d, exist_ok=True)
doc = {"recipe": recipe, "finished": finished, "run_id": run_id, doc = {
"ref": kw.get("ref", "deadbeefcafe"), "version": kw.get("version"), "recipe": recipe,
"level": kw.get("level", 5), "screenshot": kw.get("screenshot", "screenshot.png"), "finished": finished,
"results": kw.get("results", {"install": "pass"}), "flags": kw.get("flags", {})} "run_id": run_id,
"ref": kw.get("ref", "deadbeefcafe"),
"version": kw.get("version"),
"level": kw.get("level", 5),
"screenshot": kw.get("screenshot", "screenshot.png"),
"results": kw.get("results", {"install": "pass"}),
"flags": kw.get("flags", {}),
}
with open(os.path.join(d, "results.json"), "w") as fh: with open(os.path.join(d, "results.json"), "w") as fh:
json.dump(doc, fh) json.dump(doc, fh)
@@ -193,8 +200,12 @@ def test_history_sourced_from_local_artifacts(tmp_path, monkeypatch):
# mixed numeric + named ids; out-of-order on disk; the timestamp MUST decide order, not the id # mixed numeric + named ids; out-of-order on disk; the timestamp MUST decide order, not the id
_write_run(base, "753", "bsky", 1781663348, results={"install": "pass"}) _write_run(base, "753", "bsky", 1781663348, results={"install": "pass"})
_write_run(base, "427", "bsky", 1781178768, results={"install": "pass"}) _write_run(base, "427", "bsky", 1781178768, results={"install": "pass"})
_write_run(base, "m2r-bsky", "bsky", 1781121610, level=0, results={"install": "pass", "backup": "fail"}) _write_run(
_write_run(base, "423", "bsky", 1781178063, results={"install": "pass"}) # 423<427 numerically but OLDER base, "m2r-bsky", "bsky", 1781121610, level=0, results={"install": "pass", "backup": "fail"}
)
_write_run(
base, "423", "bsky", 1781178063, results={"install": "pass"}
) # 423<427 numerically but OLDER
_write_run(base, "9", "other", 1781000000) # different recipe, must not leak in _write_run(base, "9", "other", 1781000000) # different recipe, must not leak in
# graceful-skip cases (the host's in-flight/failed-early dirs) # graceful-skip cases (the host's in-flight/failed-early dirs)
os.makedirs(os.path.join(base, "EMPTY"), exist_ok=True) # in-flight dir, no results.json os.makedirs(os.path.join(base, "EMPTY"), exist_ok=True) # in-flight dir, no results.json
+1 -1
View File
@@ -66,7 +66,7 @@ def _load_conftest():
class _FakeItem: class _FakeItem:
def __init__(self, keywords): def __init__(self, keywords):
# pytest `item.keywords` supports `in`; a dict suffices. # pytest `item.keywords` supports `in`; a dict suffices.
self.keywords = {k: True for k in keywords} self.keywords = dict.fromkeys(keywords, True)
self.markers = [] self.markers = []
def add_marker(self, mark): def add_marker(self, mark):
+1 -2
View File
@@ -176,6 +176,5 @@ def test_scm_redirect_assertions(location_url, gitea_domain, client_id, expect_p
] ]
all_pass = all(checks) all_pass = all(checks)
assert all_pass == expect_pass, ( assert all_pass == expect_pass, (
f"Expected {'pass' if expect_pass else 'fail'} for URL {location_url!r}; " f"Expected {'pass' if expect_pass else 'fail'} for URL {location_url!r}; checks: {checks}"
f"checks: {checks}"
) )
@@ -42,9 +42,9 @@ def test_uptime_kuma_spa_has_branding(live_app):
body = harness_http.assert_converges(_ready, f"GET {url}", max_wait=60, interval=3) body = harness_http.assert_converges(_ready, f"GET {url}", max_wait=60, interval=3)
lower = body.lower() lower = body.lower()
assert ( assert "uptime kuma" in lower or "kuma" in lower, (
"uptime kuma" in lower or "kuma" in lower f"Page body has no 'kuma' brand. Excerpt: {body[:200]!r}"
), f"Page body has no 'kuma' brand. Excerpt: {body[:200]!r}" )
# SPA-bundle markers: at least one of these reference paths should be present # SPA-bundle markers: at least one of these reference paths should be present
bundle_markers = ("/assets/", "/icon.svg", "favicon", "main.") bundle_markers = ("/assets/", "/icon.svg", "favicon", "main.")
present = [m for m in bundle_markers if m in body] present = [m for m in bundle_markers if m in body]