Files
dev 2b54eb1527 feat(provider/northcapital): full TransactAPI adapter implementation
Pass 2 — every endpoint method is fully wired against the TransactAPI
public REST surface; the encrypted-webhook consumer is fully
implemented per the documented AES-256-CBC envelope; the conformance
suite drives every endpoint against httptest.Server fixtures with no
production HTTP traffic.

Endpoints (all fully implemented, no stubs):

  Party / Entity
    CreateParty, CreateEntity, GetParty, ListParties (paginated)

  KYC / AML / Accredited / Suitability
    PerformKYC, PerformAML
    PerformAccredited (income, net_worth, third_party_letter,
                       professional_license) + PerformAccreditedFull
    Suitability

  Offerings
    CreateOffering, GetOffering, ListOfferings (paginated)

  Trades
    CreateTrade, GetTrade, BulkUploadTrades (multipart),
    CancelTrade, RefundTrade,
    SendSubscriptionDocs, GetTradeDocuments

  Custody
    OpenCustodyAccount, GetCustodyAccount

  ATS / Secondary
    PublishATSEvent (uses EventID as the natural idem-key),
    GetSecondaryTrades

  Admin
    UpdateWebhookAuthKey

  Webhook
    ConsumeWebhook — full AES-256-CBC decryption per the documented
    TransactAPI scheme: URL-safe Base64 decode, undo "plusencr" → "+"
    substitution, split 16-byte IV, AES-256-CBC decrypt, PKCS#7 unpad
    (constant-time), parse "k=v, k=v, …" plaintext into the broker-wide
    WebhookEvent shape. HMAC-SHA256 signature gate runs when
    WebhookAuthKey is configured, with comma-separated key rotation
    overlap support.

Cross-cutting:

  - Uniform pagination: list endpoints iterate offset/limit internally
    until a short page or MaxRecords cap; callers receive complete
    result sets without writing pagination logic.
  - Rate-limiting + retry: exponential backoff with full jitter on 429
    and 5xx, honoring Retry-After (delta-seconds and HTTP-date); after
    the retry budget surfaces wrapped ErrRateLimited.
  - Idempotency: deriveIdempotencyKey(orgID, endpoint, canonicalBody)
    SHA-256 fallback on every mutating call; caller-supplied
    IdempotencyKey always wins. ATS PublishATSEvent uses EventID
    directly so duplicate events collapse upstream.
  - Authentication: clientID + developerAPIKey injected as form fields
    on every legacy v1 request; Bearer + X-Client-Id on the modern v3
    transport.
  - APIError carries StatusCode, Endpoint, Body, RetryAfter for
    errors.As recovery; envelope-level TransactAPI failures
    (statusCode != "101") are surfaced through the same shape.

Conformance suite (broker/pkg/provider/northcapital/conformance_test.go):

  - Every endpoint's happy path with form-field + idempotency-header
    assertions
  - All four accredited methods exercised independently
  - Bulk upload exercises the multipart/form-data path
  - Pagination iteration across multiple pages
  - 4xx mapping → *APIError; envelope-level errors mapped identically
  - 429 retry-then-succeed; 429 budget exhausted → ErrRateLimited
  - Retry-After parsing
  - Webhook signature: missing, bad, valid, rotation overlap
  - Webhook decryption: valid envelope, wrong key, invalid base64,
    plusencr substitution path, missing config
  - PKCS#7 unpadder unit tests (positive + 4 negative cases)

Build green: go build ./..., go vet ./..., go test -race ./pkg/provider/
northcapital/... all pass. 40 test cases pass, 0 failures.

Assumptions pinned for the NCPS sandbox handshake:

  1. Legacy v1 endpoints use form-encoded bodies with clientID +
     developerAPIKey carried as form fields on every call. Confirmed
     against the public reference; pinned if the sandbox key issuance
     diverges.
  2. The webhook HMAC ordering is decrypt-then-MAC over the raw
     envelope bytes. The public encrypted-webhooks guide is silent on
     this point; if the sandbox reveals MAC-then-decrypt, swap the
     ordering in ConsumeWebhook.
  3. The X-TransactAPI-Signature header name is the Lux-side
     convention pinned in the integration brief §7; remap if the
     sandbox uses a different upstream name.
  4. WebhookDecryptionKey is required to be exactly 32 bytes; the
     public guide says NCPS pads server-side to 32, but the adapter
     enforces 32 on the inbound to fail closed.

Source-of-design: Public-Spec
Source-ref: https://transactapi.readme.io/reference
Clean-Team-Member: dev-agent (pass 2 — full implementation)
Signed-off-by: dev <[email protected]>
2026-05-26 17:55:39 -07:00

95 lines
2.9 KiB
Go

// Source-of-design: Public-Spec
// Source-ref: https://transactapi.readme.io/reference
package northcapital
import (
"net/http"
"net/http/httptest"
"testing"
)
// newTestProvider returns a Provider whose HTTP client targets the
// supplied httptest.Server. No production HTTP traffic ever leaves
// the test process.
func newTestProvider(t *testing.T, h http.HandlerFunc) *Provider {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return New(Config{
BaseURL: srv.URL,
ClientID: "test-client",
DeveloperAPIKey: "test-key",
WebhookAuthKey: "test-webhook-key",
WebhookDecryptionKey: "0123456789abcdef0123456789abcdef", // 32 bytes
HTTPClient: srv.Client(),
MaxRetries: 1,
RetryBaseDelay: 1, // 1ns — make backoff trivial for tests
RetryMaxDelay: 1,
})
}
// TestNew verifies that a Provider can be constructed against the
// sandbox base URL with the documented credential fields, and that
// the Name() + Capabilities() surface is wired up.
func TestNew(t *testing.T) {
cases := []struct {
name string
cfg Config
}{
{
name: "sandbox",
cfg: Config{BaseURL: SandboxURL, ClientID: "cid", DeveloperAPIKey: "dak"},
},
{
name: "prod",
cfg: Config{BaseURL: ProdURL, ClientID: "cid", DeveloperAPIKey: "dak"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := New(tc.cfg)
if p == nil {
t.Fatal("New returned nil")
}
if p.Name() != "northcapital" {
t.Fatalf("Name: got %q, want %q", p.Name(), "northcapital")
}
caps := p.Capabilities()
if caps == nil || caps.Name != "northcapital" {
t.Fatalf("Capabilities: got %+v", caps)
}
if caps.Status != "active" {
t.Fatalf("Capabilities.Status: got %q, want %q", caps.Status, "active")
}
if len(caps.PaymentTypes) == 0 || len(caps.Features) == 0 {
t.Fatal("Capabilities must declare PaymentTypes + Features")
}
})
}
}
// TestDeriveIdempotencyKey_Deterministic locks the (orgID, endpoint,
// canonical-body) → key contract for the broker-local helper.
func TestDeriveIdempotencyKey_Deterministic(t *testing.T) {
a := deriveIdempotencyKey("org-A", "/createParty", []byte(`{"x":1}`))
b := deriveIdempotencyKey("org-A", "/createParty", []byte(`{"x":1}`))
c := deriveIdempotencyKey("org-B", "/createParty", []byte(`{"x":1}`))
if a == "" {
t.Fatal("deriveIdempotencyKey must produce a non-empty key")
}
if a != b {
t.Fatalf("deterministic call must collapse to the same key: %q vs %q", a, b)
}
if a == c {
t.Fatalf("different orgIDs must produce different keys; both got %q", a)
}
// Endpoint and body must each affect the key.
if d := deriveIdempotencyKey("org-A", "/createTrade", []byte(`{"x":1}`)); d == a {
t.Fatalf("different endpoint must produce different key")
}
if d := deriveIdempotencyKey("org-A", "/createParty", []byte(`{"x":2}`)); d == a {
t.Fatalf("different body must produce different key")
}
}