272 lines
11 KiB
Go
272 lines
11 KiB
Go
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{}
|
|
}
|