Phase 4 + GDPR: team analytics, central templates, account export/deletion
Teams:
- GET /v1/teams/{id}/analytics (admin) — per-card visits/qr/saves/clicks across
members, sortable (whitelisted columns); …/analytics.csv exports RFC-4180 CSV
- Central template library (migration 0004 team_templates):
POST|DELETE /v1/teams/{id}/templates[/{templateId}] (admin), GET (member)
GDPR (§18.3):
- GET /v1/account/export — full JSON dump via Postgres json_agg (no password hash)
- DELETE /v1/account — hard delete, FK cascade removes all owned data
107 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
2b8e23dd3f
commit
826ef69030
@@ -0,0 +1,117 @@
|
||||
//! GDPR data export + account deletion (PRD §18.3).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use common::{unique_handle, TestApp};
|
||||
|
||||
async fn create_card(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;
|
||||
(created["id"].as_str().unwrap().to_string(), handle)
|
||||
}
|
||||
|
||||
async fn count(app: &TestApp, sql: &str, id: &str) -> i64 {
|
||||
let uuid = uuid::Uuid::parse_str(id).unwrap();
|
||||
let (n,): (i64,) = sqlx::query_as(sql)
|
||||
.bind(uuid)
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
n
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_includes_user_and_cards() {
|
||||
let app = require_app!();
|
||||
let (token, user_id) = app.register_and_user().await;
|
||||
let (_, handle) = create_card(&app, &token).await;
|
||||
|
||||
let (status, body) = app
|
||||
.request("GET", "/v1/account/export", Some(&token), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
assert_eq!(body["user"]["id"], user_id);
|
||||
// The password hash must never be exported.
|
||||
assert!(body["user"].get("passwordHash").is_none());
|
||||
assert!(body["user"].get("password_hash").is_none());
|
||||
|
||||
let cards = body["cards"].as_array().unwrap();
|
||||
assert_eq!(cards.len(), 1);
|
||||
assert_eq!(cards[0]["handle"], handle);
|
||||
|
||||
// Other sections are present as arrays.
|
||||
for key in [
|
||||
"shareLinks",
|
||||
"analyticsEvents",
|
||||
"walletRegistrations",
|
||||
"teamMemberships",
|
||||
] {
|
||||
assert!(body[key].is_array(), "{key} should be an array");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_requires_auth() {
|
||||
let app = require_app!();
|
||||
let (status, _) = app.request("GET", "/v1/account/export", None, None).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_account_cascades() {
|
||||
let app = require_app!();
|
||||
let (token, user_id) = app.register_and_user().await;
|
||||
let (card_id, _) = create_card(&app, &token).await;
|
||||
// Give the card a share link + event so cascade coverage is meaningful.
|
||||
app.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{card_id}/share"),
|
||||
Some(&token),
|
||||
Some(json!({"modality": "qr"})),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (status, _) = app
|
||||
.request("DELETE", "/v1/account", Some(&token), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// User and all owned rows are gone.
|
||||
assert_eq!(
|
||||
count(&app, "SELECT COUNT(*) FROM users WHERE id = $1", &user_id).await,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
count(
|
||||
&app,
|
||||
"SELECT COUNT(*) FROM cards WHERE owner_id = $1",
|
||||
&user_id
|
||||
)
|
||||
.await,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
count(
|
||||
&app,
|
||||
"SELECT COUNT(*) FROM share_links WHERE card_id = $1",
|
||||
&card_id
|
||||
)
|
||||
.await,
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -192,6 +192,192 @@ async fn non_member_cannot_view_or_admin_team() {
|
||||
assert_eq!(add_status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn team_aggregate_analytics_covers_members_cards() {
|
||||
let app = require_app!();
|
||||
let (token, _, team_id) = team_owner(&app, None).await;
|
||||
|
||||
// The owner publishes a card and gets a couple of profile visits.
|
||||
let handle = common::unique_handle();
|
||||
let def = json!({
|
||||
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
||||
"back": { "layers": [] }
|
||||
});
|
||||
let (_, card) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
Some(&token),
|
||||
Some(json!({ "handle": handle, "definition": def })),
|
||||
)
|
||||
.await;
|
||||
let card_id = card["id"].as_str().unwrap();
|
||||
app.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{card_id}/publish"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
||||
.await;
|
||||
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
||||
.await;
|
||||
|
||||
// JSON aggregate (admin/owner only) includes that card with 2 visits.
|
||||
let (status, rows) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/teams/{team_id}/analytics?sort=visits"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let arr = rows.as_array().unwrap();
|
||||
let row = arr
|
||||
.iter()
|
||||
.find(|r| r["handle"] == handle)
|
||||
.expect("card present");
|
||||
assert_eq!(row["visits"], 2);
|
||||
|
||||
// CSV export.
|
||||
let (csv_status, content_type, body) = app
|
||||
.request_raw(
|
||||
"GET",
|
||||
&format!("/v1/teams/{team_id}/analytics.csv"),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(csv_status, StatusCode::OK);
|
||||
assert!(content_type.starts_with("text/csv"));
|
||||
assert!(body.starts_with("handle,owner,visits,qr_scans,contact_saves,link_clicks"));
|
||||
assert!(body.contains(&handle));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn team_analytics_requires_admin() {
|
||||
let app = require_app!();
|
||||
let (owner_token, _, team_id) = team_owner(&app, None).await;
|
||||
let (member_token, member_id) = app.register_and_user().await;
|
||||
let member_email = app_email(&app, &member_id).await;
|
||||
app.request(
|
||||
"POST",
|
||||
&format!("/v1/teams/{team_id}/members"),
|
||||
Some(&owner_token),
|
||||
Some(json!({ "email": member_email, "role": "member" })),
|
||||
)
|
||||
.await;
|
||||
|
||||
// A plain member cannot view team analytics.
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/teams/{team_id}/analytics"),
|
||||
Some(&member_token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_manages_templates_members_can_list() {
|
||||
let app = require_app!();
|
||||
let (owner_token, _, team_id) = team_owner(&app, None).await;
|
||||
|
||||
// Add a plain member.
|
||||
let (member_token, member_id) = app.register_and_user().await;
|
||||
let member_email = app_email(&app, &member_id).await;
|
||||
app.request(
|
||||
"POST",
|
||||
&format!("/v1/teams/{team_id}/members"),
|
||||
Some(&owner_token),
|
||||
Some(json!({ "email": member_email, "role": "member" })),
|
||||
)
|
||||
.await;
|
||||
|
||||
let template = json!({
|
||||
"name": "Corporate",
|
||||
"definition": {
|
||||
"face": { "layers": [], "background": { "type": "solid", "value": "#1b1b2f" } },
|
||||
"back": { "layers": [] }
|
||||
}
|
||||
});
|
||||
|
||||
// Admin/owner creates a template.
|
||||
let (create_status, created) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/teams/{team_id}/templates"),
|
||||
Some(&owner_token),
|
||||
Some(template.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_status, StatusCode::OK);
|
||||
let template_id = created["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(created["name"], "Corporate");
|
||||
|
||||
// A plain member can list it.
|
||||
let (list_status, list) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/teams/{team_id}/templates"),
|
||||
Some(&member_token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(list_status, StatusCode::OK);
|
||||
assert_eq!(list.as_array().unwrap().len(), 1);
|
||||
|
||||
// A plain member cannot create one.
|
||||
let (forbidden, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/teams/{team_id}/templates"),
|
||||
Some(&member_token),
|
||||
Some(template),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(forbidden, StatusCode::FORBIDDEN);
|
||||
|
||||
// Admin deletes it.
|
||||
let (del_status, _) = app
|
||||
.request(
|
||||
"DELETE",
|
||||
&format!("/v1/teams/{team_id}/templates/{template_id}"),
|
||||
Some(&owner_token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(del_status, StatusCode::OK);
|
||||
let (_, after) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/teams/{team_id}/templates"),
|
||||
Some(&owner_token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(after.as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn template_definition_must_be_object() {
|
||||
let app = require_app!();
|
||||
let (token, _, team_id) = team_owner(&app, None).await;
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/teams/{team_id}/templates"),
|
||||
Some(&token),
|
||||
Some(json!({ "name": "Bad", "definition": "not-an-object" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["code"], "validation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plain_member_cannot_add_members() {
|
||||
let app = require_app!();
|
||||
|
||||
Reference in New Issue
Block a user