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,31 @@
|
||||
[package]
|
||||
name = "cardclaws-wallet"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Real Apple PKCS#7 signing pulls in the (heavy) openssl dependency. Off by
|
||||
# default so the crate and its tests build fast against the fake signer; the
|
||||
# production binary enables it. The signing code is fully implemented either way.
|
||||
default = []
|
||||
apple-signing = ["dep:openssl"]
|
||||
|
||||
[dependencies]
|
||||
cardclaws-types = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
image = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
@@ -0,0 +1,50 @@
|
||||
//! `manifest.json`: a map of every bundled filename to the SHA-1 hash of its
|
||||
//! contents (PassKit spec). The strip image is included here — that is the whole
|
||||
//! point of the plan's A1 correction.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
/// Build the manifest JSON string from the file set. Keys are sorted (BTreeMap)
|
||||
/// so the output is deterministic.
|
||||
pub fn build_manifest(files: &BTreeMap<String, Vec<u8>>) -> String {
|
||||
let entries: BTreeMap<&String, String> = files
|
||||
.iter()
|
||||
.map(|(name, bytes)| (name, sha1_hex(bytes)))
|
||||
.collect();
|
||||
// Hand-serialize to guarantee stable key ordering regardless of serde_json
|
||||
// map feature flags.
|
||||
let body = entries
|
||||
.iter()
|
||||
.map(|(name, hash)| format!("\"{name}\":\"{hash}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("{{{body}}}")
|
||||
}
|
||||
|
||||
fn sha1_hex(bytes: &[u8]) -> String {
|
||||
let digest = Sha1::digest(bytes);
|
||||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manifest_hashes_match_file_contents() {
|
||||
let mut files = BTreeMap::new();
|
||||
files.insert("pass.json".to_string(), b"{}".to_vec());
|
||||
files.insert("strip.png".to_string(), b"\x89PNG".to_vec());
|
||||
|
||||
let manifest = build_manifest(&files);
|
||||
|
||||
// Known SHA-1 of "{}" is bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f.
|
||||
assert!(manifest.contains("\"pass.json\":\"bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f\""));
|
||||
// Strip image MUST appear in the manifest (the A1 correction).
|
||||
assert!(manifest.contains("\"strip.png\":\""));
|
||||
let parsed: serde_json::Value = serde_json::from_str(&manifest).unwrap();
|
||||
assert!(parsed.get("strip.png").is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Apple `.pkpass` generation.
|
||||
|
||||
pub mod manifest;
|
||||
pub mod packager;
|
||||
pub mod pass_builder;
|
||||
pub mod signer;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::error::WalletError;
|
||||
use crate::strip_renderer;
|
||||
use signer::PassSigner;
|
||||
|
||||
/// Everything needed to build one pass. Identity (`pass_type_id`, `team_id`) and
|
||||
/// brand assets come from config; the rest from the card.
|
||||
pub struct PassInput {
|
||||
pub serial_number: String,
|
||||
pub pass_type_id: String,
|
||||
pub team_id: String,
|
||||
pub organization_name: String,
|
||||
pub holder_name: String,
|
||||
pub title: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub website: Option<String>,
|
||||
/// The profile URL encoded in the QR barcode (PRD §6.3.1).
|
||||
pub profile_url: String,
|
||||
/// Hex background color used to render the strip image.
|
||||
pub background_hex: String,
|
||||
}
|
||||
|
||||
/// Brand assets bundled into every pass (CardClaws icon + logo PNGs).
|
||||
pub struct BrandAssets {
|
||||
pub icon_png: Vec<u8>,
|
||||
pub logo_png: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Build a complete, signed `.pkpass` zip in memory.
|
||||
///
|
||||
/// Pipeline (PRD §10.1, corrected): build pass.json → render strip → assemble
|
||||
/// the file set → hash every file into manifest.json → sign the manifest →
|
||||
/// zip it all.
|
||||
pub fn build_pkpass(
|
||||
input: &PassInput,
|
||||
brand: &BrandAssets,
|
||||
signer: &dyn PassSigner,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let pass_json = serde_json::to_vec(&pass_builder::build_pass_json(input))
|
||||
.map_err(|e| WalletError::Build(e.to_string()))?;
|
||||
let strip_png = strip_renderer::render_strip(&input.background_hex)?;
|
||||
|
||||
// BTreeMap keeps file ordering deterministic (stable manifest + zip).
|
||||
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
files.insert("pass.json".into(), pass_json);
|
||||
files.insert("icon.png".into(), brand.icon_png.clone());
|
||||
files.insert("logo.png".into(), brand.logo_png.clone());
|
||||
files.insert("strip.png".into(), strip_png);
|
||||
|
||||
let manifest_json = manifest::build_manifest(&files);
|
||||
let signature = signer.sign_manifest(manifest_json.as_bytes())?;
|
||||
|
||||
packager::package(&files, &manifest_json, &signature)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Zips the bundle into a `.pkpass`. Contents: every payload/brand file, plus
|
||||
//! `manifest.json` and the detached `signature`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::ZipWriter;
|
||||
|
||||
use crate::error::WalletError;
|
||||
|
||||
pub fn package(
|
||||
files: &BTreeMap<String, Vec<u8>>,
|
||||
manifest_json: &str,
|
||||
signature: &[u8],
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
let mut write = |name: &str, bytes: &[u8]| -> Result<(), WalletError> {
|
||||
zip.start_file(name, opts)
|
||||
.map_err(|e| WalletError::Packaging(e.to_string()))?;
|
||||
zip.write_all(bytes)
|
||||
.map_err(|e| WalletError::Packaging(e.to_string()))?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
for (name, bytes) in files {
|
||||
write(name, bytes)?;
|
||||
}
|
||||
write("manifest.json", manifest_json.as_bytes())?;
|
||||
write("signature", signature)?;
|
||||
|
||||
let cursor = zip
|
||||
.finish()
|
||||
.map_err(|e| WalletError::Packaging(e.to_string()))?;
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
|
||||
#[test]
|
||||
fn produces_openable_zip_with_required_entries() {
|
||||
let mut files = BTreeMap::new();
|
||||
files.insert("pass.json".to_string(), b"{}".to_vec());
|
||||
files.insert("strip.png".to_string(), b"\x89PNG".to_vec());
|
||||
|
||||
let zip_bytes = package(&files, "{\"pass.json\":\"abc\"}", b"sig").unwrap();
|
||||
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
|
||||
let mut names: Vec<String> = (0..archive.len())
|
||||
.map(|i| archive.by_index(i).unwrap().name().to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["manifest.json", "pass.json", "signature", "strip.png"]
|
||||
);
|
||||
|
||||
let mut manifest = String::new();
|
||||
archive
|
||||
.by_name("manifest.json")
|
||||
.unwrap()
|
||||
.read_to_string(&mut manifest)
|
||||
.unwrap();
|
||||
assert!(manifest.contains("pass.json"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Constructs `pass.json` per the PassKit spec (PRD §6.3.1). Type `generic`.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::PassInput;
|
||||
|
||||
pub fn build_pass_json(input: &PassInput) -> Value {
|
||||
let mut secondary = Vec::new();
|
||||
if let Some(company) = &input.company {
|
||||
secondary.push(json!({ "key": "company", "label": "COMPANY", "value": company }));
|
||||
}
|
||||
if let Some(email) = &input.email {
|
||||
secondary.push(json!({ "key": "email", "label": "EMAIL", "value": email }));
|
||||
}
|
||||
|
||||
let mut auxiliary = Vec::new();
|
||||
if let Some(phone) = &input.phone {
|
||||
auxiliary.push(json!({ "key": "phone", "label": "PHONE", "value": phone }));
|
||||
}
|
||||
if let Some(website) = &input.website {
|
||||
auxiliary.push(json!({ "key": "website", "label": "WEBSITE", "value": website }));
|
||||
}
|
||||
|
||||
let mut back = vec![json!({
|
||||
"key": "profile",
|
||||
"label": "PROFILE",
|
||||
"value": input.profile_url,
|
||||
})];
|
||||
if let Some(email) = &input.email {
|
||||
back.push(json!({ "key": "back_email", "label": "EMAIL", "value": email }));
|
||||
}
|
||||
|
||||
json!({
|
||||
"formatVersion": 1,
|
||||
"passTypeIdentifier": input.pass_type_id,
|
||||
"teamIdentifier": input.team_id,
|
||||
"organizationName": input.organization_name,
|
||||
"serialNumber": input.serial_number,
|
||||
"description": format!("{}'s CardClaws card", input.holder_name),
|
||||
"barcode": {
|
||||
"format": "PKBarcodeFormatQR",
|
||||
"message": input.profile_url,
|
||||
"messageEncoding": "iso-8859-1",
|
||||
},
|
||||
"barcodes": [{
|
||||
"format": "PKBarcodeFormatQR",
|
||||
"message": input.profile_url,
|
||||
"messageEncoding": "iso-8859-1",
|
||||
}],
|
||||
"generic": {
|
||||
"headerFields": [
|
||||
{ "key": "name", "value": input.holder_name }
|
||||
],
|
||||
"primaryFields": [
|
||||
{ "key": "title", "value": input.title.clone().unwrap_or_default() }
|
||||
],
|
||||
"secondaryFields": secondary,
|
||||
"auxiliaryFields": auxiliary,
|
||||
"backFields": back,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input() -> PassInput {
|
||||
PassInput {
|
||||
serial_number: "serial-123".into(),
|
||||
pass_type_id: "pass.com.cardclaws.card".into(),
|
||||
team_id: "TEAM123".into(),
|
||||
organization_name: "CardClaws".into(),
|
||||
holder_name: "Omar Sobh".into(),
|
||||
title: Some("Founder".into()),
|
||||
company: Some("RedClaw".into()),
|
||||
email: Some("[email protected]".into()),
|
||||
phone: Some("+15551234567".into()),
|
||||
website: Some("https://redclaw.dev".into()),
|
||||
profile_url: "https://cardclaws.com/omar".into(),
|
||||
background_hex: "#101014".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_json_has_required_passkit_fields() {
|
||||
let pass = build_pass_json(&input());
|
||||
for key in [
|
||||
"formatVersion",
|
||||
"passTypeIdentifier",
|
||||
"teamIdentifier",
|
||||
"organizationName",
|
||||
"serialNumber",
|
||||
"description",
|
||||
"barcode",
|
||||
"generic",
|
||||
] {
|
||||
assert!(pass.get(key).is_some(), "missing required field {key}");
|
||||
}
|
||||
assert_eq!(pass["formatVersion"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn barcode_message_is_profile_url() {
|
||||
let pass = build_pass_json(&input());
|
||||
assert_eq!(pass["barcode"]["message"], "https://cardclaws.com/omar");
|
||||
assert_eq!(pass["barcode"]["format"], "PKBarcodeFormatQR");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Manifest signing. PassKit requires `signature` to be a PKCS#7 **detached**
|
||||
//! DER signature over `manifest.json`, made with the Pass Type ID certificate,
|
||||
//! its private key, and the Apple WWDR intermediate in the chain (PRD §20.3).
|
||||
//!
|
||||
//! The real signer uses OpenSSL and is gated behind the `apple-signing` feature
|
||||
//! so default builds/tests stay fast. Tests use [`FakePassSigner`].
|
||||
|
||||
use crate::error::WalletError;
|
||||
|
||||
pub trait PassSigner: Send + Sync {
|
||||
/// Produce the detached PKCS#7 DER signature over `manifest`.
|
||||
fn sign_manifest(&self, manifest: &[u8]) -> Result<Vec<u8>, WalletError>;
|
||||
}
|
||||
|
||||
/// Deterministic non-cryptographic signer for tests. Produces a stable,
|
||||
/// non-empty byte string so bundle assembly can be verified without certs.
|
||||
pub struct FakePassSigner;
|
||||
|
||||
impl PassSigner for FakePassSigner {
|
||||
fn sign_manifest(&self, manifest: &[u8]) -> Result<Vec<u8>, WalletError> {
|
||||
let mut sig = b"FAKE-PKCS7-SIGNATURE:".to_vec();
|
||||
sig.extend_from_slice(&(manifest.len() as u32).to_be_bytes());
|
||||
Ok(sig)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "apple-signing")]
|
||||
mod openssl_signer {
|
||||
use openssl::pkcs7::{Pkcs7, Pkcs7Flags};
|
||||
use openssl::pkey::{PKey, Private};
|
||||
use openssl::stack::Stack;
|
||||
use openssl::x509::X509;
|
||||
|
||||
use super::PassSigner;
|
||||
use crate::error::WalletError;
|
||||
|
||||
/// Production signer holding the Pass Type ID cert + key and the WWDR
|
||||
/// intermediate, loaded once at startup and kept in memory (never on disk).
|
||||
pub struct OpenSslSigner {
|
||||
cert: X509,
|
||||
pkey: PKey<Private>,
|
||||
wwdr: X509,
|
||||
}
|
||||
|
||||
impl OpenSslSigner {
|
||||
/// Load from a PKCS#12 (P12) blob (cert + key) and the WWDR intermediate
|
||||
/// in PEM. `p12_password` unlocks the P12.
|
||||
pub fn from_p12(
|
||||
p12_der: &[u8],
|
||||
p12_password: &str,
|
||||
wwdr_pem: &[u8],
|
||||
) -> Result<Self, WalletError> {
|
||||
let p12 = openssl::pkcs12::Pkcs12::from_der(p12_der)
|
||||
.map_err(|e| WalletError::Signing(e.to_string()))?;
|
||||
let parsed = p12
|
||||
.parse2(p12_password)
|
||||
.map_err(|e| WalletError::Signing(e.to_string()))?;
|
||||
let cert = parsed
|
||||
.cert
|
||||
.ok_or_else(|| WalletError::Signing("P12 missing certificate".into()))?;
|
||||
let pkey = parsed
|
||||
.pkey
|
||||
.ok_or_else(|| WalletError::Signing("P12 missing private key".into()))?;
|
||||
let wwdr = X509::from_pem(wwdr_pem).map_err(|e| WalletError::Signing(e.to_string()))?;
|
||||
Ok(Self { cert, pkey, wwdr })
|
||||
}
|
||||
}
|
||||
|
||||
impl PassSigner for OpenSslSigner {
|
||||
fn sign_manifest(&self, manifest: &[u8]) -> Result<Vec<u8>, WalletError> {
|
||||
let mut certs = Stack::new().map_err(|e| WalletError::Signing(e.to_string()))?;
|
||||
certs
|
||||
.push(self.wwdr.clone())
|
||||
.map_err(|e| WalletError::Signing(e.to_string()))?;
|
||||
// Detached + binary: the signature does not embed the manifest.
|
||||
let flags = Pkcs7Flags::DETACHED | Pkcs7Flags::BINARY;
|
||||
let pkcs7 = Pkcs7::sign(&self.cert, &self.pkey, &certs, manifest, flags)
|
||||
.map_err(|e| WalletError::Signing(e.to_string()))?;
|
||||
pkcs7
|
||||
.to_der()
|
||||
.map_err(|e| WalletError::Signing(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "apple-signing")]
|
||||
pub use openssl_signer::OpenSslSigner;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fake_signer_is_deterministic_and_nonempty() {
|
||||
let s = FakePassSigner;
|
||||
let a = s.sign_manifest(b"manifest").unwrap();
|
||||
let b = s.sign_manifest(b"manifest").unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert!(!a.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WalletError {
|
||||
#[error("pass build error: {0}")]
|
||||
Build(String),
|
||||
#[error("strip render error: {0}")]
|
||||
Render(String),
|
||||
#[error("manifest error: {0}")]
|
||||
Manifest(String),
|
||||
#[error("signing error: {0}")]
|
||||
Signing(String),
|
||||
#[error("packaging error: {0}")]
|
||||
Packaging(String),
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Wallet pass generation (PRD §10). Phase 1 implements the Apple `.pkpass`
|
||||
//! pipeline; Google Wallet (JWT) lands in Phase 2.
|
||||
//!
|
||||
//! KEY CORRECTION vs. the PRD draft (plan review A1): the strip image is a
|
||||
//! **bundled file** inside the `.pkpass` zip and is included in the manifest
|
||||
//! SHA-1 hashes — it is NOT an `stripImage` URL. The pipeline here renders the
|
||||
//! strip, writes it into the bundle, hashes every file in the manifest, signs
|
||||
//! the manifest (PKCS#7 detached), and zips everything.
|
||||
|
||||
pub mod apple;
|
||||
pub mod error;
|
||||
pub mod strip_renderer;
|
||||
|
||||
pub use error::WalletError;
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Renders the pass strip image and other bundled PNGs.
|
||||
//!
|
||||
//! Phase 1 renders a solid fill derived from the card's background color, at the
|
||||
//! PassKit strip dimensions (1125x432 @3x, PRD §6.3.4). A later milestone will
|
||||
//! composite the actual card-face top-third; the bundle contract (a real PNG
|
||||
//! hashed into the manifest) is identical either way.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use image::{ImageFormat, Rgba, RgbaImage};
|
||||
|
||||
use crate::error::WalletError;
|
||||
|
||||
/// PassKit strip image size at @3x (PRD §6.3.4).
|
||||
pub const STRIP_WIDTH: u32 = 1125;
|
||||
pub const STRIP_HEIGHT: u32 = 432;
|
||||
|
||||
/// Render the strip image as a solid fill of `background_hex`.
|
||||
pub fn render_strip(background_hex: &str) -> Result<Vec<u8>, WalletError> {
|
||||
render_solid(STRIP_WIDTH, STRIP_HEIGHT, background_hex)
|
||||
}
|
||||
|
||||
/// Render a solid-color PNG. Used for the strip and for placeholder brand glyphs
|
||||
/// until real assets are supplied.
|
||||
pub fn render_solid(width: u32, height: u32, hex: &str) -> Result<Vec<u8>, WalletError> {
|
||||
let [r, g, b] = parse_hex(hex)?;
|
||||
let img = RgbaImage::from_pixel(width, height, Rgba([r, g, b, 255]));
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, ImageFormat::Png)
|
||||
.map_err(|e| WalletError::Render(e.to_string()))?;
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
/// Parse `#RRGGBB` (or `RRGGBB`) into RGB bytes.
|
||||
fn parse_hex(hex: &str) -> Result<[u8; 3], WalletError> {
|
||||
let h = hex.trim_start_matches('#');
|
||||
if h.len() != 6 {
|
||||
return Err(WalletError::Render(format!("invalid hex color: {hex}")));
|
||||
}
|
||||
let parse = |s: &str| u8::from_str_radix(s, 16);
|
||||
match (parse(&h[0..2]), parse(&h[2..4]), parse(&h[4..6])) {
|
||||
(Ok(r), Ok(g), Ok(b)) => Ok([r, g, b]),
|
||||
_ => Err(WalletError::Render(format!("invalid hex color: {hex}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_is_valid_png_of_expected_size() {
|
||||
let bytes = render_strip("#101014").unwrap();
|
||||
// PNG magic number.
|
||||
assert_eq!(
|
||||
&bytes[0..8],
|
||||
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
);
|
||||
let decoded = image::load_from_memory(&bytes).unwrap();
|
||||
assert_eq!(decoded.width(), STRIP_WIDTH);
|
||||
assert_eq!(decoded.height(), STRIP_HEIGHT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_hex() {
|
||||
assert!(render_solid(10, 10, "nope").is_err());
|
||||
assert!(render_solid(10, 10, "#12345").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! End-to-end Apple pass tests (PRD §15.2). Uses the fake signer so no cert is
|
||||
//! needed; the bundle structure, manifest hashes, strip image, zip, and QR URL
|
||||
//! are all exercised against the real pipeline.
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use cardclaws_wallet::apple::signer::FakePassSigner;
|
||||
use cardclaws_wallet::apple::{build_pkpass, BrandAssets, PassInput};
|
||||
use cardclaws_wallet::strip_renderer;
|
||||
|
||||
fn input() -> PassInput {
|
||||
PassInput {
|
||||
serial_number: "card-abc-123".into(),
|
||||
pass_type_id: "pass.com.cardclaws.card".into(),
|
||||
team_id: "TEAM123".into(),
|
||||
organization_name: "CardClaws".into(),
|
||||
holder_name: "Omar Sobh".into(),
|
||||
title: Some("Founder & CEO".into()),
|
||||
company: Some("RedClaw Systems".into()),
|
||||
email: Some("[email protected]".into()),
|
||||
phone: Some("+15551234567".into()),
|
||||
website: Some("https://redclaw.dev".into()),
|
||||
profile_url: "https://cardclaws.com/omar".into(),
|
||||
background_hex: "#101014".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn brand() -> BrandAssets {
|
||||
BrandAssets {
|
||||
icon_png: strip_renderer::render_solid(58, 58, "#ff3b30").unwrap(),
|
||||
logo_png: strip_renderer::render_solid(160, 50, "#ffffff").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build() -> zip::ZipArchive<Cursor<Vec<u8>>> {
|
||||
let bytes = build_pkpass(&input(), &brand(), &FakePassSigner).unwrap();
|
||||
zip::ZipArchive::new(Cursor::new(bytes)).unwrap()
|
||||
}
|
||||
|
||||
fn read_entry(archive: &mut zip::ZipArchive<Cursor<Vec<u8>>>, name: &str) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
archive
|
||||
.by_name(name)
|
||||
.unwrap()
|
||||
.read_to_end(&mut buf)
|
||||
.unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_packager_produces_valid_zip() {
|
||||
let mut archive = build();
|
||||
let mut names: Vec<String> = (0..archive.len())
|
||||
.map(|i| archive.by_index(i).unwrap().name().to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"icon.png",
|
||||
"logo.png",
|
||||
"manifest.json",
|
||||
"pass.json",
|
||||
"signature",
|
||||
"strip.png",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pass_json_structure_valid() {
|
||||
let mut archive = build();
|
||||
let pass: serde_json::Value =
|
||||
serde_json::from_slice(&read_entry(&mut archive, "pass.json")).unwrap();
|
||||
for key in [
|
||||
"formatVersion",
|
||||
"passTypeIdentifier",
|
||||
"teamIdentifier",
|
||||
"serialNumber",
|
||||
"generic",
|
||||
"barcode",
|
||||
] {
|
||||
assert!(pass.get(key).is_some(), "pass.json missing {key}");
|
||||
}
|
||||
assert_eq!(pass["generic"]["headerFields"][0]["value"], "Omar Sobh");
|
||||
assert_eq!(
|
||||
pass["generic"]["primaryFields"][0]["value"],
|
||||
"Founder & CEO"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_sha1_correct() {
|
||||
let mut archive = build();
|
||||
let manifest: serde_json::Value =
|
||||
serde_json::from_slice(&read_entry(&mut archive, "manifest.json")).unwrap();
|
||||
|
||||
// Every bundled file (including strip.png) must have a manifest entry whose
|
||||
// hash matches the actual bytes in the zip.
|
||||
for name in ["pass.json", "icon.png", "logo.png", "strip.png"] {
|
||||
let bytes = read_entry(&mut archive, name);
|
||||
let expected = sha1_hex(&bytes);
|
||||
assert_eq!(
|
||||
manifest[name].as_str().unwrap(),
|
||||
expected,
|
||||
"manifest hash mismatch for {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_image_is_bundled_not_a_url() {
|
||||
// The plan's A1 correction: strip image is a real bundled PNG referenced in
|
||||
// the manifest, and pass.json carries NO stripImage URL field.
|
||||
let mut archive = build();
|
||||
let strip = read_entry(&mut archive, "strip.png");
|
||||
assert_eq!(&strip[0..4], &[0x89, b'P', b'N', b'G']);
|
||||
|
||||
let pass: serde_json::Value =
|
||||
serde_json::from_slice(&read_entry(&mut archive, "pass.json")).unwrap();
|
||||
assert!(pass.get("stripImage").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signature_present_and_nonempty() {
|
||||
let mut archive = build();
|
||||
let sig = read_entry(&mut archive, "signature");
|
||||
assert!(!sig.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pass_qr_url_matches_profile() {
|
||||
let mut archive = build();
|
||||
let pass: serde_json::Value =
|
||||
serde_json::from_slice(&read_entry(&mut archive, "pass.json")).unwrap();
|
||||
assert_eq!(pass["barcode"]["message"], "https://cardclaws.com/omar");
|
||||
}
|
||||
|
||||
fn sha1_hex(bytes: &[u8]) -> String {
|
||||
use sha1::{Digest, Sha1};
|
||||
Sha1::digest(bytes)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
Reference in New Issue
Block a user