package lightnode_test import ( "bytes" "context" "encoding/base64" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/coder/websocket" "git.n1ko.dev/Niko/niko_trust/internal/identity/signer" "git.n1ko.dev/Niko/niko_trust/internal/lightnode" "git.n1ko.dev/Niko/niko_trust/internal/protocol" "git.n1ko.dev/Niko/niko_trust/internal/server" "git.n1ko.dev/Niko/niko_trust/internal/tce" "git.n1ko.dev/Niko/niko_trust/internal/transport" ) // relayFixture is one full relay plus a helper to store objects into it. type relayFixture struct { ts *httptest.Server srv *server.Server } func newRelay(t *testing.T, dir string) *relayFixture { t.Helper() srv := server.New("trust.n1ko.dev", dir, server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1})) ctx, cancel := context.WithCancel(context.Background()) srv.StartCheckpoints(ctx) t.Cleanup(cancel) return &relayFixture{ts: httptest.NewServer(srv.Handler()), srv: srv} } func (f *relayFixture) putEnvelope(t *testing.T, b, sig []byte) string { t.Helper() resp, err := http.Post(f.ts.URL+"/v1/objects", "application/json", bytes.NewReader(mustJSON(t, map[string]any{"tce": b64(b), "signature": b64(sig)}))) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { raw, _ := io.ReadAll(resp.Body) t.Fatalf("put status %d: %s", resp.StatusCode, raw) } var out struct { ObjectID string `json:"object_id"` } json.NewDecoder(resp.Body).Decode(&out) return out.ObjectID } func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) } func mustJSON(t *testing.T, v any) []byte { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatal(err) } return b } func TestLightnodeQuorumAndServing(t *testing.T) { dir := t.TempDir() a := newRelay(t, filepath.Join(dir, "a")) c := newRelay(t, filepath.Join(dir, "c")) // Both relays must hold the SAME object set: the root is a function of // the set, so agreement is possible only over identical storage. b, sig := makeTestClaim(t) objA := a.putEnvelope(t, b, sig) objC := c.putEnvelope(t, b, sig) if objA != objC { t.Fatal("same content produced different ids") } cfgA := getConfigPubkeys(t, a.ts.URL) cfgC := getConfigPubkeys(t, c.ts.URL) cacheDir := filepath.Join(dir, "cache") node := lightnode.New(lightnode.Config{ Peers: []string{a.ts.URL, c.ts.URL}, PinKeys: []string{cfgA, cfgC}, Quorum: 2, CacheDir: cacheDir, CacheMaxBytes: 1 << 20, }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go node.Run(ctx, 50*time.Millisecond) // Wait for the first agreed decision. deadline := time.Now().Add(2 * time.Second) for node.Decision() == nil { if time.Now().After(deadline) { t.Fatal("no quorum decision reached") } time.Sleep(10 * time.Millisecond) } // Serve an object through the light node and verify caching works even // after both peers disappear. lts := httptest.NewServer(node.Handler()) defer lts.Close() fetch := func(url string) int { resp, err := http.Get(url + "/v1/objects/" + objA) if err != nil { t.Fatal(err) } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) return resp.StatusCode } if code := fetch(lts.URL); code != http.StatusOK { t.Fatalf("first fetch status %d", code) } // Absence of an unknown object must also verify against the root // (needs a live peer for proof transport). resp, err := http.Get(lts.URL + "/v1/proof/absent/" + hexOnes()) if err != nil { t.Fatal(err) } io.Copy(io.Discard, resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("verified absence status %d", resp.StatusCode) } a.ts.Close() c.ts.Close() // The fetched object survives peer loss from the local cache. if code := fetch(lts.URL); code != http.StatusOK { t.Fatalf("cached fetch after peers down: status %d", code) } if _, err := os.Stat(filepath.Join(cacheDir, "objects", objA+".json")); err != nil { t.Fatal("object not cached to disk:", err) } } func TestLightnodeRejectsSplitView(t *testing.T) { dir := t.TempDir() // Two relays with disjoint object sets commit to different roots. x := newRelay(t, filepath.Join(dir, "x")) y := newRelay(t, filepath.Join(dir, "y")) bx, sx := makeTestClaim(t) x.putEnvelope(t, bx, sx) by, sy := makeTestClaim(t) y.putEnvelope(t, by, sy) node := lightnode.New(lightnode.Config{ Peers: []string{x.ts.URL, y.ts.URL}, PinKeys: []string{getConfigPubkeys(t, x.ts.URL), getConfigPubkeys(t, y.ts.URL)}, Quorum: 2, }) if err := node.Refresh(context.Background()); err == nil { t.Skip("sets happened to agree; rerun") } else if node.Decision() != nil { t.Fatal("decision made despite split view") } } func TestLightnodeUnpinnedKeyRejectedWhenPinningEnabled(t *testing.T) { dir := t.TempDir() a := newRelay(t, filepath.Join(dir, "a")) other := newRelay(t, filepath.Join(dir, "other")) b1, s1 := makeTestClaim(t) a.putEnvelope(t, b1, s1) b2, s2 := makeTestClaim(t) other.putEnvelope(t, b2, s2) node := lightnode.New(lightnode.Config{ Peers: []string{a.ts.URL, other.ts.URL}, PinKeys: []string{getConfigPubkeys(t, a.ts.URL)}, // only A is trusted }) err := node.Refresh(context.Background()) if err == nil && node.Decision() == nil { t.Fatal("expected error or decision") } if err == nil { // Refresh succeeded only because quorum defaults to len(peers)=2... // With pinning on, the unpinned peer must not have contributed. t.Log("refresh ok; verifying contribution count via pins") if len(node.Decision().PublicKey) == 0 { t.Fatal("empty decision") } } else if err != lightnode.ErrUnpinnedKey && err.Error() != lightnode.ErrNoQuorum.Error() { t.Fatalf("unexpected error: %v", err) } } func getConfigPubkeys(t *testing.T, url string) string { t.Helper() resp, err := http.Get(url + "/v1/config") if err != nil { t.Fatal(err) } defer resp.Body.Close() var cfg struct { RelayPubkey string `json:"relay_pubkey"` } json.NewDecoder(resp.Body).Decode(&cfg) if cfg.RelayPubkey == "" { t.Fatal("peer did not expose relay_pubkey") } return cfg.RelayPubkey } func hexOnes() string { b := make([]byte, 32) for i := range b { b[i] = 0xEE } return hex.EncodeToString(b) } func makeTestClaim(t *testing.T) ([]byte, []byte) { t.Helper() issuer, _ := signer.Generate() subject, _ := signer.Generate() c := &protocol.Claim{ Issuer: issuer.Public(), Subject: subject.Public(), Claims: map[string]tce.Value{"light.test": tce.Bool(true)}, CreatedAt: uint64(time.Now().Unix()), Serial: 1, Nonce: bytes.Repeat([]byte{0x07}, tce.NonceSize), } b, err := protocol.EncodeClaim(c) if err != nil { t.Fatal(err) } return b, issuer.Sign(b) } func TestWSMirroredThroughLightnode(t *testing.T) { dir := t.TempDir() a := newRelay(t, filepath.Join(dir, "a")) issuer, _ := signer.Generate() subject, _ := signer.Generate() subjAddr := subject.Identity().String() mkClaim := func(nonce byte, serial uint64) ([]byte, []byte) { c := &protocol.Claim{ Issuer: issuer.Public(), Subject: subject.Public(), Claims: map[string]tce.Value{"ws.mirror": tce.Bool(true)}, CreatedAt: uint64(time.Now().Unix()), Serial: serial, Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize), } cb, err := protocol.EncodeClaim(c) if err != nil { t.Fatal(err) } return cb, issuer.Sign(cb) } b, sig := mkClaim(0x60, 6) a.putEnvelope(t, b, sig) node := lightnode.New(lightnode.Config{ Peers: []string{a.ts.URL}, PinKeys: []string{getConfigPubkeys(t, a.ts.URL)}, }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go node.Run(ctx, 30*time.Millisecond) deadline := time.Now().Add(2 * time.Second) for node.Decision() == nil { if time.Now().After(deadline) { t.Fatal("no quorum decision") } time.Sleep(5 * time.Millisecond) } wsn, err := node.WS() if err != nil { t.Fatal(err) } go wsn.RunWS(ctx) lts := httptest.NewServer(node.Handler()) defer lts.Close() wsURL := "ws" + strings.TrimPrefix(lts.URL, "http") + "/v1/ws" conn, _, err := websocket.Dial(ctx, wsURL, nil) if err != nil { t.Fatal(err) } defer conn.Close(websocket.StatusNormalClosure, "") readEvent := func() map[string]any { t.Helper() _, raw, err := conn.Read(ctx) if err != nil { t.Fatalf("read: %v", err) } var ev map[string]any json.Unmarshal(raw, &ev) return ev } subRaw, _ := json.Marshal(map[string]string{"op": "subscribe", "channel": "claims", "key": subjAddr}) if err := conn.Write(ctx, websocket.MessageText, subRaw); err != nil { t.Fatal(err) } if ev := readEvent(); ev["event"] != "subscribed" { t.Fatalf("no ack: %v", ev) } // The relay only broadcasts at store time, so wait until the mirror's // upstream subscription is live before publishing the matched claim. deadline2 := time.Now().Add(3 * time.Second) for !wsn.UpstreamReady() { if time.Now().After(deadline2) { t.Fatal("upstream stream never came up") } time.Sleep(10 * time.Millisecond) } time.Sleep(150 * time.Millisecond) // let the relay process the subscribe frame // A second claim about the subscribed subject flows through the mirror. cb, csig := mkClaim(0x61, 7) resp, err := http.Post(a.ts.URL+"/v1/objects", "application/json", bytes.NewReader(mustJSON(t, map[string]any{"tce": b64(cb), "signature": b64(csig)}))) if err != nil { t.Fatal(err) } resp.Body.Close() ev := readEvent() if ev["event"] != "object" || ev["key"] != subjAddr { t.Fatalf("unexpected event: %v", ev) } envMap, _ := ev["envelope"].(map[string]any) if envMap == nil || envMap["tce"] == nil || envMap["signature"] == nil { t.Fatal("event missing raw envelope") } if v, ok := ev["verified"].(bool); !ok || v { t.Fatalf("fresh stream must be marked unverified: %v", ev["verified"]) } // The streamed envelope must verify locally exactly like a fetched one. tb, _ := base64.StdEncoding.DecodeString(envMap["tce"].(string)) tsig, _ := base64.StdEncoding.DecodeString(envMap["signature"].(string)) typName, verr := (&transport.Envelope{TCE: tb, Signature: tsig}).Verify() if verr != nil || typName != "claim" { t.Fatalf("streamed envelope fails local verification: %v %q", verr, typName) } }