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 downstream 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 }