feat: backend service
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
package testenv
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PublicRESTClient exposes the public REST surface of the gateway
|
||||
// (`/api/v1/public/*`). Tests use it for unauthenticated registration
|
||||
// flows.
|
||||
type PublicRESTClient struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewPublicRESTClient constructs a client targeting baseURL.
|
||||
func NewPublicRESTClient(baseURL string) *PublicRESTClient {
|
||||
return &PublicRESTClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// SendEmailCodeResponse mirrors the wire shape of
|
||||
// `POST /api/v1/public/auth/send-email-code`.
|
||||
type SendEmailCodeResponse struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
}
|
||||
|
||||
// ConfirmEmailCodeResponse mirrors the wire shape of
|
||||
// `POST /api/v1/public/auth/confirm-email-code`.
|
||||
type ConfirmEmailCodeResponse struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
|
||||
// SendEmailCode triggers an email-code challenge. The `locale` value
|
||||
// is sent through the public REST contract as the `Accept-Language`
|
||||
// header (gateway derives `preferred_language` from it; the body
|
||||
// schema rejects unknown fields).
|
||||
func (c *PublicRESTClient) SendEmailCode(ctx context.Context, email string, locale string) (*SendEmailCodeResponse, *http.Response, error) {
|
||||
body := map[string]any{"email": email}
|
||||
headers := http.Header{}
|
||||
if locale != "" {
|
||||
headers.Set("Accept-Language", locale)
|
||||
}
|
||||
resp, raw, err := c.doWithHeaders(ctx, http.MethodPost, "/api/v1/public/auth/send-email-code", body, headers)
|
||||
if err != nil {
|
||||
return nil, raw, err
|
||||
}
|
||||
if raw.StatusCode/100 != 2 {
|
||||
return nil, raw, fmt.Errorf("send-email-code: status %d: %s", raw.StatusCode, string(resp))
|
||||
}
|
||||
var out SendEmailCodeResponse
|
||||
if err := json.Unmarshal(resp, &out); err != nil {
|
||||
return nil, raw, err
|
||||
}
|
||||
return &out, raw, nil
|
||||
}
|
||||
|
||||
// ConfirmEmailCode confirms a challenge and registers a device
|
||||
// session.
|
||||
func (c *PublicRESTClient) ConfirmEmailCode(ctx context.Context, challengeID, code, clientPublicKey, timeZone string) (*ConfirmEmailCodeResponse, *http.Response, error) {
|
||||
body := map[string]any{
|
||||
"challenge_id": challengeID,
|
||||
"code": code,
|
||||
"client_public_key": clientPublicKey,
|
||||
"time_zone": timeZone,
|
||||
}
|
||||
resp, raw, err := c.do(ctx, http.MethodPost, "/api/v1/public/auth/confirm-email-code", body)
|
||||
if err != nil {
|
||||
return nil, raw, err
|
||||
}
|
||||
if raw.StatusCode/100 != 2 {
|
||||
return nil, raw, fmt.Errorf("confirm-email-code: status %d: %s", raw.StatusCode, string(resp))
|
||||
}
|
||||
var out ConfirmEmailCodeResponse
|
||||
if err := json.Unmarshal(resp, &out); err != nil {
|
||||
return nil, raw, err
|
||||
}
|
||||
return &out, raw, nil
|
||||
}
|
||||
|
||||
func (c *PublicRESTClient) do(ctx context.Context, method, path string, body any) ([]byte, *http.Response, error) {
|
||||
return c.doWithHeaders(ctx, method, path, body, nil)
|
||||
}
|
||||
|
||||
func (c *PublicRESTClient) doWithHeaders(ctx context.Context, method, path string, body any, headers http.Header) ([]byte, *http.Response, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for k, vs := range headers {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
return raw, resp, nil
|
||||
}
|
||||
|
||||
// BackendInternalClient hits backend's `/api/v1/internal/*` endpoints
|
||||
// directly. Per ARCHITECTURE.md the trust boundary is the network, so
|
||||
// integration tests act as a trusted gateway-equivalent caller.
|
||||
type BackendInternalClient struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewBackendInternalClient targets backend's HTTP base URL.
|
||||
func NewBackendInternalClient(baseURL string) *BackendInternalClient {
|
||||
return &BackendInternalClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Do issues an internal request. The caller decodes the body.
|
||||
func (c *BackendInternalClient) Do(ctx context.Context, method, path string, body any) ([]byte, *http.Response, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
return raw, resp, nil
|
||||
}
|
||||
|
||||
// BackendUserClient hits backend's `/api/v1/user/*` endpoints
|
||||
// directly with `X-User-ID` set, mirroring what gateway does after
|
||||
// authenticated traffic verification. Used by scenarios whose
|
||||
// message_type is not registered in gateway's gRPC router (lobby
|
||||
// create, soft delete, etc.).
|
||||
type BackendUserClient struct {
|
||||
BaseURL string
|
||||
UserID string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewBackendUserClient targets backend's HTTP base URL with userID
|
||||
// pre-bound.
|
||||
func NewBackendUserClient(baseURL, userID string) *BackendUserClient {
|
||||
return &BackendUserClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
UserID: userID,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Do issues a user-scoped backend request.
|
||||
func (c *BackendUserClient) Do(ctx context.Context, method, path string, body any) ([]byte, *http.Response, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req.Header.Set("X-User-ID", c.UserID)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
return raw, resp, nil
|
||||
}
|
||||
|
||||
// BackendAdminClient hits backend's admin surface directly with HTTP
|
||||
// Basic Auth. Per ARCHITECTURE.md §14 the admin surface is on the
|
||||
// backend HTTP listener (not gateway), so tests address it directly.
|
||||
type BackendAdminClient struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Password string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewBackendAdminClient targets backend's HTTP base URL with the
|
||||
// supplied credentials.
|
||||
func NewBackendAdminClient(baseURL, username, password string) *BackendAdminClient {
|
||||
return &BackendAdminClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Username: username,
|
||||
Password: password,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Do performs a request against an admin endpoint. The caller decodes
|
||||
// the body. Returned http.Response is always non-nil on success.
|
||||
func (c *BackendAdminClient) Do(ctx context.Context, method, path string, body any) ([]byte, *http.Response, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.Username, c.Password)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
return raw, resp, nil
|
||||
}
|
||||
Reference in New Issue
Block a user