Phase 1 foundation: backend, web profile, and mobile app
CI / policy (push) Successful in 0s
CI / mobile (push) Successful in 47s
CI / profile (push) Successful in 49s
CI / backend (push) Failing after 49s

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:
Omar Sobh
2026-06-04 10:17:26 -05:00
co-authored by Claude Opus 4.8
commit c30d3afeec
128 changed files with 36279 additions and 0 deletions
@@ -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);
}