package api import ( "encoding/base64" "encoding/json" "net/http" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-1176 scenario A through the hub's own front doors: the reports a rebuilt box sends, the ACK it reads, and the // escrow GET its agent calls during the recovery. func carryRebuiltBox(t *testing.T, st *store.Store, identity []byte) { t.Helper() if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "Tester4", APIKey: "ckey", RetrievalPassword: "p"}); err != nil { t.Fatal(err) } if err := st.UpsertHost(&store.Host{HostID: "Tester4-old", CustomerID: "Tester4", APIKey: "OLDKEY"}); err != nil { t.Fatal(err) } if _, _, err := st.SaveHostEscrow("Tester4-old", []byte("k-blob"), "fp", "zero_knowledge", "2026-10-18T00:00:00Z", "SHA_HOUSEHOLD"); err != nil { t.Fatal(err) } if err := st.SaveHostDRBundle("Tester4-old", identity); err != nil { t.Fatal(err) } if err := st.DeleteHost("Tester4-old", true); err != nil { t.Fatal(err) } if err := st.UpsertHost(&store.Host{HostID: "Tester4-new", CustomerID: "Tester4", APIKey: "NEWKEY"}); err != nil { t.Fatal(err) } } func TestR1176_TheRebuiltBoxsFirstACKCarriesTheHouseholdsPackage(t *testing.T) { h, st, _ := newTestHandler(t) identity := []byte("sealed-under-the-household-recovery-code") carryRebuiltBox(t, st, identity) rr := do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"Tester4"}`) if rr.Code != http.StatusOK { t.Fatalf("report = %d %s", rr.Code, rr.Body.String()) } var ack struct { Escrow *struct { IdentityBlobPresent bool `json:"identity_blob_present"` ResticPwSHA256 string `json:"restic_pw_sha256"` } `json:"escrow"` } if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil { t.Fatal(err) } if ack.Escrow == nil || !ack.Escrow.IdentityBlobPresent || ack.Escrow.ResticPwSHA256 != "SHA_HOUSEHOLD" { t.Fatalf("first ACK escrow = %+v — the rebuilt box would mint a fresh key (R-1176)", ack.Escrow) } // The new host's agent fetches its OWN current package (self-scoped) — the household's bytes. g := do(h, http.MethodGet, "/hosts/Tester4-new/escrow", "NEWKEY", "") var got struct { Present bool `json:"present"` B64 string `json:"identity_escrow_b64"` } if err := json.Unmarshal(g.Body.Bytes(), &got); err != nil || g.Code != http.StatusOK || !got.Present { t.Fatalf("escrow GET = %d %s", g.Code, g.Body.String()) } if b, _ := base64.StdEncoding.DecodeString(got.B64); string(b) != string(identity) { t.Fatal("the served package is not byte-identical to the household's") } // The deleted host's key is dead; nothing else of the old host is reachable by the new one. if r := do(h, http.MethodGet, "/hosts/Tester4-old/escrow/retained", "NEWKEY", ""); r.Code != http.StatusForbidden { t.Fatalf("new host reading the old host's retained packages = %d, want 403 (self-scope)", r.Code) } evs, _ := st.GetEventsByType("Tester4", eventEscrowCarried, time.Time{}) if len(evs) != 1 { t.Fatalf("escrow_carried events = %d, want 1", len(evs)) } if strings.Contains(evs[0].DetailsJSON, string(identity)) || strings.Contains(evs[0].Message, string(identity)) { t.Fatal("the audit event carries the package bytes") } } func TestR1176_TheAgentsHostReportCarriesFirst(t *testing.T) { h, st, _ := newTestHandler(t) carryRebuiltBox(t, st, []byte("id")) if rr := do(h, http.MethodPost, "/host-report", "NEWKEY", validReportBody("Tester4-new")); rr.Code != http.StatusOK { t.Fatalf("host-report = %d %s", rr.Code, rr.Body.String()) } if e, _ := st.GetHostEscrow("Tester4-new"); e == nil || e.ResticPwSHA256 != "SHA_HOUSEHOLD" { t.Fatalf("after the agent's first report the new host holds %+v, want the household's package", e) } } func TestR1176_RefusalMailsTheOperatorOnce(t *testing.T) { h, st, _ := newTestHandler(t) carryRebuiltBox(t, st, []byte("id")) if err := st.UpsertHost(&store.Host{HostID: "Tester4-second", CustomerID: "Tester4", APIKey: "K2"}); err != nil { t.Fatal(err) } for i := 0; i < 3; i++ { do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"Tester4"}`) } evs, _ := st.GetEventsByType("Tester4", eventEscrowCarryRefused, time.Time{}) if len(evs) != 1 || evs[0].Severity != "warning" { t.Fatalf("refusal events = %+v, want exactly one warning", evs) } for _, hid := range []string{"Tester4-new", "Tester4-second"} { if e, _ := st.GetHostEscrow(hid); e != nil { t.Fatalf("%s got the package while two hosts are live", hid) } } } // R-1182 at the ACK: a box that declared its abandonment is told the purge is done, although another set-aside // copy's package is (rightly) still kept and `superseded_present` stays true. func TestR1182_TheDeclaringBoxIsToldThePurgeIsDone(t *testing.T) { h, st, _ := newTestHandler(t) if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "C", APIKey: "ckey", RetrievalPassword: "p"}); err != nil { t.Fatal(err) } if err := st.UpsertHost(&store.Host{HostID: "C-h", CustomerID: "C", APIKey: "HK"}); err != nil { t.Fatal(err) } for _, sha := range []string{"SHA_1", "SHA_2", "SHA_3"} { if _, _, err := st.SaveHostEscrow("C-h", []byte("k-"+sha), "fp", "zk", "2026-10-01T00:00:00Z", sha); err != nil { t.Fatal(err) } if err := st.SaveHostDRBundle("C-h", []byte("id-"+sha)); err != nil { t.Fatal(err) } if sha != "SHA_3" { if _, err := st.TagRetainedEscrowSetAside("C", "/r.orphaned-"+sha); err != nil { t.Fatal(err) } } } // SHA_1's package now points at /r.orphaned-SHA_2 (the copy set aside after SHA_2 replaced it); SHA_2's package is // retained 'live'. The household abandons that one copy and the hub deletes it: id, _ := st.CreateOffsiteAbandon("C", "/r.orphaned-SHA_2", time.Now()) if err := st.MarkOffsiteAbandonDeleted(id); err != nil { t.Fatal(err) } rr := do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"C","offsite":{"abandon_purge_requested":true}}`) var ack struct { Escrow struct { SupersededPresent bool `json:"superseded_present"` AbandonPurgeDone bool `json:"abandon_purge_done"` } `json:"escrow"` } if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil { t.Fatal(err) } if !ack.Escrow.AbandonPurgeDone || !ack.Escrow.SupersededPresent { t.Fatalf("ACK escrow = %+v, want done=true while another copy's package is kept", ack.Escrow) } // A report that did not declare never carries the flag. rr = do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"C"}`) if strings.Contains(rr.Body.String(), "abandon_purge_done") { t.Fatal("a non-declaring report's ACK carries abandon_purge_done") } }