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 the67f3f6adscratch-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:
commit
0c8607345e
663 changed files with 139848 additions and 13614 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
25
src/frontend/apiserver/middleware/startserver_test.go
Normal file
25
src/frontend/apiserver/middleware/startserver_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue