gates / gates (push) Successful in 1m54s
SaveHostEscrowWithIdentity refuses (ErrEscrowWouldDropRepoKey); the PUT answers 409. Red-proved (store: superseded=true; API: 500). Two older tests pinned the old demotion and are updated. Commit only.
145 lines
6.6 KiB
Go
145 lines
6.6 KiB
Go
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/<id>/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 shape: a hash-less NEW blob over a hashed one. Until R-1218 it was accepted and SUPERSEDED the good
|
|
// package (the household's code then no longer opened its off-site copy). Since R-1218 (2026-10-26 night) it is REFUSED
|
|
// with 409 and nothing changes: no event (nothing was measured), no retained row, the hashed package stays current.
|
|
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.StatusConflict {
|
|
t.Fatalf("hash-less PUT over a hashed package = %d, want 409 (R-1218)", 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)
|
|
}
|
|
if n, _ := st.CountSupersededEscrow("h1"); n != 0 {
|
|
t.Fatalf("superseded rows = %d, want 0 — the refused upload must not demote the good package", 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)
|
|
}
|
|
}
|
|
}
|