Files
cardclaws/cardclaws-backend/crates/cardclaws-api/tests/auth_test.rs
T
Omar SobhandClaude Opus 4.8 c30d3afeec
CI / policy (push) Successful in 0s
CI / mobile (push) Successful in 47s
CI / profile (push) Successful in 49s
CI / backend (push) Failing after 49s
Phase 1 foundation: backend, web profile, and mobile app
Greenfield implementation of CardClaws Phase 1 across three surfaces.

Backend (Rust/Axum workspace, 67 tests):
- cardclaws-types/config/db/auth/api/wallet crates
- Auth: register, login + lockout, magic link, refresh rotation, Apple verify
- Cards: CRUD, tier-limited publish, duplicate, public handle lookup
- Assets: R2 presigned uploads; vCard export
- Apple Wallet .pkpass pipeline (PKCS#7 signer behind apple-signing feature)
- Analytics ingest + summary with daily-salted IP hashing
- Migrations 0001 (incl. cardclaws_sessions) + 0002 analytics

Web profile (Astro SSR): cardclaws.com/[handle] hero + flip, contact actions,
client-built vCard, visit attribution. Verified end-to-end.

Mobile (Expo SDK 51): auth, card list/create, builder v1 (bg/text/logo,
palette, undo/redo), Skia/Reanimated viewer (flip + ambient). 22 logic tests.

CI: policy/backend/profile/mobile jobs; LOC + no-placeholder lint.

Review fixes baked in: `back` (not `cardclaws`) key; strip image is a bundled
manifest file (not a URL); sessions table added; NFC reframed; test doubles
allowed for external services.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-04 10:17:26 -05:00

201 lines
6.1 KiB
Rust

//! Integration tests for the auth surface (PRD §13.1, §15.2). Run against a real
//! Postgres via `TEST_DATABASE_URL`; skipped cleanly when that is unset.
mod common;
use axum::http::StatusCode;
use serde_json::json;
use common::unique_identity;
fn register_body(email: &str, handle: &str) -> serde_json::Value {
json!({
"email": email,
"password": "correct horse battery",
"handle": handle,
"display_name": "Test User",
})
}
#[tokio::test]
async fn register_success_returns_tokens() {
let app = require_app!();
let (email, handle) = unique_identity();
let (status, body) = app
.post("/v1/auth/register", register_body(&email, &handle))
.await;
assert_eq!(status, StatusCode::OK);
assert!(body["access_token"].as_str().is_some());
assert!(body["refresh_token"].as_str().is_some());
assert_eq!(body["user"]["handle"], handle);
assert_eq!(body["user"]["tier"], "free");
}
#[tokio::test]
async fn register_duplicate_is_conflict() {
let app = require_app!();
let (email, handle) = unique_identity();
let (s1, _) = app
.post("/v1/auth/register", register_body(&email, &handle))
.await;
assert_eq!(s1, StatusCode::OK);
let (s2, body) = app
.post("/v1/auth/register", register_body(&email, &handle))
.await;
assert_eq!(s2, StatusCode::CONFLICT);
assert_eq!(body["code"], "conflict");
}
#[tokio::test]
async fn register_rejects_invalid_handle() {
let app = require_app!();
let (email, _) = unique_identity();
let (status, body) = app
.post("/v1/auth/register", register_body(&email, "ab"))
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "validation");
}
#[tokio::test]
async fn login_success_then_wrong_password() {
let app = require_app!();
let (email, handle) = unique_identity();
app.post("/v1/auth/register", register_body(&email, &handle))
.await;
let (ok_status, body) = app
.post(
"/v1/auth/login",
json!({"email": email, "password": "correct horse battery"}),
)
.await;
assert_eq!(ok_status, StatusCode::OK);
assert!(body["access_token"].as_str().is_some());
let (bad_status, _) = app
.post(
"/v1/auth/login",
json!({"email": email, "password": "wrong"}),
)
.await;
assert_eq!(bad_status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn login_locks_out_after_ten_failures() {
let app = require_app!();
let (email, handle) = unique_identity();
app.post("/v1/auth/register", register_body(&email, &handle))
.await;
// 10 wrong attempts are unauthorized; the 11th trips the lockout.
for _ in 0..10 {
let (status, _) = app
.post(
"/v1/auth/login",
json!({"email": email, "password": "nope"}),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
let (status, body) = app
.post(
"/v1/auth/login",
json!({"email": email, "password": "nope"}),
)
.await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(body["code"], "rate_limited");
}
#[tokio::test]
async fn refresh_rotates_and_invalidates_old_token() {
let app = require_app!();
let (email, handle) = unique_identity();
let (_, reg) = app
.post("/v1/auth/register", register_body(&email, &handle))
.await;
let refresh = reg["refresh_token"].as_str().unwrap().to_string();
let (status, body) = app
.post("/v1/auth/refresh", json!({"refresh_token": refresh}))
.await;
assert_eq!(status, StatusCode::OK);
assert!(body["access_token"].as_str().is_some());
// The original refresh token must no longer work (rotation).
let (reuse_status, _) = app
.post("/v1/auth/refresh", json!({"refresh_token": refresh}))
.await;
assert_eq!(reuse_status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn logout_invalidates_refresh_token() {
let app = require_app!();
let (email, handle) = unique_identity();
let (_, reg) = app
.post("/v1/auth/register", register_body(&email, &handle))
.await;
let refresh = reg["refresh_token"].as_str().unwrap().to_string();
let (status, _) = app
.post("/v1/auth/logout", json!({"refresh_token": refresh}))
.await;
assert_eq!(status, StatusCode::OK);
let (after, _) = app
.post("/v1/auth/refresh", json!({"refresh_token": refresh}))
.await;
assert_eq!(after, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn magic_link_sends_email_and_verifies() {
let app = require_app!();
let (email, handle) = unique_identity();
app.post("/v1/auth/register", register_body(&email, &handle))
.await;
let (status, _) = app
.post("/v1/auth/magic-link/request", json!({"email": email}))
.await;
assert_eq!(status, StatusCode::OK);
let token = {
let sent = app.email.sent.lock().unwrap();
let msg = sent.iter().find(|m| m.to == email).expect("email sent");
extract_token(&msg.html)
};
let (verify_status, body) = app
.post("/v1/auth/magic-link/verify", json!({"token": token}))
.await;
assert_eq!(verify_status, StatusCode::OK);
assert_eq!(body["user"]["handle"], handle);
}
#[tokio::test]
async fn magic_link_unknown_email_sends_nothing() {
let app = require_app!();
let (email, _) = unique_identity();
let (status, _) = app
.post("/v1/auth/magic-link/request", json!({"email": email}))
.await;
// Always 200 to avoid enumeration, but no email is dispatched.
assert_eq!(status, StatusCode::OK);
let sent = app.email.sent.lock().unwrap();
assert!(sent.iter().all(|m| m.to != email));
}
/// Pull the `token=...` value out of the magic-link email HTML.
fn extract_token(html: &str) -> String {
let start = html.find("token=").expect("token in link") + "token=".len();
let rest = &html[start..];
let end = rest.find('"').unwrap_or(rest.len());
rest[..end].to_string()
}