553 lines
22 KiB
Go
553 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testCoreClient(t *testing.T, handler http.Handler) *coreClient {
|
|
t.Helper()
|
|
ts := httptest.NewServer(handler)
|
|
t.Cleanup(ts.Close)
|
|
c, err := newCoreClient(ts.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func TestCoreClientAllowlistAndRequestID(t *testing.T) {
|
|
var gotPath, gotAuth, gotRequestID string
|
|
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath, gotAuth, gotRequestID = r.URL.RequestURI(), r.Header.Get("Authorization"), r.Header.Get("X-Request-Id")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"role":"admin"}}`))
|
|
}))
|
|
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions?page=1&evil=ignored", "CORE-TOKEN"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotPath != "/api/v1/admin/subscriptions?page=1" {
|
|
t.Fatalf("path=%q", gotPath)
|
|
}
|
|
if gotAuth != "Bearer CORE-TOKEN" || gotRequestID == "" {
|
|
t.Fatalf("headers auth=%q request_id=%q", gotAuth, gotRequestID)
|
|
}
|
|
if _, err := c.read(context.Background(), "/api/v1/admin/payment/plans/1", "TOKEN"); err == nil {
|
|
t.Fatal("unexpected allowlist success")
|
|
}
|
|
}
|
|
|
|
func TestCoreReadQueryIsSanitized(t *testing.T) {
|
|
var gotPath string
|
|
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.RequestURI()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":[]}`))
|
|
}))
|
|
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions?page=1&evil=ignored", "TOKEN"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotPath != "/api/v1/admin/subscriptions?page=1" {
|
|
t.Fatalf("sanitized path=%q", gotPath)
|
|
}
|
|
}
|
|
|
|
func TestNewCoreClientRejectsNonAbsoluteOrQueryURL(t *testing.T) {
|
|
for _, base := range []string{"", "/api", "ftp://core", "https://core.test/?token=secret", "http://core.test", "https://user:pass@core.test"} {
|
|
if _, err := newCoreClient(base); err == nil {
|
|
t.Fatalf("expected invalid Core URL: %q", base)
|
|
}
|
|
}
|
|
for _, base := range []string{"http://127.0.0.1:8080", "http://[::1]:8080", "http://localhost:8080"} {
|
|
if _, err := newCoreClient(base); err != nil {
|
|
t.Fatalf("expected loopback URL to be accepted: %q: %v", base, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCoreClientRejectsNonzeroEnvelopeCode(t *testing.T) {
|
|
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"code":422,"message":"bad","data":{}}`))
|
|
}))
|
|
if _, err := c.read(context.Background(), "/api/v1/admin/subscriptions", "TOKEN"); err == nil {
|
|
t.Fatal("expected nonzero Core envelope to fail")
|
|
}
|
|
}
|
|
|
|
func TestLoginRequiresAdminAndDoesNotReturnCoreToken(t *testing.T) {
|
|
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/login":
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"CORE-TOKEN","refresh_token":"CORE-REFRESH"}}`))
|
|
case "/api/v1/auth/me":
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":7,"role":"user","email":"user@example.com"}}`))
|
|
case "/api/v1/auth/logout":
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
|
default:
|
|
t.Errorf("unexpected Core path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
a := newApp(c, false)
|
|
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"user@example.com","password":"password"}`))
|
|
rec := httptest.NewRecorder()
|
|
a.login(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if strings.Contains(rec.Body.String(), "CORE-TOKEN") || strings.Contains(rec.Body.String(), "CORE-REFRESH") {
|
|
t.Fatalf("core token leaked: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLoginAndReadProxyUsePluginCookie(t *testing.T) {
|
|
var readAuth 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/login":
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"CORE-TOKEN","refresh_token":"CORE-REFRESH"}}`))
|
|
case "/api/v1/auth/me":
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin","email":"admin@example.com"}}`))
|
|
case "/api/v1/admin/subscriptions":
|
|
readAuth = r.Header.Get("Authorization")
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[],"total":0,"page":1,"page_size":20,"pages":1}}`))
|
|
default:
|
|
t.Errorf("unexpected Core path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
a := newApp(c, false)
|
|
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
|
|
loginRec := httptest.NewRecorder()
|
|
a.login(loginRec, loginReq)
|
|
if loginRec.Code != http.StatusOK || strings.Contains(loginRec.Body.String(), "CORE-TOKEN") {
|
|
t.Fatalf("login status/body: %d %s", loginRec.Code, loginRec.Body.String())
|
|
}
|
|
cookie := loginRec.Result().Cookies()[0]
|
|
readReq := httptest.NewRequest(http.MethodGet, "/api/subscriptions?page=1", nil)
|
|
readReq.AddCookie(cookie)
|
|
readRec := httptest.NewRecorder()
|
|
a.readProxy("/api/v1/admin/subscriptions")(readRec, readReq)
|
|
if readRec.Code != http.StatusOK || readAuth != "Bearer CORE-TOKEN" {
|
|
t.Fatalf("read status=%d auth=%q body=%s", readRec.Code, readAuth, readRec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReadProxyStripsSensitiveCoreFields(t *testing.T) {
|
|
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/subscriptions":
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"LEAK","items":[{"refresh_token":"LEAK2","id":1}]}}`))
|
|
default:
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
|
}
|
|
}))
|
|
a := newApp(c, false)
|
|
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: time.Now(), lastSeen: time.Now(), user: map[string]any{"id": 1}}
|
|
req := httptest.NewRequest(http.MethodGet, "/api/subscriptions", nil)
|
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
|
|
rec := httptest.NewRecorder()
|
|
a.readProxy("/api/v1/admin/subscriptions")(rec, req)
|
|
if strings.Contains(rec.Body.String(), "LEAK") || strings.Contains(rec.Body.String(), "refresh_token") {
|
|
t.Fatalf("sensitive field leaked: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCoreRevocationDestroysPluginSession(t *testing.T) {
|
|
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/me" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"code":401,"message":"revoked"}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
|
}))
|
|
a := newApp(c, false)
|
|
now := time.Now()
|
|
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1}}
|
|
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("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, ok := a.sessions["sid"]; ok {
|
|
t.Fatal("revoked Core session remained in plugin store")
|
|
}
|
|
}
|
|
|
|
func TestLogoutRevokesCoreRefreshToken(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")
|
|
if r.URL.Path == "/api/v1/auth/logout" {
|
|
atomic.AddInt32(&logoutCalls, 1)
|
|
var body map[string]string
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
if body["refresh_token"] != "REFRESH" {
|
|
t.Errorf("refresh token=%q", body["refresh_token"])
|
|
}
|
|
}
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
|
}))
|
|
a := newApp(c, false)
|
|
a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: time.Now(), lastSeen: time.Now(), user: map[string]any{"id": 1}}
|
|
a.sessionLocks["sid"] = &sync.Mutex{}
|
|
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
|
|
req.Header.Set("X-CSRF-Token", "CSRF")
|
|
rec := httptest.NewRecorder()
|
|
a.logout(rec, req)
|
|
if rec.Code != http.StatusOK || atomic.LoadInt32(&logoutCalls) != 1 {
|
|
t.Fatalf("status=%d logout_calls=%d body=%s", rec.Code, logoutCalls, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReadProxyRefreshesAtMostOncePerRequest(t *testing.T) {
|
|
var refreshCalls int32
|
|
var meCalls int32
|
|
var readCalls 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":
|
|
call := atomic.AddInt32(&meCalls, 1)
|
|
if call == 1 {
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
|
|
} else {
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
|
|
}
|
|
case "/api/v1/admin/subscriptions":
|
|
call := atomic.AddInt32(&readCalls, 1)
|
|
if call == 1 {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"code":401,"message":"expired"}`))
|
|
} else {
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[]}}`))
|
|
}
|
|
case "/api/v1/auth/refresh":
|
|
atomic.AddInt32(&refreshCalls, 1)
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"NEW","refresh_token":"NEW-REFRESH"}}`))
|
|
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}}
|
|
a.sessionLocks["sid"] = &sync.Mutex{}
|
|
req := httptest.NewRequest(http.MethodGet, "/api/subscriptions", nil)
|
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
|
|
rec := httptest.NewRecorder()
|
|
a.readProxy("/api/v1/admin/subscriptions")(rec, req)
|
|
if rec.Code != http.StatusOK || atomic.LoadInt32(&refreshCalls) != 1 {
|
|
t.Fatalf("status=%d refresh_calls=%d body=%s", rec.Code, refreshCalls, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
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)
|
|
a.clock = func() time.Time { return now }
|
|
a.sessions["sid"] = session{accessToken: "TOKEN", csrfToken: "CSRF", createdAt: now, lastSeen: now.Add(-sessionTTL - time.Second), user: map[string]any{"id": 1}}
|
|
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("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTwoFactorPendingTokenIsSingleUse(t *testing.T) {
|
|
now := time.Now()
|
|
a := newApp(nil, false)
|
|
a.clock = func() time.Time { return now }
|
|
a.pending["pending"] = pendingLogin{tempToken: "CORE-TEMP", expires: now.Add(time.Minute)}
|
|
first := a.pending["pending"]
|
|
delete(a.pending, "pending")
|
|
if _, ok := a.pending["pending"]; ok || first.tempToken != "CORE-TEMP" {
|
|
t.Fatal("pending token was not consumed")
|
|
}
|
|
}
|
|
|
|
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",
|
|
"/api/v1/admin/users/1/subscriptions/extra",
|
|
"/api/v1/admin/payment/plans/1",
|
|
"/api/v1/admin/../users",
|
|
} {
|
|
if allowedReadPath(path) {
|
|
t.Fatalf("unexpected allowlist match: %s", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRoutesProtectReadOnlyEndpointsAndSetSecurityHeaders(t *testing.T) {
|
|
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)
|
|
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 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)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/api/v1/auth/login":
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"A","refresh_token":"R"}}`))
|
|
case "/api/v1/auth/me":
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin","email":"a@example.com"}}`))
|
|
case "/api/v1/auth/logout":
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
|
default:
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
|
}
|
|
}))
|
|
a := newApp(c, false)
|
|
server := httptest.NewServer(a.routes())
|
|
t.Cleanup(server.Close)
|
|
request, _ := http.NewRequest(http.MethodPost, server.URL+"/login", strings.NewReader(`{"email":"a@example.com","password":"password"}`))
|
|
request.Header.Set(requestIDHeader, "client-request-123")
|
|
request.Header.Set("Content-Type", "application/json")
|
|
response, err := server.Client().Do(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.StatusCode != http.StatusOK || response.Header.Get(requestIDHeader) != "client-request-123" || coreRequestID != "client-request-123" {
|
|
t.Fatalf("status=%d response_id=%q core_id=%q", response.StatusCode, response.Header.Get(requestIDHeader), coreRequestID)
|
|
}
|
|
cookies := response.Cookies()
|
|
if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode {
|
|
t.Fatalf("cookie=%#v", cookies)
|
|
}
|
|
bootstrap, _ := http.NewRequest(http.MethodGet, server.URL+"/api/me", nil)
|
|
bootstrap.AddCookie(cookies[0])
|
|
bootstrapResponse, err := server.Client().Do(bootstrap)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bootstrapResponse.StatusCode != http.StatusOK || !strings.Contains(readBody(t, bootstrapResponse), "csrf_token") {
|
|
t.Fatalf("bootstrap status=%d", bootstrapResponse.StatusCode)
|
|
}
|
|
}
|
|
|
|
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()
|
|
data, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(data)
|
|
}
|