Skip to content
Closed
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
7 changes: 7 additions & 0 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ enabled. Migration `20261022010000_lets_talk_classification` seeds the Let's tal
classify and label flows disabled; forms-processor-v6 ADR 0001 and its
`examples/lets-talk-shadow-mode.sql` / `lets-talk-cutover.sql` describe the rollout.

Migration `20261102010000_space_tech_challenge_email` seeds `space-tech-challenge-email`,
which emails every `space_tech_challenge` submission to a fixed list, with the submitter's
`email` answer as reply-to. It is disabled with empty settings: forms-processor-v6 supplies
the recipients and template from `SPACE_TECH_CHALLENGE_RECIPIENTS` and
`SPACE_TECH_CHALLENGE_TEMPLATE_ID`. Enable it in SQL after both are set and the processor
is redeployed.

Processor events contain answers and must be included in personal-data retention
and erasure procedures separately from `Submission`. Deleting a ProcessorEvent
cascades its delivery records and removes deduplication protection, so retain it
Expand Down
36 changes: 36 additions & 0 deletions examples/space-tech-challenge.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"title": "Share your space-data challenge",
"description": "",
"successMessage": "Thanks for sharing your challenge. We'll get back to you within 48 hours on whether it's a fit.",
"access": "ANONYMOUS",
"fields": [
{
"key": "full_name",
"label": "Full Name",
"type": "TEXT",
"required": true,
"maxLength": 200
},
{
"key": "company",
"label": "Company",
"type": "TEXT",
"required": true,
"maxLength": 200
},
{
"key": "email",
"label": "Work Email",
"type": "EMAIL",
"required": true,
"maxLength": 254
},
{
"key": "challenge",
"label": "Which challenge are you trying to solve?",
"type": "TEXTAREA",
"required": true,
"maxLength": 5000
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Internal notification for SpaceTech landing page challenges (form space_tech_challenge).
-- Every submission goes to a fixed recipient list, with the submitter as reply-to.
-- forms-processor-v6 supplies the settings from SPACE_TECH_CHALLENGE_RECIPIENTS and
-- SPACE_TECH_CHALLENGE_TEMPLATE_ID (SSM); they replace these database settings.
-- Seeded disabled: enable it after both parameters are set and the processor is redeployed.
INSERT INTO forms."ProcessorFlow" (id, "formKey", action, rules)
VALUES ('space-tech-challenge-email', 'space_tech_challenge', 'sendgrid-email', '{"all":[]}')
ON CONFLICT (id) DO NOTHING;
56 changes: 56 additions & 0 deletions scripts/publish-space-tech-challenge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { readFile } from 'node:fs/promises';

/**
* Idempotently publishes the SpaceTech landing page definition to the selected Forms API (development by default).
* @param No arguments; reads FORMS_API_BASE_URL and FORMS_ADMIN_TOKEN from the environment.
* @returns Completion after registration, immutable version save, and publication; creates no submissions.
* @throws Error for a missing token, unsupported API base, conflicting revision, or failed API request.
*/
async function publishSpaceTechChallenge(): Promise<void> {
const apiBase = (
process.env.FORMS_API_BASE_URL ?? 'https://api.topcoder-dev.com/v6'
).replace(/\/+$/, '');
if (
![
'https://api.topcoder-dev.com/v6',
'https://api.topcoder.com/v6',
].includes(apiBase)
)
throw new Error(
'FORMS_API_BASE_URL must select the dev or production Topcoder API.',
);
const token = process.env.FORMS_ADMIN_TOKEN;
if (!token)
throw new Error(
'FORMS_ADMIN_TOKEN with manage:forms or Administrator access is required.',
);
const definition: unknown = JSON.parse(
await readFile('examples/space-tech-challenge.json', 'utf8'),
);
const base = `${apiBase}/forms`;
for (const [method, path, body] of [
['POST', '', { key: 'space_tech_challenge' }],
['PUT', '/space_tech_challenge/versions/1', definition],
['POST', '/space_tech_challenge/versions/1/publish', undefined],
] as const) {
const response = await fetch(`${base}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(30000),
});
if (!response.ok)
throw new Error(
`${method} ${path} failed (${response.status}); no credentials or response data logged.`,
);
}
console.log(`Published space_tech_challenge version 1 to ${apiBase}.`);
}

void publishSpaceTechChallenge().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Publication failed.');
process.exitCode = 1;
});
70 changes: 59 additions & 11 deletions test/forms.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
vi,
} from 'vitest';
import request from 'supertest';
import { randomUUID } from 'node:crypto';
import { generateKeyPairSync, randomUUID, type KeyObject } from 'node:crypto';
import jwt from 'jsonwebtoken';
import nock from 'nock';
import letsTalkDefinition from '../examples/lets-talk.json';
import { createApp } from '../src/bootstrap';
import { DbService } from '../src/db.service';
Expand All @@ -33,10 +35,49 @@
let reporter: string;
let member: string;
let machine: string;
let machineKey: KeyObject;
const machineKeyId = 'forms-integration-machine';

/**
* Signs audience-bound machine credentials for the shared RSA verification policy.
* @param scope Space-separated forms permissions. @returns Signed machine bearer token.
* @throws JWT signing errors for invalid fixture keys or claims.
*/
function machineToken(scope: string) {
return jwt.sign(
{ gty: 'client-credentials', azp: 'test-client', scope },
machineKey,
{
algorithm: 'RS256',
keyid: machineKeyId,
subject: 'test-client@clients',
issuer: 'https://forms.test',
audience: 'forms-api',
expiresIn: '5m',
},
);
}

beforeAll(async () => {
const keys = generateKeyPairSync('rsa', { modulusLength: 2048 });
machineKey = keys.privateKey;
// Mock key discovery only; the shared authenticator still verifies RSA signatures.
nock('https://forms.test')
.get('/.well-known/jwks.json')
.reply(200, {
keys: [
{
...keys.publicKey.export({ format: 'jwk' }),
kid: machineKeyId,
alg: 'RS256',
use: 'sig',
},
],
});
app = await createApp({
...testConfig(),
// TODO: restore
// machineAudience: 'forms-api',
busApi: {
BUSAPI_URL: 'https://bus.test/v6',
AUTH0_URL: 'https://auth.test',
Expand All @@ -54,17 +95,14 @@
'https://topcoder.com/roles': ['Member'],
'https://topcoder.com/userId': '12345',
});
machine = await token({
gty: 'client-credentials',
azp: 'test-client',
scope: 'manage:forms read:forms-submissions',
});
machine = machineToken('manage:forms read:forms-submissions');
});
beforeEach(() => {
postEvent.mockReset().mockResolvedValue(undefined);
});
afterAll(async () => {
if (app) await app.close();
nock.cleanAll();
});

/**
Expand Down Expand Up @@ -186,15 +224,11 @@
key,
title: 'Event interest',
});
const readOnly = await token({
gty: 'client-credentials',
azp: 'test-client',
scope: 'read:forms-submissions',
});
const readOnly = machineToken('read:forms-submissions');
await request(app.getHttpServer())
.get('/v6/forms/reports/directory')
.auth(readOnly, { type: 'bearer' })
.expect(200);

Check failure on line 231 in test/forms.integration.spec.ts

View workflow job for this annotation

GitHub Actions / verify

test/forms.integration.spec.ts > forms API with real PostgreSQL > provides a reporter-only directory and rejects unprivileged report/export access

Error: expected 200 "OK", got 401 "Unauthorized" ❯ test/forms.integration.spec.ts:231:8 ❯ Test._assertStatus node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:309:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:365:13 ❯ Test._assertFunction node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:342:13 ❯ Test.assert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:195:23 ❯ localAssert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:138:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:156:7 ❯ Test.Request.callback node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:857:3 ❯ node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:1102:18 ❯ IncomingMessage.<anonymous> node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/parsers/json.js:21:7

Check failure on line 231 in test/forms.integration.spec.ts

View workflow job for this annotation

GitHub Actions / verify

test/forms.integration.spec.ts > forms API with real PostgreSQL > provides a reporter-only directory and rejects unprivileged report/export access

Error: expected 200 "OK", got 401 "Unauthorized" ❯ test/forms.integration.spec.ts:231:8 ❯ Test._assertStatus node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:309:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:365:13 ❯ Test._assertFunction node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:342:13 ❯ Test.assert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:195:23 ❯ localAssert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:138:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:156:7 ❯ Test.Request.callback node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:857:3 ❯ node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:1102:18 ❯ IncomingMessage.<anonymous> node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/parsers/json.js:21:7
for (const path of [
'/reports/directory',
`/${key}/submissions`,
Expand Down Expand Up @@ -389,6 +423,20 @@
);
});

it('seeds the SpaceTech challenge email disabled, with settings left to the processor environment', async () => {
const flows = await db.processorFlow.findMany({
where: { formKey: 'space_tech_challenge' },
});
expect(flows).toHaveLength(1);
expect(flows[0]).toMatchObject({
id: 'space-tech-challenge-email',
enabled: false,
action: 'sendgrid-email',
rules: { all: [] },
settings: {},
});
});

it('reports AI classification columns and filters by label only for forms with a classify flow', async () => {
const key = await create({
...definition(),
Expand Down Expand Up @@ -867,7 +915,7 @@
.set('Idempotency-Key', randomUUID())
.auth(machine, { type: 'bearer' })
.send({ version: 1, answers: answers(), kafka: true })
.expect(403);

Check failure on line 918 in test/forms.integration.spec.ts

View workflow job for this annotation

GitHub Actions / verify

test/forms.integration.spec.ts > forms API with real PostgreSQL > does not publish invalid, unauthorized, or stale submissions

Error: expected 403 "Forbidden", got 401 "Unauthorized" ❯ test/forms.integration.spec.ts:918:8 ❯ Test._assertStatus node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:309:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:365:13 ❯ Test._assertFunction node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:342:13 ❯ Test.assert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:195:23 ❯ localAssert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:138:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:156:7 ❯ Test.Request.callback node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:857:3 ❯ node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:1102:18 ❯ IncomingMessage.<anonymous> node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/parsers/json.js:21:7

Check failure on line 918 in test/forms.integration.spec.ts

View workflow job for this annotation

GitHub Actions / verify

test/forms.integration.spec.ts > forms API with real PostgreSQL > does not publish invalid, unauthorized, or stale submissions

Error: expected 403 "Forbidden", got 401 "Unauthorized" ❯ test/forms.integration.spec.ts:918:8 ❯ Test._assertStatus node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:309:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:365:13 ❯ Test._assertFunction node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:342:13 ❯ Test.assert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:195:23 ❯ localAssert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:138:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:156:7 ❯ Test.Request.callback node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:857:3 ❯ node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:1102:18 ❯ IncomingMessage.<anonymous> node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/parsers/json.js:21:7
await request(app.getHttpServer())
.post(`/v6/forms/${key}/versions/1/retire`)
.auth(admin, { type: 'bearer' })
Expand Down Expand Up @@ -945,7 +993,7 @@
.post('/v6/forms')
.auth(machine, { type: 'bearer' })
.send({ key: formKey() })
.expect(201);

Check failure on line 996 in test/forms.integration.spec.ts

View workflow job for this annotation

GitHub Actions / verify

test/forms.integration.spec.ts > forms API with real PostgreSQL > protects administration/reports, validates tokens, and derives member IDs from verified claims

Error: expected 201 "Created", got 401 "Unauthorized" ❯ test/forms.integration.spec.ts:996:8 ❯ Test._assertStatus node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:309:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:365:13 ❯ Test._assertFunction node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:342:13 ❯ Test.assert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:195:23 ❯ localAssert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:138:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:156:7 ❯ Test.Request.callback node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:857:3 ❯ node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:1102:18 ❯ IncomingMessage.<anonymous> node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/parsers/json.js:21:7

Check failure on line 996 in test/forms.integration.spec.ts

View workflow job for this annotation

GitHub Actions / verify

test/forms.integration.spec.ts > forms API with real PostgreSQL > protects administration/reports, validates tokens, and derives member IDs from verified claims

Error: expected 201 "Created", got 401 "Unauthorized" ❯ test/forms.integration.spec.ts:996:8 ❯ Test._assertStatus node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:309:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:365:13 ❯ Test._assertFunction node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:342:13 ❯ Test.assert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:195:23 ❯ localAssert node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:138:14 ❯ node_modules/.pnpm/supertest@7.2.2_supports-color@7.2.0/node_modules/supertest/lib/test.js:156:7 ❯ Test.Request.callback node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:857:3 ❯ node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/index.js:1102:18 ❯ IncomingMessage.<anonymous> node_modules/.pnpm/superagent@10.3.0_supports-color@7.2.0/node_modules/superagent/lib/node/parsers/json.js:21:7
await request(app.getHttpServer())
.get(`/v6/forms/${key}`)
.auth('invalid', { type: 'bearer' })
Expand Down
2 changes: 1 addition & 1 deletion test/policies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ describe('definition and deployment policies', () => {
{
sub: 'client@clients',
userId: 'forged',
roles: ['Administrator'],
gty: 'client-credentials',
isMachine: true,
},
'https://topcoder.com/',
),
Expand Down
Loading