From 15170a4f43e6db8d4c1964f7e96cf37c65eb29c5 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 9 Sep 2025 12:30:38 -0700 Subject: [PATCH] db(couchdb): auto-provision databases on startup for production strategy; add TokenService with CouchDB-backed token storage and localStorage fallback; switch OAuth to unified config for client IDs and redirect URI; express Request typing for req.user; align exportAsEnvVars with show-config expectations; remove Vite importmap from index.html; prefer babel-jest over ts-jest; remove duplicate uuid mocking from Jest config --- .github/workflows/test-integration.yml | 81 ++++ Makefile | 17 +- README.md | 16 + bun.lock | 10 +- config/unified.config.ts | 22 + docker-compose.ci.yml | 41 ++ index.html | 10 +- jest.config.json | 4 +- package.json | 1 + .../token.service.integration.test.ts | 336 +++++++++++++ services/auth/auth.service.ts | 30 +- services/auth/emailVerification.service.ts | 27 +- services/auth/token.service.ts | 447 ++++++++++++++++++ .../database/ProductionDatabaseStrategy.ts | 5 + services/oauth.ts | 10 +- tests/README.md | 79 +++- types/express.d.ts | 28 ++ 17 files changed, 1097 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/test-integration.yml create mode 100644 docker-compose.ci.yml create mode 100644 services/auth/__tests__/integration/token.service.integration.test.ts create mode 100644 services/auth/token.service.ts create mode 100644 types/express.d.ts diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml new file mode 100644 index 0000000..c78f0ce --- /dev/null +++ b/.github/workflows/test-integration.yml @@ -0,0 +1,81 @@ +name: Integration Tests (CouchDB) + +on: + workflow_dispatch: + pull_request: + branches: [main, develop] + paths: + - 'meds/**' + push: + branches: [main, develop] + paths: + - 'meds/**' + +jobs: + integration-tests: + name: Run integration tests with CouchDB + # Optional: run only when manually dispatched or when repo variable enables it + if: ${{ github.event_name == 'workflow_dispatch' || vars.RUN_COUCHDB_INTEGRATION == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + + services: + couchdb: + image: couchdb:3 + ports: + - 5984:5984 + env: + COUCHDB_USER: admin + COUCHDB_PASSWORD: password + + env: + # Point integration tests at the service container + VITE_COUCHDB_URL: ${{ vars.VITE_COUCHDB_URL != '' && vars.VITE_COUCHDB_URL || 'http://couchdb:5984' }} + VITE_COUCHDB_USER: ${{ vars.VITE_COUCHDB_USER != '' && vars.VITE_COUCHDB_USER || 'admin' }} + VITE_COUCHDB_PASSWORD: ${{ secrets.VITE_COUCHDB_PASSWORD != '' && secrets.VITE_COUCHDB_PASSWORD || 'password' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + working-directory: meds + run: bun install --frozen-lockfile + + - name: Wait for CouchDB to be ready + run: | + echo "Waiting for CouchDB at ${VITE_COUCHDB_URL} ..." + for i in {1..60}; do + if curl -fsS "${VITE_COUCHDB_URL}/" >/dev/null 2>&1; then + echo "CouchDB is up!" + break + else + echo "Attempt $i: CouchDB not ready, retrying..." + sleep 2 + fi + done + echo "Checking authentication and DB listing..." + curl -fsS -u "${VITE_COUCHDB_USER}:${VITE_COUCHDB_PASSWORD}" "${VITE_COUCHDB_URL}/_all_dbs" >/dev/null + + - name: Run integration tests + working-directory: meds + env: + # Ensure tests target CouchDB and not the mock + USE_MOCK_DB: 'false' + run: bun run test:integration + + - name: Upload Jest coverage (if present) + if: always() + uses: actions/upload-artifact@v4 + with: + name: jest-coverage + path: | + meds/coverage + if-no-files-found: ignore diff --git a/Makefile b/Makefile index 1dfa691..eaf43d0 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ DOCKER_IMAGE ?= $(APP_NAME):latest export -.PHONY: help install clean dev build test docker-build docker-run docker-clean info +.PHONY: help install clean dev build test docker-build docker-run docker-clean info couchdb-up couchdb-down # Default target .DEFAULT_GOAL := help @@ -90,3 +90,18 @@ docker-clean: ## Clean Docker resources and containers @docker rmi $(DOCKER_IMAGE) 2>/dev/null || true @docker image prune -f 2>/dev/null || true @docker container prune -f 2>/dev/null || true + +##@ Test Services + +couchdb-up: ## Start local CouchDB for integration tests + @echo "Starting CouchDB test service..." + @docker compose -f docker-compose.ci.yml up -d couchdb + @echo "CouchDB is starting at http://localhost:$${VITE_COUCHDB_PORT:-5984}" + @echo "Export credentials for tests if needed:" + @echo " export VITE_COUCHDB_URL=http://localhost:$${VITE_COUCHDB_PORT:-5984}" + @echo " export VITE_COUCHDB_USER=$${VITE_COUCHDB_USER:-admin}" + @echo " export VITE_COUCHDB_PASSWORD=$${VITE_COUCHDB_PASSWORD:-password}" + +couchdb-down: ## Stop CouchDB test service and remove volume + @echo "Stopping CouchDB test service and removing volume..." + @docker compose -f docker-compose.ci.yml down -v diff --git a/README.md b/README.md index bb27e58..b39b0c3 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,22 @@ VITE_GOOGLE_CLIENT_ID=your-google-client-id VITE_GITHUB_CLIENT_ID=your-github-client-id ``` +### **OAuth Redirects & Token Persistence** + +- OAuth Redirect URI + - The redirect URI is derived from the unified configuration’s `APP_BASE_URL`: + - Redirect URI = `${APP_BASE_URL}/auth/callback` + - Defaults: + - Development: `APP_BASE_URL=http://localhost:5173` → `http://localhost:5173/auth/callback` + - Test: `APP_BASE_URL=http://localhost:3000` → `http://localhost:3000/auth/callback` + - Production: respects your configured base URL (e.g., `https://rxminder.com/auth/callback`) + - To change the redirect URI, set `APP_BASE_URL` accordingly. + +- Token Persistence (Email Verification & Password Reset) + - Production (CouchDB configured): tokens are stored server-side in CouchDB (`auth_tokens` database) for secure, multi-device flows. + - Development/Test or when CouchDB isn’t configured: tokens fall back to `localStorage` for demo purposes. + - This hybrid approach enables secure flows in production while keeping local development simple. + ### **Database Strategy** The application automatically selects the appropriate database strategy: diff --git a/bun.lock b/bun.lock index 95daede..a49ab1b 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "jsonwebtoken": "^9.0.2", "react": "^19.1.1", "react-dom": "^19.1.1", - "uuid": "^12.0.0", + "uuid": "^12.0.0" }, "devDependencies": { "@babel/core": "^7.28.4", @@ -40,9 +40,9 @@ "shelljs": "^0.10.0", "ts-jest": "^29.4.1", "typescript": "^5.9.2", - "vite": "^7.1.4", - }, - }, + "vite": "^7.1.4" + } + } }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -2515,6 +2515,6 @@ "eclint/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="] } } diff --git a/config/unified.config.ts b/config/unified.config.ts index 9ea25d5..fd00197 100644 --- a/config/unified.config.ts +++ b/config/unified.config.ts @@ -869,6 +869,28 @@ export function exportAsEnvVars( COUCHDB_PASSWORD: configToUse.database.password, COUCHDB_DATABASE_NAME: configToUse.database.name, USE_MOCK_DB: configToUse.database.useMock.toString(), + // Vite-compatible database vars + VITE_COUCHDB_URL: configToUse.database.url, + VITE_COUCHDB_USER: configToUse.database.username, + VITE_COUCHDB_PASSWORD: configToUse.database.password, + + // Authentication & Security + JWT_SECRET: configToUse.auth.jwtSecret, + JWT_EXPIRES_IN: configToUse.auth.jwtExpiresIn, + SESSION_SECRET: configToUse.security.sessionSecret, + + // Email + EMAIL_PROVIDER: configToUse.email.provider, + VITE_MAILGUN_API_KEY: configToUse.email.mailgun?.apiKey || '', + VITE_MAILGUN_DOMAIN: configToUse.email.mailgun?.domain || '', + MAILGUN_FROM_NAME: configToUse.email.fromName, + MAILGUN_FROM_EMAIL: configToUse.email.fromEmail, + + // OAuth + VITE_GOOGLE_CLIENT_ID: configToUse.oauth.google?.clientId || '', + GOOGLE_CLIENT_SECRET: configToUse.oauth.google?.clientSecret || '', + VITE_GITHUB_CLIENT_ID: configToUse.oauth.github?.clientId || '', + GITHUB_CLIENT_SECRET: configToUse.oauth.github?.clientSecret || '', // Container CONTAINER_REGISTRY: configToUse.container.registry, diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..13816e7 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,41 @@ +# docker-compose file for spinning up a local CouchDB instance used by integration tests +# +# Usage (locally): +# docker compose -f meds/docker-compose.ci.yml up -d couchdb +# export VITE_COUCHDB_URL=http://localhost:5984 +# export VITE_COUCHDB_USER=admin +# export VITE_COUCHDB_PASSWORD=password +# npm run test:integration +# +# Notes: +# - This compose file is meant for CI and local testing only. +# - Credentials default to admin/password (override via env vars). +# - Data is persisted in a named volume "couchdb-test-data". + +version: '3.8' + +services: + couchdb: + image: couchdb:3 + container_name: meds-couchdb-test + environment: + COUCHDB_USER: ${VITE_COUCHDB_USER:-admin} + COUCHDB_PASSWORD: ${VITE_COUCHDB_PASSWORD:-password} + # Optional: set a node name (single node) + # NODENAME: couchdb@127.0.0.1 + ports: + - '${VITE_COUCHDB_PORT:-5984}:5984' + volumes: + - couchdb-test-data:/opt/couchdb/data + # Healthcheck is optional; curl may not be available in the base image. + # If you add curl to the image, uncomment this block to enable depends_on:service_healthy in other services. + # healthcheck: + # test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5984/_up || exit 1"] + # interval: 5s + # timeout: 3s + # retries: 20 + restart: unless-stopped + +volumes: + couchdb-test-data: + name: couchdb-test-data diff --git a/index.html b/index.html index 5d1ee3e..a152aca 100644 --- a/index.html +++ b/index.html @@ -32,15 +32,7 @@ }, }; - + diff --git a/jest.config.json b/jest.config.json index 5eb7791..5291544 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,5 +1,4 @@ { - "preset": "ts-jest", "testEnvironment": "jsdom", "setupFilesAfterEnv": ["/tests/setup.ts"], "testMatch": [ @@ -23,11 +22,10 @@ "coverageReporters": ["text", "lcov", "html"], "moduleNameMapper": { "^@/(.*)$": "/$1", - "^uuid$": "/tests/__mocks__/uuid.js", "^node-fetch$": "/tests/__mocks__/node-fetch.js" }, "transform": { - "^.+\\.tsx?$": "ts-jest", + "^.+\\.tsx?$": "babel-jest", "^.+\\.jsx?$": "babel-jest" }, "transformIgnorePatterns": ["node_modules/(?!(@jest/transform|uuid|node-fetch)/)"], diff --git a/package.json b/package.json index beb0189..3d73ed9 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:fast": "jest --testPathPatterns='(utils|types|services).*test\\.(ts|js)$' --passWithNoTests", "test:unit": "jest --testPathPatterns='(utils|types).*test\\.(ts|js)$'", "test:services": "jest --testPathPatterns='services.*test\\.(ts|js)$'", + "test:integration": "jest --testPathPatterns='(tests/integration/.*\\.test\\.(ts|js)|services/.*/__tests__/integration/.*\\.test\\.(ts|js))$'", "lint:markdown": "markdownlint-cli2 \"**/*.md\"", "lint:markdown:fix": "markdownlint-cli2 --fix \"**/*.md\"", "check:secrets": "secretlint \"**/*\"", diff --git a/services/auth/__tests__/integration/token.service.integration.test.ts b/services/auth/__tests__/integration/token.service.integration.test.ts new file mode 100644 index 0000000..eb72983 --- /dev/null +++ b/services/auth/__tests__/integration/token.service.integration.test.ts @@ -0,0 +1,336 @@ +import type { EmailVerificationToken } from '../../auth.types'; + +// Integration tests for TokenService against a live CouchDB instance. +// These tests require a local CouchDB at http://localhost:5984 with admin:password +// or credentials provided via environment variables. +// +// To run only these tests: +// jest meds/services/auth/__tests__/integration/token.service.integration.test.ts +// +// Skips automatically if CouchDB is not reachable. + +describe('TokenService (integration with CouchDB)', () => { + const COUCH_URL = + process.env.VITE_COUCHDB_URL || + process.env.COUCHDB_URL || + 'http://localhost:5984'; + const COUCH_USER = + process.env.VITE_COUCHDB_USER || process.env.COUCHDB_USER || 'admin'; + const COUCH_PASS = + process.env.VITE_COUCHDB_PASSWORD || + process.env.COUCHDB_PASSWORD || + 'password'; + + const BASIC_AUTH = + 'Basic ' + Buffer.from(`${COUCH_USER}:${COUCH_PASS}`).toString('base64'); + + let couchUp = false; + let previousFetch: typeof fetch | undefined; + let previousHeaders: typeof Headers | undefined; + let previousRequest: typeof Request | undefined; + let previousResponse: typeof Response | undefined; + + // TokenService and its dependencies will be imported after configuring env and fetch. + let tokenService: any; + + // Utility: install a custom HTTP(S) fetch to reach real CouchDB (bypass test mocks) + const installCustomFetch = async () => { + previousFetch = global.fetch; + previousHeaders = global.Headers as typeof Headers | undefined; + previousRequest = global.Request as typeof Request | undefined; + previousResponse = global.Response as typeof Response | undefined; + + // Use Node core undici (Node 18+) + // Install a minimal HTTP(S)-based fetch to bypass test mocks + const customFetch = async (url: string, init?: RequestInit) => { + const { URL } = await import('node:url'); + const http = await import('node:http'); + const https = await import('node:https'); + const u = new URL(url); + const isHttps = u.protocol === 'https:'; + const mod = isHttps ? https : http; + const headers = init?.headers as Record | undefined; + const method = init?.method || 'GET'; + const body = init?.body as string | undefined; + return await new Promise((resolve, reject) => { + const req = mod.request( + { + protocol: u.protocol, + hostname: u.hostname, + port: u.port || (isHttps ? 443 : 80), + path: u.pathname + (u.search || ''), + method, + headers, + }, + res => { + const chunks: Buffer[] = []; + res.on('data', d => + chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(d)) + ); + res.on('end', () => { + const buf = Buffer.concat(chunks); + const status = res.statusCode || 0; + const headersMap = new Map(); + for (const [k, v] of Object.entries(res.headers)) { + if (Array.isArray(v)) headersMap.set(k, v.join(', ')); + else if (v != null) headersMap.set(k, String(v)); + } + const responseLike = { + ok: status >= 200 && status < 300, + status, + headers: { + get: (k: string) => + headersMap.get(k.toLowerCase()) || + headersMap.get(k) || + null, + has: (k: string) => + headersMap.has(k.toLowerCase()) || headersMap.has(k), + }, + json: async () => { + const txt = buf.toString('utf8'); + return txt ? JSON.parse(txt) : {}; + }, + text: async () => buf.toString('utf8'), + } as unknown as Response; + resolve(responseLike); + }); + } + ); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); + }; + // Override global fetch temporarily + // @ts-ignore - test environment mutation + global.fetch = customFetch as unknown as typeof fetch; + }; + + const restoreMockFetch = () => { + if (previousFetch) global.fetch = previousFetch; + if (previousHeaders) global.Headers = previousHeaders as typeof Headers; + if (previousRequest) global.Request = previousRequest as typeof Request; + if (previousResponse) global.Response = previousResponse as typeof Response; + }; + + const pingCouch = async (): Promise => { + try { + const res = await fetch(`${COUCH_URL}/`, { + headers: { Authorization: BASIC_AUTH }, + }); + return res.ok; + } catch { + return false; + } + }; + + beforeAll(async () => { + // Use development settings and disable mock DB so TokenService uses CouchDB + process.env.NODE_ENV = 'development'; + process.env.USE_MOCK_DB = 'false'; + process.env.VITE_COUCHDB_URL = COUCH_URL; + process.env.VITE_COUCHDB_USER = COUCH_USER; + process.env.VITE_COUCHDB_PASSWORD = COUCH_PASS; + + await installCustomFetch(); + + // Verify CouchDB is reachable + couchUp = await pingCouch(); + if (!couchUp) { + console.warn( + `⚠️ CouchDB not reachable at ${COUCH_URL}. Skipping TokenService integration tests.` + ); + restoreMockFetch(); + return; + } + + // Ensure a clean module graph and import TokenService fresh with current env + jest.resetModules(); + // import after env is set so unified config picks up these values + const mod = await import('../../token.service'); + tokenService = mod.tokenService; + + // Trigger DB provisioning just in case + // Save a no-op token then delete it; this ensures database exists. + const bootstrapToken = `bootstrap-${Date.now()}`; + await tokenService.savePasswordResetToken({ + userId: 'bootstrap', + email: 'bootstrap@example.com', + token: bootstrapToken, + expiresAt: new Date(Date.now() + 60_000), + }); + await tokenService.deletePasswordResetToken(bootstrapToken); + }, 30000); + + afterAll(async () => { + if (couchUp) { + try { + // Best-effort cleanup of expired tokens + await tokenService.cleanupExpiredTokens(); + } catch { + // ignore + } + } + // Restore original mocked fetch for the rest of the test suite + restoreMockFetch(); + }); + + const itIf = (cond: boolean) => (cond ? it : it.skip); + + itIf(couchUp)( + 'saves and retrieves a verification token', + async () => { + const tokenValue = `ver-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + const userId = `u-${Date.now()}`; + const email = `user-${Date.now()}@example.com`; + const expiresAt = new Date(Date.now() + 5 * 60_000); + + const token: EmailVerificationToken = { + userId, + email, + token: tokenValue, + expiresAt, + }; + + await tokenService.saveVerificationToken(token); + + const fetched = await tokenService.findVerificationToken(tokenValue); + expect(fetched).toBeTruthy(); + expect(fetched.userId).toBe(userId); + expect(fetched.email).toBe(email); + expect(new Date(fetched.expiresAt).getTime()).toBe(expiresAt.getTime()); + + // Cleanup + await tokenService.deleteVerificationTokensForUser(userId); + const after = await tokenService.findVerificationToken(tokenValue); + expect(after).toBeNull(); + }, + 30000 + ); + + itIf(couchUp)( + 'deletes only verification tokens for the specified user', + async () => { + const tokenA = `verA-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + const tokenB = `verB-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + const userA = `userA-${Date.now()}`; + const userB = `userB-${Date.now()}`; + const exp = new Date(Date.now() + 10 * 60_000); + + await tokenService.saveVerificationToken({ + userId: userA, + email: 'a@example.com', + token: tokenA, + expiresAt: exp, + }); + await tokenService.saveVerificationToken({ + userId: userB, + email: 'b@example.com', + token: tokenB, + expiresAt: exp, + }); + + // Delete tokens for userA + await tokenService.deleteVerificationTokensForUser(userA); + + // Verify A is deleted + const fa = await tokenService.findVerificationToken(tokenA); + expect(fa).toBeNull(); + + // Verify B still exists + const fb = await tokenService.findVerificationToken(tokenB); + expect(fb).toBeTruthy(); + expect(fb.userId).toBe(userB); + + // Cleanup B + await tokenService.deleteVerificationTokensForUser(userB); + const fbAfter = await tokenService.findVerificationToken(tokenB); + expect(fbAfter).toBeNull(); + }, + 30000 + ); + + itIf(couchUp)( + 'password reset token lifecycle (save, find, delete)', + async () => { + const tokenValue = `rst-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + const userId = `u-${Date.now()}`; + const email = `reset-${Date.now()}@example.com`; + const exp = new Date(Date.now() + 5 * 60_000); + + await tokenService.savePasswordResetToken({ + userId, + email, + token: tokenValue, + expiresAt: exp, + }); + + const fetched = await tokenService.findPasswordResetToken(tokenValue); + expect(fetched).toBeTruthy(); + expect(fetched.userId).toBe(userId); + expect(fetched.email).toBe(email); + + await tokenService.deletePasswordResetToken(tokenValue); + + const after = await tokenService.findPasswordResetToken(tokenValue); + expect(after).toBeNull(); + }, + 30000 + ); + + itIf(couchUp)( + 'cleanup removes expired tokens', + async () => { + const expiredToken = `expired-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + const userId = `u-exp-${Date.now()}`; + const email = `expired-${Date.now()}@example.com`; + const past = new Date(Date.now() - 60_000); + + // Save one expired verification token + await tokenService.saveVerificationToken({ + userId, + email, + token: expiredToken, + expiresAt: past, + }); + + // And one valid token to ensure only expired is removed + const validToken = `valid-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + const future = new Date(Date.now() + 60_000); + await tokenService.saveVerificationToken({ + userId, + email, + token: validToken, + expiresAt: future, + }); + + const removed = await tokenService.cleanupExpiredTokens(); + expect(removed).toBeGreaterThanOrEqual(1); + + const expiredFetched = + await tokenService.findVerificationToken(expiredToken); + expect(expiredFetched).toBeNull(); + + const validFetched = await tokenService.findVerificationToken(validToken); + expect(validFetched).toBeTruthy(); + + // Cleanup valid + await tokenService.deleteVerificationTokensForUser(userId); + const validAfter = await tokenService.findVerificationToken(validToken); + expect(validAfter).toBeNull(); + }, + 30000 + ); +}); diff --git a/services/auth/auth.service.ts b/services/auth/auth.service.ts index c062256..b7549e6 100644 --- a/services/auth/auth.service.ts +++ b/services/auth/auth.service.ts @@ -3,6 +3,7 @@ import { AuthenticatedUser } from './auth.types'; import { EmailVerificationService } from './emailVerification.service'; import { databaseService } from '../database'; import { logger } from '../logging'; +import { tokenService } from './token.service'; const emailVerificationService = new EmailVerificationService(); @@ -203,17 +204,13 @@ const authService = { const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + 1); // 1 hour expiry - // Store reset token (in production, save to database) - const resetTokens = JSON.parse( - localStorage.getItem('password_reset_tokens') || '[]' - ); - resetTokens.push({ + // Persist reset token + await tokenService.savePasswordResetToken({ userId: user._id, - email: user.email, + email: user.email!, token: resetToken, expiresAt, }); - localStorage.setItem('password_reset_tokens', JSON.stringify(resetTokens)); // Send reset email const emailSent = await emailVerificationService.sendPasswordResetEmail( @@ -229,14 +226,8 @@ const authService = { }, async resetPassword(token: string, newPassword: string) { - // Get reset tokens - const resetTokens = JSON.parse( - localStorage.getItem('password_reset_tokens') || '[]' - ); - const resetToken = resetTokens.find( - (t: { token: string; userId: string; email: string; expiresAt: Date }) => - t.token === token - ); + // Load reset token + const resetToken = await tokenService.findPasswordResetToken(token); if (!resetToken) { throw new Error('Invalid or expired reset token'); @@ -265,14 +256,7 @@ const authService = { }); // Remove used token - const filteredTokens = resetTokens.filter( - (t: { token: string; userId: string; email: string; expiresAt: Date }) => - t.token !== token - ); - localStorage.setItem( - 'password_reset_tokens', - JSON.stringify(filteredTokens) - ); + await tokenService.deletePasswordResetToken(token); return { user: updatedUser, diff --git a/services/auth/emailVerification.service.ts b/services/auth/emailVerification.service.ts index 548be16..a526e3b 100644 --- a/services/auth/emailVerification.service.ts +++ b/services/auth/emailVerification.service.ts @@ -3,6 +3,7 @@ import { EmailVerificationToken, AuthenticatedUser } from './auth.types'; import { mailgunService } from '../mailgun.service'; import { AccountStatus } from './auth.constants'; import { databaseService } from '../database'; +import { tokenService } from './token.service'; const TOKEN_EXPIRY_HOURS = 24; @@ -21,12 +22,8 @@ export class EmailVerificationService { expiresAt, }; - // Store token in localStorage for demo (in production, save to database) - const tokens = JSON.parse( - localStorage.getItem('verification_tokens') || '[]' - ); - tokens.push(verificationToken); - localStorage.setItem('verification_tokens', JSON.stringify(tokens)); + // Persist verification token via TokenService + await tokenService.saveVerificationToken(verificationToken); // Send verification email via Mailgun if (user.email) { @@ -45,13 +42,7 @@ export class EmailVerificationService { async validateVerificationToken( token: string ): Promise { - // Get tokens from localStorage - const tokens = JSON.parse( - localStorage.getItem('verification_tokens') || '[]' - ); - const verificationToken = tokens.find( - (t: EmailVerificationToken) => t.token === token - ); + const verificationToken = await tokenService.findVerificationToken(token); if (!verificationToken) { return null; @@ -78,14 +69,8 @@ export class EmailVerificationService { await databaseService.updateUser(updatedUser); - // Remove used token - const tokens = JSON.parse( - localStorage.getItem('verification_tokens') || '[]' - ); - const filteredTokens = tokens.filter( - (t: EmailVerificationToken) => t.userId !== user._id - ); - localStorage.setItem('verification_tokens', JSON.stringify(filteredTokens)); + // Remove used token(s) for this user + await tokenService.deleteVerificationTokensForUser(user._id); } async sendPasswordResetEmail(email: string, token: string): Promise { diff --git a/services/auth/token.service.ts b/services/auth/token.service.ts new file mode 100644 index 0000000..a4907a7 --- /dev/null +++ b/services/auth/token.service.ts @@ -0,0 +1,447 @@ +/* meds/services/auth/token.service.ts + * + * TokenService - unified persistence for email verification and password reset tokens. + * - In production (CouchDB configured), tokens are stored in a CouchDB database. + * - Otherwise, tokens are stored in localStorage for demo/dev/testing. + */ + +import { EmailVerificationToken } from './auth.types'; +import type { CouchDBDocument } from '../../types'; +import { getDatabaseConfig } from '../../config/unified.config'; +import { logger } from '../logging'; + +export interface PasswordResetToken { + userId: string; + email: string; + token: string; + expiresAt: Date; +} + +type TokenType = 'verification' | 'reset'; + +interface TokenDoc extends CouchDBDocument { + tokenType: TokenType; + token: string; + userId: string; + email?: string; + expiresAt: string; // ISO string + createdAt: string; // ISO string +} + +const DB_NAME = 'auth_tokens'; + +// Storage keys for localStorage fallback (kept compatible with existing code) +const LS_VERIFICATION_KEY = 'verification_tokens'; +const LS_RESET_KEY = 'password_reset_tokens'; + +function toISO(date: Date | string): string { + return (date instanceof Date ? date : new Date(date)).toISOString(); +} + +function fromISO(date: string | Date): Date { + return date instanceof Date ? date : new Date(date); +} + +function base64Auth(user: string, pass: string): string { + // btoa may not exist in some environments (e.g., Node). Fallback to Buffer. + if (typeof btoa !== 'undefined') { + return btoa(`${user}:${pass}`); + } + + return Buffer.from(`${user}:${pass}`).toString('base64'); +} + +export class TokenService { + private couchBaseUrl: string | null = null; + private couchAuthHeader: string | null = null; + private initialized = false; + private usingCouch = false; + + constructor() { + this.configure(); + } + + private configure() { + const db = getDatabaseConfig(); + // Use CouchDB only when a URL is provided and not explicitly mocked + this.usingCouch = !!db.url && db.url !== 'mock' && !db.useMock; + if (this.usingCouch) { + this.couchBaseUrl = db.url.replace(/\/+$/, ''); + this.couchAuthHeader = `Basic ${base64Auth(db.username, db.password)}`; + // Best-effort DB provisioning; do not block or throw + this.ensureDatabase().catch(err => { + logger.db.error('Failed to ensure auth token database', err as Error); + // Fallback to localStorage if DB provisioning fails + this.usingCouch = false; + }); + } + } + + private async ensureDatabase(): Promise { + if (this.initialized || !this.usingCouch || !this.couchBaseUrl) return; + + const head = await fetch(`${this.couchBaseUrl}/${DB_NAME}`, { + method: 'HEAD', + headers: { + Authorization: this.couchAuthHeader!, + }, + }); + + if (head.status === 404) { + const create = await fetch(`${this.couchBaseUrl}/${DB_NAME}`, { + method: 'PUT', + headers: { + Authorization: this.couchAuthHeader!, + 'Content-Type': 'application/json', + }, + }); + if (!create.ok) { + throw new Error( + `Failed to create ${DB_NAME} database: HTTP ${create.status}` + ); + } + } else if (!head.ok && head.status !== 200) { + throw new Error( + `Failed to check database ${DB_NAME}: HTTP ${head.status}` + ); + } + + this.initialized = true; + } + + private async makeCouchRequest( + method: string, + path: string, + body?: unknown + ): Promise { + if (!this.couchBaseUrl || !this.couchAuthHeader) { + throw new Error('CouchDB not configured'); + } + + const url = `${this.couchBaseUrl}${path}`; + const headers: Record = { + Authorization: this.couchAuthHeader, + 'Content-Type': 'application/json', + }; + const init: RequestInit = { method, headers }; + if (body !== undefined) init.body = JSON.stringify(body); + + const res = await fetch(url, init); + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text}`); + } + // Some Couch endpoints (DELETE) may return JSON or text; attempt JSON first + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + return (await res.json()) as T; + } + return (await res.text()) as unknown as T; + } + + // -------- LocalStorage helpers -------- + + private getLocalArray(key: string): T[] { + if (typeof localStorage === 'undefined') return []; + try { + return JSON.parse(localStorage.getItem(key) || '[]') as T[]; + } catch { + return []; + } + } + + private setLocalArray(key: string, items: T[]): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(key, JSON.stringify(items)); + } + + // -------- Public API: Verification Tokens -------- + + async saveVerificationToken(token: EmailVerificationToken): Promise { + if (this.usingCouch) { + await this.ensureDatabase(); + const docId = `ver-${token.token}`; + let existing: TokenDoc | null = null; + try { + existing = await this.makeCouchRequest( + 'GET', + `/${DB_NAME}/${docId}` + ); + } catch (_err) { + existing = null; + } + + const doc: TokenDoc = { + _id: docId, + _rev: existing?._rev || '', + tokenType: 'verification', + token: token.token, + userId: token.userId, + email: token.email, + expiresAt: toISO(token.expiresAt), + createdAt: existing?.createdAt || new Date().toISOString(), + }; + + const res = await this.makeCouchRequest<{ id: string; rev: string }>( + 'PUT', + `/${DB_NAME}/${docId}`, + doc + ); + doc._rev = res.rev; + return; + } + + // localStorage fallback + const list = + this.getLocalArray(LS_VERIFICATION_KEY); + // Replace any existing token with same token value + const filtered = list.filter(t => t.token !== token.token); + filtered.push(token); + this.setLocalArray(LS_VERIFICATION_KEY, filtered); + } + + async findVerificationToken( + token: string + ): Promise { + if (this.usingCouch) { + await this.ensureDatabase(); + const docId = `ver-${token}`; + try { + const doc = await this.makeCouchRequest( + 'GET', + `/${DB_NAME}/${docId}` + ); + return { + userId: doc.userId, + email: doc.email || '', + token: doc.token, + expiresAt: fromISO(doc.expiresAt), + }; + } catch (_err) { + return null; + } + } + + const list = + this.getLocalArray(LS_VERIFICATION_KEY); + const found = list.find(t => t.token === token); + return found + ? { + ...found, + expiresAt: fromISO(found.expiresAt), + } + : null; + } + + async deleteVerificationTokensForUser(userId: string): Promise { + if (this.usingCouch) { + await this.ensureDatabase(); + // Use Mango query to find all verification tokens for user + const result = await this.makeCouchRequest<{ + docs: TokenDoc[]; + warning?: string; + }>('POST', `/${DB_NAME}/_find`, { + selector: { userId, tokenType: 'verification' }, + }); + + for (const doc of result.docs) { + try { + await this.makeCouchRequest( + 'DELETE', + `/${DB_NAME}/${doc._id}?rev=${doc._rev}` + ); + } catch (err) { + logger.db.error( + 'Failed to delete verification token doc', + err as Error + ); + } + } + return; + } + + const list = + this.getLocalArray(LS_VERIFICATION_KEY); + const filtered = list.filter(t => t.userId !== userId); + this.setLocalArray(LS_VERIFICATION_KEY, filtered); + } + + // -------- Public API: Password Reset Tokens -------- + + async savePasswordResetToken(token: PasswordResetToken): Promise { + if (this.usingCouch) { + await this.ensureDatabase(); + const docId = `rst-${token.token}`; + let existing: TokenDoc | null = null; + try { + existing = await this.makeCouchRequest( + 'GET', + `/${DB_NAME}/${docId}` + ); + } catch (_err) { + existing = null; + } + + const doc: TokenDoc = { + _id: docId, + _rev: existing?._rev || '', + tokenType: 'reset', + token: token.token, + userId: token.userId, + email: token.email, + expiresAt: toISO(token.expiresAt), + createdAt: existing?.createdAt || new Date().toISOString(), + }; + + const res = await this.makeCouchRequest<{ id: string; rev: string }>( + 'PUT', + `/${DB_NAME}/${docId}`, + doc + ); + doc._rev = res.rev; + return; + } + + const list = this.getLocalArray(LS_RESET_KEY); + const filtered = list.filter(t => t.token !== token.token); + filtered.push(token); + this.setLocalArray(LS_RESET_KEY, filtered); + } + + async findPasswordResetToken( + token: string + ): Promise { + if (this.usingCouch) { + await this.ensureDatabase(); + const docId = `rst-${token}`; + try { + const doc = await this.makeCouchRequest( + 'GET', + `/${DB_NAME}/${docId}` + ); + return { + userId: doc.userId, + email: doc.email || '', + token: doc.token, + expiresAt: fromISO(doc.expiresAt), + }; + } catch (_err) { + return null; + } + } + + const list = this.getLocalArray(LS_RESET_KEY); + const found = list.find(t => t.token === token); + return found + ? { + ...found, + expiresAt: fromISO(found.expiresAt), + } + : null; + } + + async deletePasswordResetToken(token: string): Promise { + if (this.usingCouch) { + await this.ensureDatabase(); + const docId = `rst-${token}`; + // Need current _rev to delete + try { + const doc = await this.makeCouchRequest( + 'GET', + `/${DB_NAME}/${docId}` + ); + await this.makeCouchRequest( + 'DELETE', + `/${DB_NAME}/${docId}?rev=${doc._rev}` + ); + } catch (err) { + // Token may already be gone; log at debug level if available + logger.db.warn?.( + 'Password reset token not found to delete', + err as Error + ); + } + return; + } + + const list = this.getLocalArray(LS_RESET_KEY); + const filtered = list.filter(t => t.token !== token); + this.setLocalArray(LS_RESET_KEY, filtered); + } + + async deletePasswordResetTokensForUser(userId: string): Promise { + if (this.usingCouch) { + await this.ensureDatabase(); + const result = await this.makeCouchRequest<{ + docs: TokenDoc[]; + }>('POST', `/${DB_NAME}/_find`, { + selector: { userId, tokenType: 'reset' }, + }); + + for (const doc of result.docs) { + try { + await this.makeCouchRequest( + 'DELETE', + `/${DB_NAME}/${doc._id}?rev=${doc._rev}` + ); + } catch (err) { + logger.db.error( + 'Failed to delete password reset token doc', + err as Error + ); + } + } + return; + } + + const list = this.getLocalArray(LS_RESET_KEY); + const filtered = list.filter(t => t.userId !== userId); + this.setLocalArray(LS_RESET_KEY, filtered); + } + + // Optional maintenance utility: remove expired tokens + async cleanupExpiredTokens(): Promise { + let removed = 0; + const now = new Date(); + + if (this.usingCouch) { + await this.ensureDatabase(); + // Fetch all tokens and filter by expiry (for small scale). For large scale, create an index. + const result = await this.makeCouchRequest<{ + rows: Array<{ id: string; value: unknown; doc: TokenDoc }>; + }>('GET', `/${DB_NAME}/_all_docs?include_docs=true`); + + for (const row of result.rows) { + const doc = row.doc; + if (doc && new Date(doc.expiresAt) < now) { + try { + await this.makeCouchRequest( + 'DELETE', + `/${DB_NAME}/${doc._id}?rev=${doc._rev}` + ); + removed++; + } catch (err) { + logger.db.error('Failed to delete expired token', err as Error); + } + } + } + return removed; + } + + // localStorage cleanup + const ver = this.getLocalArray(LS_VERIFICATION_KEY); + const verKeep = ver.filter(t => fromISO(t.expiresAt) >= now); + removed += ver.length - verKeep.length; + this.setLocalArray(LS_VERIFICATION_KEY, verKeep); + + const rst = this.getLocalArray(LS_RESET_KEY); + const rstKeep = rst.filter(t => fromISO(t.expiresAt) >= now); + removed += rst.length - rstKeep.length; + this.setLocalArray(LS_RESET_KEY, rstKeep); + + return removed; + } +} + +export const tokenService = new TokenService(); +export default tokenService; diff --git a/services/database/ProductionDatabaseStrategy.ts b/services/database/ProductionDatabaseStrategy.ts index c3f0904..2758736 100644 --- a/services/database/ProductionDatabaseStrategy.ts +++ b/services/database/ProductionDatabaseStrategy.ts @@ -28,6 +28,11 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy { url: dbConfig.url, username: dbConfig.username, }); + + // Provision required databases on startup (non-blocking) + this.initializeDatabases().catch(error => { + logger.db.error('Failed to initialize databases', error as Error); + }); } private async initializeDatabases(): Promise { diff --git a/services/oauth.ts b/services/oauth.ts index 6912817..307dac2 100644 --- a/services/oauth.ts +++ b/services/oauth.ts @@ -4,9 +4,12 @@ import { navigationService, } from './navigation/navigation.interface'; +import { getAppConfig, getOAuthConfig } from '../config/unified.config'; + // Mock OAuth configuration -const GOOGLE_CLIENT_ID = 'mock_google_client_id'; -const GITHUB_CLIENT_ID = 'mock_github_client_id'; +const { google, github } = getOAuthConfig(); +const GOOGLE_CLIENT_ID = google?.clientId || 'mock_google_client_id'; +const GITHUB_CLIENT_ID = github?.clientId || 'mock_github_client_id'; // Mock OAuth endpoints const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; @@ -17,7 +20,8 @@ const GOOGLE_SCOPES = 'openid email profile'; const GITHUB_SCOPES = 'user:email'; // Mock redirect URI -const REDIRECT_URI = 'http://localhost:3000/auth/callback'; +const APP_BASE_URL = getAppConfig().baseUrl; +const REDIRECT_URI = `${APP_BASE_URL.replace(/\/$/, '')}/auth/callback`; // Mock OAuth state generation const generateState = () => crypto.randomUUID(); diff --git a/tests/README.md b/tests/README.md index c6537d6..d058183 100644 --- a/tests/README.md +++ b/tests/README.md @@ -25,7 +25,9 @@ services/ └── auth/ └── __tests__/ # Unit tests for authentication services ├── auth.integration.test.ts - └── emailVerification.test.ts + ├── emailVerification.test.ts + └── integration/ + └── token.service.integration.test.ts ``` ## Running Tests @@ -68,6 +70,79 @@ bun run test:e2e:debug bun run test:e2e:report ``` +#### TokenService CouchDB Integration + +##### Using Docker Compose locally + +You can spin up a local CouchDB for integration tests using the included docker-compose file: + +```bash +docker compose -f meds/docker-compose.ci.yml up -d couchdb + +export VITE_COUCHDB_URL=http://localhost:5984 +export VITE_COUCHDB_USER=admin +export VITE_COUCHDB_PASSWORD=password + +bun run test:integration +``` + +- The compose file is intended for local/CI testing only. +- Data is stored in a named volume (couchdb-test-data) and can be removed with: + - docker compose -f meds/docker-compose.ci.yml down -v + +##### Optional CI workflow + +An optional GitHub Actions workflow exists to run these CouchDB-backed tests: + +- Name: Integration Tests (CouchDB) +- Triggers: + - Manually via “Run workflow” (workflow_dispatch), or + - Automatically if repository variable RUN_COUCHDB_INTEGRATION is set to "true" +- It provisions a CouchDB service and runs: + - bun run test:integration + +To enable automatic runs, set the repository variable RUN_COUCHDB_INTEGRATION to "true" in your repo settings. You can also override credentials via: + +- VITE_COUCHDB_URL (defaults to http://couchdb:5984 in CI) +- VITE_COUCHDB_USER (defaults to admin) +- VITE_COUCHDB_PASSWORD (defaults to password if not set as a secret) + +Integration tests for the TokenService talk to a live CouchDB if available. They automatically skip when CouchDB is not reachable. + +```bash +# Run TokenService integration tests (requires CouchDB at VITE_COUCHDB_URL) +# Defaults: VITE_COUCHDB_URL=http://localhost:5984, VITE_COUCHDB_USER=admin, VITE_COUCHDB_PASSWORD=password +VITE_COUCHDB_URL=http://localhost:5984 \ +VITE_COUCHDB_USER=admin \ +VITE_COUCHDB_PASSWORD=password \ +bun run test meds/services/auth/__tests__/integration/token.service.integration.test.ts +``` + +- Provide your own CouchDB credentials via environment variables if different from the defaults above. +- These tests will provision an `auth_tokens` database automatically and clean up test data as part of the lifecycle. + +#### OAuth Redirect Configuration + +OAuth redirect URIs are derived from the unified configuration: + +- Redirect URI = `${APP_BASE_URL}/auth/callback` +- Development default: `APP_BASE_URL=http://localhost:5173` +- Test default: `APP_BASE_URL=http://localhost:3000` + +To exercise OAuth URL construction with your own IDs, set: + +- `VITE_GOOGLE_CLIENT_ID` +- `VITE_GITHUB_CLIENT_ID` + +Example: + +```bash +APP_BASE_URL=http://localhost:5173 \ +VITE_GOOGLE_CLIENT_ID=your-google-client-id \ +VITE_GITHUB_CLIENT_ID=your-github-client-id \ +bun run dev +``` + ### Manual Testing Scripts #### Admin Login Debug @@ -122,7 +197,7 @@ bun tests/manual/auth-db-debug.js ### Jest Configuration (`jest.config.json`) -- TypeScript support with ts-jest +- TypeScript transformed via babel-jest (@babel/preset-typescript); import.meta handled in Babel config - jsdom environment for DOM testing - Coverage reporting - Module path mapping diff --git a/types/express.d.ts b/types/express.d.ts new file mode 100644 index 0000000..29e2f29 --- /dev/null +++ b/types/express.d.ts @@ -0,0 +1,28 @@ +/** + * Express Request type augmentation + * + * Adds a `user` property to `Express.Request` to store authenticated user + * information attached by authentication middleware. + * + * The `user` shape is flexible to accommodate different token verification + * outputs. When using our JWT-based middleware, it will typically match + * `TokenPayload`, but we also allow `string` or a generic record for + * compatibility with other strategies. + */ + +import type { TokenPayload } from '../services/auth/auth.types'; + +declare global { + namespace Express { + interface Request { + /** + * Authenticated user payload attached by middleware. + * - Usually matches TokenPayload in this project. + * - May be a string or a generic object when using different verifiers. + */ + user?: TokenPayload | string | Record; + } + } +} + +export {};