Files
felhom.eu/hub/internal/api/escrow_retained_get_test.go
T
admin 6362bb6cb6 hub v0.103.0 — a host can read the packages we kept for it (R-311)
ListSupersededEscrow had zero production callers for nineteen days. It is the only
reader of a retained identity_blob, so the retention shipped in v0.93.0 was
material the product could not reach - proven on the fixture 2026-08-12, where a
code that opens a retained package was answered as a code that opened nothing.

New GET /api/v1/hosts/<id>/escrow/retained: self-scoped exactly as the current-row
GET, same recovery-mode gate, same audit event written BEFORE the bytes leave,
capped at 16.

Rows with a NULL identity_blob are WITHHELD and returned as unopenable_count.
They retain the PBS key, not the repository password, so they can never open what
the caller is asking about; serving them would have the agent try packages that
cannot succeed and would let the screen claim an earlier package is openable on
exactly the boxes the original defect hurt. The count is returned because their
existence is load-bearing and underivable.

The trade, stated rather than waved through: the hub still cannot read any of it -
sealed bytes in, sealed bytes out, no decrypt path, no recovery code ever held.
What widens is volume, bounded by self-scope, the recovery-mode gate and the cap.

The response is a NAMED TYPE, not a map, so the wire-contract gate can resolve it;
the wire is declared as a fourth ROOT and the gate now checks 182 tags rather than
174. A positive control shows that check is name-presence, not decodability -
filed as R-315 rather than reported as coverage.

Six tests through the real endpoint; four red-proofs asserted applied.
2026-08-12 18:43:07 +02:00

219 lines
9.3 KiB
Go

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/<id>/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)
}
}