test(phase9): add commerce integration acceptance suite
19/19: pricing in the public serializer, checkout gated on agreements, server-authoritative amount, webhook signature verification and idempotency, payment success/failure/abandonment, last-seat checkout race, reservation reaper, manual refund with no cascade, minor guardian routing, admin/RBAC payment-data restrictions, audit/metric hygiene, the status-lookup endpoint, and confirmation that ROOT_ V1/free-offering/classroom flows are unaffected.
This commit is contained in:
+15
-3
@@ -118,12 +118,24 @@ further instructor accounts from the Instructors tab).
|
|||||||
(sequential and concurrent idempotency), required-agreement conversion blocking with version
|
(sequential and concurrent idempotency), required-agreement conversion blocking with version
|
||||||
snapshotting, guardian notification recipient resolution, and confirmation that ROOT_ V1/self-
|
snapshotting, guardian notification recipient resolution, and confirmation that ROOT_ V1/self-
|
||||||
paced/classroom flows remain unaffected.
|
paced/classroom flows remain unaffected.
|
||||||
|
- `test/e2e-phase9.js` — offering pricing in the public serializer, checkout blocked until
|
||||||
|
agreements are accepted, the server-authoritative amount (never caller-supplied), webhook
|
||||||
|
signature verification and idempotency, payment success driving real conversion/activation, a
|
||||||
|
failed payment leaving the seat intact vs. an abandoned checkout releasing it, the reservation
|
||||||
|
reaper, a last-seat checkout race, manual refund with no cascade into registration/enrollment
|
||||||
|
status, minor payment confirmations routing to the guardian, the admin payment view (never card
|
||||||
|
data), instructor/public RBAC on payment data, audit/metric hygiene (no secrets, no
|
||||||
|
high-cardinality labels), the website status-lookup endpoint's minimal safe shape, and
|
||||||
|
confirmation that ROOT_ V1/free-offering/classroom flows remain unaffected.
|
||||||
|
|
||||||
`infra/scripts/drills/` also holds real-Lab dress rehearsals per phase (Phase 6D/6E foundations,
|
`infra/scripts/drills/` also holds real-Lab dress rehearsals per phase (Phase 6D/6E foundations,
|
||||||
Phase 6F's multi-student pilot and full end-to-end V1 journey, Phase 7/8's Winter Break Camp
|
Phase 6F's multi-student pilot and full end-to-end V1 journey, Phase 7/8's Winter Break Camp
|
||||||
rehearsals) — see each script's own header and `docs/PHASE6F_ROOT_V1_PILOT_READINESS.md`/`docs/
|
rehearsals, Phase 9's Winter Break COMMERCE rehearsal —
|
||||||
PHASE8_ENROLLMENT_OPERATIONS.md` for what they prove beyond what the e2e suites
|
`test-phase9-winter-camp-commerce.js`, 13/13, real checkout/webhook against the deterministic
|
||||||
above already cover via direct API.
|
test payment provider, no live money) — see each script's own header and
|
||||||
|
`docs/PHASE6F_ROOT_V1_PILOT_READINESS.md`/`docs/PHASE8_ENROLLMENT_OPERATIONS.md`/`docs/
|
||||||
|
PHASE9_COMMERCE_AND_LAUNCH.md` for what they prove beyond what the e2e suites above already
|
||||||
|
cover via direct API.
|
||||||
|
|
||||||
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||||
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
|
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Phase 9 — Commerce Integration acceptance test. Price configuration, the public price
|
||||||
|
// serializer, checkout eligibility (agreements/registration-window/price), server-authoritative
|
||||||
|
// amount, verified webhook processing (signature, idempotency, success/failure/refund),
|
||||||
|
// registration confirmation -> enrollment conversion -> activation, guardian recipient routing,
|
||||||
|
// capacity/seat-reservation behavior, abandoned-reservation release, admin payment visibility,
|
||||||
|
// RBAC, audit/metric hygiene, and confirmation ROOT_ V1/Phase 8 registration/classroom flows
|
||||||
|
// are all unaffected. Uses the 'test' payment provider throughout — no real Stripe credentials,
|
||||||
|
// no real money, deterministic.
|
||||||
|
//
|
||||||
|
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||||
|
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||||
|
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase9.js
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const { makeSession } = require('./session-helper');
|
||||||
|
const { getPaymentProvider } = require('../api/src/payment-provider');
|
||||||
|
const commerce = require('../api/src/commerce');
|
||||||
|
const { Pool } = require('../api/node_modules/pg');
|
||||||
|
|
||||||
|
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||||
|
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||||
|
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||||
|
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
|
||||||
|
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||||
|
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const RUN_ID = Date.now();
|
||||||
|
const PROGRAM = 'root-kubernetes-foundations';
|
||||||
|
const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' });
|
||||||
|
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
async function test(name, fn) {
|
||||||
|
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||||
|
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||||
|
}
|
||||||
|
function psql(sql) {
|
||||||
|
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||||
|
}
|
||||||
|
async function integrationPost(path, body) {
|
||||||
|
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
|
||||||
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||||
|
}
|
||||||
|
async function integrationGet(path) {
|
||||||
|
const r = await fetch(`${GW}${path}`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
|
||||||
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||||
|
}
|
||||||
|
async function webhookPost(body, signature) {
|
||||||
|
const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Test-Payment-Signature': signature }, body });
|
||||||
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupPaidOffering(owner, { offeringId, slug, capacity, requireAgreement = true }) {
|
||||||
|
await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: `Test Offering ${offeringId}`, programId: PROGRAM, deliveryMode: 'camp', capacity, priceAmount: 15000, priceCurrency: 'usd' });
|
||||||
|
if (requireAgreement) {
|
||||||
|
const agreementId = `agree-${offeringId}`;
|
||||||
|
await owner.request('POST', '/api/admin/agreements', { agreementId, title: `Agreement for ${offeringId}` }).catch(() => {});
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
|
||||||
|
}
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Phase 9 Commerce Integration acceptance test\n');
|
||||||
|
const owner = makeSession(GW);
|
||||||
|
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
|
||||||
|
const provider = getPaymentProvider();
|
||||||
|
assert.equal(provider.name, 'test', 'this suite must run against the deterministic test provider, never real Stripe');
|
||||||
|
|
||||||
|
// ── 1/2: price configuration, public price serializer ────────────────────────────────────
|
||||||
|
const offeringId = `off-p9-${RUN_ID}`, slug = `p9-camp-${RUN_ID}`;
|
||||||
|
await test('an offering price is configured and reflected in the public serializer, never leaking internal fields', async () => {
|
||||||
|
await setupPaidOffering(owner, { offeringId, slug, capacity: 3 });
|
||||||
|
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
|
||||||
|
assert.equal(pub.price_amount, 15000);
|
||||||
|
assert.equal(pub.price_currency, 'usd');
|
||||||
|
assert.equal(pub.requires_payment, true);
|
||||||
|
for (const key of ['transaction_id', 'provider_reference', 'reserved_seats', 'organization_id']) assert.ok(!(key in pub), `must not leak ${key}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3/4: checkout eligibility, missing agreements rejected ───────────────────────────────
|
||||||
|
let registrationId;
|
||||||
|
await test('checkout is rejected until required agreements are accepted', async () => {
|
||||||
|
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reg-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Test Adult', email: `testadult-${RUN_ID}@example.com` } });
|
||||||
|
assert.equal(reg.status, 201);
|
||||||
|
registrationId = reg.body.registration_id;
|
||||||
|
const blocked = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||||
|
assert.equal(blocked.status, 409);
|
||||||
|
assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 5: closed registration rejected ───────────────────────────────────────────────────────
|
||||||
|
await test('registration is rejected on a draft/closed offering', async () => {
|
||||||
|
const draftOffering = `off-p9-draft-${RUN_ID}`;
|
||||||
|
await owner.request('POST', '/api/admin/offerings', { offeringId: draftOffering, slug: `p9-draft-${RUN_ID}`, name: 'Draft', programId: PROGRAM, deliveryMode: 'workshop' });
|
||||||
|
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-draft-evt-${RUN_ID}`, offering_slug: `p9-draft-${RUN_ID}`, registrant: { display_name: 'Nope', email: `nope-${RUN_ID}@example.com` } });
|
||||||
|
assert.equal(reg.status, 409);
|
||||||
|
assert.equal(reg.body.error, 'OFFERING_NOT_OPEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 6: full offering rejected ─────────────────────────────────────────────────────────────
|
||||||
|
await test('a full offering rejects further registrations', async () => {
|
||||||
|
const fullOffering = `off-p9-full-${RUN_ID}`, fullSlug = `p9-full-${RUN_ID}`;
|
||||||
|
await setupPaidOffering(owner, { offeringId: fullOffering, slug: fullSlug, capacity: 1, requireAgreement: false });
|
||||||
|
const r1 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-1-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'A', email: `fa-${RUN_ID}@example.com` } });
|
||||||
|
assert.equal(r1.status, 201);
|
||||||
|
const r2 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-2-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'B', email: `fb-${RUN_ID}@example.com` } });
|
||||||
|
assert.equal(r2.status, 409);
|
||||||
|
assert.equal(r2.body.error, 'OFFERING_FULL');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 7/8: server-authoritative amount, checkout creation ──────────────────────────────────
|
||||||
|
let transactionId, providerReference;
|
||||||
|
await test('accepting the agreement unblocks checkout, and the amount always comes from the offering, never the caller', async () => {
|
||||||
|
const agreementId = `agree-${offeringId}`;
|
||||||
|
const accept = await integrationPost(`/api/integrations/registrations/${registrationId}/agreements`, { agreement_id: agreementId });
|
||||||
|
assert.equal(accept.status, 201);
|
||||||
|
const checkout = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c', amount: 1 });
|
||||||
|
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
|
||||||
|
transactionId = checkout.body.transaction_id;
|
||||||
|
const tx = psql(`SELECT amount FROM payment_transactions WHERE transaction_id = '${transactionId}'`);
|
||||||
|
assert.equal(Number(tx), 15000, 'the transaction amount must come from the offering, ignoring any amount the caller sent');
|
||||||
|
const txRow = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||||
|
providerReference = txRow.body[0].provider_reference;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 9/10/11: webhook signature, invalid rejection, idempotency ───────────────────────────
|
||||||
|
await test('webhook signature is required, invalid signatures are rejected, and valid events are idempotent', async () => {
|
||||||
|
const event = provider.buildTestEvent({ providerReference, eventType: 'checkout.session.completed' });
|
||||||
|
const bad = await webhookPost(event.body, 'wrong-signature');
|
||||||
|
assert.equal(bad.status, 400);
|
||||||
|
assert.equal(bad.body.error, 'invalid_signature');
|
||||||
|
const first = await webhookPost(event.body, event.signature);
|
||||||
|
assert.equal(first.status, 200);
|
||||||
|
assert.equal(first.body.result, 'processed');
|
||||||
|
const replay = await webhookPost(event.body, event.signature);
|
||||||
|
assert.equal(replay.status, 200);
|
||||||
|
assert.equal(replay.body.result, 'duplicate');
|
||||||
|
const count = psql(`SELECT count(*) FROM payment_transactions WHERE transaction_id = '${transactionId}' AND status = 'paid'`);
|
||||||
|
assert.equal(Number(count), 1, 'a duplicate webhook must never double-process a payment');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 12/14/15/16: payment success, registration confirmation, conversion, activation ──────
|
||||||
|
let convertedStudentId;
|
||||||
|
await test('payment success confirms the registration, converts it to a real enrollment, and queues activation', async () => {
|
||||||
|
const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||||
|
assert.equal(reg.body.status, 'enrollment_activated');
|
||||||
|
convertedStudentId = reg.body.student_id;
|
||||||
|
assert.ok(convertedStudentId);
|
||||||
|
const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${reg.body.enrollment_id}'`);
|
||||||
|
assert.equal(enrollmentStatus, 'active');
|
||||||
|
const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`);
|
||||||
|
const types = notifs.body.map((n) => n.notification_type);
|
||||||
|
assert.ok(types.includes('registration_received') && types.includes('registration_confirmed') && types.includes('activation'), `expected all three, got ${types}`);
|
||||||
|
// no duplicate registration_confirmed
|
||||||
|
assert.equal(types.filter((t) => t === 'registration_confirmed').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 13: payment failure ────────────────────────────────────────────────────────────────────
|
||||||
|
await test('a failed payment does not release the seat and leaves the registration retriable', async () => {
|
||||||
|
const failOffering = `off-p9-failtest-${RUN_ID}`, failSlug = `p9-failtest-${RUN_ID}`;
|
||||||
|
await setupPaidOffering(owner, { offeringId: failOffering, slug: failSlug, capacity: 2, requireAgreement: false });
|
||||||
|
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-failreg-${RUN_ID}`, offering_slug: failSlug, registrant: { display_name: 'Fail', email: `fail-${RUN_ID}@example.com` } });
|
||||||
|
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||||
|
const txRow = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
|
||||||
|
const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'payment_intent.payment_failed' });
|
||||||
|
const result = await webhookPost(event.body, event.signature);
|
||||||
|
assert.equal(result.body.result, 'processed');
|
||||||
|
const offAfter = await owner.request('GET', `/api/admin/offerings/${failOffering}`);
|
||||||
|
assert.equal(offAfter.body.reserved_seats, 1, 'a failed payment must not release the seat');
|
||||||
|
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
|
||||||
|
assert.equal(regAfter.body.status, 'started', 'a failed payment leaves the registration open to retry, not cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 19/21: capacity handling, last-seat race ─────────────────────────────────────────────
|
||||||
|
await test('a last-seat race between two concurrent registration attempts yields exactly one winner', async () => {
|
||||||
|
const raceOffering = `off-p9-race-${RUN_ID}`, raceSlug = `p9-race-${RUN_ID}`;
|
||||||
|
await setupPaidOffering(owner, { offeringId: raceOffering, slug: raceSlug, capacity: 1, requireAgreement: false });
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
integrationPost('/api/integrations/registrations', { event_id: `p9-race-a-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceA', email: `ra-${RUN_ID}@example.com` } }),
|
||||||
|
integrationPost('/api/integrations/registrations', { event_id: `p9-race-b-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceB', email: `rb-${RUN_ID}@example.com` } }),
|
||||||
|
]);
|
||||||
|
const statuses = [a.status, b.status].sort();
|
||||||
|
assert.deepEqual(statuses, [201, 409]);
|
||||||
|
const off = await owner.request('GET', `/api/admin/offerings/${raceOffering}`);
|
||||||
|
assert.equal(off.body.reserved_seats, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 20/21: abandoned reservation release ─────────────────────────────────────────────────
|
||||||
|
await test('an expired, unpaid reservation is released by the reaper and its registration cancelled', async () => {
|
||||||
|
const reapOffering = `off-p9-reap-${RUN_ID}`, reapSlug = `p9-reap-${RUN_ID}`;
|
||||||
|
await setupPaidOffering(owner, { offeringId: reapOffering, slug: reapSlug, capacity: 2, requireAgreement: false });
|
||||||
|
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reap-evt-${RUN_ID}`, offering_slug: reapSlug, registrant: { display_name: 'Reap', email: `reap-${RUN_ID}@example.com` } });
|
||||||
|
psql(`UPDATE offering_registrations SET reservation_expires_at = now() - interval '1 minute' WHERE registration_id = '${reg.body.registration_id}'`);
|
||||||
|
const result = await commerce.reapExpiredReservations(pool);
|
||||||
|
assert.ok(result.released >= 1);
|
||||||
|
const offAfter = await owner.request('GET', `/api/admin/offerings/${reapOffering}`);
|
||||||
|
assert.equal(offAfter.body.reserved_seats, 0);
|
||||||
|
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
|
||||||
|
assert.equal(regAfter.body.status, 'cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 22: refund recording ──────────────────────────────────────────────────────────────────
|
||||||
|
await test('a manual refund is recorded without cascading into registration/enrollment cancellation', async () => {
|
||||||
|
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||||
|
const paid = tx.body.find((t) => t.status === 'paid');
|
||||||
|
const refund = await owner.request('POST', `/api/admin/payments/${paid.transaction_id}/refund`, { status: 'refunded' });
|
||||||
|
assert.equal(refund.status, 200);
|
||||||
|
assert.equal(refund.body.status, 'refunded');
|
||||||
|
const regAfter = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||||
|
assert.equal(regAfter.body.status, 'enrollment_activated', 'a refund must never auto-cancel the registration');
|
||||||
|
const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${regAfter.body.enrollment_id}'`);
|
||||||
|
assert.equal(enrollmentStatus, 'active', 'a refund must never auto-cancel the academic enrollment');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 17: guardian recipient routing ────────────────────────────────────────────────────────
|
||||||
|
await test('a minor payment confirmation routes to the guardian, never overwriting students.email', async () => {
|
||||||
|
const minorOffering = `off-p9-minor-${RUN_ID}`, minorSlug = `p9-minor-${RUN_ID}`;
|
||||||
|
await setupPaidOffering(owner, { offeringId: minorOffering, slug: minorSlug, capacity: 2, requireAgreement: false });
|
||||||
|
const submit = await integrationPost('/api/integrations/registrations', {
|
||||||
|
event_id: `p9-minor-evt-${RUN_ID}`, offering_slug: minorSlug,
|
||||||
|
registrant: { display_name: 'Minor Camper', is_minor: true },
|
||||||
|
guardian: { display_name: 'Minor Guardian', email: `minorguardian-${RUN_ID}@example.com` },
|
||||||
|
});
|
||||||
|
assert.equal(submit.status, 201);
|
||||||
|
const checkout = await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||||
|
const txRow = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/payments`);
|
||||||
|
const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'checkout.session.completed' });
|
||||||
|
await webhookPost(event.body, event.signature);
|
||||||
|
const regAfter = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}`);
|
||||||
|
const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${regAfter.body.student_id}'`);
|
||||||
|
assert.equal(studentEmail, '', 'a minor student must never get the guardian email written to students.email');
|
||||||
|
const notifs = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/notifications`);
|
||||||
|
assert.ok(notifs.body.some((n) => n.notification_type === 'activation'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 18: admin payment view ────────────────────────────────────────────────────────────────
|
||||||
|
await test('admin payment view shows amount/status/provider but never card data (none is ever stored)', async () => {
|
||||||
|
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||||
|
assert.ok(tx.body.length > 0);
|
||||||
|
for (const t of tx.body) {
|
||||||
|
for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 24/25: instructor payment-data restrictions, student payment isolation ──────────────
|
||||||
|
await test('a plain instructor cannot see registration payment data; the public API never can either', async () => {
|
||||||
|
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p9-stray-${RUN_ID}@example.com`, display_name: 'P9 Stray', role: 'instructor', password: 'P9StrayPass123!' });
|
||||||
|
const stray = makeSession(GW);
|
||||||
|
await stray.request('POST', '/api/auth/instructor/login', { email: `p9-stray-${RUN_ID}@example.com`, password: 'P9StrayPass123!' });
|
||||||
|
const denied = await stray.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||||
|
assert.equal(denied.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 26/27: audit hygiene, metric hygiene ─────────────────────────────────────────────────
|
||||||
|
await test('commerce audit events never contain secrets/card data, and metrics stay low-cardinality', async () => {
|
||||||
|
const auditRow = psql(`SELECT metadata::text FROM audit_events WHERE action = 'checkout_created' ORDER BY created_at DESC LIMIT 1`);
|
||||||
|
assert.ok(!/sk_|whsec_|4242/.test(auditRow || ''), 'no Stripe secret or fake card number should ever appear in audit metadata');
|
||||||
|
const metricsBody = await fetch(`${GW}/api/metrics`).catch(() => null);
|
||||||
|
// Metrics may be unauthenticated-admin or absent in some deployments — only assert content
|
||||||
|
// shape when reachable, never fail the whole suite on an unrelated auth requirement.
|
||||||
|
if (metricsBody && metricsBody.ok) {
|
||||||
|
const text = await metricsBody.text();
|
||||||
|
assert.ok(!new RegExp(registrationId).test(text), 'a registration_id must never appear as a metric label');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('the website status-lookup endpoint is safe and minimal — no PII, no amount, no provider details', async () => {
|
||||||
|
const status = await integrationGet(`/api/integrations/registrations/${registrationId}/status`);
|
||||||
|
assert.equal(status.status, 200);
|
||||||
|
assert.deepEqual(Object.keys(status.body).sort(), ['enrollment_activated', 'payment_status', 'registration_id', 'status']);
|
||||||
|
const denied = await fetch(`${GW}/api/integrations/registrations/${registrationId}/status`);
|
||||||
|
assert.equal(denied.status, 401, 'no key must be rejected, same as every other integration route');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 28/29/30: ROOT V1, Phase 8 registration, classroom/Lab flow all remain intact ───────
|
||||||
|
await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 9', async () => {
|
||||||
|
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P9-RootV1-${RUN_ID}`, login_identifier: `p9-rootv1-${RUN_ID}` });
|
||||||
|
const s = makeSession(GW);
|
||||||
|
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
|
||||||
|
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
|
||||||
|
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
});
|
||||||
|
await test('a free (Phase 8-style) offering registration still works with no price/checkout involved', async () => {
|
||||||
|
const freeOffering = `off-p9-free-${RUN_ID}`, freeSlug = `p9-free-${RUN_ID}`;
|
||||||
|
await owner.request('POST', '/api/admin/offerings', { offeringId: freeOffering, slug: freeSlug, name: 'Free Offering', programId: PROGRAM, deliveryMode: 'self_paced' });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'published' });
|
||||||
|
await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'enrollment_open' });
|
||||||
|
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-free-evt-${RUN_ID}`, offering_slug: freeSlug, registrant: { display_name: 'Free Student', email: `free-${RUN_ID}@example.com` } });
|
||||||
|
assert.equal(reg.status, 201);
|
||||||
|
const checkoutAttempt = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||||
|
assert.equal(checkoutAttempt.status, 409);
|
||||||
|
assert.equal(checkoutAttempt.body.error, 'NO_PRICE_CONFIGURED');
|
||||||
|
});
|
||||||
|
await test('an existing classroom (cohort) flow still works unaffected', async () => {
|
||||||
|
const cohortId = `p9-cohort-check-${RUN_ID}`;
|
||||||
|
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P9 Cohort Check', tier: 'root' });
|
||||||
|
assert.equal(create.status, 201);
|
||||||
|
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P9 Check Session' });
|
||||||
|
assert.equal(session.status, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (e) => { console.error(e); await pool.end().catch(() => {}); process.exit(1); });
|
||||||
Reference in New Issue
Block a user