package externaladmin import ( "errors" "net/http" "net/http/httptest" "testing" "time" "hyapp-admin-server/internal/appctx" adminmiddleware "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/security" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" "gorm.io/driver/mysql" "gorm.io/gorm" ) func TestRequireCSRFAcceptsOnlyMatchingCookieHeaderAndSessionHash(t *testing.T) { gin.SetMode(gin.TestMode) csrfToken := "csrf-secret" handler := &Handler{} engine := gin.New() engine.POST("/write", func(c *gin.Context) { c.Set(contextPrincipal, SessionPrincipal{CSRFTokenHash: security.HashToken(csrfToken)}) }, handler.RequireCSRF(), func(c *gin.Context) { c.Status(http.StatusNoContent) }) validRequest := httptest.NewRequest(http.MethodPost, "/write", nil) validRequest.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: csrfToken}) validRequest.Header.Set(CSRFHeaderName, csrfToken) validResponse := httptest.NewRecorder() engine.ServeHTTP(validResponse, validRequest) if validResponse.Code != http.StatusNoContent { t.Fatalf("valid csrf status = %d body=%s", validResponse.Code, validResponse.Body.String()) } invalidRequest := httptest.NewRequest(http.MethodPost, "/write", nil) invalidRequest.AddCookie(&http.Cookie{Name: CSRFCookieName, Value: csrfToken}) invalidRequest.Header.Set(CSRFHeaderName, "different") invalidResponse := httptest.NewRecorder() engine.ServeHTTP(invalidResponse, invalidRequest) if invalidResponse.Code != http.StatusForbidden { t.Fatalf("invalid csrf status = %d body=%s", invalidResponse.Code, invalidResponse.Body.String()) } } func TestExternalSessionCookiesAreHostOnlyAndPathScoped(t *testing.T) { gin.SetMode(gin.TestMode) handler := &Handler{cfg: Config{SessionTTL: time.Hour, CookieSecure: true, CookieSameSite: "none"}} engine := gin.New() engine.POST("/login", func(c *gin.Context) { handler.setSessionCookies(c, "session-token", "csrf-token") c.Status(http.StatusNoContent) }) response := httptest.NewRecorder() engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/login", nil)) cookies := response.Result().Cookies() if len(cookies) != 2 { t.Fatalf("cookie count = %d, want 2", len(cookies)) } for _, cookie := range cookies { if cookie.Path != CookiePath || cookie.Domain != "" || !cookie.Secure || cookie.SameSite != http.SameSiteNoneMode { t.Fatalf("unsafe cookie attributes: %+v", cookie) } } if !cookieByName(cookies, SessionCookieName).HttpOnly { t.Fatal("session cookie must be HttpOnly") } if cookieByName(cookies, CSRFCookieName).HttpOnly { t.Fatal("double-submit CSRF cookie must be readable") } } func TestExternalSessionCookiesAllowLocalHTTPWithLaxSameSite(t *testing.T) { gin.SetMode(gin.TestMode) handler := &Handler{cfg: Config{SessionTTL: time.Hour, CookieSecure: false, CookieSameSite: "lax"}} engine := gin.New() engine.POST("/login", func(c *gin.Context) { handler.setSessionCookies(c, "session-token", "csrf-token") c.Status(http.StatusNoContent) }) response := httptest.NewRecorder() engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/login", nil)) for _, cookie := range response.Result().Cookies() { if cookie.Secure || cookie.SameSite != http.SameSiteLaxMode { t.Fatalf("local cookie must work over HTTP with Lax SameSite: %+v", cookie) } } } func TestAuthRequiredRejectsMismatchedAppHeaderAndBindsOmittedHeaderToSessionApp(t *testing.T) { for _, testCase := range []struct { name string headerApp string wantStatus int wantBound bool }{ {name: "mismatch rejected", headerApp: "lalu", wantStatus: http.StatusForbidden}, {name: "omitted header bound", headerApp: "", wantStatus: http.StatusNoContent, wantBound: true}, } { t.Run(testCase.name, func(t *testing.T) { handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "active") defer cleanup() gin.SetMode(gin.TestMode) engine := gin.New() engine.GET("/protected", handler.AuthRequired(), func(c *gin.Context) { if testCase.wantBound { principal, ok := CurrentPrincipal(c) if !ok || principal.SessionID != 11 { t.Fatalf("session principal = %+v ok=%t", principal, ok) } if got := appctx.FromContext(c.Request.Context()); got != "fami" { t.Fatalf("request app = %q, want session app fami", got) } if got := c.Writer.Header().Get(appctx.HeaderAppCode); got != "fami" { t.Fatalf("response app header = %q", got) } if got := adminmiddleware.CurrentUserID(c); uint64(got) != externalOperatorNamespace|7 { t.Fatalf("synthetic operator = %d", got) } } c.Status(http.StatusNoContent) }) request := httptest.NewRequest(http.MethodGet, "/protected", nil) request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"}) if testCase.headerApp != "" { request.Header.Set(appctx.HeaderAppCode, testCase.headerApp) } responseRecorder := httptest.NewRecorder() engine.ServeHTTP(responseRecorder, request) if responseRecorder.Code != testCase.wantStatus { t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) } assertAuthFixtureExpectations(t, adminMock, userMock) }) } } func TestAuthRequiredBlocksBusinessDirectlyWhenSessionAppWasDisabled(t *testing.T) { handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "disabled") defer cleanup() gin.SetMode(gin.TestMode) engine := gin.New() reachedBusiness := false // Intentionally omit /auth/me: this proves a stale browser cannot bypass the // App-state check by navigating straight to a business API. engine.GET("/business", handler.AuthRequired(), func(c *gin.Context) { reachedBusiness = true c.Status(http.StatusNoContent) }) request := httptest.NewRequest(http.MethodGet, "/business", nil) request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"}) responseRecorder := httptest.NewRecorder() engine.ServeHTTP(responseRecorder, request) if responseRecorder.Code != http.StatusUnauthorized || reachedBusiness { t.Fatalf("status=%d reached_business=%t body=%s", responseRecorder.Code, reachedBusiness, responseRecorder.Body.String()) } assertAuthFixtureExpectations(t, adminMock, userMock) } func TestAuthRequiredFailsClosedWithoutRevokingOnAppDatabaseError(t *testing.T) { handler, adminMock, userMock, cleanup := newAuthRequiredFixture(t, "session-secret", "error") defer cleanup() gin.SetMode(gin.TestMode) engine := gin.New() reachedBusiness := false engine.GET("/business", handler.AuthRequired(), func(c *gin.Context) { reachedBusiness = true c.Status(http.StatusNoContent) }) request := httptest.NewRequest(http.MethodGet, "/business", nil) request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session-secret"}) responseRecorder := httptest.NewRecorder() engine.ServeHTTP(responseRecorder, request) if responseRecorder.Code != http.StatusInternalServerError || reachedBusiness { t.Fatalf("status=%d reached_business=%t body=%s", responseRecorder.Code, reachedBusiness, responseRecorder.Body.String()) } // The fixture has no revocation UPDATE expectation. A transient owner-DB error // must fail this request closed without converting a recoverable session to 401. assertAuthFixtureExpectations(t, adminMock, userMock) } func TestRequirePasswordChangedBlocksBusinessRoutes(t *testing.T) { gin.SetMode(gin.TestMode) handler := &Handler{} engine := gin.New() engine.GET("/business", func(c *gin.Context) { c.Set(contextPrincipal, SessionPrincipal{PasswordChangeRequired: true}) c.Next() }, handler.RequirePasswordChanged(), func(c *gin.Context) { c.Status(http.StatusNoContent) }) responseRecorder := httptest.NewRecorder() engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/business", nil)) if responseRecorder.Code != http.StatusForbidden { t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String()) } } func newAuthRequiredFixture(t *testing.T, rawToken string, appState string) (*Handler, sqlmock.Sqlmock, sqlmock.Sqlmock, func()) { t.Helper() adminSQL, adminMock, err := sqlmock.New() if err != nil { t.Fatalf("create admin sql mock: %v", err) } adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{}) if err != nil { adminSQL.Close() t.Fatalf("create gorm db: %v", err) } userDB, userMock, err := sqlmock.New() if err != nil { adminSQL.Close() t.Fatalf("create user sql mock: %v", err) } nowMS := time.Now().UTC().UnixMilli() adminMock.ExpectQuery("SELECT \\* FROM `external_admin_sessions` WHERE token_hash = \\? AND revoked_at_ms = 0 AND expires_at_ms > \\?"). WithArgs(security.HashToken(rawToken), sqlmock.AnyArg(), 1). WillReturnRows(sqlmock.NewRows([]string{ "id", "account_id", "app_code", "token_hash", "csrf_token_hash", "ip", "user_agent", "expires_at_ms", "last_seen_at_ms", "revoked_at_ms", "revoke_reason", "created_at_ms", }).AddRow(11, 7, "fami", security.HashToken(rawToken), security.HashToken("csrf"), "127.0.0.1", "browser", nowMS+3600000, nowMS, 0, "", nowMS)) adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE `external_admin_accounts`.`id` = \\?"). WithArgs(uint64(7)). WillReturnRows(sqlmock.NewRows([]string{ "id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status", "password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms", }).AddRow(7, "fami", 9001, "operator", "hash", `["bd:view"]`, "active", false, 0, 0, 1, nowMS, nowMS)) appQuery := userMock.ExpectQuery("SELECT app_code, app_name, logo_url FROM apps WHERE app_code = \\? AND status = 'active' LIMIT 1").WithArgs("fami") switch appState { case "active": appQuery.WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).AddRow("fami", "Fami", "logo")) case "disabled": appQuery.WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"})) adminMock.ExpectBegin() adminMock.ExpectExec("UPDATE `external_admin_sessions`"). WithArgs("app_disabled", sqlmock.AnyArg(), uint64(11)). WillReturnResult(sqlmock.NewResult(0, 1)) adminMock.ExpectCommit() case "error": appQuery.WillReturnError(errors.New("user database temporarily unavailable")) default: t.Fatalf("unknown app state %q", appState) } return &Handler{service: NewService(adminDB, userDB, Config{})}, adminMock, userMock, func() { _ = userDB.Close() _ = adminSQL.Close() } } func assertAuthFixtureExpectations(t *testing.T, adminMock sqlmock.Sqlmock, userMock sqlmock.Sqlmock) { t.Helper() if err := adminMock.ExpectationsWereMet(); err != nil { t.Fatalf("admin auth sql expectations: %v", err) } if err := userMock.ExpectationsWereMet(); err != nil { t.Fatalf("user auth sql expectations: %v", err) } } func cookieByName(cookies []*http.Cookie, name string) *http.Cookie { for _, cookie := range cookies { if cookie.Name == name { return cookie } } return &http.Cookie{} }