aboutsummaryrefslogtreecommitdiff
path: root/internal/api/plc_compatibility_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/plc_compatibility_test.go')
-rw-r--r--internal/api/plc_compatibility_test.go232
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
+}