Files
bank/flows_test.go
zeekayandHanzo Dev 8d7457cdf0 Lux ID is the only way in
Base v1.5.72 closed the door this depended on, and closed it deliberately:
"IAM IS THE ONLY AUTH. There is no second way in... _superusers is not an
exception: it reaches schema, settings, backups and logs, and one process
serves many orgs' Bases, so two independently-keyed doors to it would be
one more than can be reasoned about."

The sandbox login was the second door. It kept a bcrypt hash in its own
collection and minted a _superusers token, and until that release Base
honoured such a token as a fallback. After it, the route still answered
200 with a token and the very next request answered 401 — a login that
succeeds and authenticates nothing. Every authed route was refusing, and
the suite could not see it: the test harness never registers the org
plugin, so 141 tests passed green against a binary whose demo login was
broken.

It was also the custom auth this estate does not build. Gone: the route,
the credentials collection, the token minting, the demo password that was
a constant in a file every browser downloads, and the merged hook shape
that let one path stand in for the other. The SPA already had the PKCE
flow it needed.

What this costs: [email protected] must exist in Lux IAM under org lux, app
lux-bank. The demo cannot sign in until it does. That is an IAM seeding
task, not a code one, and it is the same identity convention every other
surface already follows.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 15:16:53 -07:00

189 lines
6.3 KiB
Go

package bank
import (
"net/http"
"strings"
"testing"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tests"
"github.com/luxfi/bank/collections"
)
// openAccount creates a second account owned by ownerID with a USD balance,
// so a book transfer has a destination the caller also owns.
func openAccount(t *testing.T, app core.App, ownerID string, usd Minor) string {
t.Helper()
col, err := app.FindCollectionByNameOrId(collections.AccountCollectionName)
if err != nil {
t.Fatalf("account collection: %v", err)
}
a := core.NewRecord(col)
a.Set("owner", ownerID)
a.Set("entityName", "Second Account")
a.Set("entityType", "individual")
a.Set("country", "US")
a.Set("currency", "USD")
a.Set("status", "active")
a.Set("kycStatus", "approved")
a.Set("riskRating", "low")
if err := app.Save(a); err != nil {
t.Fatalf("save account: %v", err)
}
if err := setBalance(app, a.Id, "USD", usd); err != nil {
t.Fatalf("set balance: %v", err)
}
return a.Id
}
func TestBookTransferBetweenOwnAccounts(t *testing.T) {
app := newBankApp(t)
id, token := seedPrincipal(t, app)
src := primaryAccount(app, id)
if err := setBalance(app, src.Id, "USD", 100_00); err != nil {
t.Fatalf("set balance: %v", err)
}
dst := openAccount(t, app, id, 1_00)
h := map[string]string{"Authorization": token, "Content-Type": "application/json"}
run(t, app, tests.ApiScenario{
Name: "book transfer between two own accounts",
Method: http.MethodPost,
URL: "/v1/bank/transfers",
Body: strings.NewReader(`{"fromAccountId":"` + src.Id + `","toAccountId":"` + dst + `","amount":4000,"currency":"USD","reference":"sweep"}`),
Headers: h,
ExpectedStatus: 201,
ExpectedContent: []string{`"debitId"`, `"creditId"`},
})
// A transfer to an account the caller does not own is refused.
other := newBankApp(t) // throwaway to mint a foreign owner id shape
_ = other
run(t, app, tests.ApiScenario{
Name: "transfer to an unowned destination is refused",
Method: http.MethodPost,
URL: "/v1/bank/transfers",
Body: strings.NewReader(`{"fromAccountId":"` + src.Id + `","toAccountId":"nonexistent0000","amount":100,"currency":"USD"}`),
Headers: h,
ExpectedStatus: 404,
ExpectedContent: []string{`"status":404`},
})
}
func TestBeneficiaryLifecycleAndOutboundPayment(t *testing.T) {
app := newBankApp(t)
id, token := seedPrincipal(t, app)
acct := primaryAccount(app, id)
if err := setBalance(app, acct.Id, "USD", 500_00); err != nil {
t.Fatalf("set balance: %v", err)
}
h := map[string]string{"Authorization": token, "Content-Type": "application/json"}
// Create a beneficiary tied to the account.
benBody := `{"account":"` + acct.Id + `","name":"Supplier Ltd","currency":"USD","country":"US","type":"bank","accountNumber":"12345678","routing":"021000021"}`
run(t, app, tests.ApiScenario{
Name: "create a beneficiary",
Method: http.MethodPost,
URL: "/v1/bank/beneficiaries",
Body: strings.NewReader(benBody),
Headers: h,
ExpectedStatus: 201,
ExpectedContent: []string{`"id"`},
})
ben, _ := app.FindFirstRecordByFilter(collections.BeneficiaryCollectionName,
"account = {:a}", map[string]any{"a": acct.Id})
if ben == nil {
t.Fatal("beneficiary not created")
}
run(t, app, tests.ApiScenario{
Name: "outbound payment to the beneficiary",
Method: http.MethodPost,
URL: "/v1/bank/payments/outbound",
Body: strings.NewReader(`{"accountId":"` + acct.Id + `","beneficiaryId":"` + ben.Id + `","amount":5000,"currency":"USD","reference":"invoice 1"}`),
Headers: h,
ExpectedStatus: 201,
ExpectedContent: []string{`"transactionId"`},
})
run(t, app, tests.ApiScenario{
Name: "list beneficiaries",
Method: http.MethodGet,
URL: "/v1/bank/beneficiaries",
Headers: map[string]string{"Authorization": token},
ExpectedStatus: 200,
ExpectedContent: []string{`"Supplier Ltd"`},
})
run(t, app, tests.ApiScenario{
Name: "delete the beneficiary",
Method: http.MethodDelete,
URL: "/v1/bank/beneficiaries/" + ben.Id,
Headers: map[string]string{"Authorization": token},
ExpectedStatus: 200,
ExpectedContent: []string{`"deleted"`},
})
}
func TestCardIssueAndFreeze(t *testing.T) {
app := newBankApp(t)
id, token := seedPrincipal(t, app)
acct := primaryAccount(app, id)
h := map[string]string{"Authorization": token, "Content-Type": "application/json"}
run(t, app, tests.ApiScenario{
Name: "issue a virtual card",
Method: http.MethodPost,
URL: "/v1/bank/cards",
Body: strings.NewReader(`{"accountId":"` + acct.Id + `","currency":"USD"}`),
Headers: h,
ExpectedStatus: 201,
ExpectedContent: []string{`"brand":"visa"`, `"type":"virtual"`},
})
card, _ := app.FindFirstRecordByFilter(collections.CardCollectionName,
"account = {:a} && status = 'active'", map[string]any{"a": acct.Id})
if card == nil {
t.Fatal("card not issued")
}
run(t, app, tests.ApiScenario{
Name: "freeze the card",
Method: http.MethodPost,
URL: "/v1/bank/cards/" + card.Id + "/freeze",
Headers: map[string]string{"Authorization": token},
ExpectedStatus: 200,
ExpectedContent: []string{`"status":"frozen"`},
})
run(t, app, tests.ApiScenario{
Name: "unfreeze the card",
Method: http.MethodPost,
URL: "/v1/bank/cards/" + card.Id + "/unfreeze",
Headers: map[string]string{"Authorization": token},
ExpectedStatus: 200,
ExpectedContent: []string{`"status":"active"`},
})
}
func TestPerAccountReads(t *testing.T) {
app := newBankApp(t)
id, token := seedPrincipal(t, app)
acct := primaryAccount(app, id)
h := map[string]string{"Authorization": token}
for _, c := range []struct{ path, want string }{
{"/v1/bank/accounts/" + acct.Id + "/balances", `"currency"`},
{"/v1/bank/accounts/" + acct.Id + "/wallets", `[`},
{"/v1/bank/accounts/" + acct.Id + "/transactions", `[`},
} {
run(t, app, tests.ApiScenario{
Name: "read " + c.path,
Method: http.MethodGet,
URL: c.path,
Headers: h,
ExpectedStatus: 200,
ExpectedContent: []string{c.want},
})
}
}