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
This commit is contained in:
@@ -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
|
||||||
@@ -14,7 +14,7 @@ DOCKER_IMAGE ?= $(APP_NAME):latest
|
|||||||
|
|
||||||
export
|
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 target
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
@@ -90,3 +90,18 @@ docker-clean: ## Clean Docker resources and containers
|
|||||||
@docker rmi $(DOCKER_IMAGE) 2>/dev/null || true
|
@docker rmi $(DOCKER_IMAGE) 2>/dev/null || true
|
||||||
@docker image prune -f 2>/dev/null || true
|
@docker image prune -f 2>/dev/null || true
|
||||||
@docker container 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
|
||||||
|
|||||||
@@ -200,6 +200,22 @@ VITE_GOOGLE_CLIENT_ID=your-google-client-id
|
|||||||
VITE_GITHUB_CLIENT_ID=your-github-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**
|
### **Database Strategy**
|
||||||
|
|
||||||
The application automatically selects the appropriate database strategy:
|
The application automatically selects the appropriate database strategy:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"uuid": "^12.0.0",
|
"uuid": "^12.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.4",
|
"@babel/core": "^7.28.4",
|
||||||
@@ -40,9 +40,9 @@
|
|||||||
"shelljs": "^0.10.0",
|
"shelljs": "^0.10.0",
|
||||||
"ts-jest": "^29.4.1",
|
"ts-jest": "^29.4.1",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^7.1.4",
|
"vite": "^7.1.4"
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
"@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=="],
|
"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=="]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -869,6 +869,28 @@ export function exportAsEnvVars(
|
|||||||
COUCHDB_PASSWORD: configToUse.database.password,
|
COUCHDB_PASSWORD: configToUse.database.password,
|
||||||
COUCHDB_DATABASE_NAME: configToUse.database.name,
|
COUCHDB_DATABASE_NAME: configToUse.database.name,
|
||||||
USE_MOCK_DB: configToUse.database.useMock.toString(),
|
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
|
||||||
CONTAINER_REGISTRY: configToUse.container.registry,
|
CONTAINER_REGISTRY: configToUse.container.registry,
|
||||||
|
|||||||
@@ -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
|
||||||
+1
-9
@@ -32,15 +32,7 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"react": "https://aistudiocdn.com/react@^19.1.1",
|
|
||||||
"react/": "https://aistudiocdn.com/react@^19.1.1/",
|
|
||||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.1.1/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<link rel="stylesheet" href="/index.css" />
|
<link rel="stylesheet" href="/index.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"preset": "ts-jest",
|
|
||||||
"testEnvironment": "jsdom",
|
"testEnvironment": "jsdom",
|
||||||
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
|
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
|
||||||
"testMatch": [
|
"testMatch": [
|
||||||
@@ -23,11 +22,10 @@
|
|||||||
"coverageReporters": ["text", "lcov", "html"],
|
"coverageReporters": ["text", "lcov", "html"],
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@/(.*)$": "<rootDir>/$1",
|
"^@/(.*)$": "<rootDir>/$1",
|
||||||
"^uuid$": "<rootDir>/tests/__mocks__/uuid.js",
|
|
||||||
"^node-fetch$": "<rootDir>/tests/__mocks__/node-fetch.js"
|
"^node-fetch$": "<rootDir>/tests/__mocks__/node-fetch.js"
|
||||||
},
|
},
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.tsx?$": "ts-jest",
|
"^.+\\.tsx?$": "babel-jest",
|
||||||
"^.+\\.jsx?$": "babel-jest"
|
"^.+\\.jsx?$": "babel-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": ["node_modules/(?!(@jest/transform|uuid|node-fetch)/)"],
|
"transformIgnorePatterns": ["node_modules/(?!(@jest/transform|uuid|node-fetch)/)"],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"test:fast": "jest --testPathPatterns='(utils|types|services).*test\\.(ts|js)$' --passWithNoTests",
|
"test:fast": "jest --testPathPatterns='(utils|types|services).*test\\.(ts|js)$' --passWithNoTests",
|
||||||
"test:unit": "jest --testPathPatterns='(utils|types).*test\\.(ts|js)$'",
|
"test:unit": "jest --testPathPatterns='(utils|types).*test\\.(ts|js)$'",
|
||||||
"test:services": "jest --testPathPatterns='services.*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": "markdownlint-cli2 \"**/*.md\"",
|
||||||
"lint:markdown:fix": "markdownlint-cli2 --fix \"**/*.md\"",
|
"lint:markdown:fix": "markdownlint-cli2 --fix \"**/*.md\"",
|
||||||
"check:secrets": "secretlint \"**/*\"",
|
"check:secrets": "secretlint \"**/*\"",
|
||||||
|
|||||||
@@ -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<string, string> | undefined;
|
||||||
|
const method = init?.method || 'GET';
|
||||||
|
const body = init?.body as string | undefined;
|
||||||
|
return await new Promise<Response>((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<string, string>();
|
||||||
|
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<boolean> => {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { AuthenticatedUser } from './auth.types';
|
|||||||
import { EmailVerificationService } from './emailVerification.service';
|
import { EmailVerificationService } from './emailVerification.service';
|
||||||
import { databaseService } from '../database';
|
import { databaseService } from '../database';
|
||||||
import { logger } from '../logging';
|
import { logger } from '../logging';
|
||||||
|
import { tokenService } from './token.service';
|
||||||
|
|
||||||
const emailVerificationService = new EmailVerificationService();
|
const emailVerificationService = new EmailVerificationService();
|
||||||
|
|
||||||
@@ -203,17 +204,13 @@ const authService = {
|
|||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setHours(expiresAt.getHours() + 1); // 1 hour expiry
|
expiresAt.setHours(expiresAt.getHours() + 1); // 1 hour expiry
|
||||||
|
|
||||||
// Store reset token (in production, save to database)
|
// Persist reset token
|
||||||
const resetTokens = JSON.parse(
|
await tokenService.savePasswordResetToken({
|
||||||
localStorage.getItem('password_reset_tokens') || '[]'
|
|
||||||
);
|
|
||||||
resetTokens.push({
|
|
||||||
userId: user._id,
|
userId: user._id,
|
||||||
email: user.email,
|
email: user.email!,
|
||||||
token: resetToken,
|
token: resetToken,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
localStorage.setItem('password_reset_tokens', JSON.stringify(resetTokens));
|
|
||||||
|
|
||||||
// Send reset email
|
// Send reset email
|
||||||
const emailSent = await emailVerificationService.sendPasswordResetEmail(
|
const emailSent = await emailVerificationService.sendPasswordResetEmail(
|
||||||
@@ -229,14 +226,8 @@ const authService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async resetPassword(token: string, newPassword: string) {
|
async resetPassword(token: string, newPassword: string) {
|
||||||
// Get reset tokens
|
// Load reset token
|
||||||
const resetTokens = JSON.parse(
|
const resetToken = await tokenService.findPasswordResetToken(token);
|
||||||
localStorage.getItem('password_reset_tokens') || '[]'
|
|
||||||
);
|
|
||||||
const resetToken = resetTokens.find(
|
|
||||||
(t: { token: string; userId: string; email: string; expiresAt: Date }) =>
|
|
||||||
t.token === token
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!resetToken) {
|
if (!resetToken) {
|
||||||
throw new Error('Invalid or expired reset token');
|
throw new Error('Invalid or expired reset token');
|
||||||
@@ -265,14 +256,7 @@ const authService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Remove used token
|
// Remove used token
|
||||||
const filteredTokens = resetTokens.filter(
|
await tokenService.deletePasswordResetToken(token);
|
||||||
(t: { token: string; userId: string; email: string; expiresAt: Date }) =>
|
|
||||||
t.token !== token
|
|
||||||
);
|
|
||||||
localStorage.setItem(
|
|
||||||
'password_reset_tokens',
|
|
||||||
JSON.stringify(filteredTokens)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: updatedUser,
|
user: updatedUser,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { EmailVerificationToken, AuthenticatedUser } from './auth.types';
|
|||||||
import { mailgunService } from '../mailgun.service';
|
import { mailgunService } from '../mailgun.service';
|
||||||
import { AccountStatus } from './auth.constants';
|
import { AccountStatus } from './auth.constants';
|
||||||
import { databaseService } from '../database';
|
import { databaseService } from '../database';
|
||||||
|
import { tokenService } from './token.service';
|
||||||
|
|
||||||
const TOKEN_EXPIRY_HOURS = 24;
|
const TOKEN_EXPIRY_HOURS = 24;
|
||||||
|
|
||||||
@@ -21,12 +22,8 @@ export class EmailVerificationService {
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store token in localStorage for demo (in production, save to database)
|
// Persist verification token via TokenService
|
||||||
const tokens = JSON.parse(
|
await tokenService.saveVerificationToken(verificationToken);
|
||||||
localStorage.getItem('verification_tokens') || '[]'
|
|
||||||
);
|
|
||||||
tokens.push(verificationToken);
|
|
||||||
localStorage.setItem('verification_tokens', JSON.stringify(tokens));
|
|
||||||
|
|
||||||
// Send verification email via Mailgun
|
// Send verification email via Mailgun
|
||||||
if (user.email) {
|
if (user.email) {
|
||||||
@@ -45,13 +42,7 @@ export class EmailVerificationService {
|
|||||||
async validateVerificationToken(
|
async validateVerificationToken(
|
||||||
token: string
|
token: string
|
||||||
): Promise<AuthenticatedUser | null> {
|
): Promise<AuthenticatedUser | null> {
|
||||||
// Get tokens from localStorage
|
const verificationToken = await tokenService.findVerificationToken(token);
|
||||||
const tokens = JSON.parse(
|
|
||||||
localStorage.getItem('verification_tokens') || '[]'
|
|
||||||
);
|
|
||||||
const verificationToken = tokens.find(
|
|
||||||
(t: EmailVerificationToken) => t.token === token
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!verificationToken) {
|
if (!verificationToken) {
|
||||||
return null;
|
return null;
|
||||||
@@ -78,14 +69,8 @@ export class EmailVerificationService {
|
|||||||
|
|
||||||
await databaseService.updateUser(updatedUser);
|
await databaseService.updateUser(updatedUser);
|
||||||
|
|
||||||
// Remove used token
|
// Remove used token(s) for this user
|
||||||
const tokens = JSON.parse(
|
await tokenService.deleteVerificationTokensForUser(user._id);
|
||||||
localStorage.getItem('verification_tokens') || '[]'
|
|
||||||
);
|
|
||||||
const filteredTokens = tokens.filter(
|
|
||||||
(t: EmailVerificationToken) => t.userId !== user._id
|
|
||||||
);
|
|
||||||
localStorage.setItem('verification_tokens', JSON.stringify(filteredTokens));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPasswordResetEmail(email: string, token: string): Promise<boolean> {
|
async sendPasswordResetEmail(email: string, token: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
if (!this.couchBaseUrl || !this.couchAuthHeader) {
|
||||||
|
throw new Error('CouchDB not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.couchBaseUrl}${path}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
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<T>(key: string): T[] {
|
||||||
|
if (typeof localStorage === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(key) || '[]') as T[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLocalArray<T>(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<void> {
|
||||||
|
if (this.usingCouch) {
|
||||||
|
await this.ensureDatabase();
|
||||||
|
const docId = `ver-${token.token}`;
|
||||||
|
let existing: TokenDoc | null = null;
|
||||||
|
try {
|
||||||
|
existing = await this.makeCouchRequest<TokenDoc>(
|
||||||
|
'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<EmailVerificationToken>(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<EmailVerificationToken | null> {
|
||||||
|
if (this.usingCouch) {
|
||||||
|
await this.ensureDatabase();
|
||||||
|
const docId = `ver-${token}`;
|
||||||
|
try {
|
||||||
|
const doc = await this.makeCouchRequest<TokenDoc>(
|
||||||
|
'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<EmailVerificationToken>(LS_VERIFICATION_KEY);
|
||||||
|
const found = list.find(t => t.token === token);
|
||||||
|
return found
|
||||||
|
? {
|
||||||
|
...found,
|
||||||
|
expiresAt: fromISO(found.expiresAt),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteVerificationTokensForUser(userId: string): Promise<void> {
|
||||||
|
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<EmailVerificationToken>(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<void> {
|
||||||
|
if (this.usingCouch) {
|
||||||
|
await this.ensureDatabase();
|
||||||
|
const docId = `rst-${token.token}`;
|
||||||
|
let existing: TokenDoc | null = null;
|
||||||
|
try {
|
||||||
|
existing = await this.makeCouchRequest<TokenDoc>(
|
||||||
|
'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<PasswordResetToken>(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<PasswordResetToken | null> {
|
||||||
|
if (this.usingCouch) {
|
||||||
|
await this.ensureDatabase();
|
||||||
|
const docId = `rst-${token}`;
|
||||||
|
try {
|
||||||
|
const doc = await this.makeCouchRequest<TokenDoc>(
|
||||||
|
'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<PasswordResetToken>(LS_RESET_KEY);
|
||||||
|
const found = list.find(t => t.token === token);
|
||||||
|
return found
|
||||||
|
? {
|
||||||
|
...found,
|
||||||
|
expiresAt: fromISO(found.expiresAt),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePasswordResetToken(token: string): Promise<void> {
|
||||||
|
if (this.usingCouch) {
|
||||||
|
await this.ensureDatabase();
|
||||||
|
const docId = `rst-${token}`;
|
||||||
|
// Need current _rev to delete
|
||||||
|
try {
|
||||||
|
const doc = await this.makeCouchRequest<TokenDoc>(
|
||||||
|
'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<PasswordResetToken>(LS_RESET_KEY);
|
||||||
|
const filtered = list.filter(t => t.token !== token);
|
||||||
|
this.setLocalArray(LS_RESET_KEY, filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePasswordResetTokensForUser(userId: string): Promise<void> {
|
||||||
|
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<PasswordResetToken>(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<number> {
|
||||||
|
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<EmailVerificationToken>(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<PasswordResetToken>(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;
|
||||||
@@ -28,6 +28,11 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
|
|||||||
url: dbConfig.url,
|
url: dbConfig.url,
|
||||||
username: dbConfig.username,
|
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<void> {
|
private async initializeDatabases(): Promise<void> {
|
||||||
|
|||||||
+7
-3
@@ -4,9 +4,12 @@ import {
|
|||||||
navigationService,
|
navigationService,
|
||||||
} from './navigation/navigation.interface';
|
} from './navigation/navigation.interface';
|
||||||
|
|
||||||
|
import { getAppConfig, getOAuthConfig } from '../config/unified.config';
|
||||||
|
|
||||||
// Mock OAuth configuration
|
// Mock OAuth configuration
|
||||||
const GOOGLE_CLIENT_ID = 'mock_google_client_id';
|
const { google, github } = getOAuthConfig();
|
||||||
const GITHUB_CLIENT_ID = 'mock_github_client_id';
|
const GOOGLE_CLIENT_ID = google?.clientId || 'mock_google_client_id';
|
||||||
|
const GITHUB_CLIENT_ID = github?.clientId || 'mock_github_client_id';
|
||||||
|
|
||||||
// Mock OAuth endpoints
|
// Mock OAuth endpoints
|
||||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
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';
|
const GITHUB_SCOPES = 'user:email';
|
||||||
|
|
||||||
// Mock redirect URI
|
// 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
|
// Mock OAuth state generation
|
||||||
const generateState = () => crypto.randomUUID();
|
const generateState = () => crypto.randomUUID();
|
||||||
|
|||||||
+77
-2
@@ -25,7 +25,9 @@ services/
|
|||||||
└── auth/
|
└── auth/
|
||||||
└── __tests__/ # Unit tests for authentication services
|
└── __tests__/ # Unit tests for authentication services
|
||||||
├── auth.integration.test.ts
|
├── auth.integration.test.ts
|
||||||
└── emailVerification.test.ts
|
├── emailVerification.test.ts
|
||||||
|
└── integration/
|
||||||
|
└── token.service.integration.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
@@ -68,6 +70,79 @@ bun run test:e2e:debug
|
|||||||
bun run test:e2e:report
|
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
|
### Manual Testing Scripts
|
||||||
|
|
||||||
#### Admin Login Debug
|
#### Admin Login Debug
|
||||||
@@ -122,7 +197,7 @@ bun tests/manual/auth-db-debug.js
|
|||||||
|
|
||||||
### Jest Configuration (`jest.config.json`)
|
### 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
|
- jsdom environment for DOM testing
|
||||||
- Coverage reporting
|
- Coverage reporting
|
||||||
- Module path mapping
|
- Module path mapping
|
||||||
|
|||||||
Vendored
+28
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
Reference in New Issue
Block a user