package api import ( "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "github.com/Fuwn/plutia/internal/checkpoint" "github.com/Fuwn/plutia/internal/config" "github.com/Fuwn/plutia/internal/ingest" "github.com/Fuwn/plutia/internal/storage" "github.com/Fuwn/plutia/internal/types" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) func TestPLCCompatibilityGetDIDShape(t *testing.T) { ts, _, _, cleanup := newCompatibilityServer(t) defer cleanup() resp, err := http.Get(ts.URL + "/did:plc:alice") if err != nil { t.Fatalf("get did: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status: got %d want 200", resp.StatusCode) } if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/did+ld+json") { t.Fatalf("content-type mismatch: %s", got) } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if _, ok := body["@context"].([]any); !ok { t.Fatalf("missing or invalid @context: %#v", body["@context"]) } if got, _ := body["id"].(string); got != "did:plc:alice" { t.Fatalf("id mismatch: got %q want %q", got, "did:plc:alice") } if _, ok := body["authentication"]; ok { t.Fatalf("unexpected authentication field in compatibility did document") } } func TestPLCCompatibilityGetDataShape(t *testing.T) { ts, _, _, cleanup := newCompatibilityServer(t) defer cleanup() resp, err := http.Get(ts.URL + "/did:plc:alice/data") if err != nil { t.Fatalf("get data: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status: got %d want 200", resp.StatusCode) } if got := resp.Header.Get("Content-Type"); got != "application/json; charset=utf-8" { t.Fatalf("content-type mismatch: got %q want %q", got, "application/json; charset=utf-8") } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } required := []string{"did", "verificationMethods", "rotationKeys", "alsoKnownAs", "services"} for _, key := range required { if _, ok := body[key]; !ok { t.Fatalf("missing key %q in /data response", key) } } if _, ok := body["verificationMethods"].(map[string]any); !ok { t.Fatalf("verificationMethods has wrong type: %#v", body["verificationMethods"]) } if _, ok := body["rotationKeys"].([]any); !ok { t.Fatalf("rotationKeys has wrong type: %#v", body["rotationKeys"]) } if _, ok := body["alsoKnownAs"].([]any); !ok { t.Fatalf("alsoKnownAs has wrong type: %#v", body["alsoKnownAs"]) } if _, ok := body["services"].(map[string]any); !ok { t.Fatalf("services has wrong type: %#v", body["services"]) } } func TestPLCCompatibilityGetLogOrdered(t *testing.T) { ts, _, recs, cleanup := newCompatibilityServer(t) defer cleanup() resp, err := http.Get(ts.URL + "/did:plc:alice/log") if err != nil { t.Fatalf("get log: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status: got %d want 200", resp.StatusCode) } if got := resp.Header.Get("Content-Type"); got != "application/json; charset=utf-8" { t.Fatalf("content-type mismatch: got %q want %q", got, "application/json; charset=utf-8") } var ops []map[string]any if err := json.NewDecoder(resp.Body).Decode(&ops); err != nil { t.Fatalf("decode log: %v", err) } if len(ops) != 2 { t.Fatalf("log length mismatch: got %d want 2", len(ops)) } if _, ok := ops[0]["prev"]; ok { t.Fatalf("first op should be genesis without prev") } if prev, _ := ops[1]["prev"].(string); prev != recs[0].CID { t.Fatalf("second op prev mismatch: got %q want %q", prev, recs[0].CID) } } func TestPLCCompatibilityExportCount(t *testing.T) { ts, _, _, cleanup := newCompatibilityServer(t) defer cleanup() resp, err := http.Get(ts.URL + "/export?count=2") if err != nil { t.Fatalf("get export: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status: got %d want 200", resp.StatusCode) } if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/jsonlines") { t.Fatalf("content-type mismatch: %s", got) } body, _ := io.ReadAll(resp.Body) lines := strings.Split(strings.TrimSpace(string(body)), "\n") if len(lines) != 2 { t.Fatalf("line count mismatch: got %d want 2", len(lines)) } for _, line := range lines { var entry map[string]any if err := json.Unmarshal([]byte(line), &entry); err != nil { t.Fatalf("decode export line: %v", err) } for _, key := range []string{"did", "operation", "cid", "nullified", "createdAt"} { if _, ok := entry[key]; !ok { t.Fatalf("missing export key %q in %v", key, entry) } } } } func TestPLCCompatibilityPostIsMethodNotAllowed(t *testing.T) { ts, _, _, cleanup := newCompatibilityServer(t) defer cleanup() req, err := http.NewRequest(http.MethodPost, ts.URL+"/did:plc:alice", strings.NewReader(`{}`)) if err != nil { t.Fatalf("new request: %v", err) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("post did: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusMethodNotAllowed { t.Fatalf("status: got %d want 405", resp.StatusCode) } if allow := resp.Header.Get("Allow"); allow != http.MethodGet { t.Fatalf("allow header mismatch: got %q want %q", allow, http.MethodGet) } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if _, ok := body["message"].(string); !ok { t.Fatalf("expected PLC-style message field, got: %v", body) } if _, ok := body["error"]; ok { t.Fatalf("unexpected internal error field in compatibility response: %v", body) } } func TestPLCCompatibilityNoVerificationMetadataLeak(t *testing.T) { ts, _, _, cleanup := newCompatibilityServer(t) defer cleanup() resp, err := http.Get(ts.URL + "/did:plc:alice") if err != nil { t.Fatalf("get did: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if strings.Contains(string(body), "checkpoint_reference") { t.Fatalf("compatibility endpoint leaked verification metadata: %s", string(body)) } } func TestPLCCompatibilityProofEndpointStillWorks(t *testing.T) { ts, _, _, cleanup := newCompatibilityServer(t) defer cleanup() resp, err := http.Get(ts.URL + "/did/did:plc:alice/proof") if err != nil { t.Fatalf("get proof: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("proof status: got %d want 200 body=%s", resp.StatusCode, string(body)) } } func TestPLCCompatibilityNotFoundUsesPLCErrorShape(t *testing.T) { ts, _, _, cleanup := newCompatibilityServer(t) defer cleanup() resp, err := http.Get(ts.URL + "/did:plc:not-registered") if err != nil { t.Fatalf("get missing did: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("status: got %d want 404", resp.StatusCode) } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if _, ok := body["message"].(string); !ok { t.Fatalf("expected message field for 404, got: %v", body) } if _, ok := body["error"]; ok { t.Fatalf("unexpected error field in compatibility 404 body: %v", body) } } func TestPLCCompatibilityTombstonedDIDReturnsNotAvailable404(t *testing.T) { ts, store, _, cleanup := newCompatibilityServer(t) defer cleanup() const tombDID = "did:plc:tombstone-test" if err := store.PutState(types.StateV1{ DID: tombDID, ChainTipHash: "bafy-tomb-tip", DIDDocument: []byte(`{"id":"did:plc:tombstone-test","deactivated":true}`), }); err != nil { t.Fatalf("put tombstoned state: %v", err) } check := func(path string) { t.Helper() resp, err := http.Get(ts.URL + path) if err != nil { t.Fatalf("get %s: %v", path, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("%s status: got %d want 404", path, resp.StatusCode) } if got := resp.Header.Get("Content-Type"); got != "application/json; charset=utf-8" { t.Fatalf("%s content-type mismatch: got %q want %q", path, got, "application/json; charset=utf-8") } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("%s decode body: %v", path, err) } if len(body) != 1 { t.Fatalf("%s expected single message field, got: %v", path, body) } want := "DID not available: " + tombDID if got, _ := body["message"].(string); got != want { t.Fatalf("%s message mismatch: got %q want %q", path, got, want) } } check("/" + tombDID) check("/" + tombDID + "/data") } func TestPLCCompatibilitySuccessContentTypesMatchUpstreamStyle(t *testing.T) { ts, _, _, cleanup := newCompatibilityServer(t) defer cleanup() tests := []struct { path string want string }{ {path: "/did:plc:alice", want: "application/did+ld+json; charset=utf-8"}, {path: "/did:plc:alice/data", want: "application/json; charset=utf-8"}, {path: "/did:plc:alice/log", want: "application/json; charset=utf-8"}, {path: "/did:plc:alice/log/last", want: "application/json; charset=utf-8"}, {path: "/did:plc:alice/log/audit", want: "application/json; charset=utf-8"}, } for _, tc := range tests { resp, err := http.Get(ts.URL + tc.path) if err != nil { t.Fatalf("get %s: %v", tc.path, err) } if resp.StatusCode != http.StatusOK { _ = resp.Body.Close() t.Fatalf("%s status: got %d want 200", tc.path, resp.StatusCode) } got := resp.Header.Get("Content-Type") _ = resp.Body.Close() if got != tc.want { t.Fatalf("%s content-type mismatch: got %q want %q", tc.path, got, tc.want) } } } func newCompatibilityServer(t *testing.T) (*httptest.Server, *storage.PebbleStore, []types.ExportRecord, func()) { t.Helper() tmp := t.TempDir() dataDir := filepath.Join(tmp, "data") if err := os.MkdirAll(dataDir, 0o755); err != nil { t.Fatalf("mkdir data: %v", err) } seed := make([]byte, ed25519.SeedSize) if _, err := rand.Read(seed); err != nil { t.Fatalf("seed: %v", err) } keyPath := filepath.Join(tmp, "mirror.key") if err := os.WriteFile(keyPath, []byte(base64.RawURLEncoding.EncodeToString(seed)), 0o600); err != nil { t.Fatalf("write key: %v", err) } recs := buildCheckpointScenarioRecords(t) sourcePath := filepath.Join(tmp, "records.ndjson") writeRecordsFile(t, sourcePath, recs) store, err := storage.OpenPebble(dataDir) if err != nil { t.Fatalf("open pebble: %v", err) } if err := store.SetMode(config.ModeMirror); err != nil { t.Fatalf("set mode: %v", err) } bl, err := storage.OpenBlockLog(dataDir, 3, 4) if err != nil { t.Fatalf("open block log: %v", err) } cfg := config.Config{ Mode: config.ModeMirror, DataDir: dataDir, PLCSource: sourcePath, VerifyPolicy: config.VerifyFull, ZstdLevel: 3, BlockSizeMB: 4, CheckpointInterval: 2, CommitBatchSize: 2, VerifyWorkers: 2, ListenAddr: ":0", MirrorPrivateKeyPath: keyPath, PollInterval: 5 * time.Second, RequestTimeout: 10 * time.Second, } cpMgr := checkpoint.NewManager(store, dataDir, keyPath) svc := ingest.NewService(cfg, store, ingest.NewClient(sourcePath), bl, cpMgr) if err := svc.Replay(context.Background()); err != nil { t.Fatalf("replay: %v", err) } if err := svc.Flush(context.Background()); err != nil { t.Fatalf("flush: %v", err) } if _, err := svc.Snapshot(context.Background()); err != nil { t.Fatalf("snapshot: %v", err) } ts := httptest.NewServer(NewServer(cfg, store, svc, cpMgr).Handler()) cleanup := func() { ts.Close() svc.Close() _ = store.Close() } return ts, store, recs, cleanup }