mirror of
https://github.com/luxfi/broker.git
synced 2026-10-30 14:45:05 +00:00
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]>
95 lines
2.9 KiB
Go
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")
|
|
}
|
|
}
|