v2.0.0: claims model - signed MMUR2 votes in a truth DB, merged view in a rebuildable cache DB; POST /api/claims, claims and merge routes, unsigned writes answer 410
Build and Push Docker Image / build (push) Successful in 5m1s
Build and Push Docker Image / build (push) Successful in 5m1s
This commit is contained in:
+74
-178
@@ -6,211 +6,107 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/metazla/meta-core/internal/claim"
|
||||
)
|
||||
|
||||
// newUDLTestServer returns a Server wired to an in-memory Redis.
|
||||
//
|
||||
// The struct is built directly rather than through NewServer: NewServer boots
|
||||
// the file watcher, mounts manager, watchers poller and WebDAV handler, none of
|
||||
// which the UDL handlers touch — they use s.storage and nothing else.
|
||||
func newUDLTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
return &Server{storage: newTestStorage(t)}
|
||||
}
|
||||
|
||||
// getRecord drives handleUDLRecordGet and decodes the body into raw JSON
|
||||
// fields, so a test can assert on the *encoding* of `value` (is it a quoted
|
||||
// string? is the key there at all?) rather than on what a typed struct would
|
||||
// have coerced it into.
|
||||
func getRecord(t *testing.T, s *Server, uid, cid, key string) (int, map[string]json.RawMessage) {
|
||||
func udlGet(t *testing.T, s *Server, uid, cid, key string) UDLRecordResponse {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/udl/record?uid="+uid+"&cid="+cid+"&key="+key, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleUDLRecordGet(rr, req)
|
||||
|
||||
var body map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET record = %d %s", rr.Code, rr.Body)
|
||||
}
|
||||
return rr.Code, body
|
||||
var resp UDLRecordResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// The plaintext value of a PUBLIC-tier cell must come back over the wire, and
|
||||
// come back *as it was written* — a JSON string stays a quoted string, a bool
|
||||
// stays a bool.
|
||||
//
|
||||
// Both halves matter. Without the value at all, the meta-core dashboard can
|
||||
// only ever show 44-character base58 uids: display names live in the User Data
|
||||
// Layer as the public cell (uid, "self", "profile:name"), and the plaintext
|
||||
// sits in Redis where the storage layer has always read it — the handler just
|
||||
// used to drop it. And the type has to stay json.RawMessage: this endpoint is
|
||||
// generic over keys, so a `*string` would mangle `like` (bool) and `rating`
|
||||
// (number) while looking correct for names.
|
||||
func TestUDLRecordGet_ReturnsPublicValue(t *testing.T) {
|
||||
s := newUDLTestServer(t)
|
||||
|
||||
const uid = "zAlice"
|
||||
if ok, err := s.storage.UDLUpsertIfNewer(uid, "self", "profile:name", 1, 100, "cmVj", `"Ada Lovelace"`, true, false); err != nil || !ok {
|
||||
t.Fatalf("seed name: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "like", 1, 100, "cmVj", `true`, true, false); err != nil || !ok {
|
||||
t.Fatalf("seed like: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
code, body := getRecord(t, s, uid, "self", "profile:name")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", code)
|
||||
}
|
||||
if got := string(body["value"]); got != `"Ada Lovelace"` {
|
||||
t.Fatalf("value = %s, want a quoted JSON string", got)
|
||||
}
|
||||
var name string
|
||||
if err := json.Unmarshal(body["value"], &name); err != nil || name != "Ada Lovelace" {
|
||||
t.Fatalf("value should unmarshal to the plain name, got %q err=%v", name, err)
|
||||
}
|
||||
|
||||
// A boolean key survives unquoted — the assertion a *string field fails.
|
||||
if _, body = getRecord(t, s, uid, "bagcsaaa", "like"); string(body["value"]) != "true" {
|
||||
t.Fatalf("like value = %s, want the bare JSON literal true", body["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// A cell with no plaintext must omit `value` entirely rather than send null.
|
||||
//
|
||||
// That is the shape of every private-tier key (seek, watched, card, localpath —
|
||||
// their real value rides encrypted inside the opaque record) and of every
|
||||
// tombstone. A reader keys off presence: "there is no plaintext here" and "the
|
||||
// plaintext is null" are different facts, and a name cleared by its owner
|
||||
// leaves the cell existing with no value, which must not read as a name.
|
||||
func TestUDLRecordGet_OmitsValueForPrivateCell(t *testing.T) {
|
||||
s := newUDLTestServer(t)
|
||||
|
||||
const uid = "zBob"
|
||||
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "seek", 1, 100, "ZW5jcnlwdGVk", "", false, false); err != nil || !ok {
|
||||
t.Fatalf("seed private cell: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
code, body := getRecord(t, s, uid, "bagcsaaa", "seek")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", code)
|
||||
}
|
||||
if _, ok := body["value"]; ok {
|
||||
t.Fatalf("private cell must omit `value`, got %s", body["value"])
|
||||
}
|
||||
if string(body["exists"]) != "true" {
|
||||
t.Fatalf("exists = %s, want true", body["exists"])
|
||||
}
|
||||
}
|
||||
|
||||
// A cell that was never written answers 200 with exists:false and no value —
|
||||
// not a 404, and not an empty-string value that would render as a blank name.
|
||||
func TestUDLRecordGet_MissingCellHasNoValue(t *testing.T) {
|
||||
s := newUDLTestServer(t)
|
||||
|
||||
code, body := getRecord(t, s, "zNobody", "self", "profile:name")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", code)
|
||||
}
|
||||
if string(body["exists"]) != "false" {
|
||||
t.Fatalf("exists = %s, want false", body["exists"])
|
||||
}
|
||||
if _, ok := body["value"]; ok {
|
||||
t.Fatalf("missing cell must omit `value`, got %s", body["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// putRecords drives handleUDLRecordsPut with a raw JSON body and decodes the
|
||||
// per-record results.
|
||||
func putRecords(t *testing.T, s *Server, body string) (int, UDLPutBatchResponse) {
|
||||
func udlPutBatch(t *testing.T, s *Server, claims ...claim.Claim) UDLPutBatchResponse {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(body))
|
||||
body, _ := json.Marshal(UDLPutBatchRequest{Records: claims})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(string(body)))
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleUDLRecordsPut(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT records = %d %s", rr.Code, rr.Body)
|
||||
}
|
||||
var resp UDLPutBatchResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
var out UDLPutBatchResponse
|
||||
if rr.Code == http.StatusOK {
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
|
||||
// The facade stores and returns MMUR2 claims: public values in clear,
|
||||
// private ones as ciphertext, tombstones with their ts.
|
||||
func TestUDLFacadeRoundTrip(t *testing.T) {
|
||||
s := newUDLTestServer(t)
|
||||
me := testSigner(t, 7)
|
||||
res := udlPutBatch(t, s,
|
||||
signed(t, me, "self", "profile:name", "Ada Lovelace", 100),
|
||||
signed(t, me, "bagcsaaa", "like", "true", 100),
|
||||
signed(t, me, "bagcsaaa", "seek", []byte("ciphertext"), 100),
|
||||
)
|
||||
for _, r := range res.Results {
|
||||
if !r.Accepted {
|
||||
t.Fatalf("not accepted: %+v", r)
|
||||
}
|
||||
}
|
||||
return rr.Code, out
|
||||
got := udlGet(t, s, me.UID, "self", "profile:name")
|
||||
if !got.Exists || got.Claim == nil || got.Claim.Value.Text != "Ada Lovelace" || got.Claim.Verify() != nil {
|
||||
t.Fatalf("profile = %+v", got)
|
||||
}
|
||||
if seek := udlGet(t, s, me.UID, "bagcsaaa", "seek"); seek.Claim == nil || seek.Claim.Value.Kind != claim.KindBytes {
|
||||
t.Fatalf("seek = %+v", seek)
|
||||
}
|
||||
if miss := udlGet(t, s, me.UID, "bagcsaaa", "nope"); miss.Exists || miss.Claim != nil {
|
||||
t.Fatalf("missing cell = %+v", miss)
|
||||
}
|
||||
|
||||
// Retraction: stored, returned with its ts, no longer "exists".
|
||||
udlPutBatch(t, s, signed(t, me, "bagcsaaa", "like", nil, 200))
|
||||
if tomb := udlGet(t, s, me.UID, "bagcsaaa", "like"); tomb.Exists || tomb.Claim == nil || tomb.Claim.TS != 200 {
|
||||
t.Fatalf("tombstone = %+v", tomb)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/udl/cid/self/aggregate?keys=profile:name", nil)
|
||||
req = withVars(req, map[string]string{"cid": "self"})
|
||||
s.handleUDLAggregate(rr, req)
|
||||
if !strings.Contains(rr.Body.String(), `"sample_size":1`) {
|
||||
t.Fatalf("aggregate = %s", rr.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// **The load-bearing property of the batch endpoint: one stale cell does not
|
||||
// fail the batch.**
|
||||
//
|
||||
// The single-record PUT answers a stale version with 409, which is right when
|
||||
// the caller wrote one thing. In a batch it would be wrong: bulk verbs ("mark
|
||||
// this whole series seen") routinely include episodes another device already
|
||||
// marked, and 409-ing there would abandon every cell after it. So staleness is
|
||||
// reported per record as accepted:false, with 200 on the request itself.
|
||||
func TestUDLRecordsPut_StaleCellDoesNotFailTheBatch(t *testing.T) {
|
||||
// A stale claim in a batch is reported in its slot without failing the rest.
|
||||
func TestUDLRecordsPut_StaleSlotDoesNotFailTheBatch(t *testing.T) {
|
||||
s := newUDLTestServer(t)
|
||||
|
||||
const uid = "zCarol"
|
||||
const cid = "bagdsaaaseries"
|
||||
// Seed s01e01 at version 4 — the batch below tries to write it at 2.
|
||||
if ok, err := s.storage.UDLUpsertIfNewer(uid, cid, "seen:s01e01", 4, 100, "b2xk", "", false, false); err != nil || !ok {
|
||||
t.Fatalf("seed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
code, out := putRecords(t, s, `{"records":[
|
||||
{"uid":"`+uid+`","cid":"`+cid+`","key":"seen:s01e01","version":2,"ts":200,"record":"c3RhbGU"},
|
||||
{"uid":"`+uid+`","cid":"`+cid+`","key":"seen:s01e02","version":1,"ts":200,"record":"ZnJlc2g"}
|
||||
]}`)
|
||||
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 — a stale cell is an outcome, not an error", code)
|
||||
}
|
||||
if len(out.Results) != 2 {
|
||||
t.Fatalf("results = %d, want 2 (one per record, in order)", len(out.Results))
|
||||
}
|
||||
if out.Results[0].Accepted || out.Results[0].Error != "" {
|
||||
t.Fatalf("stale cell = %+v, want accepted:false with no error", out.Results[0])
|
||||
}
|
||||
if !out.Results[1].Accepted {
|
||||
t.Fatalf("fresh cell = %+v, want accepted:true", out.Results[1])
|
||||
}
|
||||
|
||||
// The fresh cell really landed, and the stale one did not overwrite.
|
||||
if _, body := getRecord(t, s, uid, cid, "seen:s01e02"); string(body["exists"]) != "true" {
|
||||
t.Fatalf("s01e02 was not written")
|
||||
}
|
||||
if _, body := getRecord(t, s, uid, cid, "seen:s01e01"); string(body["version"]) != "4" {
|
||||
t.Fatalf("s01e01 version = %s, want 4 (the stale write must not apply)", body["version"])
|
||||
me := testSigner(t, 7)
|
||||
udlPutBatch(t, s, signed(t, me, "bagcsaaa", "seen:s01e01", "true", 400))
|
||||
res := udlPutBatch(t, s,
|
||||
signed(t, me, "bagcsaaa", "seen:s01e01", "false", 300),
|
||||
signed(t, me, "bagcsaaa", "seen:s01e02", "true", 300),
|
||||
)
|
||||
if res.Results[0].Accepted || res.Results[0].Status != "stale" || !res.Results[1].Accepted {
|
||||
t.Fatalf("results = %+v", res.Results)
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed record is reported in its own slot rather than rejecting the
|
||||
// request, for the same reason: the caller retries that cell, not the batch.
|
||||
func TestUDLRecordsPut_ReportsBadRecordPerSlot(t *testing.T) {
|
||||
s := newUDLTestServer(t)
|
||||
|
||||
code, out := putRecords(t, s, `{"records":[
|
||||
{"uid":"zDan","cid":"bagdsaaa","key":"","version":1,"ts":1,"record":"eA"},
|
||||
{"uid":"zDan","cid":"bagdsaaa","key":"seen:s01e01","version":1,"ts":1,"record":"eA"}
|
||||
]}`)
|
||||
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", code)
|
||||
}
|
||||
if out.Results[0].Error == "" {
|
||||
t.Fatalf("record with no key must report an error, got %+v", out.Results[0])
|
||||
}
|
||||
if !out.Results[1].Accepted {
|
||||
t.Fatalf("the valid record beside it must still land, got %+v", out.Results[1])
|
||||
}
|
||||
}
|
||||
|
||||
// An empty batch is a client bug, not a no-op: it almost always means the
|
||||
// caller built its record list wrong, and answering 200 with an empty array
|
||||
// would hide that.
|
||||
func TestUDLRecordsPut_RejectsEmptyBatch(t *testing.T) {
|
||||
s := newUDLTestServer(t)
|
||||
|
||||
if code, _ := putRecords(t, s, `{"records":[]}`); code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", code)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(`{"records":[]}`))
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleUDLRecordsPut(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty batch = %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user