Files
zeekayandHanzo Dev 53f764d698 chore(aml): drop optional external-Jube provider — luxfi/aml is native
Remove the optional github.com/luxfi/compliance/pkg/jube provider from the
broker compliance layer. AML screening keeps working via the OFAC SDN list
and the ScamSniffer database; the native github.com/luxfi/aml engine (amld)
is the deeper risk-scoring backend. Removed the jube import, the jubeClient
field/option, and every jube-guarded block; docs updated to reflect the
native engine.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-17 12:16:00 -07:00

3552 lines
102 KiB
Go

package compliance
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/luxfi/broker/pkg/provider"
"github.com/luxfi/broker/pkg/types"
)
func newTestRouter() (chi.Router, *MemoryStore) {
store := NewStore()
router := NewRouter(store, WithScamDB(NewScamDB()))
return router, store
}
// doRequest sends a request with gateway-style IAM headers (built-in admin org).
func doRequest(r chi.Router, method, path string, body interface{}) *httptest.ResponseRecorder {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Id", "test-user-001")
req.Header.Set("X-Org-Id", "built-in")
req.Header.Set("X-User-Email", "[email protected]")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// doRequestAsOrg sends a request as a user in the specified org.
func doRequestAsOrg(r chi.Router, method, path string, body interface{}, userID, org string) *httptest.ResponseRecorder {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Id", userID)
req.Header.Set("X-Org-Id", org)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func decodeJSON(t *testing.T, w *httptest.ResponseRecorder, v interface{}) {
t.Helper()
if err := json.NewDecoder(w.Body).Decode(v); err != nil {
t.Fatalf("decode JSON: %v (body: %s)", err, w.Body.String())
}
}
// ==========================================================================
// KYC Tests
// ==========================================================================
func TestKYCVerifyAndGet(t *testing.T) {
r, _ := newTestRouter()
// Create a pending KYC identity.
w := doRequest(r, "POST", "/kyc/verify", map[string]string{
"user_id": "user-123",
"provider": "onfido",
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var ident Identity
decodeJSON(t, w, &ident)
if ident.UserID != "user-123" {
t.Fatalf("expected user_id user-123, got %s", ident.UserID)
}
if ident.Status != KYCPending {
t.Fatalf("expected status pending, got %s", ident.Status)
}
// Retrieve it.
w = doRequest(r, "GET", "/kyc/"+ident.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var fetched Identity
decodeJSON(t, w, &fetched)
if fetched.ID != ident.ID {
t.Fatalf("ID mismatch: %s != %s", fetched.ID, ident.ID)
}
}
func TestKYCVerifyMissingUserID(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/kyc/verify", map[string]string{"provider": "onfido"})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestKYCGetStatus(t *testing.T) {
r, store := newTestRouter()
// Seed an identity directly in the store.
ident := &Identity{
UserID: "user-status",
Provider: "berbix",
Status: KYCVerified,
Data: map[string]interface{}{"score": 95},
}
if err := store.SaveIdentity(ident); err != nil {
t.Fatal(err)
}
w := doRequest(r, "GET", "/kyc/"+ident.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var got Identity
decodeJSON(t, w, &got)
if got.Status != KYCVerified {
t.Fatalf("expected status verified, got %s", got.Status)
}
if got.Data["score"] == nil {
t.Fatal("expected data.score to be set")
}
}
func TestKYCVerifyWithMissingFields(t *testing.T) {
r, _ := newTestRouter()
// Missing both user_id and provider -- user_id is required.
w := doRequest(r, "POST", "/kyc/verify", map[string]string{})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestKYCDuplicateVerification(t *testing.T) {
r, _ := newTestRouter()
body := map[string]string{
"user_id": "user-dup",
"provider": "onfido",
}
// First verification.
w1 := doRequest(r, "POST", "/kyc/verify", body)
if w1.Code != http.StatusCreated {
t.Fatalf("first: expected 201, got %d", w1.Code)
}
var id1 Identity
decodeJSON(t, w1, &id1)
// Second verification for same user -- both should succeed with different IDs.
w2 := doRequest(r, "POST", "/kyc/verify", body)
if w2.Code != http.StatusCreated {
t.Fatalf("second: expected 201, got %d", w2.Code)
}
var id2 Identity
decodeJSON(t, w2, &id2)
if id1.ID == id2.ID {
t.Fatal("duplicate verifications should have different IDs")
}
}
func TestKYCGetNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/kyc/nonexistent-id", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestKYCVerifyInvalidBody(t *testing.T) {
r, _ := newTestRouter()
// Send a raw invalid JSON body.
req := httptest.NewRequest("POST", "/kyc/verify", bytes.NewBufferString("{invalid"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Id", "test-user-001")
req.Header.Set("X-Org-Id", "built-in")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
// ==========================================================================
// Pipeline Tests
// ==========================================================================
func TestPipelineCRUD(t *testing.T) {
r, _ := newTestRouter()
// Create
w := doRequest(r, "POST", "/pipelines/", map[string]interface{}{
"name": "Reg D Pipeline",
"business_id": "biz-1",
"steps": []map[string]interface{}{
{"id": "s1", "name": "KYC", "type": "kyc", "required": true, "order": 1},
{"id": "s2", "name": "eSign", "type": "esign", "required": true, "order": 2},
},
})
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var pipeline Pipeline
decodeJSON(t, w, &pipeline)
if pipeline.Name != "Reg D Pipeline" {
t.Fatalf("expected name 'Reg D Pipeline', got %q", pipeline.Name)
}
if pipeline.Status != "draft" {
t.Fatalf("expected default status 'draft', got %q", pipeline.Status)
}
if len(pipeline.Steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(pipeline.Steps))
}
// List
w = doRequest(r, "GET", "/pipelines/", nil)
if w.Code != http.StatusOK {
t.Fatalf("list: expected 200, got %d", w.Code)
}
var pipelines []*Pipeline
decodeJSON(t, w, &pipelines)
if len(pipelines) != 1 {
t.Fatalf("expected 1 pipeline, got %d", len(pipelines))
}
// Get
w = doRequest(r, "GET", "/pipelines/"+pipeline.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("get: expected 200, got %d", w.Code)
}
// Patch
newName := "Updated Pipeline"
w = doRequest(r, "PATCH", "/pipelines/"+pipeline.ID, map[string]interface{}{
"name": newName,
})
if w.Code != http.StatusOK {
t.Fatalf("patch: expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated Pipeline
decodeJSON(t, w, &updated)
if updated.Name != newName {
t.Fatalf("expected name %q, got %q", newName, updated.Name)
}
// Delete
w = doRequest(r, "DELETE", "/pipelines/"+pipeline.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete: expected 200, got %d", w.Code)
}
// Get after delete -> 404
w = doRequest(r, "GET", "/pipelines/"+pipeline.ID, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("get-after-delete: expected 404, got %d", w.Code)
}
}
func TestPipelineFilterByStatus(t *testing.T) {
r, store := newTestRouter()
// Create pipelines with different statuses.
for _, s := range []string{"active", "draft", "archived", "active"} {
store.SavePipeline(&Pipeline{Name: "Pipeline " + s, Status: s})
}
w := doRequest(r, "GET", "/pipelines/", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var all []*Pipeline
decodeJSON(t, w, &all)
if len(all) != 4 {
t.Fatalf("expected 4 pipelines, got %d", len(all))
}
// Count by status.
counts := map[string]int{}
for _, p := range all {
counts[p.Status]++
}
if counts["active"] != 2 {
t.Fatalf("expected 2 active, got %d", counts["active"])
}
if counts["draft"] != 1 {
t.Fatalf("expected 1 draft, got %d", counts["draft"])
}
if counts["archived"] != 1 {
t.Fatalf("expected 1 archived, got %d", counts["archived"])
}
}
func TestPipelineCreateMissingName(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/pipelines/", map[string]interface{}{
"business_id": "biz-1",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestPipelineCreateInvalidBody(t *testing.T) {
r, _ := newTestRouter()
req := httptest.NewRequest("POST", "/pipelines/", bytes.NewBufferString("not json"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Id", "test-user-001")
req.Header.Set("X-Org-Id", "built-in")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestPipelineDeleteNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "DELETE", "/pipelines/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestPipelineUpdateNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "PATCH", "/pipelines/nonexistent", map[string]interface{}{
"name": "x",
})
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
// ==========================================================================
// Session Tests
// ==========================================================================
func TestSessionLifecycle(t *testing.T) {
r, store := newTestRouter()
// Create a pipeline first.
p := &Pipeline{
Name: "Test Pipeline",
Status: "active",
Steps: []PipelineStep{
{ID: "s1", Name: "KYC", Type: "kyc", Required: true, Order: 1},
},
}
if err := store.SavePipeline(p); err != nil {
t.Fatalf("save pipeline: %v", err)
}
// Create session.
w := doRequest(r, "POST", "/sessions/", map[string]string{
"pipeline_id": p.ID,
"investor_email": "[email protected]",
"investor_name": "John Doe",
})
if w.Code != http.StatusCreated {
t.Fatalf("create session: expected 201, got %d: %s", w.Code, w.Body.String())
}
var sess Session
decodeJSON(t, w, &sess)
if sess.Status != SessionPending {
t.Fatalf("expected status pending, got %s", sess.Status)
}
if len(sess.Steps) != 1 {
t.Fatalf("expected 1 step, got %d", len(sess.Steps))
}
// Get session steps.
w = doRequest(r, "GET", "/sessions/"+sess.ID+"/steps", nil)
if w.Code != http.StatusOK {
t.Fatalf("get steps: expected 200, got %d", w.Code)
}
var steps []SessionStep
decodeJSON(t, w, &steps)
if len(steps) != 1 {
t.Fatalf("expected 1 step, got %d", len(steps))
}
// Patch session status.
w = doRequest(r, "PATCH", "/sessions/"+sess.ID, map[string]string{
"status": "in_progress",
})
if w.Code != http.StatusOK {
t.Fatalf("patch session: expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestSessionRequiresPipeline(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/sessions/", map[string]string{
"pipeline_id": "nonexistent",
"investor_email": "[email protected]",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestSessionStatusTransitionPendingToCompleted(t *testing.T) {
r, store := newTestRouter()
p := &Pipeline{Name: "Flow", Status: "active", Steps: []PipelineStep{
{ID: "s1", Name: "KYC", Type: "kyc", Required: true, Order: 1},
{ID: "s2", Name: "eSign", Type: "esign", Required: true, Order: 2},
}}
store.SavePipeline(p)
// Create session.
w := doRequest(r, "POST", "/sessions/", map[string]string{
"pipeline_id": p.ID,
"investor_email": "[email protected]",
"investor_name": "Alice",
})
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d", w.Code)
}
var sess Session
decodeJSON(t, w, &sess)
// pending -> in_progress
w = doRequest(r, "PATCH", "/sessions/"+sess.ID, map[string]string{"status": "in_progress"})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var s1 Session
decodeJSON(t, w, &s1)
if s1.Status != SessionInProgress {
t.Fatalf("expected in_progress, got %s", s1.Status)
}
// in_progress -> completed
w = doRequest(r, "PATCH", "/sessions/"+sess.ID, map[string]string{"status": "completed"})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var s2 Session
decodeJSON(t, w, &s2)
if s2.Status != SessionCompleted {
t.Fatalf("expected completed, got %s", s2.Status)
}
}
func TestSessionStatusTransitionPendingToFailed(t *testing.T) {
r, store := newTestRouter()
p := &Pipeline{Name: "Flow", Status: "active", Steps: []PipelineStep{
{ID: "s1", Name: "KYC", Type: "kyc", Required: true, Order: 1},
}}
store.SavePipeline(p)
w := doRequest(r, "POST", "/sessions/", map[string]string{
"pipeline_id": p.ID,
"investor_email": "[email protected]",
})
var sess Session
decodeJSON(t, w, &sess)
// pending -> failed
w = doRequest(r, "PATCH", "/sessions/"+sess.ID, map[string]string{"status": "failed"})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var patched Session
decodeJSON(t, w, &patched)
if patched.Status != SessionFailed {
t.Fatalf("expected failed, got %s", patched.Status)
}
}
func TestSessionListByPipeline(t *testing.T) {
_, store := newTestRouter()
p1 := &Pipeline{Name: "Pipeline A", Status: "active"}
p2 := &Pipeline{Name: "Pipeline B", Status: "active"}
store.SavePipeline(p1)
store.SavePipeline(p2)
store.SaveSession(&Session{PipelineID: p1.ID, InvestorEmail: "[email protected]", Status: SessionPending})
store.SaveSession(&Session{PipelineID: p1.ID, InvestorEmail: "[email protected]", Status: SessionPending})
store.SaveSession(&Session{PipelineID: p2.ID, InvestorEmail: "[email protected]", Status: SessionPending})
all := store.ListSessions()
if len(all) != 3 {
t.Fatalf("expected 3 sessions, got %d", len(all))
}
// Filter by pipeline ID.
count := 0
for _, s := range all {
if s.PipelineID == p1.ID {
count++
}
}
if count != 2 {
t.Fatalf("expected 2 sessions for pipeline A, got %d", count)
}
}
func TestSessionSearchByEmail(t *testing.T) {
_, store := newTestRouter()
p := &Pipeline{Name: "P", Status: "active"}
store.SavePipeline(p)
store.SaveSession(&Session{PipelineID: p.ID, InvestorEmail: "[email protected]", Status: SessionPending})
store.SaveSession(&Session{PipelineID: p.ID, InvestorEmail: "[email protected]", Status: SessionPending})
store.SaveSession(&Session{PipelineID: p.ID, InvestorEmail: "[email protected]", Status: SessionPending})
all := store.ListSessions()
found := 0
for _, s := range all {
if s.InvestorEmail == "[email protected]" {
found++
}
}
if found != 1 {
t.Fatalf("expected 1 match for [email protected], got %d", found)
}
}
func TestSessionCreateMissingEmail(t *testing.T) {
r, store := newTestRouter()
p := &Pipeline{Name: "P", Status: "active"}
store.SavePipeline(p)
w := doRequest(r, "POST", "/sessions/", map[string]string{
"pipeline_id": p.ID,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestSessionCreateMissingPipelineID(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/sessions/", map[string]string{
"investor_email": "[email protected]",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestSessionGetNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/sessions/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestSessionStepsNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/sessions/nonexistent/steps", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestSessionUpdateKYCStatus(t *testing.T) {
r, store := newTestRouter()
p := &Pipeline{Name: "P", Status: "active", Steps: []PipelineStep{
{ID: "s1", Name: "KYC", Type: "kyc", Required: true, Order: 1},
}}
store.SavePipeline(p)
w := doRequest(r, "POST", "/sessions/", map[string]string{
"pipeline_id": p.ID,
"investor_email": "[email protected]",
})
var sess Session
decodeJSON(t, w, &sess)
// Update KYC status to verified.
w = doRequest(r, "PATCH", "/sessions/"+sess.ID, map[string]string{
"kyc_status": "verified",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var patched Session
decodeJSON(t, w, &patched)
if patched.KYCStatus != KYCVerified {
t.Fatalf("expected kyc_status verified, got %s", patched.KYCStatus)
}
}
// ==========================================================================
// Fund Tests
// ==========================================================================
func TestFundCRUD(t *testing.T) {
r, _ := newTestRouter()
// Create
w := doRequest(r, "POST", "/funds/", map[string]interface{}{
"name": "Reg D Fund",
"business_id": "biz-1",
"type": "equity",
"min_investment": 10000,
})
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var fund Fund
decodeJSON(t, w, &fund)
if fund.Name != "Reg D Fund" {
t.Fatalf("expected name 'Reg D Fund', got %q", fund.Name)
}
if fund.Status != "raising" {
t.Fatalf("expected default status 'raising', got %q", fund.Status)
}
// Get
w = doRequest(r, "GET", "/funds/"+fund.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("get: expected 200, got %d", w.Code)
}
// Patch
w = doRequest(r, "PATCH", "/funds/"+fund.ID, map[string]interface{}{
"status": "closed",
})
if w.Code != http.StatusOK {
t.Fatalf("patch: expected 200, got %d", w.Code)
}
var patched Fund
decodeJSON(t, w, &patched)
if patched.Status != "closed" {
t.Fatalf("expected status closed, got %q", patched.Status)
}
// List investors (empty)
w = doRequest(r, "GET", "/funds/"+fund.ID+"/investors", nil)
if w.Code != http.StatusOK {
t.Fatalf("investors: expected 200, got %d", w.Code)
}
var investors []*FundInvestor
decodeJSON(t, w, &investors)
if len(investors) != 0 {
t.Fatalf("expected 0 investors, got %d", len(investors))
}
// Delete
w = doRequest(r, "DELETE", "/funds/"+fund.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete: expected 200, got %d", w.Code)
}
}
func TestFundAddInvestor(t *testing.T) {
_, store := newTestRouter()
f := &Fund{Name: "Growth Fund", Type: "equity", MinInvestment: 5000}
store.SaveFund(f)
err := store.AddFundInvestor(&FundInvestor{
FundID: f.ID,
InvestorID: "inv-1",
Name: "Alice",
Email: "[email protected]",
Amount: 25000,
Status: "committed",
})
if err != nil {
t.Fatal(err)
}
err = store.AddFundInvestor(&FundInvestor{
FundID: f.ID,
InvestorID: "inv-2",
Name: "Bob",
Email: "[email protected]",
Amount: 50000,
Status: "funded",
})
if err != nil {
t.Fatal(err)
}
got, _ := store.GetFund(f.ID)
if got.InvestorCount != 2 {
t.Fatalf("expected 2 investors, got %d", got.InvestorCount)
}
if got.TotalRaised != 75000 {
t.Fatalf("expected total_raised 75000, got %f", got.TotalRaised)
}
}
func TestFundGetInvestorsViaAPI(t *testing.T) {
r, store := newTestRouter()
f := &Fund{Name: "API Fund", Type: "debt"}
store.SaveFund(f)
store.AddFundInvestor(&FundInvestor{
FundID: f.ID, InvestorID: "inv-1", Name: "Alice", Amount: 10000,
})
store.AddFundInvestor(&FundInvestor{
FundID: f.ID, InvestorID: "inv-2", Name: "Bob", Amount: 20000,
})
w := doRequest(r, "GET", "/funds/"+f.ID+"/investors", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var investors []*FundInvestor
decodeJSON(t, w, &investors)
if len(investors) != 2 {
t.Fatalf("expected 2 investors, got %d", len(investors))
}
}
func TestFundWithZeroMinInvestment(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/funds/", map[string]interface{}{
"name": "Open Fund",
"type": "equity",
"min_investment": 0,
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
var fund Fund
decodeJSON(t, w, &fund)
if fund.MinInvestment != 0 {
t.Fatalf("expected min_investment 0, got %f", fund.MinInvestment)
}
}
func TestFundDeleteNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "DELETE", "/funds/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestFundGetNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/funds/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestFundCreateMissingName(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/funds/", map[string]interface{}{
"type": "equity",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestFundInvestorsForNonexistentFund(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/funds/nonexistent/investors", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestFundDeleteCleansUpInvestors(t *testing.T) {
_, store := newTestRouter()
f := &Fund{Name: "Temp Fund"}
store.SaveFund(f)
store.AddFundInvestor(&FundInvestor{FundID: f.ID, InvestorID: "inv-1", Amount: 1000})
// Confirm investor exists.
if len(store.ListFundInvestors(f.ID)) != 1 {
t.Fatal("expected 1 investor before delete")
}
store.DeleteFund(f.ID)
// Investors should be cleaned up.
if len(store.ListFundInvestors(f.ID)) != 0 {
t.Fatal("expected 0 investors after fund delete")
}
}
// ==========================================================================
// eSign Tests
// ==========================================================================
func TestESignEnvelopeLifecycle(t *testing.T) {
r, _ := newTestRouter()
// Create envelope
w := doRequest(r, "POST", "/esign/envelopes", map[string]interface{}{
"subject": "Subscription Agreement",
"signers": []map[string]string{
{"name": "John Doe", "email": "[email protected]", "role": "investor"},
{"name": "Jane Issuer", "email": "[email protected]", "role": "issuer"},
},
})
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var env Envelope
decodeJSON(t, w, &env)
if env.Status != EnvelopePending {
t.Fatalf("expected status pending, got %s", env.Status)
}
if len(env.Signers) != 2 {
t.Fatalf("expected 2 signers, got %d", len(env.Signers))
}
// Sign as first signer
w = doRequest(r, "POST", "/esign/envelopes/"+env.ID+"/sign", map[string]string{
"signer_id": env.Signers[0].ID,
})
if w.Code != http.StatusOK {
t.Fatalf("sign1: expected 200, got %d: %s", w.Code, w.Body.String())
}
var afterSign1 Envelope
decodeJSON(t, w, &afterSign1)
if afterSign1.Status != EnvelopeSigned {
t.Fatalf("expected status signed (partial), got %s", afterSign1.Status)
}
// Sign as second signer -> completed
w = doRequest(r, "POST", "/esign/envelopes/"+env.ID+"/sign", map[string]string{
"signer_id": env.Signers[1].ID,
})
if w.Code != http.StatusOK {
t.Fatalf("sign2: expected 200, got %d: %s", w.Code, w.Body.String())
}
var afterSign2 Envelope
decodeJSON(t, w, &afterSign2)
if afterSign2.Status != EnvelopeCompleted {
t.Fatalf("expected status completed, got %s", afterSign2.Status)
}
// List envelopes
w = doRequest(r, "GET", "/esign/envelopes", nil)
if w.Code != http.StatusOK {
t.Fatalf("list: expected 200, got %d", w.Code)
}
}
func TestESignTemplates(t *testing.T) {
r, _ := newTestRouter()
// Create template
w := doRequest(r, "POST", "/esign/templates", map[string]interface{}{
"name": "Sub Agreement v2",
"description": "Standard subscription agreement",
"roles": []string{"investor", "issuer"},
})
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
// List templates
w = doRequest(r, "GET", "/esign/templates", nil)
if w.Code != http.StatusOK {
t.Fatalf("list: expected 200, got %d", w.Code)
}
var templates []Template
decodeJSON(t, w, &templates)
if len(templates) != 1 {
t.Fatalf("expected 1 template, got %d", len(templates))
}
}
func TestESignCreateEnvelopeMissingSubject(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/esign/envelopes", map[string]interface{}{
"signers": []map[string]string{
{"name": "John", "email": "[email protected]"},
},
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestESignCreateEnvelopeNoSigners(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/esign/envelopes", map[string]interface{}{
"subject": "Test",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestESignGetEnvelopeStatus(t *testing.T) {
r, _ := newTestRouter()
// Create
w := doRequest(r, "POST", "/esign/envelopes", map[string]interface{}{
"subject": "NDA",
"signers": []map[string]string{
{"name": "Alice", "email": "[email protected]"},
},
})
var env Envelope
decodeJSON(t, w, &env)
// Get status
w = doRequest(r, "GET", "/esign/envelopes/"+env.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var got Envelope
decodeJSON(t, w, &got)
if got.Status != EnvelopePending {
t.Fatalf("expected pending, got %s", got.Status)
}
}
func TestESignEnvelopeWithMultipleSigners(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/esign/envelopes", map[string]interface{}{
"subject": "Multi-party Agreement",
"signers": []map[string]string{
{"name": "Alice", "email": "[email protected]", "role": "investor"},
{"name": "Bob", "email": "[email protected]", "role": "issuer"},
{"name": "Charlie", "email": "[email protected]", "role": "witness"},
},
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
var env Envelope
decodeJSON(t, w, &env)
if len(env.Signers) != 3 {
t.Fatalf("expected 3 signers, got %d", len(env.Signers))
}
// Each signer should have a unique ID.
ids := map[string]bool{}
for _, s := range env.Signers {
if s.ID == "" {
t.Fatal("signer missing ID")
}
if ids[s.ID] {
t.Fatal("duplicate signer ID")
}
ids[s.ID] = true
}
}
func TestESignSignInvalidSigner(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/esign/envelopes", map[string]interface{}{
"subject": "Test",
"signers": []map[string]string{
{"name": "Alice", "email": "[email protected]"},
},
})
var env Envelope
decodeJSON(t, w, &env)
// Try to sign with a bad signer_id.
w = doRequest(r, "POST", "/esign/envelopes/"+env.ID+"/sign", map[string]string{
"signer_id": "bad-id",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestESignSignMissingSignerID(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/esign/envelopes", map[string]interface{}{
"subject": "Test",
"signers": []map[string]string{
{"name": "Alice", "email": "[email protected]"},
},
})
var env Envelope
decodeJSON(t, w, &env)
w = doRequest(r, "POST", "/esign/envelopes/"+env.ID+"/sign", map[string]string{})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestESignGetEnvelopeNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/esign/envelopes/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestESignCreateTemplateMissingName(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/esign/templates", map[string]interface{}{
"description": "No name",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
// ==========================================================================
// Role Tests
// ==========================================================================
func TestRoleCRUD(t *testing.T) {
r, _ := newTestRouter()
// Create
w := doRequest(r, "POST", "/roles/", map[string]interface{}{
"name": "Compliance Officer",
"description": "Full compliance access",
"permissions": []map[string]string{
{"module": "kyc", "action": "admin"},
{"module": "funds", "action": "read"},
},
})
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var role Role
decodeJSON(t, w, &role)
if role.Name != "Compliance Officer" {
t.Fatalf("expected name 'Compliance Officer', got %q", role.Name)
}
if len(role.Permissions) != 2 {
t.Fatalf("expected 2 permissions, got %d", len(role.Permissions))
}
// Get
w = doRequest(r, "GET", "/roles/"+role.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("get: expected 200, got %d", w.Code)
}
// Patch
w = doRequest(r, "PATCH", "/roles/"+role.ID, map[string]interface{}{
"name": "Updated Role",
})
if w.Code != http.StatusOK {
t.Fatalf("patch: expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated Role
decodeJSON(t, w, &updated)
if updated.Name != "Updated Role" {
t.Fatalf("expected name 'Updated Role', got %q", updated.Name)
}
// Delete
w = doRequest(r, "DELETE", "/roles/"+role.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete: expected 200, got %d", w.Code)
}
// Get after delete -> 404
w = doRequest(r, "GET", "/roles/"+role.ID, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("get-after-delete: expected 404, got %d", w.Code)
}
}
func TestRoleDefaultSeed(t *testing.T) {
store := NewStore()
SeedStore(store)
roles := store.ListRoles()
if len(roles) < 5 {
t.Fatalf("expected at least 5 seeded roles, got %d", len(roles))
}
// Check that Owner role exists with admin permissions.
found := false
for _, role := range roles {
if role.Name == "Owner" {
found = true
if len(role.Permissions) == 0 {
t.Fatal("Owner role should have permissions")
}
}
}
if !found {
t.Fatal("expected Owner role in seed data")
}
}
func TestRoleAddRemovePermissions(t *testing.T) {
r, _ := newTestRouter()
// Create with one permission.
w := doRequest(r, "POST", "/roles/", map[string]interface{}{
"name": "Viewer",
"permissions": []map[string]string{
{"module": "kyc", "action": "read"},
},
})
var role Role
decodeJSON(t, w, &role)
// Update: add more permissions.
w = doRequest(r, "PATCH", "/roles/"+role.ID, map[string]interface{}{
"permissions": []map[string]string{
{"module": "kyc", "action": "read"},
{"module": "funds", "action": "read"},
{"module": "esign", "action": "read"},
},
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var patched Role
decodeJSON(t, w, &patched)
if len(patched.Permissions) != 3 {
t.Fatalf("expected 3 permissions, got %d", len(patched.Permissions))
}
// Update: remove permissions (set to single).
w = doRequest(r, "PATCH", "/roles/"+role.ID, map[string]interface{}{
"permissions": []map[string]string{
{"module": "kyc", "action": "read"},
},
})
var reduced Role
decodeJSON(t, w, &reduced)
if len(reduced.Permissions) != 1 {
t.Fatalf("expected 1 permission after removal, got %d", len(reduced.Permissions))
}
}
func TestRoleWithDuplicatePermissions(t *testing.T) {
r, _ := newTestRouter()
// Create role with duplicate permissions -- store allows it (idempotent).
w := doRequest(r, "POST", "/roles/", map[string]interface{}{
"name": "Dup Test",
"permissions": []map[string]string{
{"module": "kyc", "action": "read"},
{"module": "kyc", "action": "read"},
},
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
var role Role
decodeJSON(t, w, &role)
// Both are stored (dedup is a policy decision, not store's job).
if len(role.Permissions) != 2 {
t.Fatalf("expected 2 permissions, got %d", len(role.Permissions))
}
}
func TestRoleCreateMissingName(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/roles/", map[string]interface{}{
"description": "no name",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestRoleDeleteNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "DELETE", "/roles/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestRoleUpdateNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "PATCH", "/roles/nonexistent", map[string]interface{}{
"name": "x",
})
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
// ==========================================================================
// Modules Test
// ==========================================================================
func TestListModules(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/modules", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var modules []Module
decodeJSON(t, w, &modules)
if len(modules) < 6 {
t.Fatalf("expected at least 6 modules, got %d", len(modules))
}
}
// ==========================================================================
// Health Check Tests
// ==========================================================================
func TestHealthz(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/healthz", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]string
decodeJSON(t, w, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %q", resp["status"])
}
if resp["version"] == "" {
t.Fatal("expected version in health response")
}
}
// ==========================================================================
// Store Unit Tests
// ==========================================================================
func TestStoreIdentityNotFound(t *testing.T) {
s := NewStore()
_, err := s.GetIdentity("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent identity")
}
}
func TestStoreGeneratesIDs(t *testing.T) {
s := NewStore()
f := &Fund{Name: "Test"}
if err := s.SaveFund(f); err != nil {
t.Fatal(err)
}
if f.ID == "" {
t.Fatal("expected generated ID")
}
if f.CreatedAt.IsZero() {
t.Fatal("expected CreatedAt to be set")
}
}
func TestStoreFundInvestors(t *testing.T) {
s := NewStore()
f := &Fund{Name: "Test Fund"}
s.SaveFund(f)
if err := s.AddFundInvestor(&FundInvestor{
FundID: f.ID,
InvestorID: "inv-1",
Name: "Alice",
Amount: 50000,
}); err != nil {
t.Fatal(err)
}
inv := s.ListFundInvestors(f.ID)
if len(inv) != 1 {
t.Fatalf("expected 1 investor, got %d", len(inv))
}
// Verify fund stats updated.
got, _ := s.GetFund(f.ID)
if got.InvestorCount != 1 {
t.Fatalf("expected investor_count 1, got %d", got.InvestorCount)
}
if got.TotalRaised != 50000 {
t.Fatalf("expected total_raised 50000, got %f", got.TotalRaised)
}
}
func TestStoreFundInvestorNonexistentFund(t *testing.T) {
s := NewStore()
err := s.AddFundInvestor(&FundInvestor{FundID: "nope"})
if err == nil {
t.Fatal("expected error for nonexistent fund")
}
}
func TestStoreSessionTimestamps(t *testing.T) {
s := NewStore()
sess := &Session{PipelineID: "p1", InvestorEmail: "[email protected]", Status: SessionPending}
s.SaveSession(sess)
if sess.ID == "" {
t.Fatal("expected generated ID")
}
if sess.CreatedAt.IsZero() {
t.Fatal("expected CreatedAt to be set")
}
}
func TestStoreRoleTimestamps(t *testing.T) {
s := NewStore()
role := &Role{Name: "Test"}
s.SaveRole(role)
if role.ID == "" {
t.Fatal("expected generated ID")
}
if role.CreatedAt.IsZero() {
t.Fatal("expected CreatedAt")
}
if role.UpdatedAt.IsZero() {
t.Fatal("expected UpdatedAt")
}
}
func TestStoreEnvelopeTimestamps(t *testing.T) {
s := NewStore()
env := &Envelope{Subject: "Test", Status: EnvelopePending}
s.SaveEnvelope(env)
if env.ID == "" {
t.Fatal("expected generated ID")
}
if env.CreatedAt.IsZero() {
t.Fatal("expected CreatedAt")
}
}
func TestStorePipelineTimestamps(t *testing.T) {
s := NewStore()
p := &Pipeline{Name: "Test", Status: "draft"}
s.SavePipeline(p)
if p.ID == "" {
t.Fatal("expected generated ID")
}
if p.CreatedAt.IsZero() {
t.Fatal("expected CreatedAt")
}
if p.UpdatedAt.IsZero() {
t.Fatal("expected UpdatedAt")
}
}
// ==========================================================================
// Seed Tests
// ==========================================================================
func TestSeedStore(t *testing.T) {
s := NewStore()
SeedStore(s)
roles := s.ListRoles()
if len(roles) < 5 {
t.Fatalf("expected at least 5 roles, got %d", len(roles))
}
pipelines := s.ListPipelines()
if len(pipelines) < 3 {
t.Fatalf("expected at least 3 pipelines, got %d", len(pipelines))
}
sessions := s.ListSessions()
if len(sessions) < 5 {
t.Fatalf("expected at least 5 sessions, got %d", len(sessions))
}
funds := s.ListFunds()
if len(funds) < 2 {
t.Fatalf("expected at least 2 funds, got %d", len(funds))
}
}
func TestSeedStoreIdempotent(t *testing.T) {
s := NewStore()
SeedStore(s)
count1 := len(s.ListRoles())
SeedStore(s)
count2 := len(s.ListRoles())
// Second seed adds more (it is additive, not deduplicated).
// This is expected behavior for dev seed data.
if count2 < count1 {
t.Fatalf("seed should not remove data: %d < %d", count2, count1)
}
}
// ==========================================================================
// Integration Tests (full flow)
// ==========================================================================
func TestIntegrationPipelineToSessionToKYC(t *testing.T) {
r, _ := newTestRouter()
// 1. Create a pipeline.
w := doRequest(r, "POST", "/pipelines/", map[string]interface{}{
"name": "Reg D 506(c)",
"business_id": "biz-100",
"status": "active",
"steps": []map[string]interface{}{
{"id": "kyc-step", "name": "Identity Verification", "type": "kyc", "required": true, "order": 1},
{"id": "esign-step", "name": "Sign Documents", "type": "esign", "required": true, "order": 2},
{"id": "payment-step", "name": "Fund Investment", "type": "payment", "required": true, "order": 3},
},
})
if w.Code != http.StatusCreated {
t.Fatalf("create pipeline: expected 201, got %d", w.Code)
}
var pipeline Pipeline
decodeJSON(t, w, &pipeline)
// 2. Create a session for an investor.
w = doRequest(r, "POST", "/sessions/", map[string]string{
"pipeline_id": pipeline.ID,
"investor_email": "[email protected]",
"investor_name": "Jane Investor",
})
if w.Code != http.StatusCreated {
t.Fatalf("create session: expected 201, got %d", w.Code)
}
var sess Session
decodeJSON(t, w, &sess)
if len(sess.Steps) != 3 {
t.Fatalf("expected 3 steps from pipeline, got %d", len(sess.Steps))
}
// 3. Start the session.
w = doRequest(r, "PATCH", "/sessions/"+sess.ID, map[string]string{
"status": "in_progress",
})
if w.Code != http.StatusOK {
t.Fatalf("start session: expected 200, got %d", w.Code)
}
// 4. Run KYC verification.
w = doRequest(r, "POST", "/kyc/verify", map[string]string{
"user_id": "[email protected]",
"provider": "onfido",
})
if w.Code != http.StatusCreated {
t.Fatalf("kyc verify: expected 201, got %d", w.Code)
}
var ident Identity
decodeJSON(t, w, &ident)
// 5. Update session KYC status.
w = doRequest(r, "PATCH", "/sessions/"+sess.ID, map[string]string{
"kyc_status": "verified",
})
if w.Code != http.StatusOK {
t.Fatalf("update kyc status: expected 200, got %d", w.Code)
}
var kycUpdated Session
decodeJSON(t, w, &kycUpdated)
if kycUpdated.KYCStatus != KYCVerified {
t.Fatalf("expected kyc verified, got %s", kycUpdated.KYCStatus)
}
// 6. Complete the session.
w = doRequest(r, "PATCH", "/sessions/"+sess.ID, map[string]string{
"status": "completed",
})
if w.Code != http.StatusOK {
t.Fatalf("complete session: expected 200, got %d", w.Code)
}
var completed Session
decodeJSON(t, w, &completed)
if completed.Status != SessionCompleted {
t.Fatalf("expected completed, got %s", completed.Status)
}
if completed.KYCStatus != KYCVerified {
t.Fatalf("expected kyc verified preserved, got %s", completed.KYCStatus)
}
}
func TestIntegrationFundWithInvestors(t *testing.T) {
r, store := newTestRouter()
// Create fund via API.
w := doRequest(r, "POST", "/funds/", map[string]interface{}{
"name": "Series A Fund",
"business_id": "biz-200",
"type": "equity",
"min_investment": 25000,
})
if w.Code != http.StatusCreated {
t.Fatalf("create fund: expected 201, got %d", w.Code)
}
var fund Fund
decodeJSON(t, w, &fund)
// Add investors directly (store-level, simulating backend process).
store.AddFundInvestor(&FundInvestor{
FundID: fund.ID, InvestorID: "inv-a", Name: "Alice", Email: "[email protected]", Amount: 50000, Status: "funded",
})
store.AddFundInvestor(&FundInvestor{
FundID: fund.ID, InvestorID: "inv-b", Name: "Bob", Email: "[email protected]", Amount: 100000, Status: "committed",
})
// Verify via API.
w = doRequest(r, "GET", "/funds/"+fund.ID, nil)
var got Fund
decodeJSON(t, w, &got)
if got.InvestorCount != 2 {
t.Fatalf("expected 2 investors, got %d", got.InvestorCount)
}
if got.TotalRaised != 150000 {
t.Fatalf("expected total_raised 150000, got %f", got.TotalRaised)
}
// List investors via API.
w = doRequest(r, "GET", "/funds/"+fund.ID+"/investors", nil)
var investors []*FundInvestor
decodeJSON(t, w, &investors)
if len(investors) != 2 {
t.Fatalf("expected 2 investors, got %d", len(investors))
}
}
func TestIntegrationESignFullFlow(t *testing.T) {
r, _ := newTestRouter()
// 1. Create template.
w := doRequest(r, "POST", "/esign/templates", map[string]interface{}{
"name": "Subscription Agreement",
"description": "Standard subscription agreement for fund investors",
"roles": []string{"investor", "issuer"},
})
if w.Code != http.StatusCreated {
t.Fatalf("create template: expected 201, got %d", w.Code)
}
var tmpl Template
decodeJSON(t, w, &tmpl)
// 2. Create envelope from template.
w = doRequest(r, "POST", "/esign/envelopes", map[string]interface{}{
"template_id": tmpl.ID,
"subject": "Sign: Subscription Agreement - Series A",
"message": "Please review and sign the attached subscription agreement.",
"signers": []map[string]string{
{"name": "Alice Investor", "email": "[email protected]", "role": "investor"},
{"name": "Bob Issuer", "email": "[email protected]", "role": "issuer"},
},
})
if w.Code != http.StatusCreated {
t.Fatalf("create envelope: expected 201, got %d", w.Code)
}
var env Envelope
decodeJSON(t, w, &env)
if env.TemplateID != tmpl.ID {
t.Fatalf("expected template_id %s, got %s", tmpl.ID, env.TemplateID)
}
// 3. First signer signs.
w = doRequest(r, "POST", "/esign/envelopes/"+env.ID+"/sign", map[string]string{
"signer_id": env.Signers[0].ID,
})
if w.Code != http.StatusOK {
t.Fatalf("sign1: expected 200, got %d", w.Code)
}
// 4. Check status (should be "signed" - partial).
w = doRequest(r, "GET", "/esign/envelopes/"+env.ID, nil)
var mid Envelope
decodeJSON(t, w, &mid)
if mid.Status != EnvelopeSigned {
t.Fatalf("expected signed, got %s", mid.Status)
}
// 5. Second signer signs -> completed.
w = doRequest(r, "POST", "/esign/envelopes/"+env.ID+"/sign", map[string]string{
"signer_id": env.Signers[1].ID,
})
var final Envelope
decodeJSON(t, w, &final)
if final.Status != EnvelopeCompleted {
t.Fatalf("expected completed, got %s", final.Status)
}
}
// ==========================================================================
// Dashboard Tests
// ==========================================================================
func TestDashboard(t *testing.T) {
r, store := newTestRouter()
// Seed some data.
store.SaveSession(&Session{Status: SessionInProgress, KYCStatus: KYCPending})
store.SaveSession(&Session{Status: SessionCompleted, KYCStatus: KYCVerified})
store.SaveSession(&Session{Status: SessionPending, KYCStatus: KYCPending})
store.SaveFund(&Fund{Name: "Fund A"})
store.SaveTransaction(&Transaction{Type: "deposit", Amount: 100})
w := doRequest(r, "GET", "/dashboard", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var stats DashboardStats
decodeJSON(t, w, &stats)
if stats.ActiveSessions != 2 {
t.Fatalf("expected 2 active sessions, got %d", stats.ActiveSessions)
}
if stats.PendingKYC != 2 {
t.Fatalf("expected 2 pending KYC, got %d", stats.PendingKYC)
}
if stats.TotalFunds != 1 {
t.Fatalf("expected 1 fund, got %d", stats.TotalFunds)
}
if stats.MonthlyTransactions != 1 {
t.Fatalf("expected 1 transaction, got %d", stats.MonthlyTransactions)
}
}
func TestDashboardEmpty(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/dashboard", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var stats DashboardStats
decodeJSON(t, w, &stats)
if stats.ActiveSessions != 0 {
t.Fatalf("expected 0 active sessions, got %d", stats.ActiveSessions)
}
}
// ==========================================================================
// Users Tests
// ==========================================================================
func TestUserListAndCreate(t *testing.T) {
r, _ := newTestRouter()
// List (empty).
w := doRequest(r, "GET", "/users", nil)
if w.Code != http.StatusOK {
t.Fatalf("list: expected 200, got %d", w.Code)
}
var users []*User
decodeJSON(t, w, &users)
if len(users) != 0 {
t.Fatalf("expected 0 users, got %d", len(users))
}
// Create.
w = doRequest(r, "POST", "/users", map[string]string{
"name": "Test User",
"email": "[email protected]",
"role": "admin",
})
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var u User
decodeJSON(t, w, &u)
if u.Name != "Test User" {
t.Fatalf("expected name 'Test User', got %q", u.Name)
}
if u.Email != "[email protected]" {
t.Fatalf("expected email '[email protected]', got %q", u.Email)
}
if u.Role != "admin" {
t.Fatalf("expected role 'admin', got %q", u.Role)
}
if u.Status != "active" {
t.Fatalf("expected default status 'active', got %q", u.Status)
}
if u.ID == "" {
t.Fatal("expected non-empty ID")
}
// List again (should have 1).
w = doRequest(r, "GET", "/users", nil)
decodeJSON(t, w, &users)
if len(users) != 1 {
t.Fatalf("expected 1 user, got %d", len(users))
}
}
func TestUserCreateMissingName(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/users", map[string]string{"email": "[email protected]"})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestUserCreateMissingEmail(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/users", map[string]string{"name": "Test"})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestUserCreateDefaultRole(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/users", map[string]string{
"name": "Test",
"email": "[email protected]",
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
var u User
decodeJSON(t, w, &u)
if u.Role != "agent" {
t.Fatalf("expected default role 'agent', got %q", u.Role)
}
}
// ==========================================================================
// Transactions Tests
// ==========================================================================
func TestTransactionList(t *testing.T) {
r, store := newTestRouter()
// Empty list.
w := doRequest(r, "GET", "/transactions", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var txns []*Transaction
decodeJSON(t, w, &txns)
if len(txns) != 0 {
t.Fatalf("expected 0 transactions, got %d", len(txns))
}
// Seed some.
store.SaveTransaction(&Transaction{Type: "deposit", Asset: "USD", Amount: 1000})
store.SaveTransaction(&Transaction{Type: "trade", Asset: "BTC", Amount: 0.5})
w = doRequest(r, "GET", "/transactions", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
decodeJSON(t, w, &txns)
if len(txns) != 2 {
t.Fatalf("expected 2 transactions, got %d", len(txns))
}
}
// ==========================================================================
// Reports Tests
// ==========================================================================
func TestReportList(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/reports", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var reports []struct {
ID string `json:"id"`
Name string `json:"name"`
}
decodeJSON(t, w, &reports)
if len(reports) != 4 {
t.Fatalf("expected 4 reports, got %d", len(reports))
}
// Check expected report IDs.
expectedIDs := map[string]bool{
"monthly-statement": false,
"form-8949": false,
"form-1099b": false,
"transaction-history": false,
}
for _, rpt := range reports {
if _, ok := expectedIDs[rpt.ID]; !ok {
t.Fatalf("unexpected report ID: %s", rpt.ID)
}
expectedIDs[rpt.ID] = true
}
for id, found := range expectedIDs {
if !found {
t.Fatalf("missing report: %s", id)
}
}
}
// ==========================================================================
// Settings Tests
// ==========================================================================
func TestSettingsGetAndUpdate(t *testing.T) {
r, _ := newTestRouter()
// Get defaults.
w := doRequest(r, "GET", "/settings", nil)
if w.Code != http.StatusOK {
t.Fatalf("get: expected 200, got %d", w.Code)
}
var s Settings
decodeJSON(t, w, &s)
if s.BusinessName != "Your Company" {
t.Fatalf("expected default business name 'Your Company', got %q", s.BusinessName)
}
if s.Timezone != "America/New_York" {
t.Fatalf("expected timezone 'America/New_York', got %q", s.Timezone)
}
// Update.
w = doRequest(r, "PUT", "/settings", map[string]string{
"business_name": "Acme Corp",
"timezone": "America/Los_Angeles",
})
if w.Code != http.StatusOK {
t.Fatalf("update: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &s)
if s.BusinessName != "Acme Corp" {
t.Fatalf("expected 'Acme Corp', got %q", s.BusinessName)
}
if s.Timezone != "America/Los_Angeles" {
t.Fatalf("expected 'America/Los_Angeles', got %q", s.Timezone)
}
// Unchanged fields should remain.
if s.Currency != "USD" {
t.Fatalf("expected currency 'USD' unchanged, got %q", s.Currency)
}
// Verify persistence.
w = doRequest(r, "GET", "/settings", nil)
decodeJSON(t, w, &s)
if s.BusinessName != "Acme Corp" {
t.Fatalf("expected persisted 'Acme Corp', got %q", s.BusinessName)
}
}
// ==========================================================================
// Credentials Tests
// ==========================================================================
func TestCredentialCreateListDelete(t *testing.T) {
r, _ := newTestRouter()
// List (empty).
w := doRequest(r, "GET", "/credentials", nil)
if w.Code != http.StatusOK {
t.Fatalf("list: expected 200, got %d", w.Code)
}
var creds []*Credential
decodeJSON(t, w, &creds)
if len(creds) != 0 {
t.Fatalf("expected 0 credentials, got %d", len(creds))
}
// Create.
w = doRequest(r, "POST", "/credentials", map[string]interface{}{
"name": "Test Key",
"permissions": []string{"read", "trade"},
})
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created struct {
Credential
Key string `json:"key"`
}
decodeJSON(t, w, &created)
if created.Name != "Test Key" {
t.Fatalf("expected name 'Test Key', got %q", created.Name)
}
if created.Key == "" {
t.Fatal("expected non-empty key on creation")
}
if created.KeyPrefix == "" {
t.Fatal("expected non-empty key prefix")
}
if len(created.Key) != 64 { // 32 bytes hex
t.Fatalf("expected 64 char key, got %d", len(created.Key))
}
// List (should have 1).
w = doRequest(r, "GET", "/credentials", nil)
decodeJSON(t, w, &creds)
if len(creds) != 1 {
t.Fatalf("expected 1 credential, got %d", len(creds))
}
// Delete.
w = doRequest(r, "DELETE", "/credentials/"+created.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete: expected 200, got %d: %s", w.Code, w.Body.String())
}
// List (should be empty again).
w = doRequest(r, "GET", "/credentials", nil)
decodeJSON(t, w, &creds)
if len(creds) != 0 {
t.Fatalf("expected 0 credentials after delete, got %d", len(creds))
}
}
func TestCredentialCreateMissingName(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/credentials", map[string]interface{}{
"permissions": []string{"read"},
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCredentialDeleteNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "DELETE", "/credentials/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestCredentialDefaultPermissions(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/credentials", map[string]interface{}{
"name": "ReadOnly",
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
var created struct {
Credential
Key string `json:"key"`
}
decodeJSON(t, w, &created)
if len(created.Permissions) != 1 || created.Permissions[0] != "read" {
t.Fatalf("expected default [read] permissions, got %v", created.Permissions)
}
}
// ==========================================================================
// Billing Tests
// ==========================================================================
func TestBillingGet(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/billing", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var billing BillingInfo
decodeJSON(t, w, &billing)
if billing.Plan != "professional" {
t.Fatalf("expected plan 'professional', got %q", billing.Plan)
}
if len(billing.Invoices) != 3 {
t.Fatalf("expected 3 invoices, got %d", len(billing.Invoices))
}
}
// ==========================================================================
// eSign Dashboard Tests
// ==========================================================================
func TestESignDashboard(t *testing.T) {
r, store := newTestRouter()
// Seed envelopes.
store.SaveEnvelope(&Envelope{Subject: "Doc A", Status: EnvelopePending, Signers: []Signer{{ID: "s1"}}})
store.SaveEnvelope(&Envelope{Subject: "Doc B", Status: EnvelopeCompleted, Signers: []Signer{{ID: "s2"}}})
store.SaveEnvelope(&Envelope{Subject: "Doc C", Status: EnvelopeSent, Signers: []Signer{{ID: "s3"}}})
store.SaveTemplate(&Template{Name: "Template 1"})
w := doRequest(r, "GET", "/esign-dashboard", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var stats ESignStats
decodeJSON(t, w, &stats)
if stats.Pending != 2 {
t.Fatalf("expected 2 pending, got %d", stats.Pending)
}
if stats.Completed != 1 {
t.Fatalf("expected 1 completed, got %d", stats.Completed)
}
if stats.Templates != 1 {
t.Fatalf("expected 1 template, got %d", stats.Templates)
}
}
// ==========================================================================
// Envelope Direction Tests
// ==========================================================================
func TestEnvelopeInboxAndSent(t *testing.T) {
r, store := newTestRouter()
// Seed envelopes with different statuses.
store.SaveEnvelope(&Envelope{Subject: "Inbox 1", Status: EnvelopePending, Signers: []Signer{{ID: "s1"}}})
store.SaveEnvelope(&Envelope{Subject: "Inbox 2", Status: EnvelopeSent, Signers: []Signer{{ID: "s2"}}})
store.SaveEnvelope(&Envelope{Subject: "Sent 1", Status: EnvelopeCompleted, Signers: []Signer{{ID: "s3"}}})
store.SaveEnvelope(&Envelope{Subject: "Sent 2", Status: EnvelopeDeclined, Signers: []Signer{{ID: "s4"}}})
// Inbox.
w := doRequest(r, "GET", "/envelopes/inbox", nil)
if w.Code != http.StatusOK {
t.Fatalf("inbox: expected 200, got %d", w.Code)
}
var inbox []*Envelope
decodeJSON(t, w, &inbox)
if len(inbox) != 2 {
t.Fatalf("expected 2 inbox envelopes, got %d", len(inbox))
}
// Sent.
w = doRequest(r, "GET", "/envelopes/sent", nil)
if w.Code != http.StatusOK {
t.Fatalf("sent: expected 200, got %d", w.Code)
}
var sent []*Envelope
decodeJSON(t, w, &sent)
if len(sent) != 2 {
t.Fatalf("expected 2 sent envelopes, got %d", len(sent))
}
}
// ==========================================================================
// Seed Tests
// ==========================================================================
func TestSeedPopulatesNewTypes(t *testing.T) {
store := NewStore()
SeedStore(store)
if len(store.ListUsers()) == 0 {
t.Fatal("expected seeded users")
}
if len(store.ListTransactions()) == 0 {
t.Fatal("expected seeded transactions")
}
if len(store.ListCredentials()) == 0 {
t.Fatal("expected seeded credentials")
}
if len(store.ListEnvelopes()) == 0 {
t.Fatal("expected seeded envelopes")
}
if len(store.ListTemplates()) == 0 {
t.Fatal("expected seeded templates")
}
if len(store.ListFunds()) == 0 {
t.Fatal("expected seeded funds")
}
if len(store.ListRoles()) == 0 {
t.Fatal("expected seeded roles")
}
}
// ==========================================================================
// AML Screening Tests
// ==========================================================================
func TestAMLScreenAndGet(t *testing.T) {
r, _ := newTestRouter()
// Create an AML screening.
w := doRequest(r, "POST", "/aml/screen", map[string]string{
"account_id": "acct-123",
"user_id": "user-123",
"name": "John Doe",
"country": "US",
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var screening AMLScreening
decodeJSON(t, w, &screening)
if screening.AccountID != "acct-123" {
t.Fatalf("expected account_id acct-123, got %s", screening.AccountID)
}
if screening.Status != AMLPending {
t.Fatalf("expected status pending, got %s", screening.Status)
}
// Get screening by ID.
w = doRequest(r, "GET", "/aml/screenings/"+screening.ID, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var fetched AMLScreening
decodeJSON(t, w, &fetched)
if fetched.ID != screening.ID {
t.Fatalf("ID mismatch: %s != %s", fetched.ID, screening.ID)
}
}
func TestAMLScreenMissingAccountID(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/aml/screen", map[string]string{
"name": "John Doe",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestAMLScreenMissingName(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/aml/screen", map[string]string{
"account_id": "acct-1",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestAMLListByAccount(t *testing.T) {
r, store := newTestRouter()
store.SaveAMLScreening(&AMLScreening{AccountID: "acct-a", UserID: "u1", Type: "sanctions", Status: AMLPending, Provider: "manual"})
store.SaveAMLScreening(&AMLScreening{AccountID: "acct-a", UserID: "u1", Type: "pep", Status: AMLCleared, Provider: "manual"})
store.SaveAMLScreening(&AMLScreening{AccountID: "acct-b", UserID: "u2", Type: "sanctions", Status: AMLFlagged, Provider: "manual"})
w := doRequest(r, "GET", "/aml/screenings?account_id=acct-a", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var screenings []*AMLScreening
decodeJSON(t, w, &screenings)
if len(screenings) != 2 {
t.Fatalf("expected 2 screenings for acct-a, got %d", len(screenings))
}
}
func TestAMLListByAccountMissingParam(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/aml/screenings", nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestAMLListFlagged(t *testing.T) {
r, store := newTestRouter()
store.SaveAMLScreening(&AMLScreening{AccountID: "a1", Status: AMLFlagged, Provider: "manual"})
store.SaveAMLScreening(&AMLScreening{AccountID: "a2", Status: AMLCleared, Provider: "manual"})
store.SaveAMLScreening(&AMLScreening{AccountID: "a3", Status: AMLFlagged, Provider: "manual"})
w := doRequest(r, "GET", "/aml/flagged", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var flagged []*AMLScreening
decodeJSON(t, w, &flagged)
if len(flagged) != 2 {
t.Fatalf("expected 2 flagged, got %d", len(flagged))
}
}
func TestAMLReview(t *testing.T) {
r, store := newTestRouter()
sc := &AMLScreening{AccountID: "a1", Status: AMLFlagged, Provider: "manual"}
store.SaveAMLScreening(sc)
// Review and clear. reviewed_by comes from JWT context (testadmin), not request body.
w := doRequest(r, "POST", "/aml/screenings/"+sc.ID+"/review", map[string]string{
"decision": "cleared",
"details": "false positive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var reviewed AMLScreening
decodeJSON(t, w, &reviewed)
if reviewed.Status != AMLCleared {
t.Fatalf("expected cleared, got %s", reviewed.Status)
}
// Reviewer identity is extracted from JWT, not request body.
if reviewed.ReviewedBy != "test-user-001" {
t.Fatalf("expected reviewed_by test-user-001 (from gateway), got %s", reviewed.ReviewedBy)
}
}
func TestAMLReviewBadDecision(t *testing.T) {
r, store := newTestRouter()
sc := &AMLScreening{AccountID: "a1", Status: AMLFlagged, Provider: "manual"}
store.SaveAMLScreening(sc)
w := doRequest(r, "POST", "/aml/screenings/"+sc.ID+"/review", map[string]string{
"decision": "maybe",
"reviewed_by": "admin",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestAMLReviewIgnoresBodyReviewedBy(t *testing.T) {
r, store := newTestRouter()
sc := &AMLScreening{AccountID: "a1", Status: AMLFlagged, Provider: "manual"}
store.SaveAMLScreening(sc)
// Send reviewed_by in body attempting to spoof identity.
// It must be ignored — reviewer comes from JWT context.
w := doRequest(r, "POST", "/aml/screenings/"+sc.ID+"/review", map[string]string{
"decision": "cleared",
"reviewed_by": "[email protected]",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var reviewed AMLScreening
decodeJSON(t, w, &reviewed)
if reviewed.ReviewedBy == "[email protected]" {
t.Fatal("SECURITY: reviewed_by accepted from request body instead of JWT context")
}
if reviewed.ReviewedBy != "test-user-001" {
t.Fatalf("expected reviewed_by test-user-001 (from gateway), got %s", reviewed.ReviewedBy)
}
}
func TestAMLReviewNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/aml/screenings/nonexistent/review", map[string]string{
"decision": "cleared",
"reviewed_by": "admin",
})
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestAMLGetNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/aml/screenings/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestAMLRiskAssessment(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/aml/risk-assessment", map[string]interface{}{
"account_id": "acct-1",
"user_id": "user-1",
"amount": 50000.0,
"currency": "USD",
"type": "deposit",
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var screening AMLScreening
decodeJSON(t, w, &screening)
if screening.Type != "transaction" {
t.Fatalf("expected type transaction, got %s", screening.Type)
}
}
func TestAMLRiskAssessmentMissingAmount(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/aml/risk-assessment", map[string]interface{}{
"account_id": "acct-1",
"amount": 0,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
// ==========================================================================
// Application (5-Step Onboarding) Tests
// ==========================================================================
func TestApplicationFullOnboardingFlow(t *testing.T) {
r, _ := newTestRouter()
// Create application.
w := doRequest(r, "POST", "/applications/", map[string]string{
"user_id": "user-onboard",
"email": "[email protected]",
})
if w.Code != http.StatusCreated {
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var app Application
decodeJSON(t, w, &app)
if app.Status != AppDraft {
t.Fatalf("expected status draft, got %s", app.Status)
}
if app.CurrentStep != 1 {
t.Fatalf("expected current_step 1, got %d", app.CurrentStep)
}
if len(app.Steps) != 5 {
t.Fatalf("expected 5 steps, got %d", len(app.Steps))
}
// Step 1: Basic info + Contact.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/1", map[string]interface{}{
"first_name": "John",
"last_name": "Doe",
"phone": "+1-555-0100",
"date_of_birth": "1990-01-15",
"ssn": "123-45-6789",
"address_line1": "123 Main St",
"city": "New York",
"state": "NY",
"zip_code": "10001",
"country": "US",
})
if w.Code != http.StatusOK {
t.Fatalf("step1: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.FirstName != "John" {
t.Fatalf("expected first_name John, got %s", app.FirstName)
}
if app.CurrentStep != 2 {
t.Fatalf("expected current_step 2, got %d", app.CurrentStep)
}
if app.Status != AppInProgress {
t.Fatalf("expected status in_progress, got %s", app.Status)
}
// SSN must be hashed, not exposed via JSON.
if app.SSNLast4 != "6789" {
t.Fatalf("expected ssn_last4 6789, got %s", app.SSNLast4)
}
// Step 2: Identity verification.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/2", map[string]string{
"provider": "onfido",
"verification_id": "ver-abc-123",
"status": "verified",
})
if w.Code != http.StatusOK {
t.Fatalf("step2: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.KYCStatus != KYCVerified {
t.Fatalf("expected kyc_status verified, got %s", app.KYCStatus)
}
if app.CurrentStep != 3 {
t.Fatalf("expected current_step 3, got %d", app.CurrentStep)
}
// Step 3: Document upload.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/3", map[string]interface{}{
"documents": []map[string]interface{}{
{"type": "passport", "name": "passport.pdf", "mime_type": "application/pdf", "size": 102400},
{"type": "utility_bill", "name": "utility.pdf", "mime_type": "application/pdf", "size": 51200},
},
})
if w.Code != http.StatusOK {
t.Fatalf("step3: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.CurrentStep != 4 {
t.Fatalf("expected current_step 4, got %d", app.CurrentStep)
}
// Check documents were saved.
w = doRequest(r, "GET", "/applications/"+app.ID+"/documents", nil)
if w.Code != http.StatusOK {
t.Fatalf("get docs: expected 200, got %d", w.Code)
}
var docs []*DocumentUpload
decodeJSON(t, w, &docs)
if len(docs) != 2 {
t.Fatalf("expected 2 documents, got %d", len(docs))
}
// Step 4: Compliance/AML screening.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/4", nil)
if w.Code != http.StatusOK {
t.Fatalf("step4: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.CurrentStep != 5 {
t.Fatalf("expected current_step 5, got %d", app.CurrentStep)
}
// Step 5: Review + Submit.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/5", map[string]bool{
"confirmed": true,
})
if w.Code != http.StatusOK {
t.Fatalf("step5: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.Status != AppSubmitted {
t.Fatalf("expected status submitted, got %s", app.Status)
}
if app.SubmittedAt.IsZero() {
t.Fatal("expected submitted_at to be set")
}
// Admin review: approve.
w = doRequest(r, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "approved",
"reviewed_by": "[email protected]",
})
if w.Code != http.StatusOK {
t.Fatalf("review: expected 200, got %d: %s", w.Code, w.Body.String())
}
decodeJSON(t, w, &app)
if app.Status != AppApproved {
t.Fatalf("expected status approved, got %s", app.Status)
}
}
func TestApplicationCreateMissingUserID(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/applications/", map[string]string{
"email": "[email protected]",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationCreateMissingEmail(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/applications/", map[string]string{
"user_id": "user-1",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationGetNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/applications/nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestApplicationLookupByUser(t *testing.T) {
r, store := newTestRouter()
app := &Application{
UserID: "user-lookup",
Email: "[email protected]",
Status: AppDraft,
Steps: newApplicationSteps(),
}
store.SaveApplication(app)
w := doRequest(r, "GET", "/applications/lookup?user_id=user-lookup", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var fetched Application
decodeJSON(t, w, &fetched)
if fetched.UserID != "user-lookup" {
t.Fatalf("expected user_id user-lookup, got %s", fetched.UserID)
}
}
func TestApplicationLookupByUserNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/applications/lookup?user_id=nonexistent", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestApplicationLookupMissingParam(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/applications/lookup", nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationListByStatus(t *testing.T) {
r, store := newTestRouter()
store.SaveApplication(&Application{UserID: "u1", Email: "[email protected]", Status: AppDraft, Steps: newApplicationSteps()})
store.SaveApplication(&Application{UserID: "u2", Email: "[email protected]", Status: AppSubmitted, Steps: newApplicationSteps()})
store.SaveApplication(&Application{UserID: "u3", Email: "[email protected]", Status: AppSubmitted, Steps: newApplicationSteps()})
// All applications.
w := doRequest(r, "GET", "/applications/", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var all []*Application
decodeJSON(t, w, &all)
if len(all) != 3 {
t.Fatalf("expected 3 applications, got %d", len(all))
}
// Filter by status.
w = doRequest(r, "GET", "/applications/?status=submitted", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var submitted []*Application
decodeJSON(t, w, &submitted)
if len(submitted) != 2 {
t.Fatalf("expected 2 submitted, got %d", len(submitted))
}
}
func TestApplicationStep1MissingName(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppDraft, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/step/1", map[string]string{
"last_name": "Doe",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationStep2MissingProvider(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppInProgress, CurrentStep: 2, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/step/2", map[string]string{})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationStep3NoDocuments(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppInProgress, CurrentStep: 3, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/step/3", map[string]interface{}{
"documents": []interface{}{},
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationStep5NotConfirmed(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppInProgress, CurrentStep: 5, KYCStatus: KYCVerified, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/step/5", map[string]bool{
"confirmed": false,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationReviewBadDecision(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppSubmitted, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "maybe",
"reviewed_by": "admin",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationReviewNotSubmitted(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppDraft, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "approved",
"reviewed_by": "admin",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestApplicationReviewReject(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppSubmitted, Steps: newApplicationSteps()}
store.SaveApplication(app)
w := doRequest(r, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "rejected",
"reviewed_by": "[email protected]",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var reviewed Application
decodeJSON(t, w, &reviewed)
if reviewed.Status != AppRejected {
t.Fatalf("expected rejected, got %s", reviewed.Status)
}
}
func TestApplicationDocumentsNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/applications/nonexistent/documents", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
// ==========================================================================
// KYC Extended Endpoint Tests
// ==========================================================================
func TestKYCListByUser(t *testing.T) {
r, store := newTestRouter()
store.SaveIdentity(&Identity{UserID: "user-multi", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}})
store.SaveIdentity(&Identity{UserID: "user-multi", Provider: "berbix", Status: KYCVerified, Data: map[string]interface{}{}})
store.SaveIdentity(&Identity{UserID: "user-other", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}})
w := doRequest(r, "GET", "/kyc/?user_id=user-multi", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var identities []*Identity
decodeJSON(t, w, &identities)
if len(identities) != 2 {
t.Fatalf("expected 2 identities for user-multi, got %d", len(identities))
}
}
func TestKYCListByUserMissingParam(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/kyc/", nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestKYCUpdateStatus(t *testing.T) {
r, store := newTestRouter()
ident := &Identity{UserID: "user-update", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}}
store.SaveIdentity(ident)
w := doRequest(r, "PATCH", "/kyc/"+ident.ID, map[string]string{
"status": "verified",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated Identity
decodeJSON(t, w, &updated)
if updated.Status != KYCVerified {
t.Fatalf("expected verified, got %s", updated.Status)
}
}
func TestKYCUpdateStatusInvalid(t *testing.T) {
r, store := newTestRouter()
ident := &Identity{UserID: "user-bad", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}}
store.SaveIdentity(ident)
w := doRequest(r, "PATCH", "/kyc/"+ident.ID, map[string]string{
"status": "nonexistent_status",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestKYCUpdateStatusNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "PATCH", "/kyc/nonexistent", map[string]string{
"status": "verified",
})
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestKYCDocumentUpload(t *testing.T) {
r, store := newTestRouter()
ident := &Identity{UserID: "user-doc", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}}
store.SaveIdentity(ident)
w := doRequest(r, "POST", "/kyc/"+ident.ID+"/documents", map[string]string{
"type": "passport",
"name": "passport-scan.jpg",
"mime_type": "image/jpeg",
})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var doc Document
decodeJSON(t, w, &doc)
if doc.Type != "passport" {
t.Fatalf("expected type passport, got %s", doc.Type)
}
if doc.Status != "pending" {
t.Fatalf("expected status pending, got %s", doc.Status)
}
}
func TestKYCDocumentUploadMissingType(t *testing.T) {
r, store := newTestRouter()
ident := &Identity{UserID: "user-doc2", Provider: "onfido", Status: KYCPending, Data: map[string]interface{}{}}
store.SaveIdentity(ident)
w := doRequest(r, "POST", "/kyc/"+ident.ID+"/documents", map[string]string{
"name": "something.pdf",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestKYCDocumentUploadIdentityNotFound(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/kyc/nonexistent/documents", map[string]string{
"type": "passport",
"name": "test.pdf",
})
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
// ==========================================================================
// Modules Test (updated count)
// ==========================================================================
func TestListModulesIncludesAMLAndApplications(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "GET", "/modules", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var modules []Module
decodeJSON(t, w, &modules)
found := map[string]bool{}
for _, m := range modules {
found[m.Name] = true
}
for _, name := range []string{"aml", "applications"} {
if !found[name] {
t.Fatalf("expected module %q in list", name)
}
}
}
// ==========================================================================
// Store Unit Tests (new types)
// ==========================================================================
func TestStoreAMLScreeningNotFound(t *testing.T) {
s := NewStore()
_, err := s.GetAMLScreening("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent aml screening")
}
}
func TestStoreApplicationNotFound(t *testing.T) {
s := NewStore()
_, err := s.GetApplication("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent application")
}
}
func TestStoreApplicationByUserNotFound(t *testing.T) {
s := NewStore()
_, err := s.GetApplicationByUser("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent user application")
}
}
func TestStoreDocumentUploadNotFound(t *testing.T) {
s := NewStore()
_, err := s.GetDocumentUpload("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent document")
}
}
func TestStoreIdentitiesByUserEmpty(t *testing.T) {
s := NewStore()
results := s.ListIdentitiesByUser("nonexistent")
if len(results) != 0 {
t.Fatalf("expected 0 results, got %d", len(results))
}
}
func TestStoreGetRoleByName(t *testing.T) {
s := NewStore()
SeedStore(s)
role, err := s.GetRoleByName("Owner")
if err != nil {
t.Fatalf("expected Owner role, got error: %v", err)
}
if role.Name != "Owner" {
t.Fatalf("expected name Owner, got %s", role.Name)
}
if len(role.Permissions) == 0 {
t.Fatal("expected Owner to have permissions")
}
}
func TestStoreGetRoleByNameNotFound(t *testing.T) {
s := NewStore()
_, err := s.GetRoleByName("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent role name")
}
}
// ==========================================================================
// Security Tests — Red Team Findings
// ==========================================================================
// CRITICAL-2: AML reviewer identity must come from JWT, not request body.
func TestSecurityAMLReviewerSpoofingPrevented(t *testing.T) {
r, store := newTestRouter()
sc := &AMLScreening{AccountID: "a1", Status: AMLFlagged, Provider: "manual"}
store.SaveAMLScreening(sc)
// Attacker tries to claim review was by "bob" but JWT says "test-user-001".
w := doRequest(r, "POST", "/aml/screenings/"+sc.ID+"/review", map[string]string{
"decision": "blocked",
"reviewed_by": "[email protected]",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var reviewed AMLScreening
decodeJSON(t, w, &reviewed)
if reviewed.ReviewedBy == "[email protected]" {
t.Fatal("SECURITY: reviewer identity accepted from request body (spoofable)")
}
if reviewed.ReviewedBy != "test-user-001" {
t.Fatalf("expected reviewer testadmin from JWT, got %s", reviewed.ReviewedBy)
}
}
// CRITICAL-3: Application reviewer identity must come from JWT, not request body.
func TestSecurityApplicationReviewerSpoofingPrevented(t *testing.T) {
r, store := newTestRouter()
app := &Application{UserID: "u1", Email: "[email protected]", Status: AppSubmitted, Steps: newApplicationSteps()}
store.SaveApplication(app)
// Attacker sends reviewed_by in body — must be ignored.
w := doRequest(r, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "approved",
"reviewed_by": "[email protected]",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var reviewed Application
decodeJSON(t, w, &reviewed)
if reviewed.ReviewedBy == "[email protected]" {
t.Fatal("SECURITY: reviewer identity accepted from request body (spoofable)")
}
if reviewed.ReviewedBy != "test-user-001" {
t.Fatalf("expected reviewer testadmin from JWT, got %s", reviewed.ReviewedBy)
}
}
// HIGH-2: Application steps cannot be skipped.
func TestSecurityApplicationStepSkipPrevented(t *testing.T) {
r, store := newTestRouter()
app := &Application{
UserID: "u1",
Email: "[email protected]",
Status: AppDraft,
CurrentStep: 1,
Steps: newApplicationSteps(),
}
store.SaveApplication(app)
// Try to jump directly to step 5 — should be rejected.
w := doRequest(r, "POST", "/applications/"+app.ID+"/step/5", map[string]bool{
"confirmed": true,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("SECURITY: step 5 accepted without completing prior steps, got %d", w.Code)
}
// Try to jump to step 3 — should be rejected.
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/3", map[string]interface{}{
"documents": []map[string]interface{}{
{"type": "passport", "name": "p.pdf", "mime_type": "application/pdf", "size": 100},
},
})
if w.Code != http.StatusBadRequest {
t.Fatalf("SECURITY: step 3 accepted without completing step 2, got %d", w.Code)
}
// Try to jump to step 2 — should be rejected (step 1 not done).
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/2", map[string]string{
"provider": "onfido",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("SECURITY: step 2 accepted without completing step 1, got %d", w.Code)
}
// Step 1 should work (no precondition).
w = doRequest(r, "POST", "/applications/"+app.ID+"/step/1", map[string]string{
"first_name": "Test",
"last_name": "User",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for step 1, got %d: %s", w.Code, w.Body.String())
}
}
// HIGH-2 / MEDIUM-5: Application steps rejected after terminal status.
func TestSecurityApplicationTerminalStatusBlocksSteps(t *testing.T) {
r, store := newTestRouter()
app := &Application{
UserID: "u1",
Email: "[email protected]",
Status: AppApproved,
CurrentStep: 5,
Steps: newApplicationSteps(),
}
store.SaveApplication(app)
// Try to modify step 1 on an approved application — should be rejected.
w := doRequest(r, "POST", "/applications/"+app.ID+"/step/1", map[string]string{
"first_name": "Hacker",
"last_name": "Evil",
})
if w.Code != http.StatusConflict {
t.Fatalf("SECURITY: step modification allowed on approved application, got %d", w.Code)
}
// Try on a rejected application.
app2 := &Application{
UserID: "u2",
Email: "[email protected]",
Status: AppRejected,
CurrentStep: 5,
Steps: newApplicationSteps(),
}
store.SaveApplication(app2)
w = doRequest(r, "POST", "/applications/"+app2.ID+"/step/1", map[string]string{
"first_name": "Hacker",
"last_name": "Evil",
})
if w.Code != http.StatusConflict {
t.Fatalf("SECURITY: step modification allowed on rejected application, got %d", w.Code)
}
// Try on a submitted application.
app3 := &Application{
UserID: "u3",
Email: "[email protected]",
Status: AppSubmitted,
CurrentStep: 5,
Steps: newApplicationSteps(),
}
store.SaveApplication(app3)
w = doRequest(r, "POST", "/applications/"+app3.ID+"/step/1", map[string]string{
"first_name": "Hacker",
"last_name": "Evil",
})
if w.Code != http.StatusConflict {
t.Fatalf("SECURITY: step modification allowed on submitted application, got %d", w.Code)
}
}
// Org-based access: built-in org = full access, other orgs = self-service only.
func TestSecurityOrgBasedAccess(t *testing.T) {
store := NewStore()
SeedStore(store)
router := NewRouter(store)
// built-in org should have full access to admin modules (kyc, funds, etc.).
w := doRequestAsOrg(router, "GET", "/kyc/?user_id=test", nil, "admin-user", "built-in")
if w.Code == http.StatusForbidden {
t.Fatal("built-in org should have kyc:read access")
}
// Non-admin org should access self-service modules (sessions, applications).
w = doRequestAsOrg(router, "POST", "/sessions/", map[string]string{
"pipeline_id": "test", "investor_email": "[email protected]",
}, "customer-user", "liquidity")
if w.Code == http.StatusForbidden {
t.Fatal("customer org should have sessions:write access")
}
// Non-admin org should NOT access admin-only modules (funds).
w = doRequestAsOrg(router, "POST", "/funds/", map[string]interface{}{
"name": "Hack Fund", "type": "equity",
}, "customer-user", "liquidity")
if w.Code != http.StatusForbidden {
t.Fatalf("SECURITY: customer org should NOT access funds, got %d", w.Code)
}
// Non-admin org should NOT access admin-only modules (kyc review).
w = doRequestAsOrg(router, "GET", "/kyc/?user_id=test", nil, "customer-user", "liquidity")
if w.Code != http.StatusForbidden {
t.Fatalf("SECURITY: customer org should NOT access kyc admin, got %d", w.Code)
}
}
// ==========================================================================
// Wallet Screen Tests
// ==========================================================================
func TestWalletScreenCleanAddress(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"chain": "ethereum",
"direction": "receive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "low" {
t.Fatalf("expected risk low, got %s", resp.Risk)
}
if resp.Sanctioned {
t.Fatal("expected sanctioned=false for clean address")
}
if resp.Scam {
t.Fatal("expected scam=false for clean address")
}
if resp.Source != "ofac" {
t.Fatalf("expected source ofac (no external provider configured), got %s", resp.Source)
}
}
func TestWalletScreenOFACSanctioned(t *testing.T) {
r, _ := newTestRouter()
// Tornado Cash address (from OFAC SDN list).
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0x8589427373D6D84E98730D7795D8f6f8731FDA16",
"chain": "ethereum",
"direction": "send",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "blocked" {
t.Fatalf("expected risk blocked, got %s", resp.Risk)
}
if !resp.Sanctioned {
t.Fatal("expected sanctioned=true for OFAC address")
}
if resp.Source != "ofac" {
t.Fatalf("expected source ofac, got %s", resp.Source)
}
}
func TestWalletScreenOFACCaseInsensitive(t *testing.T) {
r, _ := newTestRouter()
// Same Tornado Cash address but all lowercase.
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0x8589427373d6d84e98730d7795d8f6f8731fda16",
"chain": "ethereum",
"direction": "receive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "blocked" {
t.Fatalf("expected risk blocked for lowercase OFAC address, got %s", resp.Risk)
}
if !resp.Sanctioned {
t.Fatal("expected sanctioned=true")
}
}
func TestWalletScreenLazarusGroup(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0x098B716B8Aaf21512996dC57EB0615e2383E2f96",
"chain": "ethereum",
"direction": "receive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "blocked" {
t.Fatalf("expected risk blocked for Lazarus Group address, got %s", resp.Risk)
}
if !resp.Sanctioned {
t.Fatal("expected sanctioned=true for Lazarus Group")
}
}
func TestWalletScreenMissingAddress(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"chain": "ethereum",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestWalletScreenMissingChain(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestWalletScreenInvalidChain(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"chain": "solana",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestWalletScreenInvalidDirection(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"chain": "ethereum",
"direction": "bridge",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestWalletScreenDefaultDirection(t *testing.T) {
r, _ := newTestRouter()
// Omit direction — should default to "receive" and succeed.
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"chain": "liquidity",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "low" {
t.Fatalf("expected risk low, got %s", resp.Risk)
}
}
func TestWalletScreenBitcoinChain(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"chain": "bitcoin",
"direction": "send",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "low" {
t.Fatalf("expected risk low for clean bitcoin address, got %s", resp.Risk)
}
}
func TestWalletScreenAccessibleByAnyOrg(t *testing.T) {
r, _ := newTestRouter()
// Non-admin org should still access wallet-screen (self-service via kyc module).
w := doRequestAsOrg(r, "POST", "/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"chain": "ethereum",
"direction": "receive",
}, "customer-user", "liquidity")
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for customer org, got %d: %s", w.Code, w.Body.String())
}
}
// ==========================================================================
// ScamSniffer Wallet Screen Tests
// ==========================================================================
func TestWalletScreenScamAddress(t *testing.T) {
r, _ := newTestRouter()
// Top scam address from ScamSniffer database (492 phishing domains).
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0xc75269b342c1b7f4cbb82e80a7986878ac0f545b",
"chain": "ethereum",
"direction": "receive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "high" {
t.Fatalf("expected risk high for scam address, got %s", resp.Risk)
}
if !resp.Scam {
t.Fatal("expected scam=true for ScamSniffer address")
}
if resp.Sanctioned {
t.Fatal("expected sanctioned=false for scam (non-OFAC) address")
}
if resp.Source != "scamsniffer" {
t.Fatalf("expected source scamsniffer, got %s", resp.Source)
}
}
func TestWalletScreenScamCaseInsensitive(t *testing.T) {
r, _ := newTestRouter()
// Mixed-case version of scam address.
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0xC75269B342C1B7F4CBB82E80A7986878AC0F545B",
"chain": "ethereum",
"direction": "send",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if !resp.Scam {
t.Fatal("expected scam=true for uppercase ScamSniffer address")
}
if resp.Risk != "high" {
t.Fatalf("expected risk high, got %s", resp.Risk)
}
}
func TestWalletScreenCleanNotScam(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0xdead000000000000000000000000000000000000",
"chain": "liquidity",
"direction": "receive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Scam {
t.Fatal("expected scam=false for clean address")
}
if resp.Sanctioned {
t.Fatal("expected sanctioned=false for clean address")
}
if resp.Risk != "low" {
t.Fatalf("expected risk low, got %s", resp.Risk)
}
}
func TestWalletScreenResponseIncludesBothFields(t *testing.T) {
r, _ := newTestRouter()
// Clean address — verify response JSON has both sanctioned and scam fields.
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"chain": "bitcoin",
"direction": "receive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var raw map[string]interface{}
decodeJSON(t, w, &raw)
if _, ok := raw["sanctioned"]; !ok {
t.Fatal("response missing 'sanctioned' field")
}
if _, ok := raw["scam"]; !ok {
t.Fatal("response missing 'scam' field")
}
}
func TestWalletScreenOFACTakesPrecedenceOverScam(t *testing.T) {
r, _ := newTestRouter()
// OFAC address should return blocked/sanctioned even if it were also in scam DB.
w := doRequest(r, "POST", "/wallet-screen", map[string]string{
"address": "0x8589427373D6D84E98730D7795D8f6f8731FDA16",
"chain": "ethereum",
"direction": "send",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "blocked" {
t.Fatalf("expected risk blocked (OFAC takes precedence), got %s", resp.Risk)
}
if !resp.Sanctioned {
t.Fatal("expected sanctioned=true")
}
if resp.Source != "ofac" {
t.Fatalf("expected source ofac, got %s", resp.Source)
}
}
func TestScamDBCheck(t *testing.T) {
db := NewScamDB()
// Known scam address.
isScam, source := db.Check("0xc75269b342c1b7f4cbb82e80a7986878ac0f545b")
if !isScam {
t.Fatal("expected isScam=true for known scam address")
}
if source != "scamsniffer" {
t.Fatalf("expected source scamsniffer, got %s", source)
}
// Clean address.
isScam, source = db.Check("0x0000000000000000000000000000000000000001")
if isScam {
t.Fatal("expected isScam=false for clean address")
}
if source != "" {
t.Fatalf("expected empty source for clean address, got %s", source)
}
}
func TestScamDBCount(t *testing.T) {
db := NewScamDB()
if db.Count() < 100 {
t.Fatalf("expected at least 100 default addresses, got %d", db.Count())
}
}
// ==========================================================================
// Alpaca Provisioning on Approval
// ==========================================================================
// mockAlpacaProvider implements provider.Provider for testing account provisioning.
type mockAlpacaProvider struct {
created chan *types.CreateAccountRequest
}
func (m *mockAlpacaProvider) Name() string { return "alpaca" }
func (m *mockAlpacaProvider) CreateAccount(_ context.Context, req *types.CreateAccountRequest) (*types.Account, error) {
m.created <- req
return &types.Account{ID: "mock-alpaca-acct-001", Provider: "alpaca"}, nil
}
func (m *mockAlpacaProvider) GetAccount(context.Context, string) (*types.Account, error) {
return nil, nil
}
func (m *mockAlpacaProvider) ListAccounts(context.Context) ([]*types.Account, error) {
return nil, nil
}
func (m *mockAlpacaProvider) GetPortfolio(context.Context, string) (*types.Portfolio, error) {
return nil, nil
}
func (m *mockAlpacaProvider) CreateOrder(context.Context, string, *types.CreateOrderRequest) (*types.Order, error) {
return nil, nil
}
func (m *mockAlpacaProvider) ListOrders(context.Context, string) ([]*types.Order, error) {
return nil, nil
}
func (m *mockAlpacaProvider) GetOrder(context.Context, string, string) (*types.Order, error) {
return nil, nil
}
func (m *mockAlpacaProvider) CancelOrder(context.Context, string, string) error { return nil }
func (m *mockAlpacaProvider) CreateTransfer(context.Context, string, *types.CreateTransferRequest) (*types.Transfer, error) {
return nil, nil
}
func (m *mockAlpacaProvider) ListTransfers(context.Context, string) ([]*types.Transfer, error) {
return nil, nil
}
func (m *mockAlpacaProvider) CreateBankRelationship(context.Context, string, string, string, string, string) (*types.BankRelationship, error) {
return nil, nil
}
func (m *mockAlpacaProvider) ListBankRelationships(context.Context, string) ([]*types.BankRelationship, error) {
return nil, nil
}
func (m *mockAlpacaProvider) ListAssets(context.Context, string) ([]*types.Asset, error) {
return nil, nil
}
func (m *mockAlpacaProvider) GetAsset(context.Context, string) (*types.Asset, error) {
return nil, nil
}
func (m *mockAlpacaProvider) GetSnapshot(context.Context, string) (*types.MarketSnapshot, error) {
return nil, nil
}
func (m *mockAlpacaProvider) GetSnapshots(context.Context, []string) (map[string]*types.MarketSnapshot, error) {
return nil, nil
}
func (m *mockAlpacaProvider) GetBars(context.Context, string, string, string, string, int) ([]*types.Bar, error) {
return nil, nil
}
func (m *mockAlpacaProvider) GetLatestTrades(context.Context, []string) (map[string]*types.Trade, error) {
return nil, nil
}
func (m *mockAlpacaProvider) GetLatestQuotes(context.Context, []string) (map[string]*types.Quote, error) {
return nil, nil
}
func (m *mockAlpacaProvider) GetClock(context.Context) (*types.MarketClock, error) {
return nil, nil
}
func (m *mockAlpacaProvider) GetCalendar(context.Context, string, string) ([]*types.MarketCalendarDay, error) {
return nil, nil
}
func TestApplicationApprovalProvisionsAlpacaAccount(t *testing.T) {
mock := &mockAlpacaProvider{created: make(chan *types.CreateAccountRequest, 1)}
reg := provider.NewRegistry()
reg.Register(mock)
store := NewStore()
router := NewRouter(store, WithScamDB(NewScamDB()), WithRegistry(reg))
app := &Application{
UserID: "u-prov",
Email: "[email protected]",
FirstName: "John",
LastName: "Doe",
Phone: "5551234567",
DateOfBirth: "1990-01-15",
AddressLine1: "123 Main St",
City: "New York",
State: "NY",
ZipCode: "10001",
Country: "USA",
Status: AppSubmitted,
Steps: newApplicationSteps(),
}
store.SaveApplication(app)
w := doRequest(router, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "approved",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var reviewed Application
decodeJSON(t, w, &reviewed)
if reviewed.Status != AppApproved {
t.Fatalf("expected approved, got %s", reviewed.Status)
}
// Wait for async provisioning goroutine (with timeout).
select {
case req := <-mock.created:
if req.Identity.GivenName != "John" {
t.Fatalf("expected GivenName John, got %s", req.Identity.GivenName)
}
if req.Identity.FamilyName != "Doe" {
t.Fatalf("expected FamilyName Doe, got %s", req.Identity.FamilyName)
}
if req.Contact.Email != "[email protected]" {
t.Fatalf("expected email [email protected], got %s", req.Contact.Email)
}
if req.Contact.City != "New York" {
t.Fatalf("expected city New York, got %s", req.Contact.City)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for Alpaca account provisioning")
}
// Verify the AlpacaAccountID was saved back to the application.
// Give the goroutine a moment to complete the save after CreateAccount returns.
time.Sleep(100 * time.Millisecond)
saved, err := store.GetApplicationByUser("u-prov")
if err != nil {
t.Fatalf("failed to fetch application: %v", err)
}
if saved.AlpacaAccountID != "mock-alpaca-acct-001" {
t.Fatalf("expected AlpacaAccountID mock-alpaca-acct-001, got %q", saved.AlpacaAccountID)
}
}
func TestApplicationRejectionDoesNotProvision(t *testing.T) {
mock := &mockAlpacaProvider{created: make(chan *types.CreateAccountRequest, 1)}
reg := provider.NewRegistry()
reg.Register(mock)
store := NewStore()
router := NewRouter(store, WithScamDB(NewScamDB()), WithRegistry(reg))
app := &Application{
UserID: "u-rej",
Email: "[email protected]",
Status: AppSubmitted,
Steps: newApplicationSteps(),
}
store.SaveApplication(app)
w := doRequest(router, "POST", "/applications/"+app.ID+"/review", map[string]string{
"decision": "rejected",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Should NOT trigger provisioning.
select {
case <-mock.created:
t.Fatal("Alpaca account provisioned on rejection - should not happen")
case <-time.After(200 * time.Millisecond):
// Expected: no provisioning on rejection.
}
}