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) }