All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m22s
CI/CD / test-sqlite (pull_request) Successful in 2m35s
CI/CD / build-and-deploy (pull_request) Successful in 1m18s
The role column was a pre-RBAC artifact. All authorization now flows through explicit group membership and permission grants: - Everyone group: all users added on creation (no implicit membership) - Admins group: grants surface.admin.access + all platform permissions - JWT claims, login response, profile: role field removed - OIDC: isIdPAdmin() maps IdP claims → Admins group (no role writes) - Admin UI: role dropdown removed, admin managed through groups - Middleware cache simplified to isActive only 28 files changed, -79 lines net. Zero magic roles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
312 lines
7.7 KiB
Go
312 lines
7.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"switchboard-core/auth"
|
|
"switchboard-core/config"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
func testConfig() *config.Config {
|
|
return &config.Config{
|
|
JWTSecret: "test-secret-key-for-unit-tests",
|
|
AuthMode: "builtin",
|
|
}
|
|
}
|
|
|
|
// testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests).
|
|
func testAuthHandler() *AuthHandler {
|
|
return NewAuthHandler(testConfig(), store.Stores{}, nil, auth.NewBuiltinProvider())
|
|
}
|
|
|
|
// ── JWT Token Tests ─────────────────────────
|
|
|
|
func TestJWTGeneration(t *testing.T) {
|
|
cfg := testConfig()
|
|
|
|
now := time.Now()
|
|
claims := Claims{
|
|
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
|
Email: "test@example.com",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
|
|
Issuer: "switchboard-core",
|
|
},
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenString, err := token.SignedString([]byte(cfg.JWTSecret))
|
|
if err != nil {
|
|
t.Fatalf("Failed to sign token: %v", err)
|
|
}
|
|
|
|
if tokenString == "" {
|
|
t.Fatal("Token string is empty")
|
|
}
|
|
|
|
// Parse it back
|
|
parsed := &Claims{}
|
|
parsedToken, err := jwt.ParseWithClaims(tokenString, parsed, func(t *jwt.Token) (interface{}, error) {
|
|
return []byte(cfg.JWTSecret), nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse token: %v", err)
|
|
}
|
|
if !parsedToken.Valid {
|
|
t.Fatal("Parsed token is not valid")
|
|
}
|
|
if parsed.UserID != claims.UserID {
|
|
t.Errorf("UserID mismatch: got %s, want %s", parsed.UserID, claims.UserID)
|
|
}
|
|
if parsed.Email != claims.Email {
|
|
t.Errorf("Email mismatch: got %s, want %s", parsed.Email, claims.Email)
|
|
}
|
|
}
|
|
|
|
func TestJWTExpired(t *testing.T) {
|
|
cfg := testConfig()
|
|
|
|
claims := Claims{
|
|
UserID: "test-user",
|
|
Email: "test@example.com",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-30 * time.Minute)),
|
|
},
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenString, _ := token.SignedString([]byte(cfg.JWTSecret))
|
|
|
|
parsed := &Claims{}
|
|
_, err := jwt.ParseWithClaims(tokenString, parsed, func(t *jwt.Token) (interface{}, error) {
|
|
return []byte(cfg.JWTSecret), nil
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Expected error for expired token, got nil")
|
|
}
|
|
}
|
|
|
|
func TestJWTWrongSecret(t *testing.T) {
|
|
claims := Claims{
|
|
UserID: "test-user",
|
|
Email: "test@example.com",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
|
},
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenString, _ := token.SignedString([]byte("correct-secret"))
|
|
|
|
parsed := &Claims{}
|
|
_, err := jwt.ParseWithClaims(tokenString, parsed, func(t *jwt.Token) (interface{}, error) {
|
|
return []byte("wrong-secret"), nil
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Expected error for wrong secret, got nil")
|
|
}
|
|
}
|
|
|
|
// ── Password Hashing Tests ─────────────────
|
|
|
|
func TestBcryptHash(t *testing.T) {
|
|
password := "mySecurePassword123!"
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
|
if err != nil {
|
|
t.Fatalf("Failed to hash: %v", err)
|
|
}
|
|
|
|
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
|
|
if err != nil {
|
|
t.Error("Correct password should match hash")
|
|
}
|
|
|
|
err = bcrypt.CompareHashAndPassword(hash, []byte("wrongPassword"))
|
|
if err == nil {
|
|
t.Error("Wrong password should not match hash")
|
|
}
|
|
}
|
|
|
|
func TestBcryptDifferentHashesForSamePassword(t *testing.T) {
|
|
password := "samePassword"
|
|
|
|
hash1, _ := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
|
hash2, _ := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
|
|
|
if string(hash1) == string(hash2) {
|
|
t.Error("Same password should produce different hashes (salt)")
|
|
}
|
|
|
|
if bcrypt.CompareHashAndPassword(hash1, []byte(password)) != nil {
|
|
t.Error("hash1 should verify")
|
|
}
|
|
if bcrypt.CompareHashAndPassword(hash2, []byte(password)) != nil {
|
|
t.Error("hash2 should verify")
|
|
}
|
|
}
|
|
|
|
// ── Token Hashing Tests ────────────────────
|
|
|
|
func TestTokenHash(t *testing.T) {
|
|
token := "abc123refreshtoken"
|
|
hash := hashToken(token)
|
|
|
|
if hashToken(token) != hash {
|
|
t.Error("hashToken should be deterministic")
|
|
}
|
|
|
|
if hashToken("different") == hash {
|
|
t.Error("Different tokens should produce different hashes")
|
|
}
|
|
|
|
if len(hash) != 64 {
|
|
t.Errorf("Expected 64 char hex hash, got %d chars", len(hash))
|
|
}
|
|
|
|
expected := sha256.Sum256([]byte(token))
|
|
expectedHex := hex.EncodeToString(expected[:])
|
|
if hash != expectedHex {
|
|
t.Errorf("Hash mismatch: got %s, want %s", hash, expectedHex)
|
|
}
|
|
}
|
|
|
|
// ── Request Validation Tests ────────────────
|
|
|
|
func TestRegisterValidation(t *testing.T) {
|
|
h := testAuthHandler()
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantCode int
|
|
}{
|
|
{
|
|
name: "missing fields",
|
|
body: `{}`,
|
|
wantCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "missing password",
|
|
body: `{"username":"test","email":"test@example.com"}`,
|
|
wantCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "password too short",
|
|
body: `{"username":"test","email":"test@example.com","password":"short"}`,
|
|
wantCode: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest("POST", "/api/v1/auth/register",
|
|
strings.NewReader(tt.body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
h.Register(c)
|
|
|
|
if w.Code != tt.wantCode {
|
|
t.Errorf("got status %d, want %d. Body: %s",
|
|
w.Code, tt.wantCode, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoginValidation(t *testing.T) {
|
|
h := testAuthHandler()
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantCode int
|
|
}{
|
|
{
|
|
name: "missing fields",
|
|
body: `{}`,
|
|
wantCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "missing password",
|
|
body: `{"login":"test@example.com"}`,
|
|
wantCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "missing login",
|
|
body: `{"password":"12345678"}`,
|
|
wantCode: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest("POST", "/api/v1/auth/login",
|
|
strings.NewReader(tt.body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
h.Login(c)
|
|
|
|
if w.Code != tt.wantCode {
|
|
t.Errorf("got status %d, want %d", w.Code, tt.wantCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRefreshValidation(t *testing.T) {
|
|
h := testAuthHandler()
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest("POST", "/api/v1/auth/refresh",
|
|
strings.NewReader(`{}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
h.Refresh(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected 400 for missing refresh_token, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestLogoutWithoutToken(t *testing.T) {
|
|
h := testAuthHandler()
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest("POST", "/api/v1/auth/logout",
|
|
strings.NewReader(`{}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
h.Logout(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Logout without token should succeed, got %d", w.Code)
|
|
}
|
|
|
|
var resp map[string]string
|
|
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["message"] != "logged out" {
|
|
t.Errorf("Expected 'logged out' message, got %s", resp["message"])
|
|
}
|
|
}
|