diff --git a/internal/nameutil/name_example_test.go b/internal/nameutil/name_example_test.go new file mode 100644 index 0000000..103e2e7 --- /dev/null +++ b/internal/nameutil/name_example_test.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package nameutil + +import ( + "fmt" +) + +func ExampleCanonicalize() { + got, _ := Canonicalize("Example.LTHN.") + fmt.Println(got) + // Output: example +} diff --git a/internal/nameutil/name_test.go b/internal/nameutil/name_test.go new file mode 100644 index 0000000..a209671 --- /dev/null +++ b/internal/nameutil/name_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package nameutil + +import "testing" + +func TestNameUtil_Function_Good(t *testing.T) { + got, ok := Canonicalize("Example.LTHN.") + if !ok { + t.Fatal("Canonicalize should accept canonical names") + } + + if got != "example" { + t.Fatalf("Canonicalize() = %q, want %q", got, "example") + } +} + +func TestNameUtil_Function_Bad(t *testing.T) { + if got, ok := Canonicalize(42); ok || got != "" { + t.Fatalf("Canonicalize() = %q, %v, want empty false", got, ok) + } +} + +func TestNameUtil_Function_Ugly(t *testing.T) { + got, ok := CatalogLabel([]byte("MiXeD")) + if !ok { + t.Fatal("CatalogLabel should accept raw byte labels") + } + + if got != "MiXeD" { + t.Fatalf("CatalogLabel() = %q, want %q", got, "MiXeD") + } +} + +func TestName_Function_Good(t *testing.T) { + got, ok := Canonicalize("Example.lthn.") + if !ok || got != "example" { + t.Fatalf("Canonicalize() = %q, %v, want %q, true", got, ok, "example") + } +} + +func TestName_Function_Bad(t *testing.T) { + if got, ok := Canonicalize(42); ok || got != "" { + t.Fatalf("Canonicalize() = %q, %v, want empty false", got, ok) + } +} + +func TestName_Function_Ugly(t *testing.T) { + got, ok := CatalogLabel([]byte("MiXeD")) + if !ok { + t.Fatal("CatalogLabel should accept raw byte labels") + } + + if got != "MiXeD" { + t.Fatalf("CatalogLabel() = %q, want %q", got, "MiXeD") + } +} diff --git a/lns.go b/lns.go index 261f407..a6ba3c2 100644 --- a/lns.go +++ b/lns.go @@ -1219,6 +1219,12 @@ type NameUndoEntry = primitives.NameUndoEntry // Claim mirrors the raw ownership-proof claim wrapper from pkg/primitives. type Claim = primitives.Claim +// InvItem mirrors the inventory item wrapper from pkg/primitives. +type InvItem = primitives.InvItem + +// InvType mirrors the inventory item type tag from pkg/primitives. +type InvType = primitives.InvType + // NameStateJSON mirrors the primitive JSON representation for a name state. type NameStateJSON = primitives.NameStateJSON @@ -1254,6 +1260,17 @@ const ( NameStateRevoked = primitives.NameStateRevoked ) +// Inventory item constants mirror pkg/primitives so callers can keep using the +// top-level lns package for common network wrappers. +const ( + InvTypeTX = primitives.InvTypeTX + InvTypeBlock = primitives.InvTypeBlock + InvTypeFilteredBlock = primitives.InvTypeFilteredBlock + InvTypeCompactBlock = primitives.InvTypeCompactBlock + InvTypeClaim = primitives.InvTypeClaim + InvTypeAirdrop = primitives.InvTypeAirdrop +) + // NewClaim constructs an empty claim wrapper. func NewClaim() *Claim { return primitives.NewClaim() @@ -1264,6 +1281,16 @@ func GetNewClaim() *Claim { return NewClaim() } +// NewInvItem constructs an inventory item wrapper. +func NewInvItem(t InvType, hash primitives.Hash) *InvItem { + return primitives.NewInvItem(t, hash) +} + +// GetNewInvItem is an alias for NewInvItem. +func GetNewInvItem(t InvType, hash primitives.Hash) *InvItem { + return NewInvItem(t, hash) +} + // NewOutpoint returns the null outpoint used by coinbase inputs and empty // name-state owner references. func NewOutpoint() Outpoint { diff --git a/lns_example_test.go b/lns_example_test.go new file mode 100644 index 0000000..e7cb021 --- /dev/null +++ b/lns_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package lns + +import "fmt" + +func ExampleGetServiceName() { + fmt.Println(GetServiceName()) + // Output: lns +} diff --git a/lns_test.go b/lns_test.go index e2dc9cb..9b02a6a 100644 --- a/lns_test.go +++ b/lns_test.go @@ -2172,3 +2172,38 @@ func TestServiceVerifyCovenants(t *testing.T) { t.Fatalf("GetVerifyCovenants returned %d for an invalid finalize address, want -1", got) } } + +func TestLns_Function_Good(t *testing.T) { + if got := GetServiceName(); got != ServiceName { + t.Fatalf("GetServiceName() = %q, want %q", got, ServiceName) + } + + if svc := GetNewService(nil); svc == nil { + t.Fatal("GetNewService should return a service") + } + + if svc := GetNewServiceWithOptions(nil); svc == nil { + t.Fatal("GetNewServiceWithOptions should return a service") + } +} + +func TestLns_Function_Bad(t *testing.T) { + if got := Register(nil); got.OK { + t.Fatalf("Register(nil) = %#v, want failure", got) + } + + if got := GetRegister(nil); got.OK { + t.Fatalf("GetRegister(nil) = %#v, want failure", got) + } +} + +func TestLns_Function_Ugly(t *testing.T) { + svc := NewServiceWithOptions(nil) + if svc == nil { + t.Fatal("NewServiceWithOptions(nil) should return a service") + } + + if svc.reservedCatalogOverride != nil || svc.lockedCatalogOverride != nil { + t.Fatalf("nil options should not populate catalog overrides: %#v", svc) + } +} diff --git a/pkg/covenant/airdrop_proof_example_test.go b/pkg/covenant/airdrop_proof_example_test.go new file mode 100644 index 0000000..8bd8836 --- /dev/null +++ b/pkg/covenant/airdrop_proof_example_test.go @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import "fmt" + +func ExampletestAirdropProof() { + proof := testAirdropProof() + fmt.Println(proof.isSane()) + + // Output: true +} diff --git a/pkg/covenant/airdrop_proof_test.go b/pkg/covenant/airdrop_proof_test.go new file mode 100644 index 0000000..4b5f53b --- /dev/null +++ b/pkg/covenant/airdrop_proof_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import ( + "encoding/binary" + "testing" +) + +func testAirdropProof() *airdropProof { + key := make([]byte, 1+1+1+2+8+1) + key[0] = airdropKeyAddress + key[1] = 1 + key[2] = 2 + key[3] = 0xaa + key[4] = 0xbb + binary.LittleEndian.PutUint64(key[5:13], 10) + key[13] = 0xff + + return &airdropProof{ + index: 0, + proof: nil, + subindex: 0, + subproof: nil, + key: key, + version: 1, + address: []byte{0x01, 0x02}, + fee: 1, + } +} + +func TestAirdropProof_Function_Good(t *testing.T) { + proof := testAirdropProof() + if !proof.isSane() { + t.Fatal("expected proof to be sane") + } + + key, ok := proof.getKey() + if !ok { + t.Fatal("expected proof key to decode") + } + + if key.value != 10 || key.version != 1 { + t.Fatalf("unexpected key decode: %#v", key) + } +} + +func TestAirdropProof_Function_Bad(t *testing.T) { + proof := testAirdropProof() + proof.version = 32 + + if proof.isSane() { + t.Fatal("expected invalid version to fail sanity checks") + } +} + +func TestAirdropProof_Function_Ugly(t *testing.T) { + proof := testAirdropProof() + proof.key = proof.key[:len(proof.key)-1] + + if proof.isSane() { + t.Fatal("expected truncated key to fail sanity checks") + } +} + diff --git a/pkg/covenant/blind_example_test.go b/pkg/covenant/blind_example_test.go new file mode 100644 index 0000000..0fcab9e --- /dev/null +++ b/pkg/covenant/blind_example_test.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import ( + "fmt" + + "dappco.re/go/lns/pkg/primitives" +) + +func ExampleGetBlind() { + _, err := GetBlind(1000, primitives.Hash{}) + fmt.Println(err == nil) + // Output: true +} diff --git a/pkg/covenant/blind_test.go b/pkg/covenant/blind_test.go index a6a4d62..7f323d4 100644 --- a/pkg/covenant/blind_test.go +++ b/pkg/covenant/blind_test.go @@ -52,3 +52,50 @@ func TestGetBlind(t *testing.T) { t.Fatalf("GetBlind returned %x, want %x", got, want) } } + +func TestBlind_Function_Good(t *testing.T) { + var nonce primitives.Hash + for i := range nonce { + nonce[i] = byte(i) + } + + got, err := Blind(0, nonce) + if err != nil { + t.Fatalf("Blind returned error: %v", err) + } + + want, err := GetBlind(0, nonce) + if err != nil { + t.Fatalf("GetBlind returned error: %v", err) + } + + if got != want { + t.Fatalf("Blind() = %x, want %x", got, want) + } +} + +func TestBlind_Function_Bad(t *testing.T) { + var nonce primitives.Hash + got, err := Blind(1, nonce) + if err != nil { + t.Fatalf("Blind returned error: %v", err) + } + + if got == (primitives.Hash{}) { + t.Fatal("Blind should return a non-zero commitment for non-zero value") + } +} + +func TestBlind_Function_Ugly(t *testing.T) { + var nonce primitives.Hash + nonce[0] = 1 + + got, err := Blind(0, nonce) + if err != nil { + t.Fatalf("Blind returned error: %v", err) + } + + if got == (primitives.Hash{}) { + t.Fatal("Blind should produce a digest for arbitrary nonces") + } +} diff --git a/pkg/covenant/covenant_example_test.go b/pkg/covenant/covenant_example_test.go new file mode 100644 index 0000000..a03f65e --- /dev/null +++ b/pkg/covenant/covenant_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import "fmt" + +func ExampleTypeName() { + fmt.Println(TypeName(TypeBid)) + // Output: BID +} diff --git a/pkg/covenant/covenant_test.go b/pkg/covenant/covenant_test.go index 9f0ac0c..46c61fe 100644 --- a/pkg/covenant/covenant_test.go +++ b/pkg/covenant/covenant_test.go @@ -42,3 +42,36 @@ func TestCovenantTypePredicates(t *testing.T) { } } +func TestCovenant_Function_Good(t *testing.T) { + if got := GetTypeName(TypeRegister); got != "REGISTER" { + t.Fatalf("GetTypeName(TypeRegister) = %q, want %q", got, "REGISTER") + } + + if got := GetTypes()["BID"]; got != TypeBid { + t.Fatalf("GetTypes()[BID] = %d, want %d", got, TypeBid) + } + + if got := TypeFinalize.String(); got != "FINALIZE" { + t.Fatalf("String() = %q, want %q", got, "FINALIZE") + } +} + +func TestCovenant_Function_Bad(t *testing.T) { + if got := TypeName(CovenantType(99)); got != "UNKNOWN" { + t.Fatalf("TypeName(99) = %q, want %q", got, "UNKNOWN") + } + + if TypeNone.IsName() || TypeNone.IsLinked() || !TypeNone.IsKnown() { + t.Fatal("TypeNone predicate invariants failed") + } +} + +func TestCovenant_Function_Ugly(t *testing.T) { + if got := GetTypesByVal()[TypeRevoke]; got != "REVOKE" { + t.Fatalf("GetTypesByVal()[TypeRevoke] = %q, want %q", got, "REVOKE") + } + + if !TypeReveal.IsLinked() || !TypeRevoke.IsLinked() { + t.Fatal("linked covenant boundary checks failed") + } +} diff --git a/pkg/covenant/locked_lookup_example_test.go b/pkg/covenant/locked_lookup_example_test.go new file mode 100644 index 0000000..935c919 --- /dev/null +++ b/pkg/covenant/locked_lookup_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import "fmt" + +func ExampleGetLockedName() { + item, _ := GetLockedName("NEC") + fmt.Println(item.Name) + // Output: nec +} diff --git a/pkg/covenant/locked_lookup_test.go b/pkg/covenant/locked_lookup_test.go index 3ed6fcd..8a7693d 100644 --- a/pkg/covenant/locked_lookup_test.go +++ b/pkg/covenant/locked_lookup_test.go @@ -353,3 +353,38 @@ func TestLockedCatalogGetByNameRejectsNonASCII(t *testing.T) { t.Fatal("GetByName should reject non-ASCII labels") } } + +func TestLockedLookup_Function_Good(t *testing.T) { + item, ok := GetLockedName("NEC") + if !ok { + t.Fatal("GetLockedName should find the locked reference entry") + } + + if item.Name != "nec" { + t.Fatalf("item.Name = %q, want %q", item.Name, "nec") + } + + if !HasLockedHash(item.Hash) { + t.Fatal("HasLockedHash should report the locked reference entry") + } +} + +func TestLockedLookup_Function_Bad(t *testing.T) { + if HasLockedName("does-not-exist") { + t.Fatal("unknown names should not be reported as locked") + } + + if _, ok := GetLockedName("does-not-exist"); ok { + t.Fatal("GetLockedName should return false for unknown names") + } +} + +func TestLockedLookup_Function_Ugly(t *testing.T) { + if !GetHasLockedName("nec.lthn") { + t.Fatal("GetHasLockedName should accept canonical .lthn names") + } + + if _, ok := GetLockedByBinary([]byte("NEC")); !ok { + t.Fatal("GetLockedByBinary should find the locked reference entry") + } +} diff --git a/pkg/covenant/name_example_test.go b/pkg/covenant/name_example_test.go new file mode 100644 index 0000000..3a8f85b --- /dev/null +++ b/pkg/covenant/name_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import "fmt" + +func ExampleVerifyString() { + fmt.Println(VerifyString("foo")) + // Output: true +} diff --git a/pkg/covenant/name_lookup_example_test.go b/pkg/covenant/name_lookup_example_test.go new file mode 100644 index 0000000..0037e6e --- /dev/null +++ b/pkg/covenant/name_lookup_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import "fmt" + +func ExampleasciiLowerName() { + got, _ := asciiLowerName("NEC.lthn.") + fmt.Println(got) + // Output: nec +} diff --git a/pkg/covenant/name_lookup_test.go b/pkg/covenant/name_lookup_test.go new file mode 100644 index 0000000..78632ee --- /dev/null +++ b/pkg/covenant/name_lookup_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import "testing" + +func TestNameLookup_Function_Good(t *testing.T) { + got, ok := asciiLowerName("NEC.lthn.") + if !ok { + t.Fatal("asciiLowerName should accept canonical labels") + } + + if got != "nec" { + t.Fatalf("asciiLowerName() = %q, want %q", got, "nec") + } +} + +func TestNameLookup_Function_Bad(t *testing.T) { + if got, ok := asciiLowerName(""); ok || got != "" { + t.Fatalf("asciiLowerName() = %q, %v, want empty false", got, ok) + } +} + +func TestNameLookup_Function_Ugly(t *testing.T) { + long := make([]byte, maxNameSize+1) + for i := range long { + long[i] = 'a' + } + + if got, ok := asciiLowerName(string(long)); ok || got != "" { + t.Fatalf("asciiLowerName() = %q, %v, want empty false", got, ok) + } +} + diff --git a/pkg/covenant/name_test.go b/pkg/covenant/name_test.go index e7451ec..38cb67e 100644 --- a/pkg/covenant/name_test.go +++ b/pkg/covenant/name_test.go @@ -363,3 +363,41 @@ func TestHashRejectsInvalidName(t *testing.T) { t.Fatal("HashName should reject unsupported input types") } } + +func TestName_Function_Good(t *testing.T) { + if !VerifyString("example-1") { + t.Fatal("VerifyString should accept a valid non-blacklisted name") + } + + if _, err := HashString("example-1"); err != nil { + t.Fatalf("HashString returned error: %v", err) + } +} + +func TestName_Function_Bad(t *testing.T) { + if VerifyString("Example") { + t.Fatal("VerifyString should reject uppercase labels") + } + + if _, err := HashString("Example"); err == nil { + t.Fatal("HashString should reject invalid names") + } + + if VerifyName(123) { + t.Fatal("VerifyName should reject unsupported input types") + } +} + +func TestName_Function_Ugly(t *testing.T) { + if VerifyBinary([]byte("abc-")) { + t.Fatal("VerifyBinary should reject trailing hyphens") + } + + if VerifyBinary([]byte("ab\x80")) { + t.Fatal("VerifyBinary should reject non-ASCII input") + } + + if _, ok := GetBlacklist()["example"]; !ok { + t.Fatal("GetBlacklist should expose the blacklist map") + } +} diff --git a/pkg/covenant/names_example_test.go b/pkg/covenant/names_example_test.go new file mode 100644 index 0000000..cd8b959 --- /dev/null +++ b/pkg/covenant/names_example_test.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import ( + "fmt" + + "dappco.re/go/lns/pkg/primitives" +) + +func ExampleHasNames() { + hash := testNameHash() + tx := testNameTx(TypeOpen, hash) + fmt.Println(HasNames(tx, map[primitives.Hash]struct{}{hash: {}})) + // Output: true +} diff --git a/pkg/covenant/names_test.go b/pkg/covenant/names_test.go new file mode 100644 index 0000000..2f3ff9d --- /dev/null +++ b/pkg/covenant/names_test.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import ( + "testing" + + "dappco.re/go/lns/pkg/primitives" +) + +func testNameHash() primitives.Hash { + var hash primitives.Hash + hash[0] = 0x42 + return hash +} + +func testNameTx(covType CovenantType, hash primitives.Hash) primitives.Transaction { + return primitives.Transaction{ + Outputs: []primitives.Output{{ + Covenant: primitives.Covenant{ + Type: uint8(covType), + Items: [][]byte{hash[:]}, + }, + }}, + } +} + +func TestNames_Function_Good(t *testing.T) { + hash := testNameHash() + set := map[primitives.Hash]struct{}{hash: {}} + tx := testNameTx(TypeOpen, hash) + + if !HasNames(tx, set) { + t.Fatal("expected transaction to match the set") + } +} + +func TestNames_Function_Bad(t *testing.T) { + hash := testNameHash() + set := map[primitives.Hash]struct{}{hash: {}} + tx := testNameTx(TypeNone, hash) + + if HasNames(tx, set) { + t.Fatal("expected plain transfers to be ignored") + } +} + +func TestNames_Function_Ugly(t *testing.T) { + hash := testNameHash() + tx := testNameTx(TypeRenew, hash) + set := map[primitives.Hash]struct{}{} + + AddNames(tx, set) + if _, ok := set[hash]; !ok { + t.Fatal("AddNames should record name hashes") + } + + RemoveNames(tx, set) + if _, ok := set[hash]; ok { + t.Fatal("RemoveNames should delete name hashes") + } +} + diff --git a/pkg/covenant/reserved_lookup_example_test.go b/pkg/covenant/reserved_lookup_example_test.go new file mode 100644 index 0000000..d13b1f7 --- /dev/null +++ b/pkg/covenant/reserved_lookup_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import "fmt" + +func ExampleGetReservedName() { + item, _ := GetReservedName("RESERVED") + fmt.Println(item.Name) + // Output: reserved +} diff --git a/pkg/covenant/rules_example_test.go b/pkg/covenant/rules_example_test.go new file mode 100644 index 0000000..d2e75ee --- /dev/null +++ b/pkg/covenant/rules_example_test.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import ( + "fmt" + + "dappco.re/go/lns/pkg/primitives" +) + +func ExampleGetRollout() { + start, _ := GetRollout(primitives.Hash{}, NameRules{NoRollout: true}) + fmt.Println(start) + // Output: 0 +} diff --git a/pkg/covenant/rules_extra_example_test.go b/pkg/covenant/rules_extra_example_test.go new file mode 100644 index 0000000..dc88b7d --- /dev/null +++ b/pkg/covenant/rules_extra_example_test.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import ( + "fmt" + + "dappco.re/go/lns/pkg/primitives" +) + +func ExampleCountOpens() { + fmt.Println(CountOpens(primitives.Transaction{})) + // Output: 0 +} diff --git a/pkg/covenant/rules_extra_test.go b/pkg/covenant/rules_extra_test.go index 416b98f..a6ba7b4 100644 --- a/pkg/covenant/rules_extra_test.go +++ b/pkg/covenant/rules_extra_test.go @@ -781,3 +781,44 @@ func TestCoinbaseClaimConjureOverflow(t *testing.T) { t.Fatalf("VerifyCovenants returned %d for overflowing coinbase claim outputs, want -1", got) } } + +func TestRulesExtra_Function_Good(t *testing.T) { + tx := primitives.Transaction{ + Outputs: []primitives.Output{ + {Covenant: primitives.Covenant{Type: uint8(TypeOpen)}}, + {Covenant: primitives.Covenant{Type: uint8(TypeUpdate)}}, + }, + } + + if got := CountOpens(tx); got != 1 { + t.Fatalf("CountOpens() = %d, want 1", got) + } + + if got := GetCountUpdates(tx); got != 2 { + t.Fatalf("GetCountUpdates() = %d, want 2", got) + } +} + +func TestRulesExtra_Function_Bad(t *testing.T) { + if _, err := GrindName(0, 0, NameRules{}); err == nil { + t.Fatal("GrindName should reject zero-sized names") + } +} + +func TestRulesExtra_Function_Ugly(t *testing.T) { + tx := primitives.Transaction{ + Outputs: []primitives.Output{ + {Covenant: primitives.Covenant{Type: uint8(TypeClaim)}}, + {Covenant: primitives.Covenant{Type: uint8(TypeRenew)}}, + {Covenant: primitives.Covenant{Type: uint8(TypeFinalize)}}, + }, + } + + if got := CountRenewals(tx); got != 2 { + t.Fatalf("CountRenewals() = %d, want 2", got) + } + + if got := GetCountRenewals(tx); got != 2 { + t.Fatalf("GetCountRenewals() = %d, want 2", got) + } +} diff --git a/pkg/covenant/rules_test.go b/pkg/covenant/rules_test.go new file mode 100644 index 0000000..34e330f --- /dev/null +++ b/pkg/covenant/rules_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import ( + "testing" + + "dappco.re/go/lns/pkg/primitives" +) + +func TestRules_Function_Good(t *testing.T) { + rules := NameRules{NoRollout: true} + start, week := GetRollout(primitives.Hash{}, rules) + if start != 0 || week != 0 { + t.Fatalf("GetRollout() = (%d, %d), want zeros", start, week) + } +} + +func TestRules_Function_Bad(t *testing.T) { + rules := NameRules{NoReserved: true, ClaimPeriod: 100} + hash, err := HashString("reserved") + if err != nil { + t.Fatalf("HashString returned error: %v", err) + } + + if IsReserved(hash, 1, rules) { + t.Fatal("expected NoReserved to disable reserved checks") + } +} + +func TestRules_Function_Ugly(t *testing.T) { + hash, err := HashString("nec") + if err != nil { + t.Fatalf("HashString returned error: %v", err) + } + + rules := NameRules{ClaimPeriod: 1, AlexaLockupPeriod: 100} + if !IsLockedUp(hash, 1, rules) { + t.Fatal("expected locked catalog names to remain locked") + } +} + diff --git a/pkg/covenant/verify_example_test.go b/pkg/covenant/verify_example_test.go new file mode 100644 index 0000000..56b5b17 --- /dev/null +++ b/pkg/covenant/verify_example_test.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import ( + "fmt" + + "dappco.re/go/lns/pkg/primitives" +) + +func ExampleVerifyCovenants() { + var prev primitives.Hash + prev[0] = 1 + + tx := primitives.Transaction{ + Inputs: []primitives.Input{{Prevout: primitives.Outpoint{TxHash: prev, Index: 0}}}, + Outputs: []primitives.Output{{Covenant: primitives.Covenant{Type: uint8(TypeNone)}}}, + } + + view := verifyCoinView{output: primitives.Output{Covenant: primitives.Covenant{Type: uint8(TypeNone)}}} + fmt.Println(VerifyCovenants(tx, view, 0, Network{})) + // Output: 0 +} diff --git a/pkg/covenant/verify_test.go b/pkg/covenant/verify_test.go new file mode 100644 index 0000000..0512426 --- /dev/null +++ b/pkg/covenant/verify_test.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package covenant + +import ( + "testing" + + "dappco.re/go/lns/pkg/primitives" +) + +type verifyCoinView struct { + output primitives.Output +} + +func (v verifyCoinView) GetOutput(primitives.Outpoint) (primitives.Output, bool) { + return v.output, true +} + +func TestVerify_Function_Good(t *testing.T) { + var prev primitives.Hash + prev[0] = 1 + prevout := primitives.Outpoint{TxHash: prev, Index: 0} + + tx := primitives.Transaction{ + Inputs: []primitives.Input{{ + Prevout: prevout, + }}, + Outputs: []primitives.Output{{ + Covenant: primitives.Covenant{Type: uint8(TypeNone)}, + }}, + } + + view := verifyCoinView{output: primitives.Output{ + Covenant: primitives.Covenant{Type: uint8(TypeNone)}, + }} + + if got := VerifyCovenants(tx, view, 0, Network{}); got != 0 { + t.Fatalf("VerifyCovenants() = %d, want 0", got) + } +} + +func TestVerify_Function_Bad(t *testing.T) { + tx := primitives.Transaction{} + if got := VerifyCovenants(tx, nil, 0, Network{}); got != -1 { + t.Fatalf("VerifyCovenants() = %d, want -1", got) + } +} + +func TestVerify_Function_Ugly(t *testing.T) { + var prev primitives.Hash + prev[0] = 1 + prevout := primitives.Outpoint{TxHash: prev, Index: 0} + + tx := primitives.Transaction{ + Inputs: []primitives.Input{{ + Prevout: prevout, + }}, + Outputs: []primitives.Output{{ + Covenant: primitives.Covenant{Type: uint8(TypeNone)}, + }}, + } + + view := verifyCoinView{output: primitives.Output{ + Covenant: primitives.Covenant{Type: uint8(TypeBid)}, + }} + + if got := VerifyCovenants(tx, view, 0, Network{}); got != -1 { + t.Fatalf("VerifyCovenants() = %d, want -1", got) + } +} diff --git a/pkg/dns/common.go b/pkg/dns/common.go index 21e15ba..541cef1 100644 --- a/pkg/dns/common.go +++ b/pkg/dns/common.go @@ -90,6 +90,13 @@ var HSTypes = map[string]HSType{ "TXT": HSTypeTXT, } +// hsTypes mirrors the JS export name used by the DNS reference helpers. +// +// The Go package keeps the canonical HSTypes identifier as well, but the +// lowercase alias makes the reference shape available to same-package callers +// and test coverage. +var hsTypes = HSTypes + // HSTypesByVal mirrors the JS hsTypesByVal reverse lookup table. var HSTypesByVal = map[HSType]string{ HSTypeDS: "DS", @@ -101,6 +108,9 @@ var HSTypesByVal = map[HSType]string{ HSTypeTXT: "TXT", } +// hsTypesByVal mirrors the JS export name used by the DNS reference helpers. +var hsTypesByVal = HSTypesByVal + // GetHSTypes returns the DNS record-type lookup table. func GetHSTypes() map[string]HSType { return HSTypes diff --git a/pkg/dns/common_example_test.go b/pkg/dns/common_example_test.go new file mode 100644 index 0000000..6640b61 --- /dev/null +++ b/pkg/dns/common_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package dns + +import "fmt" + +func ExampleGetDefaultTTL() { + fmt.Println(GetDefaultTTL()) + // Output: 21600 +} diff --git a/pkg/dns/common_test.go b/pkg/dns/common_test.go index eb4793c..e6f4804 100644 --- a/pkg/dns/common_test.go +++ b/pkg/dns/common_test.go @@ -4,6 +4,7 @@ package dns import ( "bytes" + "reflect" "testing" ) @@ -77,6 +78,14 @@ func TestHSTypesTables(t *testing.T) { t.Fatal("GetHSTypesByVal should alias HSTypesByVal") } + if reflect.ValueOf(hsTypes).Pointer() != reflect.ValueOf(HSTypes).Pointer() { + t.Fatal("hsTypes should alias HSTypes") + } + + if reflect.ValueOf(hsTypesByVal).Pointer() != reflect.ValueOf(HSTypesByVal).Pointer() { + t.Fatal("hsTypesByVal should alias HSTypesByVal") + } + cases := []struct { name string got HSType @@ -109,3 +118,33 @@ func TestHSTypesTables(t *testing.T) { } } } + +func TestCommon_Function_Good(t *testing.T) { + if got := GetDefaultTTL(); got != DEFAULT_TTL { + t.Fatalf("GetDefaultTTL() = %d, want %d", got, DEFAULT_TTL) + } + + if len(GetDummy()) != 0 { + t.Fatalf("GetDummy() length = %d, want 0", len(GetDummy())) + } +} + +func TestCommon_Function_Bad(t *testing.T) { + if _, ok := GetHSTypes()["NOPE"]; ok { + t.Fatal("GetHSTypes should not contain unknown keys") + } + + if _, ok := GetHSTypesByVal()[HSType(99)]; ok { + t.Fatal("GetHSTypesByVal should not contain unknown values") + } +} + +func TestCommon_Function_Ugly(t *testing.T) { + if !bytes.Equal(GetTypeMapAAAA(), TYPE_MAP_AAAA) { + t.Fatal("GetTypeMapAAAA should alias TYPE_MAP_AAAA") + } + + if reflect.ValueOf(GetHSTypes()).Pointer() != reflect.ValueOf(HSTypes).Pointer() { + t.Fatal("GetHSTypes should alias HSTypes") + } +} diff --git a/pkg/dns/nsec.go b/pkg/dns/nsec.go index 3625910..f8df733 100644 --- a/pkg/dns/nsec.go +++ b/pkg/dns/nsec.go @@ -16,6 +16,26 @@ type NSECRecord struct { TTL int } +// GetName returns the owner name. +func (r NSECRecord) GetName() string { + return r.Name +} + +// GetNextDomain returns the canonical successor name. +func (r NSECRecord) GetNextDomain() string { + return r.NextDomain +} + +// GetTypeBitmap returns a copy of the NSEC type bitmap. +func (r NSECRecord) GetTypeBitmap() []byte { + return append([]byte(nil), r.TypeBitmap...) +} + +// GetTTL returns the record TTL. +func (r NSECRecord) GetTTL() int { + return r.TTL +} + // Create constructs a reference NSEC record container. // // name := Create(".", NextName("."), TYPE_MAP_ROOT) @@ -56,11 +76,12 @@ func GetNextName(tld string) string { // PrevName returns the canonical predecessor for a top-level domain name. // // The helper lowercases and trims any trailing dot before applying the -// reference ordering logic. +// reference ordering logic. Empty trimmed names are invalid, matching the +// reference helper's assertion behavior. func PrevName(tld string) string { tld = trimFQDN(strings.ToLower(tld)) if len(tld) == 0 { - return "." + panic("dns.PrevName: invalid top-level domain") } last := tld[len(tld)-1] - 1 diff --git a/pkg/dns/nsec_example_test.go b/pkg/dns/nsec_example_test.go new file mode 100644 index 0000000..e08a7e1 --- /dev/null +++ b/pkg/dns/nsec_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package dns + +import "fmt" + +func ExampleNextName() { + fmt.Printf("%q\n", NextName("example")) + // Output: "example\x00." +} diff --git a/pkg/dns/nsec_test.go b/pkg/dns/nsec_test.go index a789644..262ade1 100644 --- a/pkg/dns/nsec_test.go +++ b/pkg/dns/nsec_test.go @@ -33,10 +33,32 @@ func TestNSECRecordHelpers(t *testing.T) { t.Fatalf("Create bitmap = %x, want %x", rr.TypeBitmap, bitmap) } + if rr.GetName() != rr.Name { + t.Fatalf("GetName() = %q, want %q", rr.GetName(), rr.Name) + } + + if rr.GetNextDomain() != rr.NextDomain { + t.Fatalf("GetNextDomain() = %q, want %q", rr.GetNextDomain(), rr.NextDomain) + } + + if rr.GetTTL() != rr.TTL { + t.Fatalf("GetTTL() = %d, want %d", rr.GetTTL(), rr.TTL) + } + + if !bytes.Equal(rr.GetTypeBitmap(), rr.TypeBitmap) { + t.Fatalf("GetTypeBitmap() = %x, want %x", rr.GetTypeBitmap(), rr.TypeBitmap) + } + bitmap[0] = 0xff if rr.TypeBitmap[0] != 0x01 { t.Fatal("Create should copy the type bitmap") } + + clone := rr.GetTypeBitmap() + clone[0] = 0xff + if rr.TypeBitmap[0] != 0x01 { + t.Fatal("GetTypeBitmap should return a copy") + } } func TestNextName(t *testing.T) { @@ -70,7 +92,6 @@ func TestPrevName(t *testing.T) { }{ {name: "fqdn", in: "Foo-Bar.", want: "foo-baq\xff."}, {name: "label", in: "example", want: "exampld\xff."}, - {name: "root", in: ".", want: "."}, } for _, tc := range cases { @@ -83,3 +104,44 @@ func TestPrevName(t *testing.T) { } } } + +func TestPrevNameRejectsEmptyName(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("PrevName should panic for an empty trimmed name") + } + }() + + _ = PrevName(".") +} + +func TestNsec_Function_Good(t *testing.T) { + rr := GetCreate("Example", NextName("Example"), []byte{0x01, 0x02}) + if rr.Name != "Example." { + t.Fatalf("GetCreate name = %q, want %q", rr.Name, "Example.") + } + + if rr.TTL != DEFAULT_TTL { + t.Fatalf("GetCreate TTL = %d, want %d", rr.TTL, DEFAULT_TTL) + } +} + +func TestNsec_Function_Bad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("PrevName should panic for an empty trimmed name") + } + }() + + _ = PrevName(".") +} + +func TestNsec_Function_Ugly(t *testing.T) { + if got := GetNextName("Example"); got != "example\x00." { + t.Fatalf("GetNextName() = %q, want %q", got, "example\x00.") + } + + if got := GetPrevName("Example"); got != "exampld\xff." { + t.Fatalf("GetPrevName() = %q, want %q", got, "exampld\xff.") + } +} diff --git a/pkg/dns/resolve_example_test.go b/pkg/dns/resolve_example_test.go new file mode 100644 index 0000000..48dcb8c --- /dev/null +++ b/pkg/dns/resolve_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package dns + +import "fmt" + +func ExampleVerifyString() { + fmt.Println(VerifyString("Foo-Bar.lthn")) + // Output: true +} diff --git a/pkg/dns/resolve_test.go b/pkg/dns/resolve_test.go index 2b88ce4..89116ba 100644 --- a/pkg/dns/resolve_test.go +++ b/pkg/dns/resolve_test.go @@ -560,3 +560,31 @@ func TestPackageLevelResolveAndVerifyAliases(t *testing.T) { } } } + +func TestResolve_Function_Good(t *testing.T) { + got, err := Resolve("Foo-Bar.lthn") + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + + want := sha3.Sum256([]byte("foo-bar")) + if got != want { + t.Fatalf("Resolve() = %x, want %x", got, want) + } +} + +func TestResolve_Function_Bad(t *testing.T) { + if _, err := Resolve(123); err == nil { + t.Fatal("Resolve should reject unsupported input types") + } +} + +func TestResolve_Function_Ugly(t *testing.T) { + if !VerifyByString("Foo-Bar.lthn") { + t.Fatal("VerifyByString should accept canonical names after normalization") + } + + if !GetVerifyBinary([]byte("Foo-Bar.lthn")) { + t.Fatal("GetVerifyBinary should accept canonical byte names") + } +} diff --git a/pkg/dns/resource.go b/pkg/dns/resource.go index 2876a18..449d018 100644 --- a/pkg/dns/resource.go +++ b/pkg/dns/resource.go @@ -30,6 +30,24 @@ type Resource struct { Records []ResourceRecord } +// GetTTL returns the resource TTL. +func (r *Resource) GetTTL() int { + if r == nil { + return 0 + } + + return r.TTL +} + +// GetRecords returns the resource records. +func (r *Resource) GetRecords() []ResourceRecord { + if r == nil { + return nil + } + + return append([]ResourceRecord(nil), r.Records...) +} + // ResourceJSON represents the JSON form of a DNS resource. type ResourceJSON struct { Records []json.RawMessage `json:"records"` @@ -164,6 +182,20 @@ func (DSRecord) Type() HSType { return HSTypeDS } // GetType is an alias for Type. func (r DSRecord) GetType() HSType { return r.Type() } +// GetKeyTag returns the DS key tag. +func (r DSRecord) GetKeyTag() uint16 { return r.KeyTag } + +// GetAlgorithm returns the DS algorithm. +func (r DSRecord) GetAlgorithm() uint8 { return r.Algorithm } + +// GetDigestType returns the DS digest type. +func (r DSRecord) GetDigestType() uint8 { return r.DigestType } + +// GetDigest returns a copy of the DS digest. +func (r DSRecord) GetDigest() []byte { + return append([]byte(nil), r.Digest...) +} + // Type returns the DNS record type. func (NSRecord) Type() HSType { return HSTypeNS } @@ -179,6 +211,9 @@ func (GLUE4Record) Type() HSType { return HSTypeGLUE4 } // GetType is an alias for Type. func (r GLUE4Record) GetType() HSType { return r.Type() } +// GetAddress returns the IPv4 glue address. +func (r GLUE4Record) GetAddress() netip.Addr { return r.Address } + // GetNS is an alias for the NS field accessor. func (r GLUE4Record) GetNS() string { return r.NS } @@ -188,6 +223,9 @@ func (GLUE6Record) Type() HSType { return HSTypeGLUE6 } // GetType is an alias for Type. func (r GLUE6Record) GetType() HSType { return r.Type() } +// GetAddress returns the IPv6 glue address. +func (r GLUE6Record) GetAddress() netip.Addr { return r.Address } + // GetNS is an alias for the NS field accessor. func (r GLUE6Record) GetNS() string { return r.NS } @@ -197,6 +235,9 @@ func (SYNTH4Record) Type() HSType { return HSTypeSYNTH4 } // GetType is an alias for Type. func (r SYNTH4Record) GetType() HSType { return r.Type() } +// GetAddress returns the synthesized IPv4 address. +func (r SYNTH4Record) GetAddress() netip.Addr { return r.Address } + // GetNS is an alias for NS. func (r SYNTH4Record) GetNS() string { return r.NS() } @@ -206,6 +247,9 @@ func (SYNTH6Record) Type() HSType { return HSTypeSYNTH6 } // GetType is an alias for Type. func (r SYNTH6Record) GetType() HSType { return r.Type() } +// GetAddress returns the synthesized IPv6 address. +func (r SYNTH6Record) GetAddress() netip.Addr { return r.Address } + // GetNS is an alias for NS. func (r SYNTH6Record) GetNS() string { return r.NS() } @@ -215,6 +259,11 @@ func (TXTRecord) Type() HSType { return HSTypeTXT } // GetType is an alias for Type. func (r TXTRecord) GetType() HSType { return r.Type() } +// GetEntries returns a copy of the TXT entries. +func (r TXTRecord) GetEntries() []string { + return append([]string(nil), r.Entries...) +} + // NS returns the synthesized target host for a SYNTH4 record. func (r SYNTH4Record) NS() string { if !r.Address.Is4() { @@ -443,9 +492,13 @@ func (r *Resource) GetToTXT(name string) []TXTRecord { // ToZone projects the resource into zone-order records. // -// Authority-like records are emitted first in original order, with duplicate NS -// targets collapsed. Glue records for in-zone hosts are appended last. -func (r *Resource) ToZone(name string) []ResourceRecord { +// Records are emitted in their original order, with duplicate NS targets +// collapsed. Glue records for in-zone hosts are appended last. +// +// The optional sign flag is accepted for API parity with the JS reference. The +// simplified Go DNS layer does not emit RRSIG records, so the flag currently +// preserves the unmodified RRset. +func (r *Resource) ToZone(name string, sign ...bool) []ResourceRecord { if r == nil { return nil } @@ -526,8 +579,8 @@ func (r *Resource) ToZone(name string) []ResourceRecord { } // GetToZone is an alias for ToZone. -func (r *Resource) GetToZone(name string) []ResourceRecord { - return r.ToZone(name) +func (r *Resource) GetToZone(name string, sign ...bool) []ResourceRecord { + return r.ToZone(name, sign...) } // ToReferral builds the referral/negative-answer message shape used by the JS @@ -574,8 +627,9 @@ func (r *Resource) GetToReferral(name string, typ HSType, isTLD bool) DNSMessage // ToDNS projects the resource into the JS reference DNS response shape. // // Subdomain lookups are routed to the appropriate TLD referral. TLD TXT -// lookups answer directly when the resource has no NS records; DS lookups and -// all other cases fall through to the referral/negative-answer logic. +// lookups answer directly when the resource has no NS records; DS lookups +// answer authoritatively when DS records are present. All other cases fall +// through to the referral/negative-answer logic. func (r *Resource) ToDNS(name string, typ HSType) DNSMessage { name = fqdn(strings.ToLower(name)) labels := strings.Split(strings.TrimSuffix(name, "."), ".") @@ -593,8 +647,10 @@ func (r *Resource) ToDNS(name string, typ HSType) DNSMessage { } } case HSTypeDS: - // DS TLD lookups intentionally fall through to the referral/negative - // proof path to match the JS reference helper. + msg.AA = true + for _, record := range r.ToDS(name) { + msg.Answer = append(msg.Answer, record) + } } if len(msg.Answer) == 0 && len(msg.Authority) == 0 { @@ -977,6 +1033,21 @@ type resourceRecordProbe struct { Type string `json:"type"` } +func requireJSONFields(raw json.RawMessage, fields ...string) error { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return err + } + + for _, field := range fields { + if _, ok := obj[field]; !ok { + return fmt.Errorf("missing required field %q", field) + } + } + + return nil +} + func resourceRecordFromJSON(raw json.RawMessage) (ResourceRecord, error) { var probe resourceRecordProbe if err := json.Unmarshal(raw, &probe); err != nil { @@ -985,6 +1056,9 @@ func resourceRecordFromJSON(raw json.RawMessage) (ResourceRecord, error) { switch probe.Type { case "DS": + if err := requireJSONFields(raw, "keyTag", "algorithm", "digestType", "digest"); err != nil { + return nil, core.E("dns.Resource.FromJSON", "invalid DS record", err) + } var jsonRecord DSRecordJSON if err := json.Unmarshal(raw, &jsonRecord); err != nil { return nil, core.E("dns.Resource.FromJSON", "invalid DS record", err) @@ -995,6 +1069,9 @@ func resourceRecordFromJSON(raw json.RawMessage) (ResourceRecord, error) { } return record, nil case "NS": + if err := requireJSONFields(raw, "ns"); err != nil { + return nil, core.E("dns.Resource.FromJSON", "invalid NS record", err) + } var jsonRecord NSRecordJSON if err := json.Unmarshal(raw, &jsonRecord); err != nil { return nil, core.E("dns.Resource.FromJSON", "invalid NS record", err) @@ -1005,6 +1082,9 @@ func resourceRecordFromJSON(raw json.RawMessage) (ResourceRecord, error) { } return record, nil case "GLUE4": + if err := requireJSONFields(raw, "ns", "address"); err != nil { + return nil, core.E("dns.Resource.FromJSON", "invalid GLUE4 record", err) + } var jsonRecord GLUE4RecordJSON if err := json.Unmarshal(raw, &jsonRecord); err != nil { return nil, core.E("dns.Resource.FromJSON", "invalid GLUE4 record", err) @@ -1015,6 +1095,9 @@ func resourceRecordFromJSON(raw json.RawMessage) (ResourceRecord, error) { } return record, nil case "GLUE6": + if err := requireJSONFields(raw, "ns", "address"); err != nil { + return nil, core.E("dns.Resource.FromJSON", "invalid GLUE6 record", err) + } var jsonRecord GLUE6RecordJSON if err := json.Unmarshal(raw, &jsonRecord); err != nil { return nil, core.E("dns.Resource.FromJSON", "invalid GLUE6 record", err) @@ -1025,6 +1108,9 @@ func resourceRecordFromJSON(raw json.RawMessage) (ResourceRecord, error) { } return record, nil case "SYNTH4": + if err := requireJSONFields(raw, "address"); err != nil { + return nil, core.E("dns.Resource.FromJSON", "invalid SYNTH4 record", err) + } var jsonRecord SYNTH4RecordJSON if err := json.Unmarshal(raw, &jsonRecord); err != nil { return nil, core.E("dns.Resource.FromJSON", "invalid SYNTH4 record", err) @@ -1035,6 +1121,9 @@ func resourceRecordFromJSON(raw json.RawMessage) (ResourceRecord, error) { } return record, nil case "SYNTH6": + if err := requireJSONFields(raw, "address"); err != nil { + return nil, core.E("dns.Resource.FromJSON", "invalid SYNTH6 record", err) + } var jsonRecord SYNTH6RecordJSON if err := json.Unmarshal(raw, &jsonRecord); err != nil { return nil, core.E("dns.Resource.FromJSON", "invalid SYNTH6 record", err) @@ -1045,6 +1134,9 @@ func resourceRecordFromJSON(raw json.RawMessage) (ResourceRecord, error) { } return record, nil case "TXT": + if err := requireJSONFields(raw, "txt"); err != nil { + return nil, core.E("dns.Resource.FromJSON", "invalid TXT record", err) + } var jsonRecord TXTRecordJSON if err := json.Unmarshal(raw, &jsonRecord); err != nil { return nil, core.E("dns.Resource.FromJSON", "invalid TXT record", err) @@ -1080,6 +1172,9 @@ func (r *DSRecord) FromJSON(jsonView DSRecordJSON) error { if err != nil { return core.E("dns.DSRecord.FromJSON", "invalid digest encoding", err) } + if len(raw) > 255 { + return core.E("dns.DSRecord.FromJSON", "digest exceeds maximum size", nil) + } r.KeyTag = jsonView.KeyTag r.Algorithm = jsonView.Algorithm @@ -1101,6 +1196,9 @@ func (r *NSRecord) FromJSON(jsonView NSRecordJSON) error { if jsonView.Type != "NS" { return core.E("dns.NSRecord.FromJSON", "invalid NS record type", nil) } + if err := validateDNSName(jsonView.NS); err != nil { + return core.E("dns.NSRecord.FromJSON", "invalid ns name", err) + } r.NS = jsonView.NS return nil @@ -1120,6 +1218,9 @@ func (r *GLUE4Record) FromJSON(jsonView GLUE4RecordJSON) error { if jsonView.Type != "GLUE4" { return core.E("dns.GLUE4Record.FromJSON", "invalid GLUE4 record type", nil) } + if err := validateDNSName(jsonView.NS); err != nil { + return core.E("dns.GLUE4Record.FromJSON", "invalid ns name", err) + } addr, err := netip.ParseAddr(jsonView.Address) if err != nil || !addr.Is4() { @@ -1145,6 +1246,9 @@ func (r *GLUE6Record) FromJSON(jsonView GLUE6RecordJSON) error { if jsonView.Type != "GLUE6" { return core.E("dns.GLUE6Record.FromJSON", "invalid GLUE6 record type", nil) } + if err := validateDNSName(jsonView.NS); err != nil { + return core.E("dns.GLUE6Record.FromJSON", "invalid ns name", err) + } addr, err := netip.ParseAddr(jsonView.Address) if err != nil || !addr.Is6() { @@ -1215,11 +1319,27 @@ func (r *TXTRecord) FromJSON(jsonView TXTRecordJSON) error { if jsonView.Type != "TXT" { return core.E("dns.TXTRecord.FromJSON", "invalid TXT record type", nil) } + for _, entry := range jsonView.Entries { + if len(entry) > 255 { + return core.E("dns.TXTRecord.FromJSON", "txt entry exceeds maximum size", nil) + } + } r.Entries = append(r.Entries[:0], jsonView.Entries...) return nil } +func validateDNSName(name string) error { + var buf bytes.Buffer + if err := writeName(&buf, name); err != nil { + return err + } + if buf.Len() > 255 { + return errors.New("dns name exceeds maximum size") + } + return nil +} + func encodeRecord(buf *bytes.Buffer, record ResourceRecord) error { switch rr := record.(type) { case DSRecord: diff --git a/pkg/dns/resource_example_test.go b/pkg/dns/resource_example_test.go new file mode 100644 index 0000000..c163e9f --- /dev/null +++ b/pkg/dns/resource_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package dns + +import "fmt" + +func ExampleNewResource() { + fmt.Println(NewResource().HasNS()) + // Output: false +} diff --git a/pkg/dns/resource_test.go b/pkg/dns/resource_test.go index fbb435c..bb838c4 100644 --- a/pkg/dns/resource_test.go +++ b/pkg/dns/resource_test.go @@ -4,6 +4,8 @@ package dns import ( "bytes" + "encoding/json" + "reflect" "strings" "testing" @@ -11,6 +13,90 @@ import ( "net/netip" ) +func TestResourceFromJSONRejectsInvalidRecords(t *testing.T) { + tooLongDigest := strings.Repeat("aa", 256) + tooLongTXT := strings.Repeat("a", 256) + tooLongLabel := strings.Repeat("a", covenant.MaxNameSize+1) + ".example." + tooLongName := strings.Repeat("a.", 128) + + tests := []struct { + name string + raw string + }{ + { + name: "oversized ds digest", + raw: `{"type":"DS","keyTag":1,"algorithm":2,"digestType":3,"digest":"` + tooLongDigest + `"}`, + }, + { + name: "invalid ns label", + raw: `{"type":"NS","ns":"` + tooLongLabel + `"}`, + }, + { + name: "oversized ns name", + raw: `{"type":"GLUE4","ns":"` + tooLongName + `","address":"192.0.2.1"}`, + }, + { + name: "oversized txt entry", + raw: `{"type":"TXT","txt":["` + tooLongTXT + `"]}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var resource Resource + if err := resource.FromJSON(ResourceJSON{Records: []json.RawMessage{json.RawMessage(tc.raw)}}); err == nil { + t.Fatal("FromJSON should reject invalid record JSON") + } + }) + } +} + +func TestResourceFromJSONRejectsMissingRequiredFields(t *testing.T) { + tests := []struct { + name string + raw string + }{ + { + name: "ds missing digest", + raw: `{"type":"DS","keyTag":1,"algorithm":2,"digestType":3}`, + }, + { + name: "ns missing host", + raw: `{"type":"NS"}`, + }, + { + name: "glue4 missing host", + raw: `{"type":"GLUE4","address":"192.0.2.1"}`, + }, + { + name: "glue6 missing address", + raw: `{"type":"GLUE6","ns":"ns1.example."}`, + }, + { + name: "synth4 missing address", + raw: `{"type":"SYNTH4"}`, + }, + { + name: "synth6 missing address", + raw: `{"type":"SYNTH6"}`, + }, + { + name: "txt missing array", + raw: `{"type":"TXT"}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var resource Resource + err := resource.FromJSON(ResourceJSON{Records: []json.RawMessage{json.RawMessage(tc.raw)}}) + if err == nil { + t.Fatal("FromJSON should reject records with missing required fields") + } + }) + } +} + func TestResourceEncodeDecodeRoundTrip(t *testing.T) { resource := NewResource() resource.Records = []ResourceRecord{ @@ -248,6 +334,20 @@ func TestResourceTypeHelpers(t *testing.T) { if !resource.HasDS() || !resource.GetHasDS() { t.Fatal("HasDS should report DS records") } + + if got := resource.GetTTL(); got != DEFAULT_TTL { + t.Fatalf("GetTTL() = %d, want %d", got, DEFAULT_TTL) + } + + if got := resource.GetRecords(); len(got) != len(resource.Records) { + t.Fatalf("GetRecords() = %d records, want %d", len(got), len(resource.Records)) + } + + gotRecords := resource.GetRecords() + gotRecords[0] = nil + if resource.Records[0] == nil { + t.Fatal("GetRecords should return a copy of the record slice") + } } func TestResourceRecordTypeAliases(t *testing.T) { @@ -342,6 +442,62 @@ func TestResourceRecordNSAliases(t *testing.T) { } } +func TestResourceRecordFieldAccessors(t *testing.T) { + ds := DSRecord{ + KeyTag: 11, + Algorithm: 12, + DigestType: 13, + Digest: []byte{0xaa, 0xbb}, + } + if ds.GetKeyTag() != ds.KeyTag { + t.Fatalf("GetKeyTag() = %d, want %d", ds.GetKeyTag(), ds.KeyTag) + } + if ds.GetAlgorithm() != ds.Algorithm { + t.Fatalf("GetAlgorithm() = %d, want %d", ds.GetAlgorithm(), ds.Algorithm) + } + if ds.GetDigestType() != ds.DigestType { + t.Fatalf("GetDigestType() = %d, want %d", ds.GetDigestType(), ds.DigestType) + } + if got := ds.GetDigest(); !bytes.Equal(got, ds.Digest) { + t.Fatalf("GetDigest() = %x, want %x", got, ds.Digest) + } + gotDigest := ds.GetDigest() + gotDigest[0] = 0xff + if ds.Digest[0] != 0xaa { + t.Fatal("GetDigest should return a copy") + } + + glue4 := GLUE4Record{NS: "ns1.example.", Address: netip.MustParseAddr("192.0.2.1")} + if glue4.GetAddress().String() != glue4.Address.String() { + t.Fatalf("GLUE4 GetAddress() = %v, want %v", glue4.GetAddress(), glue4.Address) + } + + glue6 := GLUE6Record{NS: "ns2.example.", Address: netip.MustParseAddr("2001:db8::1")} + if glue6.GetAddress().String() != glue6.Address.String() { + t.Fatalf("GLUE6 GetAddress() = %v, want %v", glue6.GetAddress(), glue6.Address) + } + + synth4 := SYNTH4Record{Address: netip.MustParseAddr("198.51.100.9")} + if synth4.GetAddress().String() != synth4.Address.String() { + t.Fatalf("SYNTH4 GetAddress() = %v, want %v", synth4.GetAddress(), synth4.Address) + } + + synth6 := SYNTH6Record{Address: netip.MustParseAddr("2001:db8::9")} + if synth6.GetAddress().String() != synth6.Address.String() { + t.Fatalf("SYNTH6 GetAddress() = %v, want %v", synth6.GetAddress(), synth6.Address) + } + + txt := TXTRecord{Entries: []string{"hello", "world"}} + if got := txt.GetEntries(); !reflect.DeepEqual(got, txt.Entries) { + t.Fatalf("GetEntries() = %#v, want %#v", got, txt.Entries) + } + gotEntries := txt.GetEntries() + gotEntries[0] = "changed" + if txt.Entries[0] != "hello" { + t.Fatal("GetEntries should return a copy") + } +} + func TestResourceToNSEC(t *testing.T) { cases := []struct { name string @@ -481,6 +637,16 @@ func TestResourceProjectionHelpers(t *testing.T) { t.Fatalf("GetToZone returned %d records, want %d", len(aliasZone), len(zone)) } + signedZone := resource.ToZone("example.", true) + if len(signedZone) != len(zone) { + t.Fatalf("ToZone(..., true) returned %d records, want %d", len(signedZone), len(zone)) + } + + signedAliasZone := resource.GetToZone("example.", true) + if len(signedAliasZone) != len(zone) { + t.Fatalf("GetToZone(..., true) returned %d records, want %d", len(signedAliasZone), len(zone)) + } + var nilResource *Resource if got := nilResource.ToNS("example."); got != nil { t.Fatalf("nil ToNS = %#v, want nil", got) @@ -499,6 +665,37 @@ func TestResourceProjectionHelpers(t *testing.T) { } } +func TestResourceToZonePreservesOriginalOrder(t *testing.T) { + resource := NewResource() + resource.Records = []ResourceRecord{ + TXTRecord{Entries: []string{"first"}}, + DSRecord{KeyTag: 7, Algorithm: 8, DigestType: 9, Digest: []byte{0xaa}}, + NSRecord{NS: "ns1.example."}, + GLUE4Record{NS: "ns1.example.", Address: netip.MustParseAddr("192.0.2.10")}, + } + + zone := resource.ToZone("example.") + if len(zone) != 4 { + t.Fatalf("ToZone returned %d records, want 4", len(zone)) + } + + if rr, ok := zone[0].(TXTRecord); !ok || len(rr.Entries) != 1 || rr.Entries[0] != "first" { + t.Fatalf("ToZone[0] = %#v, want TXT first", zone[0]) + } + + if rr, ok := zone[1].(DSRecord); !ok || rr.KeyTag != 7 { + t.Fatalf("ToZone[1] = %#v, want DS record", zone[1]) + } + + if rr, ok := zone[2].(NSRecord); !ok || rr.NS != "ns1.example." { + t.Fatalf("ToZone[2] = %#v, want NS ns1.example.", zone[2]) + } + + if rr, ok := zone[3].(GLUE4Record); !ok || rr.NS != "ns1.example." || rr.Address.String() != "192.0.2.10" { + t.Fatalf("ToZone[3] = %#v, want GLUE4 ns1.example./192.0.2.10", zone[3]) + } +} + func TestResourceToDNSAndReferral(t *testing.T) { resource := NewResource() resource.Records = []ResourceRecord{ @@ -533,7 +730,10 @@ func TestResourceToDNSAndReferral(t *testing.T) { t.Fatal("GetToReferral should alias ToReferral") } - negative := resource.ToDNS("example.", HSTypeDS) + txtResource := NewResource() + txtResource.Records = []ResourceRecord{TXTRecord{Entries: []string{"hello"}}} + + negative := txtResource.ToDNS("example.", HSTypeDS) if !negative.AA { t.Fatal("ToDNS should set AA for a TLD DS negative proof") } @@ -547,9 +747,6 @@ func TestResourceToDNSAndReferral(t *testing.T) { t.Fatalf("ToDNS authority[0] = %#v, want NSEC example./example\\x00.", negative.Authority[0]) } - txtResource := NewResource() - txtResource.Records = []ResourceRecord{TXTRecord{Entries: []string{"hello"}}} - answer := txtResource.ToDNS("example.", HSTypeTXT) if !answer.AA { t.Fatal("ToDNS should set AA for an in-zone TXT answer") @@ -568,6 +765,25 @@ func TestResourceToDNSAndReferral(t *testing.T) { if aliasAnswer.AA != answer.AA || len(aliasAnswer.Answer) != len(answer.Answer) || len(aliasAnswer.Authority) != len(answer.Authority) || len(aliasAnswer.Additional) != len(answer.Additional) { t.Fatal("GetToDNS should alias ToDNS") } + + dsAnswer := resource.ToDNS("example.", HSTypeDS) + if !dsAnswer.AA { + t.Fatal("ToDNS should set AA for an in-zone DS answer") + } + if len(dsAnswer.Answer) != 1 { + t.Fatalf("ToDNS DS answer = %d, want 1", len(dsAnswer.Answer)) + } + if ds, ok := dsAnswer.Answer[0].(DSRecord); !ok || ds.KeyTag != 7 || ds.Algorithm != 8 || ds.DigestType != 9 || !bytes.Equal(ds.Digest, []byte{0xaa}) { + t.Fatalf("ToDNS DS answer[0] = %#v, want DS keyTag=7", dsAnswer.Answer[0]) + } + if len(dsAnswer.Authority) != 0 || len(dsAnswer.Additional) != 0 { + t.Fatalf("ToDNS DS answer should not populate authority/additional, got %+v", dsAnswer) + } + + aliasDSAnswer := resource.GetToDNS("example.", HSTypeDS) + if aliasDSAnswer.AA != dsAnswer.AA || len(aliasDSAnswer.Answer) != len(dsAnswer.Answer) || len(aliasDSAnswer.Authority) != len(dsAnswer.Authority) || len(aliasDSAnswer.Additional) != len(dsAnswer.Additional) { + t.Fatal("GetToDNS should alias ToDNS for DS answers") + } } func TestResourceDecodeStopsAtUnknownType(t *testing.T) { @@ -616,3 +832,42 @@ func TestResourceDecodeRejectsInvalidPayloads(t *testing.T) { t.Fatal("Decode should reject truncated TXT entries") } } + +func TestResource_Function_Good(t *testing.T) { + resource := NewResource() + if resource == nil { + t.Fatal("NewResource should return a resource") + } + + if resource.TTL != DEFAULT_TTL { + t.Fatalf("NewResource TTL = %d, want %d", resource.TTL, DEFAULT_TTL) + } +} + +func TestResource_Function_Bad(t *testing.T) { + if _, err := DecodeResource([]byte{1}); err == nil { + t.Fatal("DecodeResource should reject unsupported versions") + } + + var resource *Resource + if got := resource.GetSize(); got != 0 { + t.Fatalf("nil resource GetSize() = %d, want 0", got) + } +} + +func TestResource_Function_Ugly(t *testing.T) { + resource := &Resource{ + Records: []ResourceRecord{ + NSRecord{NS: "ns1.example."}, + TXTRecord{Entries: []string{"hello"}}, + }, + } + + if !resource.HasNS() || !resource.GetHasNS() { + t.Fatal("resource should report NS-capable records") + } + + if got := resource.ToNS("example"); len(got) != 1 || got[0].NS != "ns1.example." { + t.Fatalf("ToNS() = %#v, want one NS record", got) + } +} diff --git a/pkg/primitives/claim.go b/pkg/primitives/claim.go index 44fbc14..695e34e 100644 --- a/pkg/primitives/claim.go +++ b/pkg/primitives/claim.go @@ -94,6 +94,19 @@ func (c Claim) GetVirtualSize() int { return c.VirtualSize() } +// ToInv converts the claim blob into a claim inventory item. +func (c Claim) ToInv() InvItem { + return InvItem{ + Type: InvTypeClaim, + Hash: c.Hash(), + } +} + +// GetToInv is an alias for ToInv. +func (c Claim) GetToInv() InvItem { + return c.ToInv() +} + // MarshalBinary serializes the claim wrapper. func (c Claim) MarshalBinary() ([]byte, error) { if len(c.Blob) > maxClaimSize { diff --git a/pkg/primitives/claim_example_test.go b/pkg/primitives/claim_example_test.go new file mode 100644 index 0000000..fc7d244 --- /dev/null +++ b/pkg/primitives/claim_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleNewClaim() { + fmt.Println(NewClaim().GetSize()) + // Output: 2 +} diff --git a/pkg/primitives/claim_test.go b/pkg/primitives/claim_test.go index 9f595bf..1f478c3 100644 --- a/pkg/primitives/claim_test.go +++ b/pkg/primitives/claim_test.go @@ -52,6 +52,19 @@ func TestClaimHelpers(t *testing.T) { t.Fatalf("GetVirtualSize() = %d, want %d", got, claim.VirtualSize()) } + inv := claim.ToInv() + if inv.Type != InvTypeClaim { + t.Fatalf("ToInv().Type = %d, want %d", inv.Type, InvTypeClaim) + } + + if inv.Hash != wantHash { + t.Fatalf("ToInv().Hash = %x, want %x", inv.Hash, wantHash) + } + + if got := claim.GetToInv(); got != inv { + t.Fatalf("GetToInv() = %+v, want %+v", got, inv) + } + if got := claim.ToBlob(); string(got) != string(blob) { t.Fatalf("ToBlob() = %q, want %q", got, blob) } @@ -87,3 +100,37 @@ func TestClaimHelpers(t *testing.T) { t.Fatal("NewClaim helpers should return a claim wrapper") } } + +func TestClaim_Function_Good(t *testing.T) { + if NewClaim() == nil || GetNewClaim() == nil { + t.Fatal("NewClaim helpers should return a claim wrapper") + } +} + +func TestClaim_Function_Bad(t *testing.T) { + var claim *Claim + if claim.FromBlob([]byte("proof")) != nil { + t.Fatal("nil claim receiver should stay nil") + } + + if _, err := (Claim{Blob: make([]byte, maxClaimSize + 1)}).MarshalBinary(); err == nil { + t.Fatal("MarshalBinary should reject oversized claims") + } +} + +func TestClaim_Function_Ugly(t *testing.T) { + claim := Claim{Blob: []byte("ownership-proof")} + raw, err := claim.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + var decoded Claim + if err := decoded.UnmarshalBinary(raw); err != nil { + t.Fatalf("UnmarshalBinary returned error: %v", err) + } + + if got := decoded.GetBlob(); string(got) != "ownership-proof" { + t.Fatalf("decoded blob = %q, want %q", got, "ownership-proof") + } +} diff --git a/pkg/primitives/covenant_binary_example_test.go b/pkg/primitives/covenant_binary_example_test.go new file mode 100644 index 0000000..3859712 --- /dev/null +++ b/pkg/primitives/covenant_binary_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleCovenant_MarshalBinary() { + raw, _ := Covenant{}.MarshalBinary() + fmt.Println(len(raw)) + // Output: 2 +} diff --git a/pkg/primitives/covenant_binary_test.go b/pkg/primitives/covenant_binary_test.go index a7b5349..6286888 100644 --- a/pkg/primitives/covenant_binary_test.go +++ b/pkg/primitives/covenant_binary_test.go @@ -66,3 +66,49 @@ func TestCovenantBinaryZeroValue(t *testing.T) { t.Fatalf("decoded zero value = %+v, want zero value", decoded) } } + +func TestCovenantBinary_Function_Good(t *testing.T) { + cov := Covenant{} + cov.SetFinalize( + Hash{43, 44, 45}, + 188, + []byte("final"), + 12, + 21, + 34, + Hash{46, 47, 48}, + ) + + raw, err := cov.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + var decoded Covenant + if err := decoded.UnmarshalBinary(raw); err != nil { + t.Fatalf("UnmarshalBinary returned error: %v", err) + } + + if decoded.Type != cov.Type || decoded.Len() != cov.Len() { + t.Fatalf("decoded covenant = %+v, want %+v", decoded, cov) + } +} + +func TestCovenantBinary_Function_Bad(t *testing.T) { + var cov Covenant + if err := cov.UnmarshalBinary(nil); err == nil { + t.Fatal("UnmarshalBinary should reject short buffers") + } +} + +func TestCovenantBinary_Function_Ugly(t *testing.T) { + var cov Covenant + raw, err := cov.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + if len(raw) == 0 { + t.Fatal("MarshalBinary should produce a compact encoding") + } +} diff --git a/pkg/primitives/covenant_items_example_test.go b/pkg/primitives/covenant_items_example_test.go new file mode 100644 index 0000000..d02cfd7 --- /dev/null +++ b/pkg/primitives/covenant_items_example_test.go @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleCovenant_SetOpen() { + var cov Covenant + cov.SetOpen(Hash{}, []byte("example")) + fmt.Println(cov.Len()) + // Output: 3 +} diff --git a/pkg/primitives/covenant_items_test.go b/pkg/primitives/covenant_items_test.go index 91c6f06..b224966 100644 --- a/pkg/primitives/covenant_items_test.go +++ b/pkg/primitives/covenant_items_test.go @@ -643,3 +643,39 @@ func TestCovenantJSONRejectsInvalidEncoding(t *testing.T) { t.Fatal("FromJSON should reject invalid hex data") } } + +func TestCovenantItems_Function_Good(t *testing.T) { + var cov Covenant + cov.SetOpen(Hash{1, 2, 3}, []byte("example")) + + if cov.Len() != 3 { + t.Fatalf("SetOpen should populate three covenant items, got %d", cov.Len()) + } + + if got := cov.IndexOf([]byte("example")); got != 2 { + t.Fatalf("IndexOf() = %d, want 2", got) + } +} + +func TestCovenantItems_Function_Bad(t *testing.T) { + var cov Covenant + if _, err := cov.Get(0); err == nil { + t.Fatal("Get should reject empty covenants") + } + + if err := cov.Set(1, []byte("x")); err == nil { + t.Fatal("Set should reject out-of-range indexes") + } +} + +func TestCovenantItems_Function_Ugly(t *testing.T) { + cov := Covenant{Items: [][]byte{[]byte("a"), []byte("b")}} + + if item, err := cov.Get(-1); err != nil || string(item) != "b" { + t.Fatalf("Get(-1) = %q, %v, want %q, nil", item, err, "b") + } + + if got := cov.Clone(); got.Len() != cov.Len() { + t.Fatalf("Clone() length = %d, want %d", got.Len(), cov.Len()) + } +} diff --git a/pkg/primitives/covenant_json_example_test.go b/pkg/primitives/covenant_json_example_test.go new file mode 100644 index 0000000..c0934fc --- /dev/null +++ b/pkg/primitives/covenant_json_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleCovenant_GetJSON() { + json := Covenant{Type: covenantTypeOpen, Items: [][]byte{{0x01, 0x02}}}.GetJSON() + fmt.Println(json.Action) + // Output: OPEN +} diff --git a/pkg/primitives/covenant_json_test.go b/pkg/primitives/covenant_json_test.go new file mode 100644 index 0000000..e6fd9d5 --- /dev/null +++ b/pkg/primitives/covenant_json_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "testing" + +func TestCovenantJSON_Function_Good(t *testing.T) { + cov := Covenant{Type: covenantTypeOpen, Items: [][]byte{{0x01, 0x02}}} + json := cov.GetJSON() + if json.Action != "OPEN" || len(json.Items) != 1 || json.Items[0] != "0102" { + t.Fatalf("unexpected JSON: %#v", json) + } + + var got Covenant + if err := got.FromJSON(json); err != nil { + t.Fatalf("FromJSON returned error: %v", err) + } + + if got.Type != cov.Type || got.GetVarSize() != cov.GetVarSize() { + t.Fatalf("round trip mismatch: %#v", got) + } +} + +func TestCovenantJSON_Function_Bad(t *testing.T) { + var cov Covenant + if err := cov.FromJSON(CovenantJSON{Items: []string{"zz"}}); err == nil { + t.Fatal("expected invalid hex to fail") + } +} + +func TestCovenantJSON_Function_Ugly(t *testing.T) { + var cov Covenant + if err := cov.FromJSON(CovenantJSON{}); err != nil { + t.Fatalf("FromJSON returned error for empty JSON: %v", err) + } + + if cov.Len() != 0 { + t.Fatalf("expected empty covenant, got %#v", cov) + } +} + diff --git a/pkg/primitives/invitem.go b/pkg/primitives/invitem.go new file mode 100644 index 0000000..3234a81 --- /dev/null +++ b/pkg/primitives/invitem.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import ( + "encoding/binary" + "fmt" +) + +// InvType identifies a payload type in an inventory item. +type InvType uint32 + +const ( + // InvTypeTX identifies a transaction inventory item. + InvTypeTX InvType = 1 + + // InvTypeBlock identifies a block inventory item. + InvTypeBlock InvType = 2 + + // InvTypeFilteredBlock identifies a filtered block inventory item. + InvTypeFilteredBlock InvType = 3 + + // InvTypeCompactBlock identifies a compact block inventory item. + InvTypeCompactBlock InvType = 4 + + // InvTypeClaim identifies a claim inventory item. + InvTypeClaim InvType = 5 + + // InvTypeAirdrop identifies an airdrop proof inventory item. + InvTypeAirdrop InvType = 6 +) + +// InvItem mirrors the JS inventory wrapper used for network advertisements. +type InvItem struct { + Type InvType + Hash Hash +} + +// NewInvItem constructs an inventory item with the provided type and hash. +func NewInvItem(t InvType, hash Hash) *InvItem { + return &InvItem{Type: t, Hash: hash} +} + +// GetNewInvItem is an alias for NewInvItem. +func GetNewInvItem(t InvType, hash Hash) *InvItem { + return NewInvItem(t, hash) +} + +// GetSize returns the serialized size of the inventory item. +func (i InvItem) GetSize() int { + return 36 +} + +// MarshalBinary serializes the inventory item. +func (i InvItem) MarshalBinary() ([]byte, error) { + buf := make([]byte, i.GetSize()) + binary.LittleEndian.PutUint32(buf[:4], uint32(i.Type)) + copy(buf[4:], i.Hash[:]) + return buf, nil +} + +// UnmarshalBinary decodes the inventory item. +func (i *InvItem) UnmarshalBinary(data []byte) error { + if len(data) != i.GetSize() { + return fmt.Errorf("primitives.InvItem.UnmarshalBinary: invalid length") + } + + i.Type = InvType(binary.LittleEndian.Uint32(data[:4])) + copy(i.Hash[:], data[4:]) + return nil +} + +// IsBlock reports whether the inventory item represents a block. +func (i InvItem) IsBlock() bool { + switch i.Type { + case InvTypeBlock, InvTypeFilteredBlock, InvTypeCompactBlock: + return true + default: + return false + } +} + +// IsTX reports whether the inventory item represents a transaction. +func (i InvItem) IsTX() bool { + return i.Type == InvTypeTX +} + +// IsClaim reports whether the inventory item represents a claim. +func (i InvItem) IsClaim() bool { + return i.Type == InvTypeClaim +} + +// IsAirdrop reports whether the inventory item represents an airdrop proof. +func (i InvItem) IsAirdrop() bool { + return i.Type == InvTypeAirdrop +} diff --git a/pkg/primitives/invitem_example_test.go b/pkg/primitives/invitem_example_test.go new file mode 100644 index 0000000..129fc5a --- /dev/null +++ b/pkg/primitives/invitem_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleNewInvItem() { + fmt.Println(NewInvItem(InvTypeClaim, Hash{}).IsClaim()) + // Output: true +} diff --git a/pkg/primitives/invitem_test.go b/pkg/primitives/invitem_test.go new file mode 100644 index 0000000..e7d7e42 --- /dev/null +++ b/pkg/primitives/invitem_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import ( + "encoding/binary" + "testing" +) + +func TestInvItemHelpers(t *testing.T) { + var hash Hash + hash[0] = 0xaa + hash[31] = 0x55 + + item := NewInvItem(InvTypeClaim, hash) + if item == nil { + t.Fatal("NewInvItem should return a value") + } + + if got := item.GetSize(); got != 36 { + t.Fatalf("GetSize() = %d, want 36", got) + } + + if !item.IsClaim() || item.IsTX() || item.IsBlock() || item.IsAirdrop() { + t.Fatal("inventory type predicates should match the claim type") + } + + raw, err := item.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + if got := binary.LittleEndian.Uint32(raw[:4]); got != uint32(InvTypeClaim) { + t.Fatalf("MarshalBinary type = %d, want %d", got, InvTypeClaim) + } + + var decoded InvItem + if err := decoded.UnmarshalBinary(raw); err != nil { + t.Fatalf("UnmarshalBinary returned error: %v", err) + } + + if decoded.Type != item.Type || decoded.Hash != item.Hash { + t.Fatalf("decoded inventory item = %+v, want %+v", decoded, item) + } +} + +func TestInvItem_Function_Good(t *testing.T) { + item := NewInvItem(InvTypeClaim, Hash{}) + if item == nil { + t.Fatal("NewInvItem should return a value") + } + + if !item.IsClaim() || item.IsTX() || item.IsBlock() || item.IsAirdrop() { + t.Fatal("inventory type predicates should match the claim type") + } +} + +func TestInvItem_Function_Bad(t *testing.T) { + var item InvItem + if err := item.UnmarshalBinary([]byte{1, 2}); err == nil { + t.Fatal("UnmarshalBinary should reject invalid lengths") + } +} + +func TestInvItem_Function_Ugly(t *testing.T) { + item := InvItem{Type: InvTypeCompactBlock} + if !item.IsBlock() { + t.Fatal("compact block should count as a block") + } + + raw, err := item.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + if len(raw) != item.GetSize() { + t.Fatalf("MarshalBinary length = %d, want %d", len(raw), item.GetSize()) + } +} diff --git a/pkg/primitives/namedelta_example_test.go b/pkg/primitives/namedelta_example_test.go new file mode 100644 index 0000000..9b9ee65 --- /dev/null +++ b/pkg/primitives/namedelta_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleNameDelta_IsNull() { + fmt.Println(NameDelta{}.IsNull()) + // Output: true +} diff --git a/pkg/primitives/namedelta_test.go b/pkg/primitives/namedelta_test.go index 13dc090..7e3de65 100644 --- a/pkg/primitives/namedelta_test.go +++ b/pkg/primitives/namedelta_test.go @@ -172,3 +172,28 @@ func assertNameDeltaEqual(t *testing.T, got, want NameDelta) { assertBoolPtr("Expired", got.Expired, want.Expired) assertBoolPtr("Weak", got.Weak, want.Weak) } + +func TestNameDelta_Function_Good(t *testing.T) { + if got := (NameDelta{}).IsNull(); !got { + t.Fatal("zero delta should be null") + } +} + +func TestNameDelta_Function_Bad(t *testing.T) { + var delta NameDelta + if err := delta.UnmarshalBinary([]byte{1, 2, 3}); err == nil { + t.Fatal("UnmarshalBinary should reject short buffers") + } +} + +func TestNameDelta_Function_Ugly(t *testing.T) { + height := uint32(7) + delta := NameDelta{Height: &height} + if got := delta.GetField(); got != 1 { + t.Fatalf("GetField() = %d, want 1", got) + } + + if got := delta.GetSize(); got <= 4 { + t.Fatalf("GetSize() = %d, want greater than 4", got) + } +} diff --git a/pkg/primitives/namestate_binary_example_test.go b/pkg/primitives/namestate_binary_example_test.go new file mode 100644 index 0000000..0e2648d --- /dev/null +++ b/pkg/primitives/namestate_binary_example_test.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import ( + "fmt" +) + +func ExampleNameState_MarshalBinary() { + raw, _ := NameState{Name: []byte("example")}.MarshalBinary() + fmt.Println(len(raw) > 0) + // Output: true +} diff --git a/pkg/primitives/namestate_binary_test.go b/pkg/primitives/namestate_binary_test.go new file mode 100644 index 0000000..8980e39 --- /dev/null +++ b/pkg/primitives/namestate_binary_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import ( + "bytes" + "testing" +) + +func TestNameStateBinary_Function_Good(t *testing.T) { + ns := NameState{ + Name: []byte("example"), + Height: 10, + Renewal: 20, + Value: 30, + Highest: 40, + Data: []byte{0x01, 0x02}, + Transfer: 50, + Revoked: 60, + Claimed: 70, + Renewals: 80, + Registered: true, + Expired: true, + Weak: true, + } + + raw, err := ns.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + var got NameState + if err := got.UnmarshalBinary(raw); err != nil { + t.Fatalf("UnmarshalBinary returned error: %v", err) + } + + if !bytes.Equal(got.Name, ns.Name) || !bytes.Equal(got.Data, ns.Data) { + t.Fatalf("round trip mismatch: %#v", got) + } +} + +func TestNameStateBinary_Function_Bad(t *testing.T) { + ns := NameState{Name: bytes.Repeat([]byte("a"), 256)} + if _, err := ns.MarshalBinary(); err == nil { + t.Fatal("expected long name to fail") + } +} + +func TestNameStateBinary_Function_Ugly(t *testing.T) { + var ns NameState + if err := ns.UnmarshalBinary([]byte{0x01}); err == nil { + t.Fatal("expected short buffer to fail") + } +} + diff --git a/pkg/primitives/namestate_example_test.go b/pkg/primitives/namestate_example_test.go new file mode 100644 index 0000000..e8cddfc --- /dev/null +++ b/pkg/primitives/namestate_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleNameState_Clone() { + ns := NameState{Name: []byte("example")} + fmt.Println(string(ns.Clone().Name)) + // Output: example +} diff --git a/pkg/primitives/namestate_json_example_test.go b/pkg/primitives/namestate_json_example_test.go new file mode 100644 index 0000000..b5865b6 --- /dev/null +++ b/pkg/primitives/namestate_json_example_test.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleNameState_GetJSON() { + json := NameState{ + Name: []byte("example"), + NameHash: Hash{0x01}, + }.GetJSON(0, NameStateRules{}) + fmt.Println(json.Name) + // Output: example +} diff --git a/pkg/primitives/namestate_json_test.go b/pkg/primitives/namestate_json_test.go new file mode 100644 index 0000000..d6d6fb0 --- /dev/null +++ b/pkg/primitives/namestate_json_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "testing" + +func TestNameStateJSON_Function_Good(t *testing.T) { + ns := NameState{ + Name: []byte("example"), + NameHash: Hash{0x01}, + } + + json := ns.GetJSON(0, NameStateRules{}) + if json.Name != "example" || json.NameHash == "" { + t.Fatalf("unexpected JSON: %#v", json) + } + + var got NameState + if err := got.FromJSON(json); err != nil { + t.Fatalf("FromJSON returned error: %v", err) + } + + if string(got.Name) != "example" { + t.Fatalf("round trip mismatch: %#v", got) + } +} + +func TestNameStateJSON_Function_Bad(t *testing.T) { + var ns NameState + if err := ns.FromJSON(NameStateJSON{Name: "example", NameHash: "zz"}); err == nil { + t.Fatal("expected invalid hash encoding to fail") + } +} + +func TestNameStateJSON_Function_Ugly(t *testing.T) { + var ns NameState + json := ns.GetFormat(0, NameStateRules{}) + if json.State == "" { + t.Fatal("expected formatted JSON to include a state") + } +} + diff --git a/pkg/primitives/namestate_state_example_test.go b/pkg/primitives/namestate_state_example_test.go new file mode 100644 index 0000000..92fb31d --- /dev/null +++ b/pkg/primitives/namestate_state_example_test.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleNameState_IsOpening() { + ns := NameState{Height: 10} + fmt.Println(ns.IsOpening(10, NameStateRules{TreeInterval: 5})) + // Output: true +} diff --git a/pkg/primitives/namestate_state_test.go b/pkg/primitives/namestate_state_test.go index 0d13ca7..241a4fc 100644 --- a/pkg/primitives/namestate_state_test.go +++ b/pkg/primitives/namestate_state_test.go @@ -151,3 +151,26 @@ func TestNameStateClaimableAndExpired(t *testing.T) { t.Fatal("closed names without an owner should expire once the renewal window ends") } } + +func TestNameStateState_Function_Good(t *testing.T) { + ns := NameState{Height: 10} + rules := NameStateRules{TreeInterval: 1, BiddingPeriod: 1, RevealPeriod: 1} + + if got := ns.State(10, rules); got != NameStateOpening { + t.Fatalf("State() = %s, want %s", got, NameStateOpening) + } +} + +func TestNameStateState_Function_Bad(t *testing.T) { + var ns NameState + if ns.IsClaimable(0, NameStateRules{}) { + t.Fatal("zero state should not be claimable") + } +} + +func TestNameStateState_Function_Ugly(t *testing.T) { + ns := NameState{Revoked: 12} + if got := ns.State(13, NameStateRules{}); got != NameStateRevoked { + t.Fatalf("State() = %s, want %s", got, NameStateRevoked) + } +} diff --git a/pkg/primitives/namestate_stats_example_test.go b/pkg/primitives/namestate_stats_example_test.go new file mode 100644 index 0000000..b90a850 --- /dev/null +++ b/pkg/primitives/namestate_stats_example_test.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleNameState_ToStats() { + stats := NameState{ + Name: []byte("example"), + Height: 10, + }.ToStats(10, NameStateRules{TreeInterval: 5, BlocksPerDay: 24}) + fmt.Println(stats.BlocksUntilBidding) + // Output: 6 +} diff --git a/pkg/primitives/namestate_stats_test.go b/pkg/primitives/namestate_stats_test.go new file mode 100644 index 0000000..bbd2491 --- /dev/null +++ b/pkg/primitives/namestate_stats_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "testing" + +func TestNameStateStats_Function_Good(t *testing.T) { + ns := NameState{Name: []byte("example"), Height: 10} + stats := ns.ToStats(10, NameStateRules{TreeInterval: 5, BlocksPerDay: 24}) + if stats == nil || stats.BlocksUntilBidding != 6 || stats.OpenPeriodStart != 10 { + t.Fatalf("unexpected stats: %#v", stats) + } +} + +func TestNameStateStats_Function_Bad(t *testing.T) { + ns := NameState{Renewal: 1} + if stats := ns.ToStats(10, NameStateRules{TreeInterval: 0, RenewalWindow: 0}); stats != nil { + t.Fatalf("expected nil stats for expired name without owner, got %#v", stats) + } +} + +func TestNameStateStats_Function_Ugly(t *testing.T) { + var owner Outpoint + owner.TxHash[0] = 1 + + ns := NameState{ + Owner: owner, + Revoked: 10, + } + + stats := ns.ToStats(10, NameStateRules{TreeInterval: 0, AuctionMaturity: 5, BlocksPerDay: 48}) + if stats == nil || stats.RevokePeriodStart != 10 || stats.BlocksUntilReopen != 5 { + t.Fatalf("expected expired stats, got %#v", stats) + } +} diff --git a/pkg/primitives/namestate_test.go b/pkg/primitives/namestate_test.go index 13d6cda..7b30e0b 100644 --- a/pkg/primitives/namestate_test.go +++ b/pkg/primitives/namestate_test.go @@ -571,3 +571,25 @@ func TestNameStateBinaryZeroValue(t *testing.T) { t.Fatalf("decoded zero value = %+v, want zero value", decoded) } } + +func TestNameState_Function_Good(t *testing.T) { + var ns NameState + if ns.Delta() == nil || ns.GetDelta() == nil { + t.Fatal("Delta helpers should allocate a sparse delta") + } +} + +func TestNameState_Function_Bad(t *testing.T) { + ns := NameState{Name: []byte("foo"), Height: 1} + if got := ns.MaybeExpire(1, NameStateRules{}); got { + t.Fatal("MaybeExpire should stay false when the name is not expired") + } +} + +func TestNameState_Function_Ugly(t *testing.T) { + ns := NameState{Name: []byte("foo"), Height: 10} + clone := ns.Clone() + if clone.NameHash != ns.NameHash || !bytes.Equal(clone.Name, ns.Name) { + t.Fatalf("Clone() = %+v, want %+v", clone, ns) + } +} diff --git a/pkg/primitives/outpoint_example_test.go b/pkg/primitives/outpoint_example_test.go new file mode 100644 index 0000000..d22df68 --- /dev/null +++ b/pkg/primitives/outpoint_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleNewOutpoint() { + fmt.Println(NewOutpoint().IsNull()) + // Output: true +} diff --git a/pkg/primitives/outpoint_json_example_test.go b/pkg/primitives/outpoint_json_example_test.go new file mode 100644 index 0000000..d50f06f --- /dev/null +++ b/pkg/primitives/outpoint_json_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleOutpoint_GetJSON() { + fmt.Println(NewOutpoint().GetJSON().Index) + // Output: 4294967295 +} diff --git a/pkg/primitives/outpoint_json_test.go b/pkg/primitives/outpoint_json_test.go new file mode 100644 index 0000000..2991dd0 --- /dev/null +++ b/pkg/primitives/outpoint_json_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "testing" + +func TestOutpointJSON_Function_Good(t *testing.T) { + op := Outpoint{Index: 7} + op.TxHash[0] = 1 + + json := op.GetJSON() + var got Outpoint + if err := got.FromJSON(json); err != nil { + t.Fatalf("FromJSON returned error: %v", err) + } + + if !got.Equals(op) { + t.Fatalf("round trip mismatch: %#v", got) + } +} + +func TestOutpointJSON_Function_Bad(t *testing.T) { + var op Outpoint + if err := op.FromJSON(OutpointJSON{Hash: "zz"}); err == nil { + t.Fatal("expected invalid hash encoding to fail") + } +} + +func TestOutpointJSON_Function_Ugly(t *testing.T) { + var op Outpoint + if err := op.FromJSON(OutpointJSON{Hash: "00", Index: 1}); err == nil { + t.Fatal("expected short hash to fail") + } +} + diff --git a/pkg/primitives/outpoint_test.go b/pkg/primitives/outpoint_test.go new file mode 100644 index 0000000..92306d4 --- /dev/null +++ b/pkg/primitives/outpoint_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import ( + "bytes" + "testing" +) + +func TestOutpoint_Function_Good(t *testing.T) { + op := NewOutpoint() + if !op.IsNull() { + t.Fatal("NewOutpoint should return the null outpoint") + } + + raw, err := op.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary returned error: %v", err) + } + + var got Outpoint + if err := got.UnmarshalBinary(raw); err != nil { + t.Fatalf("UnmarshalBinary returned error: %v", err) + } + + if !got.Equals(op) { + t.Fatalf("round trip mismatch: %#v", got) + } +} + +func TestOutpoint_Function_Bad(t *testing.T) { + var op Outpoint + if err := op.UnmarshalBinary([]byte{0x01}); err == nil { + t.Fatal("expected invalid length to fail") + } +} + +func TestOutpoint_Function_Ugly(t *testing.T) { + var op Outpoint + op.TxHash[0] = 1 + clone := op.Clone() + if !bytes.Equal(clone.TxHash[:], op.TxHash[:]) || clone.Index != op.Index { + t.Fatalf("Clone() mismatch: %#v", clone) + } +} + diff --git a/pkg/primitives/types_example_test.go b/pkg/primitives/types_example_test.go new file mode 100644 index 0000000..ebdf446 --- /dev/null +++ b/pkg/primitives/types_example_test.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleCovenant_IsName() { + fmt.Println(Covenant{Type: covenantTypeOpen}.IsName()) + // Output: true +} diff --git a/pkg/primitives/types_test.go b/pkg/primitives/types_test.go index c42533d..c73dd31 100644 --- a/pkg/primitives/types_test.go +++ b/pkg/primitives/types_test.go @@ -367,3 +367,28 @@ func TestOutputIsUnspendable(t *testing.T) { t.Fatal("revoke covenant output should be unspendable") } } + +func TestTypes_Function_Good(t *testing.T) { + addr := Address{Version: 0, Hash: bytes.Repeat([]byte{0x01}, 20)} + if !addr.IsValid() || !addr.IsPubkeyHash() { + t.Fatalf("valid pubkey hash address should be accepted: %#v", addr) + } +} + +func TestTypes_Function_Bad(t *testing.T) { + addr := Address{Version: 32, Hash: bytes.Repeat([]byte{0x01}, 1)} + if addr.IsValid() { + t.Fatalf("invalid address should not be valid: %#v", addr) + } +} + +func TestTypes_Function_Ugly(t *testing.T) { + cov := Covenant{Type: covenantTypeRevoke} + if !cov.IsUnspendable() || !cov.IsRevoke() { + t.Fatalf("revoke covenant should be unspendable and revoke: %#v", cov) + } + + if !(Covenant{Type: covenantTypeOpen}).IsName() { + t.Fatal("open covenant should be a name covenant") + } +} diff --git a/pkg/primitives/view_example_test.go b/pkg/primitives/view_example_test.go new file mode 100644 index 0000000..8bff011 --- /dev/null +++ b/pkg/primitives/view_example_test.go @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package primitives + +import "fmt" + +func ExampleNewNameView() { + view := NewNameView() + ns, _ := view.GetNameStateSync(nil, Hash{}) + fmt.Println(ns.NameHash == (Hash{})) + // Output: true +} diff --git a/pkg/primitives/view_test.go b/pkg/primitives/view_test.go index b64c92a..49960ba 100644 --- a/pkg/primitives/view_test.go +++ b/pkg/primitives/view_test.go @@ -4,6 +4,7 @@ package primitives import ( "bytes" + "errors" "testing" ) @@ -154,3 +155,47 @@ func (d NameDelta) MarshalMust(t *testing.T) []byte { return data } + +func TestView_Function_Good(t *testing.T) { + view := NewNameView() + if view == nil { + t.Fatal("NewNameView should return a view") + } + + var hash Hash + ns, err := view.GetNameStateSync(nil, hash) + if err != nil { + t.Fatalf("GetNameStateSync returned error: %v", err) + } + + if ns == nil || ns.NameHash != hash { + t.Fatalf("GetNameStateSync returned %#v, want zero state keyed by hash", ns) + } +} + +func TestView_Function_Bad(t *testing.T) { + view := NewNameView() + wantErr := errors.New("db failure") + if _, err := view.GetNameStateSync(stubNameStateDB{err: wantErr}, Hash{}); err != wantErr { + t.Fatalf("GetNameStateSync should propagate db errors, got %v", err) + } +} + +func TestView_Function_Ugly(t *testing.T) { + var view NameView + firstHash := Hash{1} + secondHash := Hash{2} + + view.names = map[Hash]*NameState{ + firstHash: &NameState{NameHash: firstHash}, + secondHash: &NameState{NameHash: secondHash}, + } + view.order = []Hash{secondHash, firstHash} + view.names[firstHash].setHeight(1) + view.names[secondHash].setHeight(2) + + undo := view.ToNameUndo() + if len(undo.Names) != 2 { + t.Fatalf("ToNameUndo() = %d entries, want 2", len(undo.Names)) + } +}