165 lines
6.5 KiB
Go
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")
|
|
}
|
|
}
|