package api import ( "encoding/base64" "encoding/json" "net/http" "reflect" "sort" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // a stand-in for the opaque R-wrapped blob — the hub treats it as ciphertext it cannot read. var opaqueBlob = []byte("\x00\x01OPAQUE-pbs-scrypt-keyfile-bytes\xff\xfe") func escrowBody(blob []byte) string { b, _ := json.Marshal(map[string]string{ "blob_b64": base64.StdEncoding.EncodeToString(blob), "key_fingerprint": "ab:cd:ef", "posture": "zero_knowledge", "created_at": "2026-06-10T05:00:00Z", }) return string(b) } func TestHandleHostEscrow_StoresOpaqueBlobVerbatim(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody(opaqueBlob)) if rr.Code != http.StatusOK { t.Fatalf("PUT escrow = %d, want 200 (%s)", rr.Code, rr.Body.String()) } got, err := st.GetHostEscrow("h1") if err != nil || got == nil { t.Fatalf("GetHostEscrow: %v / %v", got, err) } // the hub stored the OPAQUE bytes verbatim (it never decrypts / transforms them). if !reflect.DeepEqual(got.Blob, opaqueBlob) { t.Fatalf("stored blob != uploaded blob (hub must keep ciphertext verbatim)") } if got.KeyFingerprint != "ab:cd:ef" || got.Posture != "zero_knowledge" { t.Errorf("metadata not stored: %+v", got) } } func TestHandleHostEscrow_LastWriteWins(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody([]byte("first"))) rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody([]byte("second-rotated"))) if rr.Code != http.StatusOK { t.Fatalf("rotation PUT = %d", rr.Code) } got, _ := st.GetHostEscrow("h1") if string(got.Blob) != "second-rotated" { t.Fatalf("rotation must be last-write-wins, got %q", got.Blob) } } func TestHandleHostEscrow_AuthRejected(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c2", APIKey: "HKEY2"}) // absent / wrong key → 401 if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "", escrowBody(opaqueBlob)); rr.Code != http.StatusUnauthorized { t.Errorf("no key: got %d want 401", rr.Code) } if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "WRONG", escrowBody(opaqueBlob)); rr.Code != http.StatusUnauthorized { t.Errorf("wrong key: got %d want 401", rr.Code) } // h2's key writing h1's escrow → 403 (a host may only write its own) if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY2", escrowBody(opaqueBlob)); rr.Code != http.StatusForbidden { t.Errorf("host_id mismatch: got %d want 403", rr.Code) } // and nothing was stored for h1 by the rejected attempts. if got, _ := st.GetHostEscrow("h1"); got != nil { t.Errorf("rejected attempts must not store anything, got %+v", got) } } func TestHandleHostEscrow_BadBody(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", `{"blob_b64":""}`); rr.Code != http.StatusBadRequest { t.Errorf("empty blob: got %d want 400", rr.Code) } if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", `{"blob_b64":"!!!not base64!!!"}`); rr.Code != http.StatusBadRequest { t.Errorf("bad base64: got %d want 400", rr.Code) } } // TestEscrowUploadContract pins the wire shape that MUST match the agent's emit struct // (felhom-agent escrowUploadRequest). Cross-repo, no shared module — this is the hub half of the // contract guard; the agent has the mirror in its own test. func TestEscrowUploadContract(t *testing.T) { b, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t"}) var m map[string]any json.Unmarshal(b, &m) got := make([]string, 0, len(m)) for k := range m { got = append(got, k) } sort.Strings(got) want := []string{"blob_b64", "created_at", "key_fingerprint", "posture"} if !reflect.DeepEqual(got, want) { t.Fatalf("escrow wire contract drift: got %v want %v (must match the agent emit struct)", got, want) } }