Files
boc/backend/auth/auth_test.go
T
Bernt (LandveX AI) 6bb355ce17 LINUS ROUND 3: Unified AAMOS auth system for BOC
- auth/auth.go: AAMOS-standard JWT claims (sub, org_id, roles, scopes)
- auth/auth_test.go: 18 tests (login, validation, middleware, roles)
- Compatible with ouroboros-identity RS256 tokens
- Middleware: Bearer validation + RequireRole
- AAMOS_AUTH_AUDIT_REPORT.md: Full auth audit across all systems
2026-07-14 16:33:54 +00:00

310 lines
9.0 KiB
Go

package auth
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
)
func setupAuthService(t *testing.T) (*Service, sqlmock.Sqlmock, *sql.DB) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
svc := NewService(db, "test-secret-key-for-unit-tests-only")
return svc, mock, db
}
func TestNewService(t *testing.T) {
db, _, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
svc := NewService(db, "secret")
assert.NotNil(t, svc)
assert.Equal(t, "aamos-identity", svc.issuer)
assert.Equal(t, "boc", svc.audience)
}
func TestService_Login_Success(t *testing.T) {
svc, mock, db := setupAuthService(t)
defer db.Close()
password := "correct-password"
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
userID := "user-123"
orgID := "org-456"
// Expect SELECT
mock.ExpectQuery("SELECT (.+) FROM boc_users").
WithArgs("erik@wavult.com").
WillReturnRows(sqlmock.NewRows([]string{
"id", "name", "email", "password_hash", "org_id", "roles",
}).AddRow(
userID, "Erik", "erik@wavult.com", string(hash), orgID, "{admin,viewer}",
))
// Expect UPDATE last_login
mock.ExpectExec("UPDATE boc_users SET last_login").
WithArgs(userID).
WillReturnResult(sqlmock.NewResult(1, 1))
resp, err := svc.Login(context.Background(), "erik@wavult.com", password)
require.NoError(t, err)
assert.NotEmpty(t, resp.Token)
assert.Equal(t, "Bearer", resp.TokenType)
assert.Equal(t, 86400, resp.ExpiresIn)
assert.Equal(t, userID, resp.User.ID)
assert.Equal(t, orgID, resp.User.OrgID)
assert.Equal(t, []string{"admin", "viewer"}, resp.User.Roles)
assert.NoError(t, mock.ExpectationsWereMet())
}
func TestService_Login_InvalidPassword(t *testing.T) {
svc, mock, db := setupAuthService(t)
defer db.Close()
hash, _ := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.DefaultCost)
mock.ExpectQuery("SELECT (.+) FROM boc_users").
WithArgs("erik@wavult.com").
WillReturnRows(sqlmock.NewRows([]string{
"id", "name", "email", "password_hash", "org_id", "roles",
}).AddRow(
"user-123", "Erik", "erik@wavult.com", string(hash), "org-456", "{admin}",
))
_, err := svc.Login(context.Background(), "erik@wavult.com", "wrong-password")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid credentials")
assert.NoError(t, mock.ExpectationsWereMet())
}
func TestService_Login_UserNotFound(t *testing.T) {
svc, mock, db := setupAuthService(t)
defer db.Close()
mock.ExpectQuery("SELECT (.+) FROM boc_users").
WithArgs("missing@wavult.com").
WillReturnError(sql.ErrNoRows)
_, err := svc.Login(context.Background(), "missing@wavult.com", "password")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid credentials")
assert.NoError(t, mock.ExpectationsWereMet())
}
func TestService_ValidateToken_Success(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
// Issue a token
token, err := svc.issueToken("user-123", "erik@wavult.com", "org-456", []string{"admin"})
require.NoError(t, err)
// Validate it
claims, err := svc.ValidateToken(token)
require.NoError(t, err)
assert.Equal(t, "user-123", claims.Sub)
assert.Equal(t, "erik@wavult.com", claims.Email)
assert.Equal(t, "org-456", claims.OrgID)
assert.Equal(t, []string{"admin"}, claims.Roles)
assert.Equal(t, "aamos-identity", claims.Iss)
assert.Equal(t, "boc", claims.Aud)
assert.True(t, claims.Exp > time.Now().Unix())
}
func TestService_ValidateToken_Expired(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
// Create an expired token manually
expiredToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMyIsImV4cCI6MTYwOTQ1OTIwMCwiaWF0IjoxNjA5NDU5MjAwfQ.WaJ6fZ1C8Y8aLm9X8Y8aLm9X8Y8aLm9X8Y8aLm9X8Y8"
_, err := svc.ValidateToken(expiredToken)
assert.Error(t, err)
}
func TestService_ValidateToken_InvalidSignature(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
// Token signed with different secret
invalidToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMyIsImV4cCI6OTk5OTk5OTk5OX0.invalid-signature"
_, err := svc.ValidateToken(invalidToken)
assert.Error(t, err)
}
func TestService_ValidateToken_MissingSub(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
// Token without sub claim
token, err := svc.issueToken("", "erik@wavult.com", "org-456", []string{"admin"})
require.NoError(t, err)
_, err = svc.ValidateToken(token)
assert.Error(t, err)
assert.Contains(t, err.Error(), "sub claim required")
}
func TestMiddleware_ValidToken(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
// Issue token
token, err := svc.issueToken("user-123", "erik@wavult.com", "org-456", []string{"admin"})
require.NoError(t, err)
// Create handler that checks claims
handler := svc.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := FromContext(r.Context())
require.True(t, ok)
assert.Equal(t, "user-123", claims.Sub)
assert.Equal(t, "erik@wavult.com", claims.Email)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestMiddleware_MissingHeader(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
handler := svc.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not reach handler")
}))
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestMiddleware_InvalidFormat(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
handler := svc.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not reach handler")
}))
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") // Basic auth, not Bearer
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestMiddleware_InvalidToken(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
handler := svc.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not reach handler")
}))
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestRequireRole_Success(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
handler := svc.RequireRole("admin", "superuser")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Create request with admin claims in context
claims := &Claims{Sub: "user-123", Roles: []string{"admin", "viewer"}}
ctx := WithClaims(context.Background(), claims)
req := httptest.NewRequest(http.MethodGet, "/api/admin", nil).WithContext(ctx)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestRequireRole_Forbidden(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
handler := svc.RequireRole("admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not reach handler")
}))
claims := &Claims{Sub: "user-123", Roles: []string{"viewer"}}
ctx := WithClaims(context.Background(), claims)
req := httptest.NewRequest(http.MethodGet, "/api/admin", nil).WithContext(ctx)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
}
func TestRequireRole_Unauthorized(t *testing.T) {
svc, _, db := setupAuthService(t)
defer db.Close()
handler := svc.RequireRole("admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not reach handler")
}))
req := httptest.NewRequest(http.MethodGet, "/api/admin", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestClaims_Valid(t *testing.T) {
// Valid claims
claims := &Claims{Sub: "user-123", Exp: time.Now().Unix() + 3600}
assert.NoError(t, claims.Valid())
// Missing sub
claims = &Claims{Sub: "", Exp: time.Now().Unix() + 3600}
assert.Error(t, claims.Valid())
// Expired
claims = &Claims{Sub: "user-123", Exp: time.Now().Unix() - 3600}
assert.Error(t, claims.Valid())
}
func TestFromContext_Missing(t *testing.T) {
_, ok := FromContext(context.Background())
assert.False(t, ok)
}
func TestWithClaims_RoundTrip(t *testing.T) {
original := &Claims{Sub: "user-123", Email: "test@example.com"}
ctx := WithClaims(context.Background(), original)
retrieved, ok := FromContext(ctx)
require.True(t, ok)
assert.Equal(t, original.Sub, retrieved.Sub)
assert.Equal(t, original.Email, retrieved.Email)
}