package blockchain_test // Multi-device registry tests (v2.2.0). Cover happy-path link + list, // unlink idempotency, ownership validation, max-devices guard, and // backward-compat sender fall-back semantics (empty list for unknown // master). HTTP-layer tests live in the node package; these stay at // chain level — applyTx + state-shape only. import ( "crypto/rand" "encoding/hex" "strings" "sync/atomic" "testing" "time" "go-blockchain/blockchain" "go-blockchain/identity" ) // uniqueTxID sidesteps the `chain_test.go:txID` race where two helper // calls in the same nanosecond produce identical tx IDs → chain.AddBlock // dedupes the second as "already committed". We add a strictly-monotonic // counter so every tx built in the same test gets a fresh ID even on // fast machines. var devicesTxIDCounter atomic.Uint64 func uniqueTxID() string { n := devicesTxIDCounter.Add(1) h := make([]byte, 16) _, _ = rand.Read(h) return hex.EncodeToString(h) + ":" + time.Now().Format("150405.000000000") + ":" + itoa(n) } func itoa(n uint64) string { // tiny local conversion to avoid importing strconv just for tests. if n == 0 { return "0" } var buf [20]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } return string(buf[i:]) } // linkDeviceRawTx / unlinkDeviceRawTx build txs with guaranteed-unique // IDs so we can stuff many of them into a single block during tests. func linkDeviceRawTx(master *identity.Identity, x25519Pub, name string) *blockchain.Transaction { payload := mustJSON(blockchain.LinkDevicePayload{ X25519PubKey: x25519Pub, DeviceName: name, }) return &blockchain.Transaction{ ID: uniqueTxID(), Type: blockchain.EventLinkDevice, From: master.PubKeyHex(), Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } } func unlinkDeviceRawTx(master *identity.Identity, x25519Pub string) *blockchain.Transaction { payload := mustJSON(blockchain.UnlinkDevicePayload{X25519PubKey: x25519Pub}) return &blockchain.Transaction{ ID: uniqueTxID(), Type: blockchain.EventUnlinkDevice, From: master.PubKeyHex(), Fee: blockchain.MinFee, Payload: payload, Timestamp: time.Now().UTC(), } } // randX25519Pub returns a throw-away 32-byte hex string suitable for the // x25519_pub_key field. We don't need the matching private key — the // chain only stores the pub and doesn't verify it's a valid curve point. func randX25519Pub(t *testing.T) string { t.Helper() var b [32]byte if _, err := rand.Read(b[:]); err != nil { t.Fatalf("rand: %v", err) } return hex.EncodeToString(b[:]) } // fundIdentity credits enough balance to cover `nFees` worth of MinFee // payments. Every test needs this because LINK_DEVICE and UNLINK_DEVICE // debit tx.Fee from the master's balance — without funding the first tx // errors with "insufficient funds" and no state changes. func fundIdentity(t *testing.T, c *blockchain.Chain, val, recipient *identity.Identity, nFees int) { t.Helper() amount := uint64(nFees+2) * blockchain.MinFee tx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), recipient.PubKeyHex(), amount, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx})) } // TestLinkDeviceHappyPath: after a single LINK_DEVICE, DevicesOf returns // exactly one record with the right owner/name/pub. func TestLinkDeviceHappyPath(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) fundIdentity(t, c, val, alice, 1) dev1 := randX25519Pub(t) tx := linkDeviceRawTx(alice, dev1, "Alice's iPhone") b := buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}) mustAddBlock(t, c, b) devs, err := c.DevicesOf(alice.PubKeyHex()) if err != nil { t.Fatalf("DevicesOf: %v", err) } if len(devs) != 1 { t.Fatalf("expected 1 device, got %d", len(devs)) } got := devs[0] if got.Owner != alice.PubKeyHex() { t.Errorf("owner: got %s, want %s", got.Owner, alice.PubKeyHex()) } if got.X25519PubKey != dev1 { t.Errorf("x25519: got %s, want %s", got.X25519PubKey, dev1) } if got.DeviceName != "Alice's iPhone" { t.Errorf("name: got %q, want %q", got.DeviceName, "Alice's iPhone") } if got.RevokedAt != 0 { t.Errorf("freshly linked device should have RevokedAt=0, got %d", got.RevokedAt) } } // TestLinkDeviceMultiple: multiple devices for the same owner all show up // in DevicesOf, and the count matches. func TestLinkDeviceMultiple(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) fundIdentity(t, c, val, alice, 3) pubs := []string{randX25519Pub(t), randX25519Pub(t), randX25519Pub(t)} var txs []*blockchain.Transaction for i, p := range pubs { txs = append(txs, linkDeviceRawTx(alice, p, "device-"+string(rune('A'+i)))) } mustAddBlock(t, c, buildBlock(t, c.Tip(), val, txs)) devs, err := c.DevicesOf(alice.PubKeyHex()) if err != nil { t.Fatalf("DevicesOf: %v", err) } if len(devs) != 3 { t.Fatalf("expected 3 devices, got %d", len(devs)) } } // TestLinkDeviceRejectsForeignOwner: if Alice already linked a X25519 pub // and Bob tries to link the SAME pub to himself, the tx is rejected. // This protects against a malicious actor hijacking someone else's // device-pub and trying to claim it. func TestLinkDeviceRejectsForeignOwner(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) bob := newIdentity(t) fundIdentity(t, c, val, alice, 1) fundIdentity(t, c, val, bob, 1) dev := randX25519Pub(t) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")})) // Bob tries to claim the same pub. The chain silently drops invalid // txs (they're non-fatal for block commit), so we verify the *state*: // Alice still owns the pub, Bob has zero devices. bobTx := linkDeviceRawTx(bob, dev, "bob phone") mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{bobTx})) aliceDevs, _ := c.DevicesOf(alice.PubKeyHex()) if len(aliceDevs) != 1 || aliceDevs[0].DeviceName != "alice phone" { t.Fatalf("Alice should still own the device, got %+v", aliceDevs) } bobDevs, _ := c.DevicesOf(bob.PubKeyHex()) if len(bobDevs) != 0 { t.Fatalf("Bob's hijack should have been rejected, got %+v", bobDevs) } } // TestLinkDeviceSameOwnerRefresh: the same owner re-linking the same // x25519_pub is a rename/refresh (not an error), and the revoked flag // gets cleared if it was set. func TestLinkDeviceSameOwnerRefresh(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) fundIdentity(t, c, val, alice, 3) dev := randX25519Pub(t) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{linkDeviceRawTx(alice, dev, "old name")})) // Unlink then re-link — should restore the record. mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)})) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{linkDeviceRawTx(alice, dev, "new name")})) devs, err := c.DevicesOf(alice.PubKeyHex()) if err != nil { t.Fatalf("DevicesOf: %v", err) } if len(devs) != 1 { t.Fatalf("expected 1 active device after re-link, got %d", len(devs)) } if devs[0].DeviceName != "new name" { t.Errorf("expected refreshed name 'new name', got %q", devs[0].DeviceName) } } // TestUnlinkDeviceRemovesFromActive: after UNLINK_DEVICE, DevicesOf no // longer returns that record — senders stop fanning out to it. func TestUnlinkDeviceRemovesFromActive(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) fundIdentity(t, c, val, alice, 3) keep := randX25519Pub(t) revoke := randX25519Pub(t) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{ linkDeviceRawTx(alice, keep, "kept"), linkDeviceRawTx(alice, revoke, "revoked"), })) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{unlinkDeviceRawTx(alice, revoke)})) devs, err := c.DevicesOf(alice.PubKeyHex()) if err != nil { t.Fatalf("DevicesOf: %v", err) } if len(devs) != 1 || devs[0].X25519PubKey != keep { t.Fatalf("expected only the kept device, got %+v", devs) } } // TestUnlinkDeviceRejectsForeignSigner: Bob can't unlink Alice's device // even if he knows the X25519 pub — only the owner's signature (= tx.From) // authorises revoke. func TestUnlinkDeviceRejectsForeignSigner(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) bob := newIdentity(t) fundIdentity(t, c, val, alice, 1) fundIdentity(t, c, val, bob, 1) dev := randX25519Pub(t) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")})) // Bob tries to unlink Alice's device — tx is silently dropped, Alice // still has it in her active list. mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{unlinkDeviceRawTx(bob, dev)})) aliceDevs, _ := c.DevicesOf(alice.PubKeyHex()) if len(aliceDevs) != 1 || aliceDevs[0].X25519PubKey != dev { t.Fatalf("Alice's device should still be active, got %+v", aliceDevs) } } // TestUnlinkDeviceIdempotent: unlinking a device that's already revoked // does NOT error — this is needed for recovery scenarios where a client // retries the tx after a spotty connection. func TestUnlinkDeviceIdempotent(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) fundIdentity(t, c, val, alice, 3) dev := randX25519Pub(t) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{linkDeviceRawTx(alice, dev, "phone")})) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)})) // Second unlink of the same device must apply cleanly. mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)})) } // TestLinkDeviceRejectsMalformedPub: payload.X25519PubKey must be 64-char // lowercase hex. Garbage / short / uppercase values are rejected at // applyTx time before any state change. func TestLinkDeviceRejectsMalformedPub(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) fundIdentity(t, c, val, alice, 5) bad := []string{ "", // empty "abc", // too short strings.Repeat("x", 64), // non-hex strings.Repeat("A", 64), // uppercase } for _, p := range bad { tx := linkDeviceRawTx(alice, p, "dev") mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx})) } devs, _ := c.DevicesOf(alice.PubKeyHex()) if len(devs) != 0 { t.Fatalf("expected 0 devices after %d malformed link attempts, got %d", len(bad), len(devs)) } } // TestLinkDeviceRejectsBadDeviceName: empty, too long, or control-char // names are refused — this field is rendered verbatim in clients. func TestLinkDeviceRejectsBadDeviceName(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) fundIdentity(t, c, val, alice, 5) cases := map[string]string{ "empty": "", "too long": strings.Repeat("a", blockchain.MaxDeviceNameLen+1), "control char": "device\x01name", } for name, devName := range cases { t.Run(name, func(t *testing.T) { before, _ := c.DevicesOf(alice.PubKeyHex()) tx := linkDeviceRawTx(alice, randX25519Pub(t), devName) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx})) after, _ := c.DevicesOf(alice.PubKeyHex()) if len(after) != len(before) { t.Fatalf("bad %s name should not have been linked (before=%d, after=%d)", name, len(before), len(after)) } }) } } // TestLinkDeviceMaxDevices: after MaxDevicesPerOwner active devices, any // further LINK_DEVICE is rejected. Revoking one frees a slot. func TestLinkDeviceMaxDevices(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) // Enough for cap + one overflow attempt + one unlink + one replacement. fundIdentity(t, c, val, alice, blockchain.MaxDevicesPerOwner+4) // Fill to the cap — each in its own block so timestamps differ. pubs := make([]string, blockchain.MaxDevicesPerOwner) for i := 0; i < blockchain.MaxDevicesPerOwner; i++ { pubs[i] = randX25519Pub(t) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{linkDeviceRawTx(alice, pubs[i], "d")})) } // One more — the tx is dropped, cap stays exact. over := linkDeviceRawTx(alice, randX25519Pub(t), "overflow") mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{over})) if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner { t.Fatalf("overflow link should have been rejected, count=%d", len(devs)) } // Revoke one, then a fresh link succeeds. mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{unlinkDeviceRawTx(alice, pubs[0])})) mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{linkDeviceRawTx(alice, randX25519Pub(t), "replacement")})) if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner { t.Fatalf("after unlink+relink, should be %d active, got %d", blockchain.MaxDevicesPerOwner, len(devs)) } } // TestDevicesOfEmptyForUnknown: unknown master pubs return an empty list // (not an error) — this preserves the sender's ability to fall back to // IdentityInfo.X25519Pub for legacy identities. func TestDevicesOfEmptyForUnknown(t *testing.T) { c := newChain(t) val := newIdentity(t) addGenesis(t, c, val) alice := newIdentity(t) devs, err := c.DevicesOf(alice.PubKeyHex()) if err != nil { t.Fatalf("DevicesOf: %v", err) } if len(devs) != 0 { t.Fatalf("expected 0 devices for unknown master, got %d", len(devs)) } }