Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion frontend/src/lib/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('API Client', () => {
['getTransactionStatus', () => api.getTransactionStatus('0xdead'), 'GET', '/api/blockchain/tx/0xdead'],
['newsletterConfirm', () => api.newsletterConfirm('tok'), 'GET', '/api/v1/newsletter/confirm'],
['newsletterUnsubscribe', () => api.newsletterUnsubscribe('a@b.com'), 'DELETE', '/api/v1/newsletter/unsubscribe'],
['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'GET', '/api/v1/newsletter/gdpr/export'],
['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'POST', '/api/v1/newsletter/gdpr/export'],
['newsletterGdprDelete', () => api.newsletterGdprDelete('a@b.com'), 'DELETE', '/api/v1/newsletter/gdpr/delete'],
['resolveMarket', () => api.resolveMarket(3), 'POST', '/api/markets/3/resolve'],
['emailPreview', () => api.emailPreview('welcome'), 'GET', '/api/v1/email/preview/welcome'],
Expand Down Expand Up @@ -551,6 +551,27 @@ describe('API Client', () => {
});
});

describe('GDPR export (#1156)', () => {
it('sends email in the POST request body, not as a URL query parameter', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ success: true, data: {} }),
});

await api.newsletterGdprExport('user@example.com');

const [calledUrl, calledInit] = (global.fetch as jest.Mock).mock.calls[0];

// Email must NOT appear in the URL to prevent logging PII in access logs.
expect(calledUrl).not.toContain('user@example.com');
expect(calledUrl).not.toContain('email=');

// Email MUST be in the JSON body.
expect(calledInit.method).toBe('POST');
expect(JSON.parse(calledInit.body as string)).toEqual({ email: 'user@example.com' });
});
});

describe('DELETE requests', () => {
it('should handle DELETE requests with body', async () => {
const mockResponse = { success: true };
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,9 +448,9 @@ export const api = {

newsletterGdprExport: (email: string, signal?: AbortSignal) =>
request<{ success: boolean; data: Record<string, unknown> }>(
"GET",
"POST",
"/api/v1/newsletter/gdpr/export",
{ params: { email }, cacheTags: [CacheTag.NEWSLETTER], signal }
{ body: { email }, cacheTags: [CacheTag.NEWSLETTER], signal }
),

newsletterGdprDelete: (email: string, signal?: AbortSignal) =>
Expand Down
15 changes: 7 additions & 8 deletions services/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -387,17 +387,16 @@ paths:
$ref: "#/components/responses/ApiError"

/api/v1/newsletter/gdpr/export:
get:
post:
tags: [newsletter]
operationId: newsletterGdprExport
summary: GDPR data export for a subscriber
parameters:
- name: email
in: query
required: true
schema:
type: string
format: email
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EmailRequest"
responses:
"200":
description: Subscriber data
Expand Down
13 changes: 9 additions & 4 deletions services/api/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,11 @@ pub struct NewsletterExportQuery {
pub email: String,
}

#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct NewsletterExportBody {
pub email: String,
}

#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct NewsletterResponse {
pub success: bool,
Expand Down Expand Up @@ -634,10 +639,10 @@ pub async fn newsletter_unsubscribe(
}

#[utoipa::path(
get,
post,
path = "/api/v1/newsletter/gdpr/export",
tag = "newsletter",
params(NewsletterExportQuery),
request_body = NewsletterExportBody,
responses(
(status = 200, description = "GDPR data export", body = NewsletterExportResponse),
(status = 400, description = "Invalid email", body = NewsletterResponse),
Expand All @@ -649,7 +654,7 @@ pub async fn newsletter_gdpr_export(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
connect_info: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,
Query(query): Query<NewsletterExportQuery>,
Json(body): Json<NewsletterExportBody>,
) -> Result<Response, ApiError> {
use crate::security::extract_client_ip_cidrs;
let ip = extract_client_ip_cidrs(
Expand Down Expand Up @@ -677,7 +682,7 @@ pub async fn newsletter_gdpr_export(
.into_response());
}

let Some(email) = normalized_email(&query.email) else {
let Some(email) = normalized_email(&body.email) else {
return Ok((
StatusCode::BAD_REQUEST,
Json(NewsletterResponse {
Expand Down
2 changes: 1 addition & 1 deletion services/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ async fn main() -> anyhow::Result<()> {
.route("/api/v1/newsletter/subscribe", post(handlers::newsletter_subscribe))
.route("/api/v1/newsletter/confirm", get(handlers::newsletter_confirm))
.route("/api/v1/newsletter/unsubscribe", get(handlers::newsletter_unsubscribe))
.route("/api/v1/newsletter/gdpr/export", get(handlers::newsletter_gdpr_export))
.route("/api/v1/newsletter/gdpr/export", post(handlers::newsletter_gdpr_export))
.route("/api/v1/newsletter/gdpr/delete", axum::routing::delete(handlers::newsletter_gdpr_delete))
.layer(middleware::from_fn(correlation::correlation_id_middleware))
.layer(TraceLayer::new_for_http())
Expand Down
2 changes: 1 addition & 1 deletion services/api/tests/openapi_contract_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ mod tests {
("POST", "/api/v1/newsletter/subscribe"),
("GET", "/api/v1/newsletter/confirm"),
("DELETE", "/api/v1/newsletter/unsubscribe"),
("GET", "/api/v1/newsletter/gdpr/export"),
("POST", "/api/v1/newsletter/gdpr/export"),
("DELETE", "/api/v1/newsletter/gdpr/delete"),
("GET", "/api/v1/email/preview/{template_name}"),
("POST", "/api/v1/email/test"),
Expand Down
Loading