Phase 2 backend: Google Wallet pass + profile contact form
- cardclaws-wallet::google — inline GenericObject + RS256 `savetowallet` JWT
(Rs256Signer via jsonwebtoken; FakeGoogleSigner test double)
- POST /v1/cards/{id}/wallet/google → { saveUrl }
- POST /v1/profile/{handle}/contact — validate, rate-limit, record
contact_form_submission, email the owner (HTML-escaped, Resend)
- WalletConfig gains google issuer id + service account email
84 backend tests; fmt + clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4770efd861
commit
8ae1d8f263
@@ -28,6 +28,7 @@ use cardclaws_auth::JwtKeys;
|
||||
use cardclaws_config::WalletConfig;
|
||||
use cardclaws_wallet::apple::signer::FakePassSigner;
|
||||
use cardclaws_wallet::apple::BrandAssets;
|
||||
use cardclaws_wallet::google::jwt_signer::FakeGoogleSigner;
|
||||
use cardclaws_wallet::strip_renderer;
|
||||
|
||||
/// Apple provider that never returns a usable key — fine for tests that don't
|
||||
@@ -92,8 +93,11 @@ pub async fn try_setup() -> Option<TestApp> {
|
||||
apple_pass_type_id: "pass.com.cardclaws.test".into(),
|
||||
apple_team_id: "TEST123".into(),
|
||||
organization_name: "CardClaws".into(),
|
||||
google_issuer_id: "3388000000000000000".into(),
|
||||
google_service_account_email: "[email protected]".into(),
|
||||
},
|
||||
pass_signer: Arc::new(FakePassSigner),
|
||||
google_signer: Arc::new(FakeGoogleSigner),
|
||||
brand: Arc::new(BrandAssets {
|
||||
icon_png: strip_renderer::render_solid(58, 58, "#ff3b30").unwrap(),
|
||||
logo_png: strip_renderer::render_solid(160, 50, "#ffffff").unwrap(),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Integration tests for the public profile contact form (PRD §11.4, §13.6).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use common::{unique_handle, TestApp};
|
||||
|
||||
async fn published_card(app: &TestApp, token: &str) -> 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;
|
||||
handle
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contact_form_emails_the_owner() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let handle = published_card(&app, &token).await;
|
||||
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/profile/{handle}/contact"),
|
||||
None,
|
||||
Some(json!({ "name": "Visitor", "email": "[email protected]", "message": "Loved your card!" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let sent = app.email.sent.lock().unwrap();
|
||||
let msg = sent.last().expect("an email was sent");
|
||||
assert!(msg.subject.contains("contact form"));
|
||||
assert!(msg.html.contains("Loved your card!"));
|
||||
assert!(msg.html.contains("[email protected]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contact_form_rejects_bad_input() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let handle = published_card(&app, &token).await;
|
||||
|
||||
let bad_email: Value = json!({ "name": "V", "email": "not-an-email", "message": "hi" });
|
||||
let (s1, b1) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/profile/{handle}/contact"),
|
||||
None,
|
||||
Some(bad_email),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s1, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(b1["code"], "validation");
|
||||
|
||||
let empty_name = json!({ "name": " ", "email": "[email protected]", "message": "hi" });
|
||||
let (s2, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/profile/{handle}/contact"),
|
||||
None,
|
||||
Some(empty_name),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s2, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contact_form_unknown_handle_is_404() {
|
||||
let app = require_app!();
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/profile/nobody-here/contact",
|
||||
None,
|
||||
Some(json!({ "name": "V", "email": "[email protected]", "message": "hi" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -72,6 +72,59 @@ async fn apple_pass_endpoint_returns_valid_pkpass() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn google_pass_endpoint_returns_save_url_with_object() {
|
||||
use base64::Engine;
|
||||
|
||||
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": { "title": "Founder", "company": "RedClaw" } }
|
||||
]}
|
||||
});
|
||||
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, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{id}/wallet/google"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let save_url = body["saveUrl"].as_str().unwrap();
|
||||
assert!(save_url.starts_with("https://pay.google.com/gp/v/save/"));
|
||||
|
||||
// Decode the JWT payload and confirm it carries our object + QR barcode.
|
||||
let jwt = save_url.trim_start_matches("https://pay.google.com/gp/v/save/");
|
||||
let parts: Vec<&str> = jwt.split('.').collect();
|
||||
assert_eq!(parts.len(), 3);
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.unwrap();
|
||||
let claims: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(claims["typ"], "savetowallet");
|
||||
let obj = &claims["payload"]["genericObjects"][0];
|
||||
assert_eq!(
|
||||
obj["barcode"]["value"],
|
||||
format!("https://cardclaws.test/{handle}")
|
||||
);
|
||||
assert_eq!(obj["header"]["defaultValue"]["value"], "Card Owner");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apple_pass_requires_auth() {
|
||||
let app = require_app!();
|
||||
|
||||
Reference in New Issue
Block a user