Files
felhom.eu/hub/internal/api/escrow_carry_test.go
T

165 lines
6.5 KiB
Go

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")
}
}