tests: integration suite

This commit is contained in:
IliaDenisov
2026-04-09 15:27:14 +02:00
parent e04fc663f0
commit 1c8e0ca48e
20 changed files with 2748 additions and 10 deletions
@@ -0,0 +1,224 @@
package restapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
const (
authServiceSendEmailCodePath = "/api/v1/public/auth/send-email-code"
authServiceConfirmEmailCodePath = "/api/v1/public/auth/confirm-email-code"
)
// HTTPAuthServiceClient implements AuthServiceClient over the Auth / Session
// Service public HTTP API using strict JSON request and response decoding.
type HTTPAuthServiceClient struct {
baseURL string
httpClient *http.Client
}
type authServiceErrorEnvelope struct {
Error *authServiceErrorBody `json:"error"`
}
type authServiceErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// NewHTTPAuthServiceClient constructs an AuthServiceClient that delegates the
// gateway public-auth routes to the Auth / Session Service public HTTP API at
// baseURL. The resulting client relies only on the caller-provided context for
// cancellation and timeout control.
func NewHTTPAuthServiceClient(baseURL string) (*HTTPAuthServiceClient, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new auth service HTTP client: default transport is not *http.Transport")
}
return newHTTPAuthServiceClient(baseURL, &http.Client{
Transport: transport.Clone(),
})
}
func newHTTPAuthServiceClient(baseURL string, httpClient *http.Client) (*HTTPAuthServiceClient, error) {
if httpClient == nil {
return nil, errors.New("new auth service HTTP client: http client must not be nil")
}
trimmedBaseURL := strings.TrimSpace(baseURL)
if trimmedBaseURL == "" {
return nil, errors.New("new auth service HTTP client: base URL must not be empty")
}
parsedBaseURL, err := url.Parse(strings.TrimRight(trimmedBaseURL, "/"))
if err != nil {
return nil, fmt.Errorf("new auth service HTTP client: parse base URL: %w", err)
}
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
return nil, errors.New("new auth service HTTP client: base URL must be absolute")
}
return &HTTPAuthServiceClient{
baseURL: parsedBaseURL.String(),
httpClient: httpClient,
}, nil
}
// Close releases idle HTTP connections owned by the client transport.
func (c *HTTPAuthServiceClient) Close() error {
if c == nil || c.httpClient == nil {
return nil
}
type idleCloser interface {
CloseIdleConnections()
}
if transport, ok := c.httpClient.Transport.(idleCloser); ok {
transport.CloseIdleConnections()
}
return nil
}
// SendEmailCode delegates the public send-email-code route to the configured
// Auth / Session Service public HTTP API.
func (c *HTTPAuthServiceClient) SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error) {
payload, statusCode, err := c.doJSONRequest(ctx, authServiceSendEmailCodePath, input)
if err != nil {
return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: %w", err)
}
switch {
case statusCode == http.StatusOK:
var result SendEmailCodeResult
if err := decodeStrictJSONPayload(payload, &result); err != nil {
return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: decode success response: %w", err)
}
if err := validateSendEmailCodeResult(&result); err != nil {
return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: %w", err)
}
return result, nil
case statusCode >= 400 && statusCode <= 599:
authErr, err := decodeAuthServiceError(statusCode, payload)
if err != nil {
return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: %w", err)
}
return SendEmailCodeResult{}, authErr
default:
return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: unexpected HTTP status %d", statusCode)
}
}
// ConfirmEmailCode delegates the public confirm-email-code route to the
// configured Auth / Session Service public HTTP API.
func (c *HTTPAuthServiceClient) ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) {
payload, statusCode, err := c.doJSONRequest(ctx, authServiceConfirmEmailCodePath, input)
if err != nil {
return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: %w", err)
}
switch {
case statusCode == http.StatusOK:
var result ConfirmEmailCodeResult
if err := decodeStrictJSONPayload(payload, &result); err != nil {
return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: decode success response: %w", err)
}
if err := validateConfirmEmailCodeResult(&result); err != nil {
return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: %w", err)
}
return result, nil
case statusCode >= 400 && statusCode <= 599:
authErr, err := decodeAuthServiceError(statusCode, payload)
if err != nil {
return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: %w", err)
}
return ConfirmEmailCodeResult{}, authErr
default:
return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: unexpected HTTP status %d", statusCode)
}
}
func (c *HTTPAuthServiceClient) doJSONRequest(ctx context.Context, path string, requestBody any) ([]byte, int, error) {
if c == nil || c.httpClient == nil {
return nil, 0, errors.New("nil client")
}
if ctx == nil {
return nil, 0, errors.New("nil context")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
payload, err := json.Marshal(requestBody)
if err != nil {
return nil, 0, fmt.Errorf("marshal request body: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(payload))
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request)
if err != nil {
return nil, 0, err
}
defer response.Body.Close()
responsePayload, err := io.ReadAll(response.Body)
if err != nil {
return nil, 0, fmt.Errorf("read response body: %w", err)
}
return responsePayload, response.StatusCode, nil
}
func decodeAuthServiceError(statusCode int, payload []byte) (*AuthServiceError, error) {
var envelope authServiceErrorEnvelope
if err := decodeStrictJSONPayload(payload, &envelope); err != nil {
return nil, fmt.Errorf("decode error response: %w", err)
}
if envelope.Error == nil {
return nil, errors.New("decode error response: missing error object")
}
return &AuthServiceError{
StatusCode: statusCode,
Code: envelope.Error.Code,
Message: envelope.Error.Message,
}, nil
}
func decodeStrictJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
var _ AuthServiceClient = (*HTTPAuthServiceClient)(nil)
@@ -0,0 +1,346 @@
package restapi
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewHTTPAuthServiceClient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
baseURL string
wantErr string
}{
{
name: "success",
baseURL: " http://127.0.0.1:8080/ ",
},
{
name: "empty base url",
wantErr: "base URL must not be empty",
},
{
name: "relative base url",
baseURL: "/authsession",
wantErr: "base URL must be absolute",
},
{
name: "malformed base url",
baseURL: "://bad",
wantErr: "parse base URL",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
client, err := NewHTTPAuthServiceClient(tt.baseURL)
if tt.wantErr != "" {
require.Error(t, err)
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, "http://127.0.0.1:8080", client.baseURL)
assert.NoError(t, client.Close())
})
}
}
func TestHTTPAuthServiceClientSendEmailCodeSuccess(t *testing.T) {
t.Parallel()
var requestContentType string
var requestBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, authServiceSendEmailCodePath, r.URL.Path)
requestContentType = r.Header.Get("Content-Type")
payload, err := io.ReadAll(r.Body)
require.NoError(t, err)
requestBody = string(payload)
w.Header().Set("Content-Type", "application/json")
_, err = io.WriteString(w, `{"challenge_id":"challenge-123"}`)
require.NoError(t, err)
}))
defer server.Close()
client := newTestHTTPAuthServiceClient(t, server)
result, err := client.SendEmailCode(context.Background(), SendEmailCodeInput{
Email: "pilot@example.com",
})
require.NoError(t, err)
assert.Equal(t, SendEmailCodeResult{ChallengeID: "challenge-123"}, result)
assert.Equal(t, "application/json", requestContentType)
assert.JSONEq(t, `{"email":"pilot@example.com"}`, requestBody)
}
func TestHTTPAuthServiceClientConfirmEmailCodeSuccess(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, authServiceConfirmEmailCodePath, r.URL.Path)
payload, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.JSONEq(t, `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key","time_zone":"Europe/Kaliningrad"}`, string(payload))
w.Header().Set("Content-Type", "application/json")
_, err = io.WriteString(w, `{"device_session_id":"device-session-123"}`)
require.NoError(t, err)
}))
defer server.Close()
client := newTestHTTPAuthServiceClient(t, server)
result, err := client.ConfirmEmailCode(context.Background(), ConfirmEmailCodeInput{
ChallengeID: "challenge-123",
Code: "123456",
ClientPublicKey: "public-key",
TimeZone: "Europe/Kaliningrad",
})
require.NoError(t, err)
assert.Equal(t, ConfirmEmailCodeResult{DeviceSessionID: "device-session-123"}, result)
}
func TestHTTPAuthServiceClientProjectsAuthServiceErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
statusCode int
responseBody string
call func(*HTTPAuthServiceClient) error
wantStatusCode int
wantCode string
wantMessage string
}{
{
name: "send email code error",
statusCode: http.StatusServiceUnavailable,
responseBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
call: func(client *HTTPAuthServiceClient) error {
_, err := client.SendEmailCode(context.Background(), SendEmailCodeInput{Email: "pilot@example.com"})
return err
},
wantStatusCode: http.StatusServiceUnavailable,
wantCode: "service_unavailable",
wantMessage: "service is unavailable",
},
{
name: "confirm email code error",
statusCode: http.StatusConflict,
responseBody: `{"error":{"code":"session_limit_exceeded","message":"active session limit would be exceeded"}}`,
call: func(client *HTTPAuthServiceClient) error {
_, err := client.ConfirmEmailCode(context.Background(), ConfirmEmailCodeInput{
ChallengeID: "challenge-123",
Code: "123456",
ClientPublicKey: "public-key",
TimeZone: "Europe/Kaliningrad",
})
return err
},
wantStatusCode: http.StatusConflict,
wantCode: "session_limit_exceeded",
wantMessage: "active session limit would be exceeded",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.statusCode)
_, err := io.WriteString(w, tt.responseBody)
require.NoError(t, err)
}))
defer server.Close()
client := newTestHTTPAuthServiceClient(t, server)
err := tt.call(client)
require.Error(t, err)
var authErr *AuthServiceError
require.ErrorAs(t, err, &authErr)
assert.Equal(t, tt.wantStatusCode, authErr.StatusCode)
assert.Equal(t, tt.wantCode, authErr.Code)
assert.Equal(t, tt.wantMessage, authErr.Message)
})
}
}
func TestHTTPAuthServiceClientRejectsMalformedPayloads(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
statusCode int
responseBody string
wantErr string
}{
{
name: "send email code rejects unknown success field",
path: authServiceSendEmailCodePath,
statusCode: http.StatusOK,
responseBody: `{"challenge_id":"challenge-123","extra":true}`,
wantErr: "decode success response",
},
{
name: "confirm email code rejects empty success field",
path: authServiceConfirmEmailCodePath,
statusCode: http.StatusOK,
responseBody: `{"device_session_id":" "}`,
wantErr: "empty device_session_id",
},
{
name: "rejects missing error object",
path: authServiceSendEmailCodePath,
statusCode: http.StatusBadRequest,
responseBody: `{}`,
wantErr: "missing error object",
},
{
name: "rejects malformed error envelope",
path: authServiceConfirmEmailCodePath,
statusCode: http.StatusBadRequest,
responseBody: `{"error":{"code":"invalid_code","message":"confirmation code is invalid","extra":true}}`,
wantErr: "decode error response",
},
{
name: "rejects unexpected status",
path: authServiceSendEmailCodePath,
statusCode: http.StatusCreated,
responseBody: `{"challenge_id":"challenge-123"}`,
wantErr: "unexpected HTTP status 201",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, tt.path, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.statusCode)
_, err := io.WriteString(w, tt.responseBody)
require.NoError(t, err)
}))
defer server.Close()
client := newTestHTTPAuthServiceClient(t, server)
var err error
switch tt.path {
case authServiceSendEmailCodePath:
_, err = client.SendEmailCode(context.Background(), SendEmailCodeInput{Email: "pilot@example.com"})
default:
_, err = client.ConfirmEmailCode(context.Background(), ConfirmEmailCodeInput{
ChallengeID: "challenge-123",
Code: "123456",
ClientPublicKey: "public-key",
TimeZone: "Europe/Kaliningrad",
})
}
require.Error(t, err)
assert.ErrorContains(t, err, tt.wantErr)
assert.NotErrorAs(t, err, new(*AuthServiceError))
})
}
}
func TestHTTPAuthServiceClientUsesCallerContext(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"challenge_id":"challenge-123"}`)
}))
defer server.Close()
client := newTestHTTPAuthServiceClient(t, server)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
_, err := client.SendEmailCode(ctx, SendEmailCodeInput{Email: "pilot@example.com"})
require.Error(t, err)
assert.ErrorContains(t, err, "send email code via auth service")
assert.True(t, errors.Is(err, context.DeadlineExceeded))
}
func TestHTTPAuthServiceClientRejectsNilContext(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.FailNow(t, "unexpected request", r.URL.Path)
}))
defer server.Close()
client := newTestHTTPAuthServiceClient(t, server)
_, err := client.SendEmailCode(nil, SendEmailCodeInput{Email: "pilot@example.com"})
require.Error(t, err)
assert.ErrorContains(t, err, "nil context")
}
func newTestHTTPAuthServiceClient(t *testing.T, server *httptest.Server) *HTTPAuthServiceClient {
t.Helper()
client, err := newHTTPAuthServiceClient(server.URL, server.Client())
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, client.Close())
})
return client
}
func TestDecodeStrictJSONPayloadRejectsTrailingJSON(t *testing.T) {
t.Parallel()
var target struct {
Value string `json:"value"`
}
err := decodeStrictJSONPayload([]byte(`{"value":"ok"}{}`), &target)
require.Error(t, err)
assert.Equal(t, "unexpected trailing JSON input", err.Error())
}
func TestDecodeAuthServiceErrorPreservesBlankFieldsForLaterNormalization(t *testing.T) {
t.Parallel()
authErr, err := decodeAuthServiceError(http.StatusBadGateway, []byte(`{"error":{"code":" ","message":" "}}`))
require.NoError(t, err)
assert.Equal(t, http.StatusBadGateway, authErr.StatusCode)
assert.True(t, strings.TrimSpace(authErr.Code) == "")
assert.True(t, strings.TrimSpace(authErr.Message) == "")
}