1165 lines
42 KiB
Go
1165 lines
42 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 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")
|
|
}
|
|
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 {
|
|
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()
|
|
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)
|
|
}
|