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
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { MembershipsModule } from './memberships/memberships.module';
import { OnboardingModule } from './onboarding/onboarding.module';
import { PilotModule } from './pilot/pilot.module';
import { PrismaModule } from './prisma/prisma.module';
import { PrivacyRetentionModule } from './privacy/privacy-retention.module';
import { StoreModule } from './store/store.module';
import { TenantContextGuard } from './tenant/guards/tenant-context.guard';
import { TenantContextModule } from './tenant/tenant-context.module';
Expand All @@ -35,6 +36,7 @@ import { WebhooksModule } from './webhooks/webhooks.module';
EntitlementsModule,
AuthModule,
PrismaModule,
PrivacyRetentionModule,
HealthModule,
TenantContextModule,
StoreModule,
Expand Down
32 changes: 31 additions & 1 deletion backend/src/orders/order-payload.mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ describe('WooCommerce order payload mapping', () => {
total_tax: '2.60',
},
customerSnapshot: {
customer_id: 7,
billing: { first_name: 'Jane', last_name: 'Doe' },
shipping: { city: 'Austin', country: 'US' },
},
paymentSnapshot: {
method: 'cod',
Expand All @@ -60,11 +61,40 @@ describe('WooCommerce order payload mapping', () => {
remoteDeletedAt: null,
});
expect(mapped.lineItemsSnapshot).toHaveLength(1);
expect(mapped.lineItemsSnapshot).toEqual([
{ name: 'Widget', quantity: 2, total: '20.00' },
]);
expect(JSON.stringify(mapped)).not.toContain('customer_id');
expect(mapped.wcCreatedAt.toISOString()).toBe('2026-07-23T10:00:00.000Z');
expect(mapped.wcModifiedAt.toISOString()).toBe('2026-07-23T10:05:00.000Z');
expect(mapped.projectionFingerprint).toMatch(/^[a-f0-9]{64}$/);
});

it('discards contact details, order notes and arbitrary item metadata', () => {
const mapped = mapWooCommerceOrder(
orderPayload({
billing: {
first_name: 'Jane',
email: 'private@example.test',
phone: 'private-phone',
},
shipping: { city: 'Austin', phone: 'private-phone' },
customer_note: 'private-note',
line_items: [
{
name: 'Widget',
quantity: 1,
total: '10',
meta_data: [{ key: 'private', value: 'private-note' }],
},
],
})
);

const stored = JSON.stringify(mapped);
expect(stored).not.toMatch(/private@example|private-phone|private-note/);
});

it('produces the same fingerprint regardless of source object key order', () => {
const left = mapWooCommerceOrder(orderPayload());
const right = mapWooCommerceOrder({
Expand Down
51 changes: 39 additions & 12 deletions backend/src/orders/order-payload.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ export function mapWooCommerceOrder(payload: unknown): OrderProjection {
totals[field] = value;
}

const billing = requireRecord(
const billingSource = requireRecord(
record['billing'],
'malformed-order-customer',
wcOrderId
);
const shipping = requireRecord(
const shippingSource = requireRecord(
record['shipping'],
'malformed-order-customer',
wcOrderId
Expand All @@ -101,6 +101,20 @@ export function mapWooCommerceOrder(payload: unknown): OrderProjection {
);
}

const billing = pickDisplayFields(billingSource, [
'first_name',
'last_name',
'company',
]);
const shipping = pickDisplayFields(shippingSource, [
'company',
'address_1',
'address_2',
'city',
'state',
'postcode',
'country',
]);
const projection: Omit<OrderProjection, 'projectionFingerprint'> = {
wcOrderId,
orderNumber: requireDisplayString(
Expand All @@ -120,11 +134,17 @@ export function mapWooCommerceOrder(payload: unknown): OrderProjection {
),
totals: canonicalizeJson(totals) as Prisma.InputJsonObject,
customerSnapshot: canonicalizeJson({
customer_id: normalizeCustomerId(record['customer_id']),
billing,
shipping,
}) as Prisma.InputJsonObject,
lineItemsSnapshot: canonicalizeJson(lineItems) as Prisma.InputJsonArray,
lineItemsSnapshot: canonicalizeJson(
lineItems.map((item) =>
pickDisplayFields(
requireRecord(item, 'malformed-order-line-items', wcOrderId),
['name', 'quantity', 'total']
)
)
) as Prisma.InputJsonArray,
paymentSnapshot: canonicalizeJson({
method: normalizeOptionalString(record['payment_method'], wcOrderId),
method_title: normalizeOptionalString(
Expand Down Expand Up @@ -329,16 +349,23 @@ function requireGmtDate(value: unknown, code: string, wcOrderId: string): Date {
return parsed;
}

function normalizeCustomerId(value: unknown): Prisma.InputJsonValue | null {
if (
value === null ||
(typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) ||
(typeof value === 'string' && /^\d+$/.test(value))
) {
return value;
function pickDisplayFields(
record: Record<string, unknown>,
fields: readonly string[]
): Record<string, string | number> {
const result: Record<string, string | number> = {};

for (const field of fields) {
const value = record[field];
if (
typeof value === 'string' ||
(typeof value === 'number' && Number.isFinite(value))
) {
result[field] = value;
}
}

throw new OrderPayloadMappingError('malformed-order-customer');
return result;
}

function normalizeOptionalString(
Expand Down
133 changes: 133 additions & 0 deletions backend/src/privacy/pilot-data.cli.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { afterEach, describe, expect, it, jest } from '@jest/globals';
import { PrismaClient, StoreStatus } from '@prisma/client';
import { createHash } from 'node:crypto';
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import {
appendLedger,
replayLedger,
verifyExportOutput,
} from './pilot-data.cli';
import type { PilotDataService } from './pilot-data.service';

const target = {
tenantId: 'ten_a',
storeId: 'sto_a',
baseUrl: 'https://shop.example.test',
};
const directories: string[] = [];

async function fixture() {
const directory = await mkdtemp(join(tmpdir(), 'wctm-erasure-'));
directories.push(directory);
const ledger = join(directory, 'ledger.jsonl');
await writeFile(ledger, '', { mode: 0o600 });
return ledger;
}

afterEach(async () => {
for (const directory of directories.splice(0)) {
await rm(directory, { recursive: true, force: true });
}
});

describe('external erasure ledger replay', () => {
it('records only scoped identity hashes and replays a restored Store before resumption', async () => {
const ledger = await fixture();
await appendLedger(ledger, target);
const content = await readFile(ledger, 'utf8');
expect(content).not.toContain(target.baseUrl);
const disconnect = jest.fn(async () => undefined);
const erase = jest.fn(async () => undefined);
const service = { disconnect, erase } as unknown as PilotDataService;
const database = {
store: {
findFirst: jest.fn(async () => ({
baseUrl: target.baseUrl,
status: StoreStatus.ACTIVE,
})),
},
} as unknown as PrismaClient;

await expect(
replayLedger(ledger, service, database, false)
).resolves.toMatchObject({
pending: 1,
executed: false,
});
expect(disconnect).not.toHaveBeenCalled();
await expect(
replayLedger(ledger, service, database, true)
).resolves.toMatchObject({
pending: 1,
executed: true,
});
expect(disconnect).toHaveBeenCalledWith(target);
expect(erase).toHaveBeenCalledWith(target);
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(
erase.mock.invocationCallOrder[0]!
);
});

it('fails closed when restored Store identity differs', async () => {
const ledger = await fixture();
await appendLedger(ledger, target);
const database = {
store: {
findFirst: jest.fn(async () => ({
baseUrl: 'https://other.example.test',
status: StoreStatus.ACTIVE,
})),
},
} as unknown as PrismaClient;
const service = {
disconnect: jest.fn(),
erase: jest.fn(),
} as unknown as PilotDataService;
await expect(replayLedger(ledger, service, database, true)).rejects.toThrow(
'does not match'
);
expect(service.disconnect).not.toHaveBeenCalled();
});
});

describe('protected privacy export path', () => {
it('accepts only the exact Tenant/Store artifact contract beside the ledger', async () => {
const ledger = await fixture();
const directory = join(ledger, '..');
const scope = createHash('sha256')
.update(`${target.tenantId}\0${target.storeId}`)
.digest('hex');
const name = `wctm-privacy-export-${scope}-${Date.now()}-0123456789abcdef.json`;
await expect(
verifyExportOutput(join(directory, name), ledger, target)
).resolves.toBeUndefined();
await expect(
verifyExportOutput(
join(directory, 'erasure-ledger.jsonl'),
ledger,
target
)
).rejects.toThrow('scoped artifact contract');
await expect(
verifyExportOutput(join(directory, '..', name), ledger, target)
).rejects.toThrow('scoped artifact contract');
await expect(
verifyExportOutput(
join(directory, name.replace(scope, '0'.repeat(64))),
ledger,
target
)
).rejects.toThrow('scoped artifact contract');
const link = join(directory, 'linked');
await symlink(directory, link);
await expect(
verifyExportOutput(join(link, name), ledger, target)
).rejects.toThrow('scoped artifact contract');
await expect(
verifyExportOutput(`${link}/../${name}`, ledger, target)
).rejects.toThrow('scoped artifact contract');
});
});
Loading
Loading