package api import ( "encoding/base64" "encoding/json" "net/http" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/notify" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-197 (v0.93.0) — a changed offsite repository key must be REPORTED. Both halves of the comparison // (host_escrow.restic_pw_sha256 and the row it replaces) have been in the database since SLICE 3 and // nothing read them: demo-felhom's repository password changed on 2026-08-03, orphaning 36 snapshots / // 1.14 GB, and no event, e-mail, card or log line said so for thirteen hours. // // Driven through the real endpoint (PUT /hosts//escrow), not by calling the emitter, so the wiring // is part of what is proven. // escrowBodyWithHash is escrowBody plus the sealed-password hash and an identity blob — the fork-4 // upload shape. Hash VALUES here are test fixtures, never real secrets. func escrowBodyWithHash(blob []byte, resticPwSHA, identity string) string { m := map[string]string{ "blob_b64": base64.StdEncoding.EncodeToString(blob), "key_fingerprint": "ab:cd:ef", "posture": "zero_knowledge", "created_at": "2026-08-04T05:00:00Z", "restic_pw_sha256": resticPwSHA, } if identity != "" { m["identity_blob_b64"] = base64.StdEncoding.EncodeToString([]byte(identity)) } b, _ := json.Marshal(m) return string(b) } // Scenario D — a new escrow sealing a DIFFERENT repo password raises exactly one operator signal, // naming the host, carrying NO hash value. // RED-PROOF: remove the maybeEmitRepoKeyChanged call from handleHostEscrowPut (or make its predicate // always fall through) → no event → this FAILS. func TestEscrowPut_ChangedRepoKey_RaisesSignal(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k1"), "SHA_GEN1", "age-gen1")); rr.Code != http.StatusOK { t.Fatalf("first PUT = %d (%s)", rr.Code, rr.Body.String()) } // Generation 1 is onboarding, not a change — nothing may fire yet. if ev, _ := st.GetLatestEventByType("c1", eventRepoKeyChanged); ev != nil { t.Fatalf("a FIRST escrow must not raise %s: %+v", eventRepoKeyChanged, ev) } if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k2"), "SHA_GEN2", "age-gen2")); rr.Code != http.StatusOK { t.Fatalf("second PUT = %d (%s)", rr.Code, rr.Body.String()) } ev, err := st.GetLatestEventByType("c1", eventRepoKeyChanged) if err != nil { t.Fatal(err) } if ev == nil { t.Fatal("R-197: the repository key demonstrably changed and NO signal was raised — this is the " + "thirteen-hour silence that let demo-felhom lose 1.14 GB unremarked") } if ev.Severity != "warning" { t.Errorf("severity = %q, want warning (info is an intentional non-notify — the operator would not be told)", ev.Severity) } if !strings.Contains(ev.Message, "h1") { t.Errorf("message must name the host, got %q", ev.Message) } // NO HASH VALUE may travel — not in the message, not in the details. for _, forbidden := range []string{"SHA_GEN1", "SHA_GEN2"} { if strings.Contains(ev.Message, forbidden) || strings.Contains(ev.DetailsJSON, forbidden) { t.Errorf("a repo-password hash value leaked into the event (%s): msg=%q details=%q", forbidden, ev.Message, ev.DetailsJSON) } } // Exactly one, not one per anything. evs, _ := st.GetRecentEvents("c1", 50) n := 0 for _, e := range evs { if e.EventType == eventRepoKeyChanged { n++ } } if n != 1 { t.Fatalf("%s fired %d times for one supersession, want 1 (edge-triggered)", eventRepoKeyChanged, n) } } // Scenario E — an UNCHANGED key says nothing. A customer re-running a ceremony for good reasons must // not be punished for it with an alarm. func TestEscrowPut_UnchangedRepoKey_Silent(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k1"), "SHA_SAME", "age-1")) do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k2"), "SHA_SAME", "age-2")) if ev, _ := st.GetLatestEventByType("c1", eventRepoKeyChanged); ev != nil { t.Fatalf("a same-hash re-ceremony must be SILENT, got: %+v", ev) } // And it must not have created a superseded row either (pre-existing contract, re-asserted here // because Scenario E's silence would also be produced by the supersede branch never running). if n, _ := st.CountSupersededEscrow("h1"); n != 0 { t.Fatalf("same-hash re-upload created %d superseded row(s), want 0", n) } } // The in-between shapes must not be silent-and-indistinguishable either: a hash-less NEW blob cannot // show whether the key changed, and saying nothing at all would look identical to "unchanged". // Asserted at the level the code offers — no event (correct: nothing was measured) and no crash. func TestEscrowPut_HashlessSupersession_NoSignal(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k1"), "SHA_GEN1", "age-1")) if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k2"), "", "age-2")); rr.Code != http.StatusOK { t.Fatalf("hash-less supersession PUT = %d", rr.Code) } if ev, _ := st.GetLatestEventByType("c1", eventRepoKeyChanged); ev != nil { t.Fatalf("a hash-less new blob cannot EVIDENCE a change; it must not claim one: %+v", ev) } // The retention still ran (the hashes differ, so this IS a supersession) — the key is kept even // though the change cannot be evidenced. if n, _ := st.CountSupersededEscrow("h1"); n != 1 { t.Fatalf("superseded rows = %d, want 1", n) } } // R-97a's lesson, pinned in the SAME commit that mints the type: an operator-tier event that is not in // operatorOnlyEvents reaches customers as raw English, because a missing customerMessages entry is NOT // a block. Checked here rather than in notify so the type's two registers are asserted together. func TestRepoKeyChanged_IsOperatorOnly(t *testing.T) { if !notify.IsOperatorOnly(eventRepoKeyChanged) { t.Fatalf("%s is not registered operator-only — a customer would receive operator-grade English "+ "about escrow custody", eventRepoKeyChanged) } // The two R-192 types have the same property and the same reason (see the register's comment). for _, et := range []string{"offsite_delivery_stuck", "offsite_credential_restaged"} { if !notify.IsOperatorOnly(et) { t.Errorf("%s is not registered operator-only", et) } } }