package api import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gorilla/mux" "github.com/metazla/meta-core/internal/claim" "github.com/metazla/meta-core/internal/storage" ) func withVars(r *http.Request, vars map[string]string) *http.Request { return mux.SetURLVars(r, vars) } // routedServer wires the real route table, so the test sees what clients see. func routedServer(t *testing.T) *Server { t.Helper() s := &Server{storage: newTestStorage(t), router: mux.NewRouter()} s.setupRoutes() return s } func call(t *testing.T, s *Server, method, path, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) rr := httptest.NewRecorder() s.router.ServeHTTP(rr, req) return rr } // Every unsigned write route of v1.x answers 410 with a pointer to the claims. func TestUnsignedWritesAreGone(t *testing.T) { s := routedServer(t) for _, c := range []struct{ method, path string }{ {"PUT", "/meta/bafy"}, {"PATCH", "/meta/bafy"}, {"DELETE", "/meta/bafy"}, {"POST", "/meta/bafy/_add/genres"}, {"PUT", "/meta/bafy/title"}, {"DELETE", "/meta/bafy/title"}, {"PUT", "/api/metadata/bafy"}, {"DELETE", "/api/metadata/bafy"}, {"PUT", "/api/metadata/bafy/property"}, {"POST", "/api/metadata/batch"}, } { rr := call(t, s, c.method, c.path, `{"title":"x"}`) if rr.Code != http.StatusGone || !strings.Contains(rr.Body.String(), `"use":"POST /api/claims"`) { t.Errorf("%s %s = %d %s", c.method, c.path, rr.Code, rr.Body) } } if rr := call(t, s, "PUT", "/api/kv/value", `{"key":"file:R/title","value":"x"}`); rr.Code != http.StatusConflict { t.Errorf("raw record write via /api/kv/value = %d", rr.Code) } if rr := call(t, s, "PUT", "/api/kv/value", `{"key":"btmagnet:abc","value":"magnet:"}`); rr.Code != http.StatusOK { t.Errorf("service key write = %d %s", rr.Code, rr.Body) } } func TestPostClaimsAndReadBack(t *testing.T) { s := routedServer(t) a, b := testSigner(t, 1), testSigner(t, 2) bad := signed(t, b, "bafyA", "year", "1999", 5) bad.Value = claim.Text("2001") body, _ := json.Marshal([]claim.Claim{ signed(t, a, "bafyA", "title", "Matrix", 5), signed(t, b, "bafyA", "title", "Matrix", 6), bad, }) rr := call(t, s, "POST", "/api/claims", string(body)) if rr.Code != http.StatusOK { t.Fatalf("POST /api/claims = %d %s", rr.Code, rr.Body) } var res []storage.ClaimResult _ = json.Unmarshal(rr.Body.Bytes(), &res) if len(res) != 3 || res[0].Status != "accepted" || res[1].Status != "accepted" || res[2].Status != "bad_signature" { t.Fatalf("statuses %+v", res) } rr = call(t, s, "GET", "/api/meta/bafyA", "") if !strings.Contains(rr.Body.String(), `"title":"Matrix"`) { t.Fatalf("merged read = %s", rr.Body) } rr = call(t, s, "GET", "/api/meta/bafyA/claims", "") var cr ClaimsResponse _ = json.Unmarshal(rr.Body.Bytes(), &cr) if rr.Code != http.StatusOK || len(cr.Claims) != 2 { t.Fatalf("claims by cid = %d %s", rr.Code, rr.Body) } if cr.RID == "" || len(cr.CIDs) != 1 || cr.CIDs[0] != "bafyA" { t.Fatalf("claims envelope rid/cids = %q %v", cr.RID, cr.CIDs) } rr = call(t, s, "GET", "/api/metadata/"+cr.RID+"/claims?key=title&uid="+a.UID, "") var byRid ClaimsResponse _ = json.Unmarshal(rr.Body.Bytes(), &byRid) if rr.Code != http.StatusOK || len(byRid.Claims) != 1 || byRid.Claims[0].UID != a.UID || byRid.RID != cr.RID { t.Fatalf("claims by rid with key+uid = %d %s", rr.Code, rr.Body) } if rr := call(t, s, "GET", "/api/meta/bafyA/claims?key=nope", ""); !strings.Contains(rr.Body.String(), `"claims":[]`) { t.Fatalf("key filter = %s", rr.Body) } if rr := call(t, s, "GET", "/api/meta/bafyUnknown/claims", ""); rr.Code != http.StatusNotFound { t.Fatalf("unknown cid claims = %d", rr.Code) } if rr := call(t, s, "GET", "/api/metadata/01NOSUCHRID/claims", ""); rr.Code != http.StatusNotFound { t.Fatalf("unknown rid claims = %d", rr.Code) } // ?as= overlays the viewer's own vote. me := testSigner(t, 9) storeClaims(t, s.storage, signed(t, me, "bafyA", "title", "My title", 7)) if rr := call(t, s, "GET", "/api/meta/bafyA?as="+me.UID, ""); !strings.Contains(rr.Body.String(), `"title":"My title"`) { t.Fatalf("?as overlay = %s", rr.Body) } if rr := call(t, s, "GET", "/api/meta/bafyA", ""); !strings.Contains(rr.Body.String(), `"title":"Matrix"`) { t.Fatalf("shared view changed by a single viewer: %s", rr.Body) } } func TestMergeEndpoint(t *testing.T) { s := routedServer(t) a, b, c := testSigner(t, 1), testSigner(t, 2), testSigner(t, 3) bad := signed(t, c, "bafyX", "title", "Forged", 1) bad.UID = a.UID body, _ := json.Marshal(MergeRequest{Claims: []claim.Claim{ signed(t, a, "bafyX", "title", "Dune", 2), signed(t, b, "bafyX", "title", "Dune", 3), signed(t, c, "bafyX", "title", "Dune Part One", 1), bad, }}) rr := call(t, s, "POST", "/api/merge", string(body)) var mr MergeResponse _ = json.Unmarshal(rr.Body.Bytes(), &mr) if rr.Code != http.StatusOK || mr.Fields["title"] != "Dune" || mr.Nsrc["title"] != 2 || mr.Rejected != 1 { t.Fatalf("merge = %d %s", rr.Code, rr.Body) } if !strings.Contains(rr.Body.String(), `"rejected":1`) || !strings.Contains(rr.Body.String(), `"nsrc":{"title":2}`) { t.Fatalf("merge wire shape = %s", rr.Body) } // `as`: the viewer's own vote in the request wins… me := testSigner(t, 9) body, _ = json.Marshal(MergeRequest{As: me.UID, Claims: []claim.Claim{ signed(t, a, "bafyX", "title", "Dune", 2), signed(t, b, "bafyX", "title", "Dune", 3), signed(t, me, "bafyX", "title", "Dune (my cut)", 4), }}) _ = json.Unmarshal(call(t, s, "POST", "/api/merge", string(body)).Body.Bytes(), &mr) if mr.Fields["title"] != "Dune (my cut)" || mr.Nsrc["title"] != 1 { t.Fatalf("request overlay = %+v", mr) } // …and so do its votes held by this core for the same cids. storeClaims(t, s.storage, signed(t, me, "bafyX", "year", "2021", 5)) body, _ = json.Marshal(MergeRequest{As: me.UID, Claims: []claim.Claim{ signed(t, a, "bafyX", "title", "Dune", 2), signed(t, a, "bafyX", "year", "1984", 2), }}) _ = json.Unmarshal(call(t, s, "POST", "/api/merge", string(body)).Body.Bytes(), &mr) if mr.Fields["year"] != "2021" || mr.Fields["title"] != "Dune" { t.Fatalf("local overlay = %+v", mr) } // An all-invalid batch is still 200 with everything rejected. bad2 := signed(t, a, "bafyX", "title", "x", 9) bad2.Sig = bad2.Sig[:10] body, _ = json.Marshal(MergeRequest{Claims: []claim.Claim{bad2}}) rr = call(t, s, "POST", "/api/merge", string(body)) if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"rejected":1`) { t.Fatalf("all-invalid merge = %d %s", rr.Code, rr.Body) } } func TestTooManyClaimsIs413(t *testing.T) { s := routedServer(t) var sb strings.Builder sb.WriteString("[") for i := 0; i <= storage.MaxClaimsPerBatch; i++ { if i > 0 { sb.WriteString(",") } sb.WriteString(`{"cid":"c","uid":"u","key":"k","value":null,"ts":1,"sig":""}`) } sb.WriteString("]") if rr := call(t, s, "POST", "/api/claims", sb.String()); rr.Code != http.StatusRequestEntityTooLarge { t.Fatalf("oversized batch = %d", rr.Code) } }