merge: sync Rongsong's scheduler-framework (c5b172c7..3442a57d, 781 commits)

Merge origin/scheduler-framework into merge-sf-20260903 (97 commits on
ours: bug-1490/1494/1498/1509/1513/1521/1522 fixes, PEND-reason P1,
scheduler-agent Phase 2, bug-924 owner-pid shim).

Bookkeeping excluded per policy: .kiro/, .wolf/, CHANGES.md, reports/
(92 files dropped); .claude/ + CLAUDE.md keep ours.

Key theirs-side changes absorbed:
- bug-1938/1960: QUEUE_STAT_CLOSED no longer gates dispatch (closed
  queue refuses new submissions only; pending jobs still run) --
  supersedes our QUEUE_CLOSED preason on the dispatch path;
  sched_queue_status_reason drops that branch
- bug-1846: config push now stashes and applies between scheduling
  rounds (sched_stash_config/sched_apply_pending_config), applied at
  the same main-thread round boundary that preserves bug-1513
  serialization
- bug-1760: sched_check_pending_rejoin exposed +
  xcecli_is_connection_broken public predicate
- bug-1936: execuser/exechome/execcwd/exdata carried on submission
  (superset of our bug-1500 execuser fallback; our job-typed
  resolveExecUser removed as dead code)
- bug-1945~1962: apiserver auth hardening (HS256 pinning, default-
  secret rejection, logout revocation, bcrypt, RBAC) -- our API-key
  branch folded into controllers.ParseTokenClaims so both coexist
- bug-1736: rawRunUsage job run-usage fields in the Go model
- xce_test relocated src/controller/testsuite/test.c ->
  src/harness/test.c; controller suite split into
  controller_test.c + fixtures; their clitest fixtures spawn xce_test
  mid-suite under `meson test -j`

Ours preserved across the merge:
- PEND-reason P1 (codes 1-9, preason.h, job_preason machinery) --
  gate re-ported into their relocated allocator (sched_election.c)
  after plugin_job_election, with attach on the allocator thread
  only (bug-1521 race discipline)
- PEND-reason P0 main_reason semantics RECONCILED to theirs: the
  merge initially re-carried our first-detail-add-promotes-main_reason
  fallback in add_job_preason (4e2ac86), but their whole trace/verify
  stack treats "detail-only => main_reason 0" as a contract --
  sched_trace_capture counts main_reason as its own occurrence
  ("Expected 2 Was 3" in test_trace_capture), the outcome renderer
  prints "main reason: none" for detail-only PENDs (test_repl_trace_e2e),
  and the committed .replay/.sim_verify baselines encode main_reason=0
  (test_repl_trace_baseline byte compare, test_trace_equivalence
  off-vs-traced). The fallback is dropped from the shared lib;
  the user-visible "overall reason" survives as an API-layer view:
  rawJobToModel (convert_pure.go) promotes the first detail row to
  main_reason_name when main_reason is 0 (main_reason VALUE marshals
  unchanged). add_job_preason keeps only the NULL-reasons guard
- bug-1498: sched_job_is_ready rejects non-PEND/suspended jobs
- bug-1490/1781: xce_jobctrl_t.action as capnp union discriminant
  (their jobCtrlUnionDiscriminant, same semantics)
- xcecli typed PendingReason field (53c00834), bug-1522 reconnect
  resend, xcecli_force_reconnect + their is_connection_broken both
  exported in client.h/libclient
- t1/t2/t3 test-node hostconfig in src/configs/hosts.yaml (bug-1494),
  /xce/v1/events + /xce/v1/apikeys whitelist routes (agent Phase 2)
- lib ASAN_OPTIONS=detect_leaks=0 test env + their oom-test block
- drop-audit: our bug-1504 fix covered by their bug-1680 (no action)

bug-924 follow-up (replaces the 67f3f6ad scratch-dir wipe): the
take-theirs audit's one "drop" was re-evaluated and REVERTED instead of
re-applied -- a global `find /tmp -name 'xce_*' -exec rm -rf` at suite
start/exit races the LIVE /tmp/xce_* fixtures of sibling suites under
`meson test -j` (harness_unit_test's clitest fixtures spawn xce_test as
a child mid-suite; the wipe destroyed the parent's fixtures ->
test_test_cli failures). Coverage instead:
- mkdtemp_clean shim gains mkdtemp_clean_mark/sweep_since
  (testsuite_support/mkdtemp_clean.[ch])
- new case_scratch_sweep.[ch]: aggregate runners (controller_test.c,
  harness_test.c -- hundreds of cases per process) call
  case_scratch_sweep_begin/_end_ok from setUp/tearDown; a PASSED case's
  dirs are swept immediately in tearDown (after engine reaping), FAILED
  cases keep their dirs for post-mortem; XCE_MKDTEMP_KEEP_ALL=1
  disables. One full controller_unit_test run was observed to leak 12G+
  to ENOSPC without this, cascading into unrelated "Expected Non-NULL"
  failures
- harness_unit_test_deps links the shim library as well

Test reconciliations on adopt:
- core-ready-gates: sched_queue_is_ready now gates only
  INACT_ADMIN|INACT_WINDOW (bug-1938/1960); CLOSED queues elect --
  covered end-to-end by test_queue_admin_control.c
- test_worker_resync: the 4 restart-replay RUN assertions predate the
  bug-1509 B3 marking (8858afed) landing on the same lineage; replayed
  RUN jobs now assert RUN|UNKWN|TRANSIENT (TWR_ASSERT_REPLAYED_RUN);
  terminal DONE/EXIT assertions unchanged
- convert_pure.go: our job-typed resolveExecUser deleted (superseded
  by their bug-1780/1936 two-arg version)
- test_preason: the 3 P0-promotion assertions reverted to the
  detail-keeps-main-0 contract; new tests cover limit-code detail
  rows and explicit-main-wins-over-later-details; apiserver
  convert_test.go gains the detail-only name-promotion cases

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-14 05:17:03 +00:00
commit 0c8607345e
663 changed files with 139848 additions and 13614 deletions

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"sync"
"testing"
"time"
@ -21,8 +22,8 @@ type AuthResponse struct {
}
// login
func login(username, password string) (string, error) {
const loginURL = "http://localhost:8080/xce/v1/auth/logon" //replace with your login url
func login(baseURL, username, password string) (string, error) {
loginURL := baseURL + "/xce/v1/auth/logon"
// create login request json data
loginData := map[string]string{
@ -61,13 +62,20 @@ func login(username, password string) (string, error) {
}
// TestLoadJobs test function
// TestLoadJobs is a manual load test against a running API server; it is
// skipped unless XCE_LOAD_TEST_URL (e.g. http://localhost:8080) is set, with
// the credentials in XCE_LOAD_TEST_USER / XCE_LOAD_TEST_PASSWORD.
func TestLoadJobs(t *testing.T) {
baseURL := os.Getenv("XCE_LOAD_TEST_URL")
if baseURL == "" {
t.Skip("load test: set XCE_LOAD_TEST_URL (and XCE_LOAD_TEST_USER/XCE_LOAD_TEST_PASSWORD) to run it against a live API server")
}
const numRequests = 100
const username = "user01" // replace with your username
const password = "Welcome@1212" // replace with your password
username := os.Getenv("XCE_LOAD_TEST_USER")
password := os.Getenv("XCE_LOAD_TEST_PASSWORD")
fmt.Println("before login")
// login to get token
token, err := login(username, password)
token, err := login(baseURL, username, password)
if err != nil {
t.Fatalf("Failed to log in: %v", err)
}
@ -81,7 +89,7 @@ func TestLoadJobs(t *testing.T) {
for i := 0; i < numRequests; i++ {
go func() {
defer wg.Done()
req, err := http.NewRequest("GET", "http://localhost:8080/xce/v1/jobs", nil) // replace with your actual url
req, err := http.NewRequest("GET", baseURL+"/xce/v1/jobs", nil)
if err != nil {
t.Error("Error creating request:", err)
return

View file

@ -1,6 +1,7 @@
package middleware
import (
"apiserver/backend/auth"
"apiserver/utils"
logger "apiserver/utils"
"fmt"
@ -14,6 +15,16 @@ import (
"go.uber.org/zap"
)
// rootWarning is the start-up warning for running as uid under the given
// auth provider, or "" when there is nothing to warn about: only the PAM
// provider needs root (bug-1875: this warned for every provider).
func rootWarning(uid, provider string) string {
if uid == "0" || provider != "pam" {
return ""
}
return "Not running as root — PAM authentication will not work. OK for dev/testing."
}
// openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout server.key -out server.crt -config openssl.cnf -extensions v3_req
func StartServer(r *gin.Engine) error {
logger.Debug("Enter StartServer() function")
@ -24,8 +35,12 @@ func StartServer(r *gin.Engine) error {
logger.Error("Failed to get current user:", zap.Error(err))
os.Exit(1)
}
if currentUser.Uid != "0" {
logger.Warn("Not running as root — PAM authentication will not work. OK for dev/testing.")
provider := ""
if auth.GlobalProvider != nil {
provider = auth.GlobalProvider.Name()
}
if msg := rootWarning(currentUser.Uid, provider); msg != "" {
logger.Warn(msg)
}
}

View file

@ -0,0 +1,25 @@
package middleware
import "testing"
// bug-1875: the apiserver warned "Not running as root — PAM authentication
// will not work" on every non-root start, even with the default
// provider: config_file, where PAM is never used.
func TestRootWarning(t *testing.T) {
cases := []struct {
uid, provider string
warn bool
}{
{"1000", "pam", true},
{"0", "pam", false},
{"1000", "config_file", false},
{"1000", "ldap", false},
{"1000", "", false},
}
for _, c := range cases {
got := rootWarning(c.uid, c.provider)
if (got != "") != c.warn {
t.Errorf("rootWarning(%q, %q) = %q, want warning=%v", c.uid, c.provider, got, c.warn)
}
}
}

View file

@ -5,7 +5,6 @@ import (
"apiserver/controllers"
"apiserver/models"
"apiserver/store"
"apiserver/utils"
logger "apiserver/utils"
"bytes"
"encoding/json"
@ -15,7 +14,6 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
"go.uber.org/zap"
)
@ -37,6 +35,7 @@ func ValidateHandler() gin.HandlerFunc {
if err != nil || !isValidBody {
controllers.ReturnError(ctx, common.INVALID_PARAMS_CODE,
common.ERROR_READ_REQUEST_BODY_MSG+": "+err.Error(), nil)
ctx.Abort()
return
}
}
@ -47,23 +46,30 @@ func ValidateHandler() gin.HandlerFunc {
}
}
func isExcludedPath(path string) bool {
// jwtExcludedPaths are the routes that skip JWT validation. An entry is
// matched exactly, unless its last segment is a gin catch-all ("*name", as in
// the route "/swagger/*any"): then any path under the part before the "*"
// matches (bug-1962).
var jwtExcludedPaths = []string{
"/ping",
"/xce/v1/auth/logon",
"/swagger/*any",
"/xce/v1/auth/logout",
}
allowed := false
// define some excluded paths
excludedPaths := []string{
"/ping",
"/xce/v1/auth/logon",
"/swagger/*any",
"/xce/v1/auth/logout",
}
for _, excludedPath := range excludedPaths {
if strings.HasPrefix(path, excludedPath[:len(excludedPath)-1]) { // 去掉最后的 *
allowed = true
break
func isExcludedPath(path string) bool {
for _, excludedPath := range jwtExcludedPaths {
if i := strings.LastIndex(excludedPath, "/*"); i >= 0 {
if strings.HasPrefix(path, excludedPath[:i+1]) {
return true
}
continue
}
if path == excludedPath {
return true
}
}
return allowed
return false
}
// Token validation middleware
@ -99,29 +105,25 @@ func validateHeader() gin.HandlerFunc {
}
}
// isValidToken verifies the token (signature, HS256 only, expiry, not
// revoked by logout) and its IP binding.
func isValidToken(tokenString string, IP string) (bool, error) {
// Service-account API keys are a distinct credential type from JWTs and
// carry no IP binding (they're meant for long-lived automated clients
// like the scheduler-agent MCP server, which may not have a stable IP).
// API keys also bypass logout-revocation (they outlive JWT sessions).
if strings.HasPrefix(tokenString, models.APIKeyPrefix) {
_, ok := store.ValidateAPIKey(tokenString)
return ok, nil
}
token, err := jwt.ParseWithClaims(tokenString, &models.JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(utils.AuthenticationYamlConfig.Token.SecretKey), nil
})
// Their hardening (bug-1945/1946/1950): HS256 pinned, default-secret
// rejected, logout revocation — all centralized in ParseTokenClaims.
claims, err := controllers.ParseTokenClaims(tokenString)
if err != nil {
return false, err
}
if claims, ok := token.Claims.(*models.JWTClaims); ok && token.Valid {
if claims.IP == IP {
return true, nil
}
}
return false, nil
return claims.IP == IP, nil
}
func validateRequestBody(ctx *gin.Context) (bool, error) {

View file

@ -1,31 +1,66 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"apiserver/backend/auth"
"apiserver/controllers"
"apiserver/models"
"apiserver/store"
"apiserver/utils"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
"go.uber.org/zap"
)
const testJWTSecret = "middleware-test-secret"
// resetValidateHandlerState initializes the stores and logging so API-key
// validation has its backing store available.
func resetValidateHandlerState() {
store.InitStores()
utils.Log = zap.NewNop()
utils.AuthenticationYamlConfig = &utils.Authentication{
Token: utils.TokenConfig{
SecretKey: "test-secret",
Type: "HS256",
TokenExpireTime: 3600,
},
}
}
// setTestAuthConfig installs a JWT config for the test and restores the
// previous one afterwards.
func setTestAuthConfig(t *testing.T) {
t.Helper()
prev := utils.AuthenticationYamlConfig
prevLog := utils.Log
utils.AuthenticationYamlConfig = &utils.Authentication{
Token: utils.TokenConfig{Type: "jwt", SecretKey: testJWTSecret, TokenExpireTime: 3600},
}
utils.Log = zap.NewNop()
t.Cleanup(func() {
utils.AuthenticationYamlConfig = prev
utils.Log = prevLog
})
}
func signTestToken(t *testing.T, method jwt.SigningMethod, username, ip string) string {
t.Helper()
claims := &models.JWTClaims{
Username: username,
IP: ip,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
signed, err := jwt.NewWithClaims(method, claims).SignedString([]byte(testJWTSecret))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
// newValidateHandlerRouter wires one protected route behind ValidateHandler.
func newValidateHandlerRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
@ -38,6 +73,7 @@ func newValidateHandlerRouter() *gin.Engine {
// Authorization header passes the middleware (no JWT involved at all).
func TestValidateHeader_APIKey_Valid(t *testing.T) {
resetValidateHandlerState()
setTestAuthConfig(t)
raw := models.APIKeyPrefix + "valid-key"
store.AddAPIKey(raw, store.APIKeyEntry{
@ -60,6 +96,7 @@ func TestValidateHeader_APIKey_Valid(t *testing.T) {
// never issued is rejected with 401, not a confusing JWT parse error.
func TestValidateHeader_APIKey_Unknown(t *testing.T) {
resetValidateHandlerState()
setTestAuthConfig(t)
router := newValidateHandlerRouter()
req := httptest.NewRequest(http.MethodGet, "/xce/v1/jobs", nil)
@ -75,6 +112,7 @@ func TestValidateHeader_APIKey_Unknown(t *testing.T) {
// TestValidateHeader_APIKey_Expired verifies an expired API key is rejected.
func TestValidateHeader_APIKey_Expired(t *testing.T) {
resetValidateHandlerState()
setTestAuthConfig(t)
raw := models.APIKeyPrefix + "expired-key"
store.AddAPIKey(raw, store.APIKeyEntry{
@ -97,6 +135,7 @@ func TestValidateHeader_APIKey_Expired(t *testing.T) {
// API key branch must not weaken the existing "no token" rejection.
func TestValidateHeader_NoToken_StillRejected(t *testing.T) {
resetValidateHandlerState()
setTestAuthConfig(t)
router := newValidateHandlerRouter()
req := httptest.NewRequest(http.MethodGet, "/xce/v1/jobs", nil)
@ -107,3 +146,157 @@ func TestValidateHeader_NoToken_StillRejected(t *testing.T) {
t.Errorf("status = %d, want 401", w.Code)
}
}
// newAuthFlowRouter wires logon, logout and one protected route behind
// ValidateHandler, with a config_file provider holding alice/s3cret.
func newAuthFlowRouter(t *testing.T) *gin.Engine {
t.Helper()
setTestAuthConfig(t)
prevTop, prevProvider := utils.Global_TopDir, auth.GlobalProvider
t.Cleanup(func() { utils.Global_TopDir, auth.GlobalProvider = prevTop, prevProvider })
utils.Global_TopDir = t.TempDir()
provider, err := auth.NewConfigFileProvider([]byte(
"provider: config_file\nconfig_file:\n users:\n - username: alice\n password: s3cret\n"))
if err != nil {
t.Fatalf("NewConfigFileProvider: %v", err)
}
auth.GlobalProvider = provider
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(ValidateHandler())
authHandler := &controllers.AuthController{}
r.POST("/xce/v1/auth/logon", authHandler.Logon)
r.POST("/xce/v1/auth/logout", authHandler.Logout)
r.GET("/xce/v1/hosts", func(ctx *gin.Context) {
controllers.ReturnSuccess(ctx, http.StatusOK, "success", nil)
})
return r
}
func serve(r *gin.Engine, method, path, token, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.RemoteAddr = "10.0.0.1:4000"
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func logon(t *testing.T, r *gin.Engine) string {
t.Helper()
w := serve(r, http.MethodPost, "/xce/v1/auth/logon", "", `{"username":"alice","password":"s3cret"}`)
var resp struct {
Data models.LogonResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil || resp.Data.Token == "" {
t.Fatalf("logon failed: status %d body %s", w.Code, w.Body.String())
}
return resp.Data.Token
}
// bug-1946: after logout the token is rejected; a new logon (even in the
// same second) gets a working token.
func TestLogout_RevokesToken(t *testing.T) {
r := newAuthFlowRouter(t)
token := logon(t, r)
if w := serve(r, http.MethodGet, "/xce/v1/hosts", token, ""); w.Code != http.StatusOK {
t.Fatalf("before logout: status = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := serve(r, http.MethodPost, "/xce/v1/auth/logout", token, ""); w.Code != http.StatusOK {
t.Fatalf("logout: status = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := serve(r, http.MethodGet, "/xce/v1/hosts", token, ""); w.Code != http.StatusUnauthorized {
t.Errorf("after logout: status = %d, want 401 (%s)", w.Code, w.Body.String())
}
fresh := logon(t, r)
if w := serve(r, http.MethodGet, "/xce/v1/hosts", fresh, ""); w.Code != http.StatusOK {
t.Errorf("token from a new logon: status = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// bug-1950: only the HMAC algorithm used for signing (HS256) is accepted.
func TestIsValidToken_PinsSigningAlgorithm(t *testing.T) {
setTestAuthConfig(t)
tests := []struct {
name string
method jwt.SigningMethod
want bool
}{
{"HS256 (signing algorithm)", jwt.SigningMethodHS256, true},
{"HS384", jwt.SigningMethodHS384, false},
{"HS512", jwt.SigningMethodHS512, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := signTestToken(t, tt.method, "alice", "10.0.0.1")
got, err := isValidToken(token, "10.0.0.1")
if got != tt.want {
t.Errorf("isValidToken(%s) = %v (err %v), want %v", tt.method.Alg(), got, err, tt.want)
}
})
}
}
// bug-1962: exact excluded entries match only themselves; only the wildcard
// entry (/swagger/*any) matches by prefix.
func TestIsExcludedPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{"/ping", true},
{"/xce/v1/auth/logon", true},
{"/xce/v1/auth/logout", true},
{"/swagger/index.html", true},
{"/swagger/doc.json", true},
// prefix look-alikes must not skip JWT validation
{"/pinX", false},
{"/pin", false},
{"/ping/x", false},
{"/pingpong", false},
{"/xce/v1/auth/logonX", false},
{"/xce/v1/auth/logo", false},
{"/xce/v1/auth/logout/x", false},
{"/swaggerX", false},
{"/xce/v1/jobs", false},
{"/", false},
}
for _, tt := range tests {
if got := isExcludedPath(tt.path); got != tt.want {
t.Errorf("isExcludedPath(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
// bug-1962: a request to a path that only shares a prefix with an excluded
// entry must still be rejected without a token; the excluded entry itself
// still needs none.
func TestValidateHandler_PrefixLookAlikeNeedsToken(t *testing.T) {
setTestAuthConfig(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(ValidateHandler())
r.GET("/pinX", func(ctx *gin.Context) { ctx.String(http.StatusOK, "reached") })
r.GET("/ping", func(ctx *gin.Context) { ctx.String(http.StatusOK, "pong") })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/pinX", nil))
if strings.Contains(w.Body.String(), "reached") {
t.Errorf("/pinX without a token reached the handler (status %d)", w.Code)
}
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ping", nil))
if w.Code != http.StatusOK || w.Body.String() != "pong" {
t.Errorf("/ping without a token: status %d body %q, want 200 pong", w.Code, w.Body.String())
}
}

View file

@ -131,6 +131,7 @@ func WhitelistIPMiddleware(allowedIPs []string) gin.HandlerFunc {
if clientIP == nil {
logger.Error("invalid client IP", zap.String("clientIP", clientIPStr))
controllers.ReturnError(ctx, common.INVALID_PARAMS_CODE, common.ERROR_INVALID_CLIENT_IP_MSG, nil)
ctx.Abort()
return
}

View file

@ -1,6 +1,55 @@
package middleware
import "testing"
import (
"apiserver/utils"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// bug-1949: every IP-whitelist failure must stop the request before the
// handler runs.
func TestWhitelistIPMiddleware_AbortsOnFailure(t *testing.T) {
prevLog := utils.Log
utils.Log = zap.NewNop()
t.Cleanup(func() { utils.Log = prevLog })
tests := []struct {
name string
remoteAddr string
wantStatus int
}{
{"unparsable client IP", "not-an-address", http.StatusBadRequest},
{"client IP not whitelisted", "192.0.2.10:4000", http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
handlerRan := false
r.Use(WhitelistIPMiddleware([]string{"10.0.0.0/8"}))
r.GET("/ping", func(ctx *gin.Context) {
handlerRan = true
ctx.String(http.StatusOK, "pong")
})
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
req.RemoteAddr = tt.remoteAddr
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if handlerRan {
t.Errorf("handler ran after the IP whitelist rejected %q (status %d)", tt.remoteAddr, w.Code)
}
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
})
}
}
func TestPathMatchesPattern(t *testing.T) {
cases := []struct {