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.
This commit is contained in:
Lars Nolden
2026-09-11 13:39:45 +02:00
parent 35d91a5c48
commit c33e8d5573
11 changed files with 282 additions and 61 deletions
+53 -5
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"reflect"
"slices"
"strings"
"testing"
@@ -17,9 +18,11 @@ type historyBank struct {
authState string
authorizations int
fromDates []string
psuTypes []string
}
func (b *historyBank) Authorize(_ context.Context, _, _, state string) (string, error) {
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
@@ -87,14 +90,14 @@ func TestAuthorizedHistorySurvivesReopenAndRespectsIncrementalCursor(t *testing.
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 {
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", 24); err != nil {
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 {
@@ -257,7 +260,7 @@ func TestDeletedBankAccountIsNotResurrectedByLaterConnectOrSync(t *testing.T) {
// 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 {
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 {
@@ -293,7 +296,7 @@ func TestConnectWithoutSharedAccountsFailsVisibly(t *testing.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 {
if _, err := a.Authorize(ctx, "Kontist", "DE", banking.PSUBusiness, 12); err != nil {
t.Fatal(err)
}
_, err := a.Callback(ctx, "one_time_code", b.authState)
@@ -313,3 +316,48 @@ func TestConnectWithoutSharedAccountsFailsVisibly(t *testing.T) {
}
}
}
// 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)
}
}