package api import ( "encoding/base64" "encoding/json" "net/http" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-311 (v0.103.0) — GET /hosts//escrow/retained. // // WHY THESE TESTS EXIST. `ListSupersededEscrow` had zero production callers for nineteen days; the // retention it reads was proven on 2026-08-12 to hold the right material and to open a store the box // itself could not, while the customer was told their (correct) code opened nothing. These tests pin // the endpoint that ends that, and — more importantly — they pin the two things it must NOT do: // serve a package that cannot be opened, and let one host read another's. type retainedResp struct { HostID string `json:"host_id"` Count int `json:"count"` UnopenableCount int `json:"unopenable_count"` TruncatedCount int `json:"truncated_count"` Packages []struct { Index int `json:"index"` SupersededAt string `json:"superseded_at"` KeyFingerprint string `json:"key_fingerprint"` IdentityEscrowB64 string `json:"identity_escrow_b64"` } `json:"packages"` } func getRetained(t *testing.T, h *Handler, hostID, key string) (int, retainedResp) { t.Helper() rr := do(h, http.MethodGet, "/hosts/"+hostID+"/escrow/retained", key, "") var out retainedResp if rr.Code == http.StatusOK { if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatalf("decode retained response: %v (body %s)", err, rr.Body.String()) } } return rr.Code, out } // seedSuperseded drives the REAL supersession path (a PUT sealing a different password demotes the // current row) rather than inserting into host_escrow_superseded directly — so what the test proves // includes the demote wiring, not just this handler's SELECT. func seedSuperseded(t *testing.T, h *Handler, hostID, key string, gens []struct{ sha, identity string }) { t.Helper() for i, g := range gens { if rr := do(h, http.MethodPut, "/hosts/"+hostID+"/escrow", key, escrowBodyWithHash([]byte("k"), g.sha, g.identity)); rr.Code != http.StatusOK { t.Fatalf("seed PUT %d = %d (%s)", i, rr.Code, rr.Body.String()) } } } // THE ONE THAT MATTERS: a retained package that carries key material is served, and the caller can // tell it apart from the current row. // // RED-PROOF: change the handler's SELECT source from ListSupersededEscrow to GetHostDRBundle (i.e. // serve the CURRENT row) → the served blob becomes "age-gen3" → this FAILS on the body comparison, // which is exactly the confusion the endpoint exists to end. func TestRetainedEscrowGet_ServesRetainedPackages(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) seedSuperseded(t, h, "h1", "HKEY", []struct{ sha, identity string }{ {"SHA_GEN1", "age-gen1"}, {"SHA_GEN2", "age-gen2"}, {"SHA_GEN3", "age-gen3"}, // current after this }) code, out := getRetained(t, h, "h1", "HKEY") if code != http.StatusOK { t.Fatalf("GET retained = %d, want 200", code) } if out.Count != 2 || len(out.Packages) != 2 { t.Fatalf("want 2 retained packages, got count=%d len=%d", out.Count, len(out.Packages)) } if out.UnopenableCount != 0 { t.Errorf("unopenable_count = %d, want 0 (every seeded row carried an identity blob)", out.UnopenableCount) } // The retained packages must be the SUPERSEDED generations, never the current one. If this ever // serves gen3 the endpoint is answering the wrong question and the customer is misled again. got := map[string]bool{} for _, p := range out.Packages { b, err := base64.StdEncoding.DecodeString(p.IdentityEscrowB64) if err != nil { t.Fatalf("package %d: identity blob is not base64: %v", p.Index, err) } got[string(b)] = true if p.SupersededAt == "" { t.Errorf("package %d has no superseded_at — the screen needs a date to name the package", p.Index) } } if !got["age-gen1"] || !got["age-gen2"] { t.Errorf("served packages = %v, want the two SUPERSEDED generations (age-gen1, age-gen2)", got) } if got["age-gen3"] { t.Error("the CURRENT package was served as retained — that is the wrong-package confusion this endpoint exists to end") } } // A retained row with NO identity blob (every pre-v0.93.0 row) must be WITHHELD and COUNTED. Serving // it would have the agent try a package that cannot open anything, and would let the screen claim an // earlier package is recoverable on exactly the boxes hurt by the original defect. // // RED-PROOF: delete the `if len(e.IdentityBlob) == 0 { unopenable++; continue }` guard → the empty // row is served as a package → Count becomes 2 and UnopenableCount 0 → this FAILS twice. func TestRetainedEscrowGet_WithholdsAndCountsUnopenableRows(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) // gen1 carries NO identity blob — the pre-v0.93.0 shape. seedSuperseded(t, h, "h1", "HKEY", []struct{ sha, identity string }{ {"SHA_GEN1", ""}, {"SHA_GEN2", "age-gen2"}, {"SHA_GEN3", "age-gen3"}, }) code, out := getRetained(t, h, "h1", "HKEY") if code != http.StatusOK { t.Fatalf("GET retained = %d, want 200", code) } if out.Count != 1 { t.Errorf("count = %d, want 1 (only gen2 carries material)", out.Count) } if out.UnopenableCount != 1 { t.Errorf("unopenable_count = %d, want 1 — the caller cannot derive this and needs it to explain "+ "why a correct old code opens nothing on such a box", out.UnopenableCount) } for _, p := range out.Packages { if p.IdentityEscrowB64 == "" { t.Error("a package with an empty identity blob was served — it can never open anything") } } } // SELF-SCOPE. The current-row GET has this and a retained read is strictly more material, so the same // asymmetry must hold: a host key reads only its own. // // RED-PROOF: remove the `!isGlobal && authHostID != pathHostID` branch → h2's key reads h1's packages // → this FAILS with 200. func TestRetainedEscrowGet_SelfScoped(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY1"}) st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c2", APIKey: "HKEY2"}) seedSuperseded(t, h, "h1", "HKEY1", []struct{ sha, identity string }{ {"SHA_GEN1", "age-gen1"}, {"SHA_GEN2", "age-gen2"}, }) rr := do(h, http.MethodGet, "/hosts/h1/escrow/retained", "HKEY2", "") if rr.Code != http.StatusForbidden { t.Fatalf("h2 reading h1's retained packages = %d, want 403 — a host key must never be a fleet-wide reader", rr.Code) } } // A host that has never re-sealed gets a clean, empty answer — not a 404 and not a fault. "No retained // package" is an ordinary situation and must be distinguishable from "something is broken", or the // agent's try-loop cannot tell them apart either. // // RED-PROOF: make the handler 404 on an empty list → this FAILS with 404. func TestRetainedEscrowGet_NoneIsACleanEmptyAnswer(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("k"), "SHA1", "age-1")); rr.Code != http.StatusOK { t.Fatalf("seed PUT = %d", rr.Code) } code, out := getRetained(t, h, "h1", "HKEY") if code != http.StatusOK { t.Fatalf("GET retained on a never-superseded host = %d, want 200", code) } if out.Count != 0 || len(out.Packages) != 0 || out.UnopenableCount != 0 { t.Errorf("want an empty answer, got count=%d packages=%d unopenable=%d", out.Count, len(out.Packages), out.UnopenableCount) } } // The retained route must not be reachable without a key at all. // // RED-PROOF: drop the checkAuthHost block → this FAILS with 200. func TestRetainedEscrowGet_RequiresAuth(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) if rr := do(h, http.MethodGet, "/hosts/h1/escrow/retained", "", ""); rr.Code != http.StatusUnauthorized { t.Fatalf("unauthenticated retained GET = %d, want 401", rr.Code) } } // ROUTING: /escrow and /escrow/retained must stay two different answers. A prefix "tidy-up" that // collapsed them would route retained reads to the current row — silently the wrong package. // // RED-PROOF: change the retained case's suffix to "/escrow" (so the earlier case wins) → the retained // route returns the CURRENT-row shape, which has no "packages" key → this FAILS. func TestRetainedEscrowGet_IsADistinctRouteFromTheCurrentRow(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) seedSuperseded(t, h, "h1", "HKEY", []struct{ sha, identity string }{ {"SHA_GEN1", "age-gen1"}, {"SHA_GEN2", "age-gen2"}, }) cur := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", "") var curBody map[string]any if err := json.Unmarshal(cur.Body.Bytes(), &curBody); err != nil { t.Fatalf("decode current-row body: %v", err) } if _, hasPackages := curBody["packages"]; hasPackages { t.Error("the CURRENT-row route grew a packages key — the two routes have been merged") } if _, hasPresent := curBody["present"]; !hasPresent { t.Error("the current-row route lost its present flag") } _, ret := getRetained(t, h, "h1", "HKEY") if ret.Count != 1 { t.Fatalf("retained route count = %d, want 1 — it is answering the current row's question", ret.Count) } }