mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
Add a ruleguard rule forbidding direct `json.NewDecoder(res.Body).Decode(...)` on `*http.Response` in codersdk packages, so new typed endpoints use `codersdk.ReadBodyAsJSON` and keep returning structured errors for non-JSON bodies. The rule matches both the chained call form and decoders assigned to a variable first. Intentional raw-body paths carry documented `//nolint:gocritic` exceptions: the 16 agent-direct HTTP decodes in `workspacesdk/agentconn.go` route through a single `decodeAgentJSON` helper (agent-direct over tailnet, so `ReadBodyAsJSON`'s reverse proxy/SSO error guidance does not apply), and the Azure IMDS attested-document decode in `agentsdk/azure.go` keeps an inline exception. The two `UseNumber` decoders in `licenses.go` are migrated to a new `codersdk.ReadBodyAsJSONUseNumber`, so `coder licenses add/list` also return structured errors for non-JSON bodies instead of `invalid character '<' looking for beginning of value`. Note for local verification: golangci-lint caches results, so run `golangci-lint cache clean` after modifying `scripts/rules.go` or the rule may silently not fire. Final PR of the stack on #27804, #27857, and #27858. Refs #27044. Stack plan Inventory (full-tree audit): 280 migratable call sites across 47 files; 17 excluded (16 agent-direct HTTP sites in `workspacesdk/agentconn.go`, 1 Azure IMDS decode in `agentsdk/azure.go`). 1. **#27857** `refactor(codersdk): use ReadBodyAsJSON in typed endpoints`: mechanical migration of all sites except `chats.go` (224 sites, 46 files). 2. **#27858** `refactor(codersdk): use shared error helpers in chat endpoints`: migrate the 56 `chats.go` sites and consolidate the duplicated `readRawBodyAsError`/`newResponseError` helpers onto the shared `client.go` error path, with regression tests for the 409 usage-limit flow. 3. **#27859** `chore: forbid direct response body JSON decode in codersdk`: ruleguard rule with documented exceptions for the intentional raw-body paths, plus `ReadBodyAsJSONUseNumber` for the `licenses.go` decoders. Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder.
706 lines
20 KiB
Go
706 lines
20 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"testing/iotest"
|
|
"time"
|
|
|
|
"github.com/go-logr/logr"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/propagation"
|
|
"go.opentelemetry.io/otel/sdk/resource"
|
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
|
semconv "go.opentelemetry.io/otel/semconv/v1.14.0"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/sloghuman"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
const jsonCT = "application/json"
|
|
|
|
func TestIsConnectionErr(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type tc = struct {
|
|
name string
|
|
err error
|
|
expectedResult bool
|
|
}
|
|
|
|
cases := []tc{
|
|
{
|
|
// E.g. "no such host"
|
|
name: "DNSError",
|
|
err: &net.DNSError{
|
|
Err: "no such host",
|
|
Name: "foofoo",
|
|
Server: "1.1.1.1:53",
|
|
IsTimeout: false,
|
|
IsTemporary: false,
|
|
IsNotFound: true,
|
|
},
|
|
expectedResult: true,
|
|
},
|
|
{
|
|
// E.g. "connection refused"
|
|
name: "OpErr",
|
|
err: &net.OpError{
|
|
Op: "dial",
|
|
Net: "tcp",
|
|
Source: nil,
|
|
Addr: nil,
|
|
Err: &os.SyscallError{},
|
|
},
|
|
expectedResult: true,
|
|
},
|
|
{
|
|
name: "OpaqueError",
|
|
err: xerrors.Errorf("I'm opaque!"),
|
|
expectedResult: false,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
require.Equal(t, c.expectedResult, IsConnectionError(c.err))
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_Client(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const method = http.MethodPost
|
|
const path = "/ok"
|
|
const token = "token"
|
|
const reqBody = `{"msg": "request body"}`
|
|
const resBody = `{"status": "ok"}`
|
|
|
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, method, r.Method)
|
|
assert.Equal(t, path, r.URL.Path)
|
|
assert.Equal(t, token, r.Header.Get(SessionTokenHeader))
|
|
assert.NotEmpty(t, r.Header.Get("Traceparent"))
|
|
for k, v := range r.Header {
|
|
t.Logf("header %q: %q", k, strings.Join(v, ", "))
|
|
}
|
|
|
|
w.Header().Set("Content-Type", jsonCT)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, resBody)
|
|
}))
|
|
|
|
u, err := url.Parse(s.URL)
|
|
require.NoError(t, err)
|
|
client := New(u)
|
|
client.SetSessionToken(token)
|
|
|
|
logBuf := bytes.NewBuffer(nil)
|
|
client.SetLogger(slog.Make(sloghuman.Sink(logBuf)).Leveled(slog.LevelDebug))
|
|
client.SetLogBodies(true)
|
|
|
|
// Setup tracing.
|
|
res := resource.NewWithAttributes(
|
|
semconv.SchemaURL,
|
|
semconv.ServiceNameKey.String("codersdk_test"),
|
|
)
|
|
tracerOpts := []sdktrace.TracerProviderOption{
|
|
sdktrace.WithResource(res),
|
|
}
|
|
tracerProvider := sdktrace.NewTracerProvider(tracerOpts...)
|
|
otel.SetTracerProvider(tracerProvider)
|
|
otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) {}))
|
|
otel.SetTextMapPropagator(
|
|
propagation.NewCompositeTextMapPropagator(
|
|
propagation.TraceContext{},
|
|
propagation.Baggage{},
|
|
),
|
|
)
|
|
otel.SetLogger(logr.Discard())
|
|
client.Trace = true
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
ctx, span := tracerProvider.Tracer("codersdk_test").Start(ctx, "codersdk client test 1")
|
|
defer span.End()
|
|
|
|
resp, err := client.Request(ctx, method, path, []byte(reqBody))
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
require.Equal(t, jsonCT, resp.Header.Get("Content-Type"))
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, resBody, string(body))
|
|
|
|
logStr := logBuf.String()
|
|
require.Contains(t, logStr, "sdk request")
|
|
require.Contains(t, logStr, method)
|
|
require.Contains(t, logStr, path)
|
|
require.Contains(t, logStr, strings.ReplaceAll(reqBody, `"`, `\"`))
|
|
require.Contains(t, logStr, "sdk response")
|
|
require.Contains(t, logStr, "200")
|
|
require.Contains(t, logStr, strings.ReplaceAll(resBody, `"`, `\"`))
|
|
}
|
|
|
|
func Test_Client_LogBodiesFalse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const method = http.MethodPost
|
|
const path = "/ok"
|
|
const reqBody = `{"msg": "request body"}`
|
|
const resBody = `{"status": "ok"}`
|
|
|
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", jsonCT)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, resBody)
|
|
}))
|
|
|
|
u, err := url.Parse(s.URL)
|
|
require.NoError(t, err)
|
|
client := New(u)
|
|
|
|
logBuf := bytes.NewBuffer(nil)
|
|
client.SetLogger(slog.Make(sloghuman.Sink(logBuf)).Leveled(slog.LevelDebug))
|
|
client.SetLogBodies(false)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
resp, err := client.Request(ctx, method, path, []byte(reqBody))
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, resBody, string(body))
|
|
|
|
logStr := logBuf.String()
|
|
require.Contains(t, logStr, "sdk request")
|
|
require.Contains(t, logStr, "sdk response")
|
|
require.NotContains(t, logStr, "body")
|
|
}
|
|
|
|
func Test_readBodyAsError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
exampleURL := "http://example.com"
|
|
simpleResponse := Response{
|
|
Message: "test",
|
|
Detail: "hi",
|
|
}
|
|
|
|
longResponse := ""
|
|
for i := 0; i < 4000; i++ {
|
|
longResponse += "a"
|
|
}
|
|
|
|
unexpectedJSON := marshal(map[string]any{
|
|
"hello": "world",
|
|
"foo": "bar",
|
|
})
|
|
|
|
//nolint:bodyclose
|
|
tests := []struct {
|
|
name string
|
|
req *http.Request
|
|
res *http.Response
|
|
assert func(t *testing.T, err error)
|
|
}{
|
|
{
|
|
name: "JSONWithRequest",
|
|
req: httptest.NewRequest(http.MethodGet, exampleURL, nil),
|
|
res: newResponse(http.StatusNotFound, jsonCT, marshal(simpleResponse)),
|
|
assert: func(t *testing.T, err error) {
|
|
sdkErr := assertSDKError(t, err)
|
|
|
|
assert.Equal(t, simpleResponse, sdkErr.Response)
|
|
assert.ErrorContains(t, err, sdkErr.Response.Message)
|
|
assert.ErrorContains(t, err, sdkErr.Response.Detail)
|
|
|
|
assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
assert.ErrorContains(t, err, strconv.Itoa(sdkErr.StatusCode()))
|
|
|
|
assert.Equal(t, http.MethodGet, sdkErr.method)
|
|
assert.ErrorContains(t, err, sdkErr.method)
|
|
|
|
assert.Equal(t, exampleURL, sdkErr.url)
|
|
assert.ErrorContains(t, err, sdkErr.url)
|
|
|
|
assert.Empty(t, sdkErr.Helper)
|
|
},
|
|
},
|
|
{
|
|
name: "JSONWithoutRequest",
|
|
req: nil,
|
|
res: newResponse(http.StatusNotFound, jsonCT, marshal(simpleResponse)),
|
|
assert: func(t *testing.T, err error) {
|
|
sdkErr := assertSDKError(t, err)
|
|
|
|
assert.Equal(t, simpleResponse, sdkErr.Response)
|
|
assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
assert.Empty(t, sdkErr.method)
|
|
assert.Empty(t, sdkErr.url)
|
|
assert.Empty(t, sdkErr.Helper)
|
|
},
|
|
},
|
|
{
|
|
name: "UnauthorizedHelper",
|
|
req: nil,
|
|
res: newResponse(http.StatusUnauthorized, jsonCT, marshal(simpleResponse)),
|
|
assert: func(t *testing.T, err error) {
|
|
sdkErr := assertSDKError(t, err)
|
|
|
|
assert.Contains(t, sdkErr.Helper, "Try logging in")
|
|
assert.ErrorContains(t, err, sdkErr.Helper)
|
|
},
|
|
},
|
|
{
|
|
name: "NonJSON",
|
|
req: nil,
|
|
res: newResponse(http.StatusNotFound, "text/plain; charset=utf-8", "hello world"),
|
|
assert: func(t *testing.T, err error) {
|
|
sdkErr := assertSDKError(t, err)
|
|
|
|
assert.Contains(t, sdkErr.Response.Message, "unexpected non-JSON response")
|
|
assert.Equal(t, "hello world", sdkErr.Response.Detail)
|
|
},
|
|
},
|
|
{
|
|
name: "NonJSONLong",
|
|
req: nil,
|
|
res: newResponse(http.StatusNotFound, "text/plain; charset=utf-8", longResponse),
|
|
assert: func(t *testing.T, err error) {
|
|
sdkErr := assertSDKError(t, err)
|
|
|
|
assert.Contains(t, sdkErr.Response.Message, "unexpected non-JSON response")
|
|
|
|
expected := longResponse[0:2048] + "..."
|
|
assert.Equal(t, expected, sdkErr.Response.Detail)
|
|
},
|
|
},
|
|
{
|
|
name: "JSONNoBody",
|
|
req: httptest.NewRequest(http.MethodGet, exampleURL, nil),
|
|
res: newResponse(http.StatusNotFound, jsonCT, ""),
|
|
assert: func(t *testing.T, err error) {
|
|
sdkErr := assertSDKError(t, err)
|
|
|
|
assert.Contains(t, sdkErr.Response.Message, "empty response body")
|
|
|
|
assert.Equal(t, http.MethodGet, sdkErr.method)
|
|
assert.ErrorContains(t, err, sdkErr.method)
|
|
|
|
assert.Equal(t, exampleURL, sdkErr.url)
|
|
assert.ErrorContains(t, err, sdkErr.url)
|
|
},
|
|
},
|
|
{
|
|
name: "JSONNoMessage",
|
|
req: nil,
|
|
res: newResponse(http.StatusNotFound, jsonCT, unexpectedJSON),
|
|
assert: func(t *testing.T, err error) {
|
|
sdkErr := assertSDKError(t, err)
|
|
|
|
assert.Contains(t, sdkErr.Response.Message, "unexpected status code")
|
|
assert.Contains(t, sdkErr.Response.Message, "has no message")
|
|
assert.Equal(t, unexpectedJSON, sdkErr.Response.Detail)
|
|
},
|
|
},
|
|
{
|
|
// Even status code 200 should be considered an error if this function
|
|
// is called. There are parts of the code that require this function
|
|
// to always return an error.
|
|
name: "OKResp",
|
|
req: nil,
|
|
res: newResponse(http.StatusOK, jsonCT, marshal(map[string]any{})),
|
|
assert: func(t *testing.T, err error) {
|
|
require.Error(t, err)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, c := range tests {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c.res.Request = c.req
|
|
|
|
err := ReadBodyAsError(c.res)
|
|
c.assert(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_ReadBodyAsJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type apiResponse struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
const htmlBody = `<!DOCTYPE html><html><head><title>Sign in</title></head><body>Sign in to continue</body></html>`
|
|
|
|
// assertInvalidBody asserts the fields shared by every error that
|
|
// ReadBodyAsJSON returns for a response with an unusable body.
|
|
assertInvalidBody := func(t *testing.T, err error, contentType string) *Error {
|
|
t.Helper()
|
|
sdkErr := assertSDKError(t, err)
|
|
assert.Equal(t, http.StatusOK, sdkErr.StatusCode())
|
|
assert.Equal(t, http.MethodGet, sdkErr.Method())
|
|
assert.NotEmpty(t, sdkErr.URL())
|
|
assert.Equal(t, contentType, sdkErr.ContentType())
|
|
assert.NotContains(t, err.Error(), "unexpected status code")
|
|
assert.ErrorContains(t, err, "invalid API response (status code 200)")
|
|
return sdkErr
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
handler http.HandlerFunc
|
|
assert func(t *testing.T, v apiResponse, err error)
|
|
}{
|
|
{
|
|
name: "ValidJSON",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", jsonCT)
|
|
_, _ = io.WriteString(w, `{"name":"hello"}`)
|
|
},
|
|
assert: func(t *testing.T, v apiResponse, err error) {
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "hello", v.Name)
|
|
},
|
|
},
|
|
{
|
|
name: "ValidJSONWithoutContentType",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
// Intermediaries may strip or omit the Content-Type
|
|
// header even when the body is untouched JSON.
|
|
w.Header()["Content-Type"] = nil
|
|
_, _ = io.WriteString(w, `{"name":"hello"}`)
|
|
},
|
|
assert: func(t *testing.T, v apiResponse, err error) {
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "hello", v.Name)
|
|
},
|
|
},
|
|
{
|
|
name: "ValidJSONWithWrongContentType",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = io.WriteString(w, `{"name":"hello"}`)
|
|
},
|
|
assert: func(t *testing.T, v apiResponse, err error) {
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "hello", v.Name)
|
|
},
|
|
},
|
|
{
|
|
name: "ValidJSONLargerThanSniffWindow",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", jsonCT)
|
|
_, _ = io.WriteString(w, `{"name":"`+strings.Repeat("a", 4*jsonBodySniffLen)+`"}`)
|
|
},
|
|
assert: func(t *testing.T, v apiResponse, err error) {
|
|
require.NoError(t, err)
|
|
assert.Len(t, v.Name, 4*jsonBodySniffLen)
|
|
},
|
|
},
|
|
{
|
|
name: "HTMLContentType",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = io.WriteString(w, htmlBody)
|
|
},
|
|
assert: func(t *testing.T, _ apiResponse, err error) {
|
|
sdkErr := assertInvalidBody(t, err, "text/html; charset=utf-8")
|
|
assert.Contains(t, sdkErr.Message, "HTML response instead of JSON")
|
|
assert.Contains(t, sdkErr.Helper, "/api/v2")
|
|
assert.NotContains(t, sdkErr.Detail, "<!DOCTYPE html>")
|
|
},
|
|
},
|
|
{
|
|
name: "HTMLMislabeledAsJSON",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", jsonCT)
|
|
_, _ = io.WriteString(w, "\n\t "+htmlBody)
|
|
},
|
|
assert: func(t *testing.T, _ apiResponse, err error) {
|
|
sdkErr := assertInvalidBody(t, err, jsonCT)
|
|
assert.Contains(t, sdkErr.Message, "HTML response instead of JSON")
|
|
assert.Contains(t, sdkErr.Helper, "/api/v2")
|
|
},
|
|
},
|
|
{
|
|
name: "ValidJSONMislabeledAsHTML",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
// An HTML content type is authoritative: Coder API
|
|
// endpoints never serve HTML, so the body is not
|
|
// decoded even when it happens to be valid JSON.
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = io.WriteString(w, `{"name":"hello"}`)
|
|
},
|
|
assert: func(t *testing.T, _ apiResponse, err error) {
|
|
sdkErr := assertInvalidBody(t, err, "text/html; charset=utf-8")
|
|
assert.Contains(t, sdkErr.Message, "HTML response instead of JSON")
|
|
},
|
|
},
|
|
{
|
|
name: "XMLBody",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
// Markup bodies such as XML error pages from load
|
|
// balancers are reported the same way as HTML.
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = io.WriteString(w, `<?xml version="1.0"?><error>denied</error>`)
|
|
},
|
|
assert: func(t *testing.T, _ apiResponse, err error) {
|
|
sdkErr := assertInvalidBody(t, err, "application/xml")
|
|
assert.Contains(t, sdkErr.Message, "HTML response instead of JSON")
|
|
assert.NotContains(t, sdkErr.Detail, "<?xml")
|
|
},
|
|
},
|
|
{
|
|
name: "HTMLWithByteOrderMark",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", jsonCT)
|
|
_, _ = io.WriteString(w, "\xef\xbb\xbf"+htmlBody)
|
|
},
|
|
assert: func(t *testing.T, _ apiResponse, err error) {
|
|
sdkErr := assertInvalidBody(t, err, jsonCT)
|
|
assert.Contains(t, sdkErr.Message, "HTML response instead of JSON")
|
|
},
|
|
},
|
|
{
|
|
name: "MalformedJSON",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", jsonCT)
|
|
_, _ = io.WriteString(w, `{"name": "hello"`)
|
|
},
|
|
assert: func(t *testing.T, _ apiResponse, err error) {
|
|
sdkErr := assertInvalidBody(t, err, jsonCT)
|
|
assert.Contains(t, sdkErr.Message, "invalid JSON response")
|
|
assert.NotContains(t, sdkErr.Detail, `{"name": "hello"`)
|
|
assert.Error(t, errors.Unwrap(sdkErr))
|
|
},
|
|
},
|
|
{
|
|
name: "PlainTextBody",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = io.WriteString(w, "upstream connect error")
|
|
},
|
|
assert: func(t *testing.T, _ apiResponse, err error) {
|
|
sdkErr := assertInvalidBody(t, err, "text/plain; charset=utf-8")
|
|
assert.Contains(t, sdkErr.Message, "invalid JSON response")
|
|
assert.NotContains(t, sdkErr.Detail, "upstream connect error")
|
|
},
|
|
},
|
|
{
|
|
name: "EmptyBody",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", jsonCT)
|
|
},
|
|
assert: func(t *testing.T, _ apiResponse, err error) {
|
|
sdkErr := assertInvalidBody(t, err, jsonCT)
|
|
assert.Contains(t, sdkErr.Message, "empty response")
|
|
},
|
|
},
|
|
{
|
|
name: "BodyExcerptOmitted",
|
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = io.WriteString(w, "<html>secret-token")
|
|
},
|
|
assert: func(t *testing.T, _ apiResponse, err error) {
|
|
sdkErr := assertInvalidBody(t, err, "text/html")
|
|
assert.NotContains(t, sdkErr.Detail, "secret-token")
|
|
},
|
|
},
|
|
{
|
|
name: "RedirectToHTML",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/login" {
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = io.WriteString(w, htmlBody)
|
|
},
|
|
assert: func(t *testing.T, _ apiResponse, err error) {
|
|
sdkErr := assertInvalidBody(t, err, "text/html; charset=utf-8")
|
|
assert.Contains(t, sdkErr.Message, "HTML response instead of JSON")
|
|
// The error reports the final URL after redirects and
|
|
// mentions the originally requested URL.
|
|
assert.Contains(t, sdkErr.URL(), "/login")
|
|
assert.Contains(t, sdkErr.Detail, "after following redirects from")
|
|
assert.Contains(t, sdkErr.Detail, "/api/v2/users/me")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, c := range tests {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(c.handler)
|
|
defer srv.Close()
|
|
|
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL+"/api/v2/users/me", nil)
|
|
require.NoError(t, err)
|
|
res, err := srv.Client().Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
|
|
var v apiResponse
|
|
decodeErr := ReadBodyAsJSON(res, &v)
|
|
c.assert(t, v, decodeErr)
|
|
})
|
|
}
|
|
|
|
t.Run("NilResponse", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var v apiResponse
|
|
require.Error(t, ReadBodyAsJSON(nil, &v))
|
|
})
|
|
|
|
//nolint:bodyclose // The response is constructed, not from a client.
|
|
t.Run("CompleteShortJSONDoesNotReadAhead", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
errReadAhead := xerrors.New("unexpected read after JSON value")
|
|
body := io.MultiReader(
|
|
strings.NewReader(`{"name":"hello"}`),
|
|
iotest.ErrReader(errReadAhead),
|
|
)
|
|
res := newResponse(http.StatusOK, jsonCT, body)
|
|
|
|
var v apiResponse
|
|
require.NoError(t, ReadBodyAsJSON(res, &v))
|
|
require.Equal(t, "hello", v.Name)
|
|
})
|
|
|
|
//nolint:bodyclose // The response is constructed, not from a client.
|
|
t.Run("CustomUnmarshalError", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var v struct {
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
res := newResponse(http.StatusOK, jsonCT, `{"created_at":"invalid"}`)
|
|
requestURL, err := url.Parse("https://coder.example.com/api/v2/users/me")
|
|
require.NoError(t, err)
|
|
res.Request = &http.Request{Method: http.MethodGet, URL: requestURL}
|
|
err = ReadBodyAsJSON(res, &v)
|
|
sdkErr := assertInvalidBody(t, err, jsonCT)
|
|
require.Contains(t, sdkErr.Detail, "cannot parse")
|
|
require.Error(t, errors.Unwrap(sdkErr))
|
|
require.NotContains(t, err.Error(), "read response body")
|
|
})
|
|
|
|
//nolint:bodyclose // The response is constructed, not from a client.
|
|
t.Run("BodyReadError", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A transport failure while streaming the body past the sniff
|
|
// window must surface as a read error, not as an invalid API
|
|
// response.
|
|
errTransport := xerrors.New("connection reset by peer")
|
|
body := io.MultiReader(
|
|
strings.NewReader(`{"name":"`+strings.Repeat("a", 2*jsonBodySniffLen)),
|
|
iotest.ErrReader(errTransport),
|
|
)
|
|
res := newResponse(http.StatusOK, jsonCT, body)
|
|
|
|
var v apiResponse
|
|
err := ReadBodyAsJSON(res, &v)
|
|
require.ErrorIs(t, err, errTransport)
|
|
require.ErrorContains(t, err, "read response body")
|
|
require.NotContains(t, err.Error(), "invalid API response")
|
|
})
|
|
|
|
//nolint:bodyclose // The response is constructed, not from a client.
|
|
t.Run("UseNumber", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var v map[string]any
|
|
res := newResponse(http.StatusOK, jsonCT, `{"exp":1750000000}`)
|
|
require.NoError(t, ReadBodyAsJSONUseNumber(res, &v))
|
|
num, ok := v["exp"].(json.Number)
|
|
require.True(t, ok, "expected json.Number, got %T", v["exp"])
|
|
require.Equal(t, "1750000000", num.String())
|
|
|
|
// ReadBodyAsJSON decodes the same body's numbers as float64.
|
|
res = newResponse(http.StatusOK, jsonCT, `{"exp":1750000000}`)
|
|
require.NoError(t, ReadBodyAsJSON(res, &v))
|
|
_, ok = v["exp"].(float64)
|
|
require.True(t, ok, "expected float64, got %T", v["exp"])
|
|
})
|
|
}
|
|
|
|
func assertSDKError(t *testing.T, err error) *Error {
|
|
t.Helper()
|
|
|
|
var sdkErr *Error
|
|
require.Error(t, err)
|
|
require.True(t, xerrors.As(err, &sdkErr))
|
|
|
|
return sdkErr
|
|
}
|
|
|
|
func newResponse(status int, contentType string, body interface{}) *http.Response {
|
|
var r io.ReadCloser
|
|
switch v := body.(type) {
|
|
case string:
|
|
r = io.NopCloser(strings.NewReader(v))
|
|
case []byte:
|
|
r = io.NopCloser(bytes.NewReader(v))
|
|
case io.ReadCloser:
|
|
r = v
|
|
case io.Reader:
|
|
r = io.NopCloser(v)
|
|
default:
|
|
panic(fmt.Sprintf("unknown body type: %T", body))
|
|
}
|
|
|
|
return &http.Response{
|
|
Status: http.StatusText(status),
|
|
StatusCode: status,
|
|
Header: http.Header{
|
|
"Content-Type": []string{contentType},
|
|
},
|
|
Body: r,
|
|
}
|
|
}
|
|
|
|
func marshal(res any) string {
|
|
b, err := json.Marshal(res)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return string(b)
|
|
}
|