Compare commits

..

2 Commits

15 changed files with 751 additions and 36 deletions

View File

@@ -1,4 +1,6 @@
ZARINHUB_ACCESS_TOKEN= ZARINHUB_API_USERNAME=
ZARINHUB_API_PASSWORD=
ZARINHUB_AUTH_ENDPOINT=https://zarin-hub.com/api/v5/Authentication/GetToken
ZARINHUB_ENDPOINT=https://zarin-hub.com/api/v5/KYC/CardToIban ZARINHUB_ENDPOINT=https://zarin-hub.com/api/v5/KYC/CardToIban
ZARINHUB_TIMEOUT=5s ZARINHUB_TIMEOUT=5s

View File

@@ -16,7 +16,7 @@ The HTTP handler coordinates the public API contract and audit persistence. The
- Go 1.27 or later - Go 1.27 or later
- Node.js 22 or later - Node.js 22 or later
- A valid ZarinHub access token - ZarinHub API credentials
## Configuration ## Configuration
@@ -26,11 +26,13 @@ Copy the example environment file from the project root:
Copy-Item .env.sample .env Copy-Item .env.sample .env
``` ```
Set `ZARINHUB_ACCESS_TOKEN` in the new `.env` file. This file is ignored by Git. Set `ZARINHUB_API_USERNAME` and `ZARINHUB_API_PASSWORD` in the new `.env` file. These are the API credentials created in the ZarinHub panel, not the regular panel login credentials. The `.env` file is ignored by Git.
| Variable | Default | Purpose | | Variable | Default | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `ZARINHUB_ACCESS_TOKEN` | None; required | ZarinHub access token | | `ZARINHUB_API_USERNAME` | None; required | API username created in the ZarinHub panel |
| `ZARINHUB_API_PASSWORD` | None; required | API password created in the ZarinHub panel |
| `ZARINHUB_AUTH_ENDPOINT` | Official GetToken endpoint | Token acquisition and refresh endpoint |
| `ZARINHUB_ENDPOINT` | Official CardToIban endpoint | External service endpoint | | `ZARINHUB_ENDPOINT` | Official CardToIban endpoint | External service endpoint |
| `ZARINHUB_TIMEOUT` | `5s` | External request timeout | | `ZARINHUB_TIMEOUT` | `5s` | External request timeout |
| `DATABASE_PATH` | `./card-to-iban.db` | SQLite database file path | | `DATABASE_PATH` | `./card-to-iban.db` | SQLite database file path |
@@ -43,6 +45,8 @@ The frontend reads the following value from `frontend/.env` and uses the shown a
NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080 NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
``` ```
The backend obtains an access token during startup, keeps the access and refresh tokens in memory, and refreshes them automatically shortly before expiration. Credentials and tokens are never written to the database or logs.
## Running the Project ## Running the Project
Start the backend from the Go module directory. The root `.env` file is loaded automatically, and the database migration runs during startup: Start the backend from the Go module directory. The root `.env` file is loaded automatically, and the database migration runs during startup:
@@ -62,6 +66,24 @@ npm run dev
The frontend is available at `http://127.0.0.1:3000`. The backend health endpoint is available at `http://127.0.0.1:8080/health`. The frontend is available at `http://127.0.0.1:3000`. The backend health endpoint is available at `http://127.0.0.1:8080/health`.
## Running with Docker Compose
Create the root `.env` file as described in the configuration section, then build and start both services:
```powershell
docker compose up --build
```
Open `http://localhost:3000` in your browser. The API is exposed at `http://localhost:8080`, and SQLite data is kept in the named `card_to_iban_data` volume.
Stop the services with:
```powershell
docker compose down
```
The database volume is preserved by default. To remove it intentionally, run `docker compose down --volumes`.
## API Contract ## API Contract
### Convert a card number to an IBAN ### Convert a card number to an IBAN

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.env
*.db
*.sqlite
*.sqlite3
bin
coverage.out
*.test

36
backend/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM golang:1.27.1-alpine AS build
WORKDIR /src
ARG GOPROXY=https://package-mirror.liara.ir/repository/go/
ENV GOPROXY=$GOPROXY
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w" \
-o /out/api \
./cmd/api
FROM alpine:3.22
RUN apk add --no-cache ca-certificates \
&& addgroup -S app \
&& adduser -S -G app app \
&& mkdir /data \
&& chown app:app /data
WORKDIR /app
COPY --from=build /out/api ./api
USER app
EXPOSE 8080
CMD ["./api"]

View File

@@ -8,12 +8,14 @@ import (
) )
type config struct { type config struct {
ServerAddr string ServerAddr string
FrontendOrigin string FrontendOrigin string
ZarinHubEndpoint string ZarinHubEndpoint string
ZarinHubToken string ZarinHubAuthEndpoint string
ZarinHubTimeout time.Duration ZarinHubUsername string
DatabasePath string ZarinHubPassword string
ZarinHubTimeout time.Duration
DatabasePath string
} }
func loadConfig() (config, error) { func loadConfig() (config, error) {
@@ -21,9 +23,14 @@ func loadConfig() (config, error) {
} }
func readConfig(getenv func(string) string) (config, error) { func readConfig(getenv func(string) string) (config, error) {
token := strings.TrimSpace(getenv("ZARINHUB_ACCESS_TOKEN")) username := strings.TrimSpace(getenv("ZARINHUB_API_USERNAME"))
if token == "" { if username == "" {
return config{}, fmt.Errorf("ZARINHUB_ACCESS_TOKEN is required") return config{}, fmt.Errorf("ZARINHUB_API_USERNAME is required")
}
password := strings.TrimSpace(getenv("ZARINHUB_API_PASSWORD"))
if password == "" {
return config{}, fmt.Errorf("ZARINHUB_API_PASSWORD is required")
} }
timeoutText := valueOrDefault( timeoutText := valueOrDefault(
@@ -50,8 +57,13 @@ func readConfig(getenv func(string) string) (config, error) {
getenv("ZARINHUB_ENDPOINT"), getenv("ZARINHUB_ENDPOINT"),
"https://zarin-hub.com/api/v5/KYC/CardToIban", "https://zarin-hub.com/api/v5/KYC/CardToIban",
), ),
ZarinHubToken: token, ZarinHubAuthEndpoint: valueOrDefault(
ZarinHubTimeout: timeout, getenv("ZARINHUB_AUTH_ENDPOINT"),
"https://zarin-hub.com/api/v5/Authentication/GetToken",
),
ZarinHubUsername: username,
ZarinHubPassword: password,
ZarinHubTimeout: timeout,
DatabasePath: valueOrDefault( DatabasePath: valueOrDefault(
getenv("DATABASE_PATH"), getenv("DATABASE_PATH"),
"./card-to-iban.db", "./card-to-iban.db",

View File

@@ -8,7 +8,8 @@ import (
func TestReadConfigUsesDefaults(t *testing.T) { func TestReadConfigUsesDefaults(t *testing.T) {
values := map[string]string{ values := map[string]string{
"ZARINHUB_ACCESS_TOKEN": "test-token", "ZARINHUB_API_USERNAME": "test-username",
"ZARINHUB_API_PASSWORD": "test-password",
} }
config, err := readConfig(func(key string) string { config, err := readConfig(func(key string) string {
@@ -24,6 +25,9 @@ func TestReadConfigUsesDefaults(t *testing.T) {
if config.FrontendOrigin != "http://127.0.0.1:3000" { if config.FrontendOrigin != "http://127.0.0.1:3000" {
t.Errorf("FrontendOrigin = %q", config.FrontendOrigin) t.Errorf("FrontendOrigin = %q", config.FrontendOrigin)
} }
if config.ZarinHubAuthEndpoint != "https://zarin-hub.com/api/v5/Authentication/GetToken" {
t.Errorf("ZarinHubAuthEndpoint = %q", config.ZarinHubAuthEndpoint)
}
if config.ZarinHubTimeout != 5*time.Second { if config.ZarinHubTimeout != 5*time.Second {
t.Errorf("ZarinHubTimeout = %v", config.ZarinHubTimeout) t.Errorf("ZarinHubTimeout = %v", config.ZarinHubTimeout)
} }
@@ -32,16 +36,30 @@ func TestReadConfigUsesDefaults(t *testing.T) {
} }
} }
func TestReadConfigRejectsMissingAccessToken(t *testing.T) { func TestReadConfigRejectsMissingUsername(t *testing.T) {
_, err := readConfig(func(key string) string { return "" }) _, err := readConfig(func(key string) string { return "" })
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_ACCESS_TOKEN") { if err == nil || !strings.Contains(err.Error(), "ZARINHUB_API_USERNAME") {
t.Fatalf("readConfig() error = %v, want missing token error", err) t.Fatalf("readConfig() error = %v, want missing username error", err)
}
}
func TestReadConfigRejectsMissingPassword(t *testing.T) {
values := map[string]string{
"ZARINHUB_API_USERNAME": "test-username",
}
_, err := readConfig(func(key string) string {
return values[key]
})
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_API_PASSWORD") {
t.Fatalf("readConfig() error = %v, want missing password error", err)
} }
} }
func TestReadConfigRejectsInvalidTimeout(t *testing.T) { func TestReadConfigRejectsInvalidTimeout(t *testing.T) {
values := map[string]string{ values := map[string]string{
"ZARINHUB_ACCESS_TOKEN": "test-token", "ZARINHUB_API_USERNAME": "test-username",
"ZARINHUB_API_PASSWORD": "test-password",
"ZARINHUB_TIMEOUT": "later", "ZARINHUB_TIMEOUT": "later",
} }

View File

@@ -38,6 +38,24 @@ func run(logger *slog.Logger) error {
return fmt.Errorf("load configuration: %w", err) return fmt.Errorf("load configuration: %w", err)
} }
httpClient := &http.Client{Timeout: config.ZarinHubTimeout}
tokenManager := zarinhub.NewTokenManager(
httpClient,
config.ZarinHubAuthEndpoint,
config.ZarinHubUsername,
config.ZarinHubPassword,
)
authContext, cancelAuth := context.WithTimeout(
context.Background(),
config.ZarinHubTimeout,
)
_, err = tokenManager.AccessToken(authContext)
cancelAuth()
if err != nil {
return fmt.Errorf("authenticate with zarinhub: %w", err)
}
db, err := storage.OpenSQLite(config.DatabasePath) db, err := storage.OpenSQLite(config.DatabasePath)
if err != nil { if err != nil {
return err return err
@@ -58,9 +76,9 @@ func run(logger *slog.Logger) error {
} }
zarinHubClient := zarinhub.NewClient( zarinHubClient := zarinhub.NewClient(
&http.Client{Timeout: config.ZarinHubTimeout}, httpClient,
config.ZarinHubEndpoint, config.ZarinHubEndpoint,
config.ZarinHubToken, tokenManager,
) )
cardService := service.NewCardService(zarinHubClient) cardService := service.NewCardService(zarinHubClient)
auditRepository := repository.NewAuditRepository(db) auditRepository := repository.NewAuditRepository(db)

View File

@@ -16,28 +16,60 @@ import (
const maxResponseBodySize = 1 << 20 const maxResponseBodySize = 1 << 20
type Client struct { type Client struct {
httpClient *http.Client httpClient *http.Client
endpointURL string endpointURL string
accessToken string tokenProvider TokenProvider
} }
var _ service.CardToIBANClient = (*Client)(nil) var _ service.CardToIBANClient = (*Client)(nil)
type TokenProvider interface {
AccessToken(ctx context.Context) (string, error)
Invalidate(token string)
}
func NewClient( func NewClient(
httpClient *http.Client, httpClient *http.Client,
endpointURL string, endpointURL string,
accessToken string, tokenProvider TokenProvider,
) *Client { ) *Client {
return &Client{ return &Client{
httpClient: httpClient, httpClient: httpClient,
endpointURL: endpointURL, endpointURL: endpointURL,
accessToken: accessToken, tokenProvider: tokenProvider,
} }
} }
func (c *Client) Convert( func (c *Client) Convert(
ctx context.Context, ctx context.Context,
cardNumber string, cardNumber string,
) (service.ConversionResult, error) {
accessToken, err := c.tokenProvider.AccessToken(ctx)
if err != nil {
return service.ConversionResult{},
fmt.Errorf("get zarinhub access token: %w", err)
}
result, err := c.convert(ctx, cardNumber, accessToken)
if !errors.Is(err, ErrAuthentication) ||
result.UpstreamHTTPStatus != http.StatusUnauthorized {
return result, err
}
c.tokenProvider.Invalidate(accessToken)
accessToken, err = c.tokenProvider.AccessToken(ctx)
if err != nil {
return result, fmt.Errorf("refresh zarinhub access token: %w", err)
}
return c.convert(ctx, cardNumber, accessToken)
}
func (c *Client) convert(
ctx context.Context,
cardNumber string,
accessToken string,
) (service.ConversionResult, error) { ) (service.ConversionResult, error) {
payload, err := json.Marshal(convertRequest{ payload, err := json.Marshal(convertRequest{
Card: cardNumber, Card: cardNumber,
@@ -60,7 +92,7 @@ func (c *Client) Convert(
req.Header.Set( req.Header.Set(
"Authorization", "Authorization",
"Bearer "+c.accessToken, "Bearer "+accessToken,
) )
req.Header.Set( req.Header.Set(

View File

@@ -97,7 +97,7 @@ func TestClientConvertSuccess(t *testing.T) {
Timeout: time.Second, Timeout: time.Second,
}, },
server.URL, server.URL,
token, &fakeTokenProvider{tokens: []string{token}},
) )
result, err := client.Convert( result, err := client.Convert(
@@ -171,7 +171,7 @@ func TestClientReturnsRejectedForApplicationFailure(
Timeout: time.Second, Timeout: time.Second,
}, },
server.URL, server.URL,
"test-token", &fakeTokenProvider{tokens: []string{"test-token"}},
) )
result, err := client.Convert( result, err := client.Convert(
@@ -263,7 +263,7 @@ func TestClientMapsHTTPStatusErrors(t *testing.T) {
Timeout: time.Second, Timeout: time.Second,
}, },
server.URL, server.URL,
"test-token", &fakeTokenProvider{tokens: []string{"test-token"}},
) )
result, err := client.Convert( result, err := client.Convert(
@@ -321,7 +321,7 @@ func TestClientReturnsInvalidResponseForMalformedJSON(
Timeout: time.Second, Timeout: time.Second,
}, },
server.URL, server.URL,
"test-token", &fakeTokenProvider{tokens: []string{"test-token"}},
) )
result, err := client.Convert( result, err := client.Convert(
@@ -366,7 +366,7 @@ func TestClientReturnsTimeout(t *testing.T) {
Timeout: 50 * time.Millisecond, Timeout: 50 * time.Millisecond,
}, },
server.URL, server.URL,
"test-token", &fakeTokenProvider{tokens: []string{"test-token"}},
) )
_, err := client.Convert( _, err := client.Convert(
@@ -402,7 +402,7 @@ func TestClientRejectsOversizedResponse(t *testing.T) {
client := NewClient( client := NewClient(
&http.Client{Timeout: time.Second}, &http.Client{Timeout: time.Second},
server.URL, server.URL,
"test-token", &fakeTokenProvider{tokens: []string{"test-token"}},
) )
_, err := client.Convert( _, err := client.Convert(
@@ -414,3 +414,67 @@ func TestClientRejectsOversizedResponse(t *testing.T) {
t.Fatalf("error = %v, want ErrInvalidResponse", err) t.Fatalf("error = %v, want ErrInvalidResponse", err)
} }
} }
func TestClientRefreshesAndRetriesUnauthorizedRequest(t *testing.T) {
const expectedIBAN = "IR820540102680020817909002"
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
authorization := r.Header.Get("Authorization")
if requestCount == 1 {
if authorization != "Bearer expired-token" {
t.Errorf("first authorization = %q", authorization)
}
w.WriteHeader(http.StatusUnauthorized)
return
}
if authorization != "Bearer fresh-token" {
t.Errorf("second authorization = %q", authorization)
}
fmt.Fprintf(w, `{
"data":{"iban":%q},
"meta":{"isSuccess":true,"errors":[]}
}`, expectedIBAN)
}))
defer server.Close()
tokens := &fakeTokenProvider{tokens: []string{"expired-token", "fresh-token"}}
client := NewClient(server.Client(), server.URL, tokens)
result, err := client.Convert(context.Background(), "6037997512345678")
if err != nil {
t.Fatalf("Convert() error = %v", err)
}
if result.IBAN != expectedIBAN {
t.Errorf("IBAN = %q, want %q", result.IBAN, expectedIBAN)
}
if requestCount != 2 {
t.Errorf("request count = %d, want 2", requestCount)
}
if len(tokens.invalidated) != 1 || tokens.invalidated[0] != "expired-token" {
t.Errorf("invalidated tokens = %v", tokens.invalidated)
}
}
type fakeTokenProvider struct {
tokens []string
calls int
invalidated []string
}
func (p *fakeTokenProvider) AccessToken(context.Context) (string, error) {
index := p.calls
if index >= len(p.tokens) {
index = len(p.tokens) - 1
}
p.calls++
return p.tokens[index], nil
}
func (p *fakeTokenProvider) Invalidate(token string) {
p.invalidated = append(p.invalidated, token)
}

View File

@@ -0,0 +1,213 @@
package zarinhub
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
)
const tokenRefreshBefore = time.Minute
type tokenRequest struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
RefreshToken string `json:"refreshToken,omitempty"`
}
type tokenResponse struct {
Data tokenData `json:"data"`
Meta responseMeta `json:"meta"`
}
type tokenData struct {
AccessToken string `json:"accessToken"`
TokenType string `json:"tokenType"`
ExpiresIn int `json:"expiresTn"`
RefreshToken string `json:"refreshToken"`
}
type tokenCall struct {
done chan struct{}
token string
err error
}
type TokenManager struct {
httpClient *http.Client
endpoint string
username string
password string
now func() time.Time
mu sync.Mutex
accessToken string
refreshToken string
expiresAt time.Time
inFlight *tokenCall
}
func NewTokenManager(
httpClient *http.Client,
endpoint string,
username string,
password string,
) *TokenManager {
return &TokenManager{
httpClient: httpClient,
endpoint: endpoint,
username: username,
password: password,
now: time.Now,
}
}
func (m *TokenManager) AccessToken(ctx context.Context) (string, error) {
m.mu.Lock()
if m.tokenIsValid() {
token := m.accessToken
m.mu.Unlock()
return token, nil
}
if m.inFlight != nil {
call := m.inFlight
m.mu.Unlock()
select {
case <-ctx.Done():
return "", ctx.Err()
case <-call.done:
return call.token, call.err
}
}
call := &tokenCall{done: make(chan struct{})}
m.inFlight = call
refreshToken := m.refreshToken
m.mu.Unlock()
data, err := m.fetchToken(ctx, refreshToken)
if err != nil && refreshToken != "" && errors.Is(err, ErrAuthentication) {
data, err = m.fetchToken(ctx, "")
}
m.mu.Lock()
if err == nil {
m.accessToken = data.AccessToken
m.refreshToken = data.RefreshToken
m.expiresAt = m.now().Add(time.Duration(data.ExpiresIn) * time.Second)
call.token = data.AccessToken
}
call.err = err
close(call.done)
m.inFlight = nil
m.mu.Unlock()
return call.token, call.err
}
func (m *TokenManager) Invalidate(token string) {
m.mu.Lock()
defer m.mu.Unlock()
if token == m.accessToken {
m.expiresAt = time.Time{}
}
}
func (m *TokenManager) tokenIsValid() bool {
return m.accessToken != "" &&
m.now().Add(tokenRefreshBefore).Before(m.expiresAt)
}
func (m *TokenManager) fetchToken(
ctx context.Context,
refreshToken string,
) (tokenData, error) {
request := tokenRequest{RefreshToken: refreshToken}
if refreshToken == "" {
request.Username = m.username
request.Password = m.password
}
payload, err := json.Marshal(request)
if err != nil {
return tokenData{}, fmt.Errorf("marshal zarinhub token request: %w", err)
}
httpRequest, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
m.endpoint,
bytes.NewReader(payload),
)
if err != nil {
return tokenData{}, fmt.Errorf("create zarinhub token request: %w", err)
}
httpRequest.Header.Set("Content-Type", "application/json")
response, err := m.httpClient.Do(httpRequest)
if err != nil {
return tokenData{}, tokenTransportError(err)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBodySize+1))
if err != nil {
return tokenData{}, fmt.Errorf("%w: failed to read token response", ErrInvalidResponse)
}
if len(body) > maxResponseBodySize {
return tokenData{}, fmt.Errorf("%w: token response body is too large", ErrInvalidResponse)
}
switch {
case response.StatusCode == http.StatusRequestTimeout:
return tokenData{}, ErrTimeout
case response.StatusCode == http.StatusTooManyRequests ||
response.StatusCode >= http.StatusInternalServerError:
return tokenData{}, ErrUnavailable
case response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices:
return tokenData{}, ErrAuthentication
}
var decoded tokenResponse
if err := json.Unmarshal(body, &decoded); err != nil {
return tokenData{}, fmt.Errorf("%w: malformed token response", ErrInvalidResponse)
}
if !decoded.Meta.IsSuccess {
return tokenData{}, ErrAuthentication
}
if decoded.Data.AccessToken == "" ||
decoded.Data.RefreshToken == "" ||
decoded.Data.ExpiresIn <= 0 {
return tokenData{}, fmt.Errorf("%w: incomplete token response", ErrInvalidResponse)
}
return decoded.Data, nil
}
func tokenTransportError(err error) error {
switch {
case errors.Is(err, context.DeadlineExceeded):
return fmt.Errorf("%w: token request timed out", ErrTimeout)
case errors.Is(err, context.Canceled):
return fmt.Errorf("zarinhub token request canceled: %w", err)
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return fmt.Errorf("%w: token request timed out", ErrTimeout)
}
return fmt.Errorf("%w: token request failed", ErrUnavailable)
}

View File

@@ -0,0 +1,222 @@
package zarinhub
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func TestTokenManagerAuthenticatesAndCachesToken(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var request tokenRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
if request.Username != "test-username" || request.Password != "test-password" {
t.Errorf("credentials = %q/%q", request.Username, request.Password)
}
if request.RefreshToken != "" {
t.Errorf("refresh token = %q, want empty", request.RefreshToken)
}
writeTokenResponse(t, w, "access-1", "refresh-1", 28800)
}))
defer server.Close()
manager := NewTokenManager(
server.Client(),
server.URL,
"test-username",
"test-password",
)
for range 2 {
token, err := manager.AccessToken(context.Background())
if err != nil {
t.Fatalf("AccessToken() error = %v", err)
}
if token != "access-1" {
t.Errorf("token = %q, want access-1", token)
}
}
if calls != 1 {
t.Errorf("authentication calls = %d, want 1", calls)
}
}
func TestTokenManagerRefreshesExpiringToken(t *testing.T) {
var calls int
currentTime := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var request tokenRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
if calls == 1 {
writeTokenResponse(t, w, "access-1", "refresh-1", 120)
return
}
if request.RefreshToken != "refresh-1" {
t.Errorf("refresh token = %q, want refresh-1", request.RefreshToken)
}
if request.Username != "" || request.Password != "" {
t.Error("refresh request included credentials")
}
writeTokenResponse(t, w, "access-2", "refresh-2", 28800)
}))
defer server.Close()
manager := NewTokenManager(server.Client(), server.URL, "test-username", "test-password")
manager.now = func() time.Time { return currentTime }
if _, err := manager.AccessToken(context.Background()); err != nil {
t.Fatalf("initial AccessToken() error = %v", err)
}
currentTime = currentTime.Add(61 * time.Second)
token, err := manager.AccessToken(context.Background())
if err != nil {
t.Fatalf("refreshed AccessToken() error = %v", err)
}
if token != "access-2" {
t.Errorf("token = %q, want access-2", token)
}
}
func TestTokenManagerFallsBackToCredentialsWhenRefreshIsRejected(t *testing.T) {
var calls int
currentTime := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var request tokenRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
switch calls {
case 1:
writeTokenResponse(t, w, "access-1", "refresh-1", 61)
case 2:
if request.RefreshToken != "refresh-1" {
t.Errorf("refresh token = %q, want refresh-1", request.RefreshToken)
}
w.WriteHeader(http.StatusUnauthorized)
case 3:
if request.Username != "test-username" || request.Password != "test-password" {
t.Error("credential fallback did not include API credentials")
}
writeTokenResponse(t, w, "access-2", "refresh-2", 28800)
default:
t.Errorf("unexpected authentication call %d", calls)
}
}))
defer server.Close()
manager := NewTokenManager(server.Client(), server.URL, "test-username", "test-password")
manager.now = func() time.Time { return currentTime }
if _, err := manager.AccessToken(context.Background()); err != nil {
t.Fatalf("initial AccessToken() error = %v", err)
}
currentTime = currentTime.Add(2 * time.Second)
token, err := manager.AccessToken(context.Background())
if err != nil {
t.Fatalf("fallback AccessToken() error = %v", err)
}
if token != "access-2" {
t.Errorf("token = %q, want access-2", token)
}
if calls != 3 {
t.Errorf("authentication calls = %d, want 3", calls)
}
}
func TestTokenManagerSharesConcurrentAuthentication(t *testing.T) {
var (
mu sync.Mutex
calls int
)
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls++
mu.Unlock()
<-release
writeTokenResponse(t, w, "shared-access", "shared-refresh", 28800)
}))
defer server.Close()
manager := NewTokenManager(server.Client(), server.URL, "test-username", "test-password")
results := make(chan string, 5)
errors := make(chan error, 5)
for range 5 {
go func() {
token, err := manager.AccessToken(context.Background())
results <- token
errors <- err
}()
}
time.Sleep(20 * time.Millisecond)
close(release)
for range 5 {
if err := <-errors; err != nil {
t.Errorf("AccessToken() error = %v", err)
}
if token := <-results; token != "shared-access" {
t.Errorf("token = %q, want shared-access", token)
}
}
mu.Lock()
defer mu.Unlock()
if calls != 1 {
t.Errorf("authentication calls = %d, want 1", calls)
}
}
func writeTokenResponse(
t *testing.T,
w http.ResponseWriter,
accessToken string,
refreshToken string,
expiresIn int,
) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"accessToken": accessToken,
"tokenType": "Bearer",
"expiresTn": expiresIn,
"refreshToken": refreshToken,
},
"meta": map[string]any{
"isSuccess": true,
},
})
if err != nil {
t.Fatalf("encode response: %v", err)
}
}

36
compose.yaml Normal file
View File

@@ -0,0 +1,36 @@
name: card-to-iban
services:
backend:
build:
context: ./backend
env_file:
- ./.env
environment:
SERVER_ADDR: 0.0.0.0:8080
DATABASE_PATH: /data/card-to-iban.db
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
ports:
- "8080:8080"
volumes:
- card_to_iban_data:/data
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
frontend:
build:
context: ./frontend
environment:
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:8080}
ports:
- "3000:3000"
depends_on:
backend:
condition: service_healthy
volumes:
card_to_iban_data:

7
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.env
.env.*
node_modules
.nuxt
.output
dist
npm-debug.log*

1
frontend/.env.sample Normal file
View File

@@ -0,0 +1 @@
NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080

25
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
ENV NODE_ENV=production \
NITRO_HOST=0.0.0.0 \
NITRO_PORT=3000
WORKDIR /app
COPY --from=build --chown=node:node /app/.output ./.output
USER node
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]