use cm_auth::{AuthError, AuthService}; use cm_domain::{Role, User, UserId, Workspace, WorkspaceId}; async fn seeded(pool: &sqlx::PgPool) -> (Workspace, User) { let ws = Workspace { id: WorkspaceId::new(), name: "Acme".into(), plan: "team".into(), }; cm_db::repo::workspaces::insert(pool, &ws).await.unwrap(); let user = User { id: UserId::new(), workspace_id: ws.id, email: "owner@acme.test".into(), role: Role::Owner, display_name: "Owner".into(), created_at: time::OffsetDateTime::UNIX_EPOCH, }; cm_db::repo::users::insert(pool, &user).await.unwrap(); (ws, user) } #[tokio::test] async fn register_login_authenticate_round_trip() { let pool = cm_testkit::test_pool().await; let (ws, user) = seeded(&pool).await; let auth = AuthService::new(pool); auth.set_password(user.id, "correct horse battery staple") .await .unwrap(); let token = auth .login_local("owner@acme.test", "correct horse battery staple") .await .unwrap(); let authed = auth.authenticate(token.secret()).await.unwrap(); assert_eq!(authed.user_id, user.id); assert_eq!(authed.workspace_id, ws.id); assert_eq!(authed.role, Role::Owner); assert!(authed.role.is_owner()); } #[tokio::test] async fn wrong_password_is_rejected_without_detail() { let pool = cm_testkit::test_pool().await; let (_, user) = seeded(&pool).await; let auth = AuthService::new(pool); auth.set_password(user.id, "right").await.unwrap(); let err = auth .login_local("owner@acme.test", "wrong") .await .unwrap_err(); assert!(matches!(err, AuthError::InvalidCredentials)); } #[tokio::test] async fn unknown_email_is_the_same_error_as_wrong_password() { let pool = cm_testkit::test_pool().await; let auth = AuthService::new(pool); let err = auth.login_local("ghost@acme.test", "pw").await.unwrap_err(); // Indistinguishable from a wrong password: no account enumeration. assert!(matches!(err, AuthError::InvalidCredentials)); } #[tokio::test] async fn user_without_password_cannot_login_locally() { let pool = cm_testkit::test_pool().await; let (_, _user) = seeded(&pool).await; let auth = AuthService::new(pool); let err = auth.login_local("owner@acme.test", "pw").await.unwrap_err(); assert!(matches!(err, AuthError::InvalidCredentials)); } #[tokio::test] async fn unknown_token_is_unauthenticated() { let pool = cm_testkit::test_pool().await; let auth = AuthService::new(pool); let err = auth.authenticate("not-a-real-token").await.unwrap_err(); assert!(matches!(err, AuthError::Unauthenticated)); } #[tokio::test] async fn expired_session_is_unauthenticated() { let pool = cm_testkit::test_pool().await; let (_, user) = seeded(&pool).await; let auth = AuthService::new(pool.clone()); auth.set_password(user.id, "pw").await.unwrap(); let token = auth.login_local("owner@acme.test", "pw").await.unwrap(); sqlx::query("UPDATE auth_sessions SET expires_at = now() - interval '1 minute'") .execute(&pool) .await .unwrap(); let err = auth.authenticate(token.secret()).await.unwrap_err(); assert!(matches!(err, AuthError::Unauthenticated)); } #[tokio::test] async fn logout_invalidates_the_token() { let pool = cm_testkit::test_pool().await; let (_, user) = seeded(&pool).await; let auth = AuthService::new(pool); auth.set_password(user.id, "pw").await.unwrap(); let token = auth.login_local("owner@acme.test", "pw").await.unwrap(); auth.authenticate(token.secret()).await.unwrap(); auth.logout(token.secret()).await.unwrap(); let err = auth.authenticate(token.secret()).await.unwrap_err(); assert!(matches!(err, AuthError::Unauthenticated)); } #[tokio::test] async fn tokens_are_unique_per_login() { let pool = cm_testkit::test_pool().await; let (_, user) = seeded(&pool).await; let auth = AuthService::new(pool); auth.set_password(user.id, "pw").await.unwrap(); let a = auth.login_local("owner@acme.test", "pw").await.unwrap(); let b = auth.login_local("owner@acme.test", "pw").await.unwrap(); assert_ne!(a.secret(), b.secret()); // Both remain valid concurrently (multiple devices). auth.authenticate(a.secret()).await.unwrap(); auth.authenticate(b.secret()).await.unwrap(); }