234 lines
7.1 KiB
Go
234 lines
7.1 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
type handlerTransport struct {
|
|
handler http.Handler
|
|
}
|
|
|
|
func (transport handlerTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
recorder := httptest.NewRecorder()
|
|
transport.handler.ServeHTTP(recorder, request)
|
|
response := recorder.Result()
|
|
response.Request = request
|
|
return response, nil
|
|
}
|
|
|
|
func newTestClient(t *testing.T, handler http.Handler, options ...Option) *Client {
|
|
t.Helper()
|
|
httpClient := &http.Client{Transport: handlerTransport{handler: handler}}
|
|
options = append([]Option{WithHTTPClient(httpClient)}, options...)
|
|
apiClient, err := New("https://api.example", options...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return apiClient
|
|
}
|
|
|
|
func TestClientSupportsBearerAuthenticationAndBasePath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/healthcheck" {
|
|
t.Errorf("path: got %q, want %q", r.URL.Path, "/api/v1/healthcheck")
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
|
|
t.Errorf("Authorization: got %q, want %q", got, "Bearer secret")
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"status":"available","server_time":"2026-09-11T10:00:00Z","system_info":{"environment":"test","version":"v1"}}`))
|
|
})
|
|
|
|
httpClient := &http.Client{Transport: handlerTransport{handler: handler}}
|
|
apiClient, err := New("https://api.example/api", WithHTTPClient(httpClient), WithBearerToken("secret"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
health, response, err := apiClient.Healthcheck(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
t.Errorf("status: got %d, want %d", response.StatusCode, http.StatusOK)
|
|
}
|
|
if health.Status != "available" || health.SystemInfo.Environment != "test" {
|
|
t.Errorf("unexpected health response: %+v", health)
|
|
}
|
|
if health.ServerTime.IsZero() {
|
|
t.Errorf("health response does not include server time: %+v", health)
|
|
}
|
|
}
|
|
|
|
func TestSessionClientPersistsCookies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodPost && r.URL.Path == "/v1/session":
|
|
http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "session-token", Path: "/", HttpOnly: true})
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"user":{"id":42,"name":"Alice","email":"alice@example.com","activated":true}}`))
|
|
case r.Method == http.MethodGet && r.URL.Path == "/v1/session":
|
|
cookie, err := r.Cookie("gardomatic_session")
|
|
if err != nil || cookie.Value != "session-token" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"user":{"id":42,"name":"Alice","email":"alice@example.com","activated":true}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
|
|
apiClient := newTestClient(t, handler, WithSessions())
|
|
user, response, err := apiClient.CreateSession(context.Background(), Credentials{
|
|
Email: "alice@example.com", Password: "correct horse battery staple",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if user.ID != 42 {
|
|
t.Errorf("user ID: got %d, want 42", user.ID)
|
|
}
|
|
if len(response.Cookies()) != 1 {
|
|
t.Fatalf("response cookies: got %d, want 1", len(response.Cookies()))
|
|
}
|
|
|
|
user, _, err = apiClient.Session(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if user.Email != "alice@example.com" {
|
|
t.Errorf("email: got %q, want %q", user.Email, "alice@example.com")
|
|
}
|
|
}
|
|
|
|
func TestForRequestUsesIsolatedIncomingSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if _, err := r.Cookie("frontend_csrf"); !errors.Is(err, http.ErrNoCookie) {
|
|
t.Errorf("frontend-only cookie was forwarded to API")
|
|
}
|
|
cookie, err := r.Cookie("gardomatic_session")
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"missing session"}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"user":{"id":1,"name":"` + cookie.Value + `"}}`))
|
|
})
|
|
|
|
baseClient := newTestClient(t, handler)
|
|
incoming := httptest.NewRequest(http.MethodGet, "https://frontend.example/", nil)
|
|
incoming.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-a"})
|
|
incoming.AddCookie(&http.Cookie{Name: "frontend_csrf", Value: "do-not-forward"})
|
|
requestClient, err := baseClient.ForRequest(incoming)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user, _, err := requestClient.Session(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if user.Name != "browser-a" {
|
|
t.Errorf("user name: got %q, want %q", user.Name, "browser-a")
|
|
}
|
|
|
|
_, _, err = baseClient.Session(context.Background())
|
|
var apiError *APIError
|
|
if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("base client should have no session; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidationError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = w.Write([]byte(`{"error":{"email":"must be a valid email address"}}`))
|
|
})
|
|
|
|
apiClient := newTestClient(t, handler)
|
|
_, response, err := apiClient.RegisterUser(context.Background(), RegisterUserInput{})
|
|
var apiError *APIError
|
|
if !errors.As(err, &apiError) {
|
|
t.Fatalf("error type: got %T, want *APIError", err)
|
|
}
|
|
if response.StatusCode != http.StatusUnprocessableEntity {
|
|
t.Errorf("response status: got %d, want %d", response.StatusCode, http.StatusUnprocessableEntity)
|
|
}
|
|
if apiError.Validation["email"] != "must be a valid email address" {
|
|
t.Errorf("validation errors: got %#v", apiError.Validation)
|
|
}
|
|
}
|
|
|
|
func TestResponseBodyIsClosed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
closed := false
|
|
apiClient, err := New("https://api.example", WithHTTPClient(&http.Client{
|
|
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: &trackingReadCloser{
|
|
Reader: io.NopCloser(http.NoBody),
|
|
closed: &closed,
|
|
},
|
|
Request: request,
|
|
}, nil
|
|
}),
|
|
}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, _ = apiClient.Healthcheck(context.Background())
|
|
if !closed {
|
|
t.Error("response body was not closed")
|
|
}
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
return fn(request)
|
|
}
|
|
|
|
type trackingReadCloser struct {
|
|
Reader io.ReadCloser
|
|
closed *bool
|
|
}
|
|
|
|
func (reader *trackingReadCloser) Read(buffer []byte) (int, error) {
|
|
return reader.Reader.Read(buffer)
|
|
}
|
|
|
|
func (reader *trackingReadCloser) Close() error {
|
|
*reader.closed = true
|
|
return reader.Reader.Close()
|
|
}
|
|
|
|
func TestForwardCookies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
apiResponse := &http.Response{Header: make(http.Header)}
|
|
apiResponse.Header.Add("Set-Cookie", "gardomatic_session=token; Path=/; HttpOnly")
|
|
frontendResponse := httptest.NewRecorder()
|
|
|
|
ForwardCookies(frontendResponse, &Response{apiResponse})
|
|
|
|
if got := frontendResponse.Header().Values("Set-Cookie"); len(got) != 1 {
|
|
t.Fatalf("Set-Cookie headers: got %d, want 1", len(got))
|
|
}
|
|
}
|