feat(zarinhub): automate access token refresh
This commit is contained in:
@@ -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_TIMEOUT=5s
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -16,7 +16,7 @@ The HTTP handler coordinates the public API contract and audit persistence. The
|
||||
|
||||
- Go 1.27 or later
|
||||
- Node.js 22 or later
|
||||
- A valid ZarinHub access token
|
||||
- ZarinHub API credentials
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -26,11 +26,13 @@ Copy the example environment file from the project root:
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| `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_TIMEOUT` | `5s` | External request timeout |
|
||||
| `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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Start the backend from the Go module directory. The root `.env` file is loaded automatically, and the database migration runs during startup:
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
)
|
||||
|
||||
type config struct {
|
||||
ServerAddr string
|
||||
FrontendOrigin string
|
||||
ZarinHubEndpoint string
|
||||
ZarinHubToken string
|
||||
ZarinHubTimeout time.Duration
|
||||
DatabasePath string
|
||||
ServerAddr string
|
||||
FrontendOrigin string
|
||||
ZarinHubEndpoint string
|
||||
ZarinHubAuthEndpoint string
|
||||
ZarinHubUsername string
|
||||
ZarinHubPassword string
|
||||
ZarinHubTimeout time.Duration
|
||||
DatabasePath string
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
@@ -21,9 +23,14 @@ func loadConfig() (config, error) {
|
||||
}
|
||||
|
||||
func readConfig(getenv func(string) string) (config, error) {
|
||||
token := strings.TrimSpace(getenv("ZARINHUB_ACCESS_TOKEN"))
|
||||
if token == "" {
|
||||
return config{}, fmt.Errorf("ZARINHUB_ACCESS_TOKEN is required")
|
||||
username := strings.TrimSpace(getenv("ZARINHUB_API_USERNAME"))
|
||||
if username == "" {
|
||||
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(
|
||||
@@ -50,8 +57,13 @@ func readConfig(getenv func(string) string) (config, error) {
|
||||
getenv("ZARINHUB_ENDPOINT"),
|
||||
"https://zarin-hub.com/api/v5/KYC/CardToIban",
|
||||
),
|
||||
ZarinHubToken: token,
|
||||
ZarinHubTimeout: timeout,
|
||||
ZarinHubAuthEndpoint: valueOrDefault(
|
||||
getenv("ZARINHUB_AUTH_ENDPOINT"),
|
||||
"https://zarin-hub.com/api/v5/Authentication/GetToken",
|
||||
),
|
||||
ZarinHubUsername: username,
|
||||
ZarinHubPassword: password,
|
||||
ZarinHubTimeout: timeout,
|
||||
DatabasePath: valueOrDefault(
|
||||
getenv("DATABASE_PATH"),
|
||||
"./card-to-iban.db",
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
|
||||
func TestReadConfigUsesDefaults(t *testing.T) {
|
||||
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 {
|
||||
@@ -24,6 +25,9 @@ func TestReadConfigUsesDefaults(t *testing.T) {
|
||||
if config.FrontendOrigin != "http://127.0.0.1:3000" {
|
||||
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 {
|
||||
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 "" })
|
||||
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_ACCESS_TOKEN") {
|
||||
t.Fatalf("readConfig() error = %v, want missing token error", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_API_USERNAME") {
|
||||
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) {
|
||||
values := map[string]string{
|
||||
"ZARINHUB_ACCESS_TOKEN": "test-token",
|
||||
"ZARINHUB_API_USERNAME": "test-username",
|
||||
"ZARINHUB_API_PASSWORD": "test-password",
|
||||
"ZARINHUB_TIMEOUT": "later",
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,24 @@ func run(logger *slog.Logger) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -58,9 +76,9 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
|
||||
zarinHubClient := zarinhub.NewClient(
|
||||
&http.Client{Timeout: config.ZarinHubTimeout},
|
||||
httpClient,
|
||||
config.ZarinHubEndpoint,
|
||||
config.ZarinHubToken,
|
||||
tokenManager,
|
||||
)
|
||||
cardService := service.NewCardService(zarinHubClient)
|
||||
auditRepository := repository.NewAuditRepository(db)
|
||||
|
||||
@@ -16,28 +16,60 @@ import (
|
||||
const maxResponseBodySize = 1 << 20
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
endpointURL string
|
||||
accessToken string
|
||||
httpClient *http.Client
|
||||
endpointURL string
|
||||
tokenProvider TokenProvider
|
||||
}
|
||||
|
||||
var _ service.CardToIBANClient = (*Client)(nil)
|
||||
|
||||
type TokenProvider interface {
|
||||
AccessToken(ctx context.Context) (string, error)
|
||||
Invalidate(token string)
|
||||
}
|
||||
|
||||
func NewClient(
|
||||
httpClient *http.Client,
|
||||
endpointURL string,
|
||||
accessToken string,
|
||||
tokenProvider TokenProvider,
|
||||
) *Client {
|
||||
return &Client{
|
||||
httpClient: httpClient,
|
||||
endpointURL: endpointURL,
|
||||
accessToken: accessToken,
|
||||
httpClient: httpClient,
|
||||
endpointURL: endpointURL,
|
||||
tokenProvider: tokenProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Convert(
|
||||
ctx context.Context,
|
||||
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) {
|
||||
payload, err := json.Marshal(convertRequest{
|
||||
Card: cardNumber,
|
||||
@@ -60,7 +92,7 @@ func (c *Client) Convert(
|
||||
|
||||
req.Header.Set(
|
||||
"Authorization",
|
||||
"Bearer "+c.accessToken,
|
||||
"Bearer "+accessToken,
|
||||
)
|
||||
|
||||
req.Header.Set(
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestClientConvertSuccess(t *testing.T) {
|
||||
Timeout: time.Second,
|
||||
},
|
||||
server.URL,
|
||||
token,
|
||||
&fakeTokenProvider{tokens: []string{token}},
|
||||
)
|
||||
|
||||
result, err := client.Convert(
|
||||
@@ -171,7 +171,7 @@ func TestClientReturnsRejectedForApplicationFailure(
|
||||
Timeout: time.Second,
|
||||
},
|
||||
server.URL,
|
||||
"test-token",
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
)
|
||||
|
||||
result, err := client.Convert(
|
||||
@@ -263,7 +263,7 @@ func TestClientMapsHTTPStatusErrors(t *testing.T) {
|
||||
Timeout: time.Second,
|
||||
},
|
||||
server.URL,
|
||||
"test-token",
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
)
|
||||
|
||||
result, err := client.Convert(
|
||||
@@ -321,7 +321,7 @@ func TestClientReturnsInvalidResponseForMalformedJSON(
|
||||
Timeout: time.Second,
|
||||
},
|
||||
server.URL,
|
||||
"test-token",
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
)
|
||||
|
||||
result, err := client.Convert(
|
||||
@@ -366,7 +366,7 @@ func TestClientReturnsTimeout(t *testing.T) {
|
||||
Timeout: 50 * time.Millisecond,
|
||||
},
|
||||
server.URL,
|
||||
"test-token",
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
)
|
||||
|
||||
_, err := client.Convert(
|
||||
@@ -402,7 +402,7 @@ func TestClientRejectsOversizedResponse(t *testing.T) {
|
||||
client := NewClient(
|
||||
&http.Client{Timeout: time.Second},
|
||||
server.URL,
|
||||
"test-token",
|
||||
&fakeTokenProvider{tokens: []string{"test-token"}},
|
||||
)
|
||||
|
||||
_, err := client.Convert(
|
||||
@@ -414,3 +414,67 @@ func TestClientRejectsOversizedResponse(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
213
backend/internal/zarinhub/token_manager.go
Normal file
213
backend/internal/zarinhub/token_manager.go
Normal 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)
|
||||
}
|
||||
222
backend/internal/zarinhub/token_manager_test.go
Normal file
222
backend/internal/zarinhub/token_manager_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user