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 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 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) { a := newApp(nil, 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) } 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) } } func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) { 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 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) }