mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
## Stack context This is the base of a 3-PR stack promoting the chat API from `/api/experimental` to `/api/v2`: server compatibility mounts (this PR), codersdk promotion (#28497), and frontend path updates (#28498). ## Summary Double-mount the stable chat and MCP handlers under `/api/v2` while retaining the existing experimental routes for the one-release compatibility window decided in CODAGT-921. CODAGT-922 tracks removing the compatibility mounts. The shared route builders preserve existing authentication and middleware behavior. Experiment-gated, debug, tombstone, and legacy default-organization model routes remain experimental-only. Signed file URLs, external OAuth callback URLs, and mixed-version replica relays also remain on the experimental prefix during the transition. Update Swagger and the generated API reference for the promoted routes, including the workspace lookup and a runnable raw-body chat file upload example. Retain internal endpoints outside the published reference, share chat-file rate limits across both prefixes, enable CORS for the v2 MCP routes, and cover dual mounts plus exclusions with compatibility tests. Remote dogfood UAT passed for the promoted chat, model, MCP, and file flows. > [!NOTE] > Xum acted on Mike's behalf in this pull request. <!-- xum-attribution: model=claude-fable-5 thinking=high -->
433 lines
12 KiB
Go
433 lines
12 KiB
Go
package httpmw_test
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func randRemoteAddr() string {
|
|
var b [4]byte
|
|
// nolint:gosec
|
|
_, _ = rand.Read(b[:])
|
|
// nolint:gosec
|
|
return fmt.Sprintf("%s:%v", net.IP(b[:]).String(), rand.Int31()%(1<<16))
|
|
}
|
|
|
|
func TestRateLimit(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("NoUserSucceeds", func(t *testing.T) {
|
|
t.Parallel()
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.RateLimit(1, time.Second))
|
|
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
for i := 0; i < 5; i++ {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, i != 0, resp.StatusCode == http.StatusTooManyRequests)
|
|
}
|
|
})
|
|
|
|
t.Run("PathNormalizationBypass", func(t *testing.T) {
|
|
t.Parallel()
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.RateLimit(1, time.Second))
|
|
// A wildcard route so that requests for both the canonical path and
|
|
// its redundant-slash variants reach the same handler, mirroring
|
|
// how chi's router resolves /api/v2/users//validate-password to the
|
|
// same handler as /api/v2/users/validate-password in production.
|
|
rtr.Post("/*", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
remoteAddr := randRemoteAddr()
|
|
paths := []string{
|
|
"/api/v2/users/validate-password",
|
|
"/api/v2/users//validate-password",
|
|
"/api/v2/users///validate-password",
|
|
"/api/v2/users/validate-password",
|
|
}
|
|
for i, p := range paths {
|
|
req := httptest.NewRequest("POST", p, nil)
|
|
req.RemoteAddr = remoteAddr
|
|
rec := httptest.NewRecorder()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, i != 0, resp.StatusCode == http.StatusTooManyRequests, "request %d (%s)", i, p)
|
|
}
|
|
})
|
|
|
|
t.Run("DifferentAPIPrefixes", func(t *testing.T) {
|
|
t.Parallel()
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.RateLimit(1, time.Second))
|
|
rtr.Get("/*", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
remoteAddr := randRemoteAddr()
|
|
for _, p := range []string{
|
|
"/api/v2/chats/providers",
|
|
"/api/experimental/chats/providers",
|
|
} {
|
|
req := httptest.NewRequest("GET", p, nil)
|
|
req.RemoteAddr = remoteAddr
|
|
rec := httptest.NewRecorder()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, p)
|
|
}
|
|
})
|
|
|
|
t.Run("RandomIPs", func(t *testing.T) {
|
|
t.Parallel()
|
|
rtr := chi.NewRouter()
|
|
// Because these are random IPs, the limit should never be hit!
|
|
rtr.Use(httpmw.RateLimit(1, time.Second))
|
|
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
for i := 0; i < 5; i++ {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
req.RemoteAddr = randRemoteAddr()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
require.False(t, resp.StatusCode == http.StatusTooManyRequests)
|
|
}
|
|
})
|
|
|
|
t.Run("RegularUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, _ := dbtestutil.NewDB(t)
|
|
u := dbgen.User(t, db, database.User{})
|
|
_, key := dbgen.APIKey(t, db, database.APIKey{UserID: u.ID})
|
|
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
|
DB: db,
|
|
Optional: false,
|
|
}))
|
|
|
|
rtr.Use(httpmw.RateLimit(1, time.Second))
|
|
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Bypass must fail
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set(codersdk.SessionTokenHeader, key)
|
|
req.Header.Set(codersdk.BypassRatelimitHeader, "true")
|
|
rec := httptest.NewRecorder()
|
|
// Assert we're not using IP address.
|
|
req.RemoteAddr = randRemoteAddr()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusPreconditionRequired, resp.StatusCode)
|
|
|
|
for i := 0; i < 5; i++ {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set(codersdk.SessionTokenHeader, key)
|
|
rec := httptest.NewRecorder()
|
|
// Assert we're not using IP address.
|
|
req.RemoteAddr = randRemoteAddr()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, i != 0, resp.StatusCode == http.StatusTooManyRequests)
|
|
}
|
|
})
|
|
|
|
t.Run("OwnerBypass", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, _ := dbtestutil.NewDB(t)
|
|
|
|
u := dbgen.User(t, db, database.User{
|
|
RBACRoles: []string{codersdk.RoleOwner},
|
|
})
|
|
_, key := dbgen.APIKey(t, db, database.APIKey{UserID: u.ID})
|
|
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
|
DB: db,
|
|
Optional: false,
|
|
}))
|
|
|
|
rtr.Use(httpmw.RateLimit(1, time.Second))
|
|
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
for i := 0; i < 5; i++ {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set(codersdk.SessionTokenHeader, key)
|
|
req.Header.Set(codersdk.BypassRatelimitHeader, "true")
|
|
rec := httptest.NewRecorder()
|
|
// Assert we're not using IP address.
|
|
req.RemoteAddr = randRemoteAddr()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
require.False(t, resp.StatusCode == http.StatusTooManyRequests)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRateLimitByAPICompatibilityEndpoint(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.RateLimitByAPICompatibilityEndpoint(1, time.Second))
|
|
rtr.Get("/*", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
remoteAddr := randRemoteAddr()
|
|
for i, p := range []string{
|
|
"/api/v2/chats/files/00000000-0000-0000-0000-000000000000",
|
|
"/api/experimental/chats/files/00000000-0000-0000-0000-000000000000",
|
|
} {
|
|
req := httptest.NewRequest("GET", p, nil)
|
|
req.RemoteAddr = remoteAddr
|
|
rec := httptest.NewRecorder()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, i != 0, resp.StatusCode == http.StatusTooManyRequests, p)
|
|
}
|
|
}
|
|
|
|
func TestRateLimitByAuthToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("LimitsByAuthHeader", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
headerName string
|
|
headerVal string
|
|
}{
|
|
{
|
|
name: "BearerToken",
|
|
headerName: "Authorization",
|
|
headerVal: "Bearer test-token-123",
|
|
},
|
|
{
|
|
name: "XApiKey",
|
|
headerName: "X-Api-Key",
|
|
headerVal: "test-api-key-456",
|
|
},
|
|
{
|
|
name: "NoToken",
|
|
headerName: "",
|
|
headerVal: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.RateLimitByAuthToken(2, time.Hour))
|
|
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Same token (or IP if no token) should be rate limited after 2 requests.
|
|
for i := 0; i < 5; i++ {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
if tt.headerName != "" {
|
|
req.Header.Set(tt.headerName, tt.headerVal)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
if i < 2 {
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "request %d should succeed", i)
|
|
} else {
|
|
require.Equal(t, http.StatusTooManyRequests, resp.StatusCode, "request %d should be rate limited", i)
|
|
// Verify Retry-After header is set.
|
|
require.NotEmpty(t, resp.Header.Get("Retry-After"), "Retry-After header should be set")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("DifferentTokensNotLimited", func(t *testing.T) {
|
|
t.Parallel()
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.RateLimitByAuthToken(1, time.Hour))
|
|
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Different tokens should not be rate limited against each other.
|
|
for i := 0; i < 5; i++ {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer token-%d", i))
|
|
rec := httptest.NewRecorder()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "request %d should succeed", i)
|
|
}
|
|
})
|
|
|
|
t.Run("DisabledWhenZero", func(t *testing.T) {
|
|
t.Parallel()
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.RateLimitByAuthToken(0, time.Hour))
|
|
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Should not be rate limited when limit is 0.
|
|
for i := 0; i < 10; i++ {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("Authorization", "Bearer same-token")
|
|
rec := httptest.NewRecorder()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestConcurrencyLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("LimitsConcurrentRequests", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const maxConcurrency = 2
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.ConcurrencyLimit(maxConcurrency, "Test"))
|
|
|
|
// Use a WaitGroup as a barrier to ensure all requests are in the handler
|
|
// before any of them proceed.
|
|
var handlersReady sync.WaitGroup
|
|
handlersReady.Add(maxConcurrency)
|
|
releaseHandler := make(chan struct{})
|
|
|
|
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
|
handlersReady.Done()
|
|
// Wait until released.
|
|
<-releaseHandler
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
server := httptest.NewServer(rtr)
|
|
defer server.Close()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Start maxConcurrency requests that will block.
|
|
// We use channels to collect errors instead of require in goroutines.
|
|
type result struct {
|
|
statusCode int
|
|
err error
|
|
}
|
|
results := make(chan result, maxConcurrency)
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < maxConcurrency; i++ {
|
|
wg.Go(func() {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/", nil)
|
|
if err != nil {
|
|
results <- result{err: err}
|
|
return
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
results <- result{err: err}
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
results <- result{statusCode: resp.StatusCode}
|
|
})
|
|
}
|
|
|
|
// Wait for all requests to enter the handler with a timeout.
|
|
handlersReadyCh := make(chan struct{})
|
|
go func() {
|
|
handlersReady.Wait()
|
|
close(handlersReadyCh)
|
|
}()
|
|
select {
|
|
case <-handlersReadyCh:
|
|
case <-ctx.Done():
|
|
t.Fatal("timed out waiting for handlers to be ready")
|
|
}
|
|
|
|
// Next request should be rejected since we're at capacity.
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/", nil)
|
|
require.NoError(t, err)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
|
|
|
// Release all blocked requests.
|
|
close(releaseHandler)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
// Check all goroutine results.
|
|
for res := range results {
|
|
require.NoError(t, res.err)
|
|
require.Equal(t, http.StatusOK, res.statusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("DisabledWhenZero", func(t *testing.T) {
|
|
t.Parallel()
|
|
rtr := chi.NewRouter()
|
|
rtr.Use(httpmw.ConcurrencyLimit(0, "Test"))
|
|
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Should not be limited when maxConcurrency is 0.
|
|
for i := 0; i < 10; i++ {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
rtr.ServeHTTP(rec, req)
|
|
resp := rec.Result()
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
}
|
|
})
|
|
}
|