From 1b95ee7d6b8938fbd1c46e3ce14356768a4f3025 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 24 Nov 2025 09:03:01 +0200 Subject: [PATCH 1/6] Add api key to hakuvahti --- .env.dist | 2 ++ src/app.ts | 8 +++++-- src/plugins/api-key.ts | 26 ++++++++++++++++++++++ src/plugins/token.ts | 29 ------------------------- test/helper.ts | 2 ++ test/routes/confirmSubscription.test.ts | 6 ++--- test/routes/deleteSubscription.test.ts | 6 ++--- test/routes/renewSubscription.test.ts | 6 ++--- test/routes/root.test.ts | 15 ++++++++++++- test/routes/subscriptionStatus.test.ts | 4 ++-- 10 files changed, 61 insertions(+), 43 deletions(-) create mode 100644 src/plugins/api-key.ts delete mode 100644 src/plugins/token.ts diff --git a/.env.dist b/.env.dist index 951fc19..687b52e 100644 --- a/.env.dist +++ b/.env.dist @@ -24,3 +24,5 @@ DIALOGI_SENDER= # Testing TEST_SMS_NUMBER= + +HAKUVAHTI_API_KEY='123' diff --git a/src/app.ts b/src/app.ts index 2e2d584..9f4ef9d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,14 +9,18 @@ export interface AppOptions extends FastifyPluginOptions, Partial = async (fastify, opts) => { // Skip override option breaks fastify encapsulation. // This is used by tests to get access to plugins // registered by application. delete opts.skipOverride; - if (process.env.ENVIRONMENT === undefined) { - throw new Error('ENVIRONMENT environment variable is not set'); + for (const envVar of requiredEnvironmentVariables) { + if (process.env[envVar] === undefined) { + throw new Error(`${envVar} environment variable is not set`); + } } const env = process.env.ENVIRONMENT as Environment; diff --git a/src/plugins/api-key.ts b/src/plugins/api-key.ts new file mode 100644 index 0000000..585a114 --- /dev/null +++ b/src/plugins/api-key.ts @@ -0,0 +1,26 @@ +import { timingSafeEqual } from 'node:crypto'; +import fp from 'fastify-plugin'; + +/** + * Validate token in request headers + * + * Requests must have 'Authorization: api-key ' header in the request. + */ +export default fp(async (fastify, _opts) => { + fastify.addHook('preHandler', async (request, reply) => { + // Skip token check for health check routes + if (request.url === '/healthz' || request.url === '/readiness') { + return true; + } + + const { HAKUVAHTI_API_KEY } = process.env; + const expected = Buffer.from(`api-key ${HAKUVAHTI_API_KEY}`); + const received = Buffer.from(request.headers.authorization?.toString() ?? ''); + + if (!HAKUVAHTI_API_KEY || expected.length !== received.length || !timingSafeEqual(expected, received)) { + return reply.code(403).send(); + } + + return true; + }); +}); diff --git a/src/plugins/token.ts b/src/plugins/token.ts deleted file mode 100644 index 6b37512..0000000 --- a/src/plugins/token.ts +++ /dev/null @@ -1,29 +0,0 @@ -import fp from 'fastify-plugin'; - -// Validate token in request headers - -export default fp(async (fastify, _opts) => { - fastify.addHook('preHandler', async (request, reply) => { - // Skip token check for health check routes - if (request.url === '/healthz' || request.url === '/readiness') { - return true; - } - - if (!request.headers.token) { - reply - .code(403) - .header('Content-Type', 'application/json; charset=utf-8') - .send({ error: 'Authentication failed.' }); - } - - // TODO: Do something with the token - - return true; - }); -}); - -declare module 'fastify' { - export interface FastifyRequest { - tokenAuthentication?: boolean; - } -} diff --git a/test/helper.ts b/test/helper.ts index 9539bf3..5e976e4 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -14,6 +14,8 @@ export type TestContext = { after: typeof test.after; }; +process.env.HAKUVAHTI_API_KEY = 'test'; + const AppPath = path.join(__dirname, '..', 'src', 'app.ts'); // Fill in this config with all the configurations diff --git a/test/routes/confirmSubscription.test.ts b/test/routes/confirmSubscription.test.ts index 75eb95e..7746ccb 100644 --- a/test/routes/confirmSubscription.test.ts +++ b/test/routes/confirmSubscription.test.ts @@ -11,7 +11,7 @@ describe('/subscription/confirm', () => { const res = await app.inject({ method: 'GET', url: `/subscription/confirm/${new ObjectId()}/invalid`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 404); @@ -26,7 +26,7 @@ describe('/subscription/confirm', () => { const res = await app.inject({ method: 'GET', url: `/subscription/confirm/${subscriptionId}/invalid`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 404); @@ -46,7 +46,7 @@ describe('/subscription/confirm', () => { const res = await app.inject({ method: 'GET', url: `/subscription/confirm/${subscriptionId}/${hash}`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 200); diff --git a/test/routes/deleteSubscription.test.ts b/test/routes/deleteSubscription.test.ts index 7567b57..87d1dbf 100644 --- a/test/routes/deleteSubscription.test.ts +++ b/test/routes/deleteSubscription.test.ts @@ -10,7 +10,7 @@ describe('/subscription/delete', () => { const res = await app.inject({ method: 'DELETE', url: `/subscription/delete/${new ObjectId()}/invalid`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 404); @@ -25,7 +25,7 @@ describe('/subscription/delete', () => { const res = await app.inject({ method: 'DELETE', url: `/subscription/delete/${subscriptionId}/invalid`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 404); @@ -45,7 +45,7 @@ describe('/subscription/delete', () => { const res = await app.inject({ method: 'DELETE', url: `/subscription/delete/${subscriptionId}/${hash}`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 200); diff --git a/test/routes/renewSubscription.test.ts b/test/routes/renewSubscription.test.ts index ceb9ac4..0c4bfbf 100644 --- a/test/routes/renewSubscription.test.ts +++ b/test/routes/renewSubscription.test.ts @@ -11,7 +11,7 @@ describe('/subscription/renew', () => { const res = await app.inject({ method: 'GET', url: `/subscription/renew/${new ObjectId()}/invalidhash`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 404); @@ -33,7 +33,7 @@ describe('/subscription/renew', () => { const res = await app.inject({ method: 'GET', url: `/subscription/renew/${subscriptionId}/${hash}`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 400); @@ -70,7 +70,7 @@ describe('/subscription/renew', () => { const res = await app.inject({ method: 'GET', url: `/subscription/renew/${subscriptionId}/${hash}`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 200); diff --git a/test/routes/root.test.ts b/test/routes/root.test.ts index e9aa012..b805825 100644 --- a/test/routes/root.test.ts +++ b/test/routes/root.test.ts @@ -7,11 +7,24 @@ test('default root route', async (t) => { const res = await app.inject({ url: '/', - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); + + assert.strictEqual(res.statusCode, 200); assert.deepStrictEqual(JSON.parse(res.payload), { root: true }); }); +test('api key validation', async (t) => { + const app = await build(t); + + const res = await app.inject({ + url: '/', + headers: { Authorization: 'api-key invalid' }, + }); + + assert.strictEqual(res.statusCode, 403); +}); + test('/healthz', async (t) => { const app = await build(t); diff --git a/test/routes/subscriptionStatus.test.ts b/test/routes/subscriptionStatus.test.ts index 9c80812..72ebf3d 100644 --- a/test/routes/subscriptionStatus.test.ts +++ b/test/routes/subscriptionStatus.test.ts @@ -11,7 +11,7 @@ describe('/subscription/status', () => { const res = await app.inject({ method: 'GET', url: `/subscription/status/${new ObjectId()}/invalid`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 404); @@ -37,7 +37,7 @@ describe('/subscription/status', () => { const res = await app.inject({ method: 'GET', url: `/subscription/status/${subscriptionId}/${hash}`, - headers: { token: 'test' }, + headers: { Authorization: 'api-key test' }, }); assert.strictEqual(res.statusCode, 200); From 155db7a616691a595d237a53fc6c9ba85238d30e Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 24 Nov 2025 09:03:02 +0200 Subject: [PATCH 2/6] Add tests /subscription route --- src/app.ts | 4 +- test/routes/addSubscription.test.ts | 206 ++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 test/routes/addSubscription.test.ts diff --git a/src/app.ts b/src/app.ts index 9f4ef9d..c3c6945 100644 --- a/src/app.ts +++ b/src/app.ts @@ -55,12 +55,12 @@ const app: FastifyPluginAsync = async (fastify, opts) => { fastify.register(AutoLoad, { dir: join(__dirname, 'plugins'), options: opts, - ignorePattern: /(^|\/|\\)(index|.d).*\.ts$/, + ignorePattern: /(^|\/|\\)(index|\.d).*\.ts$/, }); fastify.register(AutoLoad, { dir: join(__dirname, 'routes'), options: opts, - ignorePattern: /(^|\/|\\)(index|.d).*\.ts$/, + ignorePattern: /(^|\/|\\)(index|\.d).*\.ts$/, }); }; diff --git a/test/routes/addSubscription.test.ts b/test/routes/addSubscription.test.ts new file mode 100644 index 0000000..85afdb3 --- /dev/null +++ b/test/routes/addSubscription.test.ts @@ -0,0 +1,206 @@ +import * as assert from 'node:assert'; +import { before, describe, mock, test } from 'node:test'; +import { ObjectId } from '@fastify/mongodb'; +import axios from 'axios'; +import { SubscriptionStatus } from '../../src/types/subscription'; +import { build } from '../helper'; + +const validPayload = { + email: 'test@example.com', + elastic_query: Buffer.from('{"query":{"match_all":{}}}').toString('base64'), + query: '/search?q=test', + site_id: 'rekry', + lang: 'fi', +}; + +describe('/subscription', () => { + // Set up axios mocks for tests that need external API calls + before(() => { + mock.method(axios, 'post', async (url: string) => { + // ATV. + if (url.includes('/v1/documents/')) { + return { + data: { + id: 'mock-atv-document-id', + draft: 'false', + tos_function_id: 'test', + tos_record_id: 'test', + }, + }; + } + + // Elastic proxy. + if (url.includes('_search')) { + return { data: { hits: { hits: [] } } }; + } + + throw new Error(`Unexpected URL: ${url}`); + }); + }); + + test('rejects invalid input', async (t) => { + const app = await build(t); + + const testCases = [ + { + name: 'invalid email format', + payload: { ...validPayload, email: 'invalid-email' }, + expectedError: 'Invalid email format.', + }, + { + name: 'invalid SMS format', + payload: { ...validPayload, sms: '0451234567' }, + expectedError: 'Invalid SMS format. Use international format (e.g., +358451234567).', + }, + { + name: 'invalid site_id', + payload: { ...validPayload, site_id: 'nonexistent-site' }, + expectedError: 'Invalid site_id', + }, + { + name: 'missing email', + payload: { ...validPayload, email: undefined }, + }, + { + name: 'missing elastic_query', + payload: { ...validPayload, elastic_query: undefined }, + }, + { + name: 'missing site_id', + payload: { ...validPayload, site_id: undefined }, + }, + ]; + + for (const { name, payload, expectedError } of testCases) { + await t.test(name, async () => { + const res = await app.inject({ + method: 'POST', + url: '/subscription', + headers: { Authorization: 'api-key test' }, + payload, + }); + + assert.strictEqual(res.statusCode, 400); + + if (expectedError) { + const body = JSON.parse(res.body); + assert.ok(body.error.includes(expectedError), `error should include "${expectedError}"`); + } + }); + } + }); + + test('accepts valid subscriptions', async (t) => { + const app = await build(t); + + const testCases = [ + { + name: 'email only', + payload: validPayload, + }, + { + name: 'swedish language', + payload: { ...validPayload, lang: 'sv' }, + }, + { + name: 'email and SMS', + payload: { ...validPayload, sms: '+358451234567' }, + }, + { + name: 'with search_description', + payload: { ...validPayload, search_description: 'My saved search' }, + }, + ]; + + for (const { name, payload } of testCases) { + await t.test(name, async () => { + const res = await app.inject({ + method: 'POST', + url: '/subscription', + headers: { Authorization: 'api-key test' }, + payload, + }); + + const body = JSON.parse(res.body); + + assert.strictEqual(res.statusCode, 200, `${name}: should return 200`); + assert.strictEqual(body.acknowledged, true, `${name}: should be acknowledged`); + assert.ok(body.insertedId, `${name}: should have insertedId`); + + // Verify MongoDB document was inserted. + const collection = app.mongo.db?.collection('subscription'); + const subscription = await collection?.findOne({ _id: new ObjectId(body.insertedId) }); + + assert.ok(subscription, `${name}: subscription should exist in MongoDB`); + assert.strictEqual(subscription.lang, payload.lang, `${name}: lang should match`); + assert.strictEqual(subscription.email, 'mock-atv-document-id', `${name}: email should be ATV document ID`); + assert.strictEqual(subscription.status, SubscriptionStatus.INACTIVE); + assert.strictEqual(subscription.site_id, payload.site_id); + assert.strictEqual(subscription.query, payload.query); + assert.strictEqual(subscription.lang, payload.lang); + assert.strictEqual(subscription.elastic_query, payload.elastic_query); + }); + } + }); +}); + +describe('/subscription plugin failures', () => { + test('handles ATV failure', async (t) => { + // Mock ATV to fail + mock.method(axios, 'post', async (url: string) => { + if (url.includes('/v1/documents/')) { + throw new Error('ATV service unavailable'); + } + if (url.includes('_search')) { + return { data: { hits: { hits: [] } } }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const app = await build(t); + + const res = await app.inject({ + method: 'POST', + url: '/subscription', + headers: { Authorization: 'api-key test' }, + payload: validPayload, + }); + + assert.strictEqual(res.statusCode, 500); + const body = JSON.parse(res.body); + assert.ok(body.error); + }); + + test('handles Elasticsearch validation failure', async (t) => { + // Mock Elasticsearch to fail + mock.method(axios, 'post', async (url: string) => { + if (url.includes('/v1/documents/')) { + return { + data: { + id: 'mock-atv-document-id', + draft: 'false', + tos_function_id: 'test', + tos_record_id: 'test', + }, + }; + } + if (url.includes('_search')) { + throw new Error('Elasticsearch query failed'); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const app = await build(t); + + const res = await app.inject({ + method: 'POST', + url: '/subscription', + headers: { Authorization: 'api-key test' }, + payload: validPayload, + }); + + assert.strictEqual(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.ok(body.error.includes('elastic_query')); + }); +}); From 61fcd7a13de57a3251e1df0acdb4a70dcc5e1948 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 24 Nov 2025 09:03:03 +0200 Subject: [PATCH 3/6] Add text reporter to test command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8107a31..df0cb0b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "test" }, "scripts": { - "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov node --test --test-force-exit -r ts-node/register test/**/*.test.ts", + "test": "npm run build:ts && c8 --exclude-node-modules --reporter lcov --reporter text node --test --test-force-exit -r ts-node/register test/**/*.test.ts", "start": "npm run build:ts && fastify start -l info dist/app.js", "build:ts": "npm run copy:assets; tsc", "copy:assets": "mkdir -p dist; cp -R src/templates dist/", From 3c1d5c5f5899422dcdfea1b965f2f385fe10c082 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 24 Nov 2025 09:02:38 +0200 Subject: [PATCH 4/6] Install node_modules with correct permissions Run the docker container as the current user, so all files created in the container are created with correct permissions. --- compose.yaml | 5 +---- openshift/Dockerfile | 4 ++-- tools/make/project/node.mk | 3 +++ 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/compose.yaml b/compose.yaml index 114d203..f75c845 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,7 +9,7 @@ services: - internal app: - user: root + user: "${DOCKER_UID:-1000}:${DOCKER_GID:-1000}" build: context: . dockerfile: openshift/Dockerfile @@ -20,9 +20,6 @@ services: MONGODB: mongodb://mongodb:27017/hakuvahti volumes: - .:/app:delegated - - node_modules:/app/node_modules - - type: tmpfs - target: /app/dist ports: - "3000:3000" depends_on: diff --git a/openshift/Dockerfile b/openshift/Dockerfile index 96bdf62..679d993 100644 --- a/openshift/Dockerfile +++ b/openshift/Dockerfile @@ -15,8 +15,8 @@ RUN \ FROM registry.access.redhat.com/ubi9/nodejs-22 AS development -ENV npm_config_cache="$HOME/.npm" -ENV APP_NAME rekry-hakuvahti +ENV npm_config_cache="/tmp/.npm" +ENV APP_NAME hakuvahti WORKDIR /app diff --git a/tools/make/project/node.mk b/tools/make/project/node.mk index 5a2a1f0..ab0a489 100644 --- a/tools/make/project/node.mk +++ b/tools/make/project/node.mk @@ -1,6 +1,9 @@ NODE_FRESH_TARGETS := up post-install NODE_POST_INSTALL_TARGETS := dotenv npm-install hav-build hav-init-db +DOCKER_UID ?= $(shell id -u) +DOCKER_GID ?= $(shell id -g) + PHONY += fresh fresh: ## Build fresh development environment and sync @$(MAKE) $(NODE_FRESH_TARGETS) From 9d00fc6adf42de617ce713124b5d737194824140 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 24 Nov 2025 09:39:45 +0200 Subject: [PATCH 5/6] Debug permissions --- tools/make/project/node.mk | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/make/project/node.mk b/tools/make/project/node.mk index ab0a489..87d539f 100644 --- a/tools/make/project/node.mk +++ b/tools/make/project/node.mk @@ -1,8 +1,8 @@ NODE_FRESH_TARGETS := up post-install NODE_POST_INSTALL_TARGETS := dotenv npm-install hav-build hav-init-db -DOCKER_UID ?= $(shell id -u) -DOCKER_GID ?= $(shell id -g) +export DOCKER_UID ?= $(shell id -u) +export DOCKER_GID ?= $(shell id -g) PHONY += fresh fresh: ## Build fresh development environment and sync From 4a84a49ba060ceecda52df4cca864992a1ab76f5 Mon Sep 17 00:00:00 2001 From: Santeri Hurnanen Date: Mon, 24 Nov 2025 23:13:37 +0200 Subject: [PATCH 6/6] Fix dependabot security issue --- package-lock.json | 74 +++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index c294a88..6589e59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1818,6 +1818,27 @@ "node": ">=6.0" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2290,6 +2311,22 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -3048,43 +3085,6 @@ "node": ">=18" } }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",