Three defects made new connections silently vanish while removed accounts returned: - A single shared account the journal cannot represent (securities or card entries without IBAN, stable identification or currency) aborted the entire consent. Usable accounts are now linked and the rest counted and reported. - A consent that linked nothing was stored, redirected as success and later reaped by session recovery. It now fails with the reason. - Callback failures rendered a bare JSON error page and were never logged. They now log and redirect into the app with the reason shown. - Deleting an account left its session binding, so the next connect or sync recovered the binding and re-added the account. Account deletion now releases bindings, consents and cursors before committing.
316 lines
12 KiB
Go
316 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
}
|
|
|
|
func (b *historyBank) Authorize(_ context.Context, _, _, state string) (string, error) {
|
|
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", 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", 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", 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", 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)
|
|
}
|
|
}
|
|
}
|