Score the boundary against wrong credentials, not just right ones (V-673)
Every shape of wrong credential gets a case: no header, wrong token, a prefix of the token, the token with no scheme, and Basic. Plus the two the allowlist exists for, /slots and its save action, and the caps. The readiness test now posts to /v1/chat/completions. The allowlist sits in front of the readiness check and answers 405 to a method mavgpud never serves, so the old GET measured the allowlist rather than the 503. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ok is what the boundary is protecting: anything that reaches it has spent
|
||||
// the card.
|
||||
func ok(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }
|
||||
|
||||
func TestRequireTokenRefusesEveryWrongCredential(t *testing.T) {
|
||||
h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok))
|
||||
cases := []struct {
|
||||
name string
|
||||
auth string
|
||||
want int
|
||||
}{
|
||||
{"no header", "", http.StatusUnauthorized},
|
||||
{"wrong token", "Bearer wrong", http.StatusUnauthorized},
|
||||
{"prefix of the token", "Bearer s3cre", http.StatusUnauthorized},
|
||||
{"token with no scheme", "s3cret", http.StatusUnauthorized},
|
||||
{"basic auth", "Basic czNjcmV0", http.StatusUnauthorized},
|
||||
{"right token", "Bearer s3cret", http.StatusTeapot},
|
||||
{"scheme is case-insensitive", "bearer s3cret", http.StatusTeapot},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
if tc.auth != "" {
|
||||
r.Header.Set("Authorization", tc.auth)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != tc.want {
|
||||
t.Errorf("status %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The 401 must not say which part was wrong. A probe that can tell a malformed
|
||||
// header from a wrong token learns the header shape for free.
|
||||
func TestUnauthorizedSaysNothingUseful(t *testing.T) {
|
||||
h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if got := strings.TrimSpace(w.Body.String()); got != "unauthorized" {
|
||||
t.Errorf("body %q, want %q", got, "unauthorized")
|
||||
}
|
||||
if got := w.Header().Get("WWW-Authenticate"); got != "Bearer" {
|
||||
t.Errorf("WWW-Authenticate %q, want Bearer", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The body cap applies to an authenticated request. An unauthenticated one
|
||||
// never gets far enough to allocate anything.
|
||||
func TestRequireTokenCapsTheBody(t *testing.T) {
|
||||
var read error
|
||||
h := requireToken("t", 8, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
buf := make([]byte, 64)
|
||||
for read == nil {
|
||||
if _, read = r.Body.Read(buf); read != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}))
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(strings.Repeat("x", 4096)))
|
||||
r.Header.Set("Authorization", "Bearer t")
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
if read == nil || !strings.Contains(read.Error(), "too large") {
|
||||
t.Errorf("read error %v, want the body cap", read)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlistRefusesWhatMavenDoesNotCall(t *testing.T) {
|
||||
h := allowlist(http.HandlerFunc(ok))
|
||||
cases := []struct {
|
||||
method, path string
|
||||
want int
|
||||
}{
|
||||
{http.MethodPost, "/v1/chat/completions", http.StatusTeapot},
|
||||
{http.MethodGet, "/v1/models", http.StatusTeapot},
|
||||
// /slots returns the prompts of whoever else is using the card, and
|
||||
// its actions write files the request names.
|
||||
{http.MethodGet, "/slots", http.StatusNotFound},
|
||||
{http.MethodPost, "/slots/0?action=save", http.StatusNotFound},
|
||||
{http.MethodGet, "/", http.StatusNotFound},
|
||||
{http.MethodGet, "/v1/chat/completions", http.StatusMethodNotAllowed},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
|
||||
if w.Code != tc.want {
|
||||
t.Errorf("status %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitInflightCapsConcurrency(t *testing.T) {
|
||||
const cap = 2
|
||||
var mu sync.Mutex
|
||||
now, peak := 0, 0
|
||||
release := make(chan struct{})
|
||||
h := limitInflight(cap, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
now++
|
||||
if now > peak {
|
||||
peak = now
|
||||
}
|
||||
mu.Unlock()
|
||||
<-release
|
||||
mu.Lock()
|
||||
now--
|
||||
mu.Unlock()
|
||||
}))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil))
|
||||
}()
|
||||
}
|
||||
// Let the first wave arrive, then drain. The assertion is the peak, and a
|
||||
// peak that never reached the cap still cannot exceed it.
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if peak > cap {
|
||||
t.Errorf("%d requests in flight at once, cap is %d", peak, cap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackListen(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
":8080": false, // the shipped default, and the whole LAN
|
||||
"0.0.0.0:8080": false,
|
||||
"[::]:8080": false,
|
||||
"192.168.1.105:8080": false,
|
||||
"127.0.0.1:8080": true,
|
||||
"[::1]:8080": true,
|
||||
"localhost:8080": true,
|
||||
}
|
||||
for addr, want := range cases {
|
||||
if got := loopbackListen(addr); got != want {
|
||||
t.Errorf("loopbackListen(%q) = %v, want %v", addr, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
good := filepath.Join(dir, "tok")
|
||||
if err := os.WriteFile(good, []byte(" abc123\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readToken(good)
|
||||
if err != nil || got != "abc123" {
|
||||
t.Errorf("readToken = %q, %v; want abc123", got, err)
|
||||
}
|
||||
|
||||
blank := filepath.Join(dir, "blank")
|
||||
if err := os.WriteFile(blank, []byte("\n\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readToken(blank); err == nil {
|
||||
t.Error("an empty token file is not a token")
|
||||
}
|
||||
if _, err := readToken(filepath.Join(dir, "absent")); err == nil {
|
||||
t.Error("a missing token file is not a token")
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,11 @@ func TestHealthAndProxyRefuseWhenNotReady(t *testing.T) {
|
||||
s := &supervisor{run: newRunner("fake", "/bin/true", nil, "")}
|
||||
h := s.handler(mustURL(t, "http://127.0.0.1:1"))
|
||||
|
||||
for _, path := range []string{"/health", "/v1/chat/completions"} {
|
||||
// The completion is a POST because the allowlist is in front of the
|
||||
// readiness check now, and it answers 405 to a method it never serves.
|
||||
for path, method := range map[string]string{"/health": http.MethodGet, "/v1/chat/completions": http.MethodPost} {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
h.ServeHTTP(w, httptest.NewRequest(method, path, nil))
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("%s with no model: got %d, want 503", path, w.Code)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user