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]>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
//! Integration tests for analytics ingest + summary (PRD §13.5, §18).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use common::{unique_handle, TestApp};
|
||||
|
||||
async fn create_and_publish(app: &TestApp, token: &str) -> (String, String) {
|
||||
let handle = unique_handle();
|
||||
let def = json!({
|
||||
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
||||
"back": { "layers": [] }
|
||||
});
|
||||
let (_, created) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
Some(token),
|
||||
Some(json!({"handle": handle, "definition": def})),
|
||||
)
|
||||
.await;
|
||||
let id = created["id"].as_str().unwrap().to_string();
|
||||
app.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{id}/publish"),
|
||||
Some(token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
(id, handle)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_visit_recorded_on_public_lookup() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, handle) = create_and_publish(&app, &token).await;
|
||||
|
||||
// Two public lookups => two profile visits.
|
||||
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
||||
.await;
|
||||
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
||||
.await;
|
||||
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/cards/{id}/analytics"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["totalVisits"], 2);
|
||||
assert_eq!(body["visits24h"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_event_ingest_increments_summary() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, _) = create_and_publish(&app, &token).await;
|
||||
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/analytics/event",
|
||||
None,
|
||||
Some(json!({"card_id": id, "event_type": "contact_save"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let (_, body) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/cards/{id}/analytics"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(body["contactSaves"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingest_rejects_server_only_event_type() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, _) = create_and_publish(&app, &token).await;
|
||||
|
||||
// profile_visit is server-originated; clients can't post it.
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/analytics/event",
|
||||
None,
|
||||
Some(json!({"card_id": id, "event_type": "profile_visit"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "validation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_summary_requires_ownership() {
|
||||
let app = require_app!();
|
||||
let owner = app.register_and_token().await;
|
||||
let (id, _) = create_and_publish(&app, &owner).await;
|
||||
|
||||
let intruder = app.register_and_token().await;
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/cards/{id}/analytics"),
|
||||
Some(&intruder),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Integration tests for asset presigning + vCard export (PRD §13.2, §13.7).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use common::unique_handle;
|
||||
|
||||
#[tokio::test]
|
||||
async fn presign_upload_returns_key_and_url() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/assets/upload",
|
||||
Some(&token),
|
||||
Some(json!({"ext": "png"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let key = body["key"].as_str().unwrap();
|
||||
assert!(key.ends_with(".png"));
|
||||
assert!(key.starts_with("assets/"));
|
||||
assert!(body["upload_url"].as_str().unwrap().starts_with("https://"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn presign_upload_requires_auth() {
|
||||
let app = require_app!();
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/assets/upload",
|
||||
None,
|
||||
Some(json!({"ext": "png"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn presign_rejects_unsupported_extension() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/assets/upload",
|
||||
Some(&token),
|
||||
Some(json!({"ext": "exe"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "validation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_rejects_other_users_prefix() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
// A key under someone else's prefix must be forbidden.
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"DELETE",
|
||||
"/v1/assets/assets/00000000-0000-0000-0000-000000000000/x.png",
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "forbidden");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_own_asset_succeeds() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
|
||||
// Presign to learn our own key, then delete it.
|
||||
let (_, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/assets/upload",
|
||||
Some(&token),
|
||||
Some(json!({"ext": "png"})),
|
||||
)
|
||||
.await;
|
||||
let key = body["key"].as_str().unwrap().to_string();
|
||||
|
||||
// The delete route is /v1/assets/*key and the key itself starts with
|
||||
// "assets/", so the full path is /v1/assets/assets/{user}/{file}.png.
|
||||
let (status, _) = app
|
||||
.request("DELETE", &format!("/v1/assets/{key}"), Some(&token), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(app.assets.deleted.lock().unwrap().contains(&key));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_vcf_returns_vcard_with_contact() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
|
||||
let handle = unique_handle();
|
||||
let definition = json!({
|
||||
"face": { "layers": [] },
|
||||
"back": { "layers": [
|
||||
{ "type": "contact", "fields": {
|
||||
"phone": "+15551234567",
|
||||
"email": "[email protected]",
|
||||
"company": "RedClaw",
|
||||
"title": "Founder"
|
||||
}}
|
||||
]}
|
||||
});
|
||||
let (_, created) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
Some(&token),
|
||||
Some(json!({"handle": handle, "definition": definition})),
|
||||
)
|
||||
.await;
|
||||
let id = created["id"].as_str().unwrap();
|
||||
|
||||
let (status, content_type, body) = app
|
||||
.request_raw("GET", &format!("/v1/cards/{id}/export/vcf"), Some(&token))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(content_type.starts_with("text/vcard"));
|
||||
assert!(body.starts_with("BEGIN:VCARD"));
|
||||
assert!(body.contains("EMAIL:[email protected]"));
|
||||
assert!(body.contains("ORG:RedClaw"));
|
||||
assert!(body.trim_end().ends_with("END:VCARD"));
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! 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()
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Integration tests for the card surface (PRD §13.2, §15.2).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use common::{unique_handle, TestApp};
|
||||
|
||||
fn definition() -> Value {
|
||||
json!({
|
||||
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
||||
"back": { "layers": [], "background": { "type": "solid", "value": "#101014" } }
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a draft card; returns its id and handle.
|
||||
async fn create_card(app: &TestApp, token: &str) -> (String, String) {
|
||||
let handle = unique_handle();
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
Some(token),
|
||||
Some(json!({ "handle": handle, "definition": definition() })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "create failed: {body}");
|
||||
(body["id"].as_str().unwrap().to_string(), handle)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_card_success() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, handle) = create_card(&app, &token).await;
|
||||
assert!(!id.is_empty());
|
||||
|
||||
let (status, body) = app
|
||||
.request("GET", &format!("/v1/cards/{id}"), Some(&token), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["handle"], handle);
|
||||
assert_eq!(body["status"], "draft");
|
||||
assert_eq!(body["version"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_card_unauthorized() {
|
||||
let app = require_app!();
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
None,
|
||||
Some(json!({ "handle": unique_handle(), "definition": definition() })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_card_increments_version() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, _) = create_card(&app, &token).await;
|
||||
|
||||
let (s1, b1) = app
|
||||
.request(
|
||||
"PUT",
|
||||
&format!("/v1/cards/{id}"),
|
||||
Some(&token),
|
||||
Some(json!({ "definition": definition() })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s1, StatusCode::OK);
|
||||
assert_eq!(b1["version"], 2);
|
||||
|
||||
let (_, b2) = app
|
||||
.request(
|
||||
"PUT",
|
||||
&format!("/v1/cards/{id}"),
|
||||
Some(&token),
|
||||
Some(json!({ "definition": definition() })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(b2["version"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_merges_definition_keys() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, _) = create_card(&app, &token).await;
|
||||
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"PATCH",
|
||||
&format!("/v1/cards/{id}"),
|
||||
Some(&token),
|
||||
Some(json!({ "definition": { "settings": { "flipDurationMs": 250 } } })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
// Original face key survives; new settings key is merged in.
|
||||
assert!(body["definition"]["face"].is_object());
|
||||
assert_eq!(body["definition"]["settings"]["flipDurationMs"], 250);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_card_archives_not_destroys() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, _) = create_card(&app, &token).await;
|
||||
|
||||
let (status, body) = app
|
||||
.request("DELETE", &format!("/v1/cards/{id}"), Some(&token), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["status"], "archived");
|
||||
|
||||
// Row still exists and is fetchable by its owner.
|
||||
let (get_status, _) = app
|
||||
.request("GET", &format!("/v1/cards/{id}"), Some(&token), None)
|
||||
.await;
|
||||
assert_eq!(get_status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_respects_free_tier_limit() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await; // free tier => 1 active card
|
||||
let (id_a, _) = create_card(&app, &token).await;
|
||||
let (id_b, _) = create_card(&app, &token).await;
|
||||
|
||||
let (s_a, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{id_a}/publish"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s_a, StatusCode::OK);
|
||||
|
||||
let (s_b, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{id_b}/publish"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s_b, StatusCode::PAYMENT_REQUIRED);
|
||||
assert_eq!(body["code"], "tier_limit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_card_by_handle_public_then_404_when_archived() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, handle) = create_card(&app, &token).await;
|
||||
|
||||
// Draft is not publicly resolvable.
|
||||
let (draft_status, _) = app
|
||||
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
||||
.await;
|
||||
assert_eq!(draft_status, StatusCode::NOT_FOUND);
|
||||
|
||||
// Publish -> public access works without auth.
|
||||
app.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{id}/publish"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let (active_status, body) = app
|
||||
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
||||
.await;
|
||||
assert_eq!(active_status, StatusCode::OK);
|
||||
assert_eq!(body["handle"], handle);
|
||||
|
||||
// Archive -> no longer publicly resolvable.
|
||||
app.request("DELETE", &format!("/v1/cards/{id}"), Some(&token), None)
|
||||
.await;
|
||||
let (archived_status, _) = app
|
||||
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
||||
.await;
|
||||
assert_eq!(archived_status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cannot_access_another_users_card() {
|
||||
let app = require_app!();
|
||||
let owner = app.register_and_token().await;
|
||||
let (id, _) = create_card(&app, &owner).await;
|
||||
|
||||
let intruder = app.register_and_token().await;
|
||||
let (status, _) = app
|
||||
.request("GET", &format!("/v1/cards/{id}"), Some(&intruder), None)
|
||||
.await;
|
||||
// NotFound (not Forbidden) so existence isn't leaked.
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_creates_independent_draft() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, handle) = create_card(&app, &token).await;
|
||||
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{id}/duplicate"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_ne!(body["id"].as_str().unwrap(), id);
|
||||
assert_ne!(body["handle"].as_str().unwrap(), handle);
|
||||
assert_eq!(body["status"], "draft");
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Shared test harness: builds the real router against a test Postgres with
|
||||
//! in-memory cache, capturing email, and a no-network Apple key provider.
|
||||
//!
|
||||
//! Tests are skipped (not failed) when `TEST_DATABASE_URL` is unset, so a plain
|
||||
//! `cargo test` without a database still passes; CI sets it (see ci.yml).
|
||||
|
||||
// Each test binary (`auth_test`, `cards_test`, …) includes this whole module but
|
||||
// uses only a subset of its helpers, so unused-helper warnings are expected.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::Router;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use cardclaws_api::assets::InMemoryStore;
|
||||
use cardclaws_api::cache::InMemoryCache;
|
||||
use cardclaws_api::email::CapturingEmailSender;
|
||||
use cardclaws_api::{build_router, AppState};
|
||||
use cardclaws_auth::apple::{AppleAuthError, AppleJwks, JwkProvider};
|
||||
use cardclaws_auth::JwtKeys;
|
||||
use cardclaws_config::WalletConfig;
|
||||
use cardclaws_wallet::apple::signer::FakePassSigner;
|
||||
use cardclaws_wallet::apple::BrandAssets;
|
||||
use cardclaws_wallet::strip_renderer;
|
||||
|
||||
/// Apple provider that never returns a usable key — fine for tests that don't
|
||||
/// exercise the Apple happy path (which needs a signed token + private key).
|
||||
struct NoopApple;
|
||||
|
||||
#[async_trait]
|
||||
impl JwkProvider for NoopApple {
|
||||
async fn jwks(&self) -> Result<AppleJwks, AppleAuthError> {
|
||||
Ok(AppleJwks { keys: vec![] })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TestApp {
|
||||
pub router: Router,
|
||||
pub email: Arc<CapturingEmailSender>,
|
||||
pub assets: Arc<InMemoryStore>,
|
||||
}
|
||||
|
||||
/// Returns `None` when no test DB is configured (test should early-return).
|
||||
pub async fn try_setup() -> Option<TestApp> {
|
||||
let url = std::env::var("TEST_DATABASE_URL").ok()?;
|
||||
let db = cardclaws_db::connect(&url).await.expect("connect test db");
|
||||
cardclaws_db::migrate(&db).await.expect("run migrations");
|
||||
|
||||
let email = Arc::new(CapturingEmailSender::default());
|
||||
let assets = Arc::new(InMemoryStore::default());
|
||||
let state = AppState {
|
||||
db,
|
||||
cache: Arc::new(InMemoryCache::default()),
|
||||
email: email.clone(),
|
||||
assets: assets.clone(),
|
||||
jwt: JwtKeys::new("test-jwt-secret"),
|
||||
apple: Arc::new(NoopApple),
|
||||
apple_audience: "com.cardclaws.test".into(),
|
||||
profile_base_url: "https://cardclaws.test".into(),
|
||||
ip_hash_secret: "test-ip-salt".into(),
|
||||
wallet: WalletConfig {
|
||||
apple_pass_type_id: "pass.com.cardclaws.test".into(),
|
||||
apple_team_id: "TEST123".into(),
|
||||
organization_name: "CardClaws".into(),
|
||||
},
|
||||
pass_signer: Arc::new(FakePassSigner),
|
||||
brand: Arc::new(BrandAssets {
|
||||
icon_png: strip_renderer::render_solid(58, 58, "#ff3b30").unwrap(),
|
||||
logo_png: strip_renderer::render_solid(160, 50, "#ffffff").unwrap(),
|
||||
}),
|
||||
};
|
||||
|
||||
Some(TestApp {
|
||||
router: build_router(state),
|
||||
email,
|
||||
assets,
|
||||
})
|
||||
}
|
||||
|
||||
/// Print a skip notice and return — keeps `cargo test` green without a DB.
|
||||
#[macro_export]
|
||||
macro_rules! require_app {
|
||||
() => {{
|
||||
match $crate::common::try_setup().await {
|
||||
Some(app) => app,
|
||||
None => {
|
||||
eprintln!("skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
pub async fn post(&self, path: &str, body: Value) -> (StatusCode, Value) {
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
let resp = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
(status, json)
|
||||
}
|
||||
|
||||
/// Issue an arbitrary request, optionally authenticated and/or with a JSON
|
||||
/// body. Returns the status and parsed JSON body.
|
||||
pub async fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder().method(method).uri(path);
|
||||
if let Some(t) = token {
|
||||
builder = builder.header("authorization", format!("Bearer {t}"));
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => builder
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&b).unwrap()))
|
||||
.unwrap(),
|
||||
None => builder.body(Body::empty()).unwrap(),
|
||||
};
|
||||
let resp = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
(status, json)
|
||||
}
|
||||
|
||||
/// Like `request`, but returns the raw (status, content-type, body string)
|
||||
/// for non-JSON responses such as vCard downloads.
|
||||
pub async fn request_raw(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
) -> (StatusCode, String, String) {
|
||||
let mut builder = Request::builder().method(method).uri(path);
|
||||
if let Some(t) = token {
|
||||
builder = builder.header("authorization", format!("Bearer {t}"));
|
||||
}
|
||||
let resp = self
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(builder.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
(
|
||||
status,
|
||||
content_type,
|
||||
String::from_utf8_lossy(&bytes).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Like `request`, but returns the raw response bytes (for binary downloads
|
||||
/// such as `.pkpass`). Returns (status, content-type, body bytes).
|
||||
pub async fn request_bytes(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
) -> (StatusCode, String, Vec<u8>) {
|
||||
let mut builder = Request::builder().method(method).uri(path);
|
||||
if let Some(t) = token {
|
||||
builder = builder.header("authorization", format!("Bearer {t}"));
|
||||
}
|
||||
let resp = self
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(builder.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let bytes = resp
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec();
|
||||
(status, content_type, bytes)
|
||||
}
|
||||
|
||||
/// Register a fresh user and return its access token.
|
||||
pub async fn register_and_token(&self) -> String {
|
||||
let (email, handle) = unique_identity();
|
||||
let (status, body) = self
|
||||
.post(
|
||||
"/v1/auth/register",
|
||||
serde_json::json!({
|
||||
"email": email,
|
||||
"password": "correct horse battery",
|
||||
"handle": handle,
|
||||
"display_name": "Card Owner",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "registration failed: {body}");
|
||||
body["access_token"].as_str().unwrap().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a unique (email, handle) pair so tests sharing one DB never collide.
|
||||
pub fn unique_identity() -> (String, String) {
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let short = &id[..12];
|
||||
(format!("u{short}@cardclaws.test"), format!("u{short}"))
|
||||
}
|
||||
|
||||
/// Generate a unique card handle.
|
||||
pub fn unique_handle() -> String {
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
format!("c{}", &id[..12])
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Integration test for the Apple Wallet pass endpoint (PRD §13.3). Exercises
|
||||
//! the full path: card -> service -> wallet crate -> `.pkpass` bytes.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use common::unique_handle;
|
||||
|
||||
#[tokio::test]
|
||||
async fn apple_pass_endpoint_returns_valid_pkpass() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
|
||||
let handle = unique_handle();
|
||||
let definition = json!({
|
||||
"face": { "layers": [], "background": { "type": "solid", "value": "#202028" } },
|
||||
"back": { "layers": [
|
||||
{ "type": "contact", "fields": {
|
||||
"email": "[email protected]",
|
||||
"company": "RedClaw",
|
||||
"title": "Founder"
|
||||
}}
|
||||
]}
|
||||
});
|
||||
let (_, created) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
Some(&token),
|
||||
Some(json!({"handle": handle, "definition": definition})),
|
||||
)
|
||||
.await;
|
||||
let id = created["id"].as_str().unwrap();
|
||||
|
||||
let (status, content_type, bytes) = app
|
||||
.request_bytes(
|
||||
"POST",
|
||||
&format!("/v1/cards/{id}/wallet/apple"),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(content_type, "application/vnd.apple.pkpass");
|
||||
// A .pkpass is a zip — magic bytes "PK".
|
||||
assert_eq!(&bytes[0..2], b"PK");
|
||||
|
||||
// Open the bundle and confirm pass.json's QR points at this card's profile.
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let names: Vec<String> = (0..archive.len())
|
||||
.map(|i| archive.by_index(i).unwrap().name().to_string())
|
||||
.collect();
|
||||
assert!(names.iter().any(|n| n == "pass.json"));
|
||||
assert!(names.iter().any(|n| n == "strip.png"));
|
||||
assert!(names.iter().any(|n| n == "manifest.json"));
|
||||
assert!(names.iter().any(|n| n == "signature"));
|
||||
|
||||
let mut pass_json = String::new();
|
||||
archive
|
||||
.by_name("pass.json")
|
||||
.unwrap()
|
||||
.read_to_string(&mut pass_json)
|
||||
.unwrap();
|
||||
let pass: serde_json::Value = serde_json::from_str(&pass_json).unwrap();
|
||||
assert_eq!(
|
||||
pass["barcode"]["message"],
|
||||
format!("https://cardclaws.test/{handle}")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apple_pass_requires_auth() {
|
||||
let app = require_app!();
|
||||
let (status, _, _) = app
|
||||
.request_bytes(
|
||||
"POST",
|
||||
"/v1/cards/00000000-0000-0000-0000-000000000000/wallet/apple",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
Reference in New Issue
Block a user