Files
argoyle 370ba70177
openpayments-mock / check (push) Has been skipped
openpayments-mock / vulnerabilities (push) Has been skipped
openpayments-mock / build (push) Failing after 1m11s
fix: actually include cmd/service/service.go in the repo
The initial .gitignore had 'service' as a bare pattern, which matches
the cmd/service directory and silently excluded the entire mock
implementation (~28KB of main source). Anchor the ignore patterns
to the repo root so they only match top-level build artefacts, and
force-add the real source now.
2026-04-21 06:20:50 +02:00

900 lines
28 KiB
Go

// openpayments-mock is a tiny in-memory stand-in for the Open Payments
// PSD2/BerlinGroup aggregator, built for acceptance tests. It implements
// just enough of the real API surface that banking-service can drive the
// full connect → sync → match → pay flow end-to-end, and exposes admin
// endpoints that the Robot Framework suite calls to deterministically
// drive mock state (force payment statuses, seed transactions, override
// consent expiries, reset between suites).
//
// Not safe for anything but tests. Accepts any bearer token. Holds all
// state in memory.
package main
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// ---------------------------------------------------------------------
// Domain shapes matching the relevant subset of BerlinGroup / Open Payments.
// ---------------------------------------------------------------------
type aspsp struct {
BicFi string `json:"bicFi"`
Name string `json:"name"`
LogoURL string `json:"logoUrl,omitempty"`
Country string `json:"country"`
SupportedServices map[string]bool `json:"supportedServices"`
}
type amount struct {
Currency string `json:"currency"`
Amount string `json:"amount"`
}
type accountRef struct {
IBAN string `json:"iban,omitempty"`
Currency string `json:"currency,omitempty"`
}
type balance struct {
BalanceAmount amount `json:"balanceAmount"`
BalanceType string `json:"balanceType"`
ReferenceDate string `json:"referenceDate,omitempty"`
}
type account struct {
ResourceID string `json:"resourceId"`
IBAN string `json:"iban,omitempty"`
BIC string `json:"bic,omitempty"`
Currency string `json:"currency"`
Name string `json:"name,omitempty"`
Product string `json:"product,omitempty"`
Status string `json:"status,omitempty"`
Balances []balance `json:"balances,omitempty"`
}
type transaction struct {
TransactionID string `json:"transactionId,omitempty"`
EndToEndID string `json:"endToEndId,omitempty"`
BookingDate string `json:"bookingDate,omitempty"`
ValueDate string `json:"valueDate,omitempty"`
TransactionAmount amount `json:"transactionAmount"`
CreditorName string `json:"creditorName,omitempty"`
CreditorAccount accountRef `json:"creditorAccount,omitempty"`
DebtorName string `json:"debtorName,omitempty"`
DebtorAccount accountRef `json:"debtorAccount,omitempty"`
RemittanceInfo string `json:"remittanceInformationUnstructured,omitempty"`
BookingStatus string `json:"bookingStatus,omitempty"`
PurposeCode string `json:"purposeCode,omitempty"`
BankTransactionCode string `json:"bankTransactionCode,omitempty"`
}
type consent struct {
ID string
Status string
ExpiresAt time.Time
CreatedAt time.Time
BicFi string
RedirectTo string
}
type payment struct {
ID string
Service string
Product string
Status string
StatusQueue []string // deterministic transitions
CreditorName string
InstructedAmt amount
BasketID string
}
type basket struct {
ID string
Status string
StatusQueue []string
PaymentIDs []string
}
// ---------------------------------------------------------------------
// Mock server state.
// ---------------------------------------------------------------------
type server struct {
mu sync.Mutex
aspsps []aspsp
// consent/account state (single stand-in customer per bicFi)
consents map[string]*consent // consentId -> consent
// accounts keyed by resourceId
accounts map[string]account
// transactions keyed by accountId → list (may be empty)
transactions map[string][]transaction
// payments keyed by paymentId
payments map[string]*payment
// baskets keyed by basketId
baskets map[string]*basket
logger *slog.Logger
}
func newServer(logger *slog.Logger) *server {
s := &server{
logger: logger,
consents: map[string]*consent{},
accounts: map[string]account{},
transactions: map[string][]transaction{},
payments: map[string]*payment{},
baskets: map[string]*basket{},
}
s.seedDefaults()
return s
}
// seedDefaults populates the catalog + default account so an un-touched
// mock is already useful.
func (s *server) seedDefaults() {
s.aspsps = []aspsp{
{
BicFi: "SHINYSESS",
Name: "Shiny Test Bank",
LogoURL: "",
Country: "SE",
SupportedServices: map[string]bool{
"ais": true,
"pis": true,
"cardAccounts": false,
"signingBasketSupported": true,
"multiLevelScaSupported": false,
"instantPaymentSupported": true,
},
},
{
BicFi: "MOCKSESSXXX",
Name: "Mockbank Sweden",
LogoURL: "",
Country: "SE",
SupportedServices: map[string]bool{
"ais": true,
"pis": true,
"cardAccounts": false,
"signingBasketSupported": true,
"multiLevelScaSupported": false,
"instantPaymentSupported": false,
},
},
{
BicFi: "DEMOSESSXXX",
Name: "Demobank AB",
LogoURL: "",
Country: "SE",
SupportedServices: map[string]bool{
"ais": true,
"pis": false,
"cardAccounts": false,
"signingBasketSupported": false,
"multiLevelScaSupported": false,
"instantPaymentSupported": false,
},
},
}
s.accounts["acc-1"] = account{
ResourceID: "acc-1",
IBAN: "SE1234567890",
BIC: "SHINYSESS",
Currency: "SEK",
Name: "Payment Account",
Product: "CURRENT",
Status: "enabled",
Balances: []balance{
{
BalanceAmount: amount{Currency: "SEK", Amount: "10000.00"},
BalanceType: "closingBooked",
ReferenceDate: time.Now().UTC().Format("2006-01-02"),
},
},
}
}
// reset clears everything except the default catalog / accounts.
func (s *server) reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.consents = map[string]*consent{}
s.transactions = map[string][]transaction{}
s.payments = map[string]*payment{}
s.baskets = map[string]*basket{}
// Keep aspsps + accounts seeded — most tests expect them.
s.accounts = map[string]account{}
s.seedDefaults()
}
// ---------------------------------------------------------------------
// HTTP helpers.
// ---------------------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeErr(w http.ResponseWriter, status int, code, text string) {
writeJSON(w, status, map[string]any{
"tppMessages": []map[string]string{
{"category": "ERROR", "code": code, "text": text},
},
})
}
func (s *server) withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
// ---------------------------------------------------------------------
// OAuth token: accept any client, return static bearer.
// ---------------------------------------------------------------------
func (s *server) handleToken(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"access_token": "acctest",
"token_type": "Bearer",
"expires_in": 3600,
})
}
// ---------------------------------------------------------------------
// AIS: ASPSP catalog, accounts, balances, transactions.
// ---------------------------------------------------------------------
func (s *server) handleASPSPs(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
list := make([]aspsp, len(s.aspsps))
copy(list, s.aspsps)
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"aspsps": list})
}
// handleCreateConsent returns a consentId + an scaOAuth link the frontend
// is supposed to redirect to. The link points at our own `/authorize`
// stub which in turn immediately redirects back to the TPP-Redirect-URI.
func (s *server) handleCreateConsent(w http.ResponseWriter, r *http.Request) {
redirect := r.Header.Get("TPP-Redirect-URI")
bicFi := r.Header.Get("X-BicFi")
if redirect == "" {
writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", "TPP-Redirect-URI header is required")
return
}
id := uuid.NewString()
c := &consent{
ID: id,
Status: "received",
ExpiresAt: time.Now().UTC().AddDate(0, 0, 90),
CreatedAt: time.Now().UTC(),
BicFi: bicFi,
RedirectTo: redirect,
}
s.mu.Lock()
s.consents[id] = c
s.mu.Unlock()
sca := buildSCALink(r, "consent", id, redirect)
writeJSON(w, http.StatusCreated, map[string]any{
"consentStatus": "received",
"consentId": id,
"_links": map[string]any{
"scaOAuth": map[string]string{"href": sca},
"status": map[string]string{"href": fmt.Sprintf("/psd2/consent/v1/consents/%s/status", id)},
},
})
}
func (s *server) handleConsentStatus(w http.ResponseWriter, r *http.Request) {
id := pathSuffix(r.URL.Path, "/psd2/consent/v1/consents/", "/status")
s.mu.Lock()
c, ok := s.consents[id]
s.mu.Unlock()
if !ok {
writeJSON(w, http.StatusOK, map[string]string{"consentStatus": "valid"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"consentStatus": c.Status})
}
func (s *server) handleDeleteConsent(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/psd2/consent/v1/consents/")
s.mu.Lock()
if c, ok := s.consents[id]; ok {
c.Status = "terminatedByTpp"
}
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleAuthorize(w http.ResponseWriter, r *http.Request) {
// This is the mock's own SCA stub — not a real BerlinGroup endpoint.
// It immediately bounces the browser back to the TPP-Redirect-URI
// captured when the consent/payment/basket was created, passing a
// stub auth code + echoed state. Before bouncing, promote any
// pending consent to "valid" so the downstream completeConnection
// call finds a usable consent.
q := r.URL.Query()
state := q.Get("state")
kind := q.Get("kind")
id := q.Get("id")
redirect := q.Get("redirect")
if kind == "consent" && id != "" {
s.mu.Lock()
if c, ok := s.consents[id]; ok {
c.Status = "valid"
}
s.mu.Unlock()
}
if redirect == "" {
// Shouldn't happen — caller always passes one through. Fall back
// to a JSON response so the test at least has something to
// assert against.
writeJSON(w, http.StatusOK, map[string]string{"code": "mock-auth-code", "state": state})
return
}
u, err := url.Parse(redirect)
if err != nil {
writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", "malformed redirect")
return
}
qp := u.Query()
qp.Set("code", "mock-auth-code")
if state != "" {
qp.Set("state", state)
}
u.RawQuery = qp.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}
// buildSCALink synthesises the scaOAuth href the caller should navigate
// to. Points at the mock's `/authorize` stub and carries the kind/id so
// the stub knows which consent/payment/basket to promote on redirect.
//
// `PUBLIC_BASE_URL` overrides the link base so the frontend can reach
// the mock through the acctest cluster's ingress (e.g.
// https://openpayments-mock), while banking-service itself calls in
// via cluster-DNS http://openpayments-mock:8080.
func buildSCALink(r *http.Request, kind, id, redirect string) string {
base := os.Getenv("PUBLIC_BASE_URL")
if base == "" {
host := r.Host
if host == "" {
host = "openpayments-mock:8080"
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
base = fmt.Sprintf("%s://%s", scheme, host)
}
q := url.Values{}
q.Set("kind", kind)
q.Set("id", id)
q.Set("redirect", redirect)
return base + "/authorize?" + q.Encode()
}
func (s *server) handleAccounts(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
out := make([]account, 0, len(s.accounts))
for _, a := range s.accounts {
out = append(out, a)
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"accounts": out})
}
func (s *server) handleBalances(w http.ResponseWriter, r *http.Request) {
// /psd2/accountinformation/v1/accounts/{id}/balances
id := pathSuffix(r.URL.Path, "/psd2/accountinformation/v1/accounts/", "/balances")
s.mu.Lock()
a, ok := s.accounts[id]
s.mu.Unlock()
if !ok {
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "account not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"account": accountRef{IBAN: a.IBAN, Currency: a.Currency},
"balances": a.Balances,
})
}
func (s *server) handleTransactions(w http.ResponseWriter, r *http.Request) {
// /psd2/accountinformation/v1/accounts/{id}/transactions
id := pathSuffix(r.URL.Path, "/psd2/accountinformation/v1/accounts/", "/transactions")
s.mu.Lock()
a, ok := s.accounts[id]
all := s.transactions[id]
s.mu.Unlock()
if !ok {
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "account not found")
return
}
booked := []transaction{}
pending := []transaction{}
for _, t := range all {
switch strings.ToLower(t.BookingStatus) {
case "pending":
pending = append(pending, t)
default:
booked = append(booked, t)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"account": accountRef{IBAN: a.IBAN, Currency: a.Currency},
"transactions": map[string]any{
"booked": booked,
"pending": pending,
},
})
}
func (s *server) handleCardAccounts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"cardAccounts": []any{}})
}
// ---------------------------------------------------------------------
// PIS: payment initiation + status transitions.
// ---------------------------------------------------------------------
func (s *server) handleInitiatePayment(w http.ResponseWriter, r *http.Request) {
var req struct {
EndToEndID string `json:"endToEndIdentification"`
DebtorAccount accountRef `json:"debtorAccount"`
InstructedAmount amount `json:"instructedAmount"`
CreditorAccount accountRef `json:"creditorAccount"`
CreditorName string `json:"creditorName"`
RemittanceInformationUnstructured string `json:"remittanceInformationUnstructured,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error())
return
}
redirect := r.Header.Get("TPP-Redirect-URI")
id := uuid.NewString()
p := &payment{
ID: id,
Service: "payments",
Product: "sepa-credit-transfers",
Status: "RCVD",
CreditorName: req.CreditorName,
InstructedAmt: req.InstructedAmount,
}
// Default deterministic transitions: RCVD (current) → PDNG → ACSC.
// Special creditor name "REJECT ME" makes the payment fail.
if strings.EqualFold(req.CreditorName, "REJECT ME") {
p.StatusQueue = []string{"PDNG", "RJCT"}
} else {
p.StatusQueue = []string{"PDNG", "ACSC"}
}
s.mu.Lock()
s.payments[id] = p
s.mu.Unlock()
sca := buildSCALink(r, "payment", id, redirect)
writeJSON(w, http.StatusCreated, map[string]any{
"transactionStatus": "RCVD",
"paymentId": id,
"_links": map[string]any{
"scaOAuth": map[string]string{"href": sca},
"status": map[string]string{"href": fmt.Sprintf("/psd2/paymentinitiation/v1/payments/sepa-credit-transfers/%s/status", id)},
},
})
}
func (s *server) handlePaymentStatus(w http.ResponseWriter, r *http.Request) {
// .../payments/sepa-credit-transfers/{id}/status
id := pathSuffix(r.URL.Path, "/psd2/paymentinitiation/v1/payments/sepa-credit-transfers/", "/status")
s.mu.Lock()
p, ok := s.payments[id]
if !ok {
s.mu.Unlock()
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "payment not found")
return
}
// Advance one transition per call until the queue is empty.
if len(p.StatusQueue) > 0 {
p.Status = p.StatusQueue[0]
p.StatusQueue = p.StatusQueue[1:]
}
status := p.Status
msg := ""
if status == "RJCT" {
msg = "insufficient funds"
}
s.mu.Unlock()
resp := map[string]any{"transactionStatus": status}
if msg != "" {
resp["psuMessage"] = msg
}
writeJSON(w, http.StatusOK, resp)
}
func (s *server) handleCreateBasket(w http.ResponseWriter, r *http.Request) {
var req struct {
PaymentIDs []string `json:"paymentIds"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error())
return
}
if len(req.PaymentIDs) == 0 {
writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", "paymentIds must not be empty")
return
}
redirect := r.Header.Get("TPP-Redirect-URI")
id := uuid.NewString()
b := &basket{
ID: id,
Status: "RCVD",
StatusQueue: []string{"ACCP", "ACSC"},
PaymentIDs: req.PaymentIDs,
}
s.mu.Lock()
s.baskets[id] = b
// Link basket id onto each payment for later lookups.
for _, pid := range req.PaymentIDs {
if p, ok := s.payments[pid]; ok {
p.BasketID = id
}
}
s.mu.Unlock()
sca := buildSCALink(r, "basket", id, redirect)
writeJSON(w, http.StatusCreated, map[string]any{
"transactionStatus": "RCVD",
"basketId": id,
"_links": map[string]any{
"scaOAuth": map[string]string{"href": sca},
"status": map[string]string{"href": fmt.Sprintf("/psd2/v1/signing-baskets/%s/status", id)},
},
})
}
func (s *server) handleBasketStatus(w http.ResponseWriter, r *http.Request) {
// /psd2/v1/signing-baskets/{id}/status
id := pathSuffix(r.URL.Path, "/psd2/v1/signing-baskets/", "/status")
s.mu.Lock()
b, ok := s.baskets[id]
if !ok {
s.mu.Unlock()
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "basket not found")
return
}
if len(b.StatusQueue) > 0 {
b.Status = b.StatusQueue[0]
b.StatusQueue = b.StatusQueue[1:]
// When the basket reaches ACSC, also advance all its payments
// to ACSC on their next poll.
if b.Status == "ACSC" {
for _, pid := range b.PaymentIDs {
if p, ok := s.payments[pid]; ok {
p.StatusQueue = []string{"ACSC"}
}
}
}
}
status := b.Status
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"transactionStatus": status})
}
// ---------------------------------------------------------------------
// Admin endpoints — not part of Open Payments. The acctest suite calls
// these directly to drive state.
// ---------------------------------------------------------------------
func (s *server) handleAdminReset(w http.ResponseWriter, r *http.Request) {
s.reset()
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleAdminForcePaymentStatus(w http.ResponseWriter, r *http.Request) {
// POST /admin/payments/{id}/status { "status": "ACSC" }
id := pathSuffix(r.URL.Path, "/admin/payments/", "/status")
var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error())
return
}
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.payments[id]
if !ok {
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "payment not found")
return
}
p.Status = body.Status
p.StatusQueue = nil
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleAdminForceBasketStatus(w http.ResponseWriter, r *http.Request) {
id := pathSuffix(r.URL.Path, "/admin/baskets/", "/status")
var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error())
return
}
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.baskets[id]
if !ok {
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "basket not found")
return
}
b.Status = body.Status
b.StatusQueue = nil
if body.Status == "ACSC" {
for _, pid := range b.PaymentIDs {
if p, ok := s.payments[pid]; ok {
p.Status = "ACSC"
p.StatusQueue = nil
}
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleAdminSeedTransactions(w http.ResponseWriter, r *http.Request) {
// POST /admin/transactions
// { "accountId": "acc-1", "bookingStatus": "booked", "entries": [...] }
var body struct {
AccountID string `json:"accountId"`
BookingStatus string `json:"bookingStatus"`
Entries []transaction `json:"entries"`
Replace bool `json:"replace"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error())
return
}
if body.AccountID == "" {
body.AccountID = "acc-1"
}
if body.BookingStatus == "" {
body.BookingStatus = "booked"
}
for i := range body.Entries {
if body.Entries[i].BookingStatus == "" {
body.Entries[i].BookingStatus = body.BookingStatus
}
if body.Entries[i].TransactionID == "" {
body.Entries[i].TransactionID = uuid.NewString()
}
}
s.mu.Lock()
if body.Replace {
s.transactions[body.AccountID] = body.Entries
} else {
s.transactions[body.AccountID] = append(s.transactions[body.AccountID], body.Entries...)
}
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleAdminConsentExpiry(w http.ResponseWriter, r *http.Request) {
// POST /admin/consents/{id}/expires-at { "expiresAt": "2026-05-01" }
id := pathSuffix(r.URL.Path, "/admin/consents/", "/expires-at")
var body struct {
ExpiresAt string `json:"expiresAt"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error())
return
}
t, err := time.Parse("2006-01-02", body.ExpiresAt)
if err != nil {
writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", "invalid expiresAt")
return
}
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.consents[id]; ok {
c.ExpiresAt = t
w.WriteHeader(http.StatusNoContent)
return
}
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "consent not found")
}
func (s *server) handleAdminListConsents(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]map[string]any, 0, len(s.consents))
for _, c := range s.consents {
out = append(out, map[string]any{
"id": c.ID,
"status": c.Status,
"bicFi": c.BicFi,
"expiresAt": c.ExpiresAt.Format("2006-01-02"),
})
}
writeJSON(w, http.StatusOK, map[string]any{"consents": out})
}
// ---------------------------------------------------------------------
// Routing + startup.
// ---------------------------------------------------------------------
// pathSuffix extracts the segment between a prefix and suffix in a URL
// path. Returns empty if either end doesn't match.
func pathSuffix(path, prefix, suffix string) string {
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := strings.TrimPrefix(path, prefix)
if !strings.HasSuffix(rest, suffix) {
return ""
}
return strings.TrimSuffix(rest, suffix)
}
func (s *server) healthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
func (s *server) routeConsents(w http.ResponseWriter, r *http.Request) {
// POST /psd2/consent/v1/consents
// GET /psd2/consent/v1/consents/{id}/status
// DELETE /psd2/consent/v1/consents/{id}
p := r.URL.Path
switch {
case p == "/psd2/consent/v1/consents" && r.Method == http.MethodPost:
s.handleCreateConsent(w, r)
case strings.HasPrefix(p, "/psd2/consent/v1/consents/") && strings.HasSuffix(p, "/status") && r.Method == http.MethodGet:
s.handleConsentStatus(w, r)
case strings.HasPrefix(p, "/psd2/consent/v1/consents/") && r.Method == http.MethodDelete:
s.handleDeleteConsent(w, r)
default:
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", fmt.Sprintf("no route for %s %s", r.Method, p))
}
}
func (s *server) routeAIS(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
switch {
case p == "/psd2/aspspinformation/v1/aspsps" && r.Method == http.MethodGet:
s.handleASPSPs(w, r)
case p == "/psd2/accountinformation/v1/accounts" && r.Method == http.MethodGet:
s.handleAccounts(w, r)
case strings.HasPrefix(p, "/psd2/accountinformation/v1/accounts/") && strings.HasSuffix(p, "/balances") && r.Method == http.MethodGet:
s.handleBalances(w, r)
case strings.HasPrefix(p, "/psd2/accountinformation/v1/accounts/") && strings.HasSuffix(p, "/transactions") && r.Method == http.MethodGet:
s.handleTransactions(w, r)
case p == "/psd2/cardaccountinformation/v1/card-accounts" && r.Method == http.MethodGet:
s.handleCardAccounts(w, r)
default:
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", fmt.Sprintf("no route for %s %s", r.Method, p))
}
}
func (s *server) routePIS(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
switch {
case p == "/psd2/paymentinitiation/v1/payments/sepa-credit-transfers" && r.Method == http.MethodPost:
s.handleInitiatePayment(w, r)
case strings.HasPrefix(p, "/psd2/paymentinitiation/v1/payments/sepa-credit-transfers/") && strings.HasSuffix(p, "/status") && r.Method == http.MethodGet:
s.handlePaymentStatus(w, r)
case p == "/psd2/v1/signing-baskets" && r.Method == http.MethodPost:
s.handleCreateBasket(w, r)
case strings.HasPrefix(p, "/psd2/v1/signing-baskets/") && strings.HasSuffix(p, "/status") && r.Method == http.MethodGet:
s.handleBasketStatus(w, r)
default:
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", fmt.Sprintf("no route for %s %s", r.Method, p))
}
}
func (s *server) routeAdmin(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
switch {
case p == "/admin/reset" && r.Method == http.MethodPost:
s.handleAdminReset(w, r)
case p == "/admin/consents" && r.Method == http.MethodGet:
s.handleAdminListConsents(w, r)
case p == "/admin/transactions" && r.Method == http.MethodPost:
s.handleAdminSeedTransactions(w, r)
case strings.HasPrefix(p, "/admin/payments/") && strings.HasSuffix(p, "/status") && r.Method == http.MethodPost:
s.handleAdminForcePaymentStatus(w, r)
case strings.HasPrefix(p, "/admin/baskets/") && strings.HasSuffix(p, "/status") && r.Method == http.MethodPost:
s.handleAdminForceBasketStatus(w, r)
case strings.HasPrefix(p, "/admin/consents/") && strings.HasSuffix(p, "/expires-at") && r.Method == http.MethodPost:
s.handleAdminConsentExpiry(w, r)
default:
writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", fmt.Sprintf("no route for %s %s", r.Method, p))
}
}
func (s *server) logMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.logger.Info("request", "method", r.Method, "path", r.URL.Path, "query", r.URL.RawQuery)
next.ServeHTTP(w, r)
})
}
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
s := newServer(logger)
mux := http.NewServeMux()
mux.Handle("/token", s.withCORS(s.handleToken))
mux.Handle("/authorize", s.withCORS(s.handleAuthorize))
mux.Handle("/healthz", s.withCORS(s.healthz))
mux.Handle("/psd2/consent/v1/consents", s.withCORS(s.routeConsents))
mux.Handle("/psd2/consent/v1/consents/", s.withCORS(s.routeConsents))
mux.Handle("/psd2/aspspinformation/v1/aspsps", s.withCORS(s.routeAIS))
mux.Handle("/psd2/accountinformation/v1/accounts", s.withCORS(s.routeAIS))
mux.Handle("/psd2/accountinformation/v1/accounts/", s.withCORS(s.routeAIS))
mux.Handle("/psd2/cardaccountinformation/v1/card-accounts", s.withCORS(s.routeAIS))
mux.Handle("/psd2/paymentinitiation/v1/payments/sepa-credit-transfers", s.withCORS(s.routePIS))
mux.Handle("/psd2/paymentinitiation/v1/payments/sepa-credit-transfers/", s.withCORS(s.routePIS))
mux.Handle("/psd2/v1/signing-baskets", s.withCORS(s.routePIS))
mux.Handle("/psd2/v1/signing-baskets/", s.withCORS(s.routePIS))
mux.Handle("/admin/reset", s.withCORS(s.routeAdmin))
mux.Handle("/admin/consents", s.withCORS(s.routeAdmin))
mux.Handle("/admin/consents/", s.withCORS(s.routeAdmin))
mux.Handle("/admin/transactions", s.withCORS(s.routeAdmin))
mux.Handle("/admin/payments/", s.withCORS(s.routeAdmin))
mux.Handle("/admin/baskets/", s.withCORS(s.routeAdmin))
addr := ":8080"
if v := os.Getenv("PORT"); v != "" {
addr = ":" + v
}
logger.Info("openpayments-mock starting", "addr", addr)
if err := http.ListenAndServe(addr, s.logMiddleware(mux)); err != nil {
logger.Error("server terminated", "err", err)
os.Exit(1)
}
}