diff --git a/identity_test.go b/identity_test.go new file mode 100644 index 0000000..59b7ab6 --- /dev/null +++ b/identity_test.go @@ -0,0 +1,396 @@ +// identity_test.go — Tests for ECDSA P-256 identity generation, persistence, and signing. +// +// Covers: GenerateIdentity, SaveIdentity, LoadIdentity, NodeID derivation, +// Sign/Verify, MarshalPublicKey/PublicKeyFromDER roundtrip. +package constellation + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +// --------------------------------------------------------------------------- +// Area 1: Identity Generation +// --------------------------------------------------------------------------- + +func TestGenerateIdentity_ProducesValidKeypair(t *testing.T) { + // GenerateIdentity should return a non-nil identity with valid ECDSA P-256 key. + id, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity() error: %v", err) + } + if id == nil { + t.Fatal("GenerateIdentity() returned nil identity") + } + if id.PrivateKey == nil { + t.Fatal("PrivateKey is nil") + } + if id.PublicKey == nil { + t.Fatal("PublicKey is nil") + } + if id.PrivateKey.Curve != elliptic.P256() { + t.Errorf("expected P-256 curve, got %v", id.PrivateKey.Curve.Params().Name) + } + if id.NodeID == "" { + t.Error("NodeID is empty") + } + // NodeID should be 64 hex chars (SHA-256 = 32 bytes = 64 hex chars). + if len(id.NodeID) != 64 { + t.Errorf("NodeID length = %d, want 64 hex chars", len(id.NodeID)) + } +} + +func TestGenerateIdentity_DifferentCallsProduceDifferentKeys(t *testing.T) { + // Two calls to GenerateIdentity should produce distinct keypairs. + id1, err := GenerateIdentity() + if err != nil { + t.Fatalf("first GenerateIdentity() error: %v", err) + } + id2, err := GenerateIdentity() + if err != nil { + t.Fatalf("second GenerateIdentity() error: %v", err) + } + if id1.NodeID == id2.NodeID { + t.Error("two generated identities have the same NodeID — extremely unlikely, crypto bug") + } + if id1.PrivateKey.D.Cmp(id2.PrivateKey.D) == 0 { + t.Error("two generated identities have the same private key") + } +} + +func TestNodeID_DerivedFromPublicKey(t *testing.T) { + // NodeID must equal hex(SHA-256(DER(pubkey))). + id, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity() error: %v", err) + } + + pubDER, err := x509.MarshalPKIXPublicKey(id.PublicKey) + if err != nil { + t.Fatalf("MarshalPKIXPublicKey error: %v", err) + } + hash := sha256.Sum256(pubDER) + expectedNodeID := hex.EncodeToString(hash[:]) + + if id.NodeID != expectedNodeID { + t.Errorf("NodeID = %s, want %s", id.NodeID, expectedNodeID) + } +} + +func TestSaveAndLoadIdentity_Roundtrip(t *testing.T) { + // SaveIdentity followed by LoadIdentity should produce an equivalent identity. + dir := t.TempDir() + + original, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity() error: %v", err) + } + + if err := SaveIdentity(original, dir); err != nil { + t.Fatalf("SaveIdentity() error: %v", err) + } + + loaded, err := LoadIdentity(dir) + if err != nil { + t.Fatalf("LoadIdentity() error: %v", err) + } + + if loaded.NodeID != original.NodeID { + t.Errorf("loaded NodeID = %s, want %s", loaded.NodeID, original.NodeID) + } + if loaded.PrivateKey.D.Cmp(original.PrivateKey.D) != 0 { + t.Error("loaded private key does not match original") + } + if loaded.PublicKey.X.Cmp(original.PublicKey.X) != 0 || + loaded.PublicKey.Y.Cmp(original.PublicKey.Y) != 0 { + t.Error("loaded public key does not match original") + } +} + +func TestSaveIdentity_CreatesKeyFile(t *testing.T) { + // SaveIdentity should create a node-key.pem file with correct permissions. + dir := t.TempDir() + id, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity() error: %v", err) + } + + if err := SaveIdentity(id, dir); err != nil { + t.Fatalf("SaveIdentity() error: %v", err) + } + + keyPath := filepath.Join(dir, "node-key.pem") + info, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("node-key.pem not found: %v", err) + } + // File should not be world-readable (0600). + mode := info.Mode().Perm() + if mode&0077 != 0 { + t.Errorf("node-key.pem permissions = %o, want no group/other access", mode) + } +} + +func TestLoadIdentity_MissingFile(t *testing.T) { + // LoadIdentity on a nonexistent directory should return an error. + _, err := LoadIdentity(filepath.Join(t.TempDir(), "nonexistent")) + if err == nil { + t.Error("expected error for missing key file, got nil") + } +} + +func TestLoadIdentity_InvalidPEM(t *testing.T) { + // LoadIdentity with garbage PEM data should return an error. + dir := t.TempDir() + keyPath := filepath.Join(dir, "node-key.pem") + if err := os.WriteFile(keyPath, []byte("not a PEM file"), 0600); err != nil { + t.Fatalf("write garbage PEM: %v", err) + } + + _, err := LoadIdentity(dir) + if err == nil { + t.Error("expected error for invalid PEM, got nil") + } +} + +func TestLoadIdentity_WrongPEMType(t *testing.T) { + // LoadIdentity with a PEM block of the wrong type should fail. + dir := t.TempDir() + keyPath := filepath.Join(dir, "node-key.pem") + // Write a valid-looking PEM with wrong type header. + pem := "-----BEGIN RSA PRIVATE KEY-----\nMIIBIjANBg==\n-----END RSA PRIVATE KEY-----\n" + if err := os.WriteFile(keyPath, []byte(pem), 0600); err != nil { + t.Fatalf("write wrong PEM type: %v", err) + } + + _, err := LoadIdentity(dir) + if err == nil { + t.Error("expected error for wrong PEM type, got nil") + } +} + +// --------------------------------------------------------------------------- +// Sign and Verify +// --------------------------------------------------------------------------- + +func TestSignAndVerify_ValidSignature(t *testing.T) { + // A signature produced by Sign should be verified by Verify with the same key. + id, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity() error: %v", err) + } + + data := []byte("test message for signing") + sig, err := id.Sign(data) + if err != nil { + t.Fatalf("Sign() error: %v", err) + } + + if !Verify(id.PublicKey, data, sig) { + t.Error("Verify() returned false for valid signature") + } +} + +func TestVerify_InvalidSignature(t *testing.T) { + // Verify should reject a mangled signature. + id, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity() error: %v", err) + } + + data := []byte("test message") + sig, err := id.Sign(data) + if err != nil { + t.Fatalf("Sign() error: %v", err) + } + + // Flip a byte in the signature. + sig[0] ^= 0xFF + + if Verify(id.PublicKey, data, sig) { + t.Error("Verify() returned true for tampered signature") + } +} + +func TestVerify_WrongKey(t *testing.T) { + // Verify should reject a signature checked against a different key. + id1, _ := GenerateIdentity() + id2, _ := GenerateIdentity() + + data := []byte("test message") + sig, err := id1.Sign(data) + if err != nil { + t.Fatalf("Sign() error: %v", err) + } + + if Verify(id2.PublicKey, data, sig) { + t.Error("Verify() returned true for wrong public key") + } +} + +func TestVerify_WrongData(t *testing.T) { + // Verify should reject a signature when the data has been changed. + id, _ := GenerateIdentity() + + data := []byte("original data") + sig, err := id.Sign(data) + if err != nil { + t.Fatalf("Sign() error: %v", err) + } + + if Verify(id.PublicKey, []byte("modified data"), sig) { + t.Error("Verify() returned true for modified data") + } +} + +func TestSign_EmptyData(t *testing.T) { + // Signing empty data should succeed and produce a verifiable signature. + id, _ := GenerateIdentity() + sig, err := id.Sign([]byte{}) + if err != nil { + t.Fatalf("Sign(empty) error: %v", err) + } + if !Verify(id.PublicKey, []byte{}, sig) { + t.Error("Verify(empty) returned false") + } +} + +func TestSign_LargeData(t *testing.T) { + // Signing large data should work (SHA-256 reduces it to 32 bytes before signing). + id, _ := GenerateIdentity() + largeData := make([]byte, 1<<20) // 1 MiB + sig, err := id.Sign(largeData) + if err != nil { + t.Fatalf("Sign(large) error: %v", err) + } + if !Verify(id.PublicKey, largeData, sig) { + t.Error("Verify(large) returned false") + } +} + +// --------------------------------------------------------------------------- +// Public key serialization roundtrip +// --------------------------------------------------------------------------- + +func TestMarshalPublicKey_Roundtrip(t *testing.T) { + // MarshalPublicKey + PublicKeyFromDER should roundtrip to an equivalent key. + id, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity() error: %v", err) + } + + der, err := id.MarshalPublicKey() + if err != nil { + t.Fatalf("MarshalPublicKey() error: %v", err) + } + if len(der) == 0 { + t.Fatal("MarshalPublicKey() returned empty DER") + } + + recovered, err := PublicKeyFromDER(der) + if err != nil { + t.Fatalf("PublicKeyFromDER() error: %v", err) + } + + if recovered.X.Cmp(id.PublicKey.X) != 0 || recovered.Y.Cmp(id.PublicKey.Y) != 0 { + t.Error("roundtripped public key does not match original") + } +} + +func TestPublicKeyFromDER_InvalidBytes(t *testing.T) { + // PublicKeyFromDER with garbage bytes should return an error. + _, err := PublicKeyFromDER([]byte("not a DER-encoded key")) + if err == nil { + t.Error("expected error for invalid DER, got nil") + } +} + +func TestPublicKeyFromDER_NonECDSAKey(t *testing.T) { + // PublicKeyFromDER should reject a valid-but-non-ECDSA public key. + // This exercises the type-assertion branch at identity.go:119–122. + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey() error: %v", err) + } + der, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey) + if err != nil { + t.Fatalf("MarshalPKIXPublicKey(RSA) error: %v", err) + } + _, err = PublicKeyFromDER(der) + if err == nil { + t.Fatal("expected error for non-ECDSA (RSA) key, got nil") + } +} + +// --------------------------------------------------------------------------- +// FormatNodeID +// --------------------------------------------------------------------------- + +func TestFormatNodeID_LongID(t *testing.T) { + // FormatNodeID should truncate a 64-char NodeID to 12 chars. + full := "a7ecf123456789abcdef0123456789abcdef0123456789abcdef0123456789ab" + short := FormatNodeID(full) + if len(short) != 12 { + t.Errorf("FormatNodeID length = %d, want 12", len(short)) + } + if short != "a7ecf1234567" { + t.Errorf("FormatNodeID = %q, want %q", short, "a7ecf1234567") + } +} + +func TestFormatNodeID_ShortID(t *testing.T) { + // FormatNodeID should return a short ID as-is. + short := "abc" + result := FormatNodeID(short) + if result != "abc" { + t.Errorf("FormatNodeID(%q) = %q, want %q", short, result, "abc") + } +} + +func TestFormatNodeID_ExactlyTwelve(t *testing.T) { + id := "123456789012" + result := FormatNodeID(id) + if result != id { + t.Errorf("FormatNodeID(%q) = %q, want %q", id, result, id) + } +} + +func TestFormatNodeID_Empty(t *testing.T) { + result := FormatNodeID("") + if result != "" { + t.Errorf("FormatNodeID(\"\") = %q, want empty", result) + } +} + +// --------------------------------------------------------------------------- +// Table-driven: NodeID determinism from known key material +// --------------------------------------------------------------------------- + +func TestIdentityFromKey_DeterministicNodeID(t *testing.T) { + // Given the same private key, identityFromKey should always produce the same NodeID. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("GenerateKey error: %v", err) + } + + id1, err := identityFromKey(key) + if err != nil { + t.Fatalf("first identityFromKey error: %v", err) + } + id2, err := identityFromKey(key) + if err != nil { + t.Fatalf("second identityFromKey error: %v", err) + } + + if id1.NodeID != id2.NodeID { + t.Errorf("same key produced different NodeIDs: %s vs %s", id1.NodeID, id2.NodeID) + } +} diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..9e78386 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,673 @@ +// integration_test.go — Integration tests for multi-node constellation operations. +// +// These tests exercise the full stack: identity, git store, ledger, heartbeat, +// HTTP protocol, and peer trust scoring. They use real git repositories in +// temp directories and real HTTP servers on ephemeral ports. +// +// Build tag: Run with `go test -tags integration` to include these tests. +// Without the tag, these tests are skipped. + +//go:build integration + +package constellation + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "path/filepath" + "strings" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// startTestNode creates and starts a node on a random port. Returns the node +// and a cleanup function. The node runs in a goroutine. +func startTestNode(t *testing.T, name string, port int, peers []string) (*Node, func()) { + t.Helper() + + dataDir := filepath.Join(t.TempDir(), name) + node, err := NewNode(name, port, dataDir) + if err != nil { + t.Fatalf("NewNode(%s) error: %v", name, err) + } + node.Hostname = "localhost" + + go func() { + if err := node.Start(peers); err != nil { + // Ignore "server closed" errors during shutdown. + if err != http.ErrServerClosed { + // Use stdlib log here (not t.Logf) to avoid a logging race + // if this goroutine outlives the test body. + log.Printf("[%s] Start error: %v", name, err) + } + } + }() + + // Wait for the server to be ready. + ready := false + for i := 0; i < 20; i++ { + time.Sleep(100 * time.Millisecond) + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/state", port)) + if err == nil { + resp.Body.Close() + ready = true + break + } + } + if !ready { + t.Fatalf("node %s did not start within 2s", name) + } + + cleanup := func() { + node.Stop() + } + + return node, cleanup +} + +// waitForTrust polls a node's /peers endpoint until the given peer count +// reaches the specified trust level, or times out. +func waitForTrust(t *testing.T, port int, minTrusted int, timeout time.Duration) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/peers", port)) + if err != nil { + time.Sleep(500 * time.Millisecond) + continue + } + + var peers []PeerSummary + json.NewDecoder(resp.Body).Decode(&peers) + resp.Body.Close() + + trustedCount := 0 + for _, p := range peers { + if p.Trust >= TrustThresholdTrusted { + trustedCount++ + } + } + if trustedCount >= minTrusted { + return true + } + time.Sleep(500 * time.Millisecond) + } + return false +} + +// --------------------------------------------------------------------------- +// Area 5: Integration — GitStore operations (no network, but uses real git) +// --------------------------------------------------------------------------- + +func TestGitStore_AppendAndRecover(t *testing.T) { + // Appending events to a GitStore and reading them back should work. + dir := filepath.Join(t.TempDir(), "repo") + store, err := NewGitStore(dir) + if err != nil { + t.Fatalf("NewGitStore error: %v", err) + } + + // Build a 5-event chain. + priorHash := "" + for seq := int64(1); seq <= 5; seq++ { + env, err := NewEvent("node1", "test", seq, priorHash, map[string]interface{}{ + "seq": float64(seq), + }) + if err != nil { + t.Fatalf("NewEvent(%d) error: %v", seq, err) + } + if err := store.AppendEvent(env); err != nil { + t.Fatalf("AppendEvent(%d) error: %v", seq, err) + } + priorHash = env.Metadata.Hash + } + + // Read back all events. + events, err := store.ReadEventRange(1, 5) + if err != nil { + t.Fatalf("ReadEventRange error: %v", err) + } + if len(events) != 5 { + t.Errorf("got %d events, want 5", len(events)) + } + + // Verify chain integrity. + report := ValidateCoherence(events) + if !report.Pass { + t.Errorf("coherence check failed on freshly written chain") + for _, c := range report.Checks { + if !c.Pass { + t.Errorf(" %s: %s", c.Layer, c.Detail) + } + } + } + + // Verify tree hash is available. + treeHash, err := store.TreeHash() + if err != nil { + t.Fatalf("TreeHash error: %v", err) + } + if treeHash == "" { + t.Error("TreeHash should not be empty after appending events") + } +} + +func TestGitStore_LastEvent(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + store, err := NewGitStore(dir) + if err != nil { + t.Fatalf("NewGitStore error: %v", err) + } + + // Empty store should return nil. + last, err := store.LastEvent() + if err != nil { + t.Fatalf("LastEvent (empty) error: %v", err) + } + if last != nil { + t.Error("LastEvent on empty store should return nil") + } + + // Append 3 events. + priorHash := "" + for seq := int64(1); seq <= 3; seq++ { + env, err := NewEvent("node1", "test", seq, priorHash, nil) + if err != nil { + t.Fatalf("NewEvent(%d) error: %v", seq, err) + } + if err := store.AppendEvent(env); err != nil { + t.Fatalf("AppendEvent(%d) error: %v", seq, err) + } + priorHash = env.Metadata.Hash + } + + last, err = store.LastEvent() + if err != nil { + t.Fatalf("LastEvent error: %v", err) + } + if last == nil { + t.Fatal("LastEvent returned nil after appending events") + } + if last.Metadata.Seq != 3 { + t.Errorf("LastEvent seq = %d, want 3", last.Metadata.Seq) + } +} + +func TestGitStore_CorruptEvent_DetectedByCoherence(t *testing.T) { + // CorruptEvent should modify the data such that ValidateCoherence fails. + dir := filepath.Join(t.TempDir(), "repo") + store, err := NewGitStore(dir) + if err != nil { + t.Fatalf("NewGitStore error: %v", err) + } + + // Build chain. + priorHash := "" + for seq := int64(1); seq <= 5; seq++ { + env, err := NewEvent("node1", "test", seq, priorHash, map[string]interface{}{ + "cycle": float64(seq), + }) + if err != nil { + t.Fatalf("NewEvent(%d) error: %v", seq, err) + } + if err := store.AppendEvent(env); err != nil { + t.Fatalf("AppendEvent(%d) error: %v", seq, err) + } + priorHash = env.Metadata.Hash + } + + // Corrupt event 3. + if err := store.CorruptEvent(3); err != nil { + t.Fatalf("CorruptEvent error: %v", err) + } + + // Re-read and validate. + events, err := store.ReadEventRange(1, 5) + if err != nil { + t.Fatalf("ReadEventRange error: %v", err) + } + + report := ValidateCoherence(events) + if report.Pass { + t.Error("coherence should fail after corruption, but it passed") + } + + // The hash_chain check should specifically fail. + chainFailed := false + for _, c := range report.Checks { + if c.Layer == "hash_chain" && !c.Pass { + chainFailed = true + t.Logf("detected: %s", c.Detail) + } + } + if !chainFailed { + t.Error("hash_chain check should have failed after corruption") + } +} + +func TestGitStore_ReadEventRange_Subset(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + store, _ := NewGitStore(dir) + + priorHash := "" + for seq := int64(1); seq <= 10; seq++ { + env, _ := NewEvent("node1", "test", seq, priorHash, nil) + store.AppendEvent(env) + priorHash = env.Metadata.Hash + } + + // Read only events 3-7. + events, err := store.ReadEventRange(3, 7) + if err != nil { + t.Fatalf("ReadEventRange(3,7) error: %v", err) + } + if len(events) != 5 { + t.Errorf("got %d events, want 5", len(events)) + } + if events[0].Metadata.Seq != 3 { + t.Errorf("first event seq = %d, want 3", events[0].Metadata.Seq) + } + if events[4].Metadata.Seq != 7 { + t.Errorf("last event seq = %d, want 7", events[4].Metadata.Seq) + } +} + +func TestGitStore_CommitHash_Changes(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + store, _ := NewGitStore(dir) + + env1, _ := NewEvent("node1", "test", 1, "", nil) + store.AppendEvent(env1) + + hash1, err := store.CommitHash() + if err != nil { + t.Fatalf("CommitHash error: %v", err) + } + if hash1 == "" { + t.Fatal("CommitHash should not be empty after first commit") + } + + env2, _ := NewEvent("node1", "test", 2, env1.Metadata.Hash, nil) + store.AppendEvent(env2) + + hash2, err := store.CommitHash() + if err != nil { + t.Fatalf("CommitHash error: %v", err) + } + if hash2 == hash1 { + t.Error("CommitHash should change after a new commit") + } +} + +// --------------------------------------------------------------------------- +// Area 5: Integration — Node lifecycle +// --------------------------------------------------------------------------- + +func TestNode_CreateAndAppendEvent(t *testing.T) { + // Create a node, append events, verify the ledger is coherent. + dataDir := filepath.Join(t.TempDir(), "test-node") + node, err := NewNode("test-node", 0, dataDir) + if err != nil { + t.Fatalf("NewNode error: %v", err) + } + + if node.Identity == nil { + t.Fatal("node.Identity is nil") + } + if node.Identity.NodeID == "" { + t.Fatal("node.Identity.NodeID is empty") + } + + // Append 3 events. + for i := 0; i < 3; i++ { + if err := node.AppendEvent("test", map[string]any{"i": i}); err != nil { + t.Fatalf("AppendEvent(%d) error: %v", i, err) + } + } + + // Self-check. + report, err := node.SelfCheck() + if err != nil { + t.Fatalf("SelfCheck error: %v", err) + } + if !report.Pass { + t.Error("SelfCheck failed on freshly created node") + for _, c := range report.Checks { + if !c.Pass { + t.Errorf(" %s: %s", c.Layer, c.Detail) + } + } + } +} + +func TestNode_PersistsIdentity(t *testing.T) { + // Creating a node twice in the same directory should load the same identity. + dataDir := filepath.Join(t.TempDir(), "persistent") + + node1, err := NewNode("persistent", 0, dataDir) + if err != nil { + t.Fatalf("first NewNode error: %v", err) + } + id1 := node1.Identity.NodeID + + node2, err := NewNode("persistent", 0, dataDir) + if err != nil { + t.Fatalf("second NewNode error: %v", err) + } + id2 := node2.Identity.NodeID + + if id1 != id2 { + t.Errorf("identity not persisted: first=%s, second=%s", id1, id2) + } +} + +func TestNode_RecoverSequence(t *testing.T) { + // A node should recover its sequence number from existing events on restart. + dataDir := filepath.Join(t.TempDir(), "recover") + + node1, err := NewNode("recover", 0, dataDir) + if err != nil { + t.Fatalf("NewNode error: %v", err) + } + + for i := 0; i < 5; i++ { + node1.AppendEvent("test", nil) + } + + // Create a new node instance at the same data dir. + node2, err := NewNode("recover", 0, dataDir) + if err != nil { + t.Fatalf("second NewNode error: %v", err) + } + + state, err := node2.CurrentState() + if err != nil { + t.Fatalf("CurrentState error: %v", err) + } + if state.Seq != 5 { + t.Errorf("recovered seq = %d, want 5", state.Seq) + } +} + +// --------------------------------------------------------------------------- +// Area 5: Integration — Two-node sync (HTTP-based) +// --------------------------------------------------------------------------- + +func TestTwoNodes_TrustConvergence(t *testing.T) { + // Start two nodes, verify they discover each other and converge to trusted. + // This test requires real HTTP servers and takes ~30s to converge. + if testing.Short() { + t.Skip("skipping multi-node test in short mode") + } + + nodeA, cleanupA := startTestNode(t, "alpha", 18201, nil) + defer cleanupA() + + _, cleanupB := startTestNode(t, "beta", 18202, []string{ + fmt.Sprintf("localhost:%d", 18201), + }) + defer cleanupB() + + // Also tell alpha about beta. + nodeA.Peers.AddPeer(fmt.Sprintf("localhost:%d", 18202)) + + // Wait for trust to converge (up to 60s). + if !waitForTrust(t, 18201, 1, 60*time.Second) { + t.Error("alpha did not reach trusted status with beta within 60s") + } + if !waitForTrust(t, 18202, 1, 60*time.Second) { + t.Error("beta did not reach trusted status with alpha within 60s") + } +} + +func TestTwoNodes_HealthEndpoint(t *testing.T) { + // Start a node and verify its /health endpoint returns pass=true. + if testing.Short() { + t.Skip("skipping HTTP test in short mode") + } + + _, cleanup := startTestNode(t, "health-test", 18210, nil) + defer cleanup() + + // Give it a moment to generate at least one event. + time.Sleep(6 * time.Second) + + resp, err := http.Get("http://localhost:18210/health") + if err != nil { + t.Fatalf("GET /health error: %v", err) + } + defer resp.Body.Close() + + var report CoherenceReport + if err := json.NewDecoder(resp.Body).Decode(&report); err != nil { + t.Fatalf("decode health response: %v", err) + } + + if !report.Pass { + t.Error("/health returned pass=false for a fresh node") + for _, c := range report.Checks { + if !c.Pass { + t.Errorf(" %s: %s", c.Layer, c.Detail) + } + } + } +} + +func TestTwoNodes_StateEndpoint(t *testing.T) { + // /state should return the node's identity and current sequence. + if testing.Short() { + t.Skip("skipping HTTP test in short mode") + } + + node, cleanup := startTestNode(t, "state-test", 18211, nil) + defer cleanup() + + time.Sleep(6 * time.Second) + + resp, err := http.Get("http://localhost:18211/state") + if err != nil { + t.Fatalf("GET /state error: %v", err) + } + defer resp.Body.Close() + + var result struct { + State NodeState `json:"state"` + Peers []PeerSummary `json:"peers"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("decode state response: %v", err) + } + + if result.State.NodeID != node.Identity.NodeID { + t.Errorf("state NodeID = %s, want %s", result.State.NodeID, node.Identity.NodeID) + } + if result.State.Seq < 1 { + t.Errorf("state Seq = %d, want >= 1", result.State.Seq) + } +} + +// --------------------------------------------------------------------------- +// Area 5: Integration — Tamper detection via /health after CorruptEvent +// --------------------------------------------------------------------------- + +func TestNode_TamperDetectedByHealthEndpoint(t *testing.T) { + // Corrupt an event in a running node's store, then check /health. + if testing.Short() { + t.Skip("skipping HTTP test in short mode") + } + + node, cleanup := startTestNode(t, "tamper-test", 18212, nil) + defer cleanup() + + // Wait for a few heartbeat events. + time.Sleep(12 * time.Second) + + // Corrupt event 1. + if err := node.Store.CorruptEvent(1); err != nil { + t.Fatalf("CorruptEvent error: %v", err) + } + + // /health should now report failure. + resp, err := http.Get("http://localhost:18212/health") + if err != nil { + t.Fatalf("GET /health error: %v", err) + } + defer resp.Body.Close() + + // /health intentionally returns 503 when report.Pass == false + // (protocol.go:200-203 — HTTP-native tamper signal, body still valid JSON). + // Accept 200 (healthy) or 503 (tamper). Fail on anything else, which would + // indicate a broken pipeline rather than a decisional output. + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("/health returned status %d, want 200 or 503", resp.StatusCode) + } + + var report CoherenceReport + if err := json.NewDecoder(resp.Body).Decode(&report); err != nil { + t.Fatalf("decode /health body: %v", err) + } + + // We want pass=false because tamper detection fired — confirmed by the + // explicit status/decode checks above (not because of a broken pipeline). + if report.Pass { + t.Error("/health should return pass=false after corruption") + } +} + +// --------------------------------------------------------------------------- +// Area 5: Integration — GitStore reopen (persistence across restarts) +// --------------------------------------------------------------------------- + +func TestGitStore_ReopenPreservesEvents(t *testing.T) { + dir := filepath.Join(t.TempDir(), "reopen") + + // First session: write events. + store1, err := NewGitStore(dir) + if err != nil { + t.Fatalf("first NewGitStore error: %v", err) + } + priorHash := "" + for seq := int64(1); seq <= 3; seq++ { + env, _ := NewEvent("node1", "test", seq, priorHash, nil) + store1.AppendEvent(env) + priorHash = env.Metadata.Hash + } + treeHash1, _ := store1.TreeHash() + + // Second session: reopen same directory. + store2, err := NewGitStore(dir) + if err != nil { + t.Fatalf("second NewGitStore error: %v", err) + } + + events, err := store2.ReadEventRange(1, 3) + if err != nil { + t.Fatalf("ReadEventRange error: %v", err) + } + if len(events) != 3 { + t.Errorf("got %d events after reopen, want 3", len(events)) + } + + treeHash2, _ := store2.TreeHash() + if treeHash1 != treeHash2 { + t.Errorf("tree hash changed after reopen: %s vs %s", treeHash1, treeHash2) + } +} + +// --------------------------------------------------------------------------- +// Area 3: BEP Protocol / HTTP protocol tests +// --------------------------------------------------------------------------- + +func TestProtocol_JoinEndpoint(t *testing.T) { + // POST /join should accept a new node and return a peer list. + if testing.Short() { + t.Skip("skipping HTTP test in short mode") + } + + _, cleanup := startTestNode(t, "join-host", 18220, nil) + defer cleanup() + + // Build a join request. + id, _ := GenerateIdentity() + reqBody := fmt.Sprintf(`{"node_id":"%s","name":"joiner","addr":"localhost:18221"}`, id.NodeID) + + resp, err := http.Post( + "http://localhost:18220/join", + "application/json", + strings.NewReader(reqBody), + ) + if err != nil { + t.Fatalf("POST /join error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("POST /join status = %d, want 200", resp.StatusCode) + } + + var result struct { + Status string `json:"status"` + Peers []string `json:"peers"` + } + json.NewDecoder(resp.Body).Decode(&result) + + if result.Status != "accepted" { + t.Errorf("join status = %q, want %q", result.Status, "accepted") + } +} + +func TestProtocol_PeersEndpoint(t *testing.T) { + if testing.Short() { + t.Skip("skipping HTTP test in short mode") + } + + _, cleanup := startTestNode(t, "peers-test", 18221, nil) + defer cleanup() + + resp, err := http.Get("http://localhost:18221/peers") + if err != nil { + t.Fatalf("GET /peers error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("GET /peers status = %d, want 200", resp.StatusCode) + } + + var peers []PeerSummary + if err := json.NewDecoder(resp.Body).Decode(&peers); err != nil { + t.Fatalf("decode /peers body: %v", err) + } + // A fresh node with no configured peers should return an empty list. + if len(peers) != 0 { + t.Errorf("expected empty peers on fresh node, got %d", len(peers)) + } +} + +func TestProtocol_MethodNotAllowed(t *testing.T) { + if testing.Short() { + t.Skip("skipping HTTP test in short mode") + } + + _, cleanup := startTestNode(t, "method-test", 18222, nil) + defer cleanup() + + // GET to a POST-only endpoint should return 405. + resp, err := http.Get("http://localhost:18222/heartbeat") + if err != nil { + t.Fatalf("GET /heartbeat error: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("GET /heartbeat status = %d, want 405", resp.StatusCode) + } +} + +// Note: uses strings.NewReader directly for POST bodies — no wrapper needed. diff --git a/ledger_test.go b/ledger_test.go new file mode 100644 index 0000000..78042b3 --- /dev/null +++ b/ledger_test.go @@ -0,0 +1,569 @@ +// ledger_test.go — Tests for hash-chained event ledger and coherence validation. +// +// Covers: CanonicalizeEvent, HashEvent, NewEvent, canonicalJSON (RFC 8785), +// ValidateCoherence (hash chain, schema, temporal monotonicity). +package constellation + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// Area 4: Ledger Operations — Canonicalization +// --------------------------------------------------------------------------- + +func TestCanonicalizeEvent_SortedKeys(t *testing.T) { + // RFC 8785 requires sorted keys. Verify the output has alphabetical key order. + payload := &EventPayload{ + Type: "test", + Timestamp: "2026-04-10T00:00:00Z", + NodeID: "abc123", + } + + canonical, err := CanonicalizeEvent(payload) + if err != nil { + t.Fatalf("CanonicalizeEvent error: %v", err) + } + + // The keys should be sorted: "node_id", "timestamp", "type" + expected := `{"node_id":"abc123","timestamp":"2026-04-10T00:00:00Z","type":"test"}` + if string(canonical) != expected { + t.Errorf("canonical JSON:\n got: %s\n want: %s", string(canonical), expected) + } +} + +func TestCanonicalizeEvent_WithPriorHash(t *testing.T) { + // When prior_hash is set, it should appear in the canonical output. + payload := &EventPayload{ + Type: "test", + Timestamp: "2026-04-10T00:00:00Z", + NodeID: "abc123", + PriorHash: "deadbeef", + } + + canonical, err := CanonicalizeEvent(payload) + if err != nil { + t.Fatalf("CanonicalizeEvent error: %v", err) + } + + // Keys sorted: "node_id", "prior_hash", "timestamp", "type" + expected := `{"node_id":"abc123","prior_hash":"deadbeef","timestamp":"2026-04-10T00:00:00Z","type":"test"}` + if string(canonical) != expected { + t.Errorf("canonical JSON:\n got: %s\n want: %s", string(canonical), expected) + } +} + +func TestCanonicalizeEvent_WithData(t *testing.T) { + // When data is present, it should appear sorted in the canonical output. + payload := &EventPayload{ + Type: "test", + Timestamp: "2026-04-10T00:00:00Z", + NodeID: "abc123", + Data: map[string]interface{}{ + "zebra": "last", + "alpha": "first", + }, + } + + canonical, err := CanonicalizeEvent(payload) + if err != nil { + t.Fatalf("CanonicalizeEvent error: %v", err) + } + + // The data map should also have sorted keys. + // Full sorted key order: "data", "node_id", "timestamp", "type" + expected := `{"data":{"alpha":"first","zebra":"last"},"node_id":"abc123","timestamp":"2026-04-10T00:00:00Z","type":"test"}` + if string(canonical) != expected { + t.Errorf("canonical JSON:\n got: %s\n want: %s", string(canonical), expected) + } +} + +func TestCanonicalizeEvent_EmptyPriorHash_Omitted(t *testing.T) { + // An empty prior_hash should not appear in canonical output. + payload := &EventPayload{ + Type: "test", + Timestamp: "2026-04-10T00:00:00Z", + NodeID: "abc123", + PriorHash: "", + } + + canonical, err := CanonicalizeEvent(payload) + if err != nil { + t.Fatalf("CanonicalizeEvent error: %v", err) + } + + // Should NOT contain "prior_hash". + got := string(canonical) + if contains(got, "prior_hash") { + t.Errorf("canonical JSON should not contain prior_hash when empty: %s", got) + } +} + +func TestCanonicalizeEvent_Deterministic(t *testing.T) { + // Same payload should always produce the same canonical bytes. + payload := &EventPayload{ + Type: "heartbeat", + Timestamp: "2026-04-10T12:00:00.123456789Z", + NodeID: "deadbeef01234567", + PriorHash: "aabbccdd", + Data: map[string]interface{}{"cycle": float64(42)}, + } + + c1, err := CanonicalizeEvent(payload) + if err != nil { + t.Fatalf("first CanonicalizeEvent error: %v", err) + } + c2, err := CanonicalizeEvent(payload) + if err != nil { + t.Fatalf("second CanonicalizeEvent error: %v", err) + } + + if string(c1) != string(c2) { + t.Errorf("non-deterministic canonicalization:\n c1: %s\n c2: %s", c1, c2) + } +} + +// --------------------------------------------------------------------------- +// HashEvent +// --------------------------------------------------------------------------- + +func TestHashEvent_KnownVector(t *testing.T) { + // SHA-256 of empty string is a known value. + emptyHash := HashEvent([]byte("")) + expectedEmptySHA := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + if emptyHash != expectedEmptySHA { + t.Errorf("HashEvent(\"\") = %s, want %s", emptyHash, expectedEmptySHA) + } +} + +func TestHashEvent_DifferentInputsDifferentHashes(t *testing.T) { + h1 := HashEvent([]byte("hello")) + h2 := HashEvent([]byte("world")) + if h1 == h2 { + t.Error("different inputs produced the same hash") + } +} + +func TestHashEvent_Deterministic(t *testing.T) { + data := []byte(`{"node_id":"abc","timestamp":"2026-04-10T00:00:00Z","type":"test"}`) + h1 := HashEvent(data) + h2 := HashEvent(data) + if h1 != h2 { + t.Errorf("non-deterministic HashEvent: %s vs %s", h1, h2) + } +} + +func TestHashEvent_MatchesManualSHA256(t *testing.T) { + data := []byte("constellation protocol test vector") + manual := sha256.Sum256(data) + expected := hex.EncodeToString(manual[:]) + got := HashEvent(data) + if got != expected { + t.Errorf("HashEvent = %s, manual SHA-256 = %s", got, expected) + } +} + +// --------------------------------------------------------------------------- +// NewEvent +// --------------------------------------------------------------------------- + +func TestNewEvent_FirstEvent_NoPriorHash(t *testing.T) { + // The first event in a ledger should have no prior_hash. + env, err := NewEvent("node1", "init", 1, "", nil) + if err != nil { + t.Fatalf("NewEvent error: %v", err) + } + + if env.HashedPayload.PriorHash != "" { + t.Errorf("first event should have empty prior_hash, got %q", env.HashedPayload.PriorHash) + } + if env.Metadata.Seq != 1 { + t.Errorf("seq = %d, want 1", env.Metadata.Seq) + } + if env.Metadata.Hash == "" { + t.Error("hash is empty") + } + if env.HashedPayload.NodeID != "node1" { + t.Errorf("node_id = %q, want %q", env.HashedPayload.NodeID, "node1") + } + if env.HashedPayload.Type != "init" { + t.Errorf("type = %q, want %q", env.HashedPayload.Type, "init") + } +} + +func TestNewEvent_ChainedEvents_PriorHashLinked(t *testing.T) { + // Creating events with prior_hash should link them. + e1, err := NewEvent("node1", "heartbeat", 1, "", map[string]interface{}{"cycle": float64(1)}) + if err != nil { + t.Fatalf("NewEvent(1) error: %v", err) + } + + e2, err := NewEvent("node1", "heartbeat", 2, e1.Metadata.Hash, map[string]interface{}{"cycle": float64(2)}) + if err != nil { + t.Fatalf("NewEvent(2) error: %v", err) + } + + if e2.HashedPayload.PriorHash != e1.Metadata.Hash { + t.Errorf("e2.prior_hash = %s, want %s (e1.hash)", e2.HashedPayload.PriorHash, e1.Metadata.Hash) + } +} + +func TestNewEvent_HashVerifiable(t *testing.T) { + // The hash stored in metadata should match a fresh re-hash of the canonical payload. + env, err := NewEvent("nodeX", "test", 1, "", map[string]interface{}{"key": "value"}) + if err != nil { + t.Fatalf("NewEvent error: %v", err) + } + + canonical, err := CanonicalizeEvent(&env.HashedPayload) + if err != nil { + t.Fatalf("CanonicalizeEvent error: %v", err) + } + recomputed := HashEvent(canonical) + + if recomputed != env.Metadata.Hash { + t.Errorf("recomputed hash %s != stored hash %s", recomputed, env.Metadata.Hash) + } +} + +func TestNewEvent_TimestampIsRFC3339Nano(t *testing.T) { + env, err := NewEvent("node1", "test", 1, "", nil) + if err != nil { + t.Fatalf("NewEvent error: %v", err) + } + + _, parseErr := time.Parse(time.RFC3339Nano, env.HashedPayload.Timestamp) + if parseErr != nil { + t.Errorf("timestamp %q is not valid RFC3339Nano: %v", env.HashedPayload.Timestamp, parseErr) + } +} + +func TestNewEvent_WithData(t *testing.T) { + data := map[string]interface{}{"key": "value", "count": float64(42)} + env, err := NewEvent("node1", "test", 1, "", data) + if err != nil { + t.Fatalf("NewEvent error: %v", err) + } + if env.HashedPayload.Data == nil { + t.Fatal("Data should not be nil") + } + if env.HashedPayload.Data["key"] != "value" { + t.Errorf("Data[key] = %v, want 'value'", env.HashedPayload.Data["key"]) + } +} + +// --------------------------------------------------------------------------- +// EventEnvelope JSON roundtrip +// --------------------------------------------------------------------------- + +func TestEventEnvelope_JSONRoundtrip(t *testing.T) { + // Serializing and deserializing an EventEnvelope should preserve all fields. + original, err := NewEvent("node1", "heartbeat", 5, "priorfeed", map[string]interface{}{ + "cycle": float64(5), + }) + if err != nil { + t.Fatalf("NewEvent error: %v", err) + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var restored EventEnvelope + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + if restored.Metadata.Hash != original.Metadata.Hash { + t.Errorf("hash mismatch: %s vs %s", restored.Metadata.Hash, original.Metadata.Hash) + } + if restored.Metadata.Seq != original.Metadata.Seq { + t.Errorf("seq mismatch: %d vs %d", restored.Metadata.Seq, original.Metadata.Seq) + } + if restored.HashedPayload.Type != original.HashedPayload.Type { + t.Errorf("type mismatch: %s vs %s", restored.HashedPayload.Type, original.HashedPayload.Type) + } + if restored.HashedPayload.PriorHash != original.HashedPayload.PriorHash { + t.Errorf("prior_hash mismatch: %s vs %s", restored.HashedPayload.PriorHash, original.HashedPayload.PriorHash) + } +} + +// --------------------------------------------------------------------------- +// Coherence Validation +// --------------------------------------------------------------------------- + +func buildChain(t *testing.T, nodeID string, count int) []*EventEnvelope { + t.Helper() + events := make([]*EventEnvelope, 0, count) + priorHash := "" + for i := 1; i <= count; i++ { + env, err := NewEvent(nodeID, "heartbeat", int64(i), priorHash, map[string]interface{}{ + "cycle": float64(i), + }) + if err != nil { + t.Fatalf("NewEvent(%d) error: %v", i, err) + } + priorHash = env.Metadata.Hash + events = append(events, env) + // Small delay to ensure timestamp monotonicity. + time.Sleep(time.Millisecond) + } + return events +} + +func TestValidateCoherence_ValidChain(t *testing.T) { + // A properly constructed chain should pass all 3 validation layers. + events := buildChain(t, "node1", 10) + + report := ValidateCoherence(events) + if !report.Pass { + t.Errorf("expected pass=true for valid chain, got false") + for _, c := range report.Checks { + if !c.Pass { + t.Errorf(" failed check: %s — %s", c.Layer, c.Detail) + } + } + } + if len(report.Checks) != 3 { + t.Errorf("expected 3 checks, got %d", len(report.Checks)) + } +} + +func TestValidateCoherence_EmptyLedger(t *testing.T) { + // An empty event list should pass validation. + report := ValidateCoherence([]*EventEnvelope{}) + if !report.Pass { + t.Error("empty ledger should pass validation") + } +} + +func TestValidateCoherence_SingleEvent(t *testing.T) { + // A single event should pass validation. + events := buildChain(t, "node1", 1) + report := ValidateCoherence(events) + if !report.Pass { + t.Errorf("single event should pass, got false") + for _, c := range report.Checks { + if !c.Pass { + t.Errorf(" failed: %s — %s", c.Layer, c.Detail) + } + } + } +} + +func TestValidateCoherence_BrokenHashChain(t *testing.T) { + // Modifying a prior_hash should be detected by the hash chain check. + events := buildChain(t, "node1", 5) + + // Break the chain: set event[2].prior_hash to garbage. + events[2].HashedPayload.PriorHash = "0000000000000000000000000000000000000000000000000000000000000000" + + report := ValidateCoherence(events) + if report.Pass { + t.Error("expected pass=false for broken hash chain") + } + + // The hash_chain layer should fail. + found := false + for _, c := range report.Checks { + if c.Layer == "hash_chain" && !c.Pass { + found = true + } + } + if !found { + t.Error("hash_chain check should have failed") + } +} + +func TestValidateCoherence_TamperedPayload(t *testing.T) { + // Changing the payload data without updating the hash should be detected. + events := buildChain(t, "node1", 5) + + // Tamper with event[1]'s data but keep its hash unchanged. + events[1].HashedPayload.Data = map[string]interface{}{"tampered": true} + + report := ValidateCoherence(events) + if report.Pass { + t.Error("expected pass=false for tampered payload") + } +} + +func TestValidateCoherence_SequenceGap(t *testing.T) { + // A gap in sequence numbers should be detected by temporal monotonicity. + events := buildChain(t, "node1", 3) + + // Skip seq 2 -> 4 (instead of 2 -> 3). + events[2].Metadata.Seq = 4 + + report := ValidateCoherence(events) + if report.Pass { + t.Error("expected pass=false for sequence gap") + } + + found := false + for _, c := range report.Checks { + if c.Layer == "temporal" && !c.Pass { + found = true + } + } + if !found { + t.Error("temporal check should have failed for sequence gap") + } +} + +func TestValidateCoherence_TimestampReversal(t *testing.T) { + // A timestamp going backwards should be detected. + events := buildChain(t, "node1", 3) + + // Make event[2] have a timestamp before event[1]. + events[2].HashedPayload.Timestamp = "2000-01-01T00:00:00Z" + + report := ValidateCoherence(events) + if report.Pass { + t.Error("expected pass=false for timestamp reversal") + } + + found := false + for _, c := range report.Checks { + if c.Layer == "temporal" && !c.Pass { + found = true + } + } + if !found { + t.Error("temporal check should have failed for time reversal") + } +} + +func TestValidateCoherence_MissingType(t *testing.T) { + // An event with empty type should fail schema validation. + events := buildChain(t, "node1", 3) + events[1].HashedPayload.Type = "" + + report := ValidateCoherence(events) + if report.Pass { + t.Error("expected pass=false for missing type") + } + found := false + for _, c := range report.Checks { + if c.Layer == "schema" && !c.Pass { + found = true + } + } + if !found { + t.Error("schema check should have failed for missing type") + } +} + +func TestValidateCoherence_MissingNodeID(t *testing.T) { + events := buildChain(t, "node1", 2) + events[0].HashedPayload.NodeID = "" + + report := ValidateCoherence(events) + if report.Pass { + t.Error("expected pass=false for missing node_id") + } +} + +func TestValidateCoherence_MissingTimestamp(t *testing.T) { + events := buildChain(t, "node1", 2) + events[0].HashedPayload.Timestamp = "" + + report := ValidateCoherence(events) + if report.Pass { + t.Error("expected pass=false for missing timestamp") + } +} + +func TestValidateCoherence_InvalidTimestamp(t *testing.T) { + events := buildChain(t, "node1", 2) + events[0].HashedPayload.Timestamp = "not-a-timestamp" + + report := ValidateCoherence(events) + if report.Pass { + t.Error("expected pass=false for invalid timestamp") + } +} + +func TestValidateCoherence_MissingHash(t *testing.T) { + events := buildChain(t, "node1", 2) + events[0].Metadata.Hash = "" + + report := ValidateCoherence(events) + if report.Pass { + t.Error("expected pass=false for missing hash") + } +} + +// --------------------------------------------------------------------------- +// canonicalJSON edge cases +// --------------------------------------------------------------------------- + +func TestCanonicalJSON_NestedObject(t *testing.T) { + // Nested maps should also have sorted keys. + payload := &EventPayload{ + Type: "test", + Timestamp: "2026-04-10T00:00:00Z", + NodeID: "node1", + Data: map[string]interface{}{ + "outer": map[string]interface{}{ + "z": float64(1), + "a": float64(2), + }, + }, + } + + canonical, err := CanonicalizeEvent(payload) + if err != nil { + t.Fatalf("CanonicalizeEvent error: %v", err) + } + + got := string(canonical) + // The nested keys should be sorted: "a" before "z". + expected := `{"data":{"outer":{"a":2,"z":1}},"node_id":"node1","timestamp":"2026-04-10T00:00:00Z","type":"test"}` + if got != expected { + t.Errorf("nested canonical JSON:\n got: %s\n want: %s", got, expected) + } +} + +func TestCanonicalJSON_Array(t *testing.T) { + // Arrays should preserve order (not be sorted). + payload := &EventPayload{ + Type: "test", + Timestamp: "2026-04-10T00:00:00Z", + NodeID: "node1", + Data: map[string]interface{}{ + "items": []interface{}{"b", "a", "c"}, + }, + } + + canonical, err := CanonicalizeEvent(payload) + if err != nil { + t.Fatalf("CanonicalizeEvent error: %v", err) + } + + got := string(canonical) + // Arrays preserve insertion order. + expected := `{"data":{"items":["b","a","c"]},"node_id":"node1","timestamp":"2026-04-10T00:00:00Z","type":"test"}` + if got != expected { + t.Errorf("array canonical JSON:\n got: %s\n want: %s", got, expected) + } +} + +// helper +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr)) +} + +func containsSubstr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/tls_test.go b/tls_test.go new file mode 100644 index 0000000..3096eb6 --- /dev/null +++ b/tls_test.go @@ -0,0 +1,526 @@ +// tls_test.go — Tests for trust scoring, peer registry, heartbeat signing, +// and identity conflict detection. +// +// Note: The constellation protocol does NOT use TLS certificates. Instead, +// it uses ECDSA-signed heartbeats for mutual authentication. This file +// tests those mechanisms, which serve the same purpose as mTLS in +// traditional systems. +// +// Covers: TrustLevel, PeerRegistry, ProcessHeartbeat, VerifyHeartbeat, +// identity conflict detection, EMA trust scoring. +package constellation + +import ( + "crypto/x509" + "encoding/base64" + "encoding/json" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// Area 2: Trust Scoring — TrustLevel thresholds +// --------------------------------------------------------------------------- + +func TestTrustLevel_Thresholds(t *testing.T) { + tests := []struct { + score float64 + want string + }{ + {1.0, "trusted"}, + {0.7, "trusted"}, + {0.71, "trusted"}, + {0.69, "pending"}, + {0.5, "pending"}, + {0.4, "pending"}, + {0.39, "suspect"}, + {0.2, "suspect"}, + {0.19, "rejected"}, + {0.0, "rejected"}, + {-0.1, "rejected"}, + } + + for _, tt := range tests { + got := TrustLevel(tt.score) + if got != tt.want { + t.Errorf("TrustLevel(%v) = %q, want %q", tt.score, got, tt.want) + } + } +} + +// --------------------------------------------------------------------------- +// PeerRegistry operations +// --------------------------------------------------------------------------- + +func TestNewPeerRegistry_Empty(t *testing.T) { + pr := NewPeerRegistry() + if pr == nil { + t.Fatal("NewPeerRegistry() returned nil") + } + peers := pr.AllPeers() + if len(peers) != 0 { + t.Errorf("new registry has %d peers, want 0", len(peers)) + } +} + +func TestPeerRegistry_AddPeer(t *testing.T) { + pr := NewPeerRegistry() + pr.AddPeer("localhost:8101") + + peer := pr.GetPeer("localhost:8101") + if peer == nil { + t.Fatal("GetPeer returned nil after AddPeer") + } + if peer.Trust != 0.5 { + t.Errorf("initial trust = %v, want 0.5", peer.Trust) + } + if peer.Addr != "localhost:8101" { + t.Errorf("addr = %q, want %q", peer.Addr, "localhost:8101") + } +} + +func TestPeerRegistry_AddPeer_Idempotent(t *testing.T) { + // Adding the same peer twice should not create duplicates. + pr := NewPeerRegistry() + pr.AddPeer("localhost:8101") + pr.AddPeer("localhost:8101") + + peers := pr.AllPeers() + if len(peers) != 1 { + t.Errorf("duplicate AddPeer created %d entries, want 1", len(peers)) + } +} + +func TestPeerRegistry_GetPeer_NotFound(t *testing.T) { + pr := NewPeerRegistry() + peer := pr.GetPeer("nonexistent:9999") + if peer != nil { + t.Error("GetPeer should return nil for unknown address") + } +} + +func TestPeerRegistry_GetByID_NotFound(t *testing.T) { + pr := NewPeerRegistry() + peer := pr.GetByID("unknown-id") + if peer != nil { + t.Error("GetByID should return nil for unknown node ID") + } +} + +func TestPeerRegistry_AllPeers_MultipleEntries(t *testing.T) { + pr := NewPeerRegistry() + pr.AddPeer("host1:8101") + pr.AddPeer("host2:8102") + pr.AddPeer("host3:8103") + + peers := pr.AllPeers() + if len(peers) != 3 { + t.Errorf("AllPeers() returned %d entries, want 3", len(peers)) + } +} + +// --------------------------------------------------------------------------- +// ProcessHeartbeat — trust EMA +// --------------------------------------------------------------------------- + +func TestProcessHeartbeat_LearnIdentity(t *testing.T) { + // First heartbeat from a peer should learn its identity. + pr := NewPeerRegistry() + pr.AddPeer("peer1:8100") + + id, _ := GenerateIdentity() + hb := &Heartbeat{ + NodeID: id.NodeID, + Seq: 1, + LastHash: "hash1", + TreeHash: "tree1", + } + + err := pr.ProcessHeartbeat("peer1:8100", hb, id.PublicKey) + if err != nil { + t.Fatalf("ProcessHeartbeat error: %v", err) + } + + peer := pr.GetPeer("peer1:8100") + if peer.NodeID != id.NodeID { + t.Errorf("NodeID not learned: got %q, want %q", peer.NodeID, id.NodeID) + } + + // Should also be findable by ID. + byID := pr.GetByID(id.NodeID) + if byID == nil { + t.Fatal("GetByID returned nil after learning identity") + } +} + +func TestProcessHeartbeat_ConsistentSequence_TrustIncreases(t *testing.T) { + // Consistent sequential heartbeats should increase trust toward 1.0. + pr := NewPeerRegistry() + pr.AddPeer("peer1:8100") + + id, _ := GenerateIdentity() + + // Send 10 consistent heartbeats. + for seq := int64(1); seq <= 10; seq++ { + hb := &Heartbeat{ + NodeID: id.NodeID, + Seq: seq, + LastHash: "hash", + TreeHash: "tree", + } + if err := pr.ProcessHeartbeat("peer1:8100", hb, id.PublicKey); err != nil { + t.Fatalf("ProcessHeartbeat(seq=%d) error: %v", seq, err) + } + } + + peer := pr.GetPeer("peer1:8100") + if peer.Trust < TrustThresholdTrusted { + t.Errorf("trust after 10 consistent heartbeats = %v, want >= %v", peer.Trust, TrustThresholdTrusted) + } + if TrustLevel(peer.Trust) != "trusted" { + t.Errorf("trust level = %q, want %q", TrustLevel(peer.Trust), "trusted") + } +} + +func TestProcessHeartbeat_SequenceDrift_TrustDecreases(t *testing.T) { + // A sequence gap should decrease trust. + pr := NewPeerRegistry() + pr.AddPeer("peer1:8100") + + id, _ := GenerateIdentity() + + // First heartbeat establishes seq=1. + hb1 := &Heartbeat{NodeID: id.NodeID, Seq: 1} + pr.ProcessHeartbeat("peer1:8100", hb1, id.PublicKey) + + // Skip to seq=5 (drift). + hb2 := &Heartbeat{NodeID: id.NodeID, Seq: 5} + pr.ProcessHeartbeat("peer1:8100", hb2, id.PublicKey) + + peer := pr.GetPeer("peer1:8100") + if peer.DriftCount != 1 { + t.Errorf("drift count = %d, want 1", peer.DriftCount) + } + // Trust should have decreased from the initial 0.5. + // After first consistent hb: 0.8*0.5 + 0.2*1.0 = 0.6 + // After drift: 0.8*0.6 + 0.2*0.0 = 0.48 + if peer.Trust >= 0.6 { + t.Errorf("trust after drift = %v, should have decreased from ~0.6", peer.Trust) + } +} + +func TestProcessHeartbeat_RejectedPeer_ReturnsError(t *testing.T) { + // A rejected peer's heartbeats should be refused. + pr := NewPeerRegistry() + pr.AddPeer("peer1:8100") + + // Manually mark as rejected. + peer := pr.GetPeer("peer1:8100") + peer.Rejected = true + + id, _ := GenerateIdentity() + hb := &Heartbeat{NodeID: id.NodeID, Seq: 1} + err := pr.ProcessHeartbeat("peer1:8100", hb, id.PublicKey) + if err == nil { + t.Error("expected error for rejected peer, got nil") + } +} + +// --------------------------------------------------------------------------- +// Identity Conflict Detection +// --------------------------------------------------------------------------- + +func TestProcessHeartbeat_IdentityConflict(t *testing.T) { + // Two different addresses claiming the same NodeID within the conflict window + // should be detected and both rejected. + pr := NewPeerRegistry() + pr.AddPeer("addr1:8100") + pr.AddPeer("addr2:8200") + + id, _ := GenerateIdentity() + + // First address learns identity. + hb1 := &Heartbeat{NodeID: id.NodeID, Seq: 1} + err := pr.ProcessHeartbeat("addr1:8100", hb1, id.PublicKey) + if err != nil { + t.Fatalf("first heartbeat error: %v", err) + } + + // Second address claims the same NodeID within the window. + hb2 := &Heartbeat{NodeID: id.NodeID, Seq: 1} + err = pr.ProcessHeartbeat("addr2:8200", hb2, id.PublicKey) + if err == nil { + t.Error("expected identity conflict error, got nil") + } + + // Both peers should be rejected. + peer1 := pr.GetPeer("addr1:8100") + peer2 := pr.GetPeer("addr2:8200") + + if !peer1.Rejected { + t.Error("original peer should be rejected after identity conflict") + } + if !peer2.Rejected { + t.Error("conflicting peer should be rejected after identity conflict") + } + if peer1.Trust != 0 { + t.Errorf("original peer trust = %v, want 0", peer1.Trust) + } + if peer2.Trust != 0 { + t.Errorf("conflicting peer trust = %v, want 0", peer2.Trust) + } +} + +func TestProcessHeartbeat_DriftCount_Resets_OnConsistent(t *testing.T) { + // After a drift, a subsequent consistent heartbeat should reset drift count. + pr := NewPeerRegistry() + pr.AddPeer("peer1:8100") + + id, _ := GenerateIdentity() + + // Seq 1. + pr.ProcessHeartbeat("peer1:8100", &Heartbeat{NodeID: id.NodeID, Seq: 1}, id.PublicKey) + + // Drift: seq 5. + pr.ProcessHeartbeat("peer1:8100", &Heartbeat{NodeID: id.NodeID, Seq: 5}, id.PublicKey) + peer := pr.GetPeer("peer1:8100") + if peer.DriftCount != 1 { + t.Errorf("drift count after gap = %d, want 1", peer.DriftCount) + } + + // Consistent: seq 6. + pr.ProcessHeartbeat("peer1:8100", &Heartbeat{NodeID: id.NodeID, Seq: 6}, id.PublicKey) + if peer.DriftCount != 0 { + t.Errorf("drift count after consistent = %d, want 0", peer.DriftCount) + } +} + +// --------------------------------------------------------------------------- +// Heartbeat signing/verification roundtrip +// --------------------------------------------------------------------------- + +func TestHeartbeatSignAndVerify_Roundtrip(t *testing.T) { + // Build a heartbeat the same way the heartbeat runner does, then verify it. + id, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity error: %v", err) + } + + pubDER, err := id.MarshalPublicKey() + if err != nil { + t.Fatalf("MarshalPublicKey error: %v", err) + } + + hb := &Heartbeat{ + NodeID: id.NodeID, + TreeHash: "treehash123", + Seq: 42, + LastHash: "lasthash456", + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + PublicKey: base64.StdEncoding.EncodeToString(pubDER), + } + + // Sign. + payload, err := json.Marshal(map[string]any{ + "node_id": hb.NodeID, + "tree_hash": hb.TreeHash, + "seq": hb.Seq, + "last_hash": hb.LastHash, + "timestamp": hb.Timestamp, + }) + if err != nil { + t.Fatalf("marshal payload error: %v", err) + } + + sig, err := id.Sign(payload) + if err != nil { + t.Fatalf("Sign error: %v", err) + } + hb.Signature = base64.StdEncoding.EncodeToString(sig) + + // Verify. + valid, pubKey, err := VerifyHeartbeat(hb) + if err != nil { + t.Fatalf("VerifyHeartbeat error: %v", err) + } + if !valid { + t.Error("VerifyHeartbeat returned false for a correctly signed heartbeat") + } + if pubKey == nil { + t.Error("VerifyHeartbeat returned nil public key") + } +} + +func TestVerifyHeartbeat_InvalidSignature(t *testing.T) { + // A heartbeat with a tampered signature should fail verification. + id, _ := GenerateIdentity() + pubDER, _ := id.MarshalPublicKey() + + hb := &Heartbeat{ + NodeID: id.NodeID, + TreeHash: "tree", + Seq: 1, + LastHash: "hash", + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + PublicKey: base64.StdEncoding.EncodeToString(pubDER), + } + + // Sign correctly. + payload, _ := json.Marshal(map[string]any{ + "node_id": hb.NodeID, + "tree_hash": hb.TreeHash, + "seq": hb.Seq, + "last_hash": hb.LastHash, + "timestamp": hb.Timestamp, + }) + sig, _ := id.Sign(payload) + + // Tamper with signature. + sig[0] ^= 0xFF + hb.Signature = base64.StdEncoding.EncodeToString(sig) + + valid, _, err := VerifyHeartbeat(hb) + if err != nil { + // Some tampered signatures may cause parse errors, which is also acceptable. + return + } + if valid { + t.Error("VerifyHeartbeat returned true for tampered signature") + } +} + +func TestVerifyHeartbeat_NodeIDMismatch(t *testing.T) { + // If the NodeID in the heartbeat doesn't match the public key, reject it. + id, _ := GenerateIdentity() + pubDER, _ := id.MarshalPublicKey() + + hb := &Heartbeat{ + NodeID: "wrong-node-id-not-matching-key", + TreeHash: "tree", + Seq: 1, + LastHash: "hash", + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + PublicKey: base64.StdEncoding.EncodeToString(pubDER), + } + + // Sign with valid key but wrong NodeID. + payload, _ := json.Marshal(map[string]any{ + "node_id": hb.NodeID, + "tree_hash": hb.TreeHash, + "seq": hb.Seq, + "last_hash": hb.LastHash, + "timestamp": hb.Timestamp, + }) + sig, _ := id.Sign(payload) + hb.Signature = base64.StdEncoding.EncodeToString(sig) + + _, _, err := VerifyHeartbeat(hb) + if err == nil { + t.Error("expected error for NodeID mismatch, got nil") + } +} + +func TestVerifyHeartbeat_InvalidPublicKeyBase64(t *testing.T) { + hb := &Heartbeat{ + NodeID: "somenode", + PublicKey: "not-valid-base64!!!", + Signature: "ditto", + } + + _, _, err := VerifyHeartbeat(hb) + if err == nil { + t.Error("expected error for invalid base64 public key") + } +} + +func TestVerifyHeartbeat_InvalidPublicKeyDER(t *testing.T) { + hb := &Heartbeat{ + NodeID: "somenode", + PublicKey: base64.StdEncoding.EncodeToString([]byte("garbage-not-der")), + Signature: base64.StdEncoding.EncodeToString([]byte("fakesig")), + } + + _, _, err := VerifyHeartbeat(hb) + if err == nil { + t.Error("expected error for invalid DER public key") + } +} + +// --------------------------------------------------------------------------- +// PeerSummary +// --------------------------------------------------------------------------- + +func TestPeerRegistry_Summarize(t *testing.T) { + pr := NewPeerRegistry() + pr.AddPeer("host1:8100") + + id, _ := GenerateIdentity() + hb := &Heartbeat{NodeID: id.NodeID, Seq: 1} + pr.ProcessHeartbeat("host1:8100", hb, id.PublicKey) + + summaries := pr.Summarize() + if len(summaries) != 1 { + t.Fatalf("Summarize() returned %d entries, want 1", len(summaries)) + } + + s := summaries[0] + if s.Addr != "host1:8100" { + t.Errorf("Addr = %q, want %q", s.Addr, "host1:8100") + } + if s.Seq != 1 { + t.Errorf("Seq = %d, want 1", s.Seq) + } + if s.TrustLevel == "" { + t.Error("TrustLevel should not be empty") + } + if s.NodeID == "" { + t.Error("NodeID should not be empty in summary") + } +} + +// --------------------------------------------------------------------------- +// EMA trust math +// --------------------------------------------------------------------------- + +func TestEMATrustMath_ConsistentConvergesToOne(t *testing.T) { + // After many consistent heartbeats, trust should approach 1.0. + trust := 0.5 + for i := 0; i < 50; i++ { + trust = EMADecay*trust + (1-EMADecay)*1.0 + } + if trust < 0.99 { + t.Errorf("trust after 50 consistent cycles = %v, want > 0.99", trust) + } +} + +func TestEMATrustMath_InconsistentConvergesToZero(t *testing.T) { + // After many inconsistent heartbeats, trust should approach 0.0. + trust := 0.5 + for i := 0; i < 50; i++ { + trust = EMADecay*trust + (1-EMADecay)*0.0 + } + if trust > 0.01 { + t.Errorf("trust after 50 inconsistent cycles = %v, want < 0.01", trust) + } +} + +// --------------------------------------------------------------------------- +// identityFromPubKey (helper used in VerifyHeartbeat) +// --------------------------------------------------------------------------- + +func TestIdentityFromPubKey_MatchesNodeID(t *testing.T) { + // The NodeID derived from a public key should match the identity's NodeID. + id, _ := GenerateIdentity() + + pubDER, _ := x509.MarshalPKIXPublicKey(id.PublicKey) + recovered, _ := PublicKeyFromDER(pubDER) + + derivedID, err := identityFromPubKey(recovered) + if err != nil { + t.Fatalf("identityFromPubKey error: %v", err) + } + + if derivedID != id.NodeID { + t.Errorf("derived ID = %s, want %s", derivedID, id.NodeID) + } +}