package main import ( "bytes" "context" "crypto/ecdsa" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/signer/core/apitypes" ) func TestMembershipDistinctPayerProof(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) payerKey := mustKey(t) payerAddr := strings.ToLower(crypto.PubkeyToAddress(payerKey.PublicKey).Hex()) intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{ Address: ownerAddr, Origin: "https://edut.ai", Locale: "en", ChainID: cfg.ChainID, }, http.StatusOK) issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt) if err != nil { t.Fatalf("parse issued_at: %v", err) } td := buildTypedData(cfg, designationRecord{ Code: intentRes.DesignationCode, DisplayToken: intentRes.DisplayToken, Nonce: intentRes.Nonce, IssuedAt: issuedAt, Origin: "https://edut.ai", }) sig := signTypedData(t, ownerKey, td) verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{ IntentID: intentRes.IntentID, Address: ownerAddr, ChainID: cfg.ChainID, Signature: sig, }, http.StatusOK) if verifyRes.Status != "signature_verified" { t.Fatalf("unexpected verify status: %+v", verifyRes) } // Distinct payer without proof must fail closed. _ = postJSONExpect[map[string]string](t, a, "/secret/membership/quote", membershipQuoteRequest{ DesignationCode: verifyRes.DesignationCode, Address: ownerAddr, ChainID: cfg.ChainID, PayerWallet: payerAddr, }, http.StatusForbidden) payerProofSig := signPersonalMessage( t, ownerKey, payerProofMessage(verifyRes.DesignationCode, ownerAddr, payerAddr, cfg.ChainID), ) quote := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{ DesignationCode: verifyRes.DesignationCode, Address: ownerAddr, ChainID: cfg.ChainID, PayerWallet: payerAddr, PayerProof: payerProofSig, SponsorOrgRoot: "org_company_a", }, http.StatusOK) if got := strings.ToLower(strings.TrimSpace(quote.OwnerWallet)); got != ownerAddr { t.Fatalf("owner wallet mismatch: got=%s want=%s", got, ownerAddr) } if got := strings.ToLower(strings.TrimSpace(quote.PayerWallet)); got != payerAddr { t.Fatalf("payer wallet mismatch: got=%s want=%s", got, payerAddr) } if quote.Tx["from"] != payerAddr { t.Fatalf("tx.from mismatch: %+v", quote.Tx) } } func TestMembershipCompanySponsorWithoutOwnerProof(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) companyPayerKey := mustKey(t) companyPayerAddr := strings.ToLower(crypto.PubkeyToAddress(companyPayerKey.PublicKey).Hex()) now := time.Now().UTC() if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{ Wallet: companyPayerAddr, OrgRootID: "org_company_b", PrincipalID: "principal_company_owner", PrincipalRole: "org_root_owner", EntitlementID: "gov_company_b", EntitlementStatus: "active", AccessClass: "connected", AvailabilityState: "active", UpdatedAt: now, }); err != nil { t.Fatalf("seed governance principal: %v", err) } if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{ EntitlementID: "ent:sponsor:workspace-core", QuoteID: "seed-q-sponsor-workspace-core", OfferID: offerIDWorkspaceCore, Wallet: companyPayerAddr, OrgRootID: "org_company_b", PrincipalID: "principal_company_owner", PrincipalRole: "org_root_owner", State: "active", AccessClass: "connected", AvailabilityState: "active", PolicyHash: "sha256:testpolicy", IssuedAt: now, TxHash: "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", }); err != nil { t.Fatalf("seed sponsor workspace core entitlement: %v", err) } intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{ Address: ownerAddr, Origin: "https://edut.ai", Locale: "en", ChainID: cfg.ChainID, }, http.StatusOK) issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt) if err != nil { t.Fatalf("parse issued_at: %v", err) } td := buildTypedData(cfg, designationRecord{ Code: intentRes.DesignationCode, DisplayToken: intentRes.DisplayToken, Nonce: intentRes.Nonce, IssuedAt: issuedAt, Origin: "https://edut.ai", }) sig := signTypedData(t, ownerKey, td) verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{ IntentID: intentRes.IntentID, Address: ownerAddr, ChainID: cfg.ChainID, Signature: sig, }, http.StatusOK) quote := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{ DesignationCode: verifyRes.DesignationCode, Address: ownerAddr, ChainID: cfg.ChainID, PayerWallet: companyPayerAddr, SponsorOrgRoot: "org_company_b", }, http.StatusOK) if quote.SponsorshipMode != "sponsored_company" { t.Fatalf("expected sponsored_company mode, got %+v", quote) } } func TestMembershipConfirmRequiresChainRPCWhenStrictVerificationEnabled(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() a.cfg.RequireOnchainTxVerify = true a.cfg.ChainRPCURL = "" ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{ Address: ownerAddr, Origin: "https://edut.ai", Locale: "en", ChainID: cfg.ChainID, }, http.StatusOK) issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt) if err != nil { t.Fatalf("parse issued_at: %v", err) } td := buildTypedData(cfg, designationRecord{ Code: intentRes.DesignationCode, DisplayToken: intentRes.DisplayToken, Nonce: intentRes.Nonce, IssuedAt: issuedAt, Origin: "https://edut.ai", }) sig := signTypedData(t, ownerKey, td) verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{ IntentID: intentRes.IntentID, Address: ownerAddr, ChainID: cfg.ChainID, Signature: sig, }, http.StatusOK) quote := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{ DesignationCode: verifyRes.DesignationCode, Address: ownerAddr, ChainID: cfg.ChainID, }, http.StatusOK) fail := postJSONExpect[map[string]string](t, a, "/secret/membership/confirm", membershipConfirmRequest{ DesignationCode: verifyRes.DesignationCode, QuoteID: quote.QuoteID, TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Address: ownerAddr, ChainID: cfg.ChainID, }, http.StatusServiceUnavailable) if fail["code"] != "chain_verification_unavailable" { t.Fatalf("expected chain_verification_unavailable code, got %+v", fail) } } func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() memberKey := mustKey(t) memberAddr := strings.ToLower(crypto.PubkeyToAddress(memberKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, memberAddr); err != nil { t.Fatalf("seed member membership: %v", err) } _ = postJSONExpect[map[string]string](t, a, "/governance/install/token", governanceInstallTokenRequest{ Wallet: memberAddr, OrgRootID: "org_root_a", PrincipalID: "principal_member", PrincipalRole: "workspace_member", DeviceID: "macstudio-001", LauncherVersion: "0.1.0", Platform: "macos", }, http.StatusForbidden) ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { t.Fatalf("seed membership: %v", err) } now := time.Now().UTC() if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{ EntitlementID: "ent:install:workspace-core", QuoteID: "seed-q-install-workspace-core", OfferID: offerIDWorkspaceCore, Wallet: ownerAddr, OrgRootID: "org_root_a", PrincipalID: "principal_owner", PrincipalRole: "org_root_owner", State: "active", AccessClass: "connected", AvailabilityState: "active", PolicyHash: cfg.GovernancePolicyHash, IssuedAt: now, TxHash: "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", }); err != nil { t.Fatalf("seed workspace core entitlement: %v", err) } if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{ Wallet: ownerAddr, OrgRootID: "org_root_a", PrincipalID: "principal_owner", PrincipalRole: "org_root_owner", EntitlementID: "ent:install:workspace-core", EntitlementStatus: "active", AccessClass: "connected", AvailabilityState: "active", UpdatedAt: now, }); err != nil { t.Fatalf("seed owner principal: %v", err) } tokenRes := postJSONExpect[governanceInstallTokenResponse](t, a, "/governance/install/token", governanceInstallTokenRequest{ Wallet: ownerAddr, OrgRootID: "org_root_a", PrincipalID: "principal_owner", PrincipalRole: "org_root_owner", DeviceID: "macstudio-001", LauncherVersion: "0.1.0", Platform: "macos", }, http.StatusOK) if tokenRes.InstallToken == "" { t.Fatalf("missing install token") } _ = postJSONExpect[map[string]string](t, a, "/governance/install/confirm", governanceInstallConfirmRequest{ InstallToken: tokenRes.InstallToken, Wallet: ownerAddr, DeviceID: "macstudio-001", EntitlementID: tokenRes.EntitlementID, PackageHash: "sha256:wrong", RuntimeVersion: cfg.GovernanceRuntimeVersion, InstalledAt: time.Now().UTC().Format(time.RFC3339Nano), }, http.StatusConflict) confirm := postJSONExpect[governanceInstallConfirmResponse](t, a, "/governance/install/confirm", governanceInstallConfirmRequest{ InstallToken: tokenRes.InstallToken, Wallet: ownerAddr, DeviceID: "macstudio-001", EntitlementID: tokenRes.EntitlementID, PackageHash: cfg.GovernancePackageHash, RuntimeVersion: cfg.GovernanceRuntimeVersion, InstalledAt: time.Now().UTC().Format(time.RFC3339Nano), LauncherReceiptRef: "receipt-hash-1", }, http.StatusOK) if confirm.Status != "governance_active" { t.Fatalf("unexpected confirm status: %+v", confirm) } statusReq := httptest.NewRequest(http.MethodGet, "/governance/install/status?wallet="+ownerAddr+"&device_id=macstudio-001", nil) statusRec := httptest.NewRecorder() a.routes().ServeHTTP(statusRec, statusReq) if statusRec.Code != http.StatusOK { t.Fatalf("status code=%d body=%s", statusRec.Code, statusRec.Body.String()) } var status governanceInstallStatusResponse if err := json.Unmarshal(statusRec.Body.Bytes(), &status); err != nil { t.Fatalf("decode status: %v", err) } if status.ActivationStatus != "active" { t.Fatalf("expected active status got %+v", status) } } func TestGovernanceLeaseAndOfflineRenew(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { t.Fatalf("seed membership: %v", err) } now := time.Now().UTC() if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{ EntitlementID: "ent:lease:workspace-core", QuoteID: "seed-q-lease-workspace-core", OfferID: offerIDWorkspaceCore, Wallet: ownerAddr, OrgRootID: "org_root_b", PrincipalID: "principal_owner_b", PrincipalRole: "org_root_owner", State: "active", AccessClass: "connected", AvailabilityState: "active", PolicyHash: "sha256:testpolicy", IssuedAt: now, TxHash: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", }); err != nil { t.Fatalf("seed workspace core entitlement: %v", err) } if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{ EntitlementID: "ent:lease:workspace-sovereign", QuoteID: "seed-q-lease-workspace-sovereign", OfferID: offerIDWorkspaceSovereign, Wallet: ownerAddr, OrgRootID: "org_root_b", PrincipalID: "principal_owner_b", PrincipalRole: "org_root_owner", State: "active", AccessClass: "sovereign", AvailabilityState: "active", PolicyHash: "sha256:testpolicy", IssuedAt: now, TxHash: "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", }); err != nil { t.Fatalf("seed workspace sovereign entitlement: %v", err) } if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{ Wallet: ownerAddr, OrgRootID: "org_root_b", PrincipalID: "principal_owner_b", PrincipalRole: "org_root_owner", EntitlementID: "ent:lease:workspace-core", EntitlementStatus: "active", AccessClass: "connected", AvailabilityState: "active", UpdatedAt: now, }); err != nil { t.Fatalf("seed owner principal: %v", err) } _ = postJSONExpect[governanceInstallTokenResponse](t, a, "/governance/install/token", governanceInstallTokenRequest{ Wallet: ownerAddr, OrgRootID: "org_root_b", PrincipalID: "principal_owner_b", PrincipalRole: "org_root_owner", DeviceID: "macstudio-002", LauncherVersion: "0.1.0", Platform: "macos", }, http.StatusOK) _ = postJSONExpect[map[string]string](t, a, "/governance/lease/heartbeat", governanceLeaseHeartbeatRequest{ Wallet: ownerAddr, OrgRootID: "wrong_org", PrincipalID: "principal_owner_b", DeviceID: "macstudio-002", }, http.StatusForbidden) leaseRes := postJSONExpect[governanceLeaseHeartbeatResponse](t, a, "/governance/lease/heartbeat", governanceLeaseHeartbeatRequest{ Wallet: ownerAddr, OrgRootID: "org_root_b", PrincipalID: "principal_owner_b", DeviceID: "macstudio-002", }, http.StatusOK) if leaseRes.Status != "lease_refreshed" || leaseRes.AvailabilityState != "active" { t.Fatalf("unexpected lease response: %+v", leaseRes) } renewRes := postJSONExpect[governanceOfflineRenewResponse](t, a, "/governance/lease/offline-renew", governanceOfflineRenewRequest{ Wallet: ownerAddr, OrgRootID: "org_root_b", PrincipalID: "principal_owner_b", RenewalBundle: map[string]any{ "bundle_id": "renew-1", }, }, http.StatusOK) if renewRes.Status != "renewal_applied" || renewRes.AvailabilityState != "active" { t.Fatalf("unexpected renew response: %+v", renewRes) } } func TestGovernanceInstallTokenBlockedWhenParked(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { t.Fatalf("seed membership: %v", err) } now := time.Now().UTC() if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{ Wallet: ownerAddr, OrgRootID: "org_parked", PrincipalID: "principal_parked", PrincipalRole: "org_root_owner", EntitlementID: "gov_parked", EntitlementStatus: "active", AccessClass: "connected", AvailabilityState: "parked", UpdatedAt: now, }); err != nil { t.Fatalf("seed principal: %v", err) } _ = postJSONExpect[map[string]string](t, a, "/governance/install/token", governanceInstallTokenRequest{ Wallet: ownerAddr, OrgRootID: "org_parked", PrincipalID: "principal_parked", PrincipalRole: "org_root_owner", DeviceID: "macstudio-parked", LauncherVersion: "0.1.0", Platform: "macos", }, http.StatusForbidden) } func TestMemberChannelRegisterPollAckAndUnregister(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { t.Fatalf("seed membership: %v", err) } register := postJSONExpect[memberChannelDeviceRegisterResponse](t, a, "/member/channel/device/register", memberChannelDeviceRegisterRequest{ Wallet: ownerAddr, ChainID: 84532, DeviceID: "desktop-01", Platform: "desktop", OrgRootID: "org.test.root", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", AppVersion: "0.1.0", PushProvider: "none", }, http.StatusOK) if register.Status != "active" || register.ChannelBindingID == "" { t.Fatalf("unexpected register response: %+v", register) } events := getJSONExpect[memberChannelEventsResponse](t, a, "/member/channel/events?wallet="+ownerAddr+"&device_id=desktop-01&limit=25", http.StatusOK) if len(events.Events) == 0 { t.Fatalf("expected seeded events, got none") } first := events.Events[0] ackTime := time.Now().UTC() ack := postJSONExpect[memberChannelEventAckResponse](t, a, "/member/channel/events/"+first.EventID+"/ack", memberChannelEventAckRequest{ Wallet: ownerAddr, DeviceID: "desktop-01", AcknowledgedAt: ackTime.Format(time.RFC3339Nano), }, http.StatusOK) if ack.Status != "acknowledged" || ack.EventID != first.EventID { t.Fatalf("unexpected ack response: %+v", ack) } // Idempotent ack should keep original acknowledgement timestamp. ack2 := postJSONExpect[memberChannelEventAckResponse](t, a, "/member/channel/events/"+first.EventID+"/ack", memberChannelEventAckRequest{ Wallet: ownerAddr, DeviceID: "desktop-01", AcknowledgedAt: ackTime.Add(30 * time.Second).Format(time.RFC3339Nano), }, http.StatusOK) if ack2.AcknowledgedAt != ack.AcknowledgedAt { t.Fatalf("ack should be idempotent: first=%s second=%s", ack.AcknowledgedAt, ack2.AcknowledgedAt) } _ = postJSONExpect[memberChannelDeviceUnregisterResponse](t, a, "/member/channel/device/unregister", memberChannelDeviceUnregisterRequest{ Wallet: ownerAddr, DeviceID: "desktop-01", }, http.StatusOK) _ = getJSONExpect[map[string]string](t, a, "/member/channel/events?wallet="+ownerAddr+"&device_id=desktop-01&limit=10", http.StatusForbidden) } func TestMemberChannelSupportTicketOwnerOnly(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() memberKey := mustKey(t) memberAddr := strings.ToLower(crypto.PubkeyToAddress(memberKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, memberAddr); err != nil { t.Fatalf("seed member membership: %v", err) } _ = postJSONExpect[memberChannelDeviceRegisterResponse](t, a, "/member/channel/device/register", memberChannelDeviceRegisterRequest{ Wallet: memberAddr, ChainID: 84532, DeviceID: "desktop-member-01", Platform: "desktop", OrgRootID: "org.support.test", PrincipalID: "human.member", PrincipalRole: "workspace_member", AppVersion: "0.1.0", PushProvider: "none", }, http.StatusOK) errResp := postJSONExpect[map[string]string](t, a, "/member/channel/support/ticket", memberChannelSupportTicketRequest{ Wallet: memberAddr, OrgRootID: "org.support.test", PrincipalID: "human.member", Category: "health_diagnostic", Summary: "Please investigate", }, http.StatusForbidden) if code := errResp["code"]; code != "owner_role_required" { t.Fatalf("expected owner_role_required, got %+v", errResp) } ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { t.Fatalf("seed owner membership: %v", err) } _ = postJSONExpect[memberChannelDeviceRegisterResponse](t, a, "/member/channel/device/register", memberChannelDeviceRegisterRequest{ Wallet: ownerAddr, ChainID: 84532, DeviceID: "desktop-owner-01", Platform: "desktop", OrgRootID: "org.support.test", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", AppVersion: "0.1.0", PushProvider: "none", }, http.StatusOK) ticket := postJSONExpect[memberChannelSupportTicketResponse](t, a, "/member/channel/support/ticket", memberChannelSupportTicketRequest{ Wallet: ownerAddr, OrgRootID: "org.support.test", PrincipalID: "human.owner", Category: "admin_support", Summary: "Need owner diagnostics.", Context: map[string]any{ "scope": "full", }, }, http.StatusOK) if ticket.Status != "accepted" || ticket.TicketID == "" { t.Fatalf("unexpected ticket response: %+v", ticket) } } func TestMarketplaceCheckoutBundlesMembershipAndMintsEntitlement(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ Wallet: ownerAddr, OfferID: offerIDWorkspaceCore, OrgRootID: "org.marketplace.a", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", }, http.StatusOK) if !quote.MembershipActivationIncluded { t.Fatalf("expected bundled membership on first checkout: %+v", quote) } if len(quote.LineItems) < 2 { t.Fatalf("expected license + membership line items: %+v", quote.LineItems) } confirm := postJSONExpect[marketplaceCheckoutConfirmResponse](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{ QuoteID: quote.QuoteID, Wallet: ownerAddr, OfferID: offerIDWorkspaceCore, OrgRootID: "org.marketplace.a", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ChainID: cfg.ChainID, }, http.StatusOK) if confirm.Status != "entitlement_active" || confirm.EntitlementID == "" { t.Fatalf("unexpected confirm response: %+v", confirm) } status := getJSONExpect[membershipStatusResponse](t, a, "/secret/membership/status?wallet="+ownerAddr, http.StatusOK) if status.Status != "active" { t.Fatalf("expected active membership after bundled checkout, got %+v", status) } entitlements := getJSONExpect[marketplaceEntitlementsResponse](t, a, "/marketplace/entitlements?wallet="+ownerAddr, http.StatusOK) if len(entitlements.Entitlements) == 0 { t.Fatalf("expected active entitlement after confirm") } if entitlements.Entitlements[0].OfferID != offerIDWorkspaceCore { t.Fatalf("unexpected entitlement offer: %+v", entitlements.Entitlements[0]) } } func TestMarketplaceDistinctPayerRequiresOwnershipProof(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) payerKey := mustKey(t) payerAddr := strings.ToLower(crypto.PubkeyToAddress(payerKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { t.Fatalf("seed active membership: %v", err) } errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ Wallet: ownerAddr, PayerWallet: payerAddr, OfferID: offerIDWorkspaceCore, }, http.StatusForbidden) if code := errResp["code"]; code != "ownership_proof_required" { t.Fatalf("expected ownership_proof_required, got %+v", errResp) } quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ Wallet: ownerAddr, PayerWallet: payerAddr, OfferID: offerIDWorkspaceCore, OwnershipProof: "0x" + strings.Repeat("a", 130), OrgRootID: "org.marketplace.ownerproof", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", }, http.StatusOK) if got := strings.ToLower(strings.TrimSpace(quote.PayerWallet)); got != payerAddr { t.Fatalf("payer wallet mismatch: got=%s want=%s", got, payerAddr) } if quote.Decimals != 6 || quote.OfferID != offerIDWorkspaceCore || quote.AccessClass != "connected" { t.Fatalf("unexpected quote response: %+v", quote) } } func TestMarketplaceCheckoutConfirmRejectsPayerWalletMismatch(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) payerKey := mustKey(t) payerAddr := strings.ToLower(crypto.PubkeyToAddress(payerKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { t.Fatalf("seed active membership: %v", err) } quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ Wallet: ownerAddr, PayerWallet: payerAddr, OfferID: offerIDWorkspaceCore, OwnershipProof: "0x" + strings.Repeat("a", 130), OrgRootID: "org.marketplace.payer", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", }, http.StatusOK) errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{ QuoteID: quote.QuoteID, Wallet: ownerAddr, PayerWallet: ownerAddr, OfferID: offerIDWorkspaceCore, OrgRootID: "org.marketplace.payer", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ChainID: cfg.ChainID, }, http.StatusBadRequest) if code := errResp["code"]; code != "context_mismatch" { t.Fatalf("expected context_mismatch, got %+v", errResp) } } func TestMarketplaceConfirmRequiresChainRPCWhenStrictVerificationEnabled(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() a.cfg.RequireOnchainTxVerify = true a.cfg.ChainRPCURL = "" ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ Wallet: ownerAddr, OfferID: offerIDWorkspaceCore, OrgRootID: "org.marketplace.strict", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", }, http.StatusOK) errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{ QuoteID: quote.QuoteID, Wallet: ownerAddr, OfferID: offerIDWorkspaceCore, OrgRootID: "org.marketplace.strict", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ChainID: cfg.ChainID, }, http.StatusServiceUnavailable) if code := errResp["code"]; code != "chain_verification_unavailable" { t.Fatalf("expected chain_verification_unavailable, got %+v", errResp) } } func TestMarketplaceQuoteUsesEntitlementContractTransactionWhenConfigured(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() a.cfg.EntitlementContract = "0x1111111111111111111111111111111111111111" ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { t.Fatalf("seed active membership: %v", err) } quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ Wallet: ownerAddr, OfferID: offerIDWorkspaceCore, OrgRootID: "org.marketplace.txshape", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", WorkspaceID: "workspace.alpha", }, http.StatusOK) if got := strings.ToLower(strings.TrimSpace(fmt.Sprint(quote.Tx["to"]))); got != strings.ToLower(a.cfg.EntitlementContract) { t.Fatalf("tx.to mismatch: got=%s want=%s quote=%+v", got, a.cfg.EntitlementContract, quote.Tx) } if got := strings.ToLower(strings.TrimSpace(fmt.Sprint(quote.Tx["from"]))); got != ownerAddr { t.Fatalf("tx.from mismatch: got=%s want=%s quote=%+v", got, ownerAddr, quote.Tx) } data := strings.ToLower(strings.TrimSpace(fmt.Sprint(quote.Tx["data"]))) if !strings.HasPrefix(data, "0x") || data == "0x" { t.Fatalf("expected encoded entitlement calldata, got=%+v", quote.Tx) } } func TestMarketplaceWorkspaceAddOnRequiresCoreEntitlement(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { t.Fatalf("seed active membership: %v", err) } errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ Wallet: ownerAddr, OfferID: offerIDWorkspaceAI, OrgRootID: "org.marketplace.addon", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", }, http.StatusForbidden) if code := errResp["code"]; code != "prerequisite_required" { t.Fatalf("expected prerequisite_required, got %+v", errResp) } if err := a.store.putMarketplaceEntitlement(context.Background(), marketplaceEntitlementRecord{ EntitlementID: "ent:seed:workspace-core", QuoteID: "seed-q-workspace-core", OfferID: offerIDWorkspaceCore, Wallet: ownerAddr, OrgRootID: "org.marketplace.addon", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", State: "active", AccessClass: "connected", AvailabilityState: "active", PolicyHash: "sha256:testpolicy", IssuedAt: time.Now().UTC(), TxHash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", }); err != nil { t.Fatalf("seed workspace core entitlement: %v", err) } quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ Wallet: ownerAddr, OfferID: offerIDWorkspaceAI, OrgRootID: "org.marketplace.addon", PrincipalID: "human.owner", PrincipalRole: "org_root_owner", }, http.StatusOK) if quote.OfferID != offerIDWorkspaceAI || quote.AmountAtomic != marketplaceStandardOfferAtomic { t.Fatalf("unexpected add-on quote response: %+v", quote) } } func TestMarketplaceSoloCoreScopeValidation(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() ownerKey := mustKey(t) ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { t.Fatalf("seed active membership: %v", err) } quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ Wallet: ownerAddr, OfferID: offerIDSoloCore, PrincipalRole: "org_root_owner", }, http.StatusOK) expectedRoot := defaultSoloOrgRootID(ownerAddr) if !strings.EqualFold(strings.TrimSpace(quote.OrgRootID), expectedRoot) { t.Fatalf("expected auto solo org root %s, got %+v", expectedRoot, quote) } errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ Wallet: ownerAddr, OfferID: offerIDSoloCore, OrgRootID: "org.invalid.workspace", PrincipalRole: "org_root_owner", }, http.StatusBadRequest) if code := errResp["code"]; code != "invalid_scope" { t.Fatalf("expected invalid_scope, got %+v", errResp) } } type tWalletIntentResponse struct { IntentID string `json:"intent_id"` DesignationCode string `json:"designation_code"` DisplayToken string `json:"display_token"` Nonce string `json:"nonce"` IssuedAt string `json:"issued_at"` } type tWalletVerifyResponse struct { Status string `json:"status"` DesignationCode string `json:"designation_code"` } func newTestApp(t *testing.T) (*app, Config, func()) { t.Helper() dbPath := filepath.Join(t.TempDir(), "secretapi-test.db") cfg := loadConfig() cfg.DBPath = dbPath cfg.AllowedOrigin = "*" cfg.ChainRPCURL = "" cfg.GovernancePackageHash = "sha256:testpackage" cfg.GovernancePolicyHash = "sha256:testpolicy" cfg.GovernancePackageURL = "https://cdn.test/edutd.tar.gz" cfg.GovernancePackageSig = "sig-test" cfg.GovernanceRuntimeVersion = "0.2.0" st, err := openStore(cfg.DBPath) if err != nil { t.Fatalf("open store: %v", err) } return newApp(cfg, st), cfg, func() { _ = st.close() } } func seedActiveMembership(ctx context.Context, st *store, wallet string) error { now := time.Now().UTC() return st.putDesignation(ctx, designationRecord{ Code: "1234567890123", DisplayToken: "1234-5678-9012-3", IntentID: "intent-seeded", Nonce: "nonce-seeded", Origin: "https://edut.ai", Locale: "en", Address: wallet, ChainID: 84532, IssuedAt: now.Add(-1 * time.Hour), ExpiresAt: now.Add(1 * time.Hour), VerifiedAt: &now, MembershipStatus: "active", MembershipTxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ActivatedAt: &now, }) } func postJSONExpect[T any](t *testing.T, a *app, path string, payload any, expectStatus int) T { t.Helper() body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal request: %v", err) } req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "https://edut.ai") rec := httptest.NewRecorder() a.routes().ServeHTTP(rec, req) if rec.Code != expectStatus { t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String()) } var out T if len(rec.Body.Bytes()) == 0 { return out } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode response: %v body=%s", err, rec.Body.String()) } return out } func getJSONExpect[T any](t *testing.T, a *app, path string, expectStatus int) T { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("Origin", "https://edut.ai") rec := httptest.NewRecorder() a.routes().ServeHTTP(rec, req) if rec.Code != expectStatus { t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String()) } var out T if len(rec.Body.Bytes()) == 0 { return out } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode response: %v body=%s", err, rec.Body.String()) } return out } func mustKey(t *testing.T) *ecdsa.PrivateKey { t.Helper() key, err := crypto.GenerateKey() if err != nil { t.Fatalf("generate key: %v", err) } return key } func signTypedData(t *testing.T, key *ecdsa.PrivateKey, typedData apitypes.TypedData) string { t.Helper() hash, _, err := apitypes.TypedDataAndHash(typedData) if err != nil { t.Fatalf("typed data hash: %v", err) } sig, err := crypto.Sign(hash, key) if err != nil { t.Fatalf("sign typed data: %v", err) } return "0x" + hex.EncodeToString(sig) } func signPersonalMessage(t *testing.T, key *ecdsa.PrivateKey, message string) string { t.Helper() hash := accounts.TextHash([]byte(message)) sig, err := crypto.Sign(hash, key) if err != nil { t.Fatalf("sign personal message: %v", err) } return "0x" + hex.EncodeToString(sig) }