mirror of
https://github.com/luxfi/bank.git
synced 2026-10-30 17:02:07 +00:00
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]>
189 lines
6.3 KiB
Go
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},
|
|
})
|
|
}
|
|
}
|