feat: complete unified plugin admin v1.1.0
Business Plugins CI / check (plugin-admin) (push) Successful in 1m42s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m30s

This commit is contained in:
Qiufeng
2026-08-30 12:10:04 +08:00
parent 3c1a17f4d7
commit ada4ab3c21
69 changed files with 6681 additions and 901 deletions
+192 -9
View File
@@ -259,6 +259,97 @@ func TestReadProxyRefreshesAtMostOncePerRequest(t *testing.T) {
}
}
func TestRefreshRotationRevokesCandidateWhenAdminCheckFails(t *testing.T) {
var meCalls int32
var logoutTokens []string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
if atomic.AddInt32(&meCalls, 1) == 1 {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"expired"}`))
return
}
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":2,"role":"user"}}`))
case "/api/v1/auth/refresh":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"NEW","refresh_token":"NEW-REFRESH"}}`))
case "/api/v1/auth/logout":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
logoutTokens = append(logoutTokens, body["refresh_token"])
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
now := time.Now()
a.sessions["sid"] = session{accessToken: "OLD", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1, "role": "admin"}}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.me(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected refreshed session rejection: status=%d body=%s", rec.Code, rec.Body.String())
}
for _, value := range logoutTokens {
if value == "NEW-REFRESH" {
return
}
}
t.Fatalf("rotated refresh token was not revoked: %#v", logoutTokens)
}
func TestUserProxyClearsSessionWhenReadTokenIsRevoked(t *testing.T) {
var logoutCalls int32
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
case "/api/v1/admin/users/1":
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"revoked"}`))
case "/api/v1/auth/refresh":
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":401,"message":"refresh revoked"}`))
case "/api/v1/auth/logout":
atomic.AddInt32(&logoutCalls, 1)
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
default:
t.Errorf("unexpected Core path %s", r.URL.Path)
}
}))
a := newApp(c, false)
now := time.Now()
a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1, "role": "admin"}}
a.sessionLocks["sid"] = &sync.Mutex{}
req := httptest.NewRequest(http.MethodGet, "/api/users/1", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
rec := httptest.NewRecorder()
a.userProxy(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if _, ok := a.sessions["sid"]; ok {
t.Fatal("revoked session remained in plugin store")
}
if atomic.LoadInt32(&logoutCalls) != 1 {
t.Fatalf("logout calls=%d", logoutCalls)
}
cleared := false
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == sessionCookieName && cookie.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Fatalf("session cookie was not cleared: %#v", rec.Result().Cookies())
}
}
func TestSessionExpiry(t *testing.T) {
now := time.Now()
a := newApp(nil, false)
@@ -285,6 +376,61 @@ func TestTwoFactorPendingTokenIsSingleUse(t *testing.T) {
}
}
func TestPendingLoginCapAndExpiry(t *testing.T) {
now := time.Now()
a := newApp(nil, false)
a.clock = func() time.Time { return now }
for i := 0; i < maxPendingLogins; i++ {
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "TEMP", expires: now.Add(time.Minute)}); !ok {
t.Fatalf("pending challenge %d was unexpectedly rejected", i)
}
}
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "OVERFLOW", expires: now.Add(time.Minute)}); ok {
t.Fatal("pending challenge cap was not enforced")
}
if got := len(a.pending); got != maxPendingLogins {
t.Fatalf("pending map size=%d want %d", got, maxPendingLogins)
}
a.clock = func() time.Time { return now.Add(pendingTTL + time.Second) }
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "AFTER-EXPIRY", expires: now.Add(2 * pendingTTL)}); !ok {
t.Fatal("expired pending challenges were not evicted")
}
if got := len(a.pending); got != 1 {
t.Fatalf("pending map size after expiry=%d want 1", got)
}
}
func TestStandaloneLoginRateLimitBoundsCoreAttempts(t *testing.T) {
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
t.Setenv("PLUGIN_ENV", "development")
var loginCalls int32
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/auth/login" {
atomic.AddInt32(&loginCalls, 1)
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"requires_2fa":true,"temp_token":"TEMP"}}`))
return
}
t.Errorf("unexpected Core path %s", r.URL.Path)
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
}))
a := newAppWithConfig(c, appConfig{LoginLimit: 2, LoginWindow: time.Hour})
for attempt := 0; attempt < 3; attempt++ {
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
rec := httptest.NewRecorder()
a.login(rec, req)
if attempt < 2 && rec.Code != http.StatusOK {
t.Fatalf("attempt %d status=%d body=%s", attempt+1, rec.Code, rec.Body.String())
}
if attempt == 2 && (rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "") {
t.Fatalf("limit response status=%d headers=%v body=%s", rec.Code, rec.Header(), rec.Body.String())
}
}
if got := atomic.LoadInt32(&loginCalls); got != 2 {
t.Fatalf("Core received %d login calls want 2", got)
}
}
func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
for _, path := range []string{
"/api/v1/admin/subscriptions/1/progress",
@@ -299,22 +445,39 @@ func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
}
func TestRoutesProtectReadOnlyEndpointsAndSetSecurityHeaders(t *testing.T) {
a := newApp(nil, false)
t.Setenv("PLUGIN_STANDALONE_AUTH", "false")
t.Setenv("PLUGIN_ENV", "production")
var coreCalls int32
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&coreCalls, 1)
w.WriteHeader(http.StatusInternalServerError)
}))
a := newApp(c, false)
server := httptest.NewServer(a.routes())
t.Cleanup(server.Close)
response, err := server.Client().Get(server.URL + "/api/plans")
if err != nil {
t.Fatal(err)
for _, endpoint := range []string{"/login", "/login/2fa", "/logout", "/api/me", "/api/status", "/api/plans", "/api/subscriptions", "/api/users/1"} {
response, err := server.Client().Get(server.URL + endpoint)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusNotFound {
t.Fatalf("endpoint=%s status=%d", endpoint, response.StatusCode)
}
if len(response.Cookies()) != 0 {
t.Fatalf("endpoint=%s unexpectedly set cookies: %#v", endpoint, response.Cookies())
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("endpoint=%s security headers missing: %#v", endpoint, response.Header)
}
}
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("status=%d", response.StatusCode)
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("security headers missing: %#v", response.Header)
if atomic.LoadInt32(&coreCalls) != 0 {
t.Fatalf("disabled standalone routes called Core %d times", coreCalls)
}
}
func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
t.Setenv("PLUGIN_ENV", "development")
var coreRequestID string
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
coreRequestID = r.Header.Get(requestIDHeader)
@@ -358,6 +521,26 @@ func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
}
}
func TestStandaloneAuthFailsClosedOutsideDevelopmentLoopback(t *testing.T) {
for _, test := range []struct {
name string
env string
host string
}{
{name: "production", env: "production", host: "127.0.0.1"},
{name: "public-development", env: "development", host: "0.0.0.0"},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
t.Setenv("PLUGIN_ENV", test.env)
t.Setenv("PLUGIN_HOST", test.host)
if standaloneAuthEnabled() {
t.Fatal("standalone auth unexpectedly enabled")
}
})
}
}
func readBody(t *testing.T, response *http.Response) string {
t.Helper()
defer response.Body.Close()