diff --git a/cmd/wallet/zz_more_test.go b/cmd/wallet/zz_more_test.go new file mode 100644 index 0000000..335db68 --- /dev/null +++ b/cmd/wallet/zz_more_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestOpenConns_ReturnsSentinel(t *testing.T) { + t.Parallel() + var wg sync.WaitGroup + if got := openConns(&wg); got != -1 { + t.Errorf("openConns = %d, want -1", got) + } +} + +func TestOpenStore_HappyPath(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "ledger.db") + store, err := openStore(path) + if err != nil { + t.Fatalf("openStore: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) +} + +func TestOpenStore_MkdirFails(t *testing.T) { + t.Parallel() + // Under /dev/null the parent mkdir fails. + if _, err := openStore("/dev/null/cannot/x/db.sqlite"); err == nil { + t.Error("expected mkdir error") + } +} + +func TestDefaultPath_IncludesAppsRoot(t *testing.T) { + t.Parallel() + got := defaultPath("test-wallet") + if !strings.Contains(got, "io.pilot.wallet") { + t.Errorf("path = %q, want io.pilot.wallet substring", got) + } + if !strings.HasSuffix(got, "test-wallet") { + t.Errorf("path = %q, want suffix test-wallet", got) + } +} diff --git a/pkg/evm/zz_accessor_test.go b/pkg/evm/zz_accessor_test.go new file mode 100644 index 0000000..9e12243 --- /dev/null +++ b/pkg/evm/zz_accessor_test.go @@ -0,0 +1,68 @@ +package evm + +import ( + "testing" +) + +// TestEVMMethod_AccessorsCoverIDChainTokenAddress hits the four tiny +// accessor functions on *EVMMethod that don't go through Satisfy/Verify +// and previously had 0% coverage. +func TestEVMMethod_AccessorsCoverIDChainTokenAddress(t *testing.T) { + t.Parallel() + s, err := NewEVMSigner() + if err != nil { + t.Fatalf("NewEVMSigner: %v", err) + } + m, err := NewEVMMethod(s, ChainBaseSepolia, nil) + if err != nil { + t.Fatalf("NewEVMMethod: %v", err) + } + if got := m.ID(); got != EVMMethodID { + t.Errorf("ID = %q, want %q", got, EVMMethodID) + } + if got := m.ChainID(); got != ChainBaseSepolia { + t.Errorf("ChainID = %d, want %d", got, ChainBaseSepolia) + } + zero := Address{} + if m.Token() == zero { + t.Error("Token is zero — expected canonical USDC default for chain") + } + if m.Address() == zero { + t.Error("Address is zero") + } +} + +// TestClient_EndpointReturnsConfigured covers the Endpoint accessor +// previously at 0%. +func TestClient_EndpointReturnsConfigured(t *testing.T) { + t.Parallel() + c := NewClient("https://example.invalid/rpc", nil) + if c.Endpoint() != "https://example.invalid/rpc" { + t.Errorf("Endpoint = %q", c.Endpoint()) + } +} + +// TestNewClient_NilHTTPClientGetsDefault covers the nil-client branch. +func TestNewClient_NilHTTPClientGetsDefault(t *testing.T) { + t.Parallel() + c := NewClient("https://example.invalid", nil) + if c.http == nil { + t.Error("http client should be non-nil after NewClient(_, nil)") + } +} + +// TestUint128_PacksHiLo covers the Uint128 helper used in EIP-3009 +// encoding — previously 0% because no test ever called it. +func TestUint128_PacksHiLo(t *testing.T) { + t.Parallel() + // hi=0, lo=1 → 1 + if got := Uint128(0, 1); got.Uint64() != 1 { + t.Errorf("Uint128(0,1) = %s, want 1", got.String()) + } + // hi=1, lo=0 → 2^64 + got := Uint128(1, 0) + want := "18446744073709551616" // 2^64 + if got.String() != want { + t.Errorf("Uint128(1,0) = %s, want %s", got.String(), want) + } +} diff --git a/pkg/evm/zz_eip55_test.go b/pkg/evm/zz_eip55_test.go new file mode 100644 index 0000000..3b191e2 --- /dev/null +++ b/pkg/evm/zz_eip55_test.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package evm + +// Regression for the typo-loss vector: ParseAddress accepts any 20-byte +// hex without checksum validation. A user copy-pasting an EVM address +// with a one-character typo (e.g., from a phishing site, or just human +// error) would have it silently accepted and lose funds on send. +// +// Fix: EIP-55 checksum validation. Mixed-case input MUST match the +// EIP-55 canonical form. All-lowercase and all-uppercase remain +// accepted (they convey no checksum information — wallets that don't +// emit checksummed addresses can still interoperate). + +import ( + "strings" + "testing" +) + +func TestParseAddressRejectsBadEIP55Checksum(t *testing.T) { + t.Parallel() + + // Vitalik's well-known address. The canonical EIP-55 form is: + // 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + // We tamper with ONE letter's case (the leading 'd' should be 'd', + // flip it to 'D') to produce an invalid checksum. Bytes are + // identical to the canonical form; only the case is wrong. + const canonical = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + tampered := "0x" + "D" + canonical[3:] + + // Canonical: must accept. + if _, err := ParseAddress(canonical); err != nil { + t.Fatalf("canonical EIP-55 address rejected: %v", err) + } + + // Tampered: must reject. + _, err := ParseAddress(tampered) + if err == nil { + t.Fatalf("tampered checksum accepted — typo-loss vector still open. Input: %q", tampered) + } + if !strings.Contains(err.Error(), "checksum") { + t.Errorf("expected error to mention 'checksum', got: %v", err) + } +} + +func TestParseAddressAcceptsAllLowerOrAllUpper(t *testing.T) { + t.Parallel() + // Strings with NO case mix carry no checksum signal — accept them. + // This preserves compatibility with wallets that don't emit + // checksummed addresses (older clients, some hardware wallets). + + cases := []string{ + "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", // all lower + "0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045", // all upper + } + for _, in := range cases { + if _, err := ParseAddress(in); err != nil { + t.Errorf("ParseAddress(%q) returned error: %v", in, err) + } + } +} + +func TestParseAddressNoPrefix(t *testing.T) { + t.Parallel() + // Bare hex (no 0x prefix) preserved for backwards-compat. Mixed + // case STILL gets checksum-checked if present. + + if _, err := ParseAddress("d8da6bf26964af9d7eed9e03e53415d37aa96045"); err != nil { + t.Errorf("bare-hex lowercase rejected: %v", err) + } +} diff --git a/pkg/evm/zz_more_test.go b/pkg/evm/zz_more_test.go new file mode 100644 index 0000000..dda437b --- /dev/null +++ b/pkg/evm/zz_more_test.go @@ -0,0 +1,50 @@ +package evm + +import ( + "errors" + "testing" +) + +func TestErrUnknownChain_ChainID(t *testing.T) { + t.Parallel() + err := errChainUnknown(31337) + if err.Error() == "" { + t.Error("error message empty") + } + var uc errUnknownChain + if !errors.As(err, &uc) { + t.Fatal("errors.As failed") + } + if uc.ChainID() != 31337 { + t.Errorf("ChainID = %d, want 31337", uc.ChainID()) + } +} + +func TestEVMSigner_PublicKey(t *testing.T) { + t.Parallel() + s, err := NewEVMSigner() + if err != nil { + t.Fatalf("NewEVMSigner: %v", err) + } + pub := s.PublicKey() + if len(pub) != 65 { + t.Errorf("PublicKey len = %d, want 65 (uncompressed)", len(pub)) + } + if pub[0] != 0x04 { + t.Errorf("PublicKey[0] = %#x, want 0x04 prefix", pub[0]) + } +} + +func TestEVMSigner_AddressAndPrivateKeyBytes(t *testing.T) { + t.Parallel() + s, err := NewEVMSigner() + if err != nil { + t.Fatalf("NewEVMSigner: %v", err) + } + if (s.Address() == Address{}) { + t.Error("Address is zero") + } + if len(s.PrivateKeyBytes()) != 32 { + t.Errorf("PrivateKeyBytes len = %d, want 32", len(s.PrivateKeyBytes())) + } +} diff --git a/pkg/wallet/zz_helpers_test.go b/pkg/wallet/zz_helpers_test.go new file mode 100644 index 0000000..30f50ea --- /dev/null +++ b/pkg/wallet/zz_helpers_test.go @@ -0,0 +1,174 @@ +package wallet + +import ( + "encoding/json" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/payment" +) + +func TestCloneArgs_CopiesMap(t *testing.T) { + t.Parallel() + src := map[string]any{"a": 1, "b": "two"} + dst := cloneArgs(src) + if len(dst) != 2 { + t.Errorf("len = %d, want 2", len(dst)) + } + // Mutating dst must not affect src. + dst["a"] = 999 + if src["a"] != 1 { + t.Error("cloneArgs did not isolate src") + } +} + +func TestCloneArgs_EmptyAndNil(t *testing.T) { + t.Parallel() + if got := cloneArgs(nil); got == nil || len(got) != 0 { + t.Errorf("cloneArgs(nil) = %v", got) + } + if got := cloneArgs(map[string]any{}); got == nil || len(got) != 0 { + t.Errorf("cloneArgs(empty) = %v", got) + } +} + +func TestParsePaywallSpec_Happy(t *testing.T) { + t.Parallel() + amt, asset, err := parsePaywallSpec("100 USDC") + if err != nil { + t.Fatalf("parsePaywallSpec: %v", err) + } + if amt != 100 { + t.Errorf("amt = %d", amt) + } + if asset != "USDC" { + t.Errorf("asset = %q", asset) + } +} + +func TestParsePaywallSpec_BadShape(t *testing.T) { + t.Parallel() + cases := []string{ + "", + "100", + "100 USDC extra", + "abc USDC", + "0 USDC", + } + for _, c := range cases { + if _, _, err := parsePaywallSpec(c); err == nil { + t.Errorf("expected error for %q", c) + } + } +} + +func TestCanonicalContractAD_RoundtripsThroughJSON(t *testing.T) { + t.Parallel() + c := payment.Contract{ + ID: "test-1", + Amount: 42, + Asset: "USDC", + ExpiresAt: time.Unix(1700000000, 0), + } + body, err := canonicalContractAD(c) + if err != nil { + t.Fatalf("canonicalContractAD: %v", err) + } + var got payment.Contract + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.ID != "test-1" || got.Amount != 42 { + t.Errorf("got %+v", got) + } +} + +func TestEVMChainID_NoBinding(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + if got := w.EVMChainID(); got != 0 { + t.Errorf("EVMChainID = %d, want 0", got) + } +} + +func TestEVMToken_NoBinding(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + // Zero value of evm.Address is empty bytes. + got := w.EVMToken() + for _, b := range got { + if b != 0 { + t.Errorf("EVMToken should be zero for no-binding: %v", got) + return + } + } +} + +func TestSpendCaps_DefaultEmpty(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + if got := w.SpendCaps(); len(got) != 0 { + t.Errorf("SpendCaps = %v, want empty", got) + } +} + +func TestSpendCaps_SetAndGetRoundtrip(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + caps := []SpendCap{ + {Asset: "USDC", Limit: 1000, Window: time.Hour}, + } + w.SetSpendCaps(caps...) + got := w.SpendCaps() + if len(got) != 1 || got[0].Limit != 1000 { + t.Errorf("got %v", got) + } +} + +func TestWallet_Address(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + if got := w.Address(); got != addrAlice { + t.Errorf("Address = %q, want %q", got, addrAlice) + } +} + +func TestMemoryStore_GetIssued(t *testing.T) { + t.Parallel() + s := NewMemoryStore() + defer s.Close() + ch := &Challenge{ID: "c1", Amount: 1, Asset: "USDC", ExpiresAt: time.Now().Add(time.Hour)} + if err := s.SaveIssued(ch); err != nil { + t.Fatalf("SaveIssued: %v", err) + } + got, err := s.GetIssued("c1") + if err != nil || got == nil { + t.Fatalf("GetIssued = (%v, %v)", got, err) + } + if got.Amount != 1 { + t.Errorf("Amount = %d", got.Amount) + } + // Missing returns (nil, nil) — convention in this store. + miss, err := s.GetIssued("no-such") + if err == nil && miss != nil { + t.Errorf("GetIssued(no-such) = %v", miss) + } +} + +func TestMemoryStore_IsSettled_DefaultFalse(t *testing.T) { + t.Parallel() + s := NewMemoryStore() + defer s.Close() + settled, err := s.IsSettled("never-existed") + if err != nil { + t.Fatalf("IsSettled: %v", err) + } + if settled { + t.Error("IsSettled fresh: want false") + } +} diff --git a/pkg/wallet/zz_hooks_test.go b/pkg/wallet/zz_hooks_test.go new file mode 100644 index 0000000..b19b341 --- /dev/null +++ b/pkg/wallet/zz_hooks_test.go @@ -0,0 +1,229 @@ +package wallet + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/payment" +) + +// newTestWallet returns a wallet with a fresh Ed25519 signer + memory store. +func newTestWallet(t *testing.T, addr Address) *Wallet { + t.Helper() + signer, err := NewLocalSigner() + if err != nil { + t.Fatalf("NewLocalSigner: %v", err) + } + return NewInMemory(addr, signer) +} + +// TestContractMatchesWallet covers both branches. +func TestContractMatchesWallet(t *testing.T) { + t.Parallel() + // Empty AcceptedMethods → match. + if !contractMatchesWallet(payment.Contract{}) { + t.Error("empty AcceptedMethods should match") + } + // Contains our method ID → match. + if !contractMatchesWallet(payment.Contract{ + AcceptedMethods: []string{"other", MockMethodID, "third"}, + }) { + t.Error("contains MockMethodID → match") + } + // Doesn't contain → no match. + if contractMatchesWallet(payment.Contract{ + AcceptedMethods: []string{"other-only"}, + }) { + t.Error("no MockMethodID → no match") + } +} + +// TestContractToChallenge converts every field. +func TestContractToChallenge(t *testing.T) { + t.Parallel() + exp := time.Now().Add(time.Hour) + c := payment.Contract{ + ID: "contract-1", + RecipientAddr: "0:0001.0001.0001", + Amount: 42, + Asset: "USDC", + Nonce: "nonce-x", + ExpiresAt: exp, + Memo: "test memo", + } + got := contractToChallenge(c) + if got.ID != "contract-1" { + t.Errorf("ID = %q", got.ID) + } + if Address(got.RecipientAddr) != Address("0:0001.0001.0001") { + t.Errorf("RecipientAddr = %q", got.RecipientAddr) + } + if got.Amount != 42 { + t.Errorf("Amount = %d", got.Amount) + } + if got.Asset != "USDC" { + t.Errorf("Asset = %q", got.Asset) + } + if got.Nonce != "nonce-x" { + t.Errorf("Nonce = %q", got.Nonce) + } + if !got.ExpiresAt.Equal(exp) { + t.Errorf("ExpiresAt = %v", got.ExpiresAt) + } + if got.Memo != "test memo" { + t.Errorf("Memo = %q", got.Memo) + } +} + +// TestWalletMethod_IDAndCannotSatisfy covers basic accessors. +func TestWalletMethod_IDAndCannotSatisfy(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + m := w.Method() + if m.ID() != MockMethodID { + t.Errorf("ID = %q", m.ID()) + } + + // Contract with non-matching method → ErrCannotSatisfy. + c := payment.Contract{ID: "x", AcceptedMethods: []string{"foreign"}} + if _, err := m.Satisfy(context.Background(), c); !errors.Is(err, payment.ErrCannotSatisfy) { + t.Errorf("Satisfy(non-matching): want ErrCannotSatisfy, got %v", err) + } +} + +// TestWalletMethod_VerifyWrongMethodID covers the early-rejection branch. +func TestWalletMethod_VerifyWrongMethodID(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + err := w.Method().Verify(context.Background(), + payment.Contract{ID: "x"}, + payment.Receipt{MethodID: "wrong-id"}) + if err == nil || !strings.Contains(err.Error(), "wrong method_id") { + t.Errorf("err = %v", err) + } +} + +// TestWalletMethod_VerifyBadPayload covers the JSON-unmarshal branch. +func TestWalletMethod_VerifyBadPayload(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + err := w.Method().Verify(context.Background(), + payment.Contract{ID: "x"}, + payment.Receipt{MethodID: MockMethodID, Payload: []byte("not json")}) + if err == nil { + t.Error("expected JSON decode error") + } +} + +// TestWalletEscrow_HoldRequiresIDAndKey covers the two validation +// short-circuits in Hold. +func TestWalletEscrow_HoldRequiresIDAndKey(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + e := w.Escrow() + if e.ID() != EscrowID { + t.Errorf("ID = %q", e.ID()) + } + + // Empty contract ID. + _, err := e.Hold(context.Background(), payment.Contract{}, []byte("k")) + if err == nil { + t.Error("expected error on empty contract ID") + } + // Empty key. + _, err = e.Hold(context.Background(), payment.Contract{ID: "c"}, nil) + if err == nil { + t.Error("expected error on empty key") + } +} + +// TestWalletEscrow_HoldRedeemRoundtrip drives the full happy path. +func TestWalletEscrow_HoldRedeemRoundtrip(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + e := w.Escrow() + ctx := context.Background() + + // Construct a contract that the wallet can Satisfy. + contract := payment.Contract{ + ID: "test-contract", + AcceptedMethods: []string{MockMethodID}, + RecipientAddr: string(addrAlice), + Amount: 0, // 0 amount keeps spend-cap out of play + Asset: "TEST", + Nonce: "n", + ExpiresAt: time.Now().Add(time.Hour), + Memo: "m", + } + + // Hold a key. + k := []byte("secret-key-12345") + ref, err := e.Hold(ctx, contract, k) + if err != nil { + t.Fatalf("Hold: %v", err) + } + if ref.EscrowID != EscrowID { + t.Errorf("ref.EscrowID = %q", ref.EscrowID) + } + if ref.ContractID != "test-contract" { + t.Errorf("ref.ContractID = %q", ref.ContractID) + } + if got := e.HeldCount(); got != 1 { + t.Errorf("HeldCount = %d, want 1", got) + } + + // Hold same contract again → duplicate error. + if _, err := e.Hold(ctx, contract, k); err == nil { + t.Error("expected duplicate-hold error") + } + + // Build a Receipt that Verify accepts: ask Method.Satisfy to make one. + receipt, err := w.Method().Satisfy(ctx, contract) + if err != nil { + t.Fatalf("Satisfy: %v", err) + } + + // Redeem with wrong EscrowID. + if _, err := e.Redeem(ctx, payment.EscrowRef{EscrowID: "wrong"}, receipt); err == nil { + t.Error("expected wrong-escrow-id error") + } + + // Redeem happy path. + gotK, err := e.Redeem(ctx, ref, receipt) + if err != nil { + t.Fatalf("Redeem: %v", err) + } + if string(gotK) != "secret-key-12345" { + t.Errorf("redeemed key = %q", gotK) + } + if got := e.HeldCount(); got != 0 { + t.Errorf("HeldCount after Redeem = %d, want 0", got) + } + + // Second redeem → ErrEscrowConsumed. + if _, err := e.Redeem(ctx, ref, receipt); !errors.Is(err, payment.ErrEscrowConsumed) { + t.Errorf("second Redeem: want ErrEscrowConsumed, got %v", err) + } +} + +// TestWalletEscrow_RedeemNotFound covers the not-found branch. +func TestWalletEscrow_RedeemNotFound(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + e := w.Escrow() + _, err := e.Redeem(context.Background(), + payment.EscrowRef{EscrowID: EscrowID, ContractID: "never-held"}, + payment.Receipt{MethodID: MockMethodID}) + if !errors.Is(err, payment.ErrEscrowNotFound) { + t.Errorf("want ErrEscrowNotFound, got %v", err) + } +} diff --git a/pkg/wallet/zz_paywall_hooks_test.go b/pkg/wallet/zz_paywall_hooks_test.go new file mode 100644 index 0000000..2623daf --- /dev/null +++ b/pkg/wallet/zz_paywall_hooks_test.go @@ -0,0 +1,305 @@ +package wallet + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/pilot-protocol/app-store/pkg/payment" +) + +// TestHookPreSendMessage_PassthroughWithoutPaywall covers the early +// return path: when args have no "paywall" key, the hook must return +// the args unchanged and never touch the escrow. +func TestHookPreSendMessage_PassthroughWithoutPaywall(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + + in := map[string]any{ + "peer": "0:0001.0002.0002", + "data": base64.StdEncoding.EncodeToString([]byte("hello")), + } + out, err := w.HookPreSendMessage(context.Background(), in) + if err != nil { + t.Fatalf("HookPreSendMessage: %v", err) + } + if _, ok := out["paywalled"]; ok { + t.Errorf("paywalled flag set on non-paywall args: %v", out) + } + if out["data"] != in["data"] { + t.Errorf("data was mutated on passthrough") + } + if w.Escrow().HeldCount() != 0 { + t.Errorf("escrow was touched on passthrough: HeldCount=%d", w.Escrow().HeldCount()) + } +} + +// TestHookPreSendMessage_PassthroughWithBlankPaywall covers the +// whitespace-only spec branch — that should also be a pass-through. +func TestHookPreSendMessage_PassthroughWithBlankPaywall(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + + in := map[string]any{ + "peer": "0:0001.0002.0002", + "data": base64.StdEncoding.EncodeToString([]byte("hi")), + "paywall": " ", + } + out, err := w.HookPreSendMessage(context.Background(), in) + if err != nil { + t.Fatalf("HookPreSendMessage: %v", err) + } + if _, ok := out["paywalled"]; ok { + t.Error("paywalled flag set on blank-paywall args") + } +} + +// TestHookPreSendMessage_HappyPath drives the full seal-and-hold path +// and asserts the wallet's escrow now holds a key under the contract. +func TestHookPreSendMessage_HappyPath(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + + plaintext := []byte("secret payload bytes") + in := map[string]any{ + "peer": "0:0001.0002.0002", + "data": base64.StdEncoding.EncodeToString(plaintext), + "paywall": "100 USDC", + "memo": "test memo", + } + out, err := w.HookPreSendMessage(context.Background(), in) + if err != nil { + t.Fatalf("HookPreSendMessage: %v", err) + } + if got, _ := out["paywalled"].(bool); !got { + t.Error("paywalled flag missing on happy path") + } + contractID, _ := out["contract_id"].(string) + if contractID == "" { + t.Error("contract_id missing on happy path") + } + + // "data" must now decode as a SealedEnvelope, not as the original plaintext. + sealedB64, _ := out["data"].(string) + envBytes, err := base64.StdEncoding.DecodeString(sealedB64) + if err != nil { + t.Fatalf("envelope b64 decode: %v", err) + } + var env payment.SealedEnvelope + if err := json.Unmarshal(envBytes, &env); err != nil { + t.Fatalf("envelope JSON decode: %v", err) + } + if env.Contract.ID != contractID { + t.Errorf("envelope.Contract.ID = %q, want %q", env.Contract.ID, contractID) + } + if env.Contract.Amount != 100 || env.Contract.Asset != "USDC" { + t.Errorf("envelope contract = %+v", env.Contract) + } + if string(env.Contract.RecipientAddr) != "0:0001.0002.0002" { + t.Errorf("envelope recipient = %q", env.Contract.RecipientAddr) + } + if len(env.Ciphertext) == 0 { + t.Error("envelope ciphertext is empty") + } + // Plaintext must not leak in either ciphertext or any args field. + for k, v := range out { + if s, ok := v.(string); ok && strings.Contains(s, string(plaintext)) { + t.Errorf("plaintext leaked into args[%q]", k) + } + } + + // Escrow must hold exactly one key. + if got := w.Escrow().HeldCount(); got != 1 { + t.Errorf("HeldCount = %d, want 1", got) + } +} + +// TestHookPreSendMessage_MissingPeer covers the peer-required validation +// branch. Spec is set, but peer is absent. +func TestHookPreSendMessage_MissingPeer(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + _, err := w.HookPreSendMessage(context.Background(), map[string]any{ + "paywall": "100 USDC", + "data": base64.StdEncoding.EncodeToString([]byte("x")), + }) + if err == nil || !strings.Contains(err.Error(), "peer") { + t.Errorf("err = %v, want peer-required error", err) + } +} + +// TestHookPreSendMessage_BadBase64Data covers the data-decode failure +// branch. +func TestHookPreSendMessage_BadBase64Data(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + _, err := w.HookPreSendMessage(context.Background(), map[string]any{ + "paywall": "100 USDC", + "peer": "0:0001.0002.0002", + "data": "!!!not-base64!!!", + }) + if err == nil || !strings.Contains(err.Error(), "base64") { + t.Errorf("err = %v, want base64 decode error", err) + } +} + +// TestHookPreSendMessage_BadPaywallSpec covers the spec-parse failure. +func TestHookPreSendMessage_BadPaywallSpec(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + _, err := w.HookPreSendMessage(context.Background(), map[string]any{ + "paywall": "garbage", + "peer": "0:0001.0002.0002", + "data": base64.StdEncoding.EncodeToString([]byte("x")), + }) + if err == nil { + t.Error("expected spec-parse error") + } +} + +// TestHookPreSendMessage_CustomMethodAndEscrowIDs makes sure caller- +// supplied method/escrow IDs override the defaults — exercises both +// non-empty branches. +func TestHookPreSendMessage_CustomMethodAndEscrowIDs(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + out, err := w.HookPreSendMessage(context.Background(), map[string]any{ + "paywall": "1 USDC", + "peer": "0:0001.0002.0002", + "data": base64.StdEncoding.EncodeToString([]byte("x")), + "method": "custom-method", + "escrow": "custom-escrow", + }) + if err != nil { + t.Fatalf("HookPreSendMessage: %v", err) + } + envBytes, _ := base64.StdEncoding.DecodeString(out["data"].(string)) + var env payment.SealedEnvelope + _ = json.Unmarshal(envBytes, &env) + if len(env.Contract.AcceptedMethods) != 1 || env.Contract.AcceptedMethods[0] != "custom-method" { + t.Errorf("AcceptedMethods = %v", env.Contract.AcceptedMethods) + } + if len(env.Contract.AcceptedEscrows) != 1 || env.Contract.AcceptedEscrows[0] != "custom-escrow" { + t.Errorf("AcceptedEscrows = %v", env.Contract.AcceptedEscrows) + } +} + +// ─── HookPostRecvMessage ────────────────────────────────────────────── + +// TestHookPostRecvMessage_NoDataPassthrough covers the empty-data branch. +func TestHookPostRecvMessage_NoDataPassthrough(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + in := map[string]any{"peer": "0:0001.0002.0002"} + out, err := w.HookPostRecvMessage(context.Background(), in) + if err != nil { + t.Fatalf("HookPostRecvMessage: %v", err) + } + if _, ok := out["sealed"]; ok { + t.Error("sealed flag set on no-data args") + } +} + +// TestHookPostRecvMessage_BadBase64Passthrough covers the b64-decode +// failure branch — must pass through silently (sealed=false) without +// returning an error. +func TestHookPostRecvMessage_BadBase64Passthrough(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + in := map[string]any{"data": "!!!not-base64!!!"} + out, err := w.HookPostRecvMessage(context.Background(), in) + if err != nil { + t.Fatalf("err: %v (should pass through silently)", err) + } + if _, ok := out["sealed"]; ok { + t.Error("sealed flag set on bad-b64 args") + } +} + +// TestHookPostRecvMessage_BadJSONPassthrough covers the JSON-unmarshal +// failure branch. +func TestHookPostRecvMessage_BadJSONPassthrough(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + in := map[string]any{"data": base64.StdEncoding.EncodeToString([]byte("not json"))} + out, err := w.HookPostRecvMessage(context.Background(), in) + if err != nil { + t.Fatalf("err: %v", err) + } + if _, ok := out["sealed"]; ok { + t.Error("sealed flag set on bad-json args") + } +} + +// TestHookPostRecvMessage_IncompleteEnvelopePassthrough covers the +// missing-fields branch — a JSON-valid struct that lacks required +// sealed-envelope fields must NOT be flagged as sealed. +func TestHookPostRecvMessage_IncompleteEnvelopePassthrough(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + in := map[string]any{"data": base64.StdEncoding.EncodeToString([]byte(`{"contract":{"id":""}}`))} + out, err := w.HookPostRecvMessage(context.Background(), in) + if err != nil { + t.Fatalf("err: %v", err) + } + if _, ok := out["sealed"]; ok { + t.Error("sealed flag set on incomplete envelope") + } +} + +// TestHookPostRecvMessage_HappyPath round-trips: pre-send produces a +// sealed envelope, post-recv consumes it and exposes the contract +// metadata for the recipient to decide on redemption. +func TestHookPostRecvMessage_HappyPath(t *testing.T) { + t.Parallel() + sender := newTestWallet(t, addrAlice) + defer sender.Close() + receiver := newTestWallet(t, addrBob) + defer receiver.Close() + + sealedArgs, err := sender.HookPreSendMessage(context.Background(), map[string]any{ + "paywall": "42 USDC", + "peer": string(addrBob), + "data": base64.StdEncoding.EncodeToString([]byte("payload")), + "memo": "happy-path", + }) + if err != nil { + t.Fatalf("pre-send: %v", err) + } + + out, err := receiver.HookPostRecvMessage(context.Background(), map[string]any{ + "data": sealedArgs["data"], + }) + if err != nil { + t.Fatalf("post-recv: %v", err) + } + if sealed, _ := out["sealed"].(bool); !sealed { + t.Errorf("sealed flag not set on a valid envelope: %v", out) + } + if got, _ := out["contract_id"].(string); got != sealedArgs["contract_id"].(string) { + t.Errorf("contract_id mismatch: got %q, want %q", + got, sealedArgs["contract_id"]) + } + if got, _ := out["contract_asset"].(string); got != "USDC" { + t.Errorf("contract_asset = %q", got) + } + if got, _ := out["escrow_id"].(string); got != EscrowID { + t.Errorf("escrow_id = %q", got) + } + if got, _ := out["escrow_endpoint"].(string); got != string(addrAlice) { + t.Errorf("escrow_endpoint = %q, want sender address %q", got, addrAlice) + } +} diff --git a/pkg/wallet/zz_store_request_test.go b/pkg/wallet/zz_store_request_test.go new file mode 100644 index 0000000..a6f3d6f --- /dev/null +++ b/pkg/wallet/zz_store_request_test.go @@ -0,0 +1,133 @@ +package wallet + +import ( + "path/filepath" + "testing" + "time" +) + +// TestRequest_ValidationBranches covers the three early-return errors +// in Wallet.Request: zero amount, empty asset, non-positive expiresIn. +func TestRequest_ValidationBranches(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + + if _, err := w.Request(0, "USDC", time.Minute, ""); err == nil { + t.Error("expected error for amount=0") + } + if _, err := w.Request(10, "", time.Minute, ""); err == nil { + t.Error("expected error for empty asset") + } + if _, err := w.Request(10, "USDC", 0, ""); err == nil { + t.Error("expected error for expiresIn=0") + } +} + +// TestTopup_ValidationBranches covers Topup's three early-return errors: +// zero amount, empty asset, empty source. +func TestTopup_ValidationBranches(t *testing.T) { + t.Parallel() + w := newTestWallet(t, addrAlice) + defer w.Close() + + if _, err := w.Topup("USDC", 0, "dev"); err == nil { + t.Error("expected error for amount=0") + } + if _, err := w.Topup("", 10, "dev"); err == nil { + t.Error("expected error for empty asset") + } + if _, err := w.Topup("USDC", 10, ""); err == nil { + t.Error("expected error for empty source") + } +} + +// TestSQLiteStore_GetIssuedRoundtrip covers the sqlite GetIssued path +// (previously 0% — only the memory store's GetIssued had a test). +func TestSQLiteStore_GetIssuedRoundtrip(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "issued.db") + s, err := OpenSQLiteStore(path) + if err != nil { + t.Fatalf("OpenSQLiteStore: %v", err) + } + defer s.Close() + + ch := &Challenge{ + ID: "issued-1", + Amount: 100, + Asset: "USDC", + Nonce: "n", + ExpiresAt: time.Now().Add(time.Hour), + } + if err := s.SaveIssued(ch); err != nil { + t.Fatalf("SaveIssued: %v", err) + } + got, err := s.GetIssued("issued-1") + if err != nil { + t.Fatalf("GetIssued: %v", err) + } + if got == nil || got.ID != "issued-1" || got.Amount != 100 { + t.Errorf("got %+v", got) + } + + // Missing → nil, nil. + missing, err := s.GetIssued("no-such") + if err != nil { + t.Errorf("GetIssued(missing) err: %v", err) + } + if missing != nil { + t.Errorf("GetIssued(missing) = %+v, want nil", missing) + } +} + +// TestSQLiteStore_IsSettled covers the sqlite IsSettled path (previously +// 0%). Starts at false, becomes true after a full Pay→Settle dance, and +// stays true. +func TestSQLiteStore_IsSettled(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "settled.db") + s, err := OpenSQLiteStore(path) + if err != nil { + t.Fatalf("OpenSQLiteStore: %v", err) + } + defer s.Close() + + settled, err := s.IsSettled("never-existed") + if err != nil { + t.Fatalf("IsSettled(absent): %v", err) + } + if settled { + t.Error("IsSettled(absent) = true, want false") + } + + // Drive a real settle via the wallet. + signer, _ := NewLocalSigner() + alice := New(addrAlice, signer, s) + + otherSigner, _ := NewLocalSigner() + bob := NewInMemory(addrBob, otherSigner) + defer bob.Close() + if _, err := bob.Topup("USDC", 500, "dev"); err != nil { + t.Fatal(err) + } + ch, err := alice.Request(100, "USDC", time.Minute, "") + if err != nil { + t.Fatal(err) + } + sa, err := bob.Pay(ch) + if err != nil { + t.Fatal(err) + } + if _, err := alice.Settle(ch, sa); err != nil { + t.Fatalf("Settle: %v", err) + } + + settled, err = s.IsSettled(ch.ID) + if err != nil { + t.Fatalf("IsSettled(after settle): %v", err) + } + if !settled { + t.Error("IsSettled(after settle) = false, want true") + } +} diff --git a/pkg/walletipc/zz_dispatcher_more_test.go b/pkg/walletipc/zz_dispatcher_more_test.go new file mode 100644 index 0000000..9c75b2d --- /dev/null +++ b/pkg/walletipc/zz_dispatcher_more_test.go @@ -0,0 +1,135 @@ +package walletipc + +import ( + "context" + "encoding/json" + "net" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" + "github.com/pilot-protocol/wallet/pkg/wallet" +) + +// TestHistoryOverIPC_TimeBounds drives the Since/Before time-bound +// branches on historyHandler — previously uncovered. Tops up multiple +// times across distinct timestamps and filters via SinceUnixNano and +// BeforeUnixNano. +func TestHistoryOverIPC_TimeBounds(t *testing.T) { + s, _ := wallet.NewLocalSigner() + w := wallet.NewInMemory("0:0001.0001.0001", s) + defer w.Close() + cc, sc := net.Pipe() + defer cc.Close() + defer sc.Close() + go func() { _ = ipc.Serve(context.Background(), sc, NewDispatcher(w)) }() + + // Five topups with measurable gaps. + for i := 0; i < 5; i++ { + if err := ipc.Call(cc, MethodTopup, TopupReq{ + Asset: "USDC", Amount: wallet.Amount(1 + i), Source: "dev", + }, &TopupResp{}); err != nil { + t.Fatal(err) + } + time.Sleep(2 * time.Millisecond) + } + + // Read everything to get timestamps. + var all HistoryResp + if err := ipc.Call(cc, MethodHistory, HistoryReq{}, &all); err != nil { + t.Fatal(err) + } + if len(all.Transactions) != 5 { + t.Fatalf("all = %d, want 5", len(all.Transactions)) + } + mid := all.Transactions[2].Timestamp.UnixNano() + + // Since: only entries newer than mid. + var since HistoryResp + if err := ipc.Call(cc, MethodHistory, HistoryReq{ + SinceUnixNano: mid, + }, &since); err != nil { + t.Fatal(err) + } + for _, tx := range since.Transactions { + if tx.Timestamp.UnixNano() < mid { + t.Errorf("Since filter included older tx: ts=%d, mid=%d", + tx.Timestamp.UnixNano(), mid) + } + } + + // Before: only entries older than mid. + var before HistoryResp + if err := ipc.Call(cc, MethodHistory, HistoryReq{ + BeforeUnixNano: mid, + }, &before); err != nil { + t.Fatal(err) + } + for _, tx := range before.Transactions { + if tx.Timestamp.UnixNano() >= mid { + t.Errorf("Before filter included newer tx: ts=%d, mid=%d", + tx.Timestamp.UnixNano(), mid) + } + } +} + +// TestDecodeEmptyPayloadIsValid covers the decode() empty-payload +// short-circuit by issuing a balances call with no payload. +func TestDecodeEmptyPayloadIsValid(t *testing.T) { + t.Parallel() + s, _ := wallet.NewLocalSigner() + w := wallet.NewInMemory("0:0001.0001.0001", s) + defer w.Close() + cc, sc := net.Pipe() + defer cc.Close() + defer sc.Close() + go func() { _ = ipc.Serve(context.Background(), sc, NewDispatcher(w)) }() + + var resp BalancesResp + if err := ipc.Call(cc, MethodBalances, nil, &resp); err != nil { + t.Fatalf("balances with no payload: %v", err) + } +} + +// TestRequestHandler_ZeroExpiresInRejected covers the explicit +// validation branch in requestHandler. +func TestRequestHandler_ZeroExpiresInRejected(t *testing.T) { + t.Parallel() + s, _ := wallet.NewLocalSigner() + w := wallet.NewInMemory("0:0001.0001.0001", s) + defer w.Close() + cc, sc := net.Pipe() + defer cc.Close() + defer sc.Close() + go func() { _ = ipc.Serve(context.Background(), sc, NewDispatcher(w)) }() + + err := ipc.Call(cc, MethodRequest, RequestReq{ + Amount: 100, + Asset: "USDC", + ExpiresInSeconds: 0, + }, &RequestResp{}) + if err == nil { + t.Error("expected server error for ExpiresInSeconds=0") + } +} + +// TestDecodeBadJSONPayloadRejected covers the JSON-unmarshal error +// branch in decode() by sending malformed args. +func TestDecodeBadJSONPayloadRejected(t *testing.T) { + t.Parallel() + s, _ := wallet.NewLocalSigner() + w := wallet.NewInMemory("0:0001.0001.0001", s) + defer w.Close() + cc, sc := net.Pipe() + defer cc.Close() + defer sc.Close() + go func() { _ = ipc.Serve(context.Background(), sc, NewDispatcher(w)) }() + + // Send a request with a payload that doesn't match the expected + // shape: BalanceReq wants {asset:string}, give it {asset:42}. + bad := json.RawMessage(`{"asset": 42}`) + err := ipc.Call(cc, MethodBalance, bad, &BalanceResp{}) + if err == nil { + t.Error("expected decode error for type-mismatched payload") + } +}