1902 lines
69 KiB
Go
1902 lines
69 KiB
Go
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 TestMembershipConfirmDefaultsToCryptoDirectAssurance(t *testing.T) {
|
|
a, cfg, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
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)
|
|
|
|
confirm := postJSONExpect[membershipConfirmResponse](t, a, "/secret/membership/confirm", membershipConfirmRequest{
|
|
DesignationCode: verifyRes.DesignationCode,
|
|
QuoteID: quote.QuoteID,
|
|
TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
Address: ownerAddr,
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusOK)
|
|
if confirm.IdentityAssurance != assuranceCryptoDirect {
|
|
t.Fatalf("expected %s assurance, got %+v", assuranceCryptoDirect, confirm)
|
|
}
|
|
|
|
status := getJSONExpect[membershipStatusResponse](t, a, "/secret/membership/status?wallet="+ownerAddr, http.StatusOK)
|
|
if status.IdentityAssurance != assuranceCryptoDirect {
|
|
t.Fatalf("expected status assurance %s, got %+v", assuranceCryptoDirect, status)
|
|
}
|
|
}
|
|
|
|
func TestMembershipConfirmAcceptsOnrampAttestationAssurance(t *testing.T) {
|
|
a, cfg, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
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)
|
|
|
|
confirm := postJSONExpect[membershipConfirmResponse](t, a, "/secret/membership/confirm", membershipConfirmRequest{
|
|
DesignationCode: verifyRes.DesignationCode,
|
|
QuoteID: quote.QuoteID,
|
|
TxHash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
Address: ownerAddr,
|
|
ChainID: cfg.ChainID,
|
|
IdentityAssurance: assuranceOnrampAttested,
|
|
IdentityAttestedBy: "moonpay",
|
|
IdentityAttestationID: "onramp-session-123",
|
|
}, http.StatusOK)
|
|
if confirm.IdentityAssurance != assuranceOnrampAttested || confirm.IdentityAttestedBy != "moonpay" {
|
|
t.Fatalf("unexpected attested confirm response: %+v", confirm)
|
|
}
|
|
}
|
|
|
|
func TestMembershipStatusPrefersActiveDesignationWhenNewerRecordIsUnactivated(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
ownerKey := mustKey(t)
|
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
|
now := time.Now().UTC()
|
|
|
|
activeIssuedAt := now.Add(-2 * time.Hour)
|
|
activeCode := "1111111111111"
|
|
if err := a.store.putDesignation(context.Background(), designationRecord{
|
|
Code: activeCode,
|
|
DisplayToken: "1111-1111-1111-1",
|
|
IntentID: "intent-active",
|
|
Nonce: "nonce-active",
|
|
Origin: "https://edut.ai",
|
|
Locale: "en",
|
|
Address: ownerAddr,
|
|
ChainID: 84532,
|
|
IssuedAt: activeIssuedAt,
|
|
ExpiresAt: activeIssuedAt.Add(time.Hour),
|
|
VerifiedAt: &activeIssuedAt,
|
|
MembershipStatus: "active",
|
|
MembershipTxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
ActivatedAt: &activeIssuedAt,
|
|
IdentityAssurance: assuranceCryptoDirect,
|
|
IdentityAttestedBy: "",
|
|
IdentityAttestationID: "",
|
|
}); err != nil {
|
|
t.Fatalf("seed active designation: %v", err)
|
|
}
|
|
|
|
newerIssuedAt := now
|
|
if err := a.store.putDesignation(context.Background(), designationRecord{
|
|
Code: "2222222222222",
|
|
DisplayToken: "2222-2222-2222-2",
|
|
IntentID: "intent-newer",
|
|
Nonce: "nonce-newer",
|
|
Origin: "https://edut.ai",
|
|
Locale: "en",
|
|
Address: ownerAddr,
|
|
ChainID: 84532,
|
|
IssuedAt: newerIssuedAt,
|
|
ExpiresAt: newerIssuedAt.Add(time.Hour),
|
|
VerifiedAt: &newerIssuedAt,
|
|
MembershipStatus: "none",
|
|
IdentityAssurance: assuranceNone,
|
|
IdentityAttestedBy: "",
|
|
IdentityAttestationID: "",
|
|
}); err != nil {
|
|
t.Fatalf("seed newer unactivated designation: %v", err)
|
|
}
|
|
|
|
status := getJSONExpect[membershipStatusResponse](t, a, "/secret/membership/status?wallet="+ownerAddr, http.StatusOK)
|
|
if status.Status != "active" {
|
|
t.Fatalf("expected active status, got %+v", status)
|
|
}
|
|
if status.DesignationCode != activeCode {
|
|
t.Fatalf("expected active designation code %s, got %+v", activeCode, status)
|
|
}
|
|
}
|
|
|
|
func TestMembershipConfirmRejectsAlreadyConfirmedQuote(t *testing.T) {
|
|
a, cfg, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
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)
|
|
|
|
_ = postJSONExpect[membershipConfirmResponse](t, a, "/secret/membership/confirm", membershipConfirmRequest{
|
|
DesignationCode: verifyRes.DesignationCode,
|
|
QuoteID: quote.QuoteID,
|
|
TxHash: "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
|
Address: ownerAddr,
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusOK)
|
|
|
|
_ = postJSONExpect[map[string]string](t, a, "/secret/membership/confirm", membershipConfirmRequest{
|
|
DesignationCode: verifyRes.DesignationCode,
|
|
QuoteID: quote.QuoteID,
|
|
TxHash: "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
|
Address: ownerAddr,
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusConflict)
|
|
}
|
|
|
|
func TestMembershipConfirmRejectsTxHashReplayAcrossDesignations(t *testing.T) {
|
|
a, cfg, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
ownerKey := mustKey(t)
|
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
|
|
|
signVerify := func(intent tWalletIntentResponse) tWalletVerifyResponse {
|
|
issuedAt, err := time.Parse(time.RFC3339Nano, intent.IssuedAt)
|
|
if err != nil {
|
|
t.Fatalf("parse issued_at: %v", err)
|
|
}
|
|
td := buildTypedData(cfg, designationRecord{
|
|
Code: intent.DesignationCode,
|
|
DisplayToken: intent.DisplayToken,
|
|
Nonce: intent.Nonce,
|
|
IssuedAt: issuedAt,
|
|
Origin: "https://edut.ai",
|
|
})
|
|
sig := signTypedData(t, ownerKey, td)
|
|
return postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{
|
|
IntentID: intent.IntentID,
|
|
Address: ownerAddr,
|
|
ChainID: cfg.ChainID,
|
|
Signature: sig,
|
|
}, http.StatusOK)
|
|
}
|
|
|
|
intent1 := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{
|
|
Address: ownerAddr,
|
|
Origin: "https://edut.ai",
|
|
Locale: "en",
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusOK)
|
|
verify1 := signVerify(intent1)
|
|
quote1 := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{
|
|
DesignationCode: verify1.DesignationCode,
|
|
Address: ownerAddr,
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusOK)
|
|
|
|
replayedTxHash := "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
|
_ = postJSONExpect[membershipConfirmResponse](t, a, "/secret/membership/confirm", membershipConfirmRequest{
|
|
DesignationCode: verify1.DesignationCode,
|
|
QuoteID: quote1.QuoteID,
|
|
TxHash: replayedTxHash,
|
|
Address: ownerAddr,
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusOK)
|
|
|
|
intent2 := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{
|
|
Address: ownerAddr,
|
|
Origin: "https://edut.ai",
|
|
Locale: "en",
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusOK)
|
|
verify2 := signVerify(intent2)
|
|
quote2 := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{
|
|
DesignationCode: verify2.DesignationCode,
|
|
Address: ownerAddr,
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusOK)
|
|
|
|
errResp := postJSONExpect[map[string]string](t, a, "/secret/membership/confirm", membershipConfirmRequest{
|
|
DesignationCode: verify2.DesignationCode,
|
|
QuoteID: quote2.QuoteID,
|
|
TxHash: replayedTxHash,
|
|
Address: ownerAddr,
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusConflict)
|
|
if code := errResp["code"]; code != "tx_hash_replay" {
|
|
t.Fatalf("expected tx_hash_replay, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
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 TestGovernanceInstallTokenRequiresOnrampAssurance(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
ownerKey := mustKey(t)
|
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
|
if err := seedMembershipWithAssurance(context.Background(), a.store, ownerAddr, assuranceCryptoDirect); 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:assurance",
|
|
QuoteID: "seed-q-install-workspace-core-assurance",
|
|
OfferID: offerIDWorkspaceCore,
|
|
Wallet: ownerAddr,
|
|
OrgRootID: "org_root_assurance",
|
|
PrincipalID: "principal_owner_assurance",
|
|
PrincipalRole: "org_root_owner",
|
|
State: "active",
|
|
AccessClass: "connected",
|
|
AvailabilityState: "active",
|
|
PolicyHash: "sha256:testpolicy",
|
|
IssuedAt: now,
|
|
TxHash: "0xabababababababababababababababababababababababababababababababab",
|
|
}); err != nil {
|
|
t.Fatalf("seed workspace core entitlement: %v", err)
|
|
}
|
|
if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{
|
|
Wallet: ownerAddr,
|
|
OrgRootID: "org_root_assurance",
|
|
PrincipalID: "principal_owner_assurance",
|
|
PrincipalRole: "org_root_owner",
|
|
EntitlementID: "ent:install:workspace-core:assurance",
|
|
EntitlementStatus: "active",
|
|
AccessClass: "connected",
|
|
AvailabilityState: "active",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("seed owner principal: %v", err)
|
|
}
|
|
|
|
errResp := postJSONExpect[map[string]string](t, a, "/governance/install/token", governanceInstallTokenRequest{
|
|
Wallet: ownerAddr,
|
|
OrgRootID: "org_root_assurance",
|
|
PrincipalID: "principal_owner_assurance",
|
|
PrincipalRole: "org_root_owner",
|
|
DeviceID: "macstudio-assurance",
|
|
LauncherVersion: "0.1.0",
|
|
Platform: "macos",
|
|
}, http.StatusForbidden)
|
|
if code := errResp["code"]; code != "identity_assurance_insufficient" {
|
|
t.Fatalf("expected identity_assurance_insufficient, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
if events.MembershipStatus != "active" {
|
|
t.Fatalf("expected active membership status in events payload, got %+v", events)
|
|
}
|
|
if events.IdentityAssurance != assuranceOnrampAttested {
|
|
t.Fatalf("expected onramp assurance in events payload, got %+v", events)
|
|
}
|
|
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 TestMemberChannelEventsAllowUnattestedMembership(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
memberKey := mustKey(t)
|
|
memberAddr := strings.ToLower(crypto.PubkeyToAddress(memberKey.PublicKey).Hex())
|
|
if err := seedMembershipWithAssurance(context.Background(), a.store, memberAddr, assuranceCryptoDirect); err != nil {
|
|
t.Fatalf("seed member membership: %v", err)
|
|
}
|
|
|
|
_ = postJSONExpect[memberChannelDeviceRegisterResponse](t, a, "/member/channel/device/register", memberChannelDeviceRegisterRequest{
|
|
Wallet: memberAddr,
|
|
ChainID: 84532,
|
|
DeviceID: "desktop-unattested-01",
|
|
Platform: "desktop",
|
|
OrgRootID: "org.events.unattested",
|
|
PrincipalID: "human.member",
|
|
PrincipalRole: "workspace_member",
|
|
AppVersion: "0.1.0",
|
|
PushProvider: "none",
|
|
}, http.StatusOK)
|
|
|
|
events := getJSONExpect[memberChannelEventsResponse](t, a, "/member/channel/events?wallet="+memberAddr+"&device_id=desktop-unattested-01&limit=25", http.StatusOK)
|
|
if len(events.Events) == 0 {
|
|
t.Fatalf("expected seeded events for unattested member, got none")
|
|
}
|
|
if events.IdentityAssurance != assuranceCryptoDirect {
|
|
t.Fatalf("expected crypto_direct_unattested assurance for unattested member events, got %+v", events)
|
|
}
|
|
}
|
|
|
|
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 TestMemberChannelSupportTicketRequiresOnrampAttestation(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
ownerKey := mustKey(t)
|
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
|
if err := seedMembershipWithAssurance(context.Background(), a.store, ownerAddr, assuranceCryptoDirect); 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-unattested-01",
|
|
Platform: "desktop",
|
|
OrgRootID: "org.support.assurance",
|
|
PrincipalID: "human.owner",
|
|
PrincipalRole: "org_root_owner",
|
|
AppVersion: "0.1.0",
|
|
PushProvider: "none",
|
|
}, http.StatusOK)
|
|
|
|
errResp := postJSONExpect[map[string]string](t, a, "/member/channel/support/ticket", memberChannelSupportTicketRequest{
|
|
Wallet: ownerAddr,
|
|
OrgRootID: "org.support.assurance",
|
|
PrincipalID: "human.owner",
|
|
Category: "admin_support",
|
|
Summary: "Need owner diagnostics.",
|
|
}, http.StatusForbidden)
|
|
if code := errResp["code"]; code != "identity_assurance_insufficient" {
|
|
t.Fatalf("expected identity_assurance_insufficient, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
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 TestMarketplaceCheckoutConfirmRejectsTxHashReplayAcrossQuotes(t *testing.T) {
|
|
a, cfg, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
ownerAKey := mustKey(t)
|
|
ownerAAddr := strings.ToLower(crypto.PubkeyToAddress(ownerAKey.PublicKey).Hex())
|
|
ownerBKey := mustKey(t)
|
|
ownerBAddr := strings.ToLower(crypto.PubkeyToAddress(ownerBKey.PublicKey).Hex())
|
|
if err := seedActiveMembership(context.Background(), a.store, ownerAAddr); err != nil {
|
|
t.Fatalf("seed owner A membership: %v", err)
|
|
}
|
|
if err := seedActiveMembership(context.Background(), a.store, ownerBAddr); err != nil {
|
|
t.Fatalf("seed owner B membership: %v", err)
|
|
}
|
|
|
|
quoteA := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
|
Wallet: ownerAAddr,
|
|
OfferID: offerIDWorkspaceCore,
|
|
OrgRootID: "org.marketplace.replay.a",
|
|
PrincipalID: "human.owner.a",
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusOK)
|
|
|
|
replayedTxHash := "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
|
_ = postJSONExpect[marketplaceCheckoutConfirmResponse](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{
|
|
QuoteID: quoteA.QuoteID,
|
|
Wallet: ownerAAddr,
|
|
OfferID: offerIDWorkspaceCore,
|
|
OrgRootID: "org.marketplace.replay.a",
|
|
PrincipalID: "human.owner.a",
|
|
PrincipalRole: "org_root_owner",
|
|
TxHash: replayedTxHash,
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusOK)
|
|
|
|
quoteB := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
|
Wallet: ownerBAddr,
|
|
OfferID: offerIDWorkspaceCore,
|
|
OrgRootID: "org.marketplace.replay.b",
|
|
PrincipalID: "human.owner.b",
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusOK)
|
|
|
|
errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{
|
|
QuoteID: quoteB.QuoteID,
|
|
Wallet: ownerBAddr,
|
|
OfferID: offerIDWorkspaceCore,
|
|
OrgRootID: "org.marketplace.replay.b",
|
|
PrincipalID: "human.owner.b",
|
|
PrincipalRole: "org_root_owner",
|
|
TxHash: replayedTxHash,
|
|
ChainID: cfg.ChainID,
|
|
}, http.StatusConflict)
|
|
if code := errResp["code"]; code != "tx_hash_replay" {
|
|
t.Fatalf("expected tx_hash_replay, 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 TestMarketplaceQuoteFailsWhenEntitlementContractUnconfigured(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
a.cfg.EntitlementContract = "0x0000000000000000000000000000000000000000"
|
|
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: offerIDWorkspaceCore,
|
|
OrgRootID: "org.marketplace.unconfigured",
|
|
PrincipalID: "human.owner",
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusServiceUnavailable)
|
|
if code := errResp["code"]; code != "entitlement_contract_unconfigured" {
|
|
t.Fatalf("expected entitlement_contract_unconfigured, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestWalletSessionRequiredForMarketplaceQuote(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
a.cfg.RequireWalletSession = true
|
|
|
|
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: offerIDSoloCore,
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusUnauthorized)
|
|
if code := errResp["code"]; code != "wallet_session_required" {
|
|
t.Fatalf("expected wallet_session_required, got %+v", errResp)
|
|
}
|
|
|
|
session, err := a.issueWalletSession(context.Background(), ownerAddr, "1234567890123")
|
|
if err != nil {
|
|
t.Fatalf("issue wallet session: %v", err)
|
|
}
|
|
quote := postJSONExpectWithHeaders[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
|
Wallet: ownerAddr,
|
|
OfferID: offerIDSoloCore,
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusOK, map[string]string{
|
|
sessionHeaderToken: session.SessionToken,
|
|
})
|
|
if strings.TrimSpace(quote.QuoteID) == "" {
|
|
t.Fatalf("expected quote id with valid wallet session")
|
|
}
|
|
}
|
|
|
|
func TestWalletSessionMismatchBlocked(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
a.cfg.RequireWalletSession = true
|
|
|
|
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)
|
|
}
|
|
|
|
otherKey := mustKey(t)
|
|
otherAddr := strings.ToLower(crypto.PubkeyToAddress(otherKey.PublicKey).Hex())
|
|
session, err := a.issueWalletSession(context.Background(), otherAddr, "9999999999999")
|
|
if err != nil {
|
|
t.Fatalf("issue wallet session: %v", err)
|
|
}
|
|
|
|
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
|
Wallet: ownerAddr,
|
|
OfferID: offerIDSoloCore,
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusForbidden, map[string]string{
|
|
sessionHeaderToken: session.SessionToken,
|
|
})
|
|
if code := errResp["code"]; code != "wallet_session_mismatch" {
|
|
t.Fatalf("expected wallet_session_mismatch, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
func TestWalletVerifyIssuesSessionToken(t *testing.T) {
|
|
a, cfg, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
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)
|
|
if strings.TrimSpace(verifyRes.SessionToken) == "" {
|
|
t.Fatalf("expected wallet verify to issue session token: %+v", verifyRes)
|
|
}
|
|
if strings.TrimSpace(verifyRes.SessionExpiresAt) == "" {
|
|
t.Fatalf("expected wallet verify to return session expiry: %+v", verifyRes)
|
|
}
|
|
}
|
|
|
|
func TestWalletSessionInvalidBlocked(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
a.cfg.RequireWalletSession = true
|
|
|
|
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 := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
|
Wallet: ownerAddr,
|
|
OfferID: offerIDSoloCore,
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusUnauthorized, map[string]string{
|
|
sessionHeaderToken: "deadbeef",
|
|
})
|
|
if code := errResp["code"]; code != "wallet_session_invalid" {
|
|
t.Fatalf("expected wallet_session_invalid, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
func TestWalletSessionExpiredBlocked(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
a.cfg.RequireWalletSession = true
|
|
|
|
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)
|
|
}
|
|
now := time.Now().UTC()
|
|
session := walletSessionRecord{
|
|
SessionToken: "expired-session-token",
|
|
Wallet: ownerAddr,
|
|
DesignationCode: "1234567890123",
|
|
ChainID: a.cfg.ChainID,
|
|
IssuedAt: now.Add(-2 * time.Hour),
|
|
ExpiresAt: now.Add(-1 * time.Minute),
|
|
}
|
|
if err := a.store.putWalletSession(context.Background(), session); err != nil {
|
|
t.Fatalf("seed wallet session: %v", err)
|
|
}
|
|
|
|
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
|
Wallet: ownerAddr,
|
|
OfferID: offerIDSoloCore,
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusUnauthorized, map[string]string{
|
|
sessionHeaderToken: session.SessionToken,
|
|
})
|
|
if code := errResp["code"]; code != "wallet_session_expired" {
|
|
t.Fatalf("expected wallet_session_expired, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
func TestWalletSessionRevokedBlocked(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
a.cfg.RequireWalletSession = true
|
|
|
|
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)
|
|
}
|
|
now := time.Now().UTC()
|
|
session := walletSessionRecord{
|
|
SessionToken: "revoked-session-token",
|
|
Wallet: ownerAddr,
|
|
DesignationCode: "1234567890123",
|
|
ChainID: a.cfg.ChainID,
|
|
IssuedAt: now.Add(-1 * time.Hour),
|
|
ExpiresAt: now.Add(1 * time.Hour),
|
|
RevokedAt: &now,
|
|
}
|
|
if err := a.store.putWalletSession(context.Background(), session); err != nil {
|
|
t.Fatalf("seed wallet session: %v", err)
|
|
}
|
|
|
|
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
|
Wallet: ownerAddr,
|
|
OfferID: offerIDSoloCore,
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusUnauthorized, map[string]string{
|
|
sessionHeaderToken: session.SessionToken,
|
|
})
|
|
if code := errResp["code"]; code != "wallet_session_revoked" {
|
|
t.Fatalf("expected wallet_session_revoked, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
func TestWalletSessionRefreshRotatesToken(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
a.cfg.RequireWalletSession = true
|
|
|
|
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)
|
|
}
|
|
session, err := a.issueWalletSession(context.Background(), ownerAddr, "1234567890123")
|
|
if err != nil {
|
|
t.Fatalf("issue wallet session: %v", err)
|
|
}
|
|
|
|
refresh := postJSONExpectWithHeaders[walletSessionRefreshResponse](t, a, "/secret/wallet/session/refresh", walletSessionRefreshRequest{
|
|
Wallet: ownerAddr,
|
|
}, http.StatusOK, map[string]string{
|
|
sessionHeaderToken: session.SessionToken,
|
|
})
|
|
if refresh.Status != "session_refreshed" {
|
|
t.Fatalf("expected refreshed status, got %+v", refresh)
|
|
}
|
|
if strings.TrimSpace(refresh.SessionToken) == "" || refresh.SessionToken == session.SessionToken {
|
|
t.Fatalf("expected rotated token, got %+v", refresh)
|
|
}
|
|
|
|
oldBlocked := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
|
Wallet: ownerAddr,
|
|
OfferID: offerIDSoloCore,
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusUnauthorized, map[string]string{
|
|
sessionHeaderToken: session.SessionToken,
|
|
})
|
|
if code := oldBlocked["code"]; code != "wallet_session_revoked" {
|
|
t.Fatalf("expected old token revoked, got %+v", oldBlocked)
|
|
}
|
|
newAllowed := postJSONExpectWithHeaders[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
|
Wallet: ownerAddr,
|
|
OfferID: offerIDSoloCore,
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusOK, map[string]string{
|
|
sessionHeaderToken: refresh.SessionToken,
|
|
})
|
|
if strings.TrimSpace(newAllowed.QuoteID) == "" {
|
|
t.Fatalf("expected quote with refreshed token")
|
|
}
|
|
}
|
|
|
|
func TestWalletSessionRevokeEndpointBlocksFurtherUse(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
a.cfg.RequireWalletSession = true
|
|
|
|
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)
|
|
}
|
|
session, err := a.issueWalletSession(context.Background(), ownerAddr, "1234567890123")
|
|
if err != nil {
|
|
t.Fatalf("issue wallet session: %v", err)
|
|
}
|
|
|
|
revoked := postJSONExpectWithHeaders[walletSessionRevokeResponse](t, a, "/secret/wallet/session/revoke", walletSessionRevokeRequest{
|
|
Wallet: ownerAddr,
|
|
}, http.StatusOK, map[string]string{
|
|
sessionHeaderToken: session.SessionToken,
|
|
})
|
|
if revoked.Status != "session_revoked" {
|
|
t.Fatalf("unexpected revoke response: %+v", revoked)
|
|
}
|
|
|
|
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
|
Wallet: ownerAddr,
|
|
OfferID: offerIDSoloCore,
|
|
PrincipalRole: "org_root_owner",
|
|
}, http.StatusUnauthorized, map[string]string{
|
|
sessionHeaderToken: session.SessionToken,
|
|
})
|
|
if code := errResp["code"]; code != "wallet_session_revoked" {
|
|
t.Fatalf("expected revoked token to fail, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
func TestMemberChannelRegisterRequiresWalletSession(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
a.cfg.RequireWalletSession = true
|
|
|
|
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, "/member/channel/device/register", memberChannelDeviceRegisterRequest{
|
|
Wallet: ownerAddr,
|
|
ChainID: a.cfg.ChainID,
|
|
DeviceID: "desktop-local-01",
|
|
Platform: "desktop",
|
|
OrgRootID: "org_test",
|
|
PrincipalID: "principal_test",
|
|
PrincipalRole: "org_root_owner",
|
|
AppVersion: "0.1.0",
|
|
PushProvider: "none",
|
|
}, http.StatusUnauthorized)
|
|
if code := errResp["code"]; code != "wallet_session_required" {
|
|
t.Fatalf("expected wallet_session_required, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
func TestGovernanceInstallTokenRequiresWalletSession(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
a.cfg.RequireWalletSession = true
|
|
|
|
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)
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{
|
|
Wallet: ownerAddr,
|
|
OrgRootID: "org_test",
|
|
PrincipalID: "principal_test",
|
|
PrincipalRole: "org_root_owner",
|
|
EntitlementID: "ent_test",
|
|
EntitlementStatus: "active",
|
|
AccessClass: "connected",
|
|
AvailabilityState: "active",
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("seed governance principal: %v", err)
|
|
}
|
|
errResp := postJSONExpect[map[string]string](t, a, "/governance/install/token", governanceInstallTokenRequest{
|
|
Wallet: ownerAddr,
|
|
OrgRootID: "org_test",
|
|
PrincipalID: "principal_test",
|
|
PrincipalRole: "org_root_owner",
|
|
DeviceID: "device-test",
|
|
LauncherVersion: "0.1.0",
|
|
Platform: "desktop",
|
|
}, http.StatusUnauthorized)
|
|
if code := errResp["code"]; code != "wallet_session_required" {
|
|
t.Fatalf("expected wallet_session_required, got %+v", errResp)
|
|
}
|
|
}
|
|
|
|
func TestIssueWalletSessionPrunesExpired(t *testing.T) {
|
|
a, _, cleanup := newTestApp(t)
|
|
defer cleanup()
|
|
|
|
ownerKey := mustKey(t)
|
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
|
now := time.Now().UTC()
|
|
expired := walletSessionRecord{
|
|
SessionToken: "prune-me",
|
|
Wallet: ownerAddr,
|
|
DesignationCode: "1111111111111",
|
|
ChainID: a.cfg.ChainID,
|
|
IssuedAt: now.Add(-3 * time.Hour),
|
|
ExpiresAt: now.Add(-2 * time.Hour),
|
|
}
|
|
if err := a.store.putWalletSession(context.Background(), expired); err != nil {
|
|
t.Fatalf("seed expired session: %v", err)
|
|
}
|
|
if _, err := a.issueWalletSession(context.Background(), ownerAddr, "2222222222222"); err != nil {
|
|
t.Fatalf("issue wallet session: %v", err)
|
|
}
|
|
_, err := a.store.getWalletSession(context.Background(), expired.SessionToken)
|
|
if err != errNotFound {
|
|
t.Fatalf("expected expired session to be pruned, got err=%v", err)
|
|
}
|
|
}
|
|
|
|
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"`
|
|
SessionToken string `json:"session_token"`
|
|
SessionExpiresAt string `json:"session_expires_at"`
|
|
}
|
|
|
|
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"
|
|
cfg.RequireWalletSession = false
|
|
cfg.EntitlementContract = "0x1111111111111111111111111111111111111111"
|
|
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 {
|
|
return seedMembershipWithAssurance(ctx, st, wallet, assuranceOnrampAttested)
|
|
}
|
|
|
|
func seedMembershipWithAssurance(ctx context.Context, st *store, wallet, assurance string) error {
|
|
now := time.Now().UTC()
|
|
assurance = normalizeAssuranceLevel(assurance)
|
|
attestedBy := ""
|
|
var attestedAt *time.Time
|
|
if assurance == assuranceOnrampAttested {
|
|
attestedBy = "test-onramp"
|
|
attestedAt = &now
|
|
}
|
|
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,
|
|
IdentityAssurance: assurance,
|
|
IdentityAttestedBy: attestedBy,
|
|
IdentityAttestationID: "test-attestation",
|
|
IdentityAttestedAt: attestedAt,
|
|
})
|
|
}
|
|
|
|
func postJSONExpect[T any](t *testing.T, a *app, path string, payload any, expectStatus int) T {
|
|
t.Helper()
|
|
return postJSONExpectWithHeaders[T](t, a, path, payload, expectStatus, nil)
|
|
}
|
|
|
|
func postJSONExpectWithHeaders[T any](t *testing.T, a *app, path string, payload any, expectStatus int, headers map[string]string) 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")
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
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()
|
|
return getJSONExpectWithHeaders[T](t, a, path, expectStatus, nil)
|
|
}
|
|
|
|
func getJSONExpectWithHeaders[T any](t *testing.T, a *app, path string, expectStatus int, headers map[string]string) T {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("Origin", "https://edut.ai")
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
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)
|
|
}
|