diff options
| author | Fuwn <[email protected]> | 2026-02-26 15:19:05 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-26 15:19:05 -0800 |
| commit | 4c13bd523d4deb36d8e7dfce6d446f701674e073 (patch) | |
| tree | d189cbbf09742c6b9408a5450db34dba34fe3074 /internal/api/plc_compatibility_test.go | |
| parent | feat: harden launch readiness with versioning, metrics, and resilience (diff) | |
| download | plutia-test-4c13bd523d4deb36d8e7dfce6d446f701674e073.tar.xz plutia-test-4c13bd523d4deb36d8e7dfce6d446f701674e073.zip | |
feat: add read-only PLC API compatibility endpoints
Diffstat (limited to 'internal/api/plc_compatibility_test.go')
| -rw-r--r-- | internal/api/plc_compatibility_test.go | 232 |
1 files changed, 232 insertions, 0 deletions
diff --git a/internal/api/plc_compatibility_test.go b/internal/api/plc_compatibility_test.go new file mode 100644 index 0000000..67cbafa --- /dev/null +++ b/internal/api/plc_compatibility_test.go @@ -0,0 +1,232 @@ +package api + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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" +) + +func TestPLCCompatibilityGetDIDMatchesStoredDocument(t *testing.T) { + ts, store, _, cleanup := newCompatibilityServer(t) + defer cleanup() + + state, ok, err := store.GetState("did:plc:alice") + if err != nil { + t.Fatalf("get state: %v", err) + } + if !ok { + t.Fatalf("state not found") + } + + 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) + } + body, _ := io.ReadAll(resp.Body) + if strings.TrimSpace(string(body)) != strings.TrimSpace(string(state.DIDDocument)) { + t.Fatalf("did document mismatch\n got: %s\nwant: %s", string(body), string(state.DIDDocument)) + } +} + +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) + } + 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) + } +} + +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 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 +} |