package main import ( "bytes" "context" "crypto/ecdsa" "encoding/hex" "encoding/json" "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) } 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 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) } 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) } _ = 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) } } 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) }