Files
finance-duck/internal/app/consent_test.go
T
Lars Nolden c33e8d5573 Authorize consents for the account-holder type the bank supports
Kontist authorized but shared no accounts: psu_type was hardcoded to
personal, and Enable Banking documents that a psu_type mismatch can
yield a consent without the expected accounts. The bank listing now
reports each institution's supported psu_types, the connect form offers
only those, the chosen type reaches POST /auth, and an unsupported
combination is refused before the user is sent to a bank. The choice is
stored per consent so reconnecting reuses it; consents predating the
choice stay personal.

Also repairs the frontend derivation, which the Montserrat dependency
broke: npmDepsHash was stale and web/public was missing from the
fileset, so the traced duck icon never reached the built assets.
2026-09-11 13:39:45 +02:00

364 lines
14 KiB
Go

package app
import (
"context"
"reflect"
"slices"
"strings"
"testing"
"time"
"finance-duck/internal/banking"
"finance-duck/internal/domain"
)
type historyBank struct {
bankScenario
fetched chan struct{}
authState string
authorizations int
fromDates []string
psuTypes []string
}
func (b *historyBank) Authorize(_ context.Context, _, _, psuType, state string) (string, error) {
b.psuTypes = append(b.psuTypes, psuType)
b.authState = state
b.authorizations++
return "https://bank.example/authorize", nil
}
func (b *historyBank) Institutions(context.Context, string) ([]banking.Institution, error) {
return []banking.Institution{{Name: "N26", Country: "DE"}}, nil
}
func (b *historyBank) Transactions(_ context.Context, account domain.Account, from, to string, _ bool) ([]domain.Facts, error) {
b.fromDates = append(b.fromDates, from)
var rows []domain.Facts
for _, days := range []int{400, 300, 1} {
date := time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02")
if date >= from && date <= to {
rows = append(rows, domain.Facts{Source: "enablebanking", AccountID: account.ID, BookingDate: date, Amount: "-10.00", Currency: "EUR", RawDescription: "Card payment", ExternalID: "entry_" + date})
}
}
if b.fetched != nil {
select {
case b.fetched <- struct{}{}:
default:
}
}
return rows, nil
}
func TestNewAccountImportsHistoryIndependentOfExistingSyncCursor(t *testing.T) {
a, s := testApp(t)
old := s.Data.Accounts[0]
old.ExternalAccountID = "old_uid"
fresh := domain.Account{ID: "ing", DisplayName: "ING", Institution: "ING", Currency: "EUR", ExternalAccountID: "new_uid", Active: true}
s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { d.Accounts = []domain.Account{old, fresh}; return nil })
if err != nil {
t.Fatal(err)
}
session := banking.Session{ID: "consent", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: s.Data.Accounts}
b := &historyBank{bankScenario: bankScenario{session: session}}
a.bank = b
a.ops.Sessions = []banking.Session{session}
a.ops.LastSync = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
a.ops.AccountSync[old.ID] = a.ops.LastSync
if err := a.saveOps(); err != nil {
t.Fatal(err)
}
a = reopenBankingApp(t, a)
a.bank = b
after, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
counts := map[string]int{}
for _, tx := range after.Data.Transactions {
counts[tx.Facts.AccountID]++
}
if counts[old.ID] != 1 || counts[fresh.ID] != 2 {
t.Fatalf("new account history skipped: %v", counts)
}
}
func TestAuthorizedHistorySurvivesReopenAndRespectsIncrementalCursor(t *testing.T) {
a, s := testApp(t)
ctx := context.Background()
account := s.Data.Accounts[0]
account.ExternalAccountID = "history_uid"
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "history_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}}
a.bank = b
for _, months := range []int{-1, 0, 121} {
if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, months); err == nil {
t.Fatalf("accepted invalid history choice %d", months)
}
}
if b.authorizations != 0 {
t.Fatal("invalid history choice reached the bank")
}
if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, 24); err != nil {
t.Fatal(err)
}
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
t.Fatal(err)
}
a = reopenBankingApp(t, a)
a.bank = b
s, err := a.Snapshot(ctx)
if err != nil {
t.Fatal(err)
}
if len(s.Connections) != 1 || s.Connections[0].HistoryMonths != 24 {
t.Fatalf("saved history choice unavailable after restart: %+v", s.Connections)
}
before := time.Now().UTC().AddDate(0, -24, 0).Format("2006-01-02")
first, err := a.Sync(ctx)
if err != nil {
t.Fatal(err)
}
after := time.Now().UTC().AddDate(0, -24, 0).Format("2006-01-02")
if len(first.Data.Transactions) != 3 {
t.Fatalf("selected history did not import older transactions: %+v", first.Data.Transactions)
}
if len(b.fromDates) != 1 || (b.fromDates[0] != before && b.fromDates[0] != after) {
t.Fatalf("initial import did not use 24 calendar months: %v", b.fromDates)
}
cursor, err := time.Parse(time.RFC3339, first.Status.LastSync)
if err != nil {
t.Fatal(err)
}
a = reopenBankingApp(t, a)
a.bank = b
again, err := a.Sync(ctx)
if err != nil {
t.Fatal(err)
}
wantFrom := cursor.AddDate(0, 0, -14).Format("2006-01-02")
if len(b.fromDates) != 2 || b.fromDates[1] != wantFrom {
t.Fatalf("incremental import ignored saved cursor: %v, want %s", b.fromDates, wantFrom)
}
if len(again.Data.Transactions) != 3 || again.Connections[0].HistoryMonths != 24 {
t.Fatal("incremental import duplicated history or lost the selected window")
}
}
func TestExpiredConsentIsVisibleBeforeNextScheduledSync(t *testing.T) {
a, s := testApp(t)
account := s.Data.Accounts[0]
account.ExternalAccountID = "uid"
_, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { return SaveAccount(d, account) })
if err != nil {
t.Fatal(err)
}
a.ops.Sessions = []banking.Session{{ID: "expired", ValidUntil: time.Now().Add(-time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}
a.ops.Consents["expired"] = Consent{Institution: "ING", Country: "DE"}
after, err := a.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if len(after.Connections) != 1 || after.Connections[0].Status != "reconnect_required" || after.Connections[0].Institution != "ING" {
t.Fatalf("missing bank reconnect status: %+v", after.Connections)
}
if after.Connections[0].HistoryMonths != 12 {
t.Fatal("legacy consent did not retain the default reconnect history")
}
}
func TestRenewedConsentWakesSchedulerAndAutomaticallyImports(t *testing.T) {
a, s := testApp(t)
account := s.Data.Accounts[0]
account.ExternalAccountID = "renewed_uid"
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "renewed_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}, fetched: make(chan struct{}, 1)}
a.bank = b
a.ops.LastSync = time.Now().UTC().Format(time.RFC3339)
a.authStates["state"] = authorization{Expires: time.Now().Add(time.Minute), Institution: "N26", Country: "DE", HistoryMonths: 12}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { defer close(done); a.RunScheduler(ctx) }()
defer func() { cancel(); <-done }()
if _, err := a.Callback(context.Background(), "one_time_code", "state"); err != nil {
t.Fatal(err)
}
select {
case <-b.fetched:
case <-time.After(10 * time.Second):
t.Fatal("renewal did not wake automatic synchronization")
}
after, err := a.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if len(after.Data.Transactions) != 2 || len(after.Sessions) != 1 || after.Connections[0].Status != "connected" {
t.Fatalf("renewed consent not usable: %+v", after)
}
}
type recoveryBank struct{ historyBank }
func (b *recoveryBank) Status(ctx context.Context, id string) (banking.SessionStatus, error) {
if id != b.session.ID {
return banking.SessionStatus{}, banking.ErrReconnect
}
return b.historyBank.Status(ctx, id)
}
func TestInterruptedRenewalDiscardsSupersededConsentDuringRecovery(t *testing.T) {
a, s := testApp(t)
old := s.Data.Accounts[0]
old.ExternalAccountID = "old_uid"
renewed := old
renewed.ExternalAccountID = "new_uid"
session := banking.Session{ID: "new_session", ValidUntil: time.Now().Add(time.Hour).Format(time.RFC3339), Accounts: []domain.Account{renewed}}
a.bank = &recoveryBank{historyBank{bankScenario: bankScenario{session: session}}}
a.ops.Sessions = []banking.Session{{ID: "old_session", Accounts: []domain.Account{old}}, session}
after, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
if after.Status.SyncError != "" || len(after.Sessions) != 1 || after.Sessions[0].ID != "new_session" {
t.Fatalf("superseded consent survived recovery: %+v", after)
}
if len(after.Data.Transactions) != 2 || after.Connections[0].Status != "connected" {
t.Fatal("recovered replacement did not resume imports")
}
}
// A removed bank account must stay removed. Session recovery reconstructs
// bindings for interrupted connects, so a stale binding silently resurrects
// the account on the next connect or sync.
func TestDeletedBankAccountIsNotResurrectedByLaterConnectOrSync(t *testing.T) {
a, s := testApp(t)
ctx := context.Background()
kept := s.Data.Accounts[0]
kept.ExternalAccountID = "kept_uid"
removed := domain.Account{ID: "removed_acct", DisplayName: "Mwst", Institution: "N26", Currency: "EUR", ExternalAccountID: "removed_uid", Active: true}
s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
d.Accounts = []domain.Account{kept, removed}
return nil
})
if err != nil {
t.Fatal(err)
}
session := banking.Session{ID: "n26_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{kept, removed}}
b := &historyBank{bankScenario: bankScenario{session: session}}
a.bank = b
a.ops.Sessions = []banking.Session{session}
a.ops.Consents[session.ID] = Consent{Institution: "N26", Country: "DE", HistoryMonths: 12}
a.ops.AccountSync[removed.ID] = time.Now().UTC().Format(time.RFC3339)
if err := a.saveOps(); err != nil {
t.Fatal(err)
}
s, err = a.ManageRegistry(ctx, s.Revision, "account", "delete", removed.ID, "")
if err != nil {
t.Fatal(err)
}
if slices.ContainsFunc(s.Data.Accounts, func(v domain.Account) bool { return v.ID == removed.ID }) {
t.Fatal("account deletion did not remove the account")
}
if _, tracked := a.ops.AccountSync[removed.ID]; tracked {
t.Fatal("deleted account kept its sync cursor")
}
// A later connect for a different bank triggers binding recovery.
other := domain.Account{ID: "ing_acct", DisplayName: "ING Giro", Institution: "ING", Currency: "EUR", ExternalAccountID: "ing_uid", Active: true}
b.session = banking.Session{ID: "ing_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{other}}
if _, err := a.Authorize(ctx, "ING", "DE", banking.PSUPersonal, 12); err != nil {
t.Fatal(err)
}
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
t.Fatal(err)
}
a = reopenBankingApp(t, a)
a.bank = b
after, err := a.Sync(ctx)
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, account := range after.Data.Accounts {
names[account.ID] = true
}
if names[removed.ID] {
t.Fatalf("deleted bank account reappeared: %+v", after.Data.Accounts)
}
if !names[kept.ID] || !names[other.ID] {
t.Fatalf("connecting another bank lost existing accounts: %+v", after.Data.Accounts)
}
for _, connection := range after.Connections {
if connection.AccountID == removed.ID {
t.Fatal("deleted account still reported as connected")
}
}
}
// A consent that shares no accounts cannot sync and its session is reaped by
// recovery, so the user must be told instead of being silently redirected.
func TestConnectWithoutSharedAccountsFailsVisibly(t *testing.T) {
a, _ := testApp(t)
ctx := context.Background()
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "empty_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339)}}}
a.bank = b
if _, err := a.Authorize(ctx, "Kontist", "DE", banking.PSUBusiness, 12); err != nil {
t.Fatal(err)
}
_, err := a.Callback(ctx, "one_time_code", b.authState)
if err == nil || !strings.Contains(err.Error(), "no accounts") {
t.Fatalf("empty consent reported success: %v", err)
}
after, e := a.Snapshot(ctx)
if e != nil {
t.Fatal(e)
}
if len(after.Sessions) != 0 {
t.Fatalf("unusable consent was stored: %+v", after.Sessions)
}
for _, connection := range after.Connections {
if connection.Status != "local" {
t.Fatalf("unusable consent produced a bank connection: %+v", connection)
}
}
}
// A business consent must stay business: reconnecting a business account with
// the personal flow authorizes a consent that shares no accounts.
func TestSavedAccountHolderTypeSurvivesRestartForReconnect(t *testing.T) {
a, s := testApp(t)
ctx := context.Background()
account := s.Data.Accounts[0]
account.ExternalAccountID = "kontist_uid"
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "kontist_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}}
a.bank = b
if _, err := a.Authorize(ctx, "Kontist", "DE", banking.PSUBusiness, 12); err != nil {
t.Fatal(err)
}
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(b.psuTypes, []string{banking.PSUBusiness}) {
t.Fatalf("chosen account type did not reach the provider: %q", b.psuTypes)
}
a = reopenBankingApp(t, a)
a.bank = b
after, err := a.Snapshot(ctx)
if err != nil {
t.Fatal(err)
}
if len(after.Connections) != 1 || after.Connections[0].PSUType != banking.PSUBusiness {
t.Fatalf("account type unavailable for reconnecting: %+v", after.Connections)
}
for _, invalid := range []string{"corporate", "Personal"} {
if _, err := a.Authorize(ctx, "Kontist", "DE", invalid, 12); err == nil {
t.Fatalf("accepted undocumented account type %q", invalid)
}
}
// Legacy consents predate the choice and stay on the personal flow.
meta := a.ops.Consents["kontist_session"]
meta.PSUType = ""
a.ops.Consents["kontist_session"] = meta
legacy, err := a.Snapshot(ctx)
if err != nil {
t.Fatal(err)
}
if legacy.Connections[0].PSUType != banking.PSUPersonal {
t.Fatalf("legacy consent lost its personal default: %+v", legacy.Connections)
}
}