From 5fbc2b49e12a3cd5ade26505b9ae25096625b09c Mon Sep 17 00:00:00 2001 From: Copstud3 Date: Sun, 30 Aug 2026 20:22:35 +0100 Subject: [PATCH 1/4] Refactor test files for improved readability and consistency - Updated formatting in wallet-self-custody-export.service.test.ts for better alignment and readability. - Enhanced formatting in wallet-status.controller.test.ts to maintain consistent code style. - Refactored wallet-status.service.test.ts to improve code clarity and structure. - Cleaned up outbox-relay.test.ts by standardizing function formatting and improving readability. - Improved scheduled-job-runner.test.ts with consistent formatting and clearer structure. - Adjusted tsconfig.json for better formatting consistency. - Updated vitest.config.js to include coverage thresholds for improved test quality metrics. --- .github/SECURITY.md | 62 +- .github/workflows/ci.yml | 96 +- .github/workflows/deploy-staging.yml | 16 +- PR_DESCRIPTION.md | 13 + README.md | 286 +- docker-compose.yml | 14 +- docs/API.md | 1957 +++++------ docs/API_CONVENTIONS.md | 133 +- docs/ARCHITECTURE.md | 17 +- docs/AUTH_POLICY.md | 50 +- docs/CODE_OF_CONDUCT.md | 366 +-- docs/CONTRIBUTING.md | 330 +- docs/DATA_LIFECYCLE.md | 174 +- docs/DEVELOPMENT_STACK.md | 32 +- docs/ERROR_HANDLING.md | 8 +- docs/RUNBOOK.md | 11 +- docs/SECURITY.md | 604 ++-- .../0001-phone-otp-authentication.md | 14 +- .../0003-onboarding-consent-persistence.md | 16 +- docs/decisions/0004-profile-api.md | 34 +- docs/domains/DOMAIN_DEFINITIONS.md | 62 + docs/domains/DOMAIN_MAP.md | 79 +- docs/domains/IMPLEMENTATION_SUMMARY.md | 60 +- docs/domains/README.md | 21 +- docs/domains/REQUEST_AND_EVENT_FLOWS.md | 146 +- docs/domains/SHARED_KERNEL.md | 59 +- docs/security/refresh-token-rotation.md | 26 +- eslint.config.ts | 150 +- .../architecture/domain-boundaries.test.ts | 249 +- integrations/auth.controller.test.ts | 294 +- integrations/error.middleware.test.ts | 8 +- integrations/services/webhook.service.spec.ts | 214 +- integrations/setup.ts | 2 +- integrations/stellar.service.test.ts | 79 +- integrations/unit/auth.middleware.test.ts | 39 +- .../unit/credential.controller.test.ts | 84 +- integrations/unit/employer.controller.test.ts | 33 +- integrations/unit/errorHandler.test.ts | 6 +- integrations/unit/graceful-shutdown.test.ts | 8 +- integrations/unit/health.routes.test.ts | 4 +- integrations/unit/rate-limit.test.ts | 238 +- integrations/unit/request-context.test.ts | 8 +- integrations/unit/reward.service.test.ts | 12 +- .../unit/validation.middleware.test.ts | 163 +- integrations/user.controller.test.ts | 629 ++-- nodemon.json | 10 +- package.json | 5 + patches/zeptomatch-cjs-shim/index.js | 88 +- patches/zeptomatch-cjs-wrapper.js | 36 +- patches/zeptomatch-cjs.cjs | 86 +- pnpm-lock.yaml | 11 + prisma.config.ts | 4 +- prisma/SETUP.md | 28 +- prisma/fixtures/modules.ts | 24 +- .../migration.sql | 33 +- prisma/schema.prisma | 664 ++-- prisma/seed.ts | 42 +- scripts/relay-verification.ts | 88 +- src/app.ts | 4 +- src/audit/archive.ts | 26 +- src/audit/audit-event.service.ts | 29 +- src/audit/audited-mutation.ts | 32 +- src/audit/classification.ts | 18 +- src/audit/redaction.ts | 28 +- src/audit/types.ts | 3 +- src/config/database.ts | 2 +- src/config/env.ts | 126 +- src/config/jwt.ts | 20 +- src/config/logger.ts | 78 +- src/config/scheduler.ts | 6 +- src/config/swagger.ts | 37 +- src/controllers/account.controller.ts | 1245 +++---- src/controllers/auth.controller.ts | 2906 +++++++++-------- src/controllers/avatar.controller.ts | 5 +- src/controllers/consent.controller.ts | 30 +- src/controllers/credential.controller.ts | 6 +- src/controllers/employer.controller.ts | 34 +- src/controllers/module.controller.ts | 132 +- src/controllers/notification.controller.ts | 39 +- src/controllers/onboarding.controller.ts | 28 +- src/controllers/preference.controller.ts | 54 +- src/controllers/profile.controller.ts | 13 +- src/controllers/referral.controller.ts | 192 +- src/controllers/reward.controller.ts | 4 +- src/controllers/session.controller.ts | 21 +- src/controllers/sync.controller.ts | 343 +- src/controllers/user.controller.ts | 18 +- src/controllers/wallet-status.controller.ts | 14 +- src/jobs/handler-registrations.ts | 8 +- src/jobs/user-created.handler.ts | 14 +- .../wallet-provisioning-requested.handler.ts | 6 +- src/jobs/wallet-provisioning.handler.ts | 4 +- src/lib/transactions/README.md | 168 +- src/lib/transactions/event-schema.ts | 8 +- src/lib/transactions/handler-registry.ts | 30 +- src/lib/transactions/job-lease.service.ts | 24 +- src/lib/transactions/outbox.service.ts | 12 +- .../transactions/tests/event-schema.test.ts | 21 +- src/lib/transactions/types.ts | 4 +- src/middleware/auth.middleware.ts | 510 +-- src/middleware/error.middleware.ts | 11 +- src/middleware/errorHandler.ts | 24 +- src/middleware/rate-limit.middleware.ts | 283 +- src/middleware/request-context.ts | 2 +- src/middleware/validation.middleware.ts | 17 +- src/middleware/versioning.middleware.ts | 4 +- src/routes/health.routes.ts | 4 +- src/routes/v1/account.routes.ts | 55 +- src/routes/v1/auth.routes.ts | 38 +- src/routes/v1/avatar.routes.ts | 15 +- src/routes/v1/consent.routes.ts | 24 +- src/routes/v1/credentials.routes.ts | 6 +- src/routes/v1/employer.routes.ts | 19 +- src/routes/v1/modules.routes.ts | 14 +- src/routes/v1/notifications.routes.ts | 18 +- src/routes/v1/onboarding.routes.ts | 18 +- src/routes/v1/rewards.routes.ts | 5 +- src/routes/v1/sessions.routes.ts | 14 +- src/routes/v1/users.routes.ts | 59 +- src/routes/v1/wallet.routes.ts | 5 +- src/schemas/account.schema.ts | 27 +- src/schemas/api.schema.ts | 27 +- src/schemas/auth.schema.ts | 63 +- src/schemas/profile.schema.ts | 20 +- src/schemas/session.schema.ts | 4 +- src/server.ts | 16 +- src/services/account-lifecycle.service.ts | 101 +- src/services/asset-validation.service.ts | 51 +- src/services/avatar.service.ts | 47 +- src/services/consent.service.ts | 40 +- src/services/data-export.service.ts | 89 +- src/services/email.service.ts | 14 +- src/services/notification.service.ts | 56 +- src/services/onboarding.service.ts | 31 +- src/services/otp.service.ts | 21 +- src/services/preference.service.ts | 20 +- src/services/profile-serializer.ts | 44 +- src/services/profile.service.ts | 33 +- src/services/refresh-token.service.ts | 56 +- src/services/reward.service.ts | 5 +- src/services/session.service.ts | 57 +- src/services/stellar-funding.service.ts | 63 +- src/services/stellar.service.ts | 1531 ++++----- src/services/storage/in-memory-storage.ts | 41 +- src/services/user-account.service.ts | 23 +- .../wallet-provisioning.repository.ts | 54 +- .../wallet-self-custody-export.service.ts | 17 +- src/services/wallet-status.service.ts | 33 +- src/services/webhook.service.ts | 351 +- src/types/account.types.ts | 6 +- src/types/api.types.ts | 176 +- src/types/avatar.types.ts | 22 +- src/types/consent.types.ts | 6 +- src/types/credential.types.ts | 88 +- src/types/module.types.ts | 102 +- src/types/onboarding.types.ts | 15 +- src/types/preference.types.ts | 36 +- src/types/profile.types.ts | 39 +- src/types/reward.types.ts | 72 +- src/types/session.types.ts | 3 +- src/types/user.types.ts | 58 +- src/types/wallet-provisioning.types.ts | 19 +- src/types/wallet-self-custody-export.types.ts | 5 +- src/types/wallet-status.types.ts | 11 +- src/types/webhook.types.ts | 23 +- src/utils/cookies.ts | 4 +- src/utils/date.ts | 13 +- src/utils/errors.ts | 4 +- src/utils/jwt.ts | 8 +- src/utils/logger.ts | 8 +- src/utils/money.ts | 19 +- src/utils/number.ts | 6 +- src/utils/password.ts | 8 +- src/utils/string.ts | 6 +- src/utils/transitions.ts | 6 +- src/workers/outbox-relay.ts | 66 +- src/workers/outbox-replay.ts | 9 +- src/workers/queue-metrics.ts | 9 +- src/workers/queue-registry.ts | 45 +- src/workers/scheduled-job-runner.ts | 73 +- src/workers/scheduler.worker.ts | 10 +- swagger-server.ts | 3 +- tests/account-lifecycle.service.test.ts | 78 +- tests/account.controller.test.ts | 165 +- tests/asset-validation.service.test.ts | 26 +- tests/audit.service.test.ts | 2 +- tests/audit/archive.test.ts | 92 +- tests/audit/audit-event.service.test.ts | 78 +- tests/audit/audited-mutation.test.ts | 63 +- tests/audit/classification.test.ts | 74 +- tests/audit/redaction.test.ts | 79 +- tests/auth.controller.test.ts | 2389 +++++++------- tests/avatar.controller.test.ts | 38 +- tests/avatar.service.test.ts | 95 +- tests/consent.controller.test.ts | 41 +- tests/consent.service.test.ts | 113 +- tests/contract/api-conventions.test.ts | 39 +- tests/contract/openapi.test.ts | 54 +- tests/data-export.service.test.ts | 97 +- tests/email.service.test.ts | 349 +- tests/error.middleware.test.ts | 8 +- tests/helpers/db.ts | 17 +- tests/helpers/factories.ts | 43 +- tests/in-memory-storage.test.ts | 34 +- tests/integration/audit-immutability.test.ts | 249 +- tests/integration/cleanup.test.ts | 5 +- tests/integration/guard.test.ts | 9 +- tests/integration/isolation.test.ts | 14 +- tests/integration/profile-api.test.ts | 247 +- tests/lib/handler-registry.test.ts | 35 +- tests/lib/job-lease-queue.test.ts | 21 +- tests/mock-user-scan.test.ts | 29 +- tests/notification.controller.test.ts | 24 +- tests/notification.service.test.ts | 66 +- tests/onboarding.controller.test.ts | 20 +- tests/onboarding.service.test.ts | 100 +- tests/otp.service.test.ts | 410 +-- tests/preference.controller.test.ts | 14 +- tests/preference.service.test.ts | 58 +- tests/profile-serializer.test.ts | 90 +- tests/profile.controller.test.ts | 27 +- tests/profile.service.test.ts | 152 +- tests/referral.controller.test.ts | 92 +- tests/refresh-token.service.test.ts | 113 +- tests/services/webhook.service.spec.ts | 236 +- tests/session.controller.test.ts | 177 +- tests/setup.ts | 20 +- tests/stellar-funding.service.test.ts | 69 +- tests/stellar-wallet-status.service.test.ts | 57 +- tests/stellar.service.test.ts | 79 +- tests/sync.controller.test.ts | 60 +- tests/unit/auth.middleware.test.ts | 143 +- tests/unit/credential.controller.test.ts | 84 +- tests/unit/employer.controller.test.ts | 33 +- tests/unit/employer.routes.test.ts | 23 +- tests/unit/errorHandler.test.ts | 6 +- tests/unit/jwt-config.test.ts | 15 +- tests/unit/money.test.ts | 15 +- tests/unit/rate-limit.test.ts | 238 +- tests/unit/reward.controller.test.ts | 21 +- tests/unit/reward.service.test.ts | 63 +- tests/unit/validation.middleware.test.ts | 73 +- tests/unit/wallet-transitions.test.ts | 6 +- tests/user-account.service.test.ts | 103 +- tests/user.controller.test.ts | 115 +- tests/wallet-secret-scan.test.ts | 18 +- ...wallet-self-custody-export.service.test.ts | 18 +- tests/wallet-status.controller.test.ts | 36 +- tests/wallet-status.service.test.ts | 118 +- tests/workers/outbox-relay.test.ts | 116 +- tests/workers/scheduled-job-runner.test.ts | 79 +- tsconfig.json | 48 +- vitest.config.js | 6 + 253 files changed, 15479 insertions(+), 11220 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index f4504a8a..2ac5f1e0 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,31 +1,31 @@ -# Security Policy - -## Reporting a Vulnerability - -**Please do not report security vulnerabilities through public GitHub issues.** - -Instead, please report them via email to **security@toneflix.net** with: - -- Description of the vulnerability -- Steps to reproduce -- Affected versions -- Any proof of concept - -You can expect: - -- Acknowledgment within 24 hours -- Regular updates on progress -- Credit for your discovery (if desired) - -For more details, see our full [Security Policy](./docs/SECURITY.md). - -## Supported Versions - -| Version | Supported | -| :------ | :-------- | -| 1.0.x | ✅ | -| < 1.0 | ❌ | - -## Bug Bounty - -We offer bounties for qualifying vulnerabilities. See [full policy](./docs/SECURITY.md#-bug-bounty-program) for details. +# Security Policy + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them via email to **security@toneflix.net** with: + +- Description of the vulnerability +- Steps to reproduce +- Affected versions +- Any proof of concept + +You can expect: + +- Acknowledgment within 24 hours +- Regular updates on progress +- Credit for your discovery (if desired) + +For more details, see our full [Security Policy](./docs/SECURITY.md). + +## Supported Versions + +| Version | Supported | +| :------ | :-------- | +| 1.0.x | ✅ | +| < 1.0 | ❌ | + +## Bug Bounty + +We offer bounties for qualifying vulnerabilities. See [full policy](./docs/SECURITY.md#-bug-bounty-program) for details. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43105a1c..7e7b5939 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,4 @@ -# CI: lint, format check, type check, and tests on PR and push to main -# Branch protection: enable "Require status checks to pass" for this workflow in repo Settings > Branches - -name: CI +name: Backend CI Quality Gates on: pull_request: @@ -10,14 +7,46 @@ on: branches: [main] concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: - ci: - name: Lint, typecheck, format, test + quality: + name: Quality gates runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: learnault_ci + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d learnault_ci" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + NODE_ENV: test + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/learnault_ci?schema=public + REDIS_URL: redis://localhost:6379 + JWT_SECRET: ci-test-secret + AUDIT_IP_HASH_SECRET: ci-audit-secret steps: - name: Checkout @@ -26,6 +55,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: + version: 10.0.0 run_install: false - name: Setup Node.js @@ -35,26 +65,52 @@ jobs: cache: 'pnpm' - name: Install dependencies - run: pnpm install --no-frozen-lockfile + run: pnpm install --frozen-lockfile + + - name: Check Prisma formatting + run: pnpm prisma:format:check - name: Generate Prisma Client - run: npx prisma generate - env: - DATABASE_URL: "file:./dev.db" + run: pnpm db:generate + + - name: Validate migrations on an empty database + run: pnpm db:deploy + + - name: Check formatting + run: pnpm format:check - - name: Lint (ESLint) - run: pnpm run lint + - name: Lint + run: pnpm lint - - name: Validate Docker Compose stack - run: docker compose config --quiet + - name: Type check + run: pnpm typecheck - - name: Run tests with coverage - run: pnpm run test:coverage + - name: Run tests + run: pnpm test:ci + + - name: Enforce coverage + run: pnpm test:coverage + + - name: Build + run: pnpm build + + - name: Upload failure diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ci-failure-diagnostics + path: | + coverage/ + **/test-results/** + **/*.log + if-no-files-found: ignore + retention-days: 7 - - name: Upload coverage (optional) + - name: Upload coverage report + if: always() uses: actions/upload-artifact@v4 - if: success() && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') with: name: coverage-report path: coverage/ if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index b86575f1..27d2ff50 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -11,7 +11,7 @@ jobs: build-and-deploy: runs-on: ubuntu-latest environment: staging - + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -44,7 +44,7 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - # We simulate the staging deployment locally on the GitHub Runner + # We simulate the staging deployment locally on the GitHub Runner # since there's no remote target specified yet. - name: Deploy to Staging (Runner) env: @@ -53,11 +53,11 @@ jobs: run: | echo "Actor: ${{ github.actor }}" echo "Digest/Tag: ${{ steps.meta.outputs.version }}" - + # We need to run the postgres db as a mock for staging docker compose -f docker-compose.staging.yml up -d db sleep 10 # Wait for db to initialize - + # Run deployment ./scripts/deploy-staging.sh ${{ steps.meta.outputs.version }} @@ -68,16 +68,16 @@ jobs: if: failure() run: | echo "Deployment or Smoke Tests failed. Initiating Rollback..." - + # Get the previous commit SHA to rollback to PREV_SHA=$(git rev-parse HEAD^) PREV_TAG="sha-$PREV_SHA" - + echo "Rolling back to tag: $PREV_TAG" - + # Since this is a CI mock, we just run the rollback script # In reality, this tag would be pulled from the registry # We'll just build it to simulate docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$PREV_TAG . - + ./scripts/rollback-staging.sh ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$PREV_TAG diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md index db48cbe4..ac79f50f 100644 --- a/PR_DESCRIPTION.md +++ b/PR_DESCRIPTION.md @@ -1,21 +1,25 @@ # Credential Controller & Routes Implementation ## Overview + Implements credential management endpoints for certificates and achievements as specified in issue #8. ## Changes Made ### New Files + - `src/controllers/credential.controller.ts` - Controller with three main endpoints - `src/routes/v1/credentials.routes.ts` - Route definitions with validation - `tests/unit/credential.controller.test.ts` - Comprehensive unit tests (14 tests, all passing) ### Modified Files + - `src/routes/index.ts` - Added credentials routes to API ## Implemented Endpoints ### 1. GET /api/v1/credentials + - **Auth**: Required - **Purpose**: Retrieve all credentials for authenticated user - **Query Params**: `moduleId`, `fromDate`, `toDate`, `page`, `limit` @@ -27,6 +31,7 @@ Implements credential management endpoints for certificates and achievements as - Includes shareable verification links ### 2. GET /api/v1/credentials/:id + - **Auth**: Required (user must own credential) - **Purpose**: Retrieve single credential details - **Features**: @@ -37,6 +42,7 @@ Implements credential management endpoints for certificates and achievements as - Shareable link ### 3. GET /api/v1/credentials/verify/:onChainId + - **Auth**: Not required (public endpoint) - **Purpose**: Public verification of credentials - **Features**: @@ -48,24 +54,28 @@ Implements credential management endpoints for certificates and achievements as ## Technical Details ### Validation + - Uses Zod schemas for input validation - UUID validation for IDs - ISO 8601 datetime validation for date filters - Numeric validation for pagination parameters ### Error Handling + - Proper HTTP status codes (400, 401, 404) - Descriptive error messages - Uses custom error classes (BadRequestError, NotFoundError, UnauthorizedError) - Wrapped with asyncHandler for promise rejection handling ### Database + - Uses Prisma ORM - Efficient queries with proper includes - Pagination with count queries - Indexed lookups by ID and onChainId ## Testing + - 14 unit tests covering all endpoints - Tests for success cases - Tests for error cases (invalid input, unauthorized access, not found) @@ -73,6 +83,7 @@ Implements credential management endpoints for certificates and achievements as - All tests passing ✅ ## Acceptance Criteria Met + - ✅ Users can view all their earned credentials - ✅ Public verification endpoint returns credential validity - ✅ Verification works without authentication @@ -81,6 +92,7 @@ Implements credential management endpoints for certificates and achievements as - ✅ Unit tests written and passing ## Code Quality + - ✅ Linting passed - ✅ All existing tests still passing (225 tests total) - ✅ Follows existing codebase patterns @@ -89,6 +101,7 @@ Implements credential management endpoints for certificates and achievements as - ✅ Clean, readable code with comments ## Next Steps + - Integration testing with actual database - E2E testing for complete user flows - Performance testing with large datasets diff --git a/README.md b/README.md index 3f542e64..9f5b7446 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,151 @@ -# Learnault (APP) - -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com) -[![Stellar](https://img.shields.io/badge/Stellar-Built%20on%20SDF-black)](https://stellar.org) - -**Learnault** is a decentralized learn-to-earn platform built on the Stellar blockchain that democratizes access to financial literacy and digital skills while creating verifiable, portable credentials for learners worldwide. - -## Vision - -A world where anyone, anywhere can access quality education, earn while learning, and prove their skills with verifiable blockchain credentials — all for free. - -## Features - -- **Learn & Earn**: Complete educational modules and earn Stellar-based token rewards -- **Verifiable Credentials**: All achievements stored immutably on Stellar -- **Mobile-First**: Optimized for low-bandwidth environments in emerging markets -- **Privacy-Preserving**: Future ZK-proof integration for selective disclosure -- **B2B Talent Pool**: Employers can find verified talent (paid feature) - -## Packages - -| Package | Description | Tech Stack | -| :-------------------------------------------------------------- | :---------------------------------------------- | :----------------------------------- | -| [`contracts`](https://github.com/learnault/learnault-contracts) | Soroban smart contracts for credential issuance | Rust, Soroban | -| [`api`](https://github.com/learnault/learnault-api) | Backend API for user management and rewards | Node.js, Express, PostgreSQL | -| [`app`](https://github.com/learnault/learnault) | Mobile-first PWA frontend | React, Next.js, TypeScript, Tailwind | - -## Architecture - -```txt -┌─────────────────────────────────────────────────────────────┐ -│ PWA Frontend (React) │ -└───────────────────────────┬─────────────────────────────────┘ - │ -┌───────────────────────────▼─────────────────────────────────┐ -│ Backend API (Node.js) │ -└───────────────────────────┬─────────────────────────────────┘ - │ -┌───────────────────────────▼─────────────────────────────────┐ -│ Stellar Blockchain Layer │ -│ (Horizon API • Soroban Contracts • Asset Management) │ -└─────────────────────────────────────────────────────────────┘ -``` - -For detailed architecture, see [ARCHITECTURE.md](./docs/ARCHITECTURE.md). - -## Getting Started - -### Prerequisites - -- Node.js 20+ -- pnpm 10+ -- Rust (for contract development) -- Docker (optional, for local database) - -### Installation - -```bash -# Clone the repository -git clone https://github.com/learnault/learnault-api.git -cd learnault-api - -# Install dependencies -pnpm install - -# Set up environment variables -cp .env.example .env - -# Set up database -pnpm db:migrate -pnpm db:seed - -# Run development environment -pnpm dev -``` - -### Run the full stack with Docker Compose (recommended) - -The local stack — API, wallet worker, PostgreSQL, and Redis — starts with one command: - -```bash -cp .env.example .env -docker compose up -d --build -``` - -Migrations and deterministic seed fixtures run automatically on boot. See -[Local Development Stack](./docs/DEVELOPMENT_STACK.md) for health checks, logs, -reset, and the smoke test (`pnpm stack:smoke`). - -For detailed database setup instructions, see [Prisma Setup Guide](./prisma/SETUP.md) - -### Development Workflow - -```bash -# Run all packages in dev mode -pnpm dev - -# Build all packages -pnpm build - -# Run tests -pnpm test - -# Lint code -pnpm lint -``` - -## Documentation - -- [API Documentation](./docs/API.md) - API endpoints and usage -- [Code of Conduct](./docs/CODE_OF_CONDUCT.md) - Community guidelines -- [Contributing Guide](./docs/CONTRIBUTING.md) - How to contribute - -## Contributing - -We welcome contributions! Please see our [Contributing Guide](./docs/CONTRIBUTING.md) and [Code of Conduct](./docs/CODE_OF_CONDUCT.md). - -## Security - -Found a security vulnerability? Please see our [Security Policy](./docs/SECURITY.md). - -## License - -This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. - -## Acknowledgments - -- [Stellar Development Foundation](https://stellar.org) for their incredible blockchain technology -- All our contributors and community members - -## Contact - -- Discord: [Join our community](https://discord.gg) -- Email: learnault@toneflix.net +# Learnault (APP) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com) +[![Stellar](https://img.shields.io/badge/Stellar-Built%20on%20SDF-black)](https://stellar.org) + +**Learnault** is a decentralized learn-to-earn platform built on the Stellar blockchain that democratizes access to financial literacy and digital skills while creating verifiable, portable credentials for learners worldwide. + +## Vision + +A world where anyone, anywhere can access quality education, earn while learning, and prove their skills with verifiable blockchain credentials — all for free. + +## Features + +- **Learn & Earn**: Complete educational modules and earn Stellar-based token rewards +- **Verifiable Credentials**: All achievements stored immutably on Stellar +- **Mobile-First**: Optimized for low-bandwidth environments in emerging markets +- **Privacy-Preserving**: Future ZK-proof integration for selective disclosure +- **B2B Talent Pool**: Employers can find verified talent (paid feature) + +## Packages + +| Package | Description | Tech Stack | +| :-------------------------------------------------------------- | :---------------------------------------------- | :----------------------------------- | +| [`contracts`](https://github.com/learnault/learnault-contracts) | Soroban smart contracts for credential issuance | Rust, Soroban | +| [`api`](https://github.com/learnault/learnault-api) | Backend API for user management and rewards | Node.js, Express, PostgreSQL | +| [`app`](https://github.com/learnault/learnault) | Mobile-first PWA frontend | React, Next.js, TypeScript, Tailwind | + +## Architecture + +```txt +┌─────────────────────────────────────────────────────────────┐ +│ PWA Frontend (React) │ +└───────────────────────────┬─────────────────────────────────┘ + │ +┌───────────────────────────▼─────────────────────────────────┐ +│ Backend API (Node.js) │ +└───────────────────────────┬─────────────────────────────────┘ + │ +┌───────────────────────────▼─────────────────────────────────┐ +│ Stellar Blockchain Layer │ +│ (Horizon API • Soroban Contracts • Asset Management) │ +└─────────────────────────────────────────────────────────────┘ +``` + +For detailed architecture, see [ARCHITECTURE.md](./docs/ARCHITECTURE.md). + +## Getting Started + +### Prerequisites + +- Node.js 20+ +- pnpm 10+ +- Rust (for contract development) +- Docker (optional, for local database) + +### Installation + +```bash +# Clone the repository +git clone https://github.com/learnault/learnault-api.git +cd learnault-api + +# Install dependencies +pnpm install --frozen-lockfile + +# Set up environment variables +cp .env.example .env + +# Set up database +pnpm db:migrate +pnpm db:seed + +# Run development environment +pnpm dev +``` + +### Run the full stack with Docker Compose (recommended) + +The local stack — API, wallet worker, PostgreSQL, and Redis — starts with one command: + +```bash +cp .env.example .env +docker compose up -d --build +``` + +Migrations and deterministic seed fixtures run automatically on boot. See +[Local Development Stack](./docs/DEVELOPMENT_STACK.md) for health checks, logs, +reset, and the smoke test (`pnpm stack:smoke`). + +For detailed database setup instructions, see [Prisma Setup Guide](./prisma/SETUP.md) + +### Development Workflow + +```bash +# Run all packages in dev mode +pnpm dev + +# Build all packages +pnpm build + +# Run tests +pnpm test + +# Lint code +pnpm lint + +# Run the same quality gates as CI +pnpm prisma:format:check +pnpm db:generate +pnpm format:check +pnpm lint +pnpm typecheck +pnpm test:ci +pnpm test:coverage +pnpm build +``` + +The database-backed checks require a dedicated PostgreSQL database. Set +`DATABASE_URL` to a test database (for example, +`postgresql://postgres:postgres@localhost:5432/learnault_ci`) before running +`pnpm db:deploy`; Redis should be available at `redis://localhost:6379` when +exercising Redis-dependent paths. + +## Documentation + +- [API Documentation](./docs/API.md) - API endpoints and usage +- [Code of Conduct](./docs/CODE_OF_CONDUCT.md) - Community guidelines +- [Contributing Guide](./docs/CONTRIBUTING.md) - How to contribute + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](./docs/CONTRIBUTING.md) and [Code of Conduct](./docs/CODE_OF_CONDUCT.md). + +## Security + +Found a security vulnerability? Please see our [Security Policy](./docs/SECURITY.md). + +## License + +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. + +## Acknowledgments + +- [Stellar Development Foundation](https://stellar.org) for their incredible blockchain technology +- All our contributors and community members + +## Contact + +- Discord: [Join our community](https://discord.gg) +- Email: learnault@toneflix.net diff --git a/docker-compose.yml b/docker-compose.yml index 5341e6f9..f2ca5113 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,11 @@ services: volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-learnault} -d ${POSTGRES_DB:-learnault_dev}'] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_USER:-learnault} -d ${POSTGRES_DB:-learnault_dev}', + ] interval: 5s timeout: 5s retries: 10 @@ -87,7 +91,13 @@ services: - .:/app - /app/node_modules healthcheck: - test: ['CMD', 'node', '-e', "fetch('http://localhost:5000/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + test: + [ + 'CMD', + 'node', + '-e', + "fetch('http://localhost:5000/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] interval: 10s timeout: 5s retries: 10 diff --git a/docs/API.md b/docs/API.md index 11d3a248..6c5c145c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,913 +1,1044 @@ -# Learnault API Reference - -> **Live spec:** `GET /api-docs` (Swagger UI) or `GET /api-docs/swagger.json` - -## Overview - -The Learnault API is a JSON REST API for a decentralized learn-to-earn platform on Stellar. - -| Item | Value | -|------|-------| -| Base URL (production) | `https://api.learnault.io/api/v1` | -| Base URL (local) | `http://localhost:3000/api/v1` || Auth scheme | JWT Bearer (`Authorization: Bearer `) | -| Refresh scheme | Opaque rotating refresh token (`refreshToken` body or `refresh_token` cookie) | -| Content-Type | `application/json` | - ---- - -## Authentication - -Obtain a JWT from `POST /auth/login`. Pass it on every protected request: - -```http -Authorization: Bearer eyJhbGciOiJIUzI1NiIs... -``` - -JWT payload contains `{ id, email, role }`. Roles are `learner`, `employer`, `admin`. - ---- - -## Standard Response Envelopes - -### Success - -Varies by endpoint — see individual routes. Most use one of: - -```json -{ "message": "...", "data": { ... } } -``` -```json -{ "success": true, "data": { ... } } -``` - -### Error (all 4xx / 5xx) - -```json -{ - "success": false, - "error": { - "message": "Resource not found", - "code": 404 - } -} -``` - -Validation errors from the `validate()` middleware: - -```json -{ - "message": "Validation failed", - "errors": { - "body": ["Invalid email format"], - "params": ["Invalid ID format"] - } -} -``` - ---- - -## Rate Limiting - -Every response includes: - -```http -X-RateLimit-Limit: 100 -X-RateLimit-Remaining: 97 -X-RateLimit-Reset: 2026-07-19T10:15:00.000Z -``` - -When exceeded (HTTP 429): - -```http -Retry-After: 60 -``` - -| Limiter | Applies to | Default window | Default max | -|---------|-----------|----------------|-------------| -| `authLimiter` | `/auth/login`, `/auth/resend-verification`, `/auth/forgot-password` | 15 min | 10 | -| `otpLimiter` | `/auth/otp/request`, `/auth/otp/verify` | 15 min | 5 | -| `employerLimiter` | All `/employer/*` routes | 15 min | 500 | -| `authenticatedLimiter` | All `/sync/*` routes | 15 min | 1000 | -| `generalLimiter` | Everything else | 15 min | 100 | - -All values are overridable via environment variables (`RATE_LIMIT_*_WINDOW_MS`, `RATE_LIMIT_*_MAX`). - ---- - -## Health - -### `GET /health` - -No authentication. Returns immediately. - -```json -{ "status": "ok", "timestamp": "2026-07-19T10:00:00.000Z" } -``` - ---- - -## Auth — `/auth` - -All auth routes are public (no JWT required) unless noted. - -### `POST /auth/register` - -Register a new user. Queues a verification email. - -**Request body** - -| Field | Type | Required | Notes | -|-------|------|----------|-------| -| `email` | string (email) | ✅ | | -| `password` | string | ✅ | min 8 chars | -| `username` | string | ✅ | min 3 chars | -| `role` | `"learner"` \| `"employer"` | ❌ | default `"learner"` | - -**Responses** - -| Status | Meaning | -|--------|---------| -| 201 | User created; JWT and user object returned | -| 400 | Validation failed | -| 409 | Email or username already taken | - -```json -{ - "message": "User registered successfully", - "accessToken": "", - "refreshToken": "", - "expiresIn": 900, - "tokenType": "Bearer", - "user": { "id": "...", "email": "...", "username": "...", "role": "learner" } -} -``` - ---- - -### `POST /auth/login` - -Rate-limited (10 req / 15 min). - -**Request body:** `{ "email": "...", "password": "..." }` - -**Responses:** 200 (same shape as register), 400, 401 (invalid credentials) - ---- - -### `POST /auth/refresh` - -Rotates a refresh token for a new access/refresh pair. The presented token -is consumed; a replayed (already-rotated) token revokes the whole session -family and returns `401 REFRESH_REUSE_DETECTED`. - -**Request body:** `{ "refreshToken": "" }` — or send the token -via an httpOnly `refresh_token` cookie. - -```json -{ - "message": "Token refreshed successfully", - "accessToken": "", - "refreshToken": "", - "expiresIn": 900, - "tokenType": "Bearer" -} -``` - -**Responses:** 200 (rotated), 400 (missing token), 401 (`REFRESH_INVALID`, -`REFRESH_EXPIRED`, `REFRESH_REVOKED`, or `REFRESH_REUSE_DETECTED`) - ---- - -### `POST /auth/logout` - -Logs out the current session by revoking its refresh-token family. Idempotent. - -**Request body:** `{ "refreshToken": "" }` — or via the -httpOnly `refresh_token` cookie. - -**Response:** `200 { "message": "Logged out successfully", "revokedCount": 1 }` - ---- - -### `POST /auth/logout/all` - -Logs out every session for the user identified by the refresh token. -Idempotent. - -**Request body:** `{ "refreshToken": "" }` — or via the -httpOnly `refresh_token` cookie. - -**Response:** `200 { "message": "All sessions logged out", "revokedCount": 3 }` - ---- - -### `POST /auth/verify-email` - -**Request body:** `{ "token": "<64-char hex string from email>" }` - -| Status | Meaning | -|--------|---------| -| 200 | Verified (or already verified) | -| 400 | Invalid, expired, or revoked token | - ---- - -### `POST /auth/resend-verification` - -Rate-limited (10 req / 15 min). Always returns 200 to avoid leaking whether an account exists. - -**Request body:** `{ "email": "..." }` - -**Response:** `200 { "message": "If the account exists, a verification email has been sent." }` - ---- - -### `POST /auth/forgot-password` - -Rate-limited (10 req / 15 min). Token expires after 30 minutes. Always returns 200. - -**Request body:** `{ "email": "..." }` - -**Response:** `200 { "message": "If the account exists, a password reset email has been sent." }` - ---- - -### `POST /auth/reset-password` - -On success, all active sessions are revoked and all pending verification tokens are cancelled. - -**Request body:** `{ "token": "<64-char hex>", "newPassword": "" }` - -| Status | Meaning | -|--------|---------| -| 200 | Password reset | -| 400 | Invalid/expired token or weak password | - ---- - -### `POST /auth/otp/request` - -Requests a 6-digit SMS code. Behavior depends on whether a Bearer token is sent: - -- **No token → `LOGIN`**: the phone must already be verified on an existing account. Always returns 200 with the same generic message, whether or not the phone is registered, to avoid leaking phone existence. -- **With token → `PHONE_VERIFICATION`**: attaches/verifies this phone number on the caller's own account. - -Rate-limited by IP (`otpLimiter`), by phone (1/min cooldown, 5/hour), and — if `deviceId` is supplied — by device (10/hour). SMS is sent via a mocked provider (`SMS_PROVIDER=mock`) until a real carrier is integrated; see [`docs/decisions/0001-phone-otp-authentication.md`](decisions/0001-phone-otp-authentication.md). - -**Request body** - -| Field | Type | Required | Notes | -|-------|------|----------|-------| -| `phone` | string | ✅ | E.164 format, e.g. `+2348012345678` | -| `deviceId` | string | ❌ | Used for device-level rate limiting | - -**Responses** - -| Status | Meaning | -|--------|---------| -| 200 | Code sent (or silently ignored for an unregistered/unverified `LOGIN` phone) | -| 400 | Validation failed or phone not in E.164 format | -| 409 | Phone already verified on a different account (`PHONE_VERIFICATION` only) | -| 429 | IP, phone, or device rate limit reached | - -```json -{ "message": "If this phone number is registered, a verification code has been sent." } -``` - ---- - -### `POST /auth/otp/verify` - -Verifies the code from `otp/request`. Codes are single-use, expire after 5 minutes, and the challenge locks after 5 wrong attempts (request a new code to retry). - -- **No token → `LOGIN`**: on success, returns the same JWT/user shape as `POST /auth/login`, after the same account-status checks (deactivated/pending-deletion/deleted). -- **With token → `PHONE_VERIFICATION`**: on success, marks the phone verified on the caller's account. - -**Request body:** `{ "phone": "+2348012345678", "code": "123456" }` - -**Responses** - -| Status | Meaning | -|--------|---------| -| 200 | Verified — login response or `{ "message": "Phone number verified successfully" }` | -| 400 | Invalid/expired code, or validation failed | -| 401 | Invalid credentials (tombstoned account; `LOGIN` only) | -| 403 | Account deactivated or pending deletion (`LOGIN` only) | -| 429 | Too many wrong attempts — challenge locked | - ---- - -## Users — `/users` - -### `GET /users/me` 🔒 - -Owner-only aggregate: account identity, learner profile, profile completion, -onboarding state, and the current consent record per purpose. One read, so a -client does not have to fan out across four endpoints to render a settings or -"finish setting up" screen. - -```json -{ - "data": { - "account": { - "id": "uuid", "email": "...", "username": "...", - "role": "LEARNER", "status": "ACTIVE", - "isVerified": true, "phoneVerifiedAt": null, - "walletAddress": null, - "createdAt": "...", "updatedAt": "...", "lastLoginAt": null - }, - "profile": { - "id": "uuid", "userId": "uuid", - "displayName": null, "bio": null, "avatarUrl": null, - "country": null, "timezone": null, - "languages": [], "level": "beginner", - "interests": [], "goals": [], - "visibility": "private", - "createdAt": "...", "updatedAt": "..." - }, - "completion": { "percent": 0, "missingFields": ["displayName", "bio", "..."] }, - "onboarding": { - "version": "v1", "status": "in_progress", "currentStep": "profile_basics", - "completedSteps": ["profile_basics"], "requiredStepsRemaining": ["consent"], - "startedAt": "...", "completedAt": null - }, - "consents": [ - { "purpose": "terms_of_service", "status": "granted", "required": true, - "policyVersion": "2026-01", "grantedAt": "...", "withdrawnAt": null } - ], - "requiredConsentsGranted": false - } -} -``` - -The profile row is created on first access, so a brand-new learner gets a -deterministic 0%-complete profile rather than a `null`. `onboarding` is `null` -until the learner starts onboarding. The password hash is never included. - -**Responses:** `200` · `401` no/invalid token · `404` account unknown or tombstoned - ---- - -### `PATCH /users/me` 🔒 - -Partial learner-profile update. At least one field is required, and the body is -**closed**: any property outside the table below — including account fields such -as `status`, `isVerified`, `role`, `email`, `password` or `walletAddress` — is a -`400`, not a silently ignored key. Every accepted change is written together with -its audit event in one transaction. - -**Request body** (any non-empty subset of): - -| Field | Type | Constraints | -|-------|------|-------------| -| `displayName` | string \| null | 1–80 chars | -| `bio` | string \| null | max 1000 | -| `avatarUrl` | string (URL) \| null | normally set by the avatar upload flow | -| `country` | string \| null | 2–60 chars | -| `timezone` | string \| null | | -| `languages` | string[] | max 20 | -| `level` | enum | `beginner` \| `intermediate` \| `advanced` \| `expert` | -| `interests` | string[] | max 50 | -| `goals` | string[] | max 20 | -| `visibility` | enum | `private` \| `employer` \| `public` | - -**Response:** `200` `{ "message": "...", "data": { … } }` — the same aggregate as -`GET /users/me`, recomputed from the persisted row. - -**Responses:** `200` · `400` validation failed · `401` · `404` - -`PATCH /users/me/profile` accepts the same body and returns the profile alone -(without the account aggregate). - ---- - -### `GET /users/:id` - -Public — no auth needed. Returns the learner's public profile subset, or the -redacted stub `{ "id": "...", "visible": false }`. - -```json -{ - "data": { - "id": "uuid", "displayName": "Grace H.", "bio": "Learning Soroban", - "avatarUrl": null, "country": "NG", "level": "intermediate", - "interests": ["soroban"], "visible": true - } -} -``` - -Disclosure requires **all** of: - -1. `profile.visibility === "public"`, -2. the account status is `ACTIVE`, and -3. `data_sharing` consent has not been withdrawn. - -Any refusal returns the same stub, so a caller cannot tell a private profile -from a withdrawn consent from a deactivated account. Archived profiles read as -`404`. Private account data (email, username, wallet address, status, -verification, password) never appears here — not even for the owner, who gets -the same public view as anyone else on this route and uses -`GET /users/me` or `GET /users/:id/profile` for their own full record. - -**Responses:** `200` · `400` malformed id · `404` unknown learner - ---- - -### `PATCH /users/password` 🔒 - -Verifies the current password, stores a new bcrypt hash, and revokes every -session and refresh-token family for the account **in the same transaction** — -so the caller must sign in again, and so does anyone holding a stolen session. -The change is audited; neither password reaches the audit trail. - -**Request body:** `{ "currentPassword": "...", "newPassword": "..." }` - -Password rules: min 8 chars, must contain uppercase, lowercase, digit, and special character (`@$!%*?&`). Must differ from current. - -**Response:** `200` `{ "message": "...", "revokedSessionCount": 2 }` - -**Responses:** `200` · `400` validation failed · `401` no token, or wrong current -password (`code: STEP_UP_FAILED`) · `404` account unknown or tombstoned - ---- - -### `PATCH /users/wallet` 🔒 - -Sets the learner's Stellar **public** key on their account. Never accepts or -returns a secret seed. The change is audited. - -**Request body:** `{ "walletAddress": "G..." }` - -Address must match `^G[A-Z0-9]{55}$`. - -Re-sending the address already on file is a no-op (`200`, `"Wallet address -unchanged"`, no second audit event). Addresses are unique across accounts. - -**Responses:** `200` · `400` invalid address · `401` · `404` account unknown or -tombstoned · `409` address already claimed by another account -(`code: WALLET_ADDRESS_TAKEN`) - ---- - -## Modules — `/modules` - -### `GET /modules` - -Optional auth — when a valid token is provided, each module includes `userProgress`. - -**Query parameters** - -| Param | Type | Default | -|-------|------|---------| -| `page` | integer | 1 | -| `limit` | integer | 10 | -| `category` | string | — | -| `difficulty` | string | — | -| `search` | string | — | - -**Response `200`** - -```json -{ - "modules": [ - { - "id": "...", "title": "...", "description": "...", - "category": "finance", "difficulty": "beginner", - "reward": 0.25, "createdAt": "...", "updatedAt": "...", - "completionCount": 120, - "userProgress": null - } - ], - "pagination": { "page": 1, "limit": 10, "total": 45, "totalPages": 5, "hasNext": true, "hasPrev": false } -} -``` - ---- - -### `GET /modules/:id` - -Optional auth. Same `userProgress` inclusion behaviour. - -**Response `200`:** single module object (same fields as list item). -**404** if not found. - ---- - -### `POST /modules/:id/start` 🔒 - -Creates a progress record. Must be called before `complete`. - -**Response `201`:** -```json -{ "message": "Module started successfully", "completionId": "...", "startedAt": "..." } -``` - -**400** if already started or completed. - ---- - -### `POST /modules/:id/complete` 🔒 - -Submit quiz answers. Module must have been started first. - -A score ≥ 70% qualifies for the XLM reward and triggers a push notification. - -**Request body:** -```json -{ - "quizAnswers": [ - { "questionId": "q1", "answer": "B" } - ] -} -``` - -**Response `200`:** -```json -{ - "message": "Module completed successfully", - "score": 80, - "isEligibleForReward": true, - "reward": 0.25, - "rewardTransaction": "", - "completedAt": "..." -} -``` - ---- - -## Credentials — `/credentials` - -### `GET /credentials` 🔒 - -**Query parameters** - -| Param | Type | Notes | -|-------|------|-------| -| `moduleId` | UUID | filter | -| `fromDate` | ISO datetime | filter | -| `toDate` | ISO datetime | filter | -| `page` | integer | default 1 | -| `limit` | integer | default 10, max 100 | - -**Response `200`:** -```json -{ - "success": true, - "data": [ { "id": "...", "moduleId": "...", "moduleName": "...", "onChainId": null, "issuedAt": "...", "shareableLink": "..." } ], - "meta": { "page": 1, "limit": 10, "total": 5, "totalPages": 1, "hasNextPage": false, "hasPrevPage": false } -} -``` - ---- - -### `GET /credentials/verify/:onChainId` - -Public — no auth needed. Looks up by `onChainId` first, falls back to credential UUID. - -**Response `200`:** -```json -{ - "success": true, - "data": { - "valid": true, - "credential": { "id": "...", "holderName": "...", "moduleName": "...", "onChainId": "...", "issuedAt": "..." }, - "verification": { "verifiedAt": "...", "status": "verified", "message": "This credential is valid and has been verified on-chain" } - } -} -``` - -**404** if not found. - ---- - -### `GET /credentials/:id` 🔒 - -Returns full credential detail. Returns `401` if the credential belongs to another user. - ---- - -## Rewards — `/rewards` 🔒 - -All reward routes require authentication. - -### `GET /rewards/balance` - -```json -{ - "success": true, - "data": { - "balance": { "available": 10.5, "pending": 2.0, "lifetime": 25.0 }, - "updatedAt": "..." - } -} -``` - ---- - -### `GET /rewards/history` - -**Query parameters** - -| Param | Values | Default | -|-------|--------|---------| -| `type` | `module_reward`, `streak_bonus`, `referral_reward`, `withdrawal` | — | -| `status` | `pending`, `completed`, `failed` | — | -| `fromDate` | ISO datetime | — | -| `toDate` | ISO datetime | — | -| `limit` | 1–100 | 20 | -| `offset` | ≥ 0 | 0 | - -**Response `200`:** -```json -{ - "success": true, - "data": { - "transactions": [ { "id": "...", "type": "module_reward", "status": "completed", "amount": 0.25, "moduleId": "...", "stellarTxHash": null, "createdAt": "...", "completedAt": "..." } ], - "pagination": { "total": 15, "limit": 20, "offset": 0, "hasMore": false } - } -} -``` - ---- - -### `POST /rewards/withdraw` - -**Request body:** - -| Field | Type | Required | Notes | -|-------|------|----------|-------| -| `walletAddress` | string | ✅ | Stellar address matching `^G[A-Z0-9]{50,55}$` | -| `amount` | number | ✅ | XLM, must be > 0 | -| `memo` | string | ❌ | | - -**Response `201`:** -```json -{ - "success": true, - "message": "Withdrawal processed successfully", - "data": { "transactionId": "...", "amount": 5.0, "stellarTxHash": "...", "status": "completed", "requestedAt": "...", "completedAt": "..." } -} -``` - -**400** for invalid address, zero/negative amount, or insufficient balance. - ---- - -## Referrals — `/referrals` 🔒 - -All referral routes require authentication. - -### `POST /referrals/code` - -Generate (or retrieve existing) referral code for the authenticated user. - -- Returns `200` if the user already has a code. -- Returns `201` if a new 8-character hex code was created. - -```json -{ "success": true, "message": "...", "data": { "code": "A1B2C3D4" } } -``` - ---- - -### `POST /referrals/apply` - -Apply a referral code. Cannot apply your own code or apply more than once. - -**Request body:** `{ "code": "A1B2C3D4" }` - -| Status | Meaning | -|--------|---------| -| 201 | Referral applied | -| 400 | Missing code, self-referral, or code not found | -| 409 | Already used a referral code | - ---- - -### `GET /referrals/stats` - -```json -{ - "success": true, - "data": { - "totalReferrals": 3, - "activeReferrals": 2, - "earnedBonuses": 10.0, - "pendingBonuses": 5.0 - } -} -``` - -`activeReferrals` = referrees who have completed at least one module. -`pendingBonuses` = (total − paid) × 5 XLM per referral. - ---- - -## Notifications — `/notifications` 🔒 - -All notification routes require authentication. - -### `POST /notifications/devices` - -Register a Firebase device token for push notifications. - -**Request body:** - -| Field | Type | Required | -|-------|------|----------| -| `token` | string | ✅ | -| `platform` | `"ios"` \| `"android"` \| `"web"` | ✅ | - -**Response `201`:** device token record. - ---- - -### `PATCH /notifications/preferences` - -At least one field must be provided. - -**Request body** (any subset of): - -| Field | Type | -|-------|------| -| `rewardReceipt` | boolean | -| `quizPassFail` | boolean | -| `streakReminders` | boolean | - ---- - -### `GET /notifications/delivery-status` - -**Query parameters:** `limit` (default 20, max 100), `status` (`pending` \| `success` \| `failed` \| `dead-letter`). - -```json -{ - "data": [ { "id": "...", "type": "quizPassFail", "title": "Quiz Passed!", "body": "...", "status": "success", "error": null, "attemptCount": 1, "createdAt": "..." } ], - "count": 1 -} -``` - ---- - -## Sync — `/sync` 🔒 - -All sync routes require authentication and are subject to the `authenticatedLimiter` (1000 req / 15 min). - -### `POST /sync/progress` - -Upload batched offline progress events. Each event is deduplicated by `idempotencyKey`. Events with a stale `syncVersion` are skipped without error. - -**Request body:** -```json -{ - "events": [ - { - "idempotencyKey": "device-abc-mod-xyz-1", - "deviceId": "device-abc", - "moduleId": "", - "progressPercent": 60, - "clientTimestamp": "2026-07-19T09:00:00.000Z", - "syncVersion": 3 - } - ] -} -``` - -**Response `200`:** -```json -{ - "success": true, - "data": { - "results": [ - { "idempotencyKey": "device-abc-mod-xyz-1", "status": "applied" } - ] - } -} -``` - -Each result has `status`: `applied` | `skipped` | `rejected`, plus an optional `reason`. - ---- - -### `POST /sync/completions` - -Reconcile offline quiz/completion attempts. If the user already has a completion with an equal or higher score, the event is skipped. A higher score updates the existing record. - -**Request body:** -```json -{ - "events": [ - { - "idempotencyKey": "device-abc-comp-xyz-1", - "deviceId": "device-abc", - "moduleId": "", - "score": 85, - "clientTimestamp": "2026-07-19T09:05:00.000Z", - "syncVersion": 1 - } - ] -} -``` - -Response shape identical to `POST /sync/progress`. - ---- - -## Employer — `/employer` 🔒 (employer role required) - -All employer routes require authentication with `role: employer`. An `employerLimiter` (500 req / 15 min) is applied. - -Plan tier is read from the `x-employer-plan` request header. Valid values: `starter` (default), `pro`, `enterprise`. - -### `GET /employer/search` - -Search the learner talent pool. - -**Query parameters** - -| Param | Type | Default | Notes | -|-------|------|---------|-------| -| `page` | integer | 1 | | -| `limit` | integer | 20 | Capped by plan: starter ≤ 10, pro ≤ 50, enterprise ≤ 100 | -| `skills` | string | — | Comma-separated keywords, e.g. `finance,defi` | -| `location` | string | — | | -| `credentials` | `any` \| `verified` \| `none` | `any` | | -| `search` | string | — | Free-text search on username/email | - -**Request header:** `x-employer-plan: starter | pro | enterprise` - -**Response `200`:** -```json -{ - "candidates": [ - { - "id": "...", "name": "alice42", "location": "lagos", - "skills": ["finance", "defi"], - "completions": 5, "averageScore": 82.4, - "verifiedCredentialCount": 2 - } - ], - "pagination": { "page": 1, "limit": 10, "total": 3, "totalPages": 1, "hasNext": false, "hasPrev": false }, - "filters": { "skills": ["finance"], "location": null, "credentials": "any" }, - "plan": "starter" -} -``` - -**400** if `limit` exceeds plan maximum. - ---- - -### `GET /employer/candidates/:id` - -Full candidate profile with all verified credentials. - -**403** if candidate profile is private or caller is not an employer. -**404** if candidate not found or has no module completions. - ---- - -### `POST /employer/contact` - -Record a candidate outreach attempt. Requires **pro** or **enterprise** plan — **starter returns HTTP 402**. - -**Request header:** `x-employer-plan: pro` (or `enterprise`) - -**Request body:** - -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| `candidateId` | UUID | ✅ | | -| `subject` | string | ✅ | 3–120 chars | -| `message` | string | ✅ | 10–3000 chars | -| `channel` | `"platform"` \| `"email"` \| `"both"` | ❌ | default `"platform"` | - -**Response `201`:** -```json -{ - "message": "Candidate outreach recorded", - "outreach": { "id": "...", "candidateId": "...", "channel": "platform", "status": "recorded", "createdAt": "..." } -} -``` - ---- - -## Unimplemented / Stubbed Routes - -None on `/users`. `PATCH /users/password` and `PATCH /users/wallet` were the last -two stubs here; both are Prisma-backed and audited as of the Profile API work -(see [`docs/decisions/0004-profile-api.md`](decisions/0004-profile-api.md)). - ---- - -## Error Code Reference - -| HTTP | Typical cause | -|------|--------------| -| 400 | Validation failed, malformed body, business rule violation | -| 401 | Missing, expired, or invalid JWT | -| 402 | Employer plan upgrade required | -| 403 | Authenticated but insufficient role or resource is private | -| 404 | Resource not found | -| 409 | Conflict (duplicate email, already applied referral, etc.) | -| 429 | Rate limit exceeded — see `Retry-After` header | -| 500 | Internal server error (includes not-yet-implemented stubs) | +# Learnault API Reference + +> **Live spec:** `GET /api-docs` (Swagger UI) or `GET /api-docs/swagger.json` + +## Overview + +The Learnault API is a JSON REST API for a decentralized learn-to-earn platform on Stellar. + +| Item | Value | +| --------------------- | ----------------------------------------------------------------------------- | +| Base URL (production) | `https://api.learnault.io/api/v1` | +| Base URL (local) | `http://localhost:3000/api/v1` | | Auth scheme | JWT Bearer (`Authorization: Bearer `) | +| Refresh scheme | Opaque rotating refresh token (`refreshToken` body or `refresh_token` cookie) | +| Content-Type | `application/json` | + +--- + +## Authentication + +Obtain a JWT from `POST /auth/login`. Pass it on every protected request: + +```http +Authorization: Bearer eyJhbGciOiJIUzI1NiIs... +``` + +JWT payload contains `{ id, email, role }`. Roles are `learner`, `employer`, `admin`. + +--- + +## Standard Response Envelopes + +### Success + +Varies by endpoint — see individual routes. Most use one of: + +```json +{ "message": "...", "data": { ... } } +``` + +```json +{ "success": true, "data": { ... } } +``` + +### Error (all 4xx / 5xx) + +```json +{ + "success": false, + "error": { + "message": "Resource not found", + "code": 404 + } +} +``` + +Validation errors from the `validate()` middleware: + +```json +{ + "message": "Validation failed", + "errors": { + "body": ["Invalid email format"], + "params": ["Invalid ID format"] + } +} +``` + +--- + +## Rate Limiting + +Every response includes: + +```http +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 97 +X-RateLimit-Reset: 2026-07-19T10:15:00.000Z +``` + +When exceeded (HTTP 429): + +```http +Retry-After: 60 +``` + +| Limiter | Applies to | Default window | Default max | +| ---------------------- | ------------------------------------------------------------------- | -------------- | ----------- | +| `authLimiter` | `/auth/login`, `/auth/resend-verification`, `/auth/forgot-password` | 15 min | 10 | +| `otpLimiter` | `/auth/otp/request`, `/auth/otp/verify` | 15 min | 5 | +| `employerLimiter` | All `/employer/*` routes | 15 min | 500 | +| `authenticatedLimiter` | All `/sync/*` routes | 15 min | 1000 | +| `generalLimiter` | Everything else | 15 min | 100 | + +All values are overridable via environment variables (`RATE_LIMIT_*_WINDOW_MS`, `RATE_LIMIT_*_MAX`). + +--- + +## Health + +### `GET /health` + +No authentication. Returns immediately. + +```json +{ "status": "ok", "timestamp": "2026-07-19T10:00:00.000Z" } +``` + +--- + +## Auth — `/auth` + +All auth routes are public (no JWT required) unless noted. + +### `POST /auth/register` + +Register a new user. Queues a verification email. + +**Request body** + +| Field | Type | Required | Notes | +| ---------- | --------------------------- | -------- | ------------------- | +| `email` | string (email) | ✅ | | +| `password` | string | ✅ | min 8 chars | +| `username` | string | ✅ | min 3 chars | +| `role` | `"learner"` \| `"employer"` | ❌ | default `"learner"` | + +**Responses** + +| Status | Meaning | +| ------ | ------------------------------------------ | +| 201 | User created; JWT and user object returned | +| 400 | Validation failed | +| 409 | Email or username already taken | + +```json +{ + "message": "User registered successfully", + "accessToken": "", + "refreshToken": "", + "expiresIn": 900, + "tokenType": "Bearer", + "user": { "id": "...", "email": "...", "username": "...", "role": "learner" } +} +``` + +--- + +### `POST /auth/login` + +Rate-limited (10 req / 15 min). + +**Request body:** `{ "email": "...", "password": "..." }` + +**Responses:** 200 (same shape as register), 400, 401 (invalid credentials) + +--- + +### `POST /auth/refresh` + +Rotates a refresh token for a new access/refresh pair. The presented token +is consumed; a replayed (already-rotated) token revokes the whole session +family and returns `401 REFRESH_REUSE_DETECTED`. + +**Request body:** `{ "refreshToken": "" }` — or send the token +via an httpOnly `refresh_token` cookie. + +```json +{ + "message": "Token refreshed successfully", + "accessToken": "", + "refreshToken": "", + "expiresIn": 900, + "tokenType": "Bearer" +} +``` + +**Responses:** 200 (rotated), 400 (missing token), 401 (`REFRESH_INVALID`, +`REFRESH_EXPIRED`, `REFRESH_REVOKED`, or `REFRESH_REUSE_DETECTED`) + +--- + +### `POST /auth/logout` + +Logs out the current session by revoking its refresh-token family. Idempotent. + +**Request body:** `{ "refreshToken": "" }` — or via the +httpOnly `refresh_token` cookie. + +**Response:** `200 { "message": "Logged out successfully", "revokedCount": 1 }` + +--- + +### `POST /auth/logout/all` + +Logs out every session for the user identified by the refresh token. +Idempotent. + +**Request body:** `{ "refreshToken": "" }` — or via the +httpOnly `refresh_token` cookie. + +**Response:** `200 { "message": "All sessions logged out", "revokedCount": 3 }` + +--- + +### `POST /auth/verify-email` + +**Request body:** `{ "token": "<64-char hex string from email>" }` + +| Status | Meaning | +| ------ | ---------------------------------- | +| 200 | Verified (or already verified) | +| 400 | Invalid, expired, or revoked token | + +--- + +### `POST /auth/resend-verification` + +Rate-limited (10 req / 15 min). Always returns 200 to avoid leaking whether an account exists. + +**Request body:** `{ "email": "..." }` + +**Response:** `200 { "message": "If the account exists, a verification email has been sent." }` + +--- + +### `POST /auth/forgot-password` + +Rate-limited (10 req / 15 min). Token expires after 30 minutes. Always returns 200. + +**Request body:** `{ "email": "..." }` + +**Response:** `200 { "message": "If the account exists, a password reset email has been sent." }` + +--- + +### `POST /auth/reset-password` + +On success, all active sessions are revoked and all pending verification tokens are cancelled. + +**Request body:** `{ "token": "<64-char hex>", "newPassword": "" }` + +| Status | Meaning | +| ------ | -------------------------------------- | +| 200 | Password reset | +| 400 | Invalid/expired token or weak password | + +--- + +### `POST /auth/otp/request` + +Requests a 6-digit SMS code. Behavior depends on whether a Bearer token is sent: + +- **No token → `LOGIN`**: the phone must already be verified on an existing account. Always returns 200 with the same generic message, whether or not the phone is registered, to avoid leaking phone existence. +- **With token → `PHONE_VERIFICATION`**: attaches/verifies this phone number on the caller's own account. + +Rate-limited by IP (`otpLimiter`), by phone (1/min cooldown, 5/hour), and — if `deviceId` is supplied — by device (10/hour). SMS is sent via a mocked provider (`SMS_PROVIDER=mock`) until a real carrier is integrated; see [`docs/decisions/0001-phone-otp-authentication.md`](decisions/0001-phone-otp-authentication.md). + +**Request body** + +| Field | Type | Required | Notes | +| ---------- | ------ | -------- | ----------------------------------- | +| `phone` | string | ✅ | E.164 format, e.g. `+2348012345678` | +| `deviceId` | string | ❌ | Used for device-level rate limiting | + +**Responses** + +| Status | Meaning | +| ------ | ---------------------------------------------------------------------------- | +| 200 | Code sent (or silently ignored for an unregistered/unverified `LOGIN` phone) | +| 400 | Validation failed or phone not in E.164 format | +| 409 | Phone already verified on a different account (`PHONE_VERIFICATION` only) | +| 429 | IP, phone, or device rate limit reached | + +```json +{ + "message": "If this phone number is registered, a verification code has been sent." +} +``` + +--- + +### `POST /auth/otp/verify` + +Verifies the code from `otp/request`. Codes are single-use, expire after 5 minutes, and the challenge locks after 5 wrong attempts (request a new code to retry). + +- **No token → `LOGIN`**: on success, returns the same JWT/user shape as `POST /auth/login`, after the same account-status checks (deactivated/pending-deletion/deleted). +- **With token → `PHONE_VERIFICATION`**: on success, marks the phone verified on the caller's account. + +**Request body:** `{ "phone": "+2348012345678", "code": "123456" }` + +**Responses** + +| Status | Meaning | +| ------ | ---------------------------------------------------------------------------------- | +| 200 | Verified — login response or `{ "message": "Phone number verified successfully" }` | +| 400 | Invalid/expired code, or validation failed | +| 401 | Invalid credentials (tombstoned account; `LOGIN` only) | +| 403 | Account deactivated or pending deletion (`LOGIN` only) | +| 429 | Too many wrong attempts — challenge locked | + +--- + +## Users — `/users` + +### `GET /users/me` 🔒 + +Owner-only aggregate: account identity, learner profile, profile completion, +onboarding state, and the current consent record per purpose. One read, so a +client does not have to fan out across four endpoints to render a settings or +"finish setting up" screen. + +```json +{ + "data": { + "account": { + "id": "uuid", + "email": "...", + "username": "...", + "role": "LEARNER", + "status": "ACTIVE", + "isVerified": true, + "phoneVerifiedAt": null, + "walletAddress": null, + "createdAt": "...", + "updatedAt": "...", + "lastLoginAt": null + }, + "profile": { + "id": "uuid", + "userId": "uuid", + "displayName": null, + "bio": null, + "avatarUrl": null, + "country": null, + "timezone": null, + "languages": [], + "level": "beginner", + "interests": [], + "goals": [], + "visibility": "private", + "createdAt": "...", + "updatedAt": "..." + }, + "completion": { + "percent": 0, + "missingFields": ["displayName", "bio", "..."] + }, + "onboarding": { + "version": "v1", + "status": "in_progress", + "currentStep": "profile_basics", + "completedSteps": ["profile_basics"], + "requiredStepsRemaining": ["consent"], + "startedAt": "...", + "completedAt": null + }, + "consents": [ + { + "purpose": "terms_of_service", + "status": "granted", + "required": true, + "policyVersion": "2026-01", + "grantedAt": "...", + "withdrawnAt": null + } + ], + "requiredConsentsGranted": false + } +} +``` + +The profile row is created on first access, so a brand-new learner gets a +deterministic 0%-complete profile rather than a `null`. `onboarding` is `null` +until the learner starts onboarding. The password hash is never included. + +**Responses:** `200` · `401` no/invalid token · `404` account unknown or tombstoned + +--- + +### `PATCH /users/me` 🔒 + +Partial learner-profile update. At least one field is required, and the body is +**closed**: any property outside the table below — including account fields such +as `status`, `isVerified`, `role`, `email`, `password` or `walletAddress` — is a +`400`, not a silently ignored key. Every accepted change is written together with +its audit event in one transaction. + +**Request body** (any non-empty subset of): + +| Field | Type | Constraints | +| ------------- | -------------------- | ------------------------------------------------------ | +| `displayName` | string \| null | 1–80 chars | +| `bio` | string \| null | max 1000 | +| `avatarUrl` | string (URL) \| null | normally set by the avatar upload flow | +| `country` | string \| null | 2–60 chars | +| `timezone` | string \| null | | +| `languages` | string[] | max 20 | +| `level` | enum | `beginner` \| `intermediate` \| `advanced` \| `expert` | +| `interests` | string[] | max 50 | +| `goals` | string[] | max 20 | +| `visibility` | enum | `private` \| `employer` \| `public` | + +**Response:** `200` `{ "message": "...", "data": { … } }` — the same aggregate as +`GET /users/me`, recomputed from the persisted row. + +**Responses:** `200` · `400` validation failed · `401` · `404` + +`PATCH /users/me/profile` accepts the same body and returns the profile alone +(without the account aggregate). + +--- + +### `GET /users/:id` + +Public — no auth needed. Returns the learner's public profile subset, or the +redacted stub `{ "id": "...", "visible": false }`. + +```json +{ + "data": { + "id": "uuid", + "displayName": "Grace H.", + "bio": "Learning Soroban", + "avatarUrl": null, + "country": "NG", + "level": "intermediate", + "interests": ["soroban"], + "visible": true + } +} +``` + +Disclosure requires **all** of: + +1. `profile.visibility === "public"`, +2. the account status is `ACTIVE`, and +3. `data_sharing` consent has not been withdrawn. + +Any refusal returns the same stub, so a caller cannot tell a private profile +from a withdrawn consent from a deactivated account. Archived profiles read as +`404`. Private account data (email, username, wallet address, status, +verification, password) never appears here — not even for the owner, who gets +the same public view as anyone else on this route and uses +`GET /users/me` or `GET /users/:id/profile` for their own full record. + +**Responses:** `200` · `400` malformed id · `404` unknown learner + +--- + +### `PATCH /users/password` 🔒 + +Verifies the current password, stores a new bcrypt hash, and revokes every +session and refresh-token family for the account **in the same transaction** — +so the caller must sign in again, and so does anyone holding a stolen session. +The change is audited; neither password reaches the audit trail. + +**Request body:** `{ "currentPassword": "...", "newPassword": "..." }` + +Password rules: min 8 chars, must contain uppercase, lowercase, digit, and special character (`@$!%*?&`). Must differ from current. + +**Response:** `200` `{ "message": "...", "revokedSessionCount": 2 }` + +**Responses:** `200` · `400` validation failed · `401` no token, or wrong current +password (`code: STEP_UP_FAILED`) · `404` account unknown or tombstoned + +--- + +### `PATCH /users/wallet` 🔒 + +Sets the learner's Stellar **public** key on their account. Never accepts or +returns a secret seed. The change is audited. + +**Request body:** `{ "walletAddress": "G..." }` + +Address must match `^G[A-Z0-9]{55}$`. + +Re-sending the address already on file is a no-op (`200`, `"Wallet address +unchanged"`, no second audit event). Addresses are unique across accounts. + +**Responses:** `200` · `400` invalid address · `401` · `404` account unknown or +tombstoned · `409` address already claimed by another account +(`code: WALLET_ADDRESS_TAKEN`) + +--- + +## Modules — `/modules` + +### `GET /modules` + +Optional auth — when a valid token is provided, each module includes `userProgress`. + +**Query parameters** + +| Param | Type | Default | +| ------------ | ------- | ------- | +| `page` | integer | 1 | +| `limit` | integer | 10 | +| `category` | string | — | +| `difficulty` | string | — | +| `search` | string | — | + +**Response `200`** + +```json +{ + "modules": [ + { + "id": "...", + "title": "...", + "description": "...", + "category": "finance", + "difficulty": "beginner", + "reward": 0.25, + "createdAt": "...", + "updatedAt": "...", + "completionCount": 120, + "userProgress": null + } + ], + "pagination": { + "page": 1, + "limit": 10, + "total": 45, + "totalPages": 5, + "hasNext": true, + "hasPrev": false + } +} +``` + +--- + +### `GET /modules/:id` + +Optional auth. Same `userProgress` inclusion behaviour. + +**Response `200`:** single module object (same fields as list item). +**404** if not found. + +--- + +### `POST /modules/:id/start` 🔒 + +Creates a progress record. Must be called before `complete`. + +**Response `201`:** + +```json +{ + "message": "Module started successfully", + "completionId": "...", + "startedAt": "..." +} +``` + +**400** if already started or completed. + +--- + +### `POST /modules/:id/complete` 🔒 + +Submit quiz answers. Module must have been started first. + +A score ≥ 70% qualifies for the XLM reward and triggers a push notification. + +**Request body:** + +```json +{ + "quizAnswers": [{ "questionId": "q1", "answer": "B" }] +} +``` + +**Response `200`:** + +```json +{ + "message": "Module completed successfully", + "score": 80, + "isEligibleForReward": true, + "reward": 0.25, + "rewardTransaction": "", + "completedAt": "..." +} +``` + +--- + +## Credentials — `/credentials` + +### `GET /credentials` 🔒 + +**Query parameters** + +| Param | Type | Notes | +| ---------- | ------------ | ------------------- | +| `moduleId` | UUID | filter | +| `fromDate` | ISO datetime | filter | +| `toDate` | ISO datetime | filter | +| `page` | integer | default 1 | +| `limit` | integer | default 10, max 100 | + +**Response `200`:** + +```json +{ + "success": true, + "data": [ + { + "id": "...", + "moduleId": "...", + "moduleName": "...", + "onChainId": null, + "issuedAt": "...", + "shareableLink": "..." + } + ], + "meta": { + "page": 1, + "limit": 10, + "total": 5, + "totalPages": 1, + "hasNextPage": false, + "hasPrevPage": false + } +} +``` + +--- + +### `GET /credentials/verify/:onChainId` + +Public — no auth needed. Looks up by `onChainId` first, falls back to credential UUID. + +**Response `200`:** + +```json +{ + "success": true, + "data": { + "valid": true, + "credential": { + "id": "...", + "holderName": "...", + "moduleName": "...", + "onChainId": "...", + "issuedAt": "..." + }, + "verification": { + "verifiedAt": "...", + "status": "verified", + "message": "This credential is valid and has been verified on-chain" + } + } +} +``` + +**404** if not found. + +--- + +### `GET /credentials/:id` 🔒 + +Returns full credential detail. Returns `401` if the credential belongs to another user. + +--- + +## Rewards — `/rewards` 🔒 + +All reward routes require authentication. + +### `GET /rewards/balance` + +```json +{ + "success": true, + "data": { + "balance": { "available": 10.5, "pending": 2.0, "lifetime": 25.0 }, + "updatedAt": "..." + } +} +``` + +--- + +### `GET /rewards/history` + +**Query parameters** + +| Param | Values | Default | +| ---------- | ---------------------------------------------------------------- | ------- | +| `type` | `module_reward`, `streak_bonus`, `referral_reward`, `withdrawal` | — | +| `status` | `pending`, `completed`, `failed` | — | +| `fromDate` | ISO datetime | — | +| `toDate` | ISO datetime | — | +| `limit` | 1–100 | 20 | +| `offset` | ≥ 0 | 0 | + +**Response `200`:** + +```json +{ + "success": true, + "data": { + "transactions": [ + { + "id": "...", + "type": "module_reward", + "status": "completed", + "amount": 0.25, + "moduleId": "...", + "stellarTxHash": null, + "createdAt": "...", + "completedAt": "..." + } + ], + "pagination": { "total": 15, "limit": 20, "offset": 0, "hasMore": false } + } +} +``` + +--- + +### `POST /rewards/withdraw` + +**Request body:** + +| Field | Type | Required | Notes | +| --------------- | ------ | -------- | --------------------------------------------- | +| `walletAddress` | string | ✅ | Stellar address matching `^G[A-Z0-9]{50,55}$` | +| `amount` | number | ✅ | XLM, must be > 0 | +| `memo` | string | ❌ | | + +**Response `201`:** + +```json +{ + "success": true, + "message": "Withdrawal processed successfully", + "data": { + "transactionId": "...", + "amount": 5.0, + "stellarTxHash": "...", + "status": "completed", + "requestedAt": "...", + "completedAt": "..." + } +} +``` + +**400** for invalid address, zero/negative amount, or insufficient balance. + +--- + +## Referrals — `/referrals` 🔒 + +All referral routes require authentication. + +### `POST /referrals/code` + +Generate (or retrieve existing) referral code for the authenticated user. + +- Returns `200` if the user already has a code. +- Returns `201` if a new 8-character hex code was created. + +```json +{ "success": true, "message": "...", "data": { "code": "A1B2C3D4" } } +``` + +--- + +### `POST /referrals/apply` + +Apply a referral code. Cannot apply your own code or apply more than once. + +**Request body:** `{ "code": "A1B2C3D4" }` + +| Status | Meaning | +| ------ | ---------------------------------------------- | +| 201 | Referral applied | +| 400 | Missing code, self-referral, or code not found | +| 409 | Already used a referral code | + +--- + +### `GET /referrals/stats` + +```json +{ + "success": true, + "data": { + "totalReferrals": 3, + "activeReferrals": 2, + "earnedBonuses": 10.0, + "pendingBonuses": 5.0 + } +} +``` + +`activeReferrals` = referrees who have completed at least one module. +`pendingBonuses` = (total − paid) × 5 XLM per referral. + +--- + +## Notifications — `/notifications` 🔒 + +All notification routes require authentication. + +### `POST /notifications/devices` + +Register a Firebase device token for push notifications. + +**Request body:** + +| Field | Type | Required | +| ---------- | --------------------------------- | -------- | +| `token` | string | ✅ | +| `platform` | `"ios"` \| `"android"` \| `"web"` | ✅ | + +**Response `201`:** device token record. + +--- + +### `PATCH /notifications/preferences` + +At least one field must be provided. + +**Request body** (any subset of): + +| Field | Type | +| ----------------- | ------- | +| `rewardReceipt` | boolean | +| `quizPassFail` | boolean | +| `streakReminders` | boolean | + +--- + +### `GET /notifications/delivery-status` + +**Query parameters:** `limit` (default 20, max 100), `status` (`pending` \| `success` \| `failed` \| `dead-letter`). + +```json +{ + "data": [ + { + "id": "...", + "type": "quizPassFail", + "title": "Quiz Passed!", + "body": "...", + "status": "success", + "error": null, + "attemptCount": 1, + "createdAt": "..." + } + ], + "count": 1 +} +``` + +--- + +## Sync — `/sync` 🔒 + +All sync routes require authentication and are subject to the `authenticatedLimiter` (1000 req / 15 min). + +### `POST /sync/progress` + +Upload batched offline progress events. Each event is deduplicated by `idempotencyKey`. Events with a stale `syncVersion` are skipped without error. + +**Request body:** + +```json +{ + "events": [ + { + "idempotencyKey": "device-abc-mod-xyz-1", + "deviceId": "device-abc", + "moduleId": "", + "progressPercent": 60, + "clientTimestamp": "2026-07-19T09:00:00.000Z", + "syncVersion": 3 + } + ] +} +``` + +**Response `200`:** + +```json +{ + "success": true, + "data": { + "results": [ + { "idempotencyKey": "device-abc-mod-xyz-1", "status": "applied" } + ] + } +} +``` + +Each result has `status`: `applied` | `skipped` | `rejected`, plus an optional `reason`. + +--- + +### `POST /sync/completions` + +Reconcile offline quiz/completion attempts. If the user already has a completion with an equal or higher score, the event is skipped. A higher score updates the existing record. + +**Request body:** + +```json +{ + "events": [ + { + "idempotencyKey": "device-abc-comp-xyz-1", + "deviceId": "device-abc", + "moduleId": "", + "score": 85, + "clientTimestamp": "2026-07-19T09:05:00.000Z", + "syncVersion": 1 + } + ] +} +``` + +Response shape identical to `POST /sync/progress`. + +--- + +## Employer — `/employer` 🔒 (employer role required) + +All employer routes require authentication with `role: employer`. An `employerLimiter` (500 req / 15 min) is applied. + +Plan tier is read from the `x-employer-plan` request header. Valid values: `starter` (default), `pro`, `enterprise`. + +### `GET /employer/search` + +Search the learner talent pool. + +**Query parameters** + +| Param | Type | Default | Notes | +| ------------- | ----------------------------- | ------- | -------------------------------------------------------- | +| `page` | integer | 1 | | +| `limit` | integer | 20 | Capped by plan: starter ≤ 10, pro ≤ 50, enterprise ≤ 100 | +| `skills` | string | — | Comma-separated keywords, e.g. `finance,defi` | +| `location` | string | — | | +| `credentials` | `any` \| `verified` \| `none` | `any` | | +| `search` | string | — | Free-text search on username/email | + +**Request header:** `x-employer-plan: starter | pro | enterprise` + +**Response `200`:** + +```json +{ + "candidates": [ + { + "id": "...", + "name": "alice42", + "location": "lagos", + "skills": ["finance", "defi"], + "completions": 5, + "averageScore": 82.4, + "verifiedCredentialCount": 2 + } + ], + "pagination": { + "page": 1, + "limit": 10, + "total": 3, + "totalPages": 1, + "hasNext": false, + "hasPrev": false + }, + "filters": { "skills": ["finance"], "location": null, "credentials": "any" }, + "plan": "starter" +} +``` + +**400** if `limit` exceeds plan maximum. + +--- + +### `GET /employer/candidates/:id` + +Full candidate profile with all verified credentials. + +**403** if candidate profile is private or caller is not an employer. +**404** if candidate not found or has no module completions. + +--- + +### `POST /employer/contact` + +Record a candidate outreach attempt. Requires **pro** or **enterprise** plan — **starter returns HTTP 402**. + +**Request header:** `x-employer-plan: pro` (or `enterprise`) + +**Request body:** + +| Field | Type | Required | Constraints | +| ------------- | ------------------------------------- | -------- | -------------------- | +| `candidateId` | UUID | ✅ | | +| `subject` | string | ✅ | 3–120 chars | +| `message` | string | ✅ | 10–3000 chars | +| `channel` | `"platform"` \| `"email"` \| `"both"` | ❌ | default `"platform"` | + +**Response `201`:** + +```json +{ + "message": "Candidate outreach recorded", + "outreach": { + "id": "...", + "candidateId": "...", + "channel": "platform", + "status": "recorded", + "createdAt": "..." + } +} +``` + +--- + +## Unimplemented / Stubbed Routes + +None on `/users`. `PATCH /users/password` and `PATCH /users/wallet` were the last +two stubs here; both are Prisma-backed and audited as of the Profile API work +(see [`docs/decisions/0004-profile-api.md`](decisions/0004-profile-api.md)). + +--- + +## Error Code Reference + +| HTTP | Typical cause | +| ---- | ---------------------------------------------------------- | +| 400 | Validation failed, malformed body, business rule violation | +| 401 | Missing, expired, or invalid JWT | +| 402 | Employer plan upgrade required | +| 403 | Authenticated but insufficient role or resource is private | +| 404 | Resource not found | +| 409 | Conflict (duplicate email, already applied referral, etc.) | +| 429 | Rate limit exceeded — see `Retry-After` header | +| 500 | Internal server error (includes not-yet-implemented stubs) | diff --git a/docs/API_CONVENTIONS.md b/docs/API_CONVENTIONS.md index 6736f480..a615eb9a 100644 --- a/docs/API_CONVENTIONS.md +++ b/docs/API_CONVENTIONS.md @@ -24,22 +24,24 @@ All Learnault API endpoints must adhere to consistent, deterministic response fo Returned for single-resource operations, action completions, and non-paginated queries. #### TypeScript Definition + ```typescript export interface RequestMetadata { - requestId: string; - timestamp: string; - version: string; + requestId: string + timestamp: string + version: string } export interface ApiResponse { - success: true; - data: T; - message?: string; - meta: RequestMetadata; + success: true + data: T + message?: string + meta: RequestMetadata } ``` #### Runtime Example (JSON) + ```json { "success": true, @@ -65,25 +67,27 @@ export interface ApiResponse { Used for catalog browsing, administrative tables, search results, and resource listings where total count and page navigation are required. #### TypeScript Definition + ```typescript export interface PaginationMeta extends RequestMetadata { - page: number; - limit: number; - total: number; - totalPages: number; - hasNextPage: boolean; - hasPrevPage: boolean; + page: number + limit: number + total: number + totalPages: number + hasNextPage: boolean + hasPrevPage: boolean } export interface PaginatedResponse { - success: true; - data: T[]; - meta: PaginationMeta; - message?: string; + success: true + data: T[] + meta: PaginationMeta + message?: string } ``` #### Runtime Example (JSON) + ```json { "success": true, @@ -116,23 +120,25 @@ export interface PaginatedResponse { Used for high-frequency time-series data, append-only logs, activity streams, ledger transactions, and real-time feeds to guarantee zero skipped items during live updates. #### TypeScript Definition + ```typescript export interface CursorPaginationMeta extends RequestMetadata { - cursor?: string; - nextCursor: string | null; - hasMore: boolean; - limit: number; + cursor?: string + nextCursor: string | null + hasMore: boolean + limit: number } export interface CursorPaginatedResponse { - success: true; - data: T[]; - meta: CursorPaginationMeta; - message?: string; + success: true + data: T[] + meta: CursorPaginationMeta + message?: string } ``` #### Runtime Example (JSON) + ```json { "success": true, @@ -162,6 +168,7 @@ export interface CursorPaginatedResponse { Returned when an operational error occurs (e.g. resource not found, unauthorized, forbidden action, rate limit exceeded, conflict). #### TypeScript Definition + ```typescript export enum ErrorCode { BAD_REQUEST = 'BAD_REQUEST', @@ -176,26 +183,27 @@ export enum ErrorCode { } export interface ApiErrorDetail { - message: string; - code?: string; - details?: Record; - stack?: string[]; + message: string + code?: string + details?: Record + stack?: string[] request?: { - method: string; - path: string; - headers?: Record; - }; + method: string + path: string + headers?: Record + } } export interface ApiErrorResponse { - success: false; - error: ApiErrorDetail; - requestId: string; - timestamp: string; + success: false + error: ApiErrorDetail + requestId: string + timestamp: string } ``` #### Runtime Example (JSON) + ```json { "success": false, @@ -215,20 +223,22 @@ export interface ApiErrorResponse { Returned when input parameters, query parameters, or request body fail schema validation. #### TypeScript Definition + ```typescript export interface ApiValidationErrorResponse { - success: false; + success: false error: { - code: ErrorCode.VALIDATION_ERROR; - message: string; - details: Record; - }; - requestId: string; - timestamp: string; + code: ErrorCode.VALIDATION_ERROR + message: string + details: Record + } + requestId: string + timestamp: string } ``` #### Runtime Example (JSON) + ```json { "success": false, @@ -251,13 +261,13 @@ export interface ApiValidationErrorResponse { ### 3.1 Choosing Pagination Strategy -| Feature / Criteria | Page-Based (`page`, `limit`) | Cursor-Based (`cursor`, `limit`) | -| :--- | :--- | :--- | -| **Use Cases** | Admin tables, Catalog listing, User search | Audit logs, Activity feeds, Ledger transactions | -| **Random Page Access** | Supported (`page=5`) | Not supported (sequential traversal) | -| **Total Count** | Provided (`meta.total`, `meta.totalPages`) | Omitted for high-throughput scalability | -| **Mutation Resilience** | Sensitive to insertions/deletions during paging | Immune to insertions/deletions during paging | -| **Default Boundaries** | `page=1`, `limit=20` (max: 100) | `limit=20` (max: 100) | +| Feature / Criteria | Page-Based (`page`, `limit`) | Cursor-Based (`cursor`, `limit`) | +| :---------------------- | :---------------------------------------------- | :---------------------------------------------- | +| **Use Cases** | Admin tables, Catalog listing, User search | Audit logs, Activity feeds, Ledger transactions | +| **Random Page Access** | Supported (`page=5`) | Not supported (sequential traversal) | +| **Total Count** | Provided (`meta.total`, `meta.totalPages`) | Omitted for high-throughput scalability | +| **Mutation Resilience** | Sensitive to insertions/deletions during paging | Immune to insertions/deletions during paging | +| **Default Boundaries** | `page=1`, `limit=20` (max: 100) | `limit=20` (max: 100) | ### 3.2 Stable Ordering Requirements (Tiebreakers) @@ -267,10 +277,7 @@ To avoid non-deterministic pagination where database records with matching sort 2. **Secondary tiebreaker is mandatory**: The database query MUST append `id` ASC or `id` DESC as the final ordering criteria. 3. Example Prisma ordering: ```typescript - orderBy: [ - { createdAt: sortOrder }, - { id: sortOrder } - ] + orderBy: [{ createdAt: sortOrder }, { id: sortOrder }] ``` --- @@ -278,29 +285,34 @@ To avoid non-deterministic pagination where database records with matching sort ## 4. Data Serialization Rules ### 4.1 ISO 8601 UTC Dates + - All date and time fields must be serialized as UTC strings in ISO-8601 format ending with `Z`. - Example: `"2026-07-25T14:00:00.000Z"` - Zod schema helper: `z.string().datetime()` ### 4.2 Financial, Token, & Asset Amounts + - Monetary and token values must never be serialized using native floating-point numbers. - Exact amounts are serialized as string representations alongside asset code and issuer. #### TypeScript Definition + ```typescript export interface AssetAmount { - amount: string; // e.g. "100.5000000" or stroop integer string "1005000000" - assetCode: string; // e.g. "XLM", "LEARN" - issuer?: string | null; // Stellar issuer public key or null for native asset + amount: string // e.g. "100.5000000" or stroop integer string "1005000000" + assetCode: string // e.g. "XLM", "LEARN" + issuer?: string | null // Stellar issuer public key or null for native asset } ``` ### 4.3 Identifiers + - Resource primary keys must be standard lowercase UUID v4 strings. - Example: `"9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"` - Zod schema helper: `z.string().uuid()` ### 4.4 Nulls & Optional Fields + - In PUT/PATCH requests: - Missing field (`undefined`) means field is untouched. - Explicit `null` means field value should be cleared/reset. @@ -308,6 +320,7 @@ export interface AssetAmount { - Absent values are returned as `null` rather than omitted or set to `undefined`. ### 4.5 Enum Values + - All enums in contracts are represented as `UPPERCASE_SNAKE_CASE` string literals. - Examples: `USER_ROLE_LEARNER`, `ACCOUNT_STATUS_ACTIVE`, `SORT_ORDER_ASC`. @@ -326,15 +339,19 @@ export interface AssetAmount { ## 6. API Versioning & Deprecation Policy ### 6.1 Versioning Strategy + - Primary API routes are prefixed under `/api/v1/`. - Non-breaking additions (e.g. adding new optional fields to responses) are introduced within `/api/v1/`. - Breaking changes require a new version route prefix (`/api/v2/`). ### 6.2 Version Header + Every API response includes the `X-API-Version` response header set to `v1`. ### 6.3 Deprecation Policy & Headers + When an endpoint or version path is deprecated: + 1. It continues functioning for at least **6 months** prior to sunsetting. 2. Response headers MUST include standard RFC 8594 deprecation headers: - `Deprecation: true` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e36044b4..1b95d411 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -119,6 +119,7 @@ The shared kernel contains cross-cutting concerns accessible to all domains: - **Messaging** (`shared/messaging/`): Email/webhook delivery, event bus **Rules:** + - All domains can import from shared kernel - Shared kernel CANNOT import from domains - Keep shared kernel minimal and stable @@ -133,6 +134,7 @@ Infrastructure provides technical capabilities without business logic: - **Database** (`shared/config/database.ts`): Prisma client **Rules:** + - Infrastructure cannot import from business domains - Domains call infrastructure via well-defined interfaces - Infrastructure is replaceable (e.g., swap Stellar for different blockchain) @@ -162,11 +164,13 @@ Circular dependencies ❌ ### Communication Patterns **Preferred:** + 1. **Domain Events** (async, decoupled) - Use for cross-domain notifications 2. **Public Service Interfaces** (sync, explicit) - Use sparingly for queries 3. **Database Queries** (read-only) - Acceptable for simple lookups **Anti-Patterns:** + - Direct controller-to-controller calls - Direct service-to-service imports across domains - Shared mutable state @@ -247,6 +251,7 @@ Domain B (Event Handler) Database schema is defined in `prisma/schema.prisma` using Prisma ORM. **Key Models:** + - `User` - User accounts and authentication - `Module` - Learning content - `Completion` - Module completion records @@ -274,6 +279,7 @@ export class UserRepository { ``` **Benefits:** + - Testable (mock repositories in tests) - Encapsulates query logic - Can switch database technology @@ -289,9 +295,9 @@ Domain events represent something that has happened in the system: ```typescript interface DomainEvent { eventId: string - eventType: string // e.g., "UserRegistered" - aggregateId: string // e.g., userId - aggregateType: string // e.g., "User" + eventType: string // e.g., "UserRegistered" + aggregateId: string // e.g., userId + aggregateType: string // e.g., "User" payload: object timestamp: Date version: number @@ -377,6 +383,7 @@ See `docs/ERROR_HANDLING.md` for details. - Located in `integrations/architecture/` **Run tests:** + ```bash pnpm test # All tests pnpm test:watch # Watch mode @@ -478,6 +485,7 @@ See `Dockerfile` and `docker-compose.yml`. ### Event Sourcing Store all domain events for: + - Audit trail - Event replay - Temporal queries @@ -485,18 +493,21 @@ Store all domain events for: ### CQRS (Command Query Responsibility Segregation) Separate read and write models: + - Commands: Modify state - Queries: Read-optimized views ### Saga Pattern Coordinate distributed transactions across domains: + - Orchestration-based sagas - Compensating transactions for rollback ### API Gateway Centralized API gateway for: + - Rate limiting - Authentication - Routing diff --git a/docs/AUTH_POLICY.md b/docs/AUTH_POLICY.md index 73a193da..72ec17ce 100644 --- a/docs/AUTH_POLICY.md +++ b/docs/AUTH_POLICY.md @@ -6,15 +6,15 @@ verification evidence for that issue. ## 1. Account status vs. access -| Status | Login (`/auth/login`, `/auth/otp/verify`) | Authenticated routes (`authenticate` + `authorize`) | `requireActiveAccount` routes | -| -------------------- | :---: | :---: | :---: | -| `ACTIVE` | ✅ | ✅ | ✅ | -| `DEACTIVATED` | ❌ 403 `ACCOUNT_DEACTIVATED` | ❌ 403 `ACCOUNT_DEACTIVATED` | ❌ 403 `ACCOUNT_DEACTIVATED` | -| `PENDING_DELETION` | ❌ 403 `ACCOUNT_PENDING_DELETION` | ❌ 403 `ACCOUNT_PENDING_DELETION` | ❌ 403 `ACCOUNT_PENDING_DELETION` | -| `DELETED` | ❌ 401 (indistinguishable from bad credentials) | ❌ 401 `Account not found` | ❌ 401 `Account not found` | +| Status | Login (`/auth/login`, `/auth/otp/verify`) | Authenticated routes (`authenticate` + `authorize`) | `requireActiveAccount` routes | +| ------------------ | :---------------------------------------------: | :-------------------------------------------------: | :-------------------------------: | +| `ACTIVE` | ✅ | ✅ | ✅ | +| `DEACTIVATED` | ❌ 403 `ACCOUNT_DEACTIVATED` | ❌ 403 `ACCOUNT_DEACTIVATED` | ❌ 403 `ACCOUNT_DEACTIVATED` | +| `PENDING_DELETION` | ❌ 403 `ACCOUNT_PENDING_DELETION` | ❌ 403 `ACCOUNT_PENDING_DELETION` | ❌ 403 `ACCOUNT_PENDING_DELETION` | +| `DELETED` | ❌ 401 (indistinguishable from bad credentials) | ❌ 401 `Account not found` | ❌ 401 `Account not found` | Status is always re-read from the database at request time — a JWT issued -before a status change stays *cryptographically* valid until it expires, +before a status change stays _cryptographically_ valid until it expires, but no longer grants access once the account is no longer `ACTIVE`. `authorize(...roles)` and `requireActiveAccount` both enforce this table; @@ -25,14 +25,14 @@ waiting for the old token to expire. ## 2. Operations requiring a verified email -| Operation | Verified email required? | -| -------------------------------------------- | :---: | +| Operation | Verified email required? | +| -------------------------------------------- | :-----------------------------------: | | Register / log in | No — verification happens post-signup | -| Browse modules, view own profile/credentials | No | -| `POST /rewards/withdraw` (moves funds out) | **Yes** — `requireVerifiedEmail` | -| `/employer/*` (accesses candidate PII) | **Yes** — `requireVerifiedEmail` | +| Browse modules, view own profile/credentials | No | +| `POST /rewards/withdraw` (moves funds out) | **Yes** — `requireVerifiedEmail` | +| `/employer/*` (accesses candidate PII) | **Yes** — `requireVerifiedEmail` | -Rationale: verification is not required to *use* the platform, only for +Rationale: verification is not required to _use_ the platform, only for operations that move value out of it or expose other users' data to an unverified identity. `requireVerifiedEmail` (in `auth.middleware.ts`) is the single enforcement point; extending coverage to another route means @@ -68,14 +68,14 @@ adding it to that route's middleware chain, not duplicating the check. ## 5. Rate limits (`rate-limit.middleware.ts`, `env.ts`) -| Limiter | Applies to | Default | -| --- | --- | --- | -| `authLimiter` | `/auth/register`, `/auth/login`, `/auth/resend-verification`, `/auth/forgot-password`, `/auth/reset-password` | 10 / 15 min / IP | -| `otpLimiter` | `/auth/otp/request`, `/auth/otp/verify` | 5 / 15 min / IP | -| Per-account OTP limits | `otp.service.ts` + `auth.controller.ts` (phone + device scoped) | 5/hr per phone, 10/hr per device | -| `employerLimiter` | `/employer/*` | 500 / 15 min / IP | -| `authenticatedLimiter` | authenticated, non-employer traffic | 1000 / 15 min / IP | -| `generalLimiter` | everything else | 100 / 15 min / IP | +| Limiter | Applies to | Default | +| ---------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `authLimiter` | `/auth/register`, `/auth/login`, `/auth/resend-verification`, `/auth/forgot-password`, `/auth/reset-password` | 10 / 15 min / IP | +| `otpLimiter` | `/auth/otp/request`, `/auth/otp/verify` | 5 / 15 min / IP | +| Per-account OTP limits | `otp.service.ts` + `auth.controller.ts` (phone + device scoped) | 5/hr per phone, 10/hr per device | +| `employerLimiter` | `/employer/*` | 500 / 15 min / IP | +| `authenticatedLimiter` | authenticated, non-employer traffic | 1000 / 15 min / IP | +| `generalLimiter` | everything else | 100 / 15 min / IP | All limiters set `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and — on a 429 — `Retry-After`, so clients get stable @@ -88,9 +88,9 @@ opaque and rotated on every use. The full design — family linkage, reuse detection, logout, transport, and the CSRF policy — is documented in [`docs/security/refresh-token-rotation.md`](security/refresh-token-rotation.md). -| Token | Transport | Lifetime | Storage | -| --- | --- | --- | --- | -| Access token (JWT) | `Authorization: Bearer ` header | 15 min (`JWT_ACCESS_TTL_SECONDS`) | client memory only | +| Token | Transport | Lifetime | Storage | +| ---------------------- | ------------------------------------------------------------ | ------------------------------------- | ---------------------------------------------- | +| Access token (JWT) | `Authorization: Bearer ` header | 15 min (`JWT_ACCESS_TTL_SECONDS`) | client memory only | | Refresh token (opaque) | JSON body `refreshToken`, or httpOnly `refresh_token` cookie | 30 days (`REFRESH_TOKEN_TTL_SECONDS`) | SHA-256 hash only (`refresh_tokens.tokenHash`) | - Refresh tokens are single-use: each successful `POST /auth/refresh` @@ -105,6 +105,6 @@ detection, logout, transport, and the CSRF policy — is documented in - **PIN policy** — no PIN feature exists in the codebase. - `user.controller.ts#changePassword` — its `validatePassword` / `updateUserPassword` helpers are pre-existing stubs (`mockUser`, `throw - new Error('Not implemented')`) unrelated to this change; it isn't wired +new Error('Not implemented')`) unrelated to this change; it isn't wired to Prisma yet, so there's nothing here to harden without building that feature from scratch. diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md index 3ca09e01..daa36e24 100644 --- a/docs/CODE_OF_CONDUCT.md +++ b/docs/CODE_OF_CONDUCT.md @@ -1,183 +1,183 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -- **Demonstrating empathy and kindness** toward other people -- **Being respectful** of differing opinions, viewpoints, and experiences -- **Giving and gracefully accepting constructive feedback** -- **Accepting responsibility** and apologizing to those affected by our mistakes, - and learning from the experience -- **Focusing on what is best not just for us as individuals, but for the overall - community** -- **Using inclusive language** and avoiding exclusionary terms -- **Being mindful of cultural differences** in our global community - -Examples of unacceptable behavior include: - -- **The use of sexualized language or imagery**, and sexual attention or advances of any kind -- **Trolling, insulting or derogatory comments**, and personal or political attacks -- **Public or private harassment** -- **Publishing others' private information**, such as a physical or email address, without their explicit permission -- **Spamming**, including excessive self-promotion or off-topic content -- **Other conduct** which could reasonably be considered inappropriate in a professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, including: - -- GitHub repositories (issues, pull requests, discussions) -- Discord server and other chat platforms -- Twitter, LinkedIn, and other social media -- Community events (virtual and in-person) -- One-on-one communications related to the project - -It also applies when an individual is officially representing the community in -public spaces. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at: - -**conduct@learnault.io** - -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series of -actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or permanent -ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within the -community. - -## Reporting Guide - -If you believe someone is violating the Code of Conduct, please report it by: - -1. **Emailing conduct@learnault.io** with details of the incident -2. **Contacting a community leader directly** on Discord (for urgent matters) -3. **Using the report feature** in GitHub or Discord - -When reporting, please include: - -- Your contact information -- Names (real, usernames, pseudonyms) of any individuals involved -- Date and time of the incident -- Description of what happened -- Any supporting evidence (screenshots, logs, etc.) -- Any additional context you believe is relevant - -Reports will be handled with discretion and confidentiality. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. - -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. - -For answers to common questions about this code of conduct, see the FAQ at -[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at -[https://www.contributor-covenant.org/translations][translations]. - -[homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html -[Mozilla CoC]: https://github.com/mozilla/diversity -[FAQ]: https://www.contributor-covenant.org/faq -[translations]: https://www.contributor-covenant.org/translations - -## Our Commitment to Diversity - -Learnault is committed to fostering a diverse and inclusive community. We -actively welcome contributors from all backgrounds, particularly those from -underserved communities and emerging markets. We believe that diverse -perspectives make our project stronger and more effective at achieving our -mission of democratizing education through blockchain technology. - -### Languages - -While our primary development language is English, we welcome contributions in -other languages for content and documentation. Our community spaces should be -accessible to non-native English speakers, and we encourage patience and clarity -in communication. - -### Accessibility - -We strive to make all project spaces accessible to people with disabilities. -If you encounter accessibility barriers, please report them so we can address -them. - ---- - -**Thank you for helping make Learnault a welcoming, respectful, and inclusive community.** +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- **Demonstrating empathy and kindness** toward other people +- **Being respectful** of differing opinions, viewpoints, and experiences +- **Giving and gracefully accepting constructive feedback** +- **Accepting responsibility** and apologizing to those affected by our mistakes, + and learning from the experience +- **Focusing on what is best not just for us as individuals, but for the overall + community** +- **Using inclusive language** and avoiding exclusionary terms +- **Being mindful of cultural differences** in our global community + +Examples of unacceptable behavior include: + +- **The use of sexualized language or imagery**, and sexual attention or advances of any kind +- **Trolling, insulting or derogatory comments**, and personal or political attacks +- **Public or private harassment** +- **Publishing others' private information**, such as a physical or email address, without their explicit permission +- **Spamming**, including excessive self-promotion or off-topic content +- **Other conduct** which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, including: + +- GitHub repositories (issues, pull requests, discussions) +- Discord server and other chat platforms +- Twitter, LinkedIn, and other social media +- Community events (virtual and in-person) +- One-on-one communications related to the project + +It also applies when an individual is officially representing the community in +public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at: + +**conduct@learnault.io** + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Reporting Guide + +If you believe someone is violating the Code of Conduct, please report it by: + +1. **Emailing conduct@learnault.io** with details of the incident +2. **Contacting a community leader directly** on Discord (for urgent matters) +3. **Using the report feature** in GitHub or Discord + +When reporting, please include: + +- Your contact information +- Names (real, usernames, pseudonyms) of any individuals involved +- Date and time of the incident +- Description of what happened +- Any supporting evidence (screenshots, logs, etc.) +- Any additional context you believe is relevant + +Reports will be handled with discretion and confidentiality. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + +## Our Commitment to Diversity + +Learnault is committed to fostering a diverse and inclusive community. We +actively welcome contributors from all backgrounds, particularly those from +underserved communities and emerging markets. We believe that diverse +perspectives make our project stronger and more effective at achieving our +mission of democratizing education through blockchain technology. + +### Languages + +While our primary development language is English, we welcome contributions in +other languages for content and documentation. Our community spaces should be +accessible to non-native English speakers, and we encourage patience and clarity +in communication. + +### Accessibility + +We strive to make all project spaces accessible to people with disabilities. +If you encounter accessibility barriers, please report them so we can address +them. + +--- + +**Thank you for helping make Learnault a welcoming, respectful, and inclusive community.** diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 560ccb82..2c8fe915 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,165 +1,165 @@ -# Contributing to Learnault - -First off, thank you for considering contributing to Learnault! It's people like you that make Learnault such a great tool for education and financial inclusion. - -## Code of Conduct - -This project and everyone participating in it is governed by our [Code of Conduct](./CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. - -## How Can I Contribute? - -### Reporting Bugs - -Before creating bug reports, please check the issue list as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible: - -- **Use a clear and descriptive title** -- **Describe the exact steps to reproduce the problem** -- **Provide specific examples** (e.g., screenshots, code snippets) -- **Describe the behavior you observed vs what you expected** -- **Include details about your environment** (browser, device, OS) - -### Suggesting Enhancements - -Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include: - -- **A clear and descriptive title** -- **A detailed description of the proposed feature** -- **Explain why this enhancement would be useful** to most users -- **Provide examples** of how it would work - -### Your First Code Contribution - -Unsure where to start? Look for issues labeled `good-first-issue` or `help-wanted`. These are specifically curated for newcomers. - -### Pull Requests - -1. Fork the repository -2. Create a new branch (`git checkout -b feature/amazing-feature`) -3. Make your changes -4. Run tests (`pnpm test`) -5. Commit your changes (`git commit -m 'Add some amazing feature'`) -6. Push to the branch (`git push origin feature/amazing-feature`) -7. Open a Pull Request - -## Development Setup - -### 1. Fork & Clone - -```bash -git clone https://github.com/toneflix/learnault.git -cd learnault -``` - -### 2. Install Dependencies - -```bash -pnpm install -``` - -### 3. Set Up Environment - -```bash -# Copy environment templates -cp packages/api/.env.example packages/api/.env -cp packages/app/.env.example packages/app/.env - -# Edit with your local values -``` - -### 4. Run Development - -```bash -# Start all services -pnpm dev - -# Or run specific package -pnpm --filter api dev -pnpm --filter app dev -pnpm --filter contracts build -``` - -## Coding Guidelines - -### TypeScript Style - -- Use TypeScript for all new code -- Enable strict mode in tsconfig -- Define interfaces for all data structures -- Avoid `any` type - -### Testing - -- Write tests for new features -- Maintain or improve coverage -- Run tests before committing - -```bash -pnpm test -``` - -### Commit Messages - -We follow [Conventional Commits](https://www.conventionalcommits.org/): - -- `feat:` New feature -- `fix:` Bug fix -- `docs:` Documentation only -- `style:` Code style changes -- `refactor:` Code refactoring -- `test:` Adding tests -- `chore:` Maintenance - -Example: `feat(api): add user wallet creation endpoint` - -### Branch Naming - -- `feature/` - New features -- `fix/` - Bug fixes -- `docs/` - Documentation -- `refactor/` - Code refactoring -- `test/` - Testing - -## Package Structure - -### Smart Contracts (`packages/contracts/`) - -- Rust code with Soroban SDK -- Unit tests in same file with `#[test]` -- Integration tests in `tests/` -- Follow Rust naming conventions - -### API (`packages/api/`) - -- RESTful endpoints -- Input validation using Zod or similar -- Error handling with consistent format -- Database migrations in `prisma/` - -### Frontend (`packages/app/`) - -- Functional components with hooks -- Tailwind CSS for styling -- State management with Redux Toolkit -- Responsive, mobile-first design - -## Review Process - -1. All PRs require at least one review -2. CI checks must pass -3. No merge conflicts -4. Documentation updated if needed -5. Tests added/updated - -## Community - -- Join our [Discord](https://discord.gg) for real-time chat - -## Recognition - -Contributors will be: - -- Listed in the README -- Mentioned in release notes -- Eligible for contributor rewards program - -Thank you for contributing! +# Contributing to Learnault + +First off, thank you for considering contributing to Learnault! It's people like you that make Learnault such a great tool for education and financial inclusion. + +## Code of Conduct + +This project and everyone participating in it is governed by our [Code of Conduct](./CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the issue list as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible: + +- **Use a clear and descriptive title** +- **Describe the exact steps to reproduce the problem** +- **Provide specific examples** (e.g., screenshots, code snippets) +- **Describe the behavior you observed vs what you expected** +- **Include details about your environment** (browser, device, OS) + +### Suggesting Enhancements + +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include: + +- **A clear and descriptive title** +- **A detailed description of the proposed feature** +- **Explain why this enhancement would be useful** to most users +- **Provide examples** of how it would work + +### Your First Code Contribution + +Unsure where to start? Look for issues labeled `good-first-issue` or `help-wanted`. These are specifically curated for newcomers. + +### Pull Requests + +1. Fork the repository +2. Create a new branch (`git checkout -b feature/amazing-feature`) +3. Make your changes +4. Run tests (`pnpm test`) +5. Commit your changes (`git commit -m 'Add some amazing feature'`) +6. Push to the branch (`git push origin feature/amazing-feature`) +7. Open a Pull Request + +## Development Setup + +### 1. Fork & Clone + +```bash +git clone https://github.com/toneflix/learnault.git +cd learnault +``` + +### 2. Install Dependencies + +```bash +pnpm install +``` + +### 3. Set Up Environment + +```bash +# Copy environment templates +cp packages/api/.env.example packages/api/.env +cp packages/app/.env.example packages/app/.env + +# Edit with your local values +``` + +### 4. Run Development + +```bash +# Start all services +pnpm dev + +# Or run specific package +pnpm --filter api dev +pnpm --filter app dev +pnpm --filter contracts build +``` + +## Coding Guidelines + +### TypeScript Style + +- Use TypeScript for all new code +- Enable strict mode in tsconfig +- Define interfaces for all data structures +- Avoid `any` type + +### Testing + +- Write tests for new features +- Maintain or improve coverage +- Run tests before committing + +```bash +pnpm test +``` + +### Commit Messages + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` New feature +- `fix:` Bug fix +- `docs:` Documentation only +- `style:` Code style changes +- `refactor:` Code refactoring +- `test:` Adding tests +- `chore:` Maintenance + +Example: `feat(api): add user wallet creation endpoint` + +### Branch Naming + +- `feature/` - New features +- `fix/` - Bug fixes +- `docs/` - Documentation +- `refactor/` - Code refactoring +- `test/` - Testing + +## Package Structure + +### Smart Contracts (`packages/contracts/`) + +- Rust code with Soroban SDK +- Unit tests in same file with `#[test]` +- Integration tests in `tests/` +- Follow Rust naming conventions + +### API (`packages/api/`) + +- RESTful endpoints +- Input validation using Zod or similar +- Error handling with consistent format +- Database migrations in `prisma/` + +### Frontend (`packages/app/`) + +- Functional components with hooks +- Tailwind CSS for styling +- State management with Redux Toolkit +- Responsive, mobile-first design + +## Review Process + +1. All PRs require at least one review +2. CI checks must pass +3. No merge conflicts +4. Documentation updated if needed +5. Tests added/updated + +## Community + +- Join our [Discord](https://discord.gg) for real-time chat + +## Recognition + +Contributors will be: + +- Listed in the README +- Mentioned in release notes +- Eligible for contributor rewards program + +Thank you for contributing! diff --git a/docs/DATA_LIFECYCLE.md b/docs/DATA_LIFECYCLE.md index 6e136725..78fcdd03 100644 --- a/docs/DATA_LIFECYCLE.md +++ b/docs/DATA_LIFECYCLE.md @@ -15,12 +15,12 @@ explains it. `tests/audit/classification.test.ts` fails if a model exists in Every record is exactly one of four classes. -| Class | Meaning | Deletion | -| --- | --- | --- | -| **MUTABLE** | Updated in place. Where the history matters, it lives in audit events, not in the row. | Purged when retention expires | +| Class | Meaning | Deletion | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| **MUTABLE** | Updated in place. Where the history matters, it lives in audit events, not in the row. | Purged when retention expires | | **ARCHIVABLE** | Withdrawn by stamping `archivedAt` rather than deleted, because something else still depends on it. Excluded from reads by default. | Purged some time after being archived | -| **DELETABLE** | Safe to hard-delete. Nothing else depends on it. | Deleted on expiry or erasure | -| **IMMUTABLE** | Append-only. Never updated. Deleted only by a retention purge, if at all. | Purge only | +| **DELETABLE** | Safe to hard-delete. Nothing else depends on it. | Deleted on expiry or erasure | +| **IMMUTABLE** | Append-only. Never updated. Deleted only by a retention purge, if at all. | Purge only | The distinction that matters most in practice is **archivable vs deletable**. A record is archivable when deleting it would strand another record: a `Completion` @@ -39,21 +39,21 @@ it. `Audited` means mutations must go through ### Identity -| Model | Class | Retention (anchor) | On erasure | Audited | -| --- | --- | --- | --- | --- | -| `User` | MUTABLE | Indefinite | **Anonymize** | Yes | -| `LearnerPreference` | MUTABLE | Indefinite | Cascade | Yes | -| `LearnerProfile` | ARCHIVABLE | 365d (`archivedAt`) | Cascade | Yes | -| `OnboardingProgress` | MUTABLE | Indefinite | Cascade | No | -| `NotificationPreference` | MUTABLE | Indefinite | Cascade | No | -| `DataExportRequest` | DELETABLE | **7d** (`completedAt`) | Delete | Yes | -| `AccountDeletionRequest` | MUTABLE | 7y (`createdAt`) | **Retain** | Yes | +| Model | Class | Retention (anchor) | On erasure | Audited | +| ------------------------ | ---------- | ---------------------- | ------------- | ------- | +| `User` | MUTABLE | Indefinite | **Anonymize** | Yes | +| `LearnerPreference` | MUTABLE | Indefinite | Cascade | Yes | +| `LearnerProfile` | ARCHIVABLE | 365d (`archivedAt`) | Cascade | Yes | +| `OnboardingProgress` | MUTABLE | Indefinite | Cascade | No | +| `NotificationPreference` | MUTABLE | Indefinite | Cascade | No | +| `DataExportRequest` | DELETABLE | **7d** (`completedAt`) | Delete | Yes | +| `AccountDeletionRequest` | MUTABLE | 7y (`createdAt`) | **Retain** | Yes | `User` is anonymized rather than deleted. Money and credential rows outlive the account (see below), and they need a valid referent — so the row survives as a tombstone with every identifying column overwritten. -`AccountDeletionRequest` is retained *past the erasure it triggers*: it is the +`AccountDeletionRequest` is retained _past the erasure it triggers_: it is the evidence the request was honoured. That is only acceptable because it holds no personal data beyond the user id. @@ -63,13 +63,13 @@ highest-value single row in the database. ### Money -| Model | Class | Retention (anchor) | On erasure | Audited | -| --- | --- | --- | --- | --- | -| `Transaction` | IMMUTABLE | 7y (`createdAt`) | Retain | Yes | -| `Wallet` | MUTABLE | Indefinite | Retain | Yes | -| `StellarFunding` | MUTABLE | 7y (`createdAt`) | Retain | Yes | -| `Referral` | IMMUTABLE | 7y (`createdAt`) | Retain | Yes | -| `ReferralCode` | ARCHIVABLE | 365d (`archivedAt`) | Cascade | Yes | +| Model | Class | Retention (anchor) | On erasure | Audited | +| ---------------- | ---------- | ------------------- | ---------- | ------- | +| `Transaction` | IMMUTABLE | 7y (`createdAt`) | Retain | Yes | +| `Wallet` | MUTABLE | Indefinite | Retain | Yes | +| `StellarFunding` | MUTABLE | 7y (`createdAt`) | Retain | Yes | +| `Referral` | IMMUTABLE | 7y (`createdAt`) | Retain | Yes | +| `ReferralCode` | ARCHIVABLE | 365d (`archivedAt`) | Cascade | Yes | A ledger a subject can erase is not a ledger. Money rows survive erasure, which is only defensible because they carry no personal data in-row — they reference a @@ -82,10 +82,10 @@ ledger row. ### Credentials -| Model | Class | Retention | On erasure | Audited | -| --- | --- | --- | --- | --- | -| `Credential` | IMMUTABLE | Indefinite | Retain | Yes | -| `Completion` | IMMUTABLE | Indefinite | **Delete** | Yes | +| Model | Class | Retention | On erasure | Audited | +| ------------ | --------- | ---------- | ---------- | ------- | +| `Credential` | IMMUTABLE | Indefinite | Retain | Yes | +| `Completion` | IMMUTABLE | Indefinite | **Delete** | Yes | `Credential` is verifiable by third parties against the chain, so issuance is permanent; revocation appends a revocation record rather than editing the row. @@ -97,15 +97,15 @@ identifier is not. ### Security -| Model | Class | Retention (anchor) | On erasure | Audited | -| --- | --- | --- | --- | --- | -| `AuditEvent` | IMMUTABLE | 7y (`occurredAt`) | Retain | n/a | -| `AuditLog` *(legacy)* | IMMUTABLE | 2y (`createdAt`) | Anonymize | n/a | -| `Session` | MUTABLE | 90d (`updatedAt`) | Delete | Yes | -| `RefreshToken` | MUTABLE | 90d (`updatedAt`) | Cascade | Yes | -| `VerificationToken` | MUTABLE | 30d (`createdAt`) | Delete | Yes | -| `OtpChallenge` | MUTABLE | 30d (`createdAt`) | Delete | Yes | -| `ManagedKeyReference` | IMMUTABLE | Indefinite | Retain | Yes | +| Model | Class | Retention (anchor) | On erasure | Audited | +| --------------------- | --------- | ------------------ | ---------- | ------- | +| `AuditEvent` | IMMUTABLE | 7y (`occurredAt`) | Retain | n/a | +| `AuditLog` _(legacy)_ | IMMUTABLE | 2y (`createdAt`) | Anonymize | n/a | +| `Session` | MUTABLE | 90d (`updatedAt`) | Delete | Yes | +| `RefreshToken` | MUTABLE | 90d (`updatedAt`) | Cascade | Yes | +| `VerificationToken` | MUTABLE | 30d (`createdAt`) | Delete | Yes | +| `OtpChallenge` | MUTABLE | 30d (`createdAt`) | Delete | Yes | +| `ManagedKeyReference` | IMMUTABLE | Indefinite | Retain | Yes | Indefinite retention of security data is the failure mode this category exists to prevent, so everything here is bounded — with one exception. @@ -120,10 +120,10 @@ already-rotated token is still detectable as theft. ### Consent -| Model | Class | Retention (anchor) | On erasure | Audited | -| --- | --- | --- | --- | --- | -| `ConsentRecord` | IMMUTABLE | 7y (`createdAt`) | Retain | Yes | -| `PreferenceAuditLog` | IMMUTABLE | 7y (`createdAt`) | Retain | n/a | +| Model | Class | Retention (anchor) | On erasure | Audited | +| -------------------- | --------- | ------------------ | ---------- | ------- | +| `ConsentRecord` | IMMUTABLE | 7y (`createdAt`) | Retain | Yes | +| `PreferenceAuditLog` | IMMUTABLE | 7y (`createdAt`) | Retain | n/a | Proof of consent must outlive the account it describes — otherwise a withdrawal cannot be demonstrated after the fact. Withdrawal appends a new row; it never @@ -131,11 +131,11 @@ edits the granting one. ### Content -| Model | Class | Retention (anchor) | On erasure | Audited | -| --- | --- | --- | --- | --- | -| `Module` | ARCHIVABLE | Indefinite (`archivedAt`) | Retain | Yes | -| `Avatar` | ARCHIVABLE | 365d (`archivedAt`) | Cascade | Yes | -| `AvatarVariant` | IMMUTABLE | 365d (`createdAt`) | Cascade | No | +| Model | Class | Retention (anchor) | On erasure | Audited | +| --------------- | ---------- | ------------------------- | ---------- | ------- | +| `Module` | ARCHIVABLE | Indefinite (`archivedAt`) | Retain | Yes | +| `Avatar` | ARCHIVABLE | 365d (`archivedAt`) | Cascade | Yes | +| `AvatarVariant` | IMMUTABLE | 365d (`createdAt`) | Cascade | No | `Module` is archived and never purged: completions and credentials reference the module a learner actually took, so withdrawing content archives it permanently @@ -143,19 +143,19 @@ rather than removing it. ### Operational -| Model | Class | Retention (anchor) | On erasure | Audited | -| --- | --- | --- | --- | --- | -| `WebhookEndpoint` | ARCHIVABLE | 365d (`archivedAt`) | Retain | Yes | -| `WebhookDelivery` | MUTABLE | 30d (`createdAt`) | Retain | No | -| `EmailDelivery` | MUTABLE | 30d (`createdAt`) | Delete | No | -| `NotificationLog` | MUTABLE | 30d (`createdAt`) | Delete | No | -| `DeviceToken` | DELETABLE | 90d (`updatedAt`) | Delete | No | -| `SyncEvent` | IMMUTABLE | 90d (`createdAt`) | Delete | No | -| `OutboxEvent` | MUTABLE | 30d (`createdAt`) | Retain | No | -| `JobAttempt` | MUTABLE | 30d (`createdAt`) | Cascade | No | -| `RolledBackRecord` | IMMUTABLE | 30d (`createdAt`) | Retain | No | -| `QueueLease` | MUTABLE | Indefinite | Retain | No | -| `WalletProvisioningJob` | MUTABLE | 90d (`updatedAt`) | Cascade | No | +| Model | Class | Retention (anchor) | On erasure | Audited | +| ----------------------- | ---------- | ------------------- | ---------- | ------- | +| `WebhookEndpoint` | ARCHIVABLE | 365d (`archivedAt`) | Retain | Yes | +| `WebhookDelivery` | MUTABLE | 30d (`createdAt`) | Retain | No | +| `EmailDelivery` | MUTABLE | 30d (`createdAt`) | Delete | No | +| `NotificationLog` | MUTABLE | 30d (`createdAt`) | Delete | No | +| `DeviceToken` | DELETABLE | 90d (`updatedAt`) | Delete | No | +| `SyncEvent` | IMMUTABLE | 90d (`createdAt`) | Delete | No | +| `OutboxEvent` | MUTABLE | 30d (`createdAt`) | Retain | No | +| `JobAttempt` | MUTABLE | 30d (`createdAt`) | Cascade | No | +| `RolledBackRecord` | IMMUTABLE | 30d (`createdAt`) | Retain | No | +| `QueueLease` | MUTABLE | Indefinite | Retain | No | +| `WalletProvisioningJob` | MUTABLE | 90d (`updatedAt`) | Cascade | No | `EmailDelivery` and `NotificationLog` hold rendered message bodies, which is personal data — hence the short window and hard deletion on erasure. @@ -171,18 +171,18 @@ no user data to erase and nothing to age out. `audit_events` is the audit spine. One row records **who** did **what** to **which record**, **why**, and under **which request**. -| Column | Purpose | -| --- | --- | -| `actorType`, `actorId`, `actorRole` | Who acted. `USER`, `ADMIN`, `SYSTEM`, `WORKER`, `ANONYMOUS`. Role as held *at the time*. | -| `action` | Dotted name, e.g. `account.deactivated` | -| `targetType`, `targetId` | Which record changed | -| `recordClass` | Lifecycle class of the target, from the matrix | -| `reason` | Justification. Required for `ADMIN` actors | -| `requestId`, `correlationId`, `source` | Correlation to request logs, outbox events, and the code path | -| `metadata` | Redacted JSON context | -| `actorIpHash` | Keyed HMAC of the request IP — never the address | -| `userAgentFamily` | Coarse family (`Chrome`, `Android`) — never the raw UA | -| `occurredAt` | When | +| Column | Purpose | +| -------------------------------------- | ---------------------------------------------------------------------------------------- | +| `actorType`, `actorId`, `actorRole` | Who acted. `USER`, `ADMIN`, `SYSTEM`, `WORKER`, `ANONYMOUS`. Role as held _at the time_. | +| `action` | Dotted name, e.g. `account.deactivated` | +| `targetType`, `targetId` | Which record changed | +| `recordClass` | Lifecycle class of the target, from the matrix | +| `reason` | Justification. Required for `ADMIN` actors | +| `requestId`, `correlationId`, `source` | Correlation to request logs, outbox events, and the code path | +| `metadata` | Redacted JSON context | +| `actorIpHash` | Keyed HMAC of the request IP — never the address | +| `userAgentFamily` | Coarse family (`Chrome`, `Android`) — never the raw UA | +| `occurredAt` | When | Separating **actor** from **target** is the point: "an admin deactivated a learner" is a materially different event from "a learner deactivated themselves", @@ -226,22 +226,22 @@ seven. New code writes `audit_events`. ## 4. Redaction Because audit rows cannot be scrubbed later, metadata is filtered on the way -*in*, by [`src/audit/redaction.ts`](../src/audit/redaction.ts). Two independent +_in_, by [`src/audit/redaction.ts`](../src/audit/redaction.ts). Two independent passes run over every value: 1. **Key matching** — a field named `password`, `refreshToken`, `email`, … is replaced regardless of content. 2. **Value matching** — a value shaped like a Stellar seed, a Stellar public key, a JWT, a bearer credential, an email address, an E.164 number, an IPv4 - address, a 40+ character hex blob, or a PEM header is replaced *even under an - innocuous key*, because callers nest secrets in unexpected places. + address, a 40+ character hex blob, or a PEM header is replaced _even under an + innocuous key_, because callers nest secrets in unexpected places. Structural caps then bound the whole object: depth 4, 20 array entries, 32 keys, 256 characters per string, 4 KB serialized. Over-redaction is treated as its own failure. `statusCode`, `failureCode`, `referralCode`, `amountStroops` and `requestId` are all allowed, because an audit -trail nobody can read is not reviewable. Notably `to` is *allowed*: it is the +trail nobody can read is not reviewable. Notably `to` is _allowed_: it is the obvious name for an email recipient, but also the standard name for the destination of a status transition — and the value-level email pattern catches an actual recipient anyway. @@ -355,7 +355,7 @@ matrix after the cooling-off window (`DELETION_COOLING_OFF_DAYS`, default 30): 1. Add it to `prisma/schema.prisma`. 2. Add a rule to `src/audit/classification.ts` — class, category, retention, - anchor, erasure behaviour, whether it is audited, and *why*. + anchor, erasure behaviour, whether it is audited, and _why_. 3. If it is `ARCHIVABLE`, add `archivedAt`, `archivedById` and `archivedReason`, plus the `CHECK` constraint in the migration. 4. Add it to the matrix in this document. @@ -369,24 +369,24 @@ to prevent. ## 9. Configuration -| Variable | Default | Purpose | -| --- | --- | --- | -| `AUDIT_IP_HASH_SECRET` | *(unset)* | HMAC key for `actorIpHash`. Unset in production omits the hash. | -| `DELETION_COOLING_OFF_DAYS` | `30` | Window before an erasure request is finalized | -| `EXPORT_TTL_DAYS` | `7` | Lifetime of an export artifact | -| `LIFECYCLE_SWEEP_INTERVAL_MS` | `0` (disabled) | Background sweep interval | +| Variable | Default | Purpose | +| ----------------------------- | -------------- | --------------------------------------------------------------- | +| `AUDIT_IP_HASH_SECRET` | _(unset)_ | HMAC key for `actorIpHash`. Unset in production omits the hash. | +| `DELETION_COOLING_OFF_DAYS` | `30` | Window before an erasure request is finalized | +| `EXPORT_TTL_DAYS` | `7` | Lifetime of an export artifact | +| `LIFECYCLE_SWEEP_INTERVAL_MS` | `0` (disabled) | Background sweep interval | --- ## 10. Tests -| File | Covers | -| --- | --- | -| `tests/audit/classification.test.ts` | Every schema model classified; retention, erasure and audit invariants per category | -| `tests/audit/redaction.test.ts` | Key and value deny-lists, structural caps, IP hashing, UA coarsening, over-redaction | -| `tests/audit/audit-event.service.test.ts` | Attribution, redaction on write, no mutating API, purge uses the session variable | -| `tests/audit/audited-mutation.test.ts` | Transaction atomicity, rollback on audit failure, policy enforcement, archive/restore | -| `tests/audit/archive.test.ts` | Default exclusion, the `findUnique` and write carve-outs, opt-out, patches | +| File | Covers | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `tests/audit/classification.test.ts` | Every schema model classified; retention, erasure and audit invariants per category | +| `tests/audit/redaction.test.ts` | Key and value deny-lists, structural caps, IP hashing, UA coarsening, over-redaction | +| `tests/audit/audit-event.service.test.ts` | Attribution, redaction on write, no mutating API, purge uses the session variable | +| `tests/audit/audited-mutation.test.ts` | Transaction atomicity, rollback on audit failure, policy enforcement, archive/restore | +| `tests/audit/archive.test.ts` | Default exclusion, the `findUnique` and write carve-outs, opt-out, patches | | `tests/integration/audit-immutability.test.ts` | Database-level `UPDATE`/`DELETE`/`TRUNCATE` rejection and the archive `CHECK` constraints | The integration test is skipped when no test database is reachable. Because diff --git a/docs/DEVELOPMENT_STACK.md b/docs/DEVELOPMENT_STACK.md index 9fb734f3..66756466 100644 --- a/docs/DEVELOPMENT_STACK.md +++ b/docs/DEVELOPMENT_STACK.md @@ -54,16 +54,16 @@ docker compose up -d --scale scheduler=2 A replica that loses the race logs a skipped tick and moves on; a replica that crashes mid-drain has its lease expire, and the next tick reclaims the queue. -| Variable | Default | Purpose | -| --- | --- | --- | -| `SCHEDULER_INTERVAL_MS` | `15000` | Base tick interval for every queue | -| `SCHEDULER__INTERVAL_MS` | — | Per-queue override, e.g. `SCHEDULER_WEBHOOK_INTERVAL_MS` | -| `SCHEDULER_LEASE_MS` | `60000` | Lease held per tick (floored at 2× the interval) | -| `SCHEDULER_QUEUES` | all | Comma list restricting which queues this replica runs | -| `SCHEDULER_DISABLED_QUEUES` | — | Comma list of queues to skip | -| `SCHEDULER_SHUTDOWN_TIMEOUT_MS` | `30000` | How long `SIGTERM` waits for in-flight ticks | -| `SCHEDULER_IN_PROCESS` | `false` | Opt-in: run the runner inside the API process for single-process deployments | -| `LIFECYCLE_SWEEP_INTERVAL_MS` | `0` | When `> 0`, overrides the `account-lifecycle` queue interval | +| Variable | Default | Purpose | +| ------------------------------- | ------- | ---------------------------------------------------------------------------- | +| `SCHEDULER_INTERVAL_MS` | `15000` | Base tick interval for every queue | +| `SCHEDULER__INTERVAL_MS` | — | Per-queue override, e.g. `SCHEDULER_WEBHOOK_INTERVAL_MS` | +| `SCHEDULER_LEASE_MS` | `60000` | Lease held per tick (floored at 2× the interval) | +| `SCHEDULER_QUEUES` | all | Comma list restricting which queues this replica runs | +| `SCHEDULER_DISABLED_QUEUES` | — | Comma list of queues to skip | +| `SCHEDULER_SHUTDOWN_TIMEOUT_MS` | `30000` | How long `SIGTERM` waits for in-flight ticks | +| `SCHEDULER_IN_PROCESS` | `false` | Opt-in: run the runner inside the API process for single-process deployments | +| `LIFECYCLE_SWEEP_INTERVAL_MS` | `0` | When `> 0`, overrides the `account-lifecycle` queue interval | Every tick emits a structured log line carrying per-queue `depth`, `due`, `lagMs` (age of the oldest due row), `durationMs`, and cumulative `attempts` / `failures` / `skipped`. @@ -123,12 +123,12 @@ This validates the compose file, starts the stack, waits for `/health/ready`, pr ## Troubleshooting -| Symptom | Fix | -| ------------------------------------ | -------------------------------------------------------------------- | -| Port 5432/6379/5000 already in use | Override in `.env`: `POSTGRES_PORT=5433`, `REDIS_PORT=6380`, `API_PORT=5001` | -| Prisma client errors (`@prisma/client` export) | Run `pnpm db:generate` (or `docker compose build`), then restart the stack | -| `JWT_SECRET` required error | Set a real `JWT_SECRET` in `.env` (defaults are dev-only) | -| Containers restarting after reset | Ensure `.env` exists before `docker compose up` | +| Symptom | Fix | +| ---------------------------------------------- | ---------------------------------------------------------------------------- | +| Port 5432/6379/5000 already in use | Override in `.env`: `POSTGRES_PORT=5433`, `REDIS_PORT=6380`, `API_PORT=5001` | +| Prisma client errors (`@prisma/client` export) | Run `pnpm db:generate` (or `docker compose build`), then restart the stack | +| `JWT_SECRET` required error | Set a real `JWT_SECRET` in `.env` (defaults are dev-only) | +| Containers restarting after reset | Ensure `.env` exists before `docker compose up` | ## Related diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md index 29dcd4e2..1fceb15b 100644 --- a/docs/ERROR_HANDLING.md +++ b/docs/ERROR_HANDLING.md @@ -142,9 +142,7 @@ app.use(errorHandler) ```json { "success": true, - "data": { - /* response data */ - } + "data": {/* response data */} } ``` @@ -176,9 +174,7 @@ app.use(errorHandler) "request": { "method": "GET", "path": "/api/users/123", - "headers": { - /* request headers */ - } + "headers": {/* request headers */} } } } diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 0b792331..cd3764d1 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -10,21 +10,23 @@ The staging environment is deployed using an immutable Docker image digest. The 1. **Build & Tag:** A new Docker image is built for every commit to `main` using a multi-stage `Dockerfile`. 2. **Predeploy Checks:** The `deploy-staging.sh` script validates the environment configuration (`config/staging.env.example`). -3. **Migrations:** Prisma migrations are applied *before* the application starts using the newly built image (`npx prisma migrate deploy`). +3. **Migrations:** Prisma migrations are applied _before_ the application starts using the newly built image (`npx prisma migrate deploy`). 4. **Deploy & Await:** The API container is deployed via Docker Compose and the script polls `/health` until readiness is confirmed. 5. **Smoke Tests:** `smoke-test.sh` runs a suite of safe, read-only API tests (e.g., `/health`) to verify operational sanity. -6. **Rollback:** If any step fails, the `rollback-staging.sh` script is triggered automatically to revert the API container to the *previous* immutable image digest. +6. **Rollback:** If any step fails, the `rollback-staging.sh` script is triggered automatically to revert the API container to the _previous_ immutable image digest. ## Migration-Forward Policy **CRITICAL: We never rollback the database.** In the event of a failed deployment that included a bad database migration: -1. The rollback script *only* reverts the API container to the previous image. + +1. The rollback script _only_ reverts the API container to the previous image. 2. Because the schema cannot be safely rolled back in PostgreSQL without risking data loss, **the previous API version must be backward compatible with the new schema**, or the environment will remain broken. -3. If the environment is broken, the engineering team must immediately write a *forward migration* (a new PR) to fix the schema or drop the problematic changes safely. +3. If the environment is broken, the engineering team must immediately write a _forward migration_ (a new PR) to fix the schema or drop the problematic changes safely. ### How to apply a fix: + 1. Create a new branch. 2. Fix the broken logic or write a new Prisma migration (`npx prisma migrate dev --name fix_schema`). 3. Merge the PR. The pipeline will automatically build a new image, run the new migration, and deploy. @@ -36,6 +38,7 @@ See `config/staging.env.example` for the list of required environment variables. ## Manual Execution To rehearse the deployment locally: + ```bash # 1. Build an image docker build -t learnault-api:test-tag . diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 44104949..e4ba200a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,302 +1,302 @@ -# Security Policy for Learnault - -## Overview - -Learnault is committed to protecting the security and integrity of our platform, users, and community. We take security vulnerabilities seriously and appreciate the efforts of security researchers and community members who help us maintain a safe environment. - -This document outlines our security practices, how to report vulnerabilities, and what to expect when you report a security issue. - ---- - -## Supported Versions - -We currently support the following versions of Learnault with security updates: - -| Version | Supported | Status | -| :---------- | :-------- | :------------------------------------------- | -| 1.0.x | ✅ Yes | Active development | -| 0.x | ❌ No | Beta versions - upgrade recommended | -| Main branch | ✅ Yes | Latest development, security patches applied | - -**Note:** Always use the latest stable version. Beta versions and the main branch may contain unreviewed changes. - ---- - -## Reporting a Vulnerability - -### Preferred Method: Private Report - -**PLEASE DO NOT REPORT SECURITY VULNERABILITIES THROUGH PUBLIC GITHUB ISSUES, DISCORD, OR SOCIAL MEDIA.** - -Instead, please report security vulnerabilities privately to our security team: - -**security@toneflix.net** - -### What to Include - -1. To help us respond quickly and effectively, please include: -2. Description: Clear description of the vulnerability -3. Impact: What an attacker could potentially do -4. Steps to Reproduce: Detailed steps with screenshots if helpful -5. Affected Versions: Which versions are impacted -6. Environment: Browser, OS, device details if applicable -7. Proof of Concept: Code snippets or demonstrations -8. Suggested Fix: If you have ideas for remediation -9. Your Contact: How to reach you for follow-up (email, Signal, etc.) - -### Response Timeline - -| Stage | Expected Timeframe | -| :--------------------- | :---------------------- | -| **Acknowledgment** | Within 24 hours | -| **Initial Assessment** | Within 72 hours | -| **Status Update** | Weekly until resolution | -| **Fix Development** | Depends on severity | -| **Public Disclosure** | After fix is deployed | - ---- - -## Scope - -### In Scope - -The following assets are in scope for security reporting: - -**Core Platform:** - -- Smart contracts (`https://github.com/learnault/contracts/`) -- Backend API (`https://github.com/learnault/api/`) -- Frontend application (`https://github.com/learnault/learnault/`) -- Authentication and authorization systems -- Reward distribution mechanisms -- Wallet integration - -**Infrastructure:** - -- Production servers and services -- Database systems -- CI/CD pipelines -- Domain names and DNS - -### Out of Scope - -The following are generally out of scope: - -- Issues requiring physical access to user devices -- Social engineering of Learnault team members -- Denial of service attacks (report these, but we handle separately) -- Theoretical vulnerabilities without proof of concept -- Issues in dependencies that are already reported upstream -- Previously reported issues (unless still unfixed) -- Features marked as "experimental" or "beta" - ---- - -## Bug Bounty Program - -We offer bug bounties for qualifying security vulnerabilities. The bounty amount depends on severity and impact. - -### Bounty Tiers - -| Severity | Description | Bounty Range | -| :---------------- | :----------------------------------------------- | :--------------- | -| **Critical** | Direct loss of funds, complete system compromise | $5,000 - $10,000 | -| **High** | Significant security breach, data exposure | $2,000 - $5,000 | -| **Medium** | Limited impact, requires user interaction | $500 - $2,000 | -| **Low** | Minor issues, limited scope | $100 - $500 | -| **Informational** | Best practices, theoretical risks | Recognition only | - -### Bounty Eligibility - -✅ **Eligible:** - -- First-time reporters of valid, in-scope vulnerabilities -- Clear, reproducible reports -- Responsible disclosure (no public disclosure before fix) - -**Not Eligible:** - -- Duplicate reports -- Issues already known to the team -- Automated tool outputs without analysis -- Self-reported vulnerabilities by team members -- Violations of our disclosure policy - -### Payment Methods - -- **USDC** (preferred - via Stellar) -- **XLM** -- **Bank transfer** (for larger amounts) -- **Gift cards** (Amazon, etc. - for smaller amounts) - ---- - -## Security Best Practices for Users - -### For Learners - -1. **Wallet Security** - - Never share your private keys or seed phrases - - Use strong, unique passwords - - Enable two-factor authentication (2FA) when available - - Keep recovery phrases offline and secure - -2. **Account Protection** - - Verify email confirmations come from @learnault.io - - Be cautious of phishing attempts - - Log out from shared devices - - Monitor your wallet for unauthorized transactions - -3. **Safe Learning** - - Only access Learnault through official channels - - Report suspicious modules or content - - Be wary of users asking for personal information - -### For Employers (B2B) - -1. **API Security** - - Rotate API keys regularly - - Use IP whitelisting where possible - - Implement rate limiting on your end - - Monitor for unusual API usage patterns - -2. **Data Protection** - - Only request necessary candidate information - - Securely store any downloaded credential data - - Comply with local data protection laws (GDPR, etc.) - ---- - -## Security Architecture - -### Smart Contract Security - -Our Soroban smart contracts implement: - -- **Access Control**: Only authorized issuers can mint credentials -- **Immutability**: Credentials cannot be altered once issued -- **Revocation**: Ability to revoke compromised credentials -- **Pausability**: Emergency pause functionality for critical issues -- **Formal Verification**: Critical contracts are formally verified - -### API Security - -- **Authentication**: JWT-based with short expiration -- **Rate Limiting**: Prevents abuse and DoS attacks -- **Input Validation**: All inputs sanitized and validated -- **SQL Injection Protection**: Parameterized queries via Prisma -- **CORS**: Strict cross-origin resource sharing policies - -### Infrastructure Security - -- **HTTPS/TLS**: All traffic encrypted in transit -- **AWS Security Groups**: Minimal exposure, principle of least privilege -- **Regular Backups**: Encrypted, tested recovery procedures -- **DDoS Protection**: CloudFlare or similar protection -- **WAF**: Web Application Firewall for common attack patterns - ---- - -## Responsible Disclosure Policy - -We ask that security researchers follow these guidelines: - -1. **Report Privately**: Send details to security@learnault.io first -2. **Give Us Time**: Allow reasonable time to fix before public disclosure -3. **Make Good Faith Efforts**: Avoid privacy violations, data destruction, or service interruption -4. **Provide Details**: Quality reports help us fix faster -5. **No Extortion**: We will not pay ransoms or threats - -### Disclosure Timeline - -- Day 0 - Report received and acknowledged -- Day 1-3 - Initial assessment and severity determination -- Day 4-14 - Fix development (depending on severity) -- Day 15 - Fix deployed to production -- Day 16 - Public disclosure (coordinated with reporter) - ---- - -## Known Security Considerations - -### Blockchain-Specific Risks - -| Risk | Mitigation | -| :--------------------------------- | :----------------------------------------------- | -| **Smart contract vulnerabilities** | Multiple audits, bug bounty, formal verification | -| **Private key compromise** | User education, hardware wallet support planned | -| **Network congestion** | Transaction queueing, retry logic | -| **Stellar network issues** | Monitoring, fallback procedures | - -### Platform Risks - -| Risk | Mitigation | -| :------------------- | :----------------------------------------------- | -| **Account takeover** | 2FA, suspicious activity monitoring | -| **Phishing attacks** | User education, domain monitoring | -| **Data breach** | Encryption, minimal data collection | -| **DDoS attacks** | CloudFlare protection, rate limiting | -| **Sybil attacks** | Verification requirements, rate limiting rewards | - -## Secure Development Lifecycle - -We follow these security practices in development: - -1. **Threat Modeling**: Identify risks before coding -2. **Secure Coding Guidelines**: All developers must follow -3. **Code Review**: Every PR reviewed, with security focus -4. **Automated Scanning**: SAST, DAST, dependency scanning -5. **Testing**: Unit, integration, and security tests -6. **Staging Environment**: Test in production-like environment -7. **Security Sign-off**: Required before major releases - -### Automated Security Tools - -| Tool | Purpose | Frequency | -| :------------------------- | :---------------------- | :----------- | -| **SonarQube** | Code quality & security | Every PR | -| **Snyk** | Dependency scanning | Daily | -| **ESLint Security Plugin** | JavaScript security | Every commit | -| **Trivy** | Container scanning | Every build | -| **OWASP ZAP** | DAST scanning | Weekly | - ---- - -## Emergency Contact - -For **critical security emergencies** (active attack, compromised systems, etc.): - -| Method | Contact | Availability | -| :------------------ | :--------------------- | :------------- | -| **Emergency Phone** | +1 (555) 123-4567 | 24/7 | -| **Signal** | @learnault.123 | 24/7 | -| **Email** | emergency@learnault.io | Monitored 24/7 | -| **Discord** | @security-lead (ping) | Business hours | - -For non-emergencies, please use security@learnault.io. - -_Last updated: February 2026_ - ---- - -## Policy Updates - -This security policy may be updated periodically. Significant changes will be announced via: - -- GitHub Security Advisories -- Discord #announcements channel -- Email to registered security contacts - -**Version:** 1.0 -**Last Updated:** February 18, 2026 -**Next Review:** May 2026 - ---- - -## Contact Information - -**Security Team:** security@toneflix.net - ---- - -_Thank you for helping keep Learnault and our community safe!_ 🛡️ +# Security Policy for Learnault + +## Overview + +Learnault is committed to protecting the security and integrity of our platform, users, and community. We take security vulnerabilities seriously and appreciate the efforts of security researchers and community members who help us maintain a safe environment. + +This document outlines our security practices, how to report vulnerabilities, and what to expect when you report a security issue. + +--- + +## Supported Versions + +We currently support the following versions of Learnault with security updates: + +| Version | Supported | Status | +| :---------- | :-------- | :------------------------------------------- | +| 1.0.x | ✅ Yes | Active development | +| 0.x | ❌ No | Beta versions - upgrade recommended | +| Main branch | ✅ Yes | Latest development, security patches applied | + +**Note:** Always use the latest stable version. Beta versions and the main branch may contain unreviewed changes. + +--- + +## Reporting a Vulnerability + +### Preferred Method: Private Report + +**PLEASE DO NOT REPORT SECURITY VULNERABILITIES THROUGH PUBLIC GITHUB ISSUES, DISCORD, OR SOCIAL MEDIA.** + +Instead, please report security vulnerabilities privately to our security team: + +**security@toneflix.net** + +### What to Include + +1. To help us respond quickly and effectively, please include: +2. Description: Clear description of the vulnerability +3. Impact: What an attacker could potentially do +4. Steps to Reproduce: Detailed steps with screenshots if helpful +5. Affected Versions: Which versions are impacted +6. Environment: Browser, OS, device details if applicable +7. Proof of Concept: Code snippets or demonstrations +8. Suggested Fix: If you have ideas for remediation +9. Your Contact: How to reach you for follow-up (email, Signal, etc.) + +### Response Timeline + +| Stage | Expected Timeframe | +| :--------------------- | :---------------------- | +| **Acknowledgment** | Within 24 hours | +| **Initial Assessment** | Within 72 hours | +| **Status Update** | Weekly until resolution | +| **Fix Development** | Depends on severity | +| **Public Disclosure** | After fix is deployed | + +--- + +## Scope + +### In Scope + +The following assets are in scope for security reporting: + +**Core Platform:** + +- Smart contracts (`https://github.com/learnault/contracts/`) +- Backend API (`https://github.com/learnault/api/`) +- Frontend application (`https://github.com/learnault/learnault/`) +- Authentication and authorization systems +- Reward distribution mechanisms +- Wallet integration + +**Infrastructure:** + +- Production servers and services +- Database systems +- CI/CD pipelines +- Domain names and DNS + +### Out of Scope + +The following are generally out of scope: + +- Issues requiring physical access to user devices +- Social engineering of Learnault team members +- Denial of service attacks (report these, but we handle separately) +- Theoretical vulnerabilities without proof of concept +- Issues in dependencies that are already reported upstream +- Previously reported issues (unless still unfixed) +- Features marked as "experimental" or "beta" + +--- + +## Bug Bounty Program + +We offer bug bounties for qualifying security vulnerabilities. The bounty amount depends on severity and impact. + +### Bounty Tiers + +| Severity | Description | Bounty Range | +| :---------------- | :----------------------------------------------- | :--------------- | +| **Critical** | Direct loss of funds, complete system compromise | $5,000 - $10,000 | +| **High** | Significant security breach, data exposure | $2,000 - $5,000 | +| **Medium** | Limited impact, requires user interaction | $500 - $2,000 | +| **Low** | Minor issues, limited scope | $100 - $500 | +| **Informational** | Best practices, theoretical risks | Recognition only | + +### Bounty Eligibility + +✅ **Eligible:** + +- First-time reporters of valid, in-scope vulnerabilities +- Clear, reproducible reports +- Responsible disclosure (no public disclosure before fix) + +**Not Eligible:** + +- Duplicate reports +- Issues already known to the team +- Automated tool outputs without analysis +- Self-reported vulnerabilities by team members +- Violations of our disclosure policy + +### Payment Methods + +- **USDC** (preferred - via Stellar) +- **XLM** +- **Bank transfer** (for larger amounts) +- **Gift cards** (Amazon, etc. - for smaller amounts) + +--- + +## Security Best Practices for Users + +### For Learners + +1. **Wallet Security** + - Never share your private keys or seed phrases + - Use strong, unique passwords + - Enable two-factor authentication (2FA) when available + - Keep recovery phrases offline and secure + +2. **Account Protection** + - Verify email confirmations come from @learnault.io + - Be cautious of phishing attempts + - Log out from shared devices + - Monitor your wallet for unauthorized transactions + +3. **Safe Learning** + - Only access Learnault through official channels + - Report suspicious modules or content + - Be wary of users asking for personal information + +### For Employers (B2B) + +1. **API Security** + - Rotate API keys regularly + - Use IP whitelisting where possible + - Implement rate limiting on your end + - Monitor for unusual API usage patterns + +2. **Data Protection** + - Only request necessary candidate information + - Securely store any downloaded credential data + - Comply with local data protection laws (GDPR, etc.) + +--- + +## Security Architecture + +### Smart Contract Security + +Our Soroban smart contracts implement: + +- **Access Control**: Only authorized issuers can mint credentials +- **Immutability**: Credentials cannot be altered once issued +- **Revocation**: Ability to revoke compromised credentials +- **Pausability**: Emergency pause functionality for critical issues +- **Formal Verification**: Critical contracts are formally verified + +### API Security + +- **Authentication**: JWT-based with short expiration +- **Rate Limiting**: Prevents abuse and DoS attacks +- **Input Validation**: All inputs sanitized and validated +- **SQL Injection Protection**: Parameterized queries via Prisma +- **CORS**: Strict cross-origin resource sharing policies + +### Infrastructure Security + +- **HTTPS/TLS**: All traffic encrypted in transit +- **AWS Security Groups**: Minimal exposure, principle of least privilege +- **Regular Backups**: Encrypted, tested recovery procedures +- **DDoS Protection**: CloudFlare or similar protection +- **WAF**: Web Application Firewall for common attack patterns + +--- + +## Responsible Disclosure Policy + +We ask that security researchers follow these guidelines: + +1. **Report Privately**: Send details to security@learnault.io first +2. **Give Us Time**: Allow reasonable time to fix before public disclosure +3. **Make Good Faith Efforts**: Avoid privacy violations, data destruction, or service interruption +4. **Provide Details**: Quality reports help us fix faster +5. **No Extortion**: We will not pay ransoms or threats + +### Disclosure Timeline + +- Day 0 - Report received and acknowledged +- Day 1-3 - Initial assessment and severity determination +- Day 4-14 - Fix development (depending on severity) +- Day 15 - Fix deployed to production +- Day 16 - Public disclosure (coordinated with reporter) + +--- + +## Known Security Considerations + +### Blockchain-Specific Risks + +| Risk | Mitigation | +| :--------------------------------- | :----------------------------------------------- | +| **Smart contract vulnerabilities** | Multiple audits, bug bounty, formal verification | +| **Private key compromise** | User education, hardware wallet support planned | +| **Network congestion** | Transaction queueing, retry logic | +| **Stellar network issues** | Monitoring, fallback procedures | + +### Platform Risks + +| Risk | Mitigation | +| :------------------- | :----------------------------------------------- | +| **Account takeover** | 2FA, suspicious activity monitoring | +| **Phishing attacks** | User education, domain monitoring | +| **Data breach** | Encryption, minimal data collection | +| **DDoS attacks** | CloudFlare protection, rate limiting | +| **Sybil attacks** | Verification requirements, rate limiting rewards | + +## Secure Development Lifecycle + +We follow these security practices in development: + +1. **Threat Modeling**: Identify risks before coding +2. **Secure Coding Guidelines**: All developers must follow +3. **Code Review**: Every PR reviewed, with security focus +4. **Automated Scanning**: SAST, DAST, dependency scanning +5. **Testing**: Unit, integration, and security tests +6. **Staging Environment**: Test in production-like environment +7. **Security Sign-off**: Required before major releases + +### Automated Security Tools + +| Tool | Purpose | Frequency | +| :------------------------- | :---------------------- | :----------- | +| **SonarQube** | Code quality & security | Every PR | +| **Snyk** | Dependency scanning | Daily | +| **ESLint Security Plugin** | JavaScript security | Every commit | +| **Trivy** | Container scanning | Every build | +| **OWASP ZAP** | DAST scanning | Weekly | + +--- + +## Emergency Contact + +For **critical security emergencies** (active attack, compromised systems, etc.): + +| Method | Contact | Availability | +| :------------------ | :--------------------- | :------------- | +| **Emergency Phone** | +1 (555) 123-4567 | 24/7 | +| **Signal** | @learnault.123 | 24/7 | +| **Email** | emergency@learnault.io | Monitored 24/7 | +| **Discord** | @security-lead (ping) | Business hours | + +For non-emergencies, please use security@learnault.io. + +_Last updated: February 2026_ + +--- + +## Policy Updates + +This security policy may be updated periodically. Significant changes will be announced via: + +- GitHub Security Advisories +- Discord #announcements channel +- Email to registered security contacts + +**Version:** 1.0 +**Last Updated:** February 18, 2026 +**Next Review:** May 2026 + +--- + +## Contact Information + +**Security Team:** security@toneflix.net + +--- + +_Thank you for helping keep Learnault and our community safe!_ 🛡️ diff --git a/docs/decisions/0001-phone-otp-authentication.md b/docs/decisions/0001-phone-otp-authentication.md index fe6d0f62..00f79da3 100644 --- a/docs/decisions/0001-phone-otp-authentication.md +++ b/docs/decisions/0001-phone-otp-authentication.md @@ -28,13 +28,13 @@ Phone-first login materially matters for Learnault's stated target market (low-c ### Threat model and mitigations -| Threat | Mitigation | -|---|---| -| SMS interception / SIM swap | OTP is a convenience login path, not a step-up for high-value actions (wallet export, payouts are out of scope for this change); code expires in 5 minutes and is single-use. | -| Brute-force code guessing | Codes are 6-digit (1,000,000 space), hashed (`sha256(phone:code)`, phone-peppered so a leaked hash table can't be replayed against another number), and the challenge locks after 5 wrong attempts, forcing a fresh request. | -| Phone number enumeration via `LOGIN` | Unauthenticated `otp/request` always returns the same generic 200 body regardless of whether the phone is registered/verified — mirrors the existing `forgotPassword` anti-enumeration pattern. No challenge row or SMS is created for unknown/unverified phones. | -| Request flooding to exhaust SMS budget | Three independent limits: per-IP (route middleware), per-phone (cooldown + hourly cap), per-device where a `deviceId` is supplied. A new request always revokes prior pending challenges for that user+purpose, so only one challenge can be outstanding at a time. | -| Phone takeover (attacker verifies a phone another user already verified) | `PHONE_VERIFICATION` checks for an existing *verified* owner of the normalized number before creating a challenge and rejects with 409. | +| Threat | Mitigation | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SMS interception / SIM swap | OTP is a convenience login path, not a step-up for high-value actions (wallet export, payouts are out of scope for this change); code expires in 5 minutes and is single-use. | +| Brute-force code guessing | Codes are 6-digit (1,000,000 space), hashed (`sha256(phone:code)`, phone-peppered so a leaked hash table can't be replayed against another number), and the challenge locks after 5 wrong attempts, forcing a fresh request. | +| Phone number enumeration via `LOGIN` | Unauthenticated `otp/request` always returns the same generic 200 body regardless of whether the phone is registered/verified — mirrors the existing `forgotPassword` anti-enumeration pattern. No challenge row or SMS is created for unknown/unverified phones. | +| Request flooding to exhaust SMS budget | Three independent limits: per-IP (route middleware), per-phone (cooldown + hourly cap), per-device where a `deviceId` is supplied. A new request always revokes prior pending challenges for that user+purpose, so only one challenge can be outstanding at a time. | +| Phone takeover (attacker verifies a phone another user already verified) | `PHONE_VERIFICATION` checks for an existing _verified_ owner of the normalized number before creating a challenge and rejects with 409. | ### Cost rationale diff --git a/docs/decisions/0003-onboarding-consent-persistence.md b/docs/decisions/0003-onboarding-consent-persistence.md index 33a7b572..89b0eda3 100644 --- a/docs/decisions/0003-onboarding-consent-persistence.md +++ b/docs/decisions/0003-onboarding-consent-persistence.md @@ -21,10 +21,10 @@ This issue is blocked by "Introduce Typed Status Enums and Transition Guards," w **Transition matrix** (`ONBOARDING_TRANSITIONS`): -| From | Allowed to | -|---|---| -| `in_progress` | `completed` | -| `completed` | *(none — terminal)* | +| From | Allowed to | +| ------------- | ------------------- | +| `in_progress` | `completed` | +| `completed` | _(none — terminal)_ | `saveStep` refuses to write once `status === 'completed'` (`already-completed` result), which is the transition guard in effect: once complete, the record cannot be reopened by any client action. @@ -42,10 +42,10 @@ Append-only: every grant or withdrawal action inserts a **new** row rather than **Transition matrix** (`CONSENT_TRANSITIONS`): -| From | Allowed to | -|---|---| -| `granted` | `withdrawn` | -| `withdrawn` | `granted` | +| From | Allowed to | +| ----------- | ----------- | +| `granted` | `withdrawn` | +| `withdrawn` | `granted` | **Required vs. optional** is enforced in `ConsentService.withdraw`, on top of the raw transition check: withdrawal requires the latest record to be `granted` (`canTransition(CONSENT_TRANSITIONS, 'granted', 'withdrawn')`), and additionally rejects the withdrawal outright if `required` is true. Granting has no such restriction — re-granting (e.g., accepting a new `policyVersion`) is always allowed and simply appends a new row, which is also how "duplicate" grants are handled: calling `grant` twice with the same purpose is not an error, it's two audit entries. diff --git a/docs/decisions/0004-profile-api.md b/docs/decisions/0004-profile-api.md index 34e35069..81802141 100644 --- a/docs/decisions/0004-profile-api.md +++ b/docs/decisions/0004-profile-api.md @@ -9,13 +9,13 @@ `UserController` carried five private helpers that were never wired to a database: -| Helper | What it did | -|---|---| -| `findUserById` | returned a hard-coded `test@example.com` / `testuser` record | -| `updateUserProfile` | echoed the request body back as a fake persisted user | -| `validatePassword` | returned `false`, unconditionally | -| `updateUserPassword` | `throw new Error('Not implemented')` | -| `updateUserWallet` | returned a fake user with the requested address | +| Helper | What it did | +| -------------------- | ------------------------------------------------------------ | +| `findUserById` | returned a hard-coded `test@example.com` / `testuser` record | +| `updateUserProfile` | echoed the request body back as a fake persisted user | +| `validatePassword` | returned `false`, unconditionally | +| `updateUserPassword` | `throw new Error('Not implemented')` | +| `updateUserWallet` | returned a fake user with the requested address | Every `/users` route was therefore either a fixture or a 500. Worse, the fixture described a schema that does not exist: `firstName`, `lastName`, `bio` and @@ -37,13 +37,13 @@ services and one schema module. ### Where the code lives -| Concern | Module | -|---|---| -| Owner-updatable field allow-list, password and wallet body schemas | `src/schemas/profile.schema.ts` | -| Profile reads/writes, the owner aggregate, the disclosure gate | `src/services/profile.service.ts` | -| Password change, wallet address | `src/services/user-account.service.ts` | -| Response shaping | `src/services/profile-serializer.ts` | -| Audit actor/context from a request | `src/utils/audit-context.ts` | +| Concern | Module | +| ------------------------------------------------------------------ | -------------------------------------- | +| Owner-updatable field allow-list, password and wallet body schemas | `src/schemas/profile.schema.ts` | +| Profile reads/writes, the owner aggregate, the disclosure gate | `src/services/profile.service.ts` | +| Password change, wallet address | `src/services/user-account.service.ts` | +| Response shaping | `src/services/profile-serializer.ts` | +| Audit actor/context from a request | `src/utils/audit-context.ts` | `UserController` holds HTTP concerns only: auth check, parse, map a result kind to a status code. It does not import the Prisma client — a rule the mock scan @@ -77,7 +77,7 @@ row is created for it. One Zod object (`updateProfileSchema`) is the allow-list, shared with `PATCH /users/me/profile` so the two routes cannot diverge on what an owner may write. `.strict()` is what enforces it: an unrecognised key is a `400` rather -than a silently dropped field. The fields deliberately *absent* are the point — +than a silently dropped field. The fields deliberately _absent_ are the point — `id`, `userId`, the `archived*` columns, and every account field (`status`, `isVerified`, `phoneVerifiedAt`, `role`, `email`, `password`, `walletAddress`). @@ -89,7 +89,7 @@ cannot be scrubbed afterwards. ### `GET /users/:id` — consent-aware public read The visibility threshold from 0002 still applies, with two further gates that can -only ever *narrow* disclosure (`isDisclosureAllowed`): +only ever _narrow_ disclosure (`isDisclosureAllowed`): 1. **Account status.** Only an `ACTIVE` account is disclosed, so deactivating drops third-party visibility immediately without the learner also having to @@ -134,7 +134,7 @@ matches the step-up behaviour in `AccountController`. Persists the learner's Stellar **public** key to `User.walletAddress`. Re-sending the address on file is a no-op with no second audit event. An address claimed by -another account is a `409`; the check is done up front for a clear error *and* +another account is a `409`; the check is done up front for a clear error _and_ again by catching Prisma's `P2002`, because two accounts claiming the same address concurrently both pass the up-front read. diff --git a/docs/domains/DOMAIN_DEFINITIONS.md b/docs/domains/DOMAIN_DEFINITIONS.md index 6fc49094..657d7587 100644 --- a/docs/domains/DOMAIN_DEFINITIONS.md +++ b/docs/domains/DOMAIN_DEFINITIONS.md @@ -11,6 +11,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/domains/identity/` **Responsibility:** + - User authentication (registration, login, logout) - Email verification and token management - JWT token generation and validation @@ -18,6 +19,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Session management **Public Interface:** + - `POST /api/v1/auth/register` - Register new user - `POST /api/v1/auth/login` - Authenticate user - `POST /api/v1/auth/logout` - End user session @@ -27,15 +29,18 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Service: `IdentityService.getUserRole(userId: string): Role` **Domain Events Published:** + - `UserRegistered(userId, email, role, timestamp)` - `EmailVerified(userId, timestamp)` - `UserLoggedIn(userId, timestamp)` **Allowed Dependencies:** + - Shared kernel (config, errors, logging, database) - Messaging infrastructure (for email delivery) **Forbidden Dependencies:** + - ❌ Cannot import from: users, learning, credentials, rewards, referrals, notifications domains - ❌ Cannot directly call services from other domains @@ -46,12 +51,14 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/domains/users/` **Responsibility:** + - User profile management (view, update) - Wallet address management - User preferences - User query and lookup (public profiles) **Public Interface:** + - `GET /api/v1/users/me` - Get current user profile - `PUT /api/v1/users/profile` - Update user profile - `PUT /api/v1/users/wallet` - Update wallet address @@ -61,15 +68,18 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Service: `UserService.getUserWallet(userId: string): WalletAddress | null` **Domain Events Published:** + - `UserProfileUpdated(userId, changes, timestamp)` - `WalletAddressUpdated(userId, walletAddress, timestamp)` - `PasswordChanged(userId, timestamp)` **Allowed Dependencies:** + - Shared kernel (config, errors, logging, database) - Identity domain (for authentication context) **Forbidden Dependencies:** + - ❌ Cannot import from: learning, credentials, rewards, referrals domains - ❌ User domain does NOT own user business logic from other domains @@ -80,6 +90,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/domains/learning/` **Responsibility:** + - Module/course content management - Module metadata (title, description, difficulty, category) - Learning progress tracking @@ -87,6 +98,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Curriculum structure **Public Interface:** + - `GET /api/v1/modules` - List available modules - `GET /api/v1/modules/:id` - Get module details - `POST /api/v1/modules` - Create module (admin) @@ -97,15 +109,18 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Service: `LearningService.recordCompletion(userId, moduleId, score): Completion` **Domain Events Published:** + - `ModuleCreated(moduleId, title, difficulty, reward, timestamp)` - `ModuleCompleted(userId, moduleId, score, timestamp)` - `ProgressUpdated(userId, moduleId, progress, timestamp)` **Allowed Dependencies:** + - Shared kernel (config, errors, logging, database) - Identity domain (for authentication context) **Forbidden Dependencies:** + - ❌ Cannot import from: rewards, credentials, referrals, notifications - ❌ Does NOT orchestrate reward distribution or credential issuance - ❌ Only publishes domain events; does not call downstream services @@ -117,6 +132,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/domains/credentials/` **Responsibility:** + - Digital credential issuance - Credential verification - On-chain credential management @@ -124,6 +140,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Credential lookup and validation **Public Interface:** + - `GET /api/v1/credentials` - List user's credentials - `GET /api/v1/credentials/:id` - Get credential details - `POST /api/v1/credentials/issue` - Issue new credential @@ -132,16 +149,19 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Service: `CredentialService.verifyCredential(credentialId): boolean` **Domain Events Published:** + - `CredentialIssued(credentialId, userId, moduleId, onChainId, timestamp)` - `CredentialRevoked(credentialId, reason, timestamp)` - `CredentialVerified(credentialId, verifierId, timestamp)` **Allowed Dependencies:** + - Shared kernel (config, errors, logging, database) - Identity domain (for authentication context) - Blockchain infrastructure (for on-chain operations) **Forbidden Dependencies:** + - ❌ Cannot import from: rewards, referrals, notifications, learning (except via events) --- @@ -151,6 +171,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/domains/rewards/` **Responsibility:** + - Reward calculation (base, streak, referral bonuses) - Reward distribution via blockchain - Balance tracking and management @@ -158,6 +179,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Transaction history **Public Interface:** + - `GET /api/v1/rewards/balance` - Get user's reward balance - `GET /api/v1/rewards/history` - Get transaction history - `POST /api/v1/rewards/withdraw` - Process withdrawal @@ -166,17 +188,20 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Service: `RewardService.getBalance(userId): Balance` **Domain Events Published:** + - `RewardClaimed(userId, moduleId, amount, breakdown, txHash, timestamp)` - `RewardDistributed(userId, amount, type, txHash, timestamp)` - `WithdrawalProcessed(userId, amount, walletAddress, txHash, timestamp)` - `BalanceUpdated(userId, available, pending, lifetime, timestamp)` **Allowed Dependencies:** + - Shared kernel (config, errors, logging, database) - Identity domain (for authentication context) - Blockchain infrastructure (for payment processing) **Forbidden Dependencies:** + - ❌ Cannot import from: learning, credentials, referrals, notifications domains - ❌ Should receive module completion via events, not direct calls @@ -187,12 +212,14 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/domains/referrals/` **Responsibility:** + - Referral code generation and management - Referral tracking and attribution - Referral bonus eligibility calculation - Referral relationship management **Public Interface:** + - `GET /api/v1/referrals/code` - Get user's referral code - `POST /api/v1/referrals/code` - Generate referral code - `POST /api/v1/referrals/apply` - Apply referral code @@ -202,15 +229,18 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Service: `ReferralService.getReferrerForUser(userId): string | null` **Domain Events Published:** + - `ReferralCodeGenerated(userId, code, timestamp)` - `ReferralApplied(referrerId, referreeId, code, timestamp)` - `ReferralBonusEligible(referrerId, referreeId, amount, reason, timestamp)` **Allowed Dependencies:** + - Shared kernel (config, errors, logging, database) - Identity domain (for authentication context) **Forbidden Dependencies:** + - ❌ Cannot import from: rewards, learning, credentials, notifications - ❌ Does NOT directly trigger reward payment; publishes events instead @@ -221,6 +251,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/domains/notifications/` **Responsibility:** + - Push notification delivery - Device token registration and management - Notification preferences management @@ -228,6 +259,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Notification templates and formatting **Public Interface:** + - `POST /api/v1/notifications/register-device` - Register device token - `PUT /api/v1/notifications/preferences` - Update notification preferences - `GET /api/v1/notifications/preferences` - Get notification preferences @@ -235,16 +267,19 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Service: `NotificationService.sendNotification(userId, type, title, body): void` **Domain Events Published:** + - `NotificationSent(userId, type, title, timestamp)` - `NotificationFailed(userId, type, error, timestamp)` - `DeviceTokenRegistered(userId, token, platform, timestamp)` **Allowed Dependencies:** + - Shared kernel (config, errors, logging, database) - Identity domain (for authentication context) - External: Firebase Admin SDK **Forbidden Dependencies:** + - ❌ Cannot import from: rewards, learning, credentials, referrals, users - ❌ Should be triggered by events or explicit calls, not direct imports @@ -255,6 +290,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/domains/organizations/` **Responsibility:** + - Employer/organization management - Organization profile and settings - Organization-learner relationships @@ -262,6 +298,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Organization verification **Public Interface:** + - `GET /api/v1/employer` - List employers - `GET /api/v1/employer/:id` - Get employer details - `POST /api/v1/employer` - Create employer (admin) @@ -269,15 +306,18 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Service: `OrganizationService.getOrganization(orgId): Organization` **Domain Events Published:** + - `OrganizationCreated(orgId, name, timestamp)` - `OrganizationUpdated(orgId, changes, timestamp)` - `OrganizationVerified(orgId, verifierId, timestamp)` **Allowed Dependencies:** + - Shared kernel (config, errors, logging, database) - Identity domain (for authentication context) **Forbidden Dependencies:** + - ❌ Cannot import from other business domains --- @@ -287,6 +327,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/domains/sync/` **Responsibility:** + - Client-server synchronization - Event deduplication (idempotency) - Conflict resolution @@ -294,21 +335,25 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Sync event logging and replay **Public Interface:** + - `POST /api/v1/sync/events` - Submit sync events - `GET /api/v1/sync/status` - Get sync status - Service: `SyncService.processSyncEvent(event): SyncResult` **Domain Events Published:** + - `SyncEventReceived(userId, eventType, deviceId, timestamp)` - `SyncEventApplied(userId, eventType, timestamp)` - `SyncConflictDetected(userId, eventType, conflict, timestamp)` **Allowed Dependencies:** + - Shared kernel (config, errors, logging, database) - Identity domain (for authentication context) - May coordinate with other domains via events **Forbidden Dependencies:** + - ❌ Should not have hard dependencies on business domains - ❌ Coordinates via events and interfaces, not direct imports @@ -319,6 +364,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/infrastructure/blockchain/` **Responsibility:** + - Stellar network integration - Soroban smart contract interaction - Payment processing @@ -327,20 +373,24 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Wallet management **Public Interface:** + - Service: `BlockchainService.sendPayment(params): PaymentResult` - Service: `BlockchainService.getBalance(address): Balance` - Service: `BlockchainService.submitTransaction(tx): TxResult` - Service: `BlockchainService.issueOnChainCredential(data): OnChainId` **Used By:** + - Rewards domain (for payment distribution) - Credentials domain (for on-chain credential issuance) **Allowed Dependencies:** + - Shared kernel (config, errors, logging) - External: Stellar SDK, Soroban SDK **Forbidden Dependencies:** + - ❌ Cannot import from any business domain - ❌ Pure infrastructure; no business logic @@ -351,6 +401,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, **Location:** `src/shared/` **Components:** + 1. **Configuration** (`src/shared/config/`) - Database client, environment variables, logging, external service configs @@ -372,6 +423,7 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Event bus/dispatcher (future) **Allowed Dependencies:** + - External libraries only - No business domain imports @@ -380,9 +432,11 @@ This document defines the bounded contexts, responsibilities, public interfaces, ## Orchestration Ownership ### Module Completion Orchestration + **Owner:** Learning Content Domain **Flow:** + 1. Learning domain receives `POST /modules/:id/complete` 2. Learning domain records completion 3. Learning domain publishes `ModuleCompleted` event @@ -392,9 +446,11 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Notifications domain → sends notification ### Reward Distribution Orchestration + **Owner:** Rewards Domain **Flow:** + 1. Rewards domain receives `ModuleCompleted` event OR direct `/rewards/claim` request 2. Rewards domain calculates reward (queries referral status via service/event) 3. Rewards domain processes payment via blockchain infrastructure @@ -404,9 +460,11 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Referrals domain → processes referral bonus eligibility ### Credential Issuance Orchestration + **Owner:** Credentials Domain **Flow:** + 1. Credentials domain receives `ModuleCompleted` event OR direct `/credentials/issue` request 2. Credentials domain validates completion 3. Credentials domain issues credential via blockchain infrastructure @@ -415,9 +473,11 @@ This document defines the bounded contexts, responsibilities, public interfaces, - Notifications domain → sends credential notification ### User Registration Orchestration + **Owner:** Identity Domain **Flow:** + 1. Identity domain receives `POST /auth/register` 2. Identity domain creates user record 3. Identity domain generates verification token @@ -452,11 +512,13 @@ This document defines the bounded contexts, responsibilities, public interfaces, ### Cross-Domain Communication **Preferred Methods:** + 1. **Domain Events** (async, decoupled) - Preferred 2. **Public Service Interfaces** (sync, when necessary) - Use sparingly 3. **Database queries** (read-only, via repository) - Acceptable for queries **Anti-Patterns:** + - Direct controller-to-controller calls - Direct service-to-service imports across domains - Sharing internal domain models across boundaries diff --git a/docs/domains/DOMAIN_MAP.md b/docs/domains/DOMAIN_MAP.md index ee31004a..57d4da25 100644 --- a/docs/domains/DOMAIN_MAP.md +++ b/docs/domains/DOMAIN_MAP.md @@ -121,36 +121,36 @@ Infrastructure ❌→ Any Domain ## Communication Matrix -| Source Domain | Target Domain | Communication Type | Purpose | -|---------------|---------------|-------------------|---------| -| Identity | Shared/Messaging | Service Call | Email delivery | -| Identity | Users | Domain Event | Profile init | -| Identity | Referrals | Domain Event | Referral check | -| Learning | Rewards | Domain Event | Trigger reward | -| Learning | Credentials | Domain Event | Trigger credential | -| Learning | Referrals | Domain Event | Bonus check | -| Rewards | Blockchain Infra | Service Call | Payment | -| Rewards | Notifications | Domain Event | Reward notification | -| Credentials | Blockchain Infra | Service Call | On-chain store | -| Credentials | Notifications | Domain Event | Credential notification | -| Referrals | Rewards | Domain Event | Bonus eligible | -| All | Shared Kernel | Direct Import | Config, errors, utils | +| Source Domain | Target Domain | Communication Type | Purpose | +| ------------- | ---------------- | ------------------ | ----------------------- | +| Identity | Shared/Messaging | Service Call | Email delivery | +| Identity | Users | Domain Event | Profile init | +| Identity | Referrals | Domain Event | Referral check | +| Learning | Rewards | Domain Event | Trigger reward | +| Learning | Credentials | Domain Event | Trigger credential | +| Learning | Referrals | Domain Event | Bonus check | +| Rewards | Blockchain Infra | Service Call | Payment | +| Rewards | Notifications | Domain Event | Reward notification | +| Credentials | Blockchain Infra | Service Call | On-chain store | +| Credentials | Notifications | Domain Event | Credential notification | +| Referrals | Rewards | Domain Event | Bonus eligible | +| All | Shared Kernel | Direct Import | Config, errors, utils | --- ## Domain Responsibilities Matrix -| Domain | Core Responsibility | API Endpoints | Database Models | External Dependencies | -|--------|-------------------|---------------|-----------------|---------------------| -| **Identity** | Authentication, registration, verification | `/auth/register`, `/auth/login`, `/auth/verify-email`, `/auth/logout` | User, VerificationToken, EmailDelivery | None | -| **Users** | Profile management, wallet addresses | `/users/me`, `/users/profile`, `/users/wallet`, `/users/:id` | User (read/update) | Identity (auth) | -| **Learning** | Modules, completions, progress | `/modules`, `/modules/:id`, `/modules/:id/complete` | Module, Completion | Identity (auth) | -| **Credentials** | Credential issuance, verification | `/credentials`, `/credentials/issue`, `/credentials/:id/verify` | Credential | Blockchain | -| **Rewards** | Reward calculation, distribution, withdrawal | `/rewards/balance`, `/rewards/history`, `/rewards/withdraw`, `/rewards/claim` | Transaction | Blockchain | -| **Referrals** | Referral tracking, code generation | `/referrals/code`, `/referrals/apply`, `/referrals/stats` | ReferralCode, Referral | None | -| **Notifications** | Push notifications, device tokens | `/notifications/register-device`, `/notifications/preferences` | NotificationLog, DeviceToken, NotificationPreference | Firebase | -| **Organizations** | Employer management | `/employer`, `/employer/:id` | (Future models) | None | -| **Sync** | Client-server sync, idempotency | `/sync/events`, `/sync/status` | SyncEvent | None | +| Domain | Core Responsibility | API Endpoints | Database Models | External Dependencies | +| ----------------- | -------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------- | --------------------- | +| **Identity** | Authentication, registration, verification | `/auth/register`, `/auth/login`, `/auth/verify-email`, `/auth/logout` | User, VerificationToken, EmailDelivery | None | +| **Users** | Profile management, wallet addresses | `/users/me`, `/users/profile`, `/users/wallet`, `/users/:id` | User (read/update) | Identity (auth) | +| **Learning** | Modules, completions, progress | `/modules`, `/modules/:id`, `/modules/:id/complete` | Module, Completion | Identity (auth) | +| **Credentials** | Credential issuance, verification | `/credentials`, `/credentials/issue`, `/credentials/:id/verify` | Credential | Blockchain | +| **Rewards** | Reward calculation, distribution, withdrawal | `/rewards/balance`, `/rewards/history`, `/rewards/withdraw`, `/rewards/claim` | Transaction | Blockchain | +| **Referrals** | Referral tracking, code generation | `/referrals/code`, `/referrals/apply`, `/referrals/stats` | ReferralCode, Referral | None | +| **Notifications** | Push notifications, device tokens | `/notifications/register-device`, `/notifications/preferences` | NotificationLog, DeviceToken, NotificationPreference | Firebase | +| **Organizations** | Employer management | `/employer`, `/employer/:id` | (Future models) | None | +| **Sync** | Client-server sync, idempotency | `/sync/events`, `/sync/status` | SyncEvent | None | --- @@ -244,14 +244,14 @@ src/ ## Orchestration Ownership -| Workflow | Owner Domain | Responsibility | -|----------|-------------|----------------| -| **User Registration** | Identity | Create user, generate token, queue email, publish event | -| **Module Completion** | Learning | Record completion, publish event | -| **Reward Distribution** | Rewards | Calculate, pay, record transaction (event handler) | -| **Credential Issuance** | Credentials | Issue, store on-chain (event handler) | -| **Referral Bonus** | Referrals | Check eligibility, publish event (event handler) | -| **Notification Delivery** | Notifications | Queue, deliver, retry (event handler) | +| Workflow | Owner Domain | Responsibility | +| ------------------------- | ------------- | ------------------------------------------------------- | +| **User Registration** | Identity | Create user, generate token, queue email, publish event | +| **Module Completion** | Learning | Record completion, publish event | +| **Reward Distribution** | Rewards | Calculate, pay, record transaction (event handler) | +| **Credential Issuance** | Credentials | Issue, store on-chain (event handler) | +| **Referral Bonus** | Referrals | Check eligibility, publish event (event handler) | +| **Notification Delivery** | Notifications | Queue, deliver, retry (event handler) | ### Orchestration Rules @@ -352,6 +352,7 @@ pnpm test integrations/architecture/ # Run boundary tests ``` Tests verify: + - No forbidden cross-domain imports - No circular dependencies - Infrastructure isolation @@ -373,13 +374,13 @@ Tests verify: ## Key Metrics -| Metric | Current | Target | -|--------|---------|--------| -| **Domain Boundaries** | 0 (flat structure) | 9 (enforced) | +| Metric | Current | Target | +| -------------------------- | --------------------------- | ---------------- | +| **Domain Boundaries** | 0 (flat structure) | 9 (enforced) | | **Forbidden Dependencies** | Many (direct service calls) | 0 (event-driven) | -| **Architecture Tests** | 0 | 5+ test suites | -| **Domain Documentation** | Minimal | Complete | -| **Circular Dependencies** | Unknown | 0 (tested) | +| **Architecture Tests** | 0 | 5+ test suites | +| **Domain Documentation** | Minimal | Complete | +| **Circular Dependencies** | Unknown | 0 (tested) | --- diff --git a/docs/domains/IMPLEMENTATION_SUMMARY.md b/docs/domains/IMPLEMENTATION_SUMMARY.md index 672e3012..0902454b 100644 --- a/docs/domains/IMPLEMENTATION_SUMMARY.md +++ b/docs/domains/IMPLEMENTATION_SUMMARY.md @@ -19,12 +19,14 @@ This document summarizes the implementation of clearly defined, enforceable doma **File:** `docs/domains/DOMAIN_INVENTORY.md` **Contents:** + - Identified 10 business domains from current codebase - Documented shared kernel components - Mapped cross-domain dependencies (both direct and implicit) - Identified orchestration concerns requiring ownership resolution **Key Findings:** + - Strong dependencies detected: AuthController → EmailService, RewardService → StellarService, RewardService → NotificationService - Database relationships create implicit dependencies - Orchestration ownership unclear for module completion, reward claim, and credential issuance flows @@ -36,6 +38,7 @@ This document summarizes the implementation of clearly defined, enforceable doma **File:** `docs/domains/DOMAIN_DEFINITIONS.md` **Contents:** + - Complete definitions for all 10 domains: 1. Identity & Access 2. User Management @@ -49,6 +52,7 @@ This document summarizes the implementation of clearly defined, enforceable doma 10. Blockchain Integration (Infrastructure) **For Each Domain:** + - Clear responsibility statement - Public API interfaces (HTTP endpoints and service methods) - Domain events published @@ -56,6 +60,7 @@ This document summarizes the implementation of clearly defined, enforceable doma - Forbidden dependencies **Orchestration Ownership:** + - User Registration → Identity Domain - Module Completion → Learning Domain - Reward Distribution → Rewards Domain (event handler) @@ -63,6 +68,7 @@ This document summarizes the implementation of clearly defined, enforceable doma - Referral Bonus → Referrals Domain (event handler) **Import Rules:** + - ✅ Allowed: Domain → Shared Kernel, Domain → Infrastructure, Domain → Identity (auth) - ❌ Forbidden: Direct cross-domain service imports, circular dependencies, Infrastructure → Domain @@ -73,6 +79,7 @@ This document summarizes the implementation of clearly defined, enforceable doma **File:** `docs/domains/SHARED_KERNEL.md` **Contents:** + - Complete shared kernel structure definition - 6 core components: 1. Configuration (database, env, logger) @@ -83,11 +90,13 @@ This document summarizes the implementation of clearly defined, enforceable doma 6. Messaging Infrastructure (email, webhook, events) **Import Rules:** + - ✅ All domains can import from shared kernel - ❌ Shared kernel cannot import from any domain - ❌ Shared kernel contains no business logic **Migration Path:** + - Mapped current files to target shared kernel structure - Documented transformation from `src/config/` → `src/shared/config/`, etc. @@ -98,6 +107,7 @@ This document summarizes the implementation of clearly defined, enforceable doma **File:** `integrations/architecture/domain-boundaries.test.ts` **Test Suites:** + 1. **Forbidden Cross-Domain Imports** - Tests that domains don't import from forbidden domains 2. **Infrastructure Layer Rules** - Tests that infrastructure doesn't import from business domains 3. **Shared Kernel Rules** - Tests that shared kernel doesn't import from any domain @@ -105,6 +115,7 @@ This document summarizes the implementation of clearly defined, enforceable doma 5. **File Organization** - Tests that every file maps to a domain or shared kernel **How It Works:** + - Scans all TypeScript files in `src/` - Extracts import statements using regex - Maps files to domains based on path @@ -112,6 +123,7 @@ This document summarizes the implementation of clearly defined, enforceable doma - Reports violations with file paths and import details **Run Tests:** + ```bash pnpm test integrations/architecture/domain-boundaries.test.ts ``` @@ -123,6 +135,7 @@ pnpm test integrations/architecture/domain-boundaries.test.ts **File:** `docs/domains/REQUEST_AND_EVENT_FLOWS.md` **Documented Flows:** + 1. User Registration Flow 2. User Login Flow 3. Module Completion Flow (with event cascade) @@ -133,6 +146,7 @@ pnpm test integrations/architecture/domain-boundaries.test.ts 8. Notification Delivery Flow **For Each Flow:** + - Request flow diagram (synchronous) - Domain event flow diagram (asynchronous) - Responsibility matrix showing which domain owns what @@ -140,6 +154,7 @@ pnpm test integrations/architecture/domain-boundaries.test.ts - Error handling considerations **Key Patterns:** + - Event-driven communication for cross-domain coordination - Outbox pattern for email and webhook delivery - Idempotent event handlers @@ -152,6 +167,7 @@ pnpm test integrations/architecture/domain-boundaries.test.ts **File:** `docs/ARCHITECTURE.md` **Contents:** + - System structure overview - Domain boundary definitions - Dependency rules (allowed and forbidden) @@ -172,6 +188,7 @@ pnpm test integrations/architecture/domain-boundaries.test.ts **File:** `docs/domains/DOMAIN_MAP.md` **Contents:** + - Visual domain architecture diagram - Domain dependency graph - Communication matrix (source → target → type) @@ -191,6 +208,7 @@ pnpm test integrations/architecture/domain-boundaries.test.ts ### ✅ Every source file maps to one domain or the shared kernel **Evidence:** + - Domain map created with clear ownership - 10 domains identified with boundaries - Shared kernel components specified @@ -199,6 +217,7 @@ pnpm test integrations/architecture/domain-boundaries.test.ts ### ✅ Forbidden and circular dependencies fail an automated check **Evidence:** + - Architecture test suite created (`domain-boundaries.test.ts`) - Tests check forbidden imports, circular dependencies, infrastructure isolation - Test suites cover: @@ -211,6 +230,7 @@ pnpm test integrations/architecture/domain-boundaries.test.ts ### ✅ Cross-domain ownership is unambiguous **Evidence:** + - Domain definitions document shows clear ownership for each feature - Orchestration ownership documented for: - User registration (Identity) @@ -225,6 +245,7 @@ pnpm test integrations/architecture/domain-boundaries.test.ts ### ✅ Architecture checks and build pass **Evidence:** + - Architecture test file created and is executable - Tests will pass once migration is complete (no forbidden dependencies) - Current state documented; tests ready for validation during refactoring @@ -235,7 +256,9 @@ pnpm test integrations/architecture/domain-boundaries.test.ts ## Verification Evidence ### Domain Map + See `docs/domains/DOMAIN_MAP.md` for: + - Visual architecture diagrams - Dependency graphs - Communication matrix @@ -244,13 +267,16 @@ See `docs/domains/DOMAIN_MAP.md` for: - Current vs. target state comparison ### Architecture Tests + See `integrations/architecture/domain-boundaries.test.ts` for: + - Automated boundary enforcement - Import rule validation - Circular dependency detection - File organization checks ### Test Execution + ```bash # Install dependencies first pnpm install @@ -311,29 +337,34 @@ integrations/ ## Key Achievements ### 1. Clear Domain Boundaries + - 10 business domains identified and documented - Each domain has clear responsibility - Public interfaces defined (API + service methods) - Domain events specified for async communication ### 2. Enforced Dependencies + - Forbidden dependency rules documented - Architecture tests created to enforce rules - Import patterns specified (allowed and forbidden) - Circular dependency detection implemented ### 3. Orchestration Clarity + - Each major workflow has clear owner - Event flow maps show coordination - Responsibility matrix eliminates ambiguity ### 4. Comprehensive Documentation + - 7 documentation files created - Visual diagrams and dependency graphs - Migration path from current to target state - Testing strategy for validation ### 5. Validation Strategy + - Static analysis (ESLint - future) - Architecture tests (automated) - Code review guidelines @@ -344,6 +375,7 @@ integrations/ ## Impact ### Before + - ❌ Flat file structure with no clear boundaries - ❌ Direct service-to-service calls across concerns - ❌ Tight coupling between unrelated features @@ -351,6 +383,7 @@ integrations/ - ❌ No automated boundary enforcement ### After + - ✅ 10 well-defined domain boundaries - ✅ Clear communication patterns (events) - ✅ Loose coupling via event-driven architecture @@ -362,27 +395,32 @@ integrations/ ## Next Steps (Future Phases) ### Phase 1: Shared Kernel Extraction + - Create `src/shared/` folder structure - Move config, errors, middleware, utils - Update all imports to use shared kernel ### Phase 2: Domain Folder Structure + - Create `src/domains/[domain]/` folders - Move controllers, services, routes, types - Update imports to use domain paths ### Phase 3: Event Infrastructure + - Implement event bus - Define event types and schemas - Create event handlers for each domain ### Phase 4: Refactor to Events + - Replace direct service calls with event publishing - Implement event handlers - Remove forbidden dependencies - Validate with architecture tests ### Phase 5: Repository Layer + - Add repository pattern for data access - Abstract Prisma behind repositories - Improve testability @@ -392,9 +430,11 @@ integrations/ ## Dependencies & Blockers ### Dependencies + - None (Phase 0 is independent) ### Blocks + - `Feature: Standardize API Contracts Pagination and Versioning` - `Feature: Add Transaction Outbox and Job Delivery Foundation` @@ -405,6 +445,7 @@ These features will benefit from the domain boundaries defined here. ## Testing ### Architecture Tests + ```bash # Run all tests pnpm test @@ -417,6 +458,7 @@ pnpm test:watch integrations/architecture/ ``` ### Expected Behavior + - Tests document the target state - Tests will initially detect violations (current flat structure) - Tests will pass once refactoring is complete @@ -426,15 +468,15 @@ pnpm test:watch integrations/architecture/ ## Metrics -| Metric | Value | -|--------|-------| -| Domains Identified | 10 | -| Documentation Files | 7 | -| Architecture Test Suites | 5 | -| Domain Events Defined | 20+ | -| Request Flows Documented | 8 | -| Commits Made | 4 | -| Lines of Documentation | 3000+ | +| Metric | Value | +| ------------------------ | ----- | +| Domains Identified | 10 | +| Documentation Files | 7 | +| Architecture Test Suites | 5 | +| Domain Events Defined | 20+ | +| Request Flows Documented | 8 | +| Commits Made | 4 | +| Lines of Documentation | 3000+ | --- diff --git a/docs/domains/README.md b/docs/domains/README.md index 53abc0a1..756f6230 100644 --- a/docs/domains/README.md +++ b/docs/domains/README.md @@ -6,31 +6,34 @@ This directory contains comprehensive documentation for the Learnault API domain ## Quick Links -| Document | Purpose | -|----------|---------| +| Document | Purpose | +| --------------------------------------------------------- | ---------------------------------------------------------------- | | **[Implementation Summary](./IMPLEMENTATION_SUMMARY.md)** | 📋 Start here - Overview of deliverables and acceptance criteria | -| **[Domain Map](./DOMAIN_MAP.md)** | 🗺️ Visual diagrams, dependency graphs, and metrics | -| **[Domain Definitions](./DOMAIN_DEFINITIONS.md)** | 📚 Complete specifications for all 10 domains | -| **[Domain Inventory](./DOMAIN_INVENTORY.md)** | 🔍 Analysis of current codebase and dependencies | -| **[Shared Kernel](./SHARED_KERNEL.md)** | 🛠️ Shared infrastructure specification | -| **[Request & Event Flows](./REQUEST_AND_EVENT_FLOWS.md)** | 🔄 Detailed flow diagrams for all features | -| **[Architecture](../ARCHITECTURE.md)** | 🏗️ Main architecture documentation | +| **[Domain Map](./DOMAIN_MAP.md)** | 🗺️ Visual diagrams, dependency graphs, and metrics | +| **[Domain Definitions](./DOMAIN_DEFINITIONS.md)** | 📚 Complete specifications for all 10 domains | +| **[Domain Inventory](./DOMAIN_INVENTORY.md)** | 🔍 Analysis of current codebase and dependencies | +| **[Shared Kernel](./SHARED_KERNEL.md)** | 🛠️ Shared infrastructure specification | +| **[Request & Event Flows](./REQUEST_AND_EVENT_FLOWS.md)** | 🔄 Detailed flow diagrams for all features | +| **[Architecture](../ARCHITECTURE.md)** | 🏗️ Main architecture documentation | --- ## Reading Guide ### For New Developers + 1. Start with **[Domain Map](./DOMAIN_MAP.md)** for visual overview 2. Read **[Domain Definitions](./DOMAIN_DEFINITIONS.md)** to understand boundaries 3. Check **[Request & Event Flows](./REQUEST_AND_EVENT_FLOWS.md)** for feature workflows ### For Architecture Review + 1. Read **[Implementation Summary](./IMPLEMENTATION_SUMMARY.md)** for acceptance criteria 2. Review **[Domain Definitions](./DOMAIN_DEFINITIONS.md)** for boundary rules 3. Examine **[Domain Map](./DOMAIN_MAP.md)** for dependency graphs ### For Implementation + 1. Read **[Shared Kernel](./SHARED_KERNEL.md)** for infrastructure setup 2. Check **[Domain Definitions](./DOMAIN_DEFINITIONS.md)** for your domain's responsibilities 3. Follow **[Request & Event Flows](./REQUEST_AND_EVENT_FLOWS.md)** for integration patterns @@ -104,6 +107,7 @@ pnpm test integrations/architecture/domain-boundaries.test.ts ``` Tests verify: + - No forbidden cross-domain imports - No circular dependencies - Infrastructure isolation @@ -117,6 +121,7 @@ Tests verify: **Phase 0:** ✅ Complete - Documentation & Planning **Deliverables:** + - ✅ Domain inventory - ✅ Domain definitions with boundaries - ✅ Shared kernel specification diff --git a/docs/domains/REQUEST_AND_EVENT_FLOWS.md b/docs/domains/REQUEST_AND_EVENT_FLOWS.md index 0a96b34a..200445c6 100644 --- a/docs/domains/REQUEST_AND_EVENT_FLOWS.md +++ b/docs/domains/REQUEST_AND_EVENT_FLOWS.md @@ -21,6 +21,7 @@ This document maps the key request flows and domain event propagation patterns a ## User Registration Flow ### Trigger + `POST /api/v1/auth/register` ### Request Flow @@ -60,19 +61,20 @@ Identity Domain ### Responsibilities -| Domain | Responsibility | -|--------|---------------| -| Identity | User creation, token generation, email queueing, event publication | -| Messaging Infrastructure | Email delivery (outbox pattern) | -| Users | Profile initialization (reacts to event) | -| Referrals | Referral application (reacts to event) | -| Notifications | Preference initialization (reacts to event) | +| Domain | Responsibility | +| ------------------------ | ------------------------------------------------------------------ | +| Identity | User creation, token generation, email queueing, event publication | +| Messaging Infrastructure | Email delivery (outbox pattern) | +| Users | Profile initialization (reacts to event) | +| Referrals | Referral application (reacts to event) | +| Notifications | Preference initialization (reacts to event) | --- ## User Login Flow ### Trigger + `POST /api/v1/auth/login` ### Request Flow @@ -103,16 +105,17 @@ Identity Domain ### Responsibilities -| Domain | Responsibility | -|--------|---------------| -| Identity | Authentication, JWT generation | -| Users | User data retrieval (via database query) | +| Domain | Responsibility | +| -------- | ---------------------------------------- | +| Identity | Authentication, JWT generation | +| Users | User data retrieval (via database query) | --- ## Module Completion Flow ### Trigger + `POST /api/v1/modules/:id/complete` ### Request Flow @@ -168,6 +171,7 @@ Learning Domain **Owner:** Learning Domain The Learning domain ONLY records the completion and publishes the event. It does NOT: + - Calculate or distribute rewards - Issue credentials - Send notifications @@ -176,20 +180,21 @@ All downstream actions are decoupled via event handlers. ### Responsibilities -| Domain | Responsibility | -|--------|---------------| -| Learning | Completion recording, event publication | -| Rewards | Reward calculation and distribution (event handler) | -| Credentials | Credential issuance (event handler) | -| Referrals | Referral bonus eligibility (event handler) | -| Notifications | User notifications (event handler) | -| Blockchain Infrastructure | Payment processing, on-chain credential storage | +| Domain | Responsibility | +| ------------------------- | --------------------------------------------------- | +| Learning | Completion recording, event publication | +| Rewards | Reward calculation and distribution (event handler) | +| Credentials | Credential issuance (event handler) | +| Referrals | Referral bonus eligibility (event handler) | +| Notifications | User notifications (event handler) | +| Blockchain Infrastructure | Payment processing, on-chain credential storage | --- ## Reward Claim Flow ### Trigger + `POST /api/v1/rewards/claim` or `ModuleCompleted` event ### Request Flow (Direct API Call) @@ -228,18 +233,19 @@ Rewards Domain ### Responsibilities -| Domain | Responsibility | -|--------|---------------| -| Rewards | Reward calculation, payment processing, transaction recording | -| Blockchain Infrastructure | Payment execution | -| Notifications | User notification (event handler) | -| Referrals | Bonus tracking (event handler) | +| Domain | Responsibility | +| ------------------------- | ------------------------------------------------------------- | +| Rewards | Reward calculation, payment processing, transaction recording | +| Blockchain Infrastructure | Payment execution | +| Notifications | User notification (event handler) | +| Referrals | Bonus tracking (event handler) | --- ## Credential Issuance Flow ### Trigger + `POST /api/v1/credentials/issue` or `ModuleCompleted` event ### Request Flow (Direct API Call) @@ -272,17 +278,18 @@ Credentials Domain ### Responsibilities -| Domain | Responsibility | -|--------|---------------| -| Credentials | Credential issuance, on-chain storage, event publication | -| Blockchain Infrastructure | On-chain credential creation | -| Notifications | User notification (event handler) | +| Domain | Responsibility | +| ------------------------- | -------------------------------------------------------- | +| Credentials | Credential issuance, on-chain storage, event publication | +| Blockchain Infrastructure | On-chain credential creation | +| Notifications | User notification (event handler) | --- ## Referral Application Flow ### Trigger + `POST /api/v1/referrals/apply` ### Request Flow @@ -331,16 +338,17 @@ Rewards Domain (event handler) ### Responsibilities -| Domain | Responsibility | -|--------|---------------| +| Domain | Responsibility | +| --------- | -------------------------------------------------- | | Referrals | Referral tracking, bonus eligibility determination | -| Rewards | Bonus payment processing (event handler) | +| Rewards | Bonus payment processing (event handler) | --- ## Withdrawal Flow ### Trigger + `POST /api/v1/rewards/withdraw` ### Request Flow @@ -373,23 +381,25 @@ Rewards Domain ### Error Handling If blockchain payment fails: + - Transaction marked as `failed` - Balance remains unchanged - User can retry ### Responsibilities -| Domain | Responsibility | -|--------|---------------| -| Rewards | Balance validation, transaction management | -| Blockchain Infrastructure | Payment execution | -| Notifications | User notification (event handler) | +| Domain | Responsibility | +| ------------------------- | ------------------------------------------ | +| Rewards | Balance validation, transaction management | +| Blockchain Infrastructure | Payment execution | +| Notifications | User notification (event handler) | --- ## Notification Delivery Flow ### Trigger + Domain events or direct API calls ### Event-Driven Flow @@ -429,10 +439,10 @@ Client ### Responsibilities -| Domain | Responsibility | -|--------|---------------| +| Domain | Responsibility | +| ------------- | -------------------------------------------------------- | | Notifications | Delivery management, preference enforcement, retry logic | -| Firebase | Push notification infrastructure | +| Firebase | Push notification infrastructure | --- @@ -464,17 +474,17 @@ All domain events will follow this schema: ```typescript interface DomainEvent { - eventId: string // UUID - eventType: string // e.g., "UserRegistered" - aggregateId: string // e.g., userId - aggregateType: string // e.g., "User" - payload: object // Event-specific data + eventId: string // UUID + eventType: string // e.g., "UserRegistered" + aggregateId: string // e.g., userId + aggregateType: string // e.g., "User" + payload: object // Event-specific data timestamp: Date - version: number // For event versioning + version: number // For event versioning metadata?: { correlationId?: string // For tracing - causationId?: string // Event that caused this event - userId?: string // Actor who triggered + causationId?: string // Event that caused this event + userId?: string // Actor who triggered } } ``` @@ -490,25 +500,25 @@ Target state: Event-driven communication via domain events **Phase 1:** Document flows (this document) **Phase 2:** Implement event infrastructure **Phase 3:** Refactor to event-driven architecture -**Phase 4:** Remove direct cross-domain service calls +**Phase 4:** Remove direct cross-domain service calls --- ## Summary Table: Domain Interactions -| Source Domain | Target Domain | Interaction Type | Purpose | -|--------------|---------------|------------------|---------| -| Identity | Messaging Infra | Service Call | Email delivery | -| Identity | Users | Domain Event | Profile initialization | -| Identity | Referrals | Domain Event | Referral application | -| Learning | Rewards | Domain Event | Reward distribution | -| Learning | Credentials | Domain Event | Credential issuance | -| Learning | Referrals | Domain Event | Referral bonus check | -| Rewards | Blockchain Infra | Service Call | Payment processing | -| Rewards | Notifications | Domain Event | Reward notification | -| Credentials | Blockchain Infra | Service Call | On-chain storage | -| Credentials | Notifications | Domain Event | Credential notification | -| All Domains | Shared Kernel | Direct Import | Config, errors, middleware, utils | +| Source Domain | Target Domain | Interaction Type | Purpose | +| ------------- | ---------------- | ---------------- | --------------------------------- | +| Identity | Messaging Infra | Service Call | Email delivery | +| Identity | Users | Domain Event | Profile initialization | +| Identity | Referrals | Domain Event | Referral application | +| Learning | Rewards | Domain Event | Reward distribution | +| Learning | Credentials | Domain Event | Credential issuance | +| Learning | Referrals | Domain Event | Referral bonus check | +| Rewards | Blockchain Infra | Service Call | Payment processing | +| Rewards | Notifications | Domain Event | Reward notification | +| Credentials | Blockchain Infra | Service Call | On-chain storage | +| Credentials | Notifications | Domain Event | Credential notification | +| All Domains | Shared Kernel | Direct Import | Config, errors, middleware, utils | --- @@ -528,10 +538,12 @@ registry.register({ version: 1, eventType: 'ModuleCompleted', validate: async (payload) => { - await z.object({ - completionId: z.string().uuid(), - userId: z.string().uuid(), - }).parseAsync(payload) + await z + .object({ + completionId: z.string().uuid(), + userId: z.string().uuid(), + }) + .parseAsync(payload) }, }) ``` diff --git a/docs/domains/SHARED_KERNEL.md b/docs/domains/SHARED_KERNEL.md index b9c82446..3292396c 100644 --- a/docs/domains/SHARED_KERNEL.md +++ b/docs/domains/SHARED_KERNEL.md @@ -50,23 +50,29 @@ src/shared/ ## 1. Configuration (`shared/config/`) ### Purpose + Centralized configuration management for database, environment, logging, and external services. ### Components #### `database.ts` + ```typescript // Exports configured Prisma client import { PrismaClient } from '@prisma/client' export const prisma = new PrismaClient({ - log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + log: + process.env.NODE_ENV === 'development' + ? ['query', 'error', 'warn'] + : ['error'], }) export default prisma ``` #### `env.ts` + ```typescript // Validates and exports environment variables import { z } from 'zod' @@ -85,6 +91,7 @@ export const env = envSchema.parse(process.env) ``` #### `logger.ts` + ```typescript // Exports configured logger (winston, pino, etc.) import winston from 'winston' @@ -99,6 +106,7 @@ export default logger ``` ### Usage Rules + - All domains MUST use shared config, never read `process.env` directly - Configuration is read-only; domains cannot modify shared config - Domain-specific configuration goes in domain folder, imports from shared @@ -108,18 +116,20 @@ export default logger ## 2. Error Handling (`shared/errors/`) ### Purpose + Standardized error types and error handling across the application. ### Components #### `types.ts` + ```typescript export class AppError extends Error { constructor( public message: string, public statusCode: number, public code: string, - public details?: any + public details?: any, ) { super(message) this.name = this.constructor.name @@ -132,7 +142,7 @@ export class NotFoundError extends AppError { super( `${resource}${id ? ` with id ${id}` : ''} not found`, 404, - 'NOT_FOUND' + 'NOT_FOUND', ) } } @@ -169,18 +179,19 @@ export class BadRequestError extends AppError { ``` #### `codes.ts` + ```typescript export const ERROR_CODES = { // General INTERNAL_ERROR: 'INTERNAL_ERROR', NOT_FOUND: 'NOT_FOUND', VALIDATION_ERROR: 'VALIDATION_ERROR', - + // Auth UNAUTHORIZED: 'UNAUTHORIZED', INVALID_CREDENTIALS: 'INVALID_CREDENTIALS', TOKEN_EXPIRED: 'TOKEN_EXPIRED', - + // Business logic INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', ALREADY_CLAIMED: 'ALREADY_CLAIMED', @@ -189,6 +200,7 @@ export const ERROR_CODES = { ``` ### Usage Rules + - All domains MUST throw shared error types - Never throw generic `Error`; always extend `AppError` - Domain-specific errors can extend shared error classes @@ -198,11 +210,13 @@ export const ERROR_CODES = { ## 3. Middleware (`shared/middleware/`) ### Purpose + Reusable Express middleware for authentication, validation, error handling, and rate limiting. ### Components #### `auth.middleware.ts` + ```typescript import { Request, Response, NextFunction } from 'express' import jwt from 'jsonwebtoken' @@ -215,7 +229,11 @@ export interface AuthRequest extends Request { } } -export const authenticate = (req: AuthRequest, res: Response, next: NextFunction) => { +export const authenticate = ( + req: AuthRequest, + res: Response, + next: NextFunction, +) => { // JWT validation logic // Attaches user to req.user } @@ -231,6 +249,7 @@ export const authorize = (...roles: string[]) => { ``` #### `validation.middleware.ts` + ```typescript import { Request, Response, NextFunction } from 'express' import { ZodSchema } from 'zod' @@ -248,6 +267,7 @@ export const validate = (schema: ZodSchema) => { ``` #### `error.middleware.ts` + ```typescript import { Request, Response, NextFunction } from 'express' import { AppError } from '../errors' @@ -257,7 +277,7 @@ export const errorHandler = ( err: Error, req: Request, res: Response, - next: NextFunction + next: NextFunction, ) => { logger.error('Error:', { error: err.message, stack: err.stack }) @@ -292,6 +312,7 @@ export const asyncHandler = (fn: Function) => { ``` #### `rate-limit.middleware.ts` + ```typescript import rateLimit from 'express-rate-limit' @@ -309,6 +330,7 @@ export const authLimiter = rateLimit({ ``` ### Usage Rules + - All routes SHOULD use shared middleware - Domain-specific middleware can extend/compose shared middleware - Middleware must be stateless and reusable @@ -318,11 +340,13 @@ export const authLimiter = rateLimit({ ## 4. Common Types (`shared/types/`) ### Purpose + Type definitions shared across all domains. ### Components #### `api.types.ts` + ```typescript export interface ApiResponse { success: boolean @@ -351,6 +375,7 @@ export interface ApiError { ``` #### `pagination.types.ts` + ```typescript export interface PaginationParams { page?: number @@ -370,6 +395,7 @@ export interface PaginationMeta { ``` #### `common.types.ts` + ```typescript export type Timestamp = string // ISO 8601 export type UUID = string @@ -388,6 +414,7 @@ export interface BaseEntity { ``` ### Usage Rules + - Use for DTOs that cross domain boundaries - Domain-specific types belong in domain folders - Keep types minimal and stable @@ -397,11 +424,13 @@ export interface BaseEntity { ## 5. Utilities (`shared/utils/`) ### Purpose + Pure utility functions without business logic. ### Components #### `jwt.ts` + ```typescript import jwt from 'jsonwebtoken' import { env } from '../config/env' @@ -418,6 +447,7 @@ export const verifyToken = (token: string): any => { ``` #### `password.ts` + ```typescript import bcrypt from 'bcryptjs' @@ -428,18 +458,20 @@ export const hashPassword = async (password: string): Promise => { export const comparePassword = async ( password: string, - hash: string + hash: string, ): Promise => { return bcrypt.compare(password, hash) } ``` #### `date.ts`, `number.ts`, `string.ts` + ```typescript // Pure utility functions for formatting, parsing, validation ``` ### Usage Rules + - Utilities MUST be pure functions (no side effects) - No database access or external API calls - No business logic; only technical utilities @@ -449,11 +481,13 @@ export const comparePassword = async ( ## 6. Messaging Infrastructure (`shared/messaging/`) ### Purpose + Outbox pattern implementation for emails, webhooks, and domain events. ### Components #### `email.service.ts` + ```typescript import prisma from '../config/database' import logger from '../config/logger' @@ -464,14 +498,14 @@ export class EmailService { to: string, subject: string, body: string, - type: string = 'GENERAL' + type: string = 'GENERAL', ): Promise { await prisma.emailDelivery.create({ data: { userId, to, subject, body, type, status: 'pending' }, }) - + // Trigger async processing - this.processQueue().catch(err => logger.error('Email queue error:', err)) + this.processQueue().catch((err) => logger.error('Email queue error:', err)) } async processQueue(): Promise { @@ -483,11 +517,13 @@ export const emailService = new EmailService() ``` #### `webhook.service.ts` + ```typescript // Similar outbox pattern for webhook delivery ``` #### `events.types.ts` + ```typescript export interface DomainEvent { eventType: string @@ -513,6 +549,7 @@ export interface UserRegisteredEvent extends DomainEvent { ``` ### Usage Rules + - Domains MUST use messaging infrastructure for async communication - No direct service-to-service calls for cross-domain operations - Events are write-only (fire and forget) diff --git a/docs/security/refresh-token-rotation.md b/docs/security/refresh-token-rotation.md index a4e31fe9..7b7e0654 100644 --- a/docs/security/refresh-token-rotation.md +++ b/docs/security/refresh-token-rotation.md @@ -6,9 +6,9 @@ stateful, rotating refresh sessions. ## 1. Token model -| Token | Type | Lifetime | Issued at | Sent back | -| --- | --- | --- | --- | --- | -| Access token | JWT (HS256, `kid`-pinned) | 15 min (`JWT_ACCESS_TTL_SECONDS`) | login, register, OTP-login, refresh | `Authorization: Bearer ` | +| Token | Type | Lifetime | Issued at | Sent back | +| ------------- | ---------------------------------- | ------------------------------------- | ----------------------------------- | -------------------------------------------------- | +| Access token | JWT (HS256, `kid`-pinned) | 15 min (`JWT_ACCESS_TTL_SECONDS`) | login, register, OTP-login, refresh | `Authorization: Bearer ` | | Refresh token | opaque, 64-char base64url, 256-bit | 30 days (`REFRESH_TOKEN_TTL_SECONDS`) | login, register, OTP-login, refresh | JSON body `refreshToken` or `refresh_token` cookie | The raw refresh token is **never persisted**. Only its SHA-256 hash is stored @@ -44,9 +44,9 @@ copy of the token is now worthless. ## 4. Logout -| Endpoint | Input | Effect | -| --- | --- | --- | -| `POST /auth/logout` | refresh token | revokes the session + its family (logout current) | +| Endpoint | Input | Effect | +| ----------------------- | ------------- | -------------------------------------------------------------- | +| `POST /auth/logout` | refresh token | revokes the session + its family (logout current) | | `POST /auth/logout/all` | refresh token | revokes **every** session for the identified user (logout all) | Both are idempotent and return `revokedCount`. Unknown tokens are a neutral @@ -78,19 +78,19 @@ first, then cookie. 2. Reject cross-site requests at the edge, e.g. verify `Origin` / `Sec-Fetch-Site` before forwarding `/auth/refresh` and `/auth/logout`. -The server does not set cookies itself; it only *reads* an existing +The server does not set cookies itself; it only _reads_ an existing `refresh_token` cookie. This keeps the API contract transport-agnostic and leaves cookie lifecycle (and its CSRF obligations) to the client/edge. ## 7. Failure matrix -| Condition | Result | -| --- | --- | -| Unknown token | `401 REFRESH_INVALID` | +| Condition | Result | +| ------------------------ | -------------------------------------------- | +| Unknown token | `401 REFRESH_INVALID` | | `ROTATED` token replayed | revoke family → `401 REFRESH_REUSE_DETECTED` | -| `REVOKED` token/session | `401 REFRESH_REVOKED` | -| Expired token or session | `401 REFRESH_EXPIRED` | -| Missing token | `400 refreshToken is required` | +| `REVOKED` token/session | `401 REFRESH_REVOKED` | +| Expired token or session | `401 REFRESH_EXPIRED` | +| Missing token | `400 refreshToken is required` | ## 8. Verification diff --git a/eslint.config.ts b/eslint.config.ts index cc2d8c52..c43b1bad 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,71 +1,79 @@ -import { defineConfig } from 'eslint/config' -import { globalIgnores } from 'eslint/config' -import js from '@eslint/js' -import markdown from '@eslint/markdown' -import tseslint from 'typescript-eslint' - -export default defineConfig( - { - languageOptions: { - globals: { - console: 'readonly', - process: 'readonly', - }, - parserOptions: { - tsconfigRootDir: process.cwd() - } - }, - }, - js.configs.recommended, - ...tseslint.configs.recommended, - { - files: ['docs/**/*.md', 'README.md'], - plugins: { - markdown, - }, - extends: ['markdown/recommended'], - rules: { - 'no-irregular-whitespace': 'off', - 'markdown/no-missing-label-refs': 'off', - } - }, - [ - globalIgnores([ - 'docs/.vitepress/**', - 'bin/**', - 'dist/**', - 'build/**', - 'patches/**', - 'node_modules/**', - ]) - ], - { - rules: { - 'brace-style': [ - 'error', - '1tbs', - { 'allowSingleLine': false }, - ], - 'no-console': 'off', - 'no-thenable': 'off', - // 'no-ternary': 'error', - 'newline-before-return': 'error', - 'semi': ['error', 'never'], - 'quotes': ['error', 'single'], - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': [ - 'warn', { - 'argsIgnorePattern': '^_|_', - 'vars': 'all', - 'args': 'after-used', - 'ignoreRestSiblings': false, - 'varsIgnorePattern': '^I[A-Z]|^_', - } - ], - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/triple-slash-reference': ['error', { - 'path': 'always' - }] - } - }, -) +import { defineConfig } from 'eslint/config' +import { globalIgnores } from 'eslint/config' +import js from '@eslint/js' +import markdown from '@eslint/markdown' +import tseslint from 'typescript-eslint' + +export default defineConfig( + { + languageOptions: { + globals: { + console: 'readonly', + process: 'readonly', + }, + parserOptions: { + tsconfigRootDir: process.cwd(), + }, + }, + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['docs/**/*.md', 'README.md'], + plugins: { + markdown, + }, + extends: ['markdown/recommended'], + rules: { + 'no-irregular-whitespace': 'off', + 'markdown/no-missing-label-refs': 'off', + }, + }, + [ + globalIgnores([ + 'docs/.vitepress/**', + 'bin/**', + 'dist/**', + 'build/**', + 'patches/**', + 'node_modules/**', + ]), + ], + { + rules: { + 'brace-style': ['error', '1tbs', { allowSingleLine: false }], + 'no-console': 'off', + 'no-thenable': 'off', + // 'no-ternary': 'error', + 'newline-before-return': 'error', + semi: ['error', 'never'], + quotes: ['error', 'single'], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_|_', + vars: 'all', + args: 'after-used', + ignoreRestSiblings: false, + varsIgnorePattern: '^I[A-Z]|^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/triple-slash-reference': [ + 'error', + { + path: 'always', + }, + ], + }, + }, + { + files: ['src/services/stellar.service.ts'], + rules: { + // Control-character stripping is intentional for user-provided Stellar + // text memos and is covered by service tests. + 'no-control-regex': 'off', + }, + }, +) diff --git a/integrations/architecture/domain-boundaries.test.ts b/integrations/architecture/domain-boundaries.test.ts index 323018ec..7e6944ac 100644 --- a/integrations/architecture/domain-boundaries.test.ts +++ b/integrations/architecture/domain-boundaries.test.ts @@ -25,36 +25,103 @@ const DOMAIN_PATHS = { } const SHARED_KERNEL_PATH = 'shared' -const INFRASTRUCTURE_PATHS = ['infrastructure/blockchain', 'infrastructure/database'] +const INFRASTRUCTURE_PATHS = [ + 'infrastructure/blockchain', + 'infrastructure/database', +] // Forbidden cross-domain import patterns const FORBIDDEN_IMPORTS = [ // Identity domain - { from: 'identity', cannot: ['users', 'learning', 'credentials', 'rewards', 'referrals', 'notifications', 'organizations', 'sync'] }, - + { + from: 'identity', + cannot: [ + 'users', + 'learning', + 'credentials', + 'rewards', + 'referrals', + 'notifications', + 'organizations', + 'sync', + ], + }, + // Users domain - { from: 'users', cannot: ['learning', 'credentials', 'rewards', 'referrals', 'organizations', 'sync'] }, - + { + from: 'users', + cannot: [ + 'learning', + 'credentials', + 'rewards', + 'referrals', + 'organizations', + 'sync', + ], + }, + // Learning domain - { from: 'learning', cannot: ['rewards', 'credentials', 'referrals', 'notifications', 'users', 'organizations'] }, - + { + from: 'learning', + cannot: [ + 'rewards', + 'credentials', + 'referrals', + 'notifications', + 'users', + 'organizations', + ], + }, + // Credentials domain - { from: 'credentials', cannot: ['rewards', 'referrals', 'notifications', 'learning', 'users'] }, - + { + from: 'credentials', + cannot: ['rewards', 'referrals', 'notifications', 'learning', 'users'], + }, + // Rewards domain - { from: 'rewards', cannot: ['learning', 'credentials', 'referrals', 'notifications', 'users'] }, - + { + from: 'rewards', + cannot: ['learning', 'credentials', 'referrals', 'notifications', 'users'], + }, + // Referrals domain - { from: 'referrals', cannot: ['rewards', 'learning', 'credentials', 'notifications', 'users'] }, - + { + from: 'referrals', + cannot: ['rewards', 'learning', 'credentials', 'notifications', 'users'], + }, + // Notifications domain - { from: 'notifications', cannot: ['rewards', 'learning', 'credentials', 'referrals', 'users'] }, - + { + from: 'notifications', + cannot: ['rewards', 'learning', 'credentials', 'referrals', 'users'], + }, + // Organizations domain - { from: 'organizations', cannot: ['rewards', 'learning', 'credentials', 'referrals', 'users', 'sync'] }, - + { + from: 'organizations', + cannot: [ + 'rewards', + 'learning', + 'credentials', + 'referrals', + 'users', + 'sync', + ], + }, + // Sync domain - { from: 'sync', cannot: ['rewards', 'learning', 'credentials', 'referrals', 'users', 'organizations'] }, + { + from: 'sync', + cannot: [ + 'rewards', + 'learning', + 'credentials', + 'referrals', + 'users', + 'organizations', + ], + }, ] // Infrastructure cannot import from business domains @@ -73,7 +140,7 @@ function findTsFiles(dir: string, fileList: string[] = []): string[] { const files = fs.readdirSync(dir) - files.forEach(file => { + files.forEach((file) => { const filePath = path.join(dir, file) const stat = fs.statSync(filePath) @@ -176,17 +243,21 @@ describe('Architecture: Domain Boundaries', () => { FORBIDDEN_IMPORTS.forEach(({ from, cannot }) => { test(`Domain '${from}' should not import from forbidden domains: ${cannot.join(', ')}`, () => { - const violations: Array<{ file: string; importPath: string; targetDomain: string }> = [] + const violations: Array<{ + file: string + importPath: string + targetDomain: string + }> = [] - allFiles.forEach(file => { + allFiles.forEach((file) => { const fileDomain = getDomainFromPath(file) - + if (fileDomain === from) { const imports = extractImports(file) - - imports.forEach(importPath => { + + imports.forEach((importPath) => { const targetDomain = getTargetDomain(importPath) - + if (targetDomain && cannot.includes(targetDomain)) { violations.push({ file: path.relative(srcDir, file), @@ -199,14 +270,17 @@ describe('Architecture: Domain Boundaries', () => { }) if (violations.length > 0) { - const violationDetails = violations.map(v => - ` - ${v.file} imports from ${v.targetDomain}: '${v.importPath}'` - ).join('\n') + const violationDetails = violations + .map( + (v) => + ` - ${v.file} imports from ${v.targetDomain}: '${v.importPath}'`, + ) + .join('\n') throw new Error( 'Domain boundary violation detected!\n\n' + - `Domain '${from}' has forbidden imports:\n${violationDetails}\n\n` + - `Forbidden domains: ${cannot.join(', ')}` + `Domain '${from}' has forbidden imports:\n${violationDetails}\n\n` + + `Forbidden domains: ${cannot.join(', ')}`, ) } @@ -218,18 +292,25 @@ describe('Architecture: Domain Boundaries', () => { describe('Infrastructure Layer Rules', () => { test('Infrastructure should not import from business domains', () => { const allFiles = findTsFiles(srcDir) - const violations: Array<{ file: string; importPath: string; targetDomain: string }> = [] + const violations: Array<{ + file: string + importPath: string + targetDomain: string + }> = [] - allFiles.forEach(file => { + allFiles.forEach((file) => { const fileDomain = getDomainFromPath(file) - + if (fileDomain === 'infrastructure') { const imports = extractImports(file) - - imports.forEach(importPath => { + + imports.forEach((importPath) => { const targetDomain = getTargetDomain(importPath) - - if (targetDomain && INFRASTRUCTURE_FORBIDDEN.includes(targetDomain)) { + + if ( + targetDomain && + INFRASTRUCTURE_FORBIDDEN.includes(targetDomain) + ) { violations.push({ file: path.relative(srcDir, file), importPath, @@ -241,13 +322,16 @@ describe('Architecture: Domain Boundaries', () => { }) if (violations.length > 0) { - const violationDetails = violations.map(v => - ` - ${v.file} imports from ${v.targetDomain}: '${v.importPath}'` - ).join('\n') + const violationDetails = violations + .map( + (v) => + ` - ${v.file} imports from ${v.targetDomain}: '${v.importPath}'`, + ) + .join('\n') throw new Error( 'Infrastructure layer violation detected!\n\n' + - `Infrastructure files have forbidden domain imports:\n${violationDetails}` + `Infrastructure files have forbidden domain imports:\n${violationDetails}`, ) } @@ -258,18 +342,25 @@ describe('Architecture: Domain Boundaries', () => { describe('Shared Kernel Rules', () => { test('Shared kernel should not import from any business domain', () => { const allFiles = findTsFiles(srcDir) - const violations: Array<{ file: string; importPath: string; targetDomain: string }> = [] + const violations: Array<{ + file: string + importPath: string + targetDomain: string + }> = [] - allFiles.forEach(file => { + allFiles.forEach((file) => { const fileDomain = getDomainFromPath(file) - + if (fileDomain === 'shared') { const imports = extractImports(file) - - imports.forEach(importPath => { + + imports.forEach((importPath) => { const targetDomain = getTargetDomain(importPath) - - if (targetDomain && SHARED_KERNEL_FORBIDDEN.includes(targetDomain)) { + + if ( + targetDomain && + SHARED_KERNEL_FORBIDDEN.includes(targetDomain) + ) { violations.push({ file: path.relative(srcDir, file), importPath, @@ -281,14 +372,17 @@ describe('Architecture: Domain Boundaries', () => { }) if (violations.length > 0) { - const violationDetails = violations.map(v => - ` - ${v.file} imports from ${v.targetDomain}: '${v.importPath}'` - ).join('\n') + const violationDetails = violations + .map( + (v) => + ` - ${v.file} imports from ${v.targetDomain}: '${v.importPath}'`, + ) + .join('\n') throw new Error( 'Shared kernel violation detected!\n\n' + - `Shared kernel files have forbidden domain imports:\n${violationDetails}\n\n` + - 'The shared kernel must not depend on any business domain.' + `Shared kernel files have forbidden domain imports:\n${violationDetails}\n\n` + + 'The shared kernel must not depend on any business domain.', ) } @@ -300,23 +394,31 @@ describe('Architecture: Domain Boundaries', () => { test('Should not have circular dependencies between domains', () => { // This is a simplified check - full circular dependency detection requires graph analysis // For now, we ensure no domain imports another domain that imports it back - + const allFiles = findTsFiles(srcDir) const domainImports: Record> = {} // Build import graph - allFiles.forEach(file => { + allFiles.forEach((file) => { const fileDomain = getDomainFromPath(file) - - if (fileDomain && fileDomain !== 'shared' && fileDomain !== 'infrastructure') { + + if ( + fileDomain && + fileDomain !== 'shared' && + fileDomain !== 'infrastructure' + ) { if (!domainImports[fileDomain]) { domainImports[fileDomain] = new Set() } const imports = extractImports(file) - imports.forEach(importPath => { + imports.forEach((importPath) => { const targetDomain = getTargetDomain(importPath) - if (targetDomain && targetDomain !== 'shared' && targetDomain !== 'infrastructure') { + if ( + targetDomain && + targetDomain !== 'shared' && + targetDomain !== 'infrastructure' + ) { domainImports[fileDomain].add(targetDomain) } }) @@ -325,13 +427,18 @@ describe('Architecture: Domain Boundaries', () => { // Check for direct circular dependencies (A → B, B → A) const circularDeps: Array<[string, string]> = [] - - Object.keys(domainImports).forEach(domainA => { - domainImports[domainA].forEach(domainB => { + + Object.keys(domainImports).forEach((domainA) => { + domainImports[domainA].forEach((domainB) => { if (domainImports[domainB]?.has(domainA)) { // Found circular dependency - const pair: [string, string] = [domainA, domainB].sort() as [string, string] - if (!circularDeps.some(([a, b]) => a === pair[0] && b === pair[1])) { + const pair: [string, string] = [domainA, domainB].sort() as [ + string, + string, + ] + if ( + !circularDeps.some(([a, b]) => a === pair[0] && b === pair[1]) + ) { circularDeps.push(pair) } } @@ -339,10 +446,12 @@ describe('Architecture: Domain Boundaries', () => { }) if (circularDeps.length > 0) { - const details = circularDeps.map(([a, b]) => ` - ${a} ↔ ${b}`).join('\n') + const details = circularDeps + .map(([a, b]) => ` - ${a} ↔ ${b}`) + .join('\n') throw new Error( `Circular dependencies detected between domains:\n${details}\n\n` + - 'Domains should not have circular dependencies.' + 'Domains should not have circular dependencies.', ) } @@ -356,7 +465,7 @@ describe('Architecture: File Organization', () => { const allFiles = findTsFiles(srcDir) const unmappedFiles: string[] = [] - allFiles.forEach(file => { + allFiles.forEach((file) => { const domain = getDomainFromPath(file) const relativePath = path.relative(srcDir, file) @@ -379,7 +488,9 @@ describe('Architecture: File Organization', () => { 'docs/', ] - const isLegacy = legacyPaths.some(legacy => relativePath.startsWith(legacy)) + const isLegacy = legacyPaths.some((legacy) => + relativePath.startsWith(legacy), + ) if (isLegacy) { return // Skip legacy files during transition } @@ -392,7 +503,7 @@ describe('Architecture: File Organization', () => { if (unmappedFiles.length > 0) { console.warn( `Warning: ${unmappedFiles.length} files are not mapped to any domain:\n` + - unmappedFiles.map(f => ` - ${f}`).join('\n') + unmappedFiles.map((f) => ` - ${f}`).join('\n'), ) } diff --git a/integrations/auth.controller.test.ts b/integrations/auth.controller.test.ts index f916d4c1..1e5496b5 100644 --- a/integrations/auth.controller.test.ts +++ b/integrations/auth.controller.test.ts @@ -7,151 +7,185 @@ import bcrypt from 'bcryptjs' // Mock dependencies vi.mock('../src/config/database', () => ({ - default: { - user: { - findFirst: vi.fn(), - findUnique: vi.fn(), - create: vi.fn(), - update: vi.fn(), - }, + default: { + user: { + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), }, + }, })) vi.mock('bcryptjs', () => ({ - default: { - genSalt: vi.fn().mockResolvedValue('salt'), - hash: vi.fn().mockResolvedValue('hashed_password'), - compare: vi.fn(), - }, + default: { + genSalt: vi.fn().mockResolvedValue('salt'), + hash: vi.fn().mockResolvedValue('hashed_password'), + compare: vi.fn(), + }, })) vi.mock('jsonwebtoken', () => ({ - default: { - sign: vi.fn().mockReturnValue('mock_token'), - }, + default: { + sign: vi.fn().mockReturnValue('mock_token'), + }, })) describe('AuthController', () => { - let authController: AuthController - let mockRequest: Partial - let mockResponse: Partial - - beforeEach(() => { - authController = new AuthController() - mockRequest = {} - mockResponse = { - json: vi.fn(), - status: vi.fn().mockReturnThis(), - } - vi.clearAllMocks() + let authController: AuthController + let mockRequest: Partial + let mockResponse: Partial + + beforeEach(() => { + authController = new AuthController() + mockRequest = {} + mockResponse = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } + vi.clearAllMocks() + }) + + describe('register', () => { + it('should register a new user successfully', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + username: 'testuser', + } + + ;(prisma.user.findFirst as any).mockResolvedValue(null) + ;(prisma.user.create as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + username: 'testuser', + role: 'LEARNER', + }) + + await authController.register( + mockRequest as Request, + mockResponse as Response, + ) + + expect(prisma.user.create).toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(201) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User registered successfully', + token: 'mock_token', + }), + ) }) - describe('register', () => { - it('should register a new user successfully', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'Password123!', - username: 'testuser', - }; - - (prisma.user.findFirst as any).mockResolvedValue(null); - (prisma.user.create as any).mockResolvedValue({ - id: '1', - email: 'test@example.com', - username: 'testuser', - role: 'LEARNER', - }) - - await authController.register(mockRequest as Request, mockResponse as Response) - - expect(prisma.user.create).toHaveBeenCalled() - expect(mockResponse.status).toHaveBeenCalledWith(201) - expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ - message: 'User registered successfully', - token: 'mock_token', - })) - }) - - it('should return 400 for invalid input', async () => { - mockRequest.body = { - email: 'invalid-email', - password: 'short', - } - - await authController.register(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ - error: 'Validation failed', - })) - }) - - it('should return 409 if user already exists', async () => { - mockRequest.body = { - email: 'exists@example.com', - password: 'Password123!', - username: 'exists', - }; - - (prisma.user.findFirst as any).mockResolvedValue({ id: '1' }) - - await authController.register(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(409) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User with this email or username already exists' }) - }) + it('should return 400 for invalid input', async () => { + mockRequest.body = { + email: 'invalid-email', + password: 'short', + } + + await authController.register( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Validation failed', + }), + ) }) - describe('login', () => { - it('should login successfully with valid credentials', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'Password123!', - } - - const mockUser = { - id: '1', - email: 'test@example.com', - password: 'hashed_password', - username: 'testuser', - role: 'LEARNER', - }; - - (prisma.user.findUnique as any).mockResolvedValue(mockUser); - (bcrypt.compare as any).mockResolvedValue(true); - (prisma.user.update as any).mockResolvedValue(mockUser) - - await authController.login(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ - message: 'Login successful', - token: 'mock_token', - })) - }) - - it('should return 401 for invalid credentials', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'wrong_password', - }; - - (prisma.user.findUnique as any).mockResolvedValue({ id: '1', password: 'hashed' }); - (bcrypt.compare as any).mockResolvedValue(false) - - await authController.login(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(401) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid credentials' }) - }) - }) + it('should return 409 if user already exists', async () => { + mockRequest.body = { + email: 'exists@example.com', + password: 'Password123!', + username: 'exists', + } + + ;(prisma.user.findFirst as any).mockResolvedValue({ id: '1' }) - describe('logout', () => { - it('should return success message', async () => { - await authController.logout(mockRequest as Request, mockResponse as Response) + await authController.register( + mockRequest as Request, + mockResponse as Response, + ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Logged out successfully. Please clear your token client-side.' }) - }) + expect(mockResponse.status).toHaveBeenCalledWith(409) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'User with this email or username already exists', + }) + }) + }) + + describe('login', () => { + it('should login successfully with valid credentials', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + const mockUser = { + id: '1', + email: 'test@example.com', + password: 'hashed_password', + username: 'testuser', + role: 'LEARNER', + } + + ;(prisma.user.findUnique as any).mockResolvedValue(mockUser) + ;(bcrypt.compare as any).mockResolvedValue(true) + ;(prisma.user.update as any).mockResolvedValue(mockUser) + + await authController.login( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Login successful', + token: 'mock_token', + }), + ) + }) + + it('should return 401 for invalid credentials', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'wrong_password', + } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + password: 'hashed', + }) + ;(bcrypt.compare as any).mockResolvedValue(false) + + await authController.login( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(401) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid credentials', + }) + }) + }) + + describe('logout', () => { + it('should return success message', async () => { + await authController.logout( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: + 'Logged out successfully. Please clear your token client-side.', + }) }) + }) }) diff --git a/integrations/error.middleware.test.ts b/integrations/error.middleware.test.ts index 129f113d..eb685577 100644 --- a/integrations/error.middleware.test.ts +++ b/integrations/error.middleware.test.ts @@ -234,7 +234,7 @@ describe('Error Handling Middleware', () => { message: 'Test error', path: '/api/test', method: 'GET', - }) + }), ) }) @@ -287,7 +287,7 @@ describe('Error Handling Middleware', () => { message: 'Not Found', path: '/api/test', method: 'GET', - }) + }), ) }) @@ -358,7 +358,7 @@ describe('Error Handling Middleware', () => { expect.objectContaining({ message: 'Async error caught', error: 'Database error', - }) + }), ) }) @@ -433,4 +433,4 @@ describe('Error Handling Middleware', () => { }) }) }) -}) \ No newline at end of file +}) diff --git a/integrations/services/webhook.service.spec.ts b/integrations/services/webhook.service.spec.ts index 2926f372..dcbef958 100644 --- a/integrations/services/webhook.service.spec.ts +++ b/integrations/services/webhook.service.spec.ts @@ -3,122 +3,138 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { WebhookService } from '../../src/services/webhook.service' const { mockPrismaInstance } = vi.hoisted(() => ({ - mockPrismaInstance: { - webhookEndpoint: { - create: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, - webhookDelivery: { - create: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, + mockPrismaInstance: { + webhookEndpoint: { + create: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), }, + webhookDelivery: { + create: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + }, })) vi.mock('@prisma/client', () => ({ - PrismaClient: class { - webhookEndpoint = mockPrismaInstance.webhookEndpoint - webhookDelivery = mockPrismaInstance.webhookDelivery - }, + PrismaClient: class { + webhookEndpoint = mockPrismaInstance.webhookEndpoint + webhookDelivery = mockPrismaInstance.webhookDelivery + }, })) // Mock global fetch global.fetch = vi.fn() describe('WebhookService', () => { - let service: WebhookService - - beforeEach(() => { - vi.clearAllMocks() - service = new WebhookService() + let service: WebhookService + + beforeEach(() => { + vi.clearAllMocks() + service = new WebhookService() + }) + + describe('registerEndpoint', () => { + it('should create a new endpoint with a generated secret', async () => { + const data = { + url: 'https://example.com/webhook', + events: ['module.completed' as any], + } + + mockPrismaInstance.webhookEndpoint.create.mockResolvedValue({ + id: '1', + ...data, + secret: 'secret', + }) + + const result = await service.registerEndpoint(data) + + expect(mockPrismaInstance.webhookEndpoint.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + url: data.url, + events: 'module.completed', + }), + }), + ) + expect(result.id).toBe('1') }) + }) + + describe('queueEvent', () => { + it('should create deliveries for subscribed endpoints', async () => { + mockPrismaInstance.webhookEndpoint.findMany.mockResolvedValue([ + { + id: 'ep1', + url: 'https://ep1.com', + secret: 's1', + events: 'module.completed', + isActive: true, + }, + ]) + mockPrismaInstance.webhookDelivery.create.mockResolvedValue({ id: 'd1' }) + mockPrismaInstance.webhookDelivery.findMany.mockResolvedValue([]) // for processQueue - describe('registerEndpoint', () => { - it('should create a new endpoint with a generated secret', async () => { - const data = { - url: 'https://example.com/webhook', - events: ['module.completed' as any], - } - - mockPrismaInstance.webhookEndpoint.create.mockResolvedValue({ id: '1', ...data, secret: 'secret' }) - - const result = await service.registerEndpoint(data) - - expect(mockPrismaInstance.webhookEndpoint.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - url: data.url, - events: 'module.completed', - }), - }) - ) - expect(result.id).toBe('1') - }) - }) + await service.queueEvent('module.completed', { foo: 'bar' }) - describe('queueEvent', () => { - it('should create deliveries for subscribed endpoints', async () => { - mockPrismaInstance.webhookEndpoint.findMany.mockResolvedValue([ - { id: 'ep1', url: 'https://ep1.com', secret: 's1', events: 'module.completed', isActive: true }, - ]) - mockPrismaInstance.webhookDelivery.create.mockResolvedValue({ id: 'd1' }) - mockPrismaInstance.webhookDelivery.findMany.mockResolvedValue([]) // for processQueue - - await service.queueEvent('module.completed', { foo: 'bar' }) - - expect(mockPrismaInstance.webhookDelivery.create).toHaveBeenCalledOnce() - const createCall = mockPrismaInstance.webhookDelivery.create.mock.calls[0][0] - expect(createCall.data.eventType).toBe('module.completed') - expect(JSON.parse(createCall.data.payload).data).toEqual({ foo: 'bar' }) - }) + expect(mockPrismaInstance.webhookDelivery.create).toHaveBeenCalledOnce() + const createCall = + mockPrismaInstance.webhookDelivery.create.mock.calls[0][0] + expect(createCall.data.eventType).toBe('module.completed') + expect(JSON.parse(createCall.data.payload).data).toEqual({ foo: 'bar' }) }) + }) - describe('signature generation', () => { - it('should generate a valid HMAC SHA256 signature', () => { - const payload = '{"foo":"bar"}' - const secret = 'test-secret' - // @ts-expect-error just ignore for now - const signature = service.generateSignature(payload, secret) + describe('signature generation', () => { + it('should generate a valid HMAC SHA256 signature', () => { + const payload = '{"foo":"bar"}' + const secret = 'test-secret' + // @ts-expect-error just ignore for now + const signature = service.generateSignature(payload, secret) - expect(signature).toBeDefined() - expect(signature).toHaveLength(64) - }) + expect(signature).toBeDefined() + expect(signature).toHaveLength(64) + }) + }) + + describe('retry logic', () => { + it('should calculate exponential backoff', async () => { + const delivery = { id: 'd1', attemptCount: 1, maxAttempts: 5 } + mockPrismaInstance.webhookDelivery.update.mockResolvedValue({}) + + // @ts-expect-error just ignore for now + await service.handleFailure(delivery, 'error') + + expect(mockPrismaInstance.webhookDelivery.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + nextAttemptAt: expect.any(Date), + }), + }), + ) }) - describe('retry logic', () => { - it('should calculate exponential backoff', async () => { - const delivery = { id: 'd1', attemptCount: 1, maxAttempts: 5 } - mockPrismaInstance.webhookDelivery.update.mockResolvedValue({}) - - // @ts-expect-error just ignore for now - await service.handleFailure(delivery, 'error') - - expect(mockPrismaInstance.webhookDelivery.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - nextAttemptAt: expect.any(Date), - }), - }) - ) - }) - - it('should handle terminal failure after max attempts', async () => { - const delivery = { id: 'd1', attemptCount: 4, maxAttempts: 5, endpointId: 'ep1' } - mockPrismaInstance.webhookDelivery.update.mockResolvedValue({}) - mockPrismaInstance.webhookDelivery.findMany.mockResolvedValue([]) - - // @ts-expect-error just ignore for now - await service.handleFailure(delivery, 'max retry error') - - expect(mockPrismaInstance.webhookDelivery.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - status: 'failed', - }), - }) - ) - }) + it('should handle terminal failure after max attempts', async () => { + const delivery = { + id: 'd1', + attemptCount: 4, + maxAttempts: 5, + endpointId: 'ep1', + } + mockPrismaInstance.webhookDelivery.update.mockResolvedValue({}) + mockPrismaInstance.webhookDelivery.findMany.mockResolvedValue([]) + + // @ts-expect-error just ignore for now + await service.handleFailure(delivery, 'max retry error') + + expect(mockPrismaInstance.webhookDelivery.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'failed', + }), + }), + ) }) + }) }) diff --git a/integrations/setup.ts b/integrations/setup.ts index e98dec3f..e87de017 100644 --- a/integrations/setup.ts +++ b/integrations/setup.ts @@ -1,3 +1,3 @@ import { config } from 'dotenv' -config({ path: '.env.test' }) \ No newline at end of file +config({ path: '.env.test' }) diff --git a/integrations/stellar.service.test.ts b/integrations/stellar.service.test.ts index a2c278c6..a0fcb8c6 100644 --- a/integrations/stellar.service.test.ts +++ b/integrations/stellar.service.test.ts @@ -55,14 +55,14 @@ describe('StellarService', () => { this._pub = pub this._sec = sec } - publicKey () { + publicKey() { return this._pub } - secret () { + secret() { return this._sec } - static random () { + static random() { const seg = () => Math.random().toString(36).slice(2).toUpperCase().padEnd(11, 'A') const pub = ('G' + seg() + seg() + seg() + seg() + seg()).slice(0, 56) @@ -71,44 +71,44 @@ describe('StellarService', () => { return new FakeKeypair(pub, sec) } - static fromSecret (secret: string) { + static fromSecret(secret: string) { return new FakeKeypair(('G' + secret.slice(1)).slice(0, 56), secret) } } // ── FakeTransactionBuilder ───────────────────────────────────────────── class FakeTransactionBuilder { - addOperation (_op: unknown) { + addOperation(_op: unknown) { return this } - addMemo (_m: unknown) { + addMemo(_m: unknown) { return this } - setTimeout (_t: number) { + setTimeout(_t: number) { return this } - build () { + build() { return { sign: vi.fn() } } } // ── FakeServer (MUST use `function`, not arrow) ─────────────────────── - function FakeServer (this: any) { + function FakeServer(this: any) { this.getAccount = mockGetAccount this.sendTransaction = mockSendTransaction this.simulateTransaction = mockSimulateTransaction this.getTransaction = mockGetTransaction } - function FakeHorizonServer (this: any) { + function FakeHorizonServer(this: any) { this.loadAccount = mockGetAccount this.submitTransaction = mockSubmitTransaction } // ── FakeContract (MUST use `function`, not arrow) ───────────────────── - function FakeContract (this: any) { + function FakeContract(this: any) { this.call = vi.fn().mockReturnValue('mock_operation') } @@ -141,15 +141,11 @@ describe('StellarService', () => { })), Api: { - isSimulationError: vi.fn( - (r: unknown) => - Boolean(r && typeof r === 'object' && 'error' in (r as object)) + isSimulationError: vi.fn((r: unknown) => + Boolean(r && typeof r === 'object' && 'error' in (r as object)), ), - isSimulationSuccess: vi.fn( - (r: unknown) => - Boolean( - r && typeof r === 'object' && !('error' in (r as object)) - ) + isSimulationSuccess: vi.fn((r: unknown) => + Boolean(r && typeof r === 'object' && !('error' in (r as object))), ), GetTransactionStatus, }, @@ -179,12 +175,11 @@ describe('StellarService', () => { } }) - // Typed helper for FakeKeypair static methods type FakeKeypairStatic = { - random: () => { publicKey: () => string; secret: () => string }; - fromSecret: (s: string) => { publicKey: () => string; secret: () => string }; - }; + random: () => { publicKey: () => string; secret: () => string } + fromSecret: (s: string) => { publicKey: () => string; secret: () => string } + } const FakeKeypair = StellarSdk.Keypair as unknown as FakeKeypairStatic // --------------------------------------------------------------------------- @@ -229,14 +224,14 @@ describe('StellarService', () => { it('throws StellarServiceError on mainnet', async () => { const mainnetService = new StellarService('mainnet') await expect( - mainnetService.fundTestnetAccount('GABC...') + mainnetService.fundTestnetAccount('GABC...'), ).rejects.toThrow(StellarServiceError) }) it('error code is INVALID_NETWORK on mainnet', async () => { const mainnetService = new StellarService('mainnet') await expect( - mainnetService.fundTestnetAccount('GABC...') + mainnetService.fundTestnetAccount('GABC...'), ).rejects.toMatchObject({ code: 'INVALID_NETWORK' }) }) @@ -245,14 +240,14 @@ describe('StellarService', () => { const { publicKey } = service.generateWallet() await service.fundTestnetAccount(publicKey) expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining(encodeURIComponent(publicKey)) + expect.stringContaining(encodeURIComponent(publicKey)), ) }) it('throws FRIENDBOT_ERROR when friendbot returns non-ok response', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 400 }) await expect( - service.fundTestnetAccount('GPUBKEY...') + service.fundTestnetAccount('GPUBKEY...'), ).rejects.toMatchObject({ code: 'FRIENDBOT_ERROR' }) }) }) @@ -311,7 +306,7 @@ describe('StellarService', () => { expect(await service.getNativeBalance('GPUBKEY...')).toBe('42.0000000') }) - it('returns \'0\' when no native balance exists', async () => { + it("returns '0' when no native balance exists", async () => { mockGetAccount.mockResolvedValue({ balances: [] }) expect(await service.getNativeBalance('GPUBKEY...')).toBe('0') }) @@ -329,9 +324,12 @@ describe('StellarService', () => { sequence: '1234', balances: [{ asset_type: 'native', balance: '1000.0000000' }], incrementSequenceNumber: vi.fn(), - }) + }), ) - mockSubmitTransaction.mockResolvedValue({ successful: true, hash: 'TXHASH123' }) + mockSubmitTransaction.mockResolvedValue({ + successful: true, + hash: 'TXHASH123', + }) mockGetTransaction.mockResolvedValue({ status: 'SUCCESS', ledger: 999 }) }) @@ -366,7 +364,7 @@ describe('StellarService', () => { sourceSecret: sourceKeypair.secret(), destinationPublicKey: FakeKeypair.random().publicKey(), amount: '10', - }) + }), ).rejects.toMatchObject({ code: 'PAYMENT_ERROR' }) }) }) @@ -388,7 +386,10 @@ describe('StellarService', () => { transactionData: 'mock_footprint', minResourceFee: '100', }) - mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'CREDHASH456' }) + mockSendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'CREDHASH456', + }) mockGetTransaction.mockResolvedValue({ status: 'SUCCESS', ledger: 1001, @@ -415,7 +416,7 @@ describe('StellarService', () => { recipientPublicKey: 'GDEST...', credentialType: 'ID', data: {}, - }) + }), ).rejects.toMatchObject({ code: 'CONTRACT_NOT_CONFIGURED' }) }) @@ -426,7 +427,7 @@ describe('StellarService', () => { recipientPublicKey: FakeKeypair.random().publicKey(), credentialType: 'ID', data: {}, - }) + }), ).rejects.toMatchObject({ code: 'CREDENTIAL_ISSUANCE_ERROR' }) }) }) @@ -508,7 +509,9 @@ describe('StellarService', () => { describe('StellarServiceError', () => { it('has name StellarServiceError', () => { - expect(new StellarServiceError('msg', 'CODE').name).toBe('StellarServiceError') + expect(new StellarServiceError('msg', 'CODE').name).toBe( + 'StellarServiceError', + ) }) it('exposes code and message', () => { @@ -519,11 +522,13 @@ describe('StellarService', () => { it('stores the original cause', () => { const cause = new Error('original') - expect(new StellarServiceError('wrapped', 'CODE', cause).cause).toBe(cause) + expect(new StellarServiceError('wrapped', 'CODE', cause).cause).toBe( + cause, + ) }) it('is an instance of Error', () => { expect(new StellarServiceError('test', 'CODE')).toBeInstanceOf(Error) }) }) -}) \ No newline at end of file +}) diff --git a/integrations/unit/auth.middleware.test.ts b/integrations/unit/auth.middleware.test.ts index e0da93bd..8b2013d8 100644 --- a/integrations/unit/auth.middleware.test.ts +++ b/integrations/unit/auth.middleware.test.ts @@ -12,20 +12,19 @@ const JWT_SECRET = 'test-secret-key' vi.stubEnv('JWT_SECRET', JWT_SECRET) // Dynamically import AFTER stubbing so the module-level guard sees the value -const { authenticate, optionalAuthenticate, authorize } = await import( - '../../src/middleware/auth.middleware' -) +const { authenticate, optionalAuthenticate, authorize } = + await import('../../src/middleware/auth.middleware') // ── helpers ─────────────────────────────────────────────────────────────────── -function makeToken ( +function makeToken( payload: Record, expiresIn: string | number = '1h', ): string { return jwt.sign(payload, JWT_SECRET, { expiresIn } as jwt.SignOptions) } -function makeMocks () { +function makeMocks() { const req = { headers: {} } as Partial const res = { status: vi.fn().mockReturnThis(), @@ -59,7 +58,9 @@ describe('authenticate', () => { authenticate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(401) - expect(res.json).toHaveBeenCalledWith({ message: 'Authorization token required' }) + expect(res.json).toHaveBeenCalledWith({ + message: 'Authorization token required', + }) expect(next).not.toHaveBeenCalled() }) @@ -70,7 +71,9 @@ describe('authenticate', () => { authenticate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(401) - expect(res.json).toHaveBeenCalledWith({ message: 'Authorization token required' }) + expect(res.json).toHaveBeenCalledWith({ + message: 'Authorization token required', + }) expect(next).not.toHaveBeenCalled() }) @@ -149,8 +152,8 @@ describe('optionalAuthenticate', () => { describe('authorize', () => { it('calls next() when user has a matching role', () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } authorize('learner')(req as Request, res as Response, next) @@ -159,8 +162,8 @@ describe('authorize', () => { }) it('calls next() when user role matches one of multiple allowed roles', () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } authorize('learner', 'employer')(req as Request, res as Response, next) @@ -168,14 +171,16 @@ describe('authorize', () => { }) it('returns 403 when user role is not in the allowed list', () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } authorize('employer')(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(403) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('Access denied') }), + expect.objectContaining({ + message: expect.stringContaining('Access denied'), + }), ) expect(next).not.toHaveBeenCalled() }) @@ -186,7 +191,9 @@ describe('authorize', () => { authorize('learner')(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(401) - expect(res.json).toHaveBeenCalledWith({ message: 'Authentication required' }) + expect(res.json).toHaveBeenCalledWith({ + message: 'Authentication required', + }) expect(next).not.toHaveBeenCalled() }) -}) \ No newline at end of file +}) diff --git a/integrations/unit/credential.controller.test.ts b/integrations/unit/credential.controller.test.ts index 7b44b5f2..49097856 100644 --- a/integrations/unit/credential.controller.test.ts +++ b/integrations/unit/credential.controller.test.ts @@ -71,16 +71,18 @@ describe('CredentialController', () => { mockRequest.user = { id: 'user-1', email: 'john@example.com' } vi.mocked(prisma.credential.count).mockResolvedValue(1) - vi.mocked(prisma.credential.findMany).mockResolvedValue(mockCredentials as any) + vi.mocked(prisma.credential.findMany).mockResolvedValue( + mockCredentials as any, + ) await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(prisma.credential.count).toHaveBeenCalledWith({ where: { userId: 'user-1' }, @@ -113,7 +115,7 @@ describe('CredentialController', () => { await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(prisma.credential.count).toHaveBeenCalledWith({ @@ -134,7 +136,7 @@ describe('CredentialController', () => { await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(prisma.credential.count).toHaveBeenCalledWith({ @@ -155,11 +157,11 @@ describe('CredentialController', () => { await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Invalid fromDate format' }) + expect.objectContaining({ message: 'Invalid fromDate format' }), ) }) @@ -169,11 +171,11 @@ describe('CredentialController', () => { await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'User ID not found' }) + expect.objectContaining({ message: 'User ID not found' }), ) }) @@ -187,17 +189,17 @@ describe('CredentialController', () => { await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(prisma.credential.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 5, take: 5, - }) + }), ) expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -209,7 +211,7 @@ describe('CredentialController', () => { hasNextPage: true, hasPrevPage: true, }), - }) + }), ) }) }) @@ -239,12 +241,14 @@ describe('CredentialController', () => { mockRequest.user = { id: 'user-1', email: 'john@example.com' } mockRequest.params = { id: 'cred-1' } - vi.mocked(prisma.credential.findUnique).mockResolvedValue(mockCredential as any) + vi.mocked(prisma.credential.findUnique).mockResolvedValue( + mockCredential as any, + ) await credentialController.getCredentialById( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(prisma.credential.findUnique).toHaveBeenCalledWith({ @@ -270,14 +274,14 @@ describe('CredentialController', () => { await credentialController.getCredentialById( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Credential not found' }) + expect.objectContaining({ message: 'Credential not found' }), ) }) @@ -301,19 +305,23 @@ describe('CredentialController', () => { mockRequest.user = { id: 'user-1', email: 'john@example.com' } mockRequest.params = { id: 'cred-1' } - vi.mocked(prisma.credential.findUnique).mockResolvedValue(mockCredential as any) + vi.mocked(prisma.credential.findUnique).mockResolvedValue( + mockCredential as any, + ) await credentialController.getCredentialById( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'You do not have access to this credential' }) + expect.objectContaining({ + message: 'You do not have access to this credential', + }), ) }) @@ -324,11 +332,11 @@ describe('CredentialController', () => { await credentialController.getCredentialById( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'User ID not found' }) + expect.objectContaining({ message: 'User ID not found' }), ) }) }) @@ -354,12 +362,14 @@ describe('CredentialController', () => { } mockRequest.params = { onChainId: 'chain-1' } - vi.mocked(prisma.credential.findFirst).mockResolvedValue(mockCredential as any) + vi.mocked(prisma.credential.findFirst).mockResolvedValue( + mockCredential as any, + ) await credentialController.verifyCredential( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(prisma.credential.findFirst).toHaveBeenCalledWith({ @@ -402,16 +412,18 @@ describe('CredentialController', () => { mockRequest.params = { onChainId: 'cred-1' } vi.mocked(prisma.credential.findFirst).mockResolvedValue(null) - vi.mocked(prisma.credential.findUnique).mockResolvedValue(mockCredential as any) + vi.mocked(prisma.credential.findUnique).mockResolvedValue( + mockCredential as any, + ) await credentialController.verifyCredential( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(prisma.credential.findFirst).toHaveBeenCalled() expect(prisma.credential.findUnique).toHaveBeenCalledWith({ @@ -422,7 +434,7 @@ describe('CredentialController', () => { expect.objectContaining({ success: true, data: expect.objectContaining({ valid: true }), - }) + }), ) }) @@ -434,14 +446,14 @@ describe('CredentialController', () => { await credentialController.verifyCredential( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Credential not found or invalid' }) + expect.objectContaining({ message: 'Credential not found or invalid' }), ) }) @@ -463,12 +475,14 @@ describe('CredentialController', () => { mockRequest.user = undefined mockRequest.params = { onChainId: 'chain-1' } - vi.mocked(prisma.credential.findFirst).mockResolvedValue(mockCredential as any) + vi.mocked(prisma.credential.findFirst).mockResolvedValue( + mockCredential as any, + ) await credentialController.verifyCredential( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(mockResponse.json).toHaveBeenCalled() diff --git a/integrations/unit/employer.controller.test.ts b/integrations/unit/employer.controller.test.ts index 7ee680fa..0d434674 100644 --- a/integrations/unit/employer.controller.test.ts +++ b/integrations/unit/employer.controller.test.ts @@ -1,6 +1,10 @@ import { Request, Response } from 'express' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { contactCandidate, getCandidateProfile, searchTalent } from '../../src/controllers/employer.controller' +import { + contactCandidate, + getCandidateProfile, + searchTalent, +} from '../../src/controllers/employer.controller' import prisma from '../../src/config/database' vi.mock('../../src/config/database', () => ({ @@ -125,7 +129,12 @@ describe('EmployerController', () => { { score: 91, completedAt: new Date('2026-02-01T00:00:00Z'), - module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, + module: { + id: 'm1', + title: 'Stellar Fundamentals', + category: 'blockchain', + difficulty: 'beginner', + }, }, ], credentials: [ @@ -133,7 +142,12 @@ describe('EmployerController', () => { id: 'cred-1', onChainId: 'onchain-abc', issuedAt: new Date('2026-02-03T00:00:00Z'), - module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, + module: { + id: 'm1', + title: 'Stellar Fundamentals', + category: 'blockchain', + difficulty: 'beginner', + }, }, ], }) @@ -166,7 +180,9 @@ describe('EmployerController', () => { await getCandidateProfile(req, res) expect(res.status).toHaveBeenCalledWith(403) - expect(res.json).toHaveBeenCalledWith({ message: 'Candidate profile is private' }) + expect(res.json).toHaveBeenCalledWith({ + message: 'Candidate profile is private', + }) }) it('contactCandidate requires pro plan', async () => { @@ -198,7 +214,9 @@ describe('EmployerController', () => { email: 'alice.learner+seed@learnault.dev', name: 'Alice Learner', }) - ;(prisma.webhookEndpoint.upsert as any).mockResolvedValue({ id: 'system-employer-outreach-log' }) + ;(prisma.webhookEndpoint.upsert as any).mockResolvedValue({ + id: 'system-employer-outreach-log', + }) ;(prisma.webhookDelivery.create as any).mockResolvedValue({ id: 'attempt-1', createdAt: new Date('2026-03-01T10:00:00Z'), @@ -230,7 +248,10 @@ describe('EmployerController', () => { expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ message: 'Candidate outreach recorded', - outreach: expect.objectContaining({ id: 'attempt-1', candidateId: 'cand-1' }), + outreach: expect.objectContaining({ + id: 'attempt-1', + candidateId: 'cand-1', + }), }), ) }) diff --git a/integrations/unit/errorHandler.test.ts b/integrations/unit/errorHandler.test.ts index d614debd..b5c06777 100644 --- a/integrations/unit/errorHandler.test.ts +++ b/integrations/unit/errorHandler.test.ts @@ -11,8 +11,8 @@ function makeMocks() { json: vi.fn(), } as Partial const next: NextFunction = vi.fn() - -return { req, res, next } + + return { req, res, next } } // ── errorHandler ────────────────────────────────────────────────────────────── @@ -104,4 +104,4 @@ describe('errorHandler', () => { message: 'Forbidden', }) }) -}) \ No newline at end of file +}) diff --git a/integrations/unit/graceful-shutdown.test.ts b/integrations/unit/graceful-shutdown.test.ts index 0f1f5d7a..337be370 100644 --- a/integrations/unit/graceful-shutdown.test.ts +++ b/integrations/unit/graceful-shutdown.test.ts @@ -8,14 +8,14 @@ describe('Graceful Shutdown', () => { if (serverProcess && !serverProcess.killed) { serverProcess.kill('SIGTERM') // Wait a bit for the process to shut down - await new Promise(resolve => setTimeout(resolve, 1000)) + await new Promise((resolve) => setTimeout(resolve, 1000)) } }) it('should handle SIGTERM and shutdown gracefully', async () => { // This is a simulation test - in real scenario, you'd start the server // and send SIGTERM to test graceful shutdown - + const mockServer = { close: vi.fn((callback) => callback()), } @@ -55,7 +55,7 @@ describe('Graceful Shutdown', () => { } const lifecycleSweepInterval = setInterval(() => {}, 1000) - + // Simulate clearing interval during shutdown clearInterval(lifecycleSweepInterval) mockInterval.clear() @@ -131,7 +131,7 @@ describe('Graceful Shutdown', () => { isShuttingDown = true shutdownCount++ // Simulate shutdown - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) } // Simulate multiple signals diff --git a/integrations/unit/health.routes.test.ts b/integrations/unit/health.routes.test.ts index 939bf9dc..00f8a852 100644 --- a/integrations/unit/health.routes.test.ts +++ b/integrations/unit/health.routes.test.ts @@ -63,7 +63,9 @@ describe('Health Routes', () => { it('should return 503 when database is unavailable', async () => { // Mock database failure - vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error('Connection refused')) + vi.mocked(prisma.$queryRaw).mockRejectedValue( + new Error('Connection refused'), + ) const response = await request(app).get('/health/ready') diff --git a/integrations/unit/rate-limit.test.ts b/integrations/unit/rate-limit.test.ts index c9cf7e94..41d3f05d 100644 --- a/integrations/unit/rate-limit.test.ts +++ b/integrations/unit/rate-limit.test.ts @@ -1,115 +1,123 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { Request, Response, NextFunction } from 'express' -import { generalLimiter, authLimiter, employerLimiter, authenticatedLimiter, dynamicRateLimiter } from '../../src/middleware/rate-limit.middleware' - -// Mock the env -vi.mock('../../src/config/env', () => ({ - env: { - RATE_LIMIT_GENERAL_WINDOW_MS: 900000, // 15 min - RATE_LIMIT_GENERAL_MAX: 100, - RATE_LIMIT_AUTH_WINDOW_MS: 900000, - RATE_LIMIT_AUTH_MAX: 10, - RATE_LIMIT_EMPLOYER_WINDOW_MS: 900000, - RATE_LIMIT_EMPLOYER_MAX: 500, - RATE_LIMIT_AUTHENTICATED_WINDOW_MS: 900000, - RATE_LIMIT_AUTHENTICATED_MAX: 1000, - }, -})) - -describe('Rate Limiting Middleware', () => { - let mockReq: Partial - let mockRes: Partial - let mockNext: NextFunction - - beforeEach(() => { - mockReq = { - headers: {}, - connection: { remoteAddress: '127.0.0.1' }, - socket: { remoteAddress: '127.0.0.1' }, - originalUrl: '/test', - } - mockRes = { - set: vi.fn(), - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } - mockNext = vi.fn() - }) - - describe('General Limiter', () => { - it('should allow requests within limit', () => { - for (let i = 0; i < 100; i++) { - generalLimiter(mockReq as Request, mockRes as Response, mockNext) - } - expect(mockNext).toHaveBeenCalledTimes(100) - expect(mockRes.status).not.toHaveBeenCalled() - }) - - it('should block requests over limit', () => { - for (let i = 0; i < 101; i++) { - generalLimiter(mockReq as Request, mockRes as Response, mockNext) - } - expect(mockNext).toHaveBeenCalledTimes(100) - expect(mockRes.status).toHaveBeenCalledWith(429) - expect(mockRes.json).toHaveBeenCalledWith({ error: 'Too many requests, please try again later.' }) - }) - - it('should set correct headers', () => { - generalLimiter(mockReq as Request, mockRes as Response, mockNext) - expect(mockRes.set).toHaveBeenCalledWith({ - 'X-RateLimit-Limit': '100', - 'X-RateLimit-Remaining': '99', - 'X-RateLimit-Reset': expect.any(String), - }) - }) - }) - - describe('Auth Limiter', () => { - it('should have stricter limits', () => { - for (let i = 0; i < 11; i++) { - authLimiter(mockReq as Request, mockRes as Response, mockNext) - } - expect(mockNext).toHaveBeenCalledTimes(10) - expect(mockRes.status).toHaveBeenCalledWith(429) - }) - }) - - describe('Employer Limiter', () => { - it('should have higher limits', () => { - for (let i = 0; i < 500; i++) { - employerLimiter(mockReq as Request, mockRes as Response, mockNext) - } - expect(mockNext).toHaveBeenCalledTimes(500) - expect(mockRes.status).not.toHaveBeenCalled() - }) - }) - - describe('Authenticated Limiter', () => { - it('should have high limits', () => { - for (let i = 0; i < 1000; i++) { - authenticatedLimiter(mockReq as Request, mockRes as Response, mockNext) - } - expect(mockNext).toHaveBeenCalledTimes(1000) - expect(mockRes.status).not.toHaveBeenCalled() - }) - }) - - describe('Dynamic Rate Limiter', () => { - it('should use general limiter for unauthenticated', () => { - dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) - expect(mockNext).toHaveBeenCalled() - }) - - it('should use authenticated limiter for authenticated users', () => { - (mockReq as any).user = { role: 'user' } - dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) - expect(mockNext).toHaveBeenCalled() - }) - - it('should use employer limiter for employers', () => { - (mockReq as any).user = { role: 'employer' } - dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) - expect(mockNext).toHaveBeenCalled() - }) - }) -}) \ No newline at end of file +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Request, Response, NextFunction } from 'express' +import { + generalLimiter, + authLimiter, + employerLimiter, + authenticatedLimiter, + dynamicRateLimiter, +} from '../../src/middleware/rate-limit.middleware' + +// Mock the env +vi.mock('../../src/config/env', () => ({ + env: { + RATE_LIMIT_GENERAL_WINDOW_MS: 900000, // 15 min + RATE_LIMIT_GENERAL_MAX: 100, + RATE_LIMIT_AUTH_WINDOW_MS: 900000, + RATE_LIMIT_AUTH_MAX: 10, + RATE_LIMIT_EMPLOYER_WINDOW_MS: 900000, + RATE_LIMIT_EMPLOYER_MAX: 500, + RATE_LIMIT_AUTHENTICATED_WINDOW_MS: 900000, + RATE_LIMIT_AUTHENTICATED_MAX: 1000, + }, +})) + +describe('Rate Limiting Middleware', () => { + let mockReq: Partial + let mockRes: Partial + let mockNext: NextFunction + + beforeEach(() => { + mockReq = { + headers: {}, + connection: { remoteAddress: '127.0.0.1' }, + socket: { remoteAddress: '127.0.0.1' }, + originalUrl: '/test', + } + mockRes = { + set: vi.fn(), + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } + mockNext = vi.fn() + }) + + describe('General Limiter', () => { + it('should allow requests within limit', () => { + for (let i = 0; i < 100; i++) { + generalLimiter(mockReq as Request, mockRes as Response, mockNext) + } + expect(mockNext).toHaveBeenCalledTimes(100) + expect(mockRes.status).not.toHaveBeenCalled() + }) + + it('should block requests over limit', () => { + for (let i = 0; i < 101; i++) { + generalLimiter(mockReq as Request, mockRes as Response, mockNext) + } + expect(mockNext).toHaveBeenCalledTimes(100) + expect(mockRes.status).toHaveBeenCalledWith(429) + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Too many requests, please try again later.', + }) + }) + + it('should set correct headers', () => { + generalLimiter(mockReq as Request, mockRes as Response, mockNext) + expect(mockRes.set).toHaveBeenCalledWith({ + 'X-RateLimit-Limit': '100', + 'X-RateLimit-Remaining': '99', + 'X-RateLimit-Reset': expect.any(String), + }) + }) + }) + + describe('Auth Limiter', () => { + it('should have stricter limits', () => { + for (let i = 0; i < 11; i++) { + authLimiter(mockReq as Request, mockRes as Response, mockNext) + } + expect(mockNext).toHaveBeenCalledTimes(10) + expect(mockRes.status).toHaveBeenCalledWith(429) + }) + }) + + describe('Employer Limiter', () => { + it('should have higher limits', () => { + for (let i = 0; i < 500; i++) { + employerLimiter(mockReq as Request, mockRes as Response, mockNext) + } + expect(mockNext).toHaveBeenCalledTimes(500) + expect(mockRes.status).not.toHaveBeenCalled() + }) + }) + + describe('Authenticated Limiter', () => { + it('should have high limits', () => { + for (let i = 0; i < 1000; i++) { + authenticatedLimiter(mockReq as Request, mockRes as Response, mockNext) + } + expect(mockNext).toHaveBeenCalledTimes(1000) + expect(mockRes.status).not.toHaveBeenCalled() + }) + }) + + describe('Dynamic Rate Limiter', () => { + it('should use general limiter for unauthenticated', () => { + dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) + expect(mockNext).toHaveBeenCalled() + }) + + it('should use authenticated limiter for authenticated users', () => { + ;(mockReq as any).user = { role: 'user' } + dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) + expect(mockNext).toHaveBeenCalled() + }) + + it('should use employer limiter for employers', () => { + ;(mockReq as any).user = { role: 'employer' } + dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) + expect(mockNext).toHaveBeenCalled() + }) + }) +}) diff --git a/integrations/unit/request-context.test.ts b/integrations/unit/request-context.test.ts index 41057ed9..dca736d1 100644 --- a/integrations/unit/request-context.test.ts +++ b/integrations/unit/request-context.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, beforeEach } from 'vitest' import request from 'supertest' import express, { Request, Response, NextFunction } from 'express' -import { requestContext, getRequestId, getActor } from '../../src/middleware/request-context' +import { + requestContext, + getRequestId, + getActor, +} from '../../src/middleware/request-context' describe('Request Context Middleware', () => { let app: express.Application @@ -178,7 +182,7 @@ describe('Request Context Middleware', () => { request(app).get('/test'), ]) - const requestIds = responses.map(r => r.body.requestId) + const requestIds = responses.map((r) => r.body.requestId) // All request IDs should be unique const uniqueIds = new Set(requestIds) diff --git a/integrations/unit/reward.service.test.ts b/integrations/unit/reward.service.test.ts index 5f7646e1..696de971 100644 --- a/integrations/unit/reward.service.test.ts +++ b/integrations/unit/reward.service.test.ts @@ -39,13 +39,11 @@ describe('RewardService', () => { beforeEach(() => { stellarMock = { - sendPayment: vi - .fn() - .mockResolvedValue({ - hash: MOCK_TX_HASH, - ledger: 123, - successful: true, - }), + sendPayment: vi.fn().mockResolvedValue({ + hash: MOCK_TX_HASH, + ledger: 123, + successful: true, + }), verifyTransaction: vi.fn().mockResolvedValue(true), } as unknown as StellarService diff --git a/integrations/unit/validation.middleware.test.ts b/integrations/unit/validation.middleware.test.ts index d7a34d04..c9a2500c 100644 --- a/integrations/unit/validation.middleware.test.ts +++ b/integrations/unit/validation.middleware.test.ts @@ -11,7 +11,11 @@ import { z } from 'zod' // ── helpers ─────────────────────────────────────────────────────────────────── -function makeMocks (body: Record = {}, query: Record = {}, params: Record = {}) { +function makeMocks( + body: Record = {}, + query: Record = {}, + params: Record = {}, +) { const req = { body, query, params } as Partial const res = { status: vi.fn().mockReturnThis(), @@ -31,7 +35,9 @@ describe('commonSchemas', () => { }) it('rejects invalid email', () => { - expect(() => commonSchemas.email.parse('invalid-email')).toThrow('Invalid email format') + expect(() => commonSchemas.email.parse('invalid-email')).toThrow( + 'Invalid email format', + ) }) }) @@ -41,33 +47,47 @@ describe('commonSchemas', () => { }) it('rejects short password', () => { - expect(() => commonSchemas.password.parse('Ab1!')).toThrow('Password must be at least 8 characters long') + expect(() => commonSchemas.password.parse('Ab1!')).toThrow( + 'Password must be at least 8 characters long', + ) }) it('rejects password without lowercase', () => { - expect(() => commonSchemas.password.parse('STRONGPASS1!')).toThrow('Password must contain at least one lowercase letter') + expect(() => commonSchemas.password.parse('STRONGPASS1!')).toThrow( + 'Password must contain at least one lowercase letter', + ) }) it('rejects password without uppercase', () => { - expect(() => commonSchemas.password.parse('strongpass1!')).toThrow('Password must contain at least one uppercase letter') + expect(() => commonSchemas.password.parse('strongpass1!')).toThrow( + 'Password must contain at least one uppercase letter', + ) }) it('rejects password without number', () => { - expect(() => commonSchemas.password.parse('StrongPass!')).toThrow('Password must contain at least one number') + expect(() => commonSchemas.password.parse('StrongPass!')).toThrow( + 'Password must contain at least one number', + ) }) it('rejects password without special character', () => { - expect(() => commonSchemas.password.parse('StrongPass1')).toThrow('Password must contain at least one special character') + expect(() => commonSchemas.password.parse('StrongPass1')).toThrow( + 'Password must contain at least one special character', + ) }) }) describe('id', () => { it('validates correct UUID', () => { - expect(() => commonSchemas.id.parse('123e4567-e89b-12d3-a456-426614174000')).not.toThrow() + expect(() => + commonSchemas.id.parse('123e4567-e89b-12d3-a456-426614174000'), + ).not.toThrow() }) it('rejects invalid UUID', () => { - expect(() => commonSchemas.id.parse('not-a-uuid')).toThrow('Invalid ID format') + expect(() => commonSchemas.id.parse('not-a-uuid')).toThrow( + 'Invalid ID format', + ) }) }) @@ -77,25 +97,35 @@ describe('commonSchemas', () => { }) it('rejects short username', () => { - expect(() => commonSchemas.username.parse('ab')).toThrow('Username must be at least 3 characters long') + expect(() => commonSchemas.username.parse('ab')).toThrow( + 'Username must be at least 3 characters long', + ) }) it('rejects long username', () => { - expect(() => commonSchemas.username.parse('a'.repeat(31))).toThrow('Username must be less than 30 characters') + expect(() => commonSchemas.username.parse('a'.repeat(31))).toThrow( + 'Username must be less than 30 characters', + ) }) it('rejects username with invalid characters', () => { - expect(() => commonSchemas.username.parse('bad user!')).toThrow('Username can only contain letters, numbers, and underscores') + expect(() => commonSchemas.username.parse('bad user!')).toThrow( + 'Username can only contain letters, numbers, and underscores', + ) }) }) describe('walletAddress', () => { it('validates correct Stellar address', () => { - expect(() => commonSchemas.walletAddress.parse('G' + 'A'.repeat(55))).not.toThrow() + expect(() => + commonSchemas.walletAddress.parse('G' + 'A'.repeat(55)), + ).not.toThrow() }) it('rejects invalid Stellar address', () => { - expect(() => commonSchemas.walletAddress.parse('X' + 'A'.repeat(55))).toThrow('Invalid Stellar wallet address format') + expect(() => + commonSchemas.walletAddress.parse('X' + 'A'.repeat(55)), + ).toThrow('Invalid Stellar wallet address format') }) }) @@ -105,7 +135,9 @@ describe('commonSchemas', () => { }) it('rejects invalid URL', () => { - expect(() => commonSchemas.url.parse('not-a-url')).toThrow('Invalid URL format') + expect(() => commonSchemas.url.parse('not-a-url')).toThrow( + 'Invalid URL format', + ) }) }) }) @@ -134,7 +166,7 @@ describe('validate', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['String must contain at least 5 character(s)'] } + errors: { body: ['String must contain at least 5 character(s)'] }, }) expect(next).not.toHaveBeenCalled() }) @@ -149,7 +181,7 @@ describe('validate', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { query: expect.any(Array) } + errors: { query: expect.any(Array) }, }) expect(next).not.toHaveBeenCalled() }) @@ -164,7 +196,7 @@ describe('validate', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { params: ['Invalid ID format'] } + errors: { params: ['Invalid ID format'] }, }) expect(next).not.toHaveBeenCalled() }) @@ -173,7 +205,10 @@ describe('validate', () => { const bodySchema = z.object({ name: z.string().min(5) }) const querySchema = z.object({ limit: z.number() }) const middleware = validate({ body: bodySchema, query: querySchema }) - const { req, res, next } = makeMocks({ name: 'abc' }, { limit: 'not-a-number' }) + const { req, res, next } = makeMocks( + { name: 'abc' }, + { limit: 'not-a-number' }, + ) middleware(req as Request, res as Response, next) @@ -185,7 +220,9 @@ describe('validate', () => { }) it('parses and updates req.body when validation passes', () => { - const schema = z.object({ age: z.string().transform(val => parseInt(val)) }) + const schema = z.object({ + age: z.string().transform((val) => parseInt(val)), + }) const middleware = validate({ body: schema }) const { req, res, next } = makeMocks({ age: '25' }) @@ -230,7 +267,7 @@ describe('validateProfileUpdate', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Username must be at least 3 characters long'] } + errors: { body: ['Username must be at least 3 characters long'] }, }) expect(next).not.toHaveBeenCalled() }) @@ -243,7 +280,7 @@ describe('validateProfileUpdate', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Username must be less than 30 characters'] } + errors: { body: ['Username must be less than 30 characters'] }, }) }) @@ -255,7 +292,9 @@ describe('validateProfileUpdate', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Username can only contain letters, numbers, and underscores'] } + errors: { + body: ['Username can only contain letters, numbers, and underscores'], + }, }) }) @@ -267,7 +306,7 @@ describe('validateProfileUpdate', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['First name must be less than 50 characters'] } + errors: { body: ['First name must be less than 50 characters'] }, }) }) @@ -279,7 +318,7 @@ describe('validateProfileUpdate', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Last name must be less than 50 characters'] } + errors: { body: ['Last name must be less than 50 characters'] }, }) }) @@ -291,7 +330,7 @@ describe('validateProfileUpdate', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Bio must be less than 500 characters'] } + errors: { body: ['Bio must be less than 500 characters'] }, }) }) @@ -303,7 +342,7 @@ describe('validateProfileUpdate', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Invalid URL format'] } + errors: { body: ['Invalid URL format'] }, }) }) @@ -344,7 +383,7 @@ describe('validatePasswordChange', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Current password is required'] } + errors: { body: ['Current password is required'] }, }) }) @@ -356,79 +395,101 @@ describe('validatePasswordChange', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['New password is required'] } + errors: { body: ['New password is required'] }, }) }) it('returns 400 when newPassword is too short', () => { - const { req, res, next } = makeMocks({ currentPassword: 'OldPass1!', newPassword: 'Ab1!' }) + const { req, res, next } = makeMocks({ + currentPassword: 'OldPass1!', + newPassword: 'Ab1!', + }) validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Password must be at least 8 characters long'] } + errors: { body: ['Password must be at least 8 characters long'] }, }) }) it('returns 400 when newPassword has no lowercase letter', () => { - const { req, res, next } = makeMocks({ currentPassword: 'OldPass1!', newPassword: 'NEWPASS1!' }) + const { req, res, next } = makeMocks({ + currentPassword: 'OldPass1!', + newPassword: 'NEWPASS1!', + }) validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Password must contain at least one lowercase letter'] } + errors: { body: ['Password must contain at least one lowercase letter'] }, }) }) it('returns 400 when newPassword has no uppercase letter', () => { - const { req, res, next } = makeMocks({ currentPassword: 'OldPass1!', newPassword: 'newpass1!' }) + const { req, res, next } = makeMocks({ + currentPassword: 'OldPass1!', + newPassword: 'newpass1!', + }) validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Password must contain at least one uppercase letter'] } + errors: { body: ['Password must contain at least one uppercase letter'] }, }) }) it('returns 400 when newPassword has no number', () => { - const { req, res, next } = makeMocks({ currentPassword: 'OldPass1!', newPassword: 'NewPassword!' }) + const { req, res, next } = makeMocks({ + currentPassword: 'OldPass1!', + newPassword: 'NewPassword!', + }) validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Password must contain at least one number'] } + errors: { body: ['Password must contain at least one number'] }, }) }) it('returns 400 when newPassword has no special character', () => { - const { req, res, next } = makeMocks({ currentPassword: 'OldPass1!', newPassword: 'NewPassword1' }) + const { req, res, next } = makeMocks({ + currentPassword: 'OldPass1!', + newPassword: 'NewPassword1', + }) validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Password must contain at least one special character'] } + errors: { + body: ['Password must contain at least one special character'], + }, }) }) it('returns 400 when newPassword is the same as currentPassword', () => { - const { req, res, next } = makeMocks({ currentPassword: 'SamePass1!', newPassword: 'SamePass1!' }) + const { req, res, next } = makeMocks({ + currentPassword: 'SamePass1!', + newPassword: 'SamePass1!', + }) validatePasswordChange(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['New password must be different from current password'] } + errors: { + body: ['New password must be different from current password'], + }, }) }) }) @@ -455,20 +516,22 @@ describe('validateWalletAddress', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Required'] } + errors: { body: ['Required'] }, }) expect(next).not.toHaveBeenCalled() }) it('returns 400 when walletAddress does not start with G', () => { - const { req, res, next } = makeMocks({ walletAddress: 'X' + 'A'.repeat(55) }) + const { req, res, next } = makeMocks({ + walletAddress: 'X' + 'A'.repeat(55), + }) validateWalletAddress(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Invalid Stellar wallet address format'] } + errors: { body: ['Invalid Stellar wallet address format'] }, }) }) @@ -480,19 +543,21 @@ describe('validateWalletAddress', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Invalid Stellar wallet address format'] } + errors: { body: ['Invalid Stellar wallet address format'] }, }) }) it('returns 400 when walletAddress contains lowercase characters', () => { - const { req, res, next } = makeMocks({ walletAddress: 'g' + 'a'.repeat(55) }) + const { req, res, next } = makeMocks({ + walletAddress: 'g' + 'a'.repeat(55), + }) validateWalletAddress(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Invalid Stellar wallet address format'] } + errors: { body: ['Invalid Stellar wallet address format'] }, }) }) @@ -504,8 +569,8 @@ describe('validateWalletAddress', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith({ message: 'Validation failed', - errors: { body: ['Expected string, received number'] } + errors: { body: ['Expected string, received number'] }, }) expect(next).not.toHaveBeenCalled() }) -}) \ No newline at end of file +}) diff --git a/integrations/user.controller.test.ts b/integrations/user.controller.test.ts index 6137cc5d..47e38f50 100644 --- a/integrations/user.controller.test.ts +++ b/integrations/user.controller.test.ts @@ -1,282 +1,347 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { Request, Response } from 'express' -import { UserController } from '../src/controllers/user.controller' -import { User } from '../src/types/user.types' - -interface AuthRequest extends Request { - user?: { - id: string; - email: string; - }; -} - -describe('UserController', () => { - let userController: UserController - let mockRequest: Partial - let mockResponse: Partial - - beforeEach(() => { - userController = new UserController() - mockRequest = {} - mockResponse = { - json: vi.fn(), - status: vi.fn().mockReturnThis(), - } - }) - - describe('getCurrentUser', () => { - it('should return current user profile', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'testuser', - firstName: 'Test', - lastName: 'User', - bio: 'Test bio', - avatar: 'https://example.com/avatar.jpg', - walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.user = { id: '1', email: 'test@example.com' } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - - await userController.getCurrentUser(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.json).toHaveBeenCalledWith({ - id: mockUser.id, - email: mockUser.email, - username: mockUser.username, - firstName: mockUser.firstName, - lastName: mockUser.lastName, - bio: mockUser.bio, - avatar: mockUser.avatar, - walletAddress: mockUser.walletAddress, - isActive: mockUser.isActive, - createdAt: mockUser.createdAt, - updatedAt: mockUser.updatedAt, - }) - }) - - it('should return 404 if user not found', async () => { - mockRequest.user = { id: '1', email: 'test@example.com' } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(null) - - await userController.getCurrentUser(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(404) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User not found' }) - }) - }) - - describe('updateProfile', () => { - it('should update user profile successfully', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'updateduser', - firstName: 'Updated', - lastName: 'User', - bio: 'Updated bio', - avatar: 'https://example.com/new-avatar.jpg', - walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - username: 'updateduser', - firstName: 'Updated', - lastName: 'User', - bio: 'Updated bio', - avatar: 'https://example.com/new-avatar.jpg', - } - - vi.spyOn(userController as any, 'updateUserProfile').mockResolvedValue(mockUser) - - await userController.updateProfile(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.json).toHaveBeenCalledWith({ - id: mockUser.id, - email: mockUser.email, - username: mockUser.username, - firstName: mockUser.firstName, - lastName: mockUser.lastName, - bio: mockUser.bio, - avatar: mockUser.avatar, - walletAddress: mockUser.walletAddress, - isActive: mockUser.isActive, - createdAt: mockUser.createdAt, - updatedAt: mockUser.updatedAt, - }) - }) - }) - - describe('getUserById', () => { - it('should return public user info', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'testuser', - firstName: 'Test', - lastName: 'User', - bio: 'Test bio', - avatar: 'https://example.com/avatar.jpg', - walletAddress: 'GABC123456789012345678901234567890123456789012345678901234567890', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.params = { id: '1' } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - - await userController.getUserById(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.json).toHaveBeenCalledWith({ - id: mockUser.id, - username: mockUser.username, - firstName: mockUser.firstName, - lastName: mockUser.lastName, - avatar: mockUser.avatar, - createdAt: mockUser.createdAt, - }) - }) - - it('should return 404 if user not found', async () => { - mockRequest.params = { id: '1' } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(null) - - await userController.getUserById(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(404) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User not found' }) - }) - }) - - describe('changePassword', () => { - it('should change password successfully', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'testuser', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - currentPassword: 'oldpassword', - newPassword: 'NewPassword123!', - } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - vi.spyOn(userController as any, 'validatePassword').mockResolvedValue(true) - vi.spyOn(userController as any, 'updateUserPassword').mockResolvedValue(undefined) - - await userController.changePassword(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Password updated successfully' }) - }) - - it('should return 400 if current password is incorrect', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'testuser', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - currentPassword: 'wrongpassword', - newPassword: 'NewPassword123!', - } - - vi.spyOn(userController as any, 'findUserById').mockResolvedValue(mockUser) - vi.spyOn(userController as any, 'validatePassword').mockResolvedValue(false) - - await userController.changePassword(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Current password is incorrect' }) - }) - }) - - describe('updateWalletAddress', () => { - it('should update wallet address successfully', async () => { - const mockUser: User = { - id: '1', - email: 'test@example.com', - username: 'testuser', - walletAddress: 'GABC1234567890123456789012345678901234567890123456789', - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - } - - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - walletAddress: 'GABC1234567890123456789012345678901234567890123456789', - } - - vi.spyOn(userController as any, 'updateUserWallet').mockResolvedValue(mockUser) - - await userController.updateWalletAddress(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.json).toHaveBeenCalledWith({ - id: mockUser.id, - email: mockUser.email, - username: mockUser.username, - firstName: mockUser.firstName, - lastName: mockUser.lastName, - bio: mockUser.bio, - avatar: mockUser.avatar, - walletAddress: mockUser.walletAddress, - isActive: mockUser.isActive, - createdAt: mockUser.createdAt, - updatedAt: mockUser.updatedAt, - }) - }) - - it('should return 400 for invalid wallet address', async () => { - mockRequest.user = { id: '1', email: 'test@example.com' } - mockRequest.body = { - walletAddress: 'invalid-address', - } - - await userController.updateWalletAddress(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid Stellar wallet address' }) - }) - }) - - describe('isValidStellarAddress', () => { - it('should validate correct Stellar address', () => { - const validAddress = 'GABC1234567890123456789012345678901234567890123456789' - expect((userController as any).isValidStellarAddress(validAddress)).toBe(true) - }) - - it('should reject invalid Stellar address', () => { - const invalidAddress = 'invalid-address' - expect((userController as any).isValidStellarAddress(invalidAddress)).toBe(false) - }) - - it('should reject address with wrong length', () => { - const shortAddress = 'GABC123' - expect((userController as any).isValidStellarAddress(shortAddress)).toBe(false) - }) - }) -}) +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Request, Response } from 'express' +import { UserController } from '../src/controllers/user.controller' +import { User } from '../src/types/user.types' + +interface AuthRequest extends Request { + user?: { + id: string + email: string + } +} + +describe('UserController', () => { + let userController: UserController + let mockRequest: Partial + let mockResponse: Partial + + beforeEach(() => { + userController = new UserController() + mockRequest = {} + mockResponse = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } + }) + + describe('getCurrentUser', () => { + it('should return current user profile', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'testuser', + firstName: 'Test', + lastName: 'User', + bio: 'Test bio', + avatar: 'https://example.com/avatar.jpg', + walletAddress: + 'GABC123456789012345678901234567890123456789012345678901234567890', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.user = { id: '1', email: 'test@example.com' } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue( + mockUser, + ) + + await userController.getCurrentUser( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.json).toHaveBeenCalledWith({ + id: mockUser.id, + email: mockUser.email, + username: mockUser.username, + firstName: mockUser.firstName, + lastName: mockUser.lastName, + bio: mockUser.bio, + avatar: mockUser.avatar, + walletAddress: mockUser.walletAddress, + isActive: mockUser.isActive, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }) + }) + + it('should return 404 if user not found', async () => { + mockRequest.user = { id: '1', email: 'test@example.com' } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue(null) + + await userController.getCurrentUser( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(404) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'User not found', + }) + }) + }) + + describe('updateProfile', () => { + it('should update user profile successfully', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'updateduser', + firstName: 'Updated', + lastName: 'User', + bio: 'Updated bio', + avatar: 'https://example.com/new-avatar.jpg', + walletAddress: + 'GABC123456789012345678901234567890123456789012345678901234567890', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.user = { id: '1', email: 'test@example.com' } + mockRequest.body = { + username: 'updateduser', + firstName: 'Updated', + lastName: 'User', + bio: 'Updated bio', + avatar: 'https://example.com/new-avatar.jpg', + } + + vi.spyOn(userController as any, 'updateUserProfile').mockResolvedValue( + mockUser, + ) + + await userController.updateProfile( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.json).toHaveBeenCalledWith({ + id: mockUser.id, + email: mockUser.email, + username: mockUser.username, + firstName: mockUser.firstName, + lastName: mockUser.lastName, + bio: mockUser.bio, + avatar: mockUser.avatar, + walletAddress: mockUser.walletAddress, + isActive: mockUser.isActive, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }) + }) + }) + + describe('getUserById', () => { + it('should return public user info', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'testuser', + firstName: 'Test', + lastName: 'User', + bio: 'Test bio', + avatar: 'https://example.com/avatar.jpg', + walletAddress: + 'GABC123456789012345678901234567890123456789012345678901234567890', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.params = { id: '1' } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue( + mockUser, + ) + + await userController.getUserById( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.json).toHaveBeenCalledWith({ + id: mockUser.id, + username: mockUser.username, + firstName: mockUser.firstName, + lastName: mockUser.lastName, + avatar: mockUser.avatar, + createdAt: mockUser.createdAt, + }) + }) + + it('should return 404 if user not found', async () => { + mockRequest.params = { id: '1' } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue(null) + + await userController.getUserById( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(404) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'User not found', + }) + }) + }) + + describe('changePassword', () => { + it('should change password successfully', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'testuser', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.user = { id: '1', email: 'test@example.com' } + mockRequest.body = { + currentPassword: 'oldpassword', + newPassword: 'NewPassword123!', + } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue( + mockUser, + ) + vi.spyOn(userController as any, 'validatePassword').mockResolvedValue( + true, + ) + vi.spyOn(userController as any, 'updateUserPassword').mockResolvedValue( + undefined, + ) + + await userController.changePassword( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Password updated successfully', + }) + }) + + it('should return 400 if current password is incorrect', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'testuser', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.user = { id: '1', email: 'test@example.com' } + mockRequest.body = { + currentPassword: 'wrongpassword', + newPassword: 'NewPassword123!', + } + + vi.spyOn(userController as any, 'findUserById').mockResolvedValue( + mockUser, + ) + vi.spyOn(userController as any, 'validatePassword').mockResolvedValue( + false, + ) + + await userController.changePassword( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Current password is incorrect', + }) + }) + }) + + describe('updateWalletAddress', () => { + it('should update wallet address successfully', async () => { + const mockUser: User = { + id: '1', + email: 'test@example.com', + username: 'testuser', + walletAddress: 'GABC1234567890123456789012345678901234567890123456789', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } + + mockRequest.user = { id: '1', email: 'test@example.com' } + mockRequest.body = { + walletAddress: 'GABC1234567890123456789012345678901234567890123456789', + } + + vi.spyOn(userController as any, 'updateUserWallet').mockResolvedValue( + mockUser, + ) + + await userController.updateWalletAddress( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.json).toHaveBeenCalledWith({ + id: mockUser.id, + email: mockUser.email, + username: mockUser.username, + firstName: mockUser.firstName, + lastName: mockUser.lastName, + bio: mockUser.bio, + avatar: mockUser.avatar, + walletAddress: mockUser.walletAddress, + isActive: mockUser.isActive, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }) + }) + + it('should return 400 for invalid wallet address', async () => { + mockRequest.user = { id: '1', email: 'test@example.com' } + mockRequest.body = { + walletAddress: 'invalid-address', + } + + await userController.updateWalletAddress( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid Stellar wallet address', + }) + }) + }) + + describe('isValidStellarAddress', () => { + it('should validate correct Stellar address', () => { + const validAddress = + 'GABC1234567890123456789012345678901234567890123456789' + expect((userController as any).isValidStellarAddress(validAddress)).toBe( + true, + ) + }) + + it('should reject invalid Stellar address', () => { + const invalidAddress = 'invalid-address' + expect( + (userController as any).isValidStellarAddress(invalidAddress), + ).toBe(false) + }) + + it('should reject address with wrong length', () => { + const shortAddress = 'GABC123' + expect((userController as any).isValidStellarAddress(shortAddress)).toBe( + false, + ) + }) + }) +}) diff --git a/nodemon.json b/nodemon.json index 9eea9a47..857e581f 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,5 +1,5 @@ -{ - "watch": ["src"], - "ext": "ts", - "exec": "tsx src/server.ts" -} \ No newline at end of file +{ + "watch": ["src"], + "ext": "ts", + "exec": "tsx src/server.ts" +} diff --git a/package.json b/package.json index 436419cc..87283712 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,11 @@ "prebuild": "prisma generate", "build": "tsc", "start": "node dist/server.js", + "format": "prettier --write .", + "format:check": "prettier --check .", "lint": "eslint .", + "typecheck": "tsc --noEmit", + "prisma:format:check": "prisma format --check", "test": "vitest", "test:watch": "vitest", "test:coverage": "vitest run --coverage", @@ -85,6 +89,7 @@ "eslint": "^10.0.2", "nodemon": "^3.1.14", "prisma": "^7.4.2", + "prettier": "^3.5.3", "supertest": "^7.2.2", "ts-node": "^10.9.2", "tsx": "^4.7.0", diff --git a/patches/zeptomatch-cjs-shim/index.js b/patches/zeptomatch-cjs-shim/index.js index 6e969f25..f96cc73b 100644 --- a/patches/zeptomatch-cjs-shim/index.js +++ b/patches/zeptomatch-cjs-shim/index.js @@ -1,4 +1,4 @@ -'use strict'; +'use strict' /** * Minimal CJS zeptomatch-compatible glob matcher. @@ -6,80 +6,80 @@ * zeptomatch(pattern, path) → boolean */ -const SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g; +const SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g function escapeRegex(str) { - return str.replace(SPECIAL_CHARS, '\\$&'); + return str.replace(SPECIAL_CHARS, '\\$&') } function compilePattern(pattern) { - let regexStr = '^'; - let i = 0; - const len = pattern.length; + let regexStr = '^' + let i = 0 + const len = pattern.length while (i < len) { - const ch = pattern[i]; + const ch = pattern[i] if (ch === '*') { if (pattern[i + 1] === '*') { - regexStr += '.*'; - i += 2; - if (pattern[i] === '/') i++; + regexStr += '.*' + i += 2 + if (pattern[i] === '/') i++ } else { - regexStr += '[^/]*'; - i++; + regexStr += '[^/]*' + i++ } } else if (ch === '?') { - regexStr += '[^/]'; - i++; + regexStr += '[^/]' + i++ } else if (ch === '{') { - let j = i + 1; - let depth = 1; + let j = i + 1 + let depth = 1 while (j < len && depth > 0) { - if (pattern[j] === '{') depth++; - else if (pattern[j] === '}') depth--; - j++; + if (pattern[j] === '{') depth++ + else if (pattern[j] === '}') depth-- + j++ } - const alternatives = pattern.slice(i + 1, j - 1).split(','); - regexStr += '(' + alternatives.map(escapeRegex).join('|') + ')'; - i = j; + const alternatives = pattern.slice(i + 1, j - 1).split(',') + regexStr += '(' + alternatives.map(escapeRegex).join('|') + ')' + i = j } else if (ch === '[') { - let j = i + 1; - while (j < len && pattern[j] !== ']') j++; - regexStr += pattern.slice(i, j + 1); - i = j + 1; + let j = i + 1 + while (j < len && pattern[j] !== ']') j++ + regexStr += pattern.slice(i, j + 1) + i = j + 1 } else if (ch === '\\') { - regexStr += escapeRegex(pattern[i + 1]); - i += 2; + regexStr += escapeRegex(pattern[i + 1]) + i += 2 } else { - regexStr += escapeRegex(ch); - i++; + regexStr += escapeRegex(ch) + i++ } } - regexStr += '$'; - return new RegExp(regexStr); + regexStr += '$' + return new RegExp(regexStr) } -const cache = new Map(); +const cache = new Map() function zeptomatch(pattern, path) { - if (typeof pattern !== 'string') return false; - let re = cache.get(pattern); + if (typeof pattern !== 'string') return false + let re = cache.get(pattern) if (!re) { - re = compilePattern(pattern); - cache.set(pattern, re); + re = compilePattern(pattern) + cache.set(pattern, re) } - return re.test(path); + return re.test(path) } zeptomatch.compile = function compileGlob(pattern) { if (typeof pattern === 'string') { - const re = compilePattern(pattern); - return { test: (path) => re.test(path) }; + const re = compilePattern(pattern) + return { test: (path) => re.test(path) } } - return { test: () => false }; -}; + return { test: () => false } +} -module.exports = zeptomatch; -module.exports.default = zeptomatch; +module.exports = zeptomatch +module.exports.default = zeptomatch diff --git a/patches/zeptomatch-cjs-wrapper.js b/patches/zeptomatch-cjs-wrapper.js index 785a07fa..80712c6d 100644 --- a/patches/zeptomatch-cjs-wrapper.js +++ b/patches/zeptomatch-cjs-wrapper.js @@ -1,30 +1,34 @@ -const { createRequire } = require('node:module'); -const { pathToFileURL } = require('node:url'); +const { createRequire } = require('node:module') +const { pathToFileURL } = require('node:url') // Resolve the ESM entry point from the real zeptomatch package -const esmPath = require.resolve('zeptomatch/dist/index.js', { paths: __dirname }); +const esmPath = require.resolve('zeptomatch/dist/index.js', { + paths: __dirname, +}) -let cached = null; +let cached = null async function loadZeptomatch() { - if (cached) return cached; - const mod = await import(pathToFileURL(esmPath).href); - cached = mod.default; - return cached; + if (cached) return cached + const mod = await import(pathToFileURL(esmPath).href) + cached = mod.default + return cached } // Synchronous wrapper that returns a thenable matching zeptomatch's API. // Prisma only uses zeptomatch synchronously (compile + test), so we can // eagerly load the module at require-time using import(). const zeptomatchSync = (glob, path, options) => { - throw new Error('zeptomatch-cjs: async-only mode; use the ESM entry point'); -}; + throw new Error('zeptomatch-cjs: async-only mode; use the ESM entry point') +} -module.exports = zeptomatchSync; -module.exports.default = zeptomatchSync; +module.exports = zeptomatchSync +module.exports.default = zeptomatchSync // Pre-load in background so it's ready for synchronous use -loadZeptomatch().then(fn => { - module.exports = fn; - module.exports.default = fn; -}).catch(() => {}); +loadZeptomatch() + .then((fn) => { + module.exports = fn + module.exports.default = fn + }) + .catch(() => {}) diff --git a/patches/zeptomatch-cjs.cjs b/patches/zeptomatch-cjs.cjs index d75a2b1a..f71a3b4b 100644 --- a/patches/zeptomatch-cjs.cjs +++ b/patches/zeptomatch-cjs.cjs @@ -3,84 +3,84 @@ * Implements the subset of zeptomatch's API used by @prisma/dev: * zeptomatch(pattern, path) → boolean */ -'use strict'; +'use strict' -const SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g; +const SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g function escapeRegex(str) { - return str.replace(SPECIAL_CHARS, '\\$&'); + return str.replace(SPECIAL_CHARS, '\\$&') } function compile(pattern) { - let regexStr = '^'; - let i = 0; - const len = pattern.length; + let regexStr = '^' + let i = 0 + const len = pattern.length while (i < len) { - const ch = pattern[i]; + const ch = pattern[i] if (ch === '*') { if (pattern[i + 1] === '*') { // ** — match anything including / - regexStr += '.*'; - i += 2; - if (pattern[i] === '/') i++; // skip trailing slash after **/ + regexStr += '.*' + i += 2 + if (pattern[i] === '/') i++ // skip trailing slash after **/ } else { // * — match anything except / - regexStr += '[^/]*'; - i++; + regexStr += '[^/]*' + i++ } } else if (ch === '?') { - regexStr += '[^/]'; - i++; + regexStr += '[^/]' + i++ } else if (ch === '{') { // Find closing brace - let j = i + 1; - let depth = 1; + let j = i + 1 + let depth = 1 while (j < len && depth > 0) { - if (pattern[j] === '{') depth++; - else if (pattern[j] === '}') depth--; - j++; + if (pattern[j] === '{') depth++ + else if (pattern[j] === '}') depth-- + j++ } - const alternatives = pattern.slice(i + 1, j - 1).split(','); - regexStr += '(' + alternatives.map(escapeRegex).join('|') + ')'; - i = j; + const alternatives = pattern.slice(i + 1, j - 1).split(',') + regexStr += '(' + alternatives.map(escapeRegex).join('|') + ')' + i = j } else if (ch === '[') { - let j = i + 1; - while (j < len && pattern[j] !== ']') j++; - regexStr += pattern.slice(i, j + 1); - i = j + 1; + let j = i + 1 + while (j < len && pattern[j] !== ']') j++ + regexStr += pattern.slice(i, j + 1) + i = j + 1 } else if (ch === '\\') { - regexStr += escapeRegex(pattern[i + 1]); - i += 2; + regexStr += escapeRegex(pattern[i + 1]) + i += 2 } else { - regexStr += escapeRegex(ch); - i++; + regexStr += escapeRegex(ch) + i++ } } - regexStr += '$'; - return new RegExp(regexStr); + regexStr += '$' + return new RegExp(regexStr) } -const cache = new Map(); +const cache = new Map() function zeptomatch(pattern, path, options) { - if (typeof pattern !== 'string') return false; - let re = cache.get(pattern); + if (typeof pattern !== 'string') return false + let re = cache.get(pattern) if (!re) { - re = compile(pattern); - cache.set(pattern, re); + re = compile(pattern) + cache.set(pattern, re) } - return re.test(path); + return re.test(path) } zeptomatch.compile = function compileGlob(pattern, options) { if (typeof pattern === 'string') { - return { test: (path) => zeptomatch(pattern, path, options) }; + return { test: (path) => zeptomatch(pattern, path, options) } } - return { test: () => false }; -}; + return { test: () => false } +} -module.exports = zeptomatch; -module.exports.default = zeptomatch; +module.exports = zeptomatch +module.exports.default = zeptomatch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11fa1fff..7aafd7b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: nodemon: specifier: ^3.1.14 version: 3.1.14 + prettier: + specifier: ^3.5.3 + version: 3.9.6 prisma: specifier: ^7.4.2 version: 7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) @@ -769,6 +772,7 @@ packages: '@stellar/stellar-base@14.1.0': resolution: {integrity: sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==} engines: {node: '>=20.0.0'} + deprecated: This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support. '@stellar/stellar-sdk@14.6.1': resolution: {integrity: sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==} @@ -2423,6 +2427,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + prisma@7.4.2: resolution: {integrity: sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==} engines: {node: ^20.19 || ^22.12 || >=24.0} @@ -5637,6 +5646,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.9.6: {} + prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: '@prisma/config': 7.4.2 diff --git a/prisma.config.ts b/prisma.config.ts index 7c15b046..0dd93333 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -4,7 +4,9 @@ import { defineConfig } from 'prisma/config' // `prisma generate` (and the Docker build) must succeed even when no database // is reachable, so fall back to a local default when DATABASE_URL is unset. // `prisma migrate`/`db push` still require a real DATABASE_URL. -const DATABASE_URL = process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/learnault_dev?schema=public' +const DATABASE_URL = + process.env.DATABASE_URL ?? + 'postgresql://postgres:postgres@localhost:5432/learnault_dev?schema=public' export default defineConfig({ schema: 'prisma/schema.prisma', diff --git a/prisma/SETUP.md b/prisma/SETUP.md index d56ffa50..99386175 100644 --- a/prisma/SETUP.md +++ b/prisma/SETUP.md @@ -21,6 +21,7 @@ cp .env.example .env ``` Example for local PostgreSQL: + ```env DATABASE_URL="postgresql://postgres:postgres@localhost:5432/learnault_dev?schema=public" ``` @@ -34,6 +35,7 @@ pnpm db:migrate ``` This will: + - Create all tables based on the schema defined in `schema.prisma` - Store migration history in the `_prisma_migrations` table - Generate the Prisma Client @@ -47,6 +49,7 @@ npm run seed ``` This will create deterministic local-only fixtures: + - 6 test users (including admin, verified learner, unverified learner, disabled learner, instructor, employer) - 8 sample modules with different difficulties - Module completions and credentials for testing success/empty/pending states @@ -55,6 +58,7 @@ This will create deterministic local-only fixtures: > **Note**: All fixture identities are strictly local-only and idempotent. Running `npm run seed` multiple times will not duplicate records. > To explicitly reset the database before seeding, use: +> > ```bash > npm run seed:reset > ``` @@ -72,32 +76,38 @@ pnpm db:studio ### Models #### User + - Email and wallet address are unique - Relates to completions, credentials, and transactions - Password is stored as bcrypt hash -#### Module +#### Module + - Contains course/learning content - Has difficulty (easy, medium, hard) - Associates reward amount - Relates to completions and credentials #### Completion + - Links users to completed modules - Stores completion score - Unique constraint on userId + moduleId pair #### Credential + - NFT/on-chain credential issued upon module completion - Stores on-chain ID if already issued - Unique constraint on userId + moduleId pair #### Transaction + - Records all reward/transfer transactions - Status tracking: pending, completed, failed - Types: reward, refund, transfer #### WebhookEndpoint & WebhookDelivery + - For event webhook delivery - Supports retry logic with configurable attempts @@ -132,12 +142,14 @@ prisma format ## Environment Configuration ### Development + ```env DATABASE_URL="postgresql://postgres:postgres@localhost:5432/learnault_dev?schema=public" NODE_ENV=development ``` ### Production + ```env DATABASE_URL="postgresql://user:password@host:5432/learnault_prod?schema=public&sslmode=require" NODE_ENV=production @@ -176,35 +188,39 @@ sudo -u postgres psql -c "CREATE DATABASE learnault_dev;" After setup, test the connection from the Node.js application: ```typescript -import { PrismaClient } from "@prisma/client"; +import { PrismaClient } from '@prisma/client' -const prisma = new PrismaClient(); +const prisma = new PrismaClient() async function main() { - const users = await prisma.user.findMany(); - console.log("Users:", users); + const users = await prisma.user.findMany() + console.log('Users:', users) } main() .catch(console.error) - .finally(() => prisma.$disconnect()); + .finally(() => prisma.$disconnect()) ``` ## Troubleshooting ### "Can't reach database server" + - Verify PostgreSQL is running - Check DATABASE_URL format - Ensure correct host, port, credentials ### "Schema validation failed" + - Run `prisma migrate reset` to reset database - Check for any migration files and review them ### "Prisma Client not found" + - Run `pnpm install` to install dependencies - Run `pnpm db:migrate` to regenerate Prisma Client ### Import errors in seed.ts + - Ensure dependencies are installed: `pnpm install` - Check that TypeScript and ts-node are in devDependencies diff --git a/prisma/fixtures/modules.ts b/prisma/fixtures/modules.ts index 71149d4d..de34ea30 100644 --- a/prisma/fixtures/modules.ts +++ b/prisma/fixtures/modules.ts @@ -11,7 +11,8 @@ export const seedModuleFixtures: SeedModule[] = [ { id: 'seed-module-blockchain-101', title: 'Stellar Fundamentals', - description: 'Core ledger concepts, accounts, trustlines, and transaction flow on Stellar.', + description: + 'Core ledger concepts, accounts, trustlines, and transaction flow on Stellar.', category: 'blockchain', difficulty: 'beginner', reward: 10, @@ -19,7 +20,8 @@ export const seedModuleFixtures: SeedModule[] = [ { id: 'seed-module-finance-101', title: 'Understanding Stablecoins', - description: 'How fiat-backed and crypto-backed stablecoins work across global payment rails.', + description: + 'How fiat-backed and crypto-backed stablecoins work across global payment rails.', category: 'finance', difficulty: 'beginner', reward: 12, @@ -27,7 +29,8 @@ export const seedModuleFixtures: SeedModule[] = [ { id: 'seed-module-security-201', title: 'Wallet Security & Key Management', - description: 'Threat modeling, custody approaches, and secure key handling in production systems.', + description: + 'Threat modeling, custody approaches, and secure key handling in production systems.', category: 'security', difficulty: 'intermediate', reward: 18, @@ -35,7 +38,8 @@ export const seedModuleFixtures: SeedModule[] = [ { id: 'seed-module-development-301', title: 'Build with Soroban', - description: 'Develop and test smart contracts using practical Soroban development workflows.', + description: + 'Develop and test smart contracts using practical Soroban development workflows.', category: 'development', difficulty: 'advanced', reward: 30, @@ -43,7 +47,8 @@ export const seedModuleFixtures: SeedModule[] = [ { id: 'seed-module-compliance-201', title: 'AML/KYC for Digital Finance', - description: 'Compliance basics, sanctions screening, and regulated onboarding for fintech teams.', + description: + 'Compliance basics, sanctions screening, and regulated onboarding for fintech teams.', category: 'compliance', difficulty: 'intermediate', reward: 20, @@ -51,7 +56,8 @@ export const seedModuleFixtures: SeedModule[] = [ { id: 'seed-module-identity-301', title: 'Decentralized Identity in Practice', - description: 'Verifiable credentials, selective disclosure, and identity portability patterns.', + description: + 'Verifiable credentials, selective disclosure, and identity portability patterns.', category: 'identity', difficulty: 'advanced', reward: 28, @@ -59,7 +65,8 @@ export const seedModuleFixtures: SeedModule[] = [ { id: 'seed-module-development-401', title: 'Production API Hardening', - description: 'Rate limiting, auth patterns, observability, and safe rollout practices.', + description: + 'Rate limiting, auth patterns, observability, and safe rollout practices.', category: 'development', difficulty: 'expert', reward: 40, @@ -67,7 +74,8 @@ export const seedModuleFixtures: SeedModule[] = [ { id: 'seed-module-blockchain-202', title: 'Stellar Asset Issuance', - description: 'Issue and manage custom assets with issuer/distributor architecture.', + description: + 'Issue and manage custom assets with issuer/distributor architecture.', category: 'blockchain', difficulty: 'intermediate', reward: 22, diff --git a/prisma/migrations/20260824090000_auditable_data_lifecycle/migration.sql b/prisma/migrations/20260824090000_auditable_data_lifecycle/migration.sql index 6c8de38c..04c14be3 100644 --- a/prisma/migrations/20260824090000_auditable_data_lifecycle/migration.sql +++ b/prisma/migrations/20260824090000_auditable_data_lifecycle/migration.sql @@ -143,11 +143,18 @@ ALTER TABLE "Module" ADD COLUMN "archivedById" TEXT, ADD COLUMN "archivedReason" TEXT; --- AlterTable: avatars -ALTER TABLE "avatars" - ADD COLUMN "archivedAt" TIMESTAMP(3), - ADD COLUMN "archivedById" TEXT, - ADD COLUMN "archivedReason" TEXT; +-- `avatars` was introduced after this lifecycle migration in the schema +-- history. Keep this migration deployable on a clean database while applying +-- the archive policy when the table already exists. +DO $$ +BEGIN + IF to_regclass('public.avatars') IS NOT NULL THEN + ALTER TABLE "avatars" + ADD COLUMN "archivedAt" TIMESTAMP(3), + ADD COLUMN "archivedById" TEXT, + ADD COLUMN "archivedReason" TEXT; + END IF; +END $$; -- AlterTable: referral_codes ALTER TABLE "referral_codes" @@ -168,7 +175,12 @@ ALTER TABLE "WebhookEndpoint" -- schema.prisma and no drift is reported. CREATE INDEX "learner_profiles_archivedAt_idx" ON "learner_profiles"("archivedAt"); CREATE INDEX "Module_archivedAt_idx" ON "Module"("archivedAt"); -CREATE INDEX "avatars_archivedAt_idx" ON "avatars"("archivedAt"); +DO $$ +BEGIN + IF to_regclass('public.avatars') IS NOT NULL THEN + CREATE INDEX "avatars_archivedAt_idx" ON "avatars"("archivedAt"); + END IF; +END $$; CREATE INDEX "referral_codes_archivedAt_idx" ON "referral_codes"("archivedAt"); CREATE INDEX "WebhookEndpoint_archivedAt_idx" ON "WebhookEndpoint"("archivedAt"); @@ -179,8 +191,13 @@ ALTER TABLE "learner_profiles" ADD CONSTRAINT "learner_profiles_archive_reason_c CHECK ("archivedAt" IS NULL OR "archivedReason" IS NOT NULL); ALTER TABLE "Module" ADD CONSTRAINT "Module_archive_reason_check" CHECK ("archivedAt" IS NULL OR "archivedReason" IS NOT NULL); -ALTER TABLE "avatars" ADD CONSTRAINT "avatars_archive_reason_check" - CHECK ("archivedAt" IS NULL OR "archivedReason" IS NOT NULL); +DO $$ +BEGIN + IF to_regclass('public.avatars') IS NOT NULL THEN + ALTER TABLE "avatars" ADD CONSTRAINT "avatars_archive_reason_check" + CHECK ("archivedAt" IS NULL OR "archivedReason" IS NOT NULL); + END IF; +END $$; ALTER TABLE "referral_codes" ADD CONSTRAINT "referral_codes_archive_reason_check" CHECK ("archivedAt" IS NULL OR "archivedReason" IS NOT NULL); ALTER TABLE "WebhookEndpoint" ADD CONSTRAINT "WebhookEndpoint_archive_reason_check" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7d3490b8..bb0c70db 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,106 +7,106 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique - username String @unique - password String - role Role @default(LEARNER) - walletAddress String? @unique - isVerified Boolean @default(false) - phone String? @unique - phoneVerifiedAt DateTime? - status String @default("ACTIVE") // ACTIVE, DEACTIVATED, PENDING_DELETION, DELETED - statusChangedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastLoginAt DateTime? - completions Completion[] - credentials Credential[] - transactions Transaction[] - deviceTokens DeviceToken[] - notificationPref NotificationPreference? - notifications NotificationLog[] - syncEvents SyncEvent[] - referralCode ReferralCode? - referrals Referral[] @relation("Referrer") - referredBy Referral? @relation("Referree") - verificationTokens VerificationToken[] - emailDeliveries EmailDelivery[] - learnerPreference LearnerPreference? + id String @id @default(uuid()) + email String @unique + username String @unique + password String + role Role @default(LEARNER) + walletAddress String? @unique + isVerified Boolean @default(false) + phone String? @unique + phoneVerifiedAt DateTime? + status String @default("ACTIVE") // ACTIVE, DEACTIVATED, PENDING_DELETION, DELETED + statusChangedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLoginAt DateTime? + completions Completion[] + credentials Credential[] + transactions Transaction[] + deviceTokens DeviceToken[] + notificationPref NotificationPreference? + notifications NotificationLog[] + syncEvents SyncEvent[] + referralCode ReferralCode? + referrals Referral[] @relation("Referrer") + referredBy Referral? @relation("Referree") + verificationTokens VerificationToken[] + emailDeliveries EmailDelivery[] + learnerPreference LearnerPreference? preferenceAuditLogs PreferenceAuditLog[] - learnerProfile LearnerProfile? - sessions Session[] - audits AuditLog[] - dataExports DataExportRequest[] - deletionRequests AccountDeletionRequest[] - otpChallenges OtpChallenge[] - onboarding OnboardingProgress? - consentRecords ConsentRecord[] - wallet Wallet? - avatars Avatar[] + learnerProfile LearnerProfile? + sessions Session[] + audits AuditLog[] + dataExports DataExportRequest[] + deletionRequests AccountDeletionRequest[] + otpChallenges OtpChallenge[] + onboarding OnboardingProgress? + consentRecords ConsentRecord[] + wallet Wallet? + avatars Avatar[] @@map("users") } model LearnerPreference { - id String @id @default(uuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) // Locale & regional - locale String @default("en-US") - timezone String @default("UTC") + locale String @default("en-US") + timezone String @default("UTC") // Connectivity - lowDataMode Boolean @default(false) + lowDataMode Boolean @default(false) // Accessibility - highContrast Boolean @default(false) - reduceMotion Boolean @default(false) + highContrast Boolean @default(false) + reduceMotion Boolean @default(false) screenReaderOptimized Boolean @default(false) - textSize String @default("medium") // small, medium, large, extra_large + textSize String @default("medium") // small, medium, large, extra_large // Content preferredDifficulty String @default("beginner") // beginner, intermediate, advanced preferredCategories String[] @default([]) // Privacy (authoritative source for privacy-impacting preferences) - profileVisibility String @default("public") // public, private, connections - analyticsConsent Boolean @default(false) - dataSharingConsent Boolean @default(false) + profileVisibility String @default("public") // public, private, connections + analyticsConsent Boolean @default(false) + dataSharingConsent Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("learner_preferences") } model LearnerProfile { - id String @id @default(uuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - displayName String? - bio String? - avatarUrl String? - country String? - timezone String? - languages String[] @default([]) - level String @default("beginner") // beginner, intermediate, advanced, expert - interests String[] @default([]) - goals String[] @default([]) - - visibility String @default("private") // private, employer, public + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + displayName String? + bio String? + avatarUrl String? + country String? + timezone String? + languages String[] @default([]) + level String @default("beginner") // beginner, intermediate, advanced, expert + interests String[] @default([]) + goals String[] @default([]) + + visibility String @default("private") // private, employer, public // Archive (soft delete). Reads exclude archived rows by default — see // src/audit/archive.ts. Purged 365 days after archivedAt. - archivedAt DateTime? - archivedById String? - archivedReason String? + archivedAt DateTime? + archivedById String? + archivedReason String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([country]) @@index([level]) @@ -116,38 +116,38 @@ model LearnerProfile { } model OnboardingProgress { - id String @id @default(uuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) version String @default("v1") currentStep String completedSteps String[] @default([]) status String @default("in_progress") // in_progress, completed - startedAt DateTime @default(now()) - completedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + startedAt DateTime @default(now()) + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([status]) @@map("onboarding_progress") } model ConsentRecord { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - purpose String // terms_of_service, privacy_policy, marketing_emails, analytics, data_sharing, custodial_wallet - required Boolean @default(false) + purpose String // terms_of_service, privacy_policy, marketing_emails, analytics, data_sharing, custodial_wallet + required Boolean @default(false) policyVersion String - status String // granted, withdrawn - source String // onboarding, settings, api + status String // granted, withdrawn + source String // onboarding, settings, api grantedAt DateTime? withdrawnAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([userId, purpose, createdAt]) @@map("consent_records") @@ -167,29 +167,29 @@ model PreferenceAuditLog { } model Session { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - token String @unique - refreshToken String? @unique - userAgent String? - ipAddress String? + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique + refreshToken String? @unique + userAgent String? + ipAddress String? // Device / browser / OS labels (derived at login time from User-Agent) - deviceName String? - browser String? - os String? + deviceName String? + browser String? + os String? // Approximate location (derived at login time from IP geo-lookup) - country String? - city String? + country String? + city String? // Opaque device fingerprint (e.g. hash of UA + screen-res, never raw) - fingerprint String? + fingerprint String? // Updated each time a refresh token is consumed on this session - lastUsedAt DateTime? - expiresAt DateTime - isRevoked Boolean @default(false) - revokedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + lastUsedAt DateTime? + expiresAt DateTime + isRevoked Boolean @default(false) + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt refreshTokens RefreshToken[] @@index([userId, isRevoked]) @@ -234,31 +234,31 @@ model RefreshToken { /// `onDelete: Cascade` (erasure destroys the trail) or `onDelete: SetNull` /// (erasure mutates an immutable row). `actorId` is a soft reference instead. model AuditEvent { - id String @id @default(uuid()) + id String @id @default(uuid()) // ── Actor: who caused the change ── - actorType String // USER, ADMIN, SYSTEM, WORKER, ANONYMOUS - actorId String? // opaque id; null for SYSTEM and ANONYMOUS actors - actorRole String? // role held at the time of the action, not now + actorType String // USER, ADMIN, SYSTEM, WORKER, ANONYMOUS + actorId String? // opaque id; null for SYSTEM and ANONYMOUS actors + actorRole String? // role held at the time of the action, not now // ── Action and target: what changed ── - action String // e.g. "account.deactivated", "wallet.provisioned" - recordClass String // MUTABLE, ARCHIVABLE, DELETABLE, IMMUTABLE - targetType String // Prisma model name, e.g. "User", "Wallet" - targetId String? // primary key of the affected row + action String // e.g. "account.deactivated", "wallet.provisioned" + recordClass String // MUTABLE, ARCHIVABLE, DELETABLE, IMMUTABLE + targetType String // Prisma model name, e.g. "User", "Wallet" + targetId String? // primary key of the affected row // ── Justification and correlation ── - reason String? // caller-supplied; required for ADMIN actors - requestId String? // x-request-id, correlates to request logs - correlationId String? // outbox event or job id for async changes - source String? // e.g. "api.account.deactivate", "worker.sweep" + reason String? // caller-supplied; required for ADMIN actors + requestId String? // x-request-id, correlates to request logs + correlationId String? // outbox event or job id for async changes + source String? // e.g. "api.account.deactivate", "worker.sweep" // ── Safe context: redacted, hashed, coarsened ── - metadata String? // redacted JSON; never secrets, never raw PII - actorIpHash String? // keyed SHA-256 of the request IP, never the raw IP - userAgentFamily String? // e.g. "Chrome", "Android"; never the raw UA + metadata String? // redacted JSON; never secrets, never raw PII + actorIpHash String? // keyed SHA-256 of the request IP, never the raw IP + userAgentFamily String? // e.g. "Chrome", "Android"; never the raw UA - occurredAt DateTime @default(now()) + occurredAt DateTime @default(now()) @@index([targetType, targetId, occurredAt]) @@index([actorType, actorId, occurredAt]) @@ -275,8 +275,8 @@ model AuditLog { id String @id @default(uuid()) userId String? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - action String // "PASSWORD_RESET", "EMAIL_VERIFIED", "LOGIN", etc. - metadata String? // JSON string for additional details (without secrets) + action String // "PASSWORD_RESET", "EMAIL_VERIFIED", "LOGIN", etc. + metadata String? // JSON string for additional details (without secrets) ipAddress String? userAgent String? createdAt DateTime @default(now()) @@ -301,100 +301,100 @@ model VerificationToken { } model EmailDelivery { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - to String - subject String - body String - type String @default("EMAIL_VERIFICATION") - status String @default("pending") // pending, sent, failed, dead-letter - error String? - attemptCount Int @default(0) - maxAttempts Int @default(5) + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + to String + subject String + body String + type String @default("EMAIL_VERIFICATION") + status String @default("pending") // pending, sent, failed, dead-letter + error String? + attemptCount Int @default(0) + maxAttempts Int @default(5) nextAttemptAt DateTime? @default(now()) lastAttemptAt DateTime? - sentAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([status, nextAttemptAt]) @@map("email_deliveries") } model Module { - id String @id @default(uuid()) - title String - description String - category String - difficulty String // easy, medium, hard + id String @id @default(uuid()) + title String + description String + category String + difficulty String // easy, medium, hard /// Reward expressed in whole-integer stroops (1 XLM = 10_000_000 stroops). /// Stored as bigint to eliminate binary floating-point error. - rewardStroops BigInt @default(0) + rewardStroops BigInt @default(0) /// ISO-4217 or Stellar asset code (e.g. "XLM", "USDC"). - assetCode String @default("XLM") + assetCode String @default("XLM") /// Stellar issuer public key; NULL for the native XLM asset. - assetIssuer String? + assetIssuer String? /// Stellar network: "testnet" or "mainnet". - assetNetwork String @default("testnet") + assetNetwork String @default("testnet") /// Archive (soft delete). Withdrawn content is archived, never deleted: /// completions and credentials reference the module a learner actually took. /// Reads exclude archived rows by default — see src/audit/archive.ts. - archivedAt DateTime? - archivedById String? - archivedReason String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - completions Completion[] - credentials Credential[] + archivedAt DateTime? + archivedById String? + archivedReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completions Completion[] + credentials Credential[] @@index([archivedAt]) } model Completion { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - moduleId String - module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) - score Float - completedAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + moduleId String + module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) + score Float + completedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([userId, moduleId]) } model Credential { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - moduleId String - module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) - onChainId String? - issuedAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + moduleId String + module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) + onChainId String? + issuedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([userId, moduleId]) } model Transaction { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Transaction amount expressed in whole-integer stroops (1 XLM = 10_000_000 stroops). - amountStroops BigInt + amountStroops BigInt /// ISO-4217 or Stellar asset code (e.g. "XLM", "USDC"). - assetCode String @default("XLM") + assetCode String @default("XLM") /// Stellar issuer public key; NULL for the native XLM asset. - assetIssuer String? + assetIssuer String? /// Stellar network: "testnet" or "mainnet". - assetNetwork String @default("testnet") - type String // reward, refund, transfer - status String @default("pending") // pending, completed, failed - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + assetNetwork String @default("testnet") + type String // reward, refund, transfer + status String @default("pending") // pending, completed, failed + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum Role { @@ -404,51 +404,49 @@ enum Role { } model ReferralCode { - id String @id @default(uuid()) - code String @unique - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + code String @unique + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Archive (soft delete). Retiring a code archives it so the Referral rows /// pointing at it keep a valid referent. Reads exclude archived rows by /// default — see src/audit/archive.ts. archivedAt DateTime? archivedById String? archivedReason String? - createdAt DateTime @default(now()) - referrals Referral[] + createdAt DateTime @default(now()) + referrals Referral[] @@index([archivedAt]) @@map("referral_codes") } model Referral { - id String @id @default(uuid()) - referrerId String - referrer User @relation("Referrer", fields: [referrerId], references: [id], onDelete: Cascade) - referreeId String @unique - referree User @relation("Referree", fields: [referreeId], references: [id], onDelete: Cascade) - codeId String - code ReferralCode @relation(fields: [codeId], references: [id]) - bonusPaid Boolean @default(false) + id String @id @default(uuid()) + referrerId String + referrer User @relation("Referrer", fields: [referrerId], references: [id], onDelete: Cascade) + referreeId String @unique + referree User @relation("Referree", fields: [referreeId], references: [id], onDelete: Cascade) + codeId String + code ReferralCode @relation(fields: [codeId], references: [id]) + bonusPaid Boolean @default(false) /// Bonus amount expressed in whole-integer stroops; NULL if no bonus has been granted. bonusAmountStroops BigInt? - bonusPaidAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + bonusPaidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("referrals") } - - model SyncEvent { id String @id @default(uuid()) idempotencyKey String @unique userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) deviceId String - eventType String // progress, completion - payload String // JSON string + eventType String // progress, completion + payload String // JSON string clientTimestamp DateTime serverTimestamp DateTime @default(now()) syncVersion Int @@ -460,40 +458,40 @@ model SyncEvent { } model WebhookEndpoint { - id String @id @default(uuid()) - url String - secret String? - description String? - isActive Boolean @default(true) - events String // Comma-separated list of events: "module.completed,reward.issued" + id String @id @default(uuid()) + url String + secret String? + description String? + isActive Boolean @default(true) + events String // Comma-separated list of events: "module.completed,reward.issued" /// Archive (soft delete), distinct from isActive: isActive pauses delivery, /// archiving retires the endpoint while its delivery history keeps a valid /// referent. Reads exclude archived rows by default — see src/audit/archive.ts. archivedAt DateTime? archivedById String? archivedReason String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deliveries WebhookDelivery[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deliveries WebhookDelivery[] @@index([archivedAt]) } model WebhookDelivery { - id String @id @default(uuid()) - endpointId String - endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade) - eventType String - payload String // JSON string - status String @default("pending") // pending, success, failed - statusCode Int? - responseBody String? - error String? - attemptCount Int @default(0) - maxAttempts Int @default(5) - nextAttemptAt DateTime? @default(now()) - lastAttemptAt DateTime? - createdAt DateTime @default(now()) + id String @id @default(uuid()) + endpointId String + endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade) + eventType String + payload String // JSON string + status String @default("pending") // pending, success, failed + statusCode Int? + responseBody String? + error String? + attemptCount Int @default(0) + maxAttempts Int @default(5) + nextAttemptAt DateTime? @default(now()) + lastAttemptAt DateTime? + createdAt DateTime @default(now()) } model DeviceToken { @@ -501,7 +499,7 @@ model DeviceToken { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) token String @unique - platform String // "ios", "android", "web" + platform String // "ios", "android", "web" createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -518,19 +516,19 @@ model NotificationPreference { } model NotificationLog { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - type String // "reward", "quiz", "streak" - title String - body String - status String @default("pending") // pending, sent, failed, dead-letter - error String? - attemptCount Int @default(0) - maxAttempts Int @default(5) + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String // "reward", "quiz", "streak" + title String + body String + status String @default("pending") // pending, sent, failed, dead-letter + error String? + attemptCount Int @default(0) + maxAttempts Int @default(5) nextAttemptAt DateTime? @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model StellarFunding { @@ -558,7 +556,7 @@ model DataExportRequest { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) status String @default("pending") // pending, processing, ready, failed, expired - artifact String? // JSON export payload; nulled on expiry/purge + artifact String? // JSON export payload; nulled on expiry/purge artifactBytes Int? error String? attemptCount Int @default(0) @@ -578,21 +576,21 @@ model DataExportRequest { } model OtpChallenge { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - phone String - purpose String // LOGIN, PHONE_VERIFICATION - codeHash String - status String @default("PENDING") // PENDING, CONSUMED, EXPIRED, REVOKED, LOCKED - attempts Int @default(0) - maxAttempts Int @default(5) - expiresAt DateTime - consumedAt DateTime? - requestIp String? - deviceId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + phone String + purpose String // LOGIN, PHONE_VERIFICATION + codeHash String + status String @default("PENDING") // PENDING, CONSUMED, EXPIRED, REVOKED, LOCKED + attempts Int @default(0) + maxAttempts Int @default(5) + expiresAt DateTime + consumedAt DateTime? + requestIp String? + deviceId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([phone, purpose, status]) @@index([userId, purpose, status]) @@ -678,28 +676,28 @@ model WalletProvisioningJob { } model Avatar { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - storageKey String - originalName String? - contentType String // declared MIME from upload intent - detectedMime String? // MIME after server-side sniffing (null before finalize) - originalBytes Int @default(0) - status String @default("PENDING") // PENDING, PROCESSING, ACTIVE, FAILED - scanResult String? // clean, rejected, error - scanReason String? - width Int? - height Int? - variantCount Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - finalizedAt DateTime? - replacedAt DateTime? - replacedById String? - replacedBy Avatar? @relation("AvatarReplacement", fields: [replacedById], references: [id]) - replacements Avatar[] @relation("AvatarReplacement") - variants AvatarVariant[] + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + storageKey String + originalName String? + contentType String // declared MIME from upload intent + detectedMime String? // MIME after server-side sniffing (null before finalize) + originalBytes Int @default(0) + status String @default("PENDING") // PENDING, PROCESSING, ACTIVE, FAILED + scanResult String? // clean, rejected, error + scanReason String? + width Int? + height Int? + variantCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + finalizedAt DateTime? + replacedAt DateTime? + replacedById String? + replacedBy Avatar? @relation("AvatarReplacement", fields: [replacedById], references: [id]) + replacements Avatar[] @relation("AvatarReplacement") + variants AvatarVariant[] /// Archive (soft delete), distinct from replacedAt: replacedAt records that a /// newer avatar superseded this one, archivedAt records that it was withdrawn /// (by the learner or by moderation). Reads exclude archived rows by default — @@ -717,7 +715,7 @@ model AvatarVariant { id String @id @default(uuid()) avatarId String avatar Avatar @relation(fields: [avatarId], references: [id], onDelete: Cascade) - label String // original, thumb, medium + label String // original, thumb, medium storageKey String bytes Int @default(0) width Int? @@ -744,27 +742,27 @@ model AvatarVariant { // - Event payloads are versioned and validated before delivery model OutboxEvent { - id String @id @default(uuid()) - aggregateId String // UUID of the root aggregate (e.g. userId, walletId) - aggregateType String // e.g. "User", "Wallet", "Completion" - eventType String // e.g. "UserCreated", "WalletProvisioned", "RewardClaimed" - eventVersion Int @default(1) // Schema version for event payload - payload String // JSON event data; validated against eventVersion schema - + id String @id @default(uuid()) + aggregateId String // UUID of the root aggregate (e.g. userId, walletId) + aggregateType String // e.g. "User", "Wallet", "Completion" + eventType String // e.g. "UserCreated", "WalletProvisioned", "RewardClaimed" + eventVersion Int @default(1) // Schema version for event payload + payload String // JSON event data; validated against eventVersion schema + // Outbox status: tracks delivery to all job queues and notification channels - status String @default("PENDING") // PENDING, PROCESSING, PUBLISHED, DEAD_LETTER, ROLLED_BACK - publishedAt DateTime? // Set when all JobAttempts for this event succeed - + status String @default("PENDING") // PENDING, PROCESSING, PUBLISHED, DEAD_LETTER, ROLLED_BACK + publishedAt DateTime? // Set when all JobAttempts for this event succeed + // Source context: helps trace event causation and multi-domain workflows - source String? // e.g. "api.reward.claim", "worker.wallet-provisioning", "sync.module-completion" - causedBy String? // Foreign key to OutboxEvent.id if this event was triggered by another - + source String? // e.g. "api.reward.claim", "worker.wallet-provisioning", "sync.module-completion" + causedBy String? // Foreign key to OutboxEvent.id if this event was triggered by another + // Audit trail - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relation to job attempts for this event - jobAttempts JobAttempt[] + jobAttempts JobAttempt[] @@index([aggregateId, aggregateType]) @@index([eventType, status]) @@ -775,40 +773,40 @@ model OutboxEvent { } model JobAttempt { - id String @id @default(uuid()) - outboxEventId String // FK to OutboxEvent - outboxEvent OutboxEvent @relation(fields: [outboxEventId], references: [id], onDelete: Cascade) - + id String @id @default(uuid()) + outboxEventId String // FK to OutboxEvent + outboxEvent OutboxEvent @relation(fields: [outboxEventId], references: [id], onDelete: Cascade) + // Job identity: determines which worker processes this attempt - jobType String // e.g. "wallet.provision", "email.send", "reward.distribute", "notification.push" - jobName String // Human-readable identifier for monitoring - + jobType String // e.g. "wallet.provision", "email.send", "reward.distribute", "notification.push" + jobName String // Human-readable identifier for monitoring + // Lease-based concurrency control: ensures only one worker processes this job at a time - status String @default("PENDING") // PENDING, LEASED, COMPLETED, FAILED, DEAD_LETTER, ROLLED_BACK - leaseToken String? @unique // Opaque token held by worker; null if not leased - leasedUntil DateTime? // Lease expiration; null if not leased - + status String @default("PENDING") // PENDING, LEASED, COMPLETED, FAILED, DEAD_LETTER, ROLLED_BACK + leaseToken String? @unique // Opaque token held by worker; null if not leased + leasedUntil DateTime? // Lease expiration; null if not leased + // Retry configuration: exponential backoff, max attempts, dead-lettering - availableAt DateTime @default(now()) // When job becomes available to lease - attempt Int @default(0) // 0-indexed attempt number - maxAttempts Int @default(3) // Configurable per job type - backoffMultiplier Float @default(2.0) // Exponential backoff factor - backoffBaseMs Int @default(1000) // Base delay in milliseconds - + availableAt DateTime @default(now()) // When job becomes available to lease + attempt Int @default(0) // 0-indexed attempt number + maxAttempts Int @default(3) // Configurable per job type + backoffMultiplier Float @default(2.0) // Exponential backoff factor + backoffBaseMs Int @default(1000) // Base delay in milliseconds + // Failure tracking - lastError String? // Last failure reason or stack trace - lastAttemptAt DateTime? // Timestamp of most recent attempt - + lastError String? // Last failure reason or stack trace + lastAttemptAt DateTime? // Timestamp of most recent attempt + // Idempotent completion: prevents duplicate side effects from retries // If a job succeeds but worker crashes before releasing lease, // retry will see idempotencyKey in Completed entry and skip re-execution - idempotencyKey String? @unique // Optional; worker-defined for idempotent jobs - completedAt DateTime? // Set when job succeeds - result String? // JSON result payload (e.g. transaction hash) - + idempotencyKey String? @unique // Optional; worker-defined for idempotent jobs + completedAt DateTime? // Set when job succeeds + result String? // JSON result payload (e.g. transaction hash) + // Audit trail - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([outboxEventId, status]) @@index([jobType, status]) @@ -831,11 +829,11 @@ model JobAttempt { // passed to ensure no in-flight attempts reference them. model RolledBackRecord { - id String @id @default(uuid()) - recordType String // "OutboxEvent" or "JobAttempt" - recordId String // ID of OutboxEvent or JobAttempt that was rolled back - reason String? // Optional reason for rollback - createdAt DateTime @default(now()) + id String @id @default(uuid()) + recordType String // "OutboxEvent" or "JobAttempt" + recordId String // ID of OutboxEvent or JobAttempt that was rolled back + reason String? // Optional reason for rollback + createdAt DateTime @default(now()) @@index([recordType, recordId]) @@index([createdAt]) // For periodic cleanup @@ -843,14 +841,14 @@ model RolledBackRecord { } model QueueLease { - id String @id @default(uuid()) - queueName String @unique - leaseToken String? - leasedUntil DateTime? - owner String? - lastTickAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + queueName String @unique + leaseToken String? + leasedUntil DateTime? + owner String? + lastTickAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([leasedUntil]) @@map("queue_leases") diff --git a/prisma/seed.ts b/prisma/seed.ts index c88f4f68..d9bc4c17 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -78,7 +78,9 @@ async function upsertUsers(passwordHash: string) { }, }) } - console.log(`✅ Upserted ${seedUserFixtures.length} users (learners, employers, admin)`) + console.log( + `✅ Upserted ${seedUserFixtures.length} users (learners, employers, admin)`, + ) } async function upsertModules() { @@ -95,7 +97,9 @@ async function upsertModules() { create: moduleData, }) } - console.log(`✅ Upserted ${seedModuleFixtures.length} modules across all categories`) + console.log( + `✅ Upserted ${seedModuleFixtures.length} modules across all categories`, + ) } async function seedLearningData() { @@ -110,7 +114,9 @@ async function seedLearningData() { const completed = rand() < 0.72 if (!completed) continue - const completedAt = new Date(Date.now() - Math.floor(rand() * 75) * MS_PER_DAY) + const completedAt = new Date( + Date.now() - Math.floor(rand() * 75) * MS_PER_DAY, + ) const score = scoreFor(moduleData, rand) const completionId = `seed-completion-${user.id}-${moduleData.id}` @@ -143,14 +149,20 @@ async function seedLearningData() { userId: user.id, moduleId: moduleData.id, onChainId: `cred_${user.id.slice(-5)}_${moduleData.id.slice(-5)}`, - issuedAt: new Date(completedAt.getTime() + Math.floor(rand() * 12) * 60 * 60 * 1000), + issuedAt: new Date( + completedAt.getTime() + + Math.floor(rand() * 12) * 60 * 60 * 1000, + ), }, create: { id: credentialId, userId: user.id, moduleId: moduleData.id, onChainId: `cred_${user.id.slice(-5)}_${moduleData.id.slice(-5)}`, - issuedAt: new Date(completedAt.getTime() + Math.floor(rand() * 12) * 60 * 60 * 1000), + issuedAt: new Date( + completedAt.getTime() + + Math.floor(rand() * 12) * 60 * 60 * 1000, + ), }, }), ) @@ -190,7 +202,9 @@ async function seedLearningData() { amount: Number((15 + rand() * 30).toFixed(2)), type: 'withdrawal', status: rand() < 0.85 ? 'completed' : 'pending', - createdAt: new Date(Date.now() - Math.floor(rand() * 30) * MS_PER_DAY), + createdAt: new Date( + Date.now() - Math.floor(rand() * 30) * MS_PER_DAY, + ), }, create: { id: payoutTxnId, @@ -198,13 +212,17 @@ async function seedLearningData() { amount: Number((15 + rand() * 30).toFixed(2)), type: 'withdrawal', status: rand() < 0.85 ? 'completed' : 'pending', - createdAt: new Date(Date.now() - Math.floor(rand() * 30) * MS_PER_DAY), + createdAt: new Date( + Date.now() - Math.floor(rand() * 30) * MS_PER_DAY, + ), }, }), ) } - const employers = seedUserFixtures.filter((u) => u.email.includes('.employer+')) + const employers = seedUserFixtures.filter((u) => + u.email.includes('.employer+'), + ) for (const employer of employers) { const txId = `seed-transaction-employer-credit-${employer.id}` transactions.push( @@ -274,7 +292,13 @@ async function main() { await seedLearningData() await seedWebhookData() - const [userCount, moduleCount, completionCount, credentialCount, transactionCount] = await Promise.all([ + const [ + userCount, + moduleCount, + completionCount, + credentialCount, + transactionCount, + ] = await Promise.all([ prisma.user.count(), prisma.module.count(), prisma.completion.count(), diff --git a/scripts/relay-verification.ts b/scripts/relay-verification.ts index 1b6632f2..353d5710 100644 --- a/scripts/relay-verification.ts +++ b/scripts/relay-verification.ts @@ -6,7 +6,10 @@ import { createOutboxRelay } from '../src/workers/outbox-relay' const TAG = 'relay-evidence' -const relay = createOutboxRelay({ prisma, handlers: registerOutboxHandlers({ prisma }) }) +const relay = createOutboxRelay({ + prisma, + handlers: registerOutboxHandlers({ prisma }), +}) function banner(title: string): void { console.log('') @@ -20,7 +23,7 @@ async function cleanup(): Promise { where: { username: { startsWith: TAG } }, select: { id: true }, }) - const ids = users.map(u => u.id) + const ids = users.map((u) => u.id) await prisma.outboxEvent.deleteMany({ where: { source: { in: [TAG, 'relay.user-created'] } }, @@ -48,14 +51,24 @@ async function makeUser(suffix: string, withConsent: boolean): Promise { if (withConsent) { await prisma.consentRecord.create({ - data: { userId: user.id, purpose: 'custodial_wallet', policyVersion: '1', status: 'granted', source: 'api' }, + data: { + userId: user.id, + purpose: 'custodial_wallet', + policyVersion: '1', + status: 'granted', + source: 'api', + }, }) } return user.id } -async function emit(eventType: string, aggregateId: string, payload: unknown): Promise { +async function emit( + eventType: string, + aggregateId: string, + payload: unknown, +): Promise { const event = await prisma.outboxEvent.create({ data: { id: randomUUID(), @@ -108,9 +121,11 @@ async function showEvents(): Promise { for (const event of events) { const jobs = event.jobAttempts - .map(j => `${j.jobType}=${j.status}(attempt ${j.attempt})`) + .map((j) => `${j.jobType}=${j.status}(attempt ${j.attempt})`) .join(' ') - console.log(` ${event.eventType.padEnd(28)} ${event.status.padEnd(12)} ${jobs}`) + console.log( + ` ${event.eventType.padEnd(28)} ${event.status.padEnd(12)} ${jobs}`, + ) } } @@ -128,8 +143,12 @@ async function main(): Promise { console.log('') await showEvents() console.log('') - console.log(' One UserCreated event fanned out to its handler, which emitted') - console.log(' WalletProvisioningRequested; the relay dispatched that to a different handler.') + console.log( + ' One UserCreated event fanned out to its handler, which emitted', + ) + console.log( + ' WalletProvisioningRequested; the relay dispatched that to a different handler.', + ) banner('B. Event type with no registered handler') await emit('ModuleCompleted', okUser, { @@ -145,7 +164,9 @@ async function main(): Promise { where: { source: TAG, eventType: 'ModuleCompleted' }, select: { status: true }, }) - console.log(` ModuleCompleted -> ${unhandled?.status} (dead-lettered, not left PENDING)`) + console.log( + ` ModuleCompleted -> ${unhandled?.status} (dead-lettered, not left PENDING)`, + ) banner('C. Failing handler dead-letters, then replays cleanly') const blockedUser = await makeUser('blocked', false) @@ -155,16 +176,23 @@ async function main(): Promise { role: 'LEARNER', }) console.log('') - console.log(' User has no custodial-wallet consent, so the handler keeps failing.') + console.log( + ' User has no custodial-wallet consent, so the handler keeps failing.', + ) await drain(12) const dead = await prisma.outboxEvent.findUnique({ where: { id: blockedEvent }, - select: { status: true, jobAttempts: { select: { status: true, attempt: true, lastError: true } } }, + select: { + status: true, + jobAttempts: { select: { status: true, attempt: true, lastError: true } }, + }, }) const failedJob = dead?.jobAttempts[0] console.log(` event -> ${dead?.status}`) - console.log(` job -> ${failedJob?.status} after ${failedJob?.attempt} attempts`) + console.log( + ` job -> ${failedJob?.status} after ${failedJob?.attempt} attempts`, + ) console.log(` error -> ${failedJob?.lastError?.split('\n')[0]}`) const before = await prisma.wallet.count({ where: { userId: blockedUser } }) @@ -172,27 +200,47 @@ async function main(): Promise { console.log('') console.log(' Operator grants the missing consent, then replays:') await prisma.consentRecord.create({ - data: { userId: blockedUser, purpose: 'custodial_wallet', policyVersion: '1', status: 'granted', source: 'api' }, + data: { + userId: blockedUser, + purpose: 'custodial_wallet', + policyVersion: '1', + status: 'granted', + source: 'api', + }, }) await relay.replayDeadLetter(blockedEvent) await drain(6) const replayed = await prisma.outboxEvent.findUnique({ where: { id: blockedEvent }, - select: { status: true, jobAttempts: { select: { jobType: true, status: true } } }, + select: { + status: true, + jobAttempts: { select: { jobType: true, status: true } }, + }, }) const after = await prisma.wallet.count({ where: { userId: blockedUser } }) console.log(` event -> ${replayed?.status}`) - console.log(` jobs -> ${replayed?.jobAttempts.map(j => `${j.jobType}=${j.status}`).join(' ')}`) + console.log( + ` jobs -> ${replayed?.jobAttempts.map((j) => `${j.jobType}=${j.status}`).join(' ')}`, + ) console.log(` wallets for user: ${before} before replay, ${after} after`) banner('D. Re-delivering an already-published event is idempotent') await prisma.jobAttempt.updateMany({ where: { outboxEventId: blockedEvent }, - data: { status: 'PENDING', attempt: 0, availableAt: new Date(), leaseToken: null, leasedUntil: null }, + data: { + status: 'PENDING', + attempt: 0, + availableAt: new Date(), + leaseToken: null, + leasedUntil: null, + }, + }) + await prisma.outboxEvent.update({ + where: { id: blockedEvent }, + data: { status: 'PENDING' }, }) - await prisma.outboxEvent.update({ where: { id: blockedEvent }, data: { status: 'PENDING' } }) console.log('') console.log(' Forcing the same event through the relay a second time:') await drain(6) @@ -201,14 +249,16 @@ async function main(): Promise { where: { id: blockedEvent }, select: { status: true }, }) - const afterRedelivery = await prisma.wallet.count({ where: { userId: blockedUser } }) + const afterRedelivery = await prisma.wallet.count({ + where: { userId: blockedUser }, + }) const walletJobs = await prisma.walletProvisioningJob.count({ where: { wallet: { userId: blockedUser } }, }) console.log(` event -> ${redelivered?.status}`) console.log( - ` wallets for user: ${after} before re-delivery, ${afterRedelivery} after; provisioning jobs: ${walletJobs}` + ` wallets for user: ${after} before re-delivery, ${afterRedelivery} after; provisioning jobs: ${walletJobs}`, ) console.log(' The handler ran again and produced no second wallet.') diff --git a/src/app.ts b/src/app.ts index dcf96519..aaff5571 100644 --- a/src/app.ts +++ b/src/app.ts @@ -21,7 +21,7 @@ app.use(cors()) app.use( helmet({ contentSecurityPolicy: false, // Disable CSP for Swagger UI to work correctly - }) + }), ) // Request context middleware - must be early to track all requests @@ -77,4 +77,4 @@ app.use(notFoundHandler) // Global error handler - must be last app.use(errorHandler) -export default app \ No newline at end of file +export default app diff --git a/src/audit/archive.ts b/src/audit/archive.ts index 606e2849..86902b98 100644 --- a/src/audit/archive.ts +++ b/src/audit/archive.ts @@ -16,7 +16,7 @@ import { RecordClass } from './types.js' /** Models carrying archive columns, derived from the lifecycle matrix. */ export const ARCHIVABLE_MODELS: ReadonlySet = new Set( - modelsInClass(RecordClass.ARCHIVABLE) + modelsInClass(RecordClass.ARCHIVABLE), ) /** @@ -76,7 +76,7 @@ export function mentionsArchivedAt(where: unknown): boolean { return (['AND', 'OR', 'NOT'] as const).some( (combinator) => Object.prototype.hasOwnProperty.call(clause, combinator) && - mentionsArchivedAt(clause[combinator]) + mentionsArchivedAt(clause[combinator]), ) } @@ -103,7 +103,11 @@ export async function excludeArchivedFromReads({ args, query, }: QueryInterception): Promise { - if (!model || !ARCHIVABLE_MODELS.has(model) || !FILTERED_OPERATIONS.has(operation)) { + if ( + !model || + !ARCHIVABLE_MODELS.has(model) || + !FILTERED_OPERATIONS.has(operation) + ) { return query(args) } @@ -144,13 +148,15 @@ export const archiveExclusionExtension = { * Restrict a `where` clause to live rows. Redundant for the operations the * extension already covers; useful for `findUnique` and for raw queries. */ -export function activeOnly(where?: W): W & { archivedAt: null } { +export function activeOnly( + where?: W, +): W & { archivedAt: null } { return { ...((where ?? {}) as W), archivedAt: null } } /** Restrict a `where` clause to archived rows only. */ export function archivedOnly( - where?: W + where?: W, ): W & { archivedAt: { not: null } } { return { ...((where ?? {}) as W), archivedAt: { not: null } } } @@ -164,7 +170,7 @@ export function archivedOnly( * review. */ export function includeArchived( - where?: W + where?: W, ): W & { archivedAt: undefined } { return { ...((where ?? {}) as W), archivedAt: undefined } } @@ -176,7 +182,7 @@ export function includeArchived( export function archivePatch( reason: string, archivedById?: string | null, - now: Date = new Date() + now: Date = new Date(), ): ArchiveColumns { return { archivedAt: now, @@ -203,7 +209,9 @@ export function isArchived(record: MaybeArchived | null | undefined): boolean { * Narrow a point lookup to a live record, returning `null` for an archived one. * The counterpart to `findUnique` being exempt from the extension. */ -export function assertActive(record: T | null): T | null { +export function assertActive( + record: T | null, +): T | null { return record && !isArchived(record) ? record : null } @@ -216,7 +224,7 @@ export function assertActive(record: T | null): T | nul */ export function archivedPurgeCutoff( retentionDays: number | null, - now: Date = new Date() + now: Date = new Date(), ): Date | null { if (retentionDays === null) { return null diff --git a/src/audit/audit-event.service.ts b/src/audit/audit-event.service.ts index c49a9aa2..369aaf08 100644 --- a/src/audit/audit-event.service.ts +++ b/src/audit/audit-event.service.ts @@ -11,7 +11,11 @@ import prisma from '../config/database' import logger from '../utils/logger' import { env } from '../config/env' import { recordClassFor, retentionCutoff } from './classification.js' -import { hashIpAddress, serializeMetadata, userAgentFamily } from './redaction.js' +import { + hashIpAddress, + serializeMetadata, + userAgentFamily, +} from './redaction.js' import { AuditEventInput, AuditEventQuery, @@ -101,7 +105,10 @@ export class AuditEventService { * rejected audit write must roll the mutation back rather than let an * unaudited change through. */ - async recordWithin(tx: AuditEventWriter, input: AuditEventInput): Promise { + async recordWithin( + tx: AuditEventWriter, + input: AuditEventInput, + ): Promise { await tx.auditEvent.create({ data: this.toRow(input) }) } @@ -111,11 +118,17 @@ export class AuditEventService { * on the way out. */ async list(query: AuditEventQuery = {}): Promise { - const take = Math.min(Math.max(query.take ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE) + const take = Math.min( + Math.max(query.take ?? DEFAULT_PAGE_SIZE, 1), + MAX_PAGE_SIZE, + ) const occurredAt = query.from || query.to - ? { ...(query.from ? { gte: query.from } : {}), ...(query.to ? { lte: query.to } : {}) } + ? { + ...(query.from ? { gte: query.from } : {}), + ...(query.to ? { lte: query.to } : {}), + } : undefined const rows = await prisma.auditEvent.findMany({ @@ -140,7 +153,7 @@ export class AuditEventService { async historyFor( targetType: string, targetId: string, - take = DEFAULT_PAGE_SIZE + take = DEFAULT_PAGE_SIZE, ): Promise { const rows = await prisma.auditEvent.findMany({ where: { targetType, targetId }, @@ -216,7 +229,9 @@ export class AuditEventService { correlationId: input.correlationId ?? null, source: input.source ?? null, metadata: serializeMetadata(input.metadata), - actorIpHash: ipHashSecret ? hashIpAddress(input.ipAddress, ipHashSecret) : null, + actorIpHash: ipHashSecret + ? hashIpAddress(input.ipAddress, ipHashSecret) + : null, userAgentFamily: userAgentFamily(input.userAgent), } } @@ -238,7 +253,7 @@ export class AuditEventService { if (!this.warnedAboutSecret) { this.warnedAboutSecret = true logger.warn( - '[AuditEventService] AUDIT_IP_HASH_SECRET is not set; audit events will omit the source IP hash.' + '[AuditEventService] AUDIT_IP_HASH_SECRET is not set; audit events will omit the source IP hash.', ) } diff --git a/src/audit/audited-mutation.ts b/src/audit/audited-mutation.ts index b380b760..57916fd7 100644 --- a/src/audit/audited-mutation.ts +++ b/src/audit/audited-mutation.ts @@ -96,7 +96,9 @@ export class AuditPolicyError extends Error { * }) * ``` */ -export async function auditedMutation(spec: AuditedMutationSpec): Promise { +export async function auditedMutation( + spec: AuditedMutationSpec, +): Promise { assertPolicy(spec) return prisma.$transaction(async (tx) => { @@ -146,13 +148,16 @@ export async function auditedArchive(input: { correlationId?: string | null source?: string | null metadata?: Record | null - archive: (tx: AuditedTransactionClient, patch: ReturnType) => Promise + archive: ( + tx: AuditedTransactionClient, + patch: ReturnType, + ) => Promise }): Promise { assertArchivable(input.model, 'archive') if (!input.reason?.trim()) { throw new AuditPolicyError( - `Archiving ${input.model} requires a reason: an archive with no stated reason cannot be reviewed later.` + `Archiving ${input.model} requires a reason: an archive with no stated reason cannot be reviewed later.`, ) } @@ -183,7 +188,10 @@ export async function auditedRestore(input: { correlationId?: string | null source?: string | null metadata?: Record | null - restore: (tx: AuditedTransactionClient, patch: ReturnType) => Promise + restore: ( + tx: AuditedTransactionClient, + patch: ReturnType, + ) => Promise }): Promise { assertArchivable(input.model, 'restore') @@ -254,19 +262,23 @@ function assertPolicy(spec: AuditedMutationSpec): void { if (!spec.target.type.trim()) { throw new AuditPolicyError( - `Audited mutation "${spec.action}" requires a target type (the Prisma model name).` + `Audited mutation "${spec.action}" requires a target type (the Prisma model name).`, ) } if (spec.actor.type === ActorType.ADMIN && !spec.reason?.trim()) { throw new AuditPolicyError( - `Audited mutation "${spec.action}" is performed by an ADMIN actor and therefore requires a reason.` + `Audited mutation "${spec.action}" is performed by an ADMIN actor and therefore requires a reason.`, ) } - if ((spec.actor.type === ActorType.USER || spec.actor.type === ActorType.ADMIN) && !spec.actor.id) { + if ( + (spec.actor.type === ActorType.USER || + spec.actor.type === ActorType.ADMIN) && + !spec.actor.id + ) { throw new AuditPolicyError( - `Audited mutation "${spec.action}" has a ${spec.actor.type} actor with no id; the event would be unattributable.` + `Audited mutation "${spec.action}" has a ${spec.actor.type} actor with no id; the event would be unattributable.`, ) } } @@ -276,13 +288,13 @@ function assertArchivable(model: string, verb: string): void { if (!rule) { throw new AuditPolicyError( - `Cannot ${verb} ${model}: it has no rule in the lifecycle matrix (src/audit/classification.ts).` + `Cannot ${verb} ${model}: it has no rule in the lifecycle matrix (src/audit/classification.ts).`, ) } if (rule.recordClass !== 'ARCHIVABLE') { throw new AuditPolicyError( - `Cannot ${verb} ${model}: the lifecycle matrix classifies it as ${rule.recordClass}, not ARCHIVABLE.` + `Cannot ${verb} ${model}: the lifecycle matrix classifies it as ${rule.recordClass}, not ARCHIVABLE.`, ) } } diff --git a/src/audit/classification.ts b/src/audit/classification.ts index 18e70d3a..3d0e282a 100644 --- a/src/audit/classification.ts +++ b/src/audit/classification.ts @@ -474,7 +474,8 @@ const RULES: readonly LifecycleRule[] = [ retentionAnchor: 'createdAt', onErasure: ErasureAction.RETAIN, audited: false, - notes: 'Tombstone marking an event as unprocessable. Written once, then only read.', + notes: + 'Tombstone marking an event as unprocessable. Written once, then only read.', }, { model: 'QueueLease', @@ -491,7 +492,7 @@ const RULES: readonly LifecycleRule[] = [ ] const BY_MODEL: ReadonlyMap = new Map( - RULES.map((rule) => [rule.model, rule]) + RULES.map((rule) => [rule.model, rule]), ) /** Every rule in the matrix, in declaration order. */ @@ -524,15 +525,22 @@ export function requiresAudit(model: string): boolean { } /** Models in a given lifecycle class. */ -export function modelsInClass(recordClass: RecordClassValue): readonly string[] { - return RULES.filter((rule) => rule.recordClass === recordClass).map((rule) => rule.model) +export function modelsInClass( + recordClass: RecordClassValue, +): readonly string[] { + return RULES.filter((rule) => rule.recordClass === recordClass).map( + (rule) => rule.model, + ) } /** * Cut-off before which a model's rows are eligible for a retention purge, or * `null` when the model is retained indefinitely. */ -export function retentionCutoff(model: string, now: Date = new Date()): Date | null { +export function retentionCutoff( + model: string, + now: Date = new Date(), +): Date | null { const rule = BY_MODEL.get(model) if (!rule || rule.retentionDays === null) { return null diff --git a/src/audit/redaction.ts b/src/audit/redaction.ts index d9f05023..2f049ba7 100644 --- a/src/audit/redaction.ts +++ b/src/audit/redaction.ts @@ -207,7 +207,7 @@ export interface RedactionResult { * would take down the mutation being audited. */ export function redactMetadata( - input: Record | null | undefined + input: Record | null | undefined, ): RedactionResult { if (input === null || input === undefined) { return { value: null, redactedPaths: [], truncated: false } @@ -272,7 +272,10 @@ export function redactMetadata( if (value instanceof Error) { // Keep the class and message; a stack trace can embed request payloads. - return { name: value.name, message: walk(value.message, `${path}.message`, depth + 1) } + return { + name: value.name, + message: walk(value.message, `${path}.message`, depth + 1), + } } if (seen.has(value as object)) { @@ -288,7 +291,9 @@ export function redactMetadata( state.truncated = true } - return kept.map((entry, index) => walk(entry, `${path}[${index}]`, depth + 1)) + return kept.map((entry, index) => + walk(entry, `${path}[${index}]`, depth + 1), + ) } const entries = Object.entries(value as Record) @@ -328,7 +333,7 @@ export function redactMetadata( * dropping the payload rather than storing a truncated, unparseable prefix. */ export function serializeMetadata( - input: Record | null | undefined + input: Record | null | undefined, ): string | null { const { value, truncated } = redactMetadata(input) @@ -343,7 +348,9 @@ export function serializeMetadata( return JSON.stringify({ _redacted: ['*'], _reason: 'unserializable' }) } - if (Buffer.byteLength(serialized, 'utf8') > RedactionLimits.maxSerializedBytes) { + if ( + Buffer.byteLength(serialized, 'utf8') > RedactionLimits.maxSerializedBytes + ) { return JSON.stringify({ _truncated: true, _reason: 'metadata exceeded size limit', @@ -369,13 +376,16 @@ export function serializeMetadata( */ export function hashIpAddress( ipAddress: string | null | undefined, - secret: string + secret: string, ): string | null { if (!ipAddress) { return null } - return createHmac('sha256', secret).update(ipAddress.trim()).digest('hex').slice(0, 32) + return createHmac('sha256', secret) + .update(ipAddress.trim()) + .digest('hex') + .slice(0, 32) } /** @@ -383,7 +393,9 @@ export function hashIpAddress( * console" from "the mobile app" in an investigation, not enough to fingerprint * a device. */ -export function userAgentFamily(userAgent: string | null | undefined): string | null { +export function userAgentFamily( + userAgent: string | null | undefined, +): string | null { if (!userAgent) { return null } diff --git a/src/audit/types.ts b/src/audit/types.ts index afff5cca..5cc98016 100644 --- a/src/audit/types.ts +++ b/src/audit/types.ts @@ -70,7 +70,8 @@ export const ErasureAction = { CASCADE: 'CASCADE', } as const -export type ErasureActionValue = (typeof ErasureAction)[keyof typeof ErasureAction] +export type ErasureActionValue = + (typeof ErasureAction)[keyof typeof ErasureAction] /** One row of the lifecycle matrix. */ export interface LifecycleRule { diff --git a/src/config/database.ts b/src/config/database.ts index e4fbec2c..59a92ed3 100644 --- a/src/config/database.ts +++ b/src/config/database.ts @@ -37,7 +37,7 @@ function createPrismaClient(): PrismaClient { // shape. Widening every injection site to a union of both client types is a // worse trade — a union of overloaded $transaction signatures stops resolving. return new PrismaClient({ adapter }).$extends( - archiveExclusionExtension + archiveExclusionExtension, ) as unknown as PrismaClient } diff --git a/src/config/env.ts b/src/config/env.ts index 19605caa..8ab53d8c 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,45 +1,81 @@ -import { config } from 'dotenv' - -config() - -export const env = { - NODE_ENV: process.env.NODE_ENV || 'development', - PORT: parseInt(process.env.PORT || '3000', 10), - - // Graceful shutdown configuration - SHUTDOWN_TIMEOUT_MS: parseInt(process.env.SHUTDOWN_TIMEOUT_MS || '30000', 10), // 30 seconds default - - // Rate limiting configurations - RATE_LIMIT_GENERAL_WINDOW_MS: parseInt(process.env.RATE_LIMIT_GENERAL_WINDOW_MS || '900000', 10), // 15 minutes - RATE_LIMIT_GENERAL_MAX: parseInt(process.env.RATE_LIMIT_GENERAL_MAX || '100', 10), - - RATE_LIMIT_AUTH_WINDOW_MS: parseInt(process.env.RATE_LIMIT_AUTH_WINDOW_MS || '900000', 10), // 15 minutes - RATE_LIMIT_AUTH_MAX: parseInt(process.env.RATE_LIMIT_AUTH_MAX || '10', 10), - - RATE_LIMIT_EMPLOYER_WINDOW_MS: parseInt(process.env.RATE_LIMIT_EMPLOYER_WINDOW_MS || '900000', 10), // 15 minutes - RATE_LIMIT_EMPLOYER_MAX: parseInt(process.env.RATE_LIMIT_EMPLOYER_MAX || '500', 10), - - RATE_LIMIT_AUTHENTICATED_WINDOW_MS: parseInt(process.env.RATE_LIMIT_AUTHENTICATED_WINDOW_MS || '900000', 10), // 15 minutes - RATE_LIMIT_AUTHENTICATED_MAX: parseInt(process.env.RATE_LIMIT_AUTHENTICATED_MAX || '1000', 10), - - RATE_LIMIT_OTP_WINDOW_MS: parseInt(process.env.RATE_LIMIT_OTP_WINDOW_MS || '900000', 10), // 15 minutes - RATE_LIMIT_OTP_MAX: parseInt(process.env.RATE_LIMIT_OTP_MAX || '5', 10), - - // SMS provider selection — only "mock" is implemented until a real provider is integrated - SMS_PROVIDER: process.env.SMS_PROVIDER || 'mock', - - // Token lifetimes - ACCESS_TOKEN_TTL_SECONDS: parseInt(process.env.JWT_ACCESS_TTL_SECONDS || '900', 10), - REFRESH_TOKEN_TTL_SECONDS: parseInt(process.env.REFRESH_TOKEN_TTL_SECONDS || '2592000', 10), // 30 days - - // Account lifecycle configurations - DELETION_COOLING_OFF_DAYS: parseInt(process.env.DELETION_COOLING_OFF_DAYS || '30', 10), - EXPORT_TTL_DAYS: parseInt(process.env.EXPORT_TTL_DAYS || '7', 10), - LIFECYCLE_SWEEP_INTERVAL_MS: parseInt(process.env.LIFECYCLE_SWEEP_INTERVAL_MS || '0', 10), - - // Data lifecycle / audit configurations — see docs/DATA_LIFECYCLE.md - // HMAC key for the source-IP hash on audit events. Unset in production means - // audit events omit the IP hash entirely, rather than storing an unkeyed - // digest of a search space small enough to enumerate. - AUDIT_IP_HASH_SECRET: process.env.AUDIT_IP_HASH_SECRET || '', -} \ No newline at end of file +import { config } from 'dotenv' + +config() + +export const env = { + NODE_ENV: process.env.NODE_ENV || 'development', + PORT: parseInt(process.env.PORT || '3000', 10), + + // Graceful shutdown configuration + SHUTDOWN_TIMEOUT_MS: parseInt(process.env.SHUTDOWN_TIMEOUT_MS || '30000', 10), // 30 seconds default + + // Rate limiting configurations + RATE_LIMIT_GENERAL_WINDOW_MS: parseInt( + process.env.RATE_LIMIT_GENERAL_WINDOW_MS || '900000', + 10, + ), // 15 minutes + RATE_LIMIT_GENERAL_MAX: parseInt( + process.env.RATE_LIMIT_GENERAL_MAX || '100', + 10, + ), + + RATE_LIMIT_AUTH_WINDOW_MS: parseInt( + process.env.RATE_LIMIT_AUTH_WINDOW_MS || '900000', + 10, + ), // 15 minutes + RATE_LIMIT_AUTH_MAX: parseInt(process.env.RATE_LIMIT_AUTH_MAX || '10', 10), + + RATE_LIMIT_EMPLOYER_WINDOW_MS: parseInt( + process.env.RATE_LIMIT_EMPLOYER_WINDOW_MS || '900000', + 10, + ), // 15 minutes + RATE_LIMIT_EMPLOYER_MAX: parseInt( + process.env.RATE_LIMIT_EMPLOYER_MAX || '500', + 10, + ), + + RATE_LIMIT_AUTHENTICATED_WINDOW_MS: parseInt( + process.env.RATE_LIMIT_AUTHENTICATED_WINDOW_MS || '900000', + 10, + ), // 15 minutes + RATE_LIMIT_AUTHENTICATED_MAX: parseInt( + process.env.RATE_LIMIT_AUTHENTICATED_MAX || '1000', + 10, + ), + + RATE_LIMIT_OTP_WINDOW_MS: parseInt( + process.env.RATE_LIMIT_OTP_WINDOW_MS || '900000', + 10, + ), // 15 minutes + RATE_LIMIT_OTP_MAX: parseInt(process.env.RATE_LIMIT_OTP_MAX || '5', 10), + + // SMS provider selection — only "mock" is implemented until a real provider is integrated + SMS_PROVIDER: process.env.SMS_PROVIDER || 'mock', + + // Token lifetimes + ACCESS_TOKEN_TTL_SECONDS: parseInt( + process.env.JWT_ACCESS_TTL_SECONDS || '900', + 10, + ), + REFRESH_TOKEN_TTL_SECONDS: parseInt( + process.env.REFRESH_TOKEN_TTL_SECONDS || '2592000', + 10, + ), // 30 days + + // Account lifecycle configurations + DELETION_COOLING_OFF_DAYS: parseInt( + process.env.DELETION_COOLING_OFF_DAYS || '30', + 10, + ), + EXPORT_TTL_DAYS: parseInt(process.env.EXPORT_TTL_DAYS || '7', 10), + LIFECYCLE_SWEEP_INTERVAL_MS: parseInt( + process.env.LIFECYCLE_SWEEP_INTERVAL_MS || '0', + 10, + ), + + // Data lifecycle / audit configurations — see docs/DATA_LIFECYCLE.md + // HMAC key for the source-IP hash on audit events. Unset in production means + // audit events omit the IP hash entirely, rather than storing an unkeyed + // digest of a search space small enough to enumerate. + AUDIT_IP_HASH_SECRET: process.env.AUDIT_IP_HASH_SECRET || '', +} diff --git a/src/config/jwt.ts b/src/config/jwt.ts index bd9226aa..78cf9fb3 100644 --- a/src/config/jwt.ts +++ b/src/config/jwt.ts @@ -31,7 +31,7 @@ function loadActiveSecret(): string { throw new Error( 'JWT_SECRET environment variable is required (NODE_ENV != test). ' + - 'Refusing to start with an insecure fallback secret.' + 'Refusing to start with an insecure fallback secret.', ) } @@ -60,8 +60,8 @@ const ACTIVE_SECRET = loadActiveSecret() const RETIRED_KEYS = loadRetiredKeys() export interface AccessTokenClaims extends JWTPayload { - id: string; - role: string; + id: string + role: string } /** Reads the unverified `kid` header so we know which key to check against. */ @@ -73,7 +73,9 @@ function readKeyId(token: string): string | undefined { } try { - const header = JSON.parse(Buffer.from(headerSegment, 'base64url').toString('utf8')) + const header = JSON.parse( + Buffer.from(headerSegment, 'base64url').toString('utf8'), + ) return typeof header.kid === 'string' ? header.kid : undefined } catch { @@ -87,7 +89,7 @@ function readKeyId(token: string): string | undefined { */ export function issueAccessToken( claims: AccessTokenClaims, - options: Omit = {} + options: Omit = {}, ): string { return signToken(claims, ACTIVE_SECRET, { ...options, @@ -105,9 +107,13 @@ export function issueAccessToken( * change), and always pins algorithm/issuer/audience — a token that used a * different algorithm or was minted for another audience is rejected. */ -export function verifyAccessToken(token: string, options: VerifyOptions = {}): AccessTokenClaims { +export function verifyAccessToken( + token: string, + options: VerifyOptions = {}, +): AccessTokenClaims { const kid = readKeyId(token) - const secret = !kid || kid === ACTIVE_KEY_ID ? ACTIVE_SECRET : RETIRED_KEYS.get(kid) + const secret = + !kid || kid === ACTIVE_KEY_ID ? ACTIVE_SECRET : RETIRED_KEYS.get(kid) if (!secret) { // Same error type jwt.verify() itself throws for a bad signature, so diff --git a/src/config/logger.ts b/src/config/logger.ts index 5b8a30dc..4e5d75e1 100644 --- a/src/config/logger.ts +++ b/src/config/logger.ts @@ -1,39 +1,39 @@ -import winston from 'winston' - -const isProduction = process.env.NODE_ENV === 'production' - -// JSON format for production (better for log aggregation) -const jsonFormat = winston.format.combine( - winston.format.timestamp(), - winston.format.errors({ stack: true }), - winston.format.json() -) - -// Human-readable format for development -const devFormat = winston.format.combine( - winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.errors({ stack: true }), - winston.format.colorize(), - winston.format.printf(({ timestamp, level, message, ...meta }) => { - let metaStr = '' - if (Object.keys(meta).length > 0) { - metaStr = JSON.stringify(meta, null, 2) - } - -return `${timestamp} [${level}]: ${message}${metaStr ? '\n' + metaStr : ''}` - }) -) - -const logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', - format: isProduction ? jsonFormat : devFormat, - transports: [ - new winston.transports.Console({ - stderrLevels: ['error'], - }) - ], - // Don't exit on uncaught exceptions - let the process handle it - exitOnError: false, -}) - -export default logger \ No newline at end of file +import winston from 'winston' + +const isProduction = process.env.NODE_ENV === 'production' + +// JSON format for production (better for log aggregation) +const jsonFormat = winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json(), +) + +// Human-readable format for development +const devFormat = winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.colorize(), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + let metaStr = '' + if (Object.keys(meta).length > 0) { + metaStr = JSON.stringify(meta, null, 2) + } + + return `${timestamp} [${level}]: ${message}${metaStr ? '\n' + metaStr : ''}` + }), +) + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: isProduction ? jsonFormat : devFormat, + transports: [ + new winston.transports.Console({ + stderrLevels: ['error'], + }), + ], + // Don't exit on uncaught exceptions - let the process handle it + exitOnError: false, +}) + +export default logger diff --git a/src/config/scheduler.ts b/src/config/scheduler.ts index fac95d97..0f9ad7e0 100644 --- a/src/config/scheduler.ts +++ b/src/config/scheduler.ts @@ -22,8 +22,8 @@ function toBool(value: string | undefined, fallback = false): boolean { function toList(value: string | undefined): string[] { return (value ?? '') .split(',') - .map(entry => entry.trim()) - .filter(entry => entry.length > 0) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) } function envKeyFor(queueName: string): string { @@ -35,7 +35,7 @@ export const schedulerConfig = { leaseMs: toInt(process.env.SCHEDULER_LEASE_MS, DEFAULT_LEASE_MS), shutdownTimeoutMs: toInt( process.env.SCHEDULER_SHUTDOWN_TIMEOUT_MS, - DEFAULT_SHUTDOWN_TIMEOUT_MS + DEFAULT_SHUTDOWN_TIMEOUT_MS, ), inProcess: toBool(process.env.SCHEDULER_IN_PROCESS, false), only: toList(process.env.SCHEDULER_QUEUES), diff --git a/src/config/swagger.ts b/src/config/swagger.ts index bd821ecd..64f7b94a 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -44,15 +44,38 @@ const options: swaggerJsdoc.Options = { ], tags: [ { name: 'Health', description: 'Service health check' }, - { name: 'Auth', description: 'Registration, login, refresh rotation, logout, email verification, password reset, phone OTP' }, + { + name: 'Auth', + description: + 'Registration, login, refresh rotation, logout, email verification, password reset, phone OTP', + }, { name: 'Users', description: 'User profile management' }, - { name: 'Modules', description: 'Learning module catalogue and progress tracking' }, + { + name: 'Modules', + description: 'Learning module catalogue and progress tracking', + }, { name: 'Credentials', description: 'On-chain verifiable credentials' }, - { name: 'Rewards', description: 'XLM balance, transaction history, and withdrawals' }, - { name: 'Referrals', description: 'Referral code generation and bonus tracking' }, - { name: 'Notifications', description: 'Push-notification device tokens and preferences' }, - { name: 'Sync', description: 'Offline progress and completion reconciliation' }, - { name: 'Employer', description: 'B2B talent search and candidate outreach (employer role required)' }, + { + name: 'Rewards', + description: 'XLM balance, transaction history, and withdrawals', + }, + { + name: 'Referrals', + description: 'Referral code generation and bonus tracking', + }, + { + name: 'Notifications', + description: 'Push-notification device tokens and preferences', + }, + { + name: 'Sync', + description: 'Offline progress and completion reconciliation', + }, + { + name: 'Employer', + description: + 'B2B talent search and candidate outreach (employer role required)', + }, ], components: { securitySchemes: { diff --git a/src/controllers/account.controller.ts b/src/controllers/account.controller.ts index 4c9d4234..a8e3ed98 100644 --- a/src/controllers/account.controller.ts +++ b/src/controllers/account.controller.ts @@ -3,22 +3,30 @@ import prisma from '../config/database' import { issueAccessToken } from '../config/jwt' import logger from '../utils/logger' import { - deactivateSchema, - reactivateSchema, - requestDeletionSchema, - cancelDeletionSchema, - exportIdParamSchema, + deactivateSchema, + reactivateSchema, + requestDeletionSchema, + cancelDeletionSchema, + exportIdParamSchema, } from '../schemas/account.schema' import { dataExportService } from '../services/data-export.service' import { accountLifecycleService } from '../services/account-lifecycle.service' import { auditService } from '../services/audit.service' import { emailService } from '../services/email.service' import { comparePassword } from '../utils/password' -import { AccountStatus, AuditAction, ExportStatus, RequestContext } from '../types/account.types' - -function buildDeletionRequestedEmail(username: string, scheduledFor: Date): { subject: string; body: string } { - const subject = 'Your account deletion request' - const body = `\ +import { + AccountStatus, + AuditAction, + ExportStatus, + RequestContext, +} from '../types/account.types' + +function buildDeletionRequestedEmail( + username: string, + scheduledFor: Date, +): { subject: string; body: string } { + const subject = 'Your account deletion request' + const body = `\ @@ -34,12 +42,15 @@ function buildDeletionRequestedEmail(username: string, scheduledFor: Date): { su ` - return { subject, body } + return { subject, body } } -function buildDeletionCancelledEmail(username: string): { subject: string; body: string } { - const subject = 'Your account deletion was cancelled' - const body = `\ +function buildDeletionCancelledEmail(username: string): { + subject: string + body: string +} { + const subject = 'Your account deletion was cancelled' + const body = `\ @@ -51,571 +62,681 @@ function buildDeletionCancelledEmail(username: string): { subject: string; body: ` - return { subject, body } + return { subject, body } } export class AccountController { - /** - * @openapi - * /v1/account/export: - * post: - * summary: Request a data export - * description: Starts asynchronous generation of a user-scoped data export. Only one active export request is allowed at a time. - * tags: [Account] - * security: - * - bearerAuth: [] - * responses: - * 202: - * description: Export request accepted - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/DataExportRequest' - * 401: - * description: Authentication required - * 403: - * description: Account is not active - * 409: - * description: An active export request already exists - */ - async requestExport(req: Request, res: Response): Promise { - try { - const userId = req.user!.id - - const result = await dataExportService.requestExport(userId) - - if (result.kind === 'duplicate') { - res.status(409).json({ - error: 'An export request is already in progress', - existingRequestId: result.request.id, - }) - - return - } - - res.status(202).json({ - id: result.request.id, - status: result.request.status, - createdAt: result.request.createdAt, - }) - } catch (error) { - logger.error('Export request error:', error) - res.status(500).json({ error: 'Internal server error during export request' }) - } + /** + * @openapi + * /v1/account/export: + * post: + * summary: Request a data export + * description: Starts asynchronous generation of a user-scoped data export. Only one active export request is allowed at a time. + * tags: [Account] + * security: + * - bearerAuth: [] + * responses: + * 202: + * description: Export request accepted + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DataExportRequest' + * 401: + * description: Authentication required + * 403: + * description: Account is not active + * 409: + * description: An active export request already exists + */ + async requestExport(req: Request, res: Response): Promise { + try { + const userId = req.user!.id + + const result = await dataExportService.requestExport(userId) + + if (result.kind === 'duplicate') { + res.status(409).json({ + error: 'An export request is already in progress', + existingRequestId: result.request.id, + }) + + return + } + + res.status(202).json({ + id: result.request.id, + status: result.request.status, + createdAt: result.request.createdAt, + }) + } catch (error) { + logger.error('Export request error:', error) + res + .status(500) + .json({ error: 'Internal server error during export request' }) } - - /** - * @openapi - * /v1/account/export/{id}: - * get: - * summary: Get export request status - * description: Returns the status of an export request owned by the authenticated user. Requests owned by other users behave as not found. - * tags: [Account] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * responses: - * 200: - * description: Export request status - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/DataExportRequest' - * 400: - * description: Invalid export id - * 401: - * description: Authentication required - * 404: - * description: Export request not found - */ - async getExportStatus(req: Request, res: Response): Promise { - try { - const params = exportIdParamSchema.safeParse(req.params) - if (!params.success) { - res.status(400).json({ error: 'Validation failed', details: params.error.format() }) - - return - } - - const request = await dataExportService.getExportStatus(req.user!.id, params.data.id) - - if (!request) { - res.status(404).json({ error: 'Export request not found' }) - - return - } - - res.status(200).json({ - id: request.id, - status: request.status, - createdAt: request.createdAt, - completedAt: request.completedAt, - expiresAt: request.expiresAt, - downloadedAt: request.downloadedAt, - }) - } catch (error) { - logger.error('Export status error:', error) - res.status(500).json({ error: 'Internal server error during export status lookup' }) - } + } + + /** + * @openapi + * /v1/account/export/{id}: + * get: + * summary: Get export request status + * description: Returns the status of an export request owned by the authenticated user. Requests owned by other users behave as not found. + * tags: [Account] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Export request status + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DataExportRequest' + * 400: + * description: Invalid export id + * 401: + * description: Authentication required + * 404: + * description: Export request not found + */ + async getExportStatus(req: Request, res: Response): Promise { + try { + const params = exportIdParamSchema.safeParse(req.params) + if (!params.success) { + res + .status(400) + .json({ error: 'Validation failed', details: params.error.format() }) + + return + } + + const request = await dataExportService.getExportStatus( + req.user!.id, + params.data.id, + ) + + if (!request) { + res.status(404).json({ error: 'Export request not found' }) + + return + } + + res.status(200).json({ + id: request.id, + status: request.status, + createdAt: request.createdAt, + completedAt: request.completedAt, + expiresAt: request.expiresAt, + downloadedAt: request.downloadedAt, + }) + } catch (error) { + logger.error('Export status error:', error) + res + .status(500) + .json({ error: 'Internal server error during export status lookup' }) } - - /** - * @openapi - * /v1/account/export/{id}/download: - * get: - * summary: Download a ready data export - * description: Streams the export artifact as a JSON attachment. Exports expire and are purged after their retention window. - * tags: [Account] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * responses: - * 200: - * description: Export artifact (JSON attachment) - * 400: - * description: Invalid export id - * 401: - * description: Authentication required - * 404: - * description: Export request not found - * 409: - * description: Export is not ready yet - * 410: - * description: Export expired, was purged, or failed - */ - async downloadExport(req: Request, res: Response): Promise { - try { - const params = exportIdParamSchema.safeParse(req.params) - if (!params.success) { - res.status(400).json({ error: 'Validation failed', details: params.error.format() }) - - return - } - - const userId = req.user!.id - const request = await dataExportService.getExportStatus(userId, params.data.id) - - if (!request) { - res.status(404).json({ error: 'Export request not found' }) - - return - } - - if (request.status === ExportStatus.PENDING || request.status === ExportStatus.PROCESSING) { - res.status(409).json({ error: 'Export is not ready yet', status: request.status }) - - return - } - - const isExpired = - request.status === ExportStatus.EXPIRED || - (request.expiresAt !== null && request.expiresAt <= new Date()) - - if (request.status === ExportStatus.FAILED || isExpired || !request.artifact) { - res.status(410).json({ error: 'Export is no longer available' }) - - return - } - - await dataExportService.markDownloaded(userId, request.id) - - res.setHeader('Content-Type', 'application/json') - res.setHeader( - 'Content-Disposition', - `attachment; filename="learnault-export-${request.id}.json"` - ) - res.status(200).send(request.artifact) - } catch (error) { - logger.error('Export download error:', error) - res.status(500).json({ error: 'Internal server error during export download' }) - } + } + + /** + * @openapi + * /v1/account/export/{id}/download: + * get: + * summary: Download a ready data export + * description: Streams the export artifact as a JSON attachment. Exports expire and are purged after their retention window. + * tags: [Account] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Export artifact (JSON attachment) + * 400: + * description: Invalid export id + * 401: + * description: Authentication required + * 404: + * description: Export request not found + * 409: + * description: Export is not ready yet + * 410: + * description: Export expired, was purged, or failed + */ + async downloadExport(req: Request, res: Response): Promise { + try { + const params = exportIdParamSchema.safeParse(req.params) + if (!params.success) { + res + .status(400) + .json({ error: 'Validation failed', details: params.error.format() }) + + return + } + + const userId = req.user!.id + const request = await dataExportService.getExportStatus( + userId, + params.data.id, + ) + + if (!request) { + res.status(404).json({ error: 'Export request not found' }) + + return + } + + if ( + request.status === ExportStatus.PENDING || + request.status === ExportStatus.PROCESSING + ) { + res + .status(409) + .json({ error: 'Export is not ready yet', status: request.status }) + + return + } + + const isExpired = + request.status === ExportStatus.EXPIRED || + (request.expiresAt !== null && request.expiresAt <= new Date()) + + if ( + request.status === ExportStatus.FAILED || + isExpired || + !request.artifact + ) { + res.status(410).json({ error: 'Export is no longer available' }) + + return + } + + await dataExportService.markDownloaded(userId, request.id) + + res.setHeader('Content-Type', 'application/json') + res.setHeader( + 'Content-Disposition', + `attachment; filename="learnault-export-${request.id}.json"`, + ) + res.status(200).send(request.artifact) + } catch (error) { + logger.error('Export download error:', error) + res + .status(500) + .json({ error: 'Internal server error during export download' }) } - - /** - * @openapi - * /v1/account/deactivate: - * post: - * summary: Deactivate account - * description: Reversibly deactivates the account. Requires password re-entry (step-up). Revokes sessions and blocks login until reactivation. - * tags: [Account] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/DeactivateInput' - * responses: - * 200: - * description: Account deactivated - * 400: - * description: Validation failed - * 401: - * description: Authentication required or wrong password - * 409: - * description: Account is already deactivated or pending deletion - */ - async deactivate(req: Request, res: Response): Promise { - try { - const validation = deactivateSchema.safeParse(req.body) - if (!validation.success) { - res.status(400).json({ error: 'Validation failed', details: validation.error.format() }) - - return - } - - const user = await this.stepUp(req, res, validation.data.password, 'deactivate') - if (!user) { - return - } - - const result = await accountLifecycleService.deactivate(user.id, user.status, this.context(req)) - - if (result.kind === 'conflict') { - res.status(409).json({ - error: result.status === AccountStatus.PENDING_DELETION - ? 'Account is pending deletion' - : 'Account is already deactivated', - code: result.status, - }) - - return - } - - res.status(200).json({ message: 'Account deactivated successfully', status: AccountStatus.DEACTIVATED }) - } catch (error) { - logger.error('Deactivation error:', error) - res.status(500).json({ error: 'Internal server error during deactivation' }) - } + } + + /** + * @openapi + * /v1/account/deactivate: + * post: + * summary: Deactivate account + * description: Reversibly deactivates the account. Requires password re-entry (step-up). Revokes sessions and blocks login until reactivation. + * tags: [Account] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DeactivateInput' + * responses: + * 200: + * description: Account deactivated + * 400: + * description: Validation failed + * 401: + * description: Authentication required or wrong password + * 409: + * description: Account is already deactivated or pending deletion + */ + async deactivate(req: Request, res: Response): Promise { + try { + const validation = deactivateSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const user = await this.stepUp( + req, + res, + validation.data.password, + 'deactivate', + ) + if (!user) { + return + } + + const result = await accountLifecycleService.deactivate( + user.id, + user.status, + this.context(req), + ) + + if (result.kind === 'conflict') { + res.status(409).json({ + error: + result.status === AccountStatus.PENDING_DELETION + ? 'Account is pending deletion' + : 'Account is already deactivated', + code: result.status, + }) + + return + } + + res.status(200).json({ + message: 'Account deactivated successfully', + status: AccountStatus.DEACTIVATED, + }) + } catch (error) { + logger.error('Deactivation error:', error) + res + .status(500) + .json({ error: 'Internal server error during deactivation' }) } - - /** - * @openapi - * /v1/account/reactivate: - * post: - * summary: Reactivate a deactivated account - * description: Public endpoint (deactivated accounts cannot log in). Verifies credentials and reactivates the account, returning a fresh token. - * tags: [Account] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ReactivateInput' - * responses: - * 200: - * description: Account reactivated - * 400: - * description: Validation failed - * 401: - * description: Invalid credentials - * 409: - * description: Account is pending deletion — cancel the deletion request instead - */ - async reactivate(req: Request, res: Response): Promise { - try { - const validation = reactivateSchema.safeParse(req.body) - if (!validation.success) { - res.status(400).json({ error: 'Validation failed', details: validation.error.format() }) - - return - } - - const user = await this.verifyCredentials(validation.data.email, validation.data.password) - if (!user) { - res.status(401).json({ error: 'Invalid credentials' }) - - return - } - - if (user.status === AccountStatus.PENDING_DELETION) { - res.status(409).json({ - error: 'Account is scheduled for deletion. Cancel the deletion request to restore access.', - code: 'ACCOUNT_PENDING_DELETION', - }) - - return - } - - if (user.status === AccountStatus.DEACTIVATED) { - await accountLifecycleService.reactivate(user.id, this.context(req)) - } - - const token = this.generateToken(user.id, user.role) - - res.status(200).json({ - message: 'Account reactivated successfully', - token, - user: { - id: user.id, - email: user.email, - username: user.username, - role: user.role, - }, - }) - } catch (error) { - logger.error('Reactivation error:', error) - res.status(500).json({ error: 'Internal server error during reactivation' }) - } + } + + /** + * @openapi + * /v1/account/reactivate: + * post: + * summary: Reactivate a deactivated account + * description: Public endpoint (deactivated accounts cannot log in). Verifies credentials and reactivates the account, returning a fresh token. + * tags: [Account] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ReactivateInput' + * responses: + * 200: + * description: Account reactivated + * 400: + * description: Validation failed + * 401: + * description: Invalid credentials + * 409: + * description: Account is pending deletion — cancel the deletion request instead + */ + async reactivate(req: Request, res: Response): Promise { + try { + const validation = reactivateSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const user = await this.verifyCredentials( + validation.data.email, + validation.data.password, + ) + if (!user) { + res.status(401).json({ error: 'Invalid credentials' }) + + return + } + + if (user.status === AccountStatus.PENDING_DELETION) { + res.status(409).json({ + error: + 'Account is scheduled for deletion. Cancel the deletion request to restore access.', + code: 'ACCOUNT_PENDING_DELETION', + }) + + return + } + + if (user.status === AccountStatus.DEACTIVATED) { + await accountLifecycleService.reactivate(user.id, this.context(req)) + } + + const token = this.generateToken(user.id, user.role) + + res.status(200).json({ + message: 'Account reactivated successfully', + token, + user: { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + }, + }) + } catch (error) { + logger.error('Reactivation error:', error) + res + .status(500) + .json({ error: 'Internal server error during reactivation' }) } - - /** - * @openapi - * /v1/account/deletion: - * post: - * summary: Request account deletion - * description: Starts the deletion process with a cooling-off window. Requires password re-entry (step-up). The account behaves as deactivated until finalization or cancellation. - * tags: [Account] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RequestDeletionInput' - * responses: - * 202: - * description: Deletion request accepted - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/AccountDeletionRequest' - * 400: - * description: Validation failed - * 401: - * description: Authentication required or wrong password - * 403: - * description: Account is not active - * 409: - * description: An active deletion request already exists - */ - async requestDeletion(req: Request, res: Response): Promise { - try { - const validation = requestDeletionSchema.safeParse(req.body) - if (!validation.success) { - res.status(400).json({ error: 'Validation failed', details: validation.error.format() }) - - return - } - - const user = await this.stepUp(req, res, validation.data.password, 'deletion') - if (!user) { - return - } - - const result = await accountLifecycleService.requestDeletion( - user.id, - validation.data.reason, - this.context(req) - ) - - if (result.kind === 'duplicate') { - res.status(409).json({ - error: 'A deletion request is already pending', - existingRequestId: result.request.id, - scheduledFor: result.request.scheduledFor, - }) - - return - } - - const email = buildDeletionRequestedEmail(user.username, result.request.scheduledFor) - await emailService.queueEmail(user.id, user.email, email.subject, email.body, 'ACCOUNT_DELETION') - - res.status(202).json({ - id: result.request.id, - status: result.request.status, - scheduledFor: result.request.scheduledFor, - }) - } catch (error) { - logger.error('Deletion request error:', error) - res.status(500).json({ error: 'Internal server error during deletion request' }) - } + } + + /** + * @openapi + * /v1/account/deletion: + * post: + * summary: Request account deletion + * description: Starts the deletion process with a cooling-off window. Requires password re-entry (step-up). The account behaves as deactivated until finalization or cancellation. + * tags: [Account] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RequestDeletionInput' + * responses: + * 202: + * description: Deletion request accepted + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AccountDeletionRequest' + * 400: + * description: Validation failed + * 401: + * description: Authentication required or wrong password + * 403: + * description: Account is not active + * 409: + * description: An active deletion request already exists + */ + async requestDeletion(req: Request, res: Response): Promise { + try { + const validation = requestDeletionSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const user = await this.stepUp( + req, + res, + validation.data.password, + 'deletion', + ) + if (!user) { + return + } + + const result = await accountLifecycleService.requestDeletion( + user.id, + validation.data.reason, + this.context(req), + ) + + if (result.kind === 'duplicate') { + res.status(409).json({ + error: 'A deletion request is already pending', + existingRequestId: result.request.id, + scheduledFor: result.request.scheduledFor, + }) + + return + } + + const email = buildDeletionRequestedEmail( + user.username, + result.request.scheduledFor, + ) + await emailService.queueEmail( + user.id, + user.email, + email.subject, + email.body, + 'ACCOUNT_DELETION', + ) + + res.status(202).json({ + id: result.request.id, + status: result.request.status, + scheduledFor: result.request.scheduledFor, + }) + } catch (error) { + logger.error('Deletion request error:', error) + res + .status(500) + .json({ error: 'Internal server error during deletion request' }) } - - /** - * @openapi - * /v1/account/deletion: - * get: - * summary: Get deletion request status - * description: Returns the latest deletion request for the authenticated user, or a null status when none exists. - * tags: [Account] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Latest deletion request, or null status - * 401: - * description: Authentication required - */ - async getDeletionStatus(req: Request, res: Response): Promise { - try { - const request = await accountLifecycleService.getLatestDeletionRequest(req.user!.id) - - if (!request) { - res.status(200).json({ status: null }) - - return - } - - res.status(200).json({ - id: request.id, - status: request.status, - scheduledFor: request.scheduledFor, - cancelledAt: request.cancelledAt, - completedAt: request.completedAt, - createdAt: request.createdAt, - }) - } catch (error) { - logger.error('Deletion status error:', error) - res.status(500).json({ error: 'Internal server error during deletion status lookup' }) - } + } + + /** + * @openapi + * /v1/account/deletion: + * get: + * summary: Get deletion request status + * description: Returns the latest deletion request for the authenticated user, or a null status when none exists. + * tags: [Account] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Latest deletion request, or null status + * 401: + * description: Authentication required + */ + async getDeletionStatus(req: Request, res: Response): Promise { + try { + const request = await accountLifecycleService.getLatestDeletionRequest( + req.user!.id, + ) + + if (!request) { + res.status(200).json({ status: null }) + + return + } + + res.status(200).json({ + id: request.id, + status: request.status, + scheduledFor: request.scheduledFor, + cancelledAt: request.cancelledAt, + completedAt: request.completedAt, + createdAt: request.createdAt, + }) + } catch (error) { + logger.error('Deletion status error:', error) + res + .status(500) + .json({ error: 'Internal server error during deletion status lookup' }) } - - /** - * @openapi - * /v1/account/deletion/cancel: - * post: - * summary: Cancel a pending deletion request - * description: Public endpoint (accounts pending deletion cannot log in). Verifies credentials and cancels the pending deletion, restoring the account. - * tags: [Account] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/CancelDeletionInput' - * responses: - * 200: - * description: Deletion cancelled, account restored - * 400: - * description: Validation failed - * 401: - * description: Invalid credentials - * 404: - * description: No pending deletion request - * 410: - * description: Deletion has already been finalized - */ - async cancelDeletion(req: Request, res: Response): Promise { - try { - const validation = cancelDeletionSchema.safeParse(req.body) - if (!validation.success) { - res.status(400).json({ error: 'Validation failed', details: validation.error.format() }) - - return - } - - const user = await this.verifyCredentials(validation.data.email, validation.data.password) - if (!user) { - res.status(401).json({ error: 'Invalid credentials' }) - - return - } - - const result = await accountLifecycleService.cancelDeletion(user.id, this.context(req)) - - if (result.kind === 'none') { - res.status(404).json({ error: 'No pending deletion request found' }) - - return - } - - if (result.kind === 'finalized') { - res.status(410).json({ error: 'Deletion has already been finalized and cannot be cancelled' }) - - return - } - - const email = buildDeletionCancelledEmail(user.username) - await emailService.queueEmail(user.id, user.email, email.subject, email.body, 'ACCOUNT_DELETION') - - res.status(200).json({ message: 'Deletion request cancelled. Your account is active again.' }) - } catch (error) { - logger.error('Deletion cancellation error:', error) - res.status(500).json({ error: 'Internal server error during deletion cancellation' }) - } + } + + /** + * @openapi + * /v1/account/deletion/cancel: + * post: + * summary: Cancel a pending deletion request + * description: Public endpoint (accounts pending deletion cannot log in). Verifies credentials and cancels the pending deletion, restoring the account. + * tags: [Account] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CancelDeletionInput' + * responses: + * 200: + * description: Deletion cancelled, account restored + * 400: + * description: Validation failed + * 401: + * description: Invalid credentials + * 404: + * description: No pending deletion request + * 410: + * description: Deletion has already been finalized + */ + async cancelDeletion(req: Request, res: Response): Promise { + try { + const validation = cancelDeletionSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const user = await this.verifyCredentials( + validation.data.email, + validation.data.password, + ) + if (!user) { + res.status(401).json({ error: 'Invalid credentials' }) + + return + } + + const result = await accountLifecycleService.cancelDeletion( + user.id, + this.context(req), + ) + + if (result.kind === 'none') { + res.status(404).json({ error: 'No pending deletion request found' }) + + return + } + + if (result.kind === 'finalized') { + res.status(410).json({ + error: 'Deletion has already been finalized and cannot be cancelled', + }) + + return + } + + const email = buildDeletionCancelledEmail(user.username) + await emailService.queueEmail( + user.id, + user.email, + email.subject, + email.body, + 'ACCOUNT_DELETION', + ) + + res.status(200).json({ + message: 'Deletion request cancelled. Your account is active again.', + }) + } catch (error) { + logger.error('Deletion cancellation error:', error) + res + .status(500) + .json({ error: 'Internal server error during deletion cancellation' }) + } + } + + /** + * Step-up authentication: even with a valid JWT, sensitive actions require + * fresh password re-entry. Responds 401 and returns null on failure. + */ + private async stepUp( + req: Request, + res: Response, + password: string, + action: string, + ): Promise<{ + id: string + email: string + username: string + role: string + status: string + } | null> { + const userId = req.user!.id + const user = await prisma.user.findUnique({ where: { id: userId } }) + + if (!user || user.status === AccountStatus.DELETED) { + res.status(401).json({ error: 'Account not found' }) + + return null } - /** - * Step-up authentication: even with a valid JWT, sensitive actions require - * fresh password re-entry. Responds 401 and returns null on failure. - */ - private async stepUp( - req: Request, - res: Response, - password: string, - action: string - ): Promise<{ id: string; email: string; username: string; role: string; status: string } | null> { - const userId = req.user!.id - const user = await prisma.user.findUnique({ where: { id: userId } }) - - if (!user || user.status === AccountStatus.DELETED) { - res.status(401).json({ error: 'Account not found' }) - - return null - } - - const isMatch = await comparePassword(password, user.password) - if (!isMatch) { - await auditService.record({ - userId, - action: AuditAction.STEP_UP_FAILED, - metadata: { attemptedAction: action }, - ...this.context(req), - }) - res.status(401).json({ error: 'Invalid password', code: 'STEP_UP_FAILED' }) - - return null - } - - return user + const isMatch = await comparePassword(password, user.password) + if (!isMatch) { + await auditService.record({ + userId, + action: AuditAction.STEP_UP_FAILED, + metadata: { attemptedAction: action }, + ...this.context(req), + }) + res + .status(401) + .json({ error: 'Invalid password', code: 'STEP_UP_FAILED' }) + + return null } - /** - * Credential verification for public lifecycle endpoints. Neutral null on - * unknown email, wrong password, or tombstoned account (no state leaks). - */ - private async verifyCredentials( - email: string, - password: string - ): Promise<{ id: string; email: string; username: string; role: string; status: string } | null> { - const user = await prisma.user.findUnique({ where: { email } }) - - if (!user || user.status === AccountStatus.DELETED) { - return null - } - - const isMatch = await comparePassword(password, user.password) - if (!isMatch) { - return null - } - - return user + return user + } + + /** + * Credential verification for public lifecycle endpoints. Neutral null on + * unknown email, wrong password, or tombstoned account (no state leaks). + */ + private async verifyCredentials( + email: string, + password: string, + ): Promise<{ + id: string + email: string + username: string + role: string + status: string + } | null> { + const user = await prisma.user.findUnique({ where: { email } }) + + if (!user || user.status === AccountStatus.DELETED) { + return null } - private context(req: Request): RequestContext { - return { - ipAddress: req.ip, - userAgent: req.headers['user-agent'], - } + const isMatch = await comparePassword(password, user.password) + if (!isMatch) { + return null } - private generateToken(userId: string, role: string): string { - return issueAccessToken({ id: userId, role }) + return user + } + + private context(req: Request): RequestContext { + return { + ipAddress: req.ip, + userAgent: req.headers['user-agent'], } + } + + private generateToken(userId: string, role: string): string { + return issueAccessToken({ id: userId, role }) + } } diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 97ae1237..9918dd2e 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -1,7 +1,17 @@ import crypto from 'crypto' import { Request, Response } from 'express' import prisma from '../config/database' -import { loginSchema, registerSchema, verifyEmailSchema, resendVerificationSchema, forgotPasswordSchema, resetPasswordSchema, otpRequestSchema, otpVerifySchema, refreshTokenSchema } from '../schemas/auth.schema' +import { + loginSchema, + registerSchema, + verifyEmailSchema, + resendVerificationSchema, + forgotPasswordSchema, + resetPasswordSchema, + otpRequestSchema, + otpVerifySchema, + refreshTokenSchema, +} from '../schemas/auth.schema' import { UserRole } from '../types/user.types' import { emailService } from '../services/email.service' import { createOutboxService } from '../lib/transactions/outbox.service' @@ -26,22 +36,37 @@ const OTP_DEVICE_LIMIT = 10 const OTP_DEVICE_WINDOW_MS = 60 * 60 * 1000 // 1 hour export const resendCooldowns = new Map() -export const resendAccountCounts = new Map() +export const resendAccountCounts = new Map< + string, + { count: number; resetAt: number } +>() export const resetPasswordCooldowns = new Map() -export const resetPasswordAccountCounts = new Map() -export const otpPhoneCounts = new Map() -export const otpDeviceCounts = new Map() +export const resetPasswordAccountCounts = new Map< + string, + { count: number; resetAt: number } +>() +export const otpPhoneCounts = new Map< + string, + { count: number; resetAt: number } +>() +export const otpDeviceCounts = new Map< + string, + { count: number; resetAt: number } +>() function generateVerificationToken(): { rawToken: string; tokenHash: string } { - const rawToken = crypto.randomBytes(32).toString('hex') - const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex') + const rawToken = crypto.randomBytes(32).toString('hex') + const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex') - return { rawToken, tokenHash } + return { rawToken, tokenHash } } -function buildVerificationEmail(to: string, rawToken: string): { subject: string; body: string } { - const subject = 'Verify your email address' - const body = `\ +function buildVerificationEmail( + to: string, + rawToken: string, +): { subject: string; body: string } { + const subject = 'Verify your email address' + const body = `\ @@ -58,12 +83,15 @@ function buildVerificationEmail(to: string, rawToken: string): { subject: string ` - return { subject, body } + return { subject, body } } -function buildPasswordResetEmail(to: string, rawToken: string): { subject: string; body: string } { - const subject = 'Reset your password' - const body = `\ +function buildPasswordResetEmail( + to: string, + rawToken: string, +): { subject: string; body: string } { + const subject = 'Reset your password' + const body = `\ @@ -80,1429 +108,1547 @@ function buildPasswordResetEmail(to: string, rawToken: string): { subject: strin ` - return { subject, body } + return { subject, body } } export class AuthController { - /** - * @openapi - * /auth/register: - * post: - * operationId: authRegister - * summary: Register a new user - * tags: [Auth] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RegisterInput' - * responses: - * 201: - * description: User registered successfully; a verification email is queued. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/AuthResponse' - * 400: - * description: Validation failed - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 409: - * description: Email or username already taken - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async register(req: Request, res: Response): Promise { - try { - const validation = registerSchema.safeParse(req.body) - if (!validation.success) { - res.status(400).json({ - error: 'Validation failed', - details: validation.error.format() - }) - - return - } - - const { email, password, username, role } = validation.data - - const existingUser = await prisma.user.findFirst({ - where: { - OR: [ - { email }, - { username } - ] - } - }) - - if (existingUser) { - res.status(409).json({ error: 'User with this email or username already exists' }) - - return - } - - const hashedPassword = await hashPassword(password) - - const { rawToken, tokenHash } = generateVerificationToken() - const expiresAt = new Date(Date.now() + VERIFICATION_TOKEN_EXPIRY_MS) - - const user = await prisma.$transaction(async (tx) => { - const created = await tx.user.create({ - data: { - email, - username, - password: hashedPassword, - role: (role as any) || UserRole.LEARNER, - } - }) - - await tx.verificationToken.create({ - data: { - userId: created.id, - tokenHash, - expiresAt, - } - }) - - await createOutboxService(prisma).createEvent(tx, { - aggregateId: created.id, - aggregateType: 'User', - eventType: 'UserCreated', - eventVersion: 1, - payload: { - userId: created.id, - email: created.email, - role: created.role, - }, - source: 'api.auth.register', - }) - - return created - }) - - // Queue verification email via outbox - const { subject, body } = buildVerificationEmail(email, rawToken) - emailService.queueEmail(user.id, email, subject, body).catch(err => - logger.error('[Auth] Failed to queue verification email:', err) - ) - - const session = await refreshTokenService.issueSession({ - userId: user.id, - role: user.role, - ...this.clientContext(req), - }) - - res.status(201).json({ - message: 'User registered successfully', - ...this.tokenPayload(session), - user: { - id: user.id, - email: user.email, - username: user.username, - role: user.role - } - }) - } catch (error) { - console.error('Registration error:', error) - res.status(500).json({ error: 'Internal server error during registration' }) - } + /** + * @openapi + * /auth/register: + * post: + * operationId: authRegister + * summary: Register a new user + * tags: [Auth] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RegisterInput' + * responses: + * 201: + * description: User registered successfully; a verification email is queued. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthResponse' + * 400: + * description: Validation failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 409: + * description: Email or username already taken + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async register(req: Request, res: Response): Promise { + try { + const validation = registerSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const { email, password, username, role } = validation.data + + const existingUser = await prisma.user.findFirst({ + where: { + OR: [{ email }, { username }], + }, + }) + + if (existingUser) { + res + .status(409) + .json({ error: 'User with this email or username already exists' }) + + return + } + + const hashedPassword = await hashPassword(password) + + const { rawToken, tokenHash } = generateVerificationToken() + const expiresAt = new Date(Date.now() + VERIFICATION_TOKEN_EXPIRY_MS) + + const user = await prisma.$transaction(async (tx) => { + const created = await tx.user.create({ + data: { + email, + username, + password: hashedPassword, + role: (role as any) || UserRole.LEARNER, + }, + }) + + await tx.verificationToken.create({ + data: { + userId: created.id, + tokenHash, + expiresAt, + }, + }) + + await createOutboxService(prisma).createEvent(tx, { + aggregateId: created.id, + aggregateType: 'User', + eventType: 'UserCreated', + eventVersion: 1, + payload: { + userId: created.id, + email: created.email, + role: created.role, + }, + source: 'api.auth.register', + }) + + return created + }) + + // Queue verification email via outbox + const { subject, body } = buildVerificationEmail(email, rawToken) + emailService + .queueEmail(user.id, email, subject, body) + .catch((err) => + logger.error('[Auth] Failed to queue verification email:', err), + ) + + const session = await refreshTokenService.issueSession({ + userId: user.id, + role: user.role, + ...this.clientContext(req), + }) + + res.status(201).json({ + message: 'User registered successfully', + ...this.tokenPayload(session), + user: { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + }, + }) + } catch (error) { + console.error('Registration error:', error) + res + .status(500) + .json({ error: 'Internal server error during registration' }) } - - /** - * @openapi - * /auth/verify-email: - * post: - * operationId: authVerifyEmail - * summary: Verify email address with a token - * tags: [Auth] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/VerifyEmailInput' - * responses: - * 200: - * description: Email verified (or already verified) - * 400: - * description: Invalid or expired token - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async verifyEmail(req: Request, res: Response): Promise { - try { - const validation = verifyEmailSchema.safeParse(req.body) - if (!validation.success) { - res.status(400).json({ error: 'Invalid token' }) - - return - } - - const { token } = validation.data - - // Validate token format before hashing - if (!/^[0-9a-f]{64}$/i.test(token)) { - res.status(400).json({ error: 'Invalid token' }) - - return - } - - const tokenHash = crypto.createHash('sha256').update(token).digest('hex') - - const verificationToken = await prisma.verificationToken.findFirst({ - where: { tokenHash }, - include: { user: true }, - }) - - if (!verificationToken) { - res.status(400).json({ error: 'Invalid token' }) - - return - } - - if (verificationToken.status === 'USED') { - res.status(200).json({ message: 'Email already verified' }) - - return - } - - if (verificationToken.status === 'REVOKED') { - res.status(400).json({ error: 'Invalid token' }) - - return - } - - if (new Date() > verificationToken.expiresAt) { - await prisma.verificationToken.update({ - where: { id: verificationToken.id }, - data: { status: 'REVOKED' }, - }) - res.status(400).json({ error: 'Token expired' }) - - return - } - - // Mark token as used and verify user - await prisma.$transaction([ - prisma.verificationToken.update({ - where: { id: verificationToken.id }, - data: { status: 'USED' }, - }), - prisma.user.update({ - where: { id: verificationToken.userId }, - data: { isVerified: true }, - }), - ]) - - logger.info(`[Auth] Email verified for user ${verificationToken.userId}`) - - res.status(200).json({ message: 'Email verified successfully' }) - } catch (error) { - console.error('Email verification error:', error) - res.status(500).json({ error: 'Internal server error during verification' }) - } + } + + /** + * @openapi + * /auth/verify-email: + * post: + * operationId: authVerifyEmail + * summary: Verify email address with a token + * tags: [Auth] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/VerifyEmailInput' + * responses: + * 200: + * description: Email verified (or already verified) + * 400: + * description: Invalid or expired token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async verifyEmail(req: Request, res: Response): Promise { + try { + const validation = verifyEmailSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ error: 'Invalid token' }) + + return + } + + const { token } = validation.data + + // Validate token format before hashing + if (!/^[0-9a-f]{64}$/i.test(token)) { + res.status(400).json({ error: 'Invalid token' }) + + return + } + + const tokenHash = crypto.createHash('sha256').update(token).digest('hex') + + const verificationToken = await prisma.verificationToken.findFirst({ + where: { tokenHash }, + include: { user: true }, + }) + + if (!verificationToken) { + res.status(400).json({ error: 'Invalid token' }) + + return + } + + if (verificationToken.status === 'USED') { + res.status(200).json({ message: 'Email already verified' }) + + return + } + + if (verificationToken.status === 'REVOKED') { + res.status(400).json({ error: 'Invalid token' }) + + return + } + + if (new Date() > verificationToken.expiresAt) { + await prisma.verificationToken.update({ + where: { id: verificationToken.id }, + data: { status: 'REVOKED' }, + }) + res.status(400).json({ error: 'Token expired' }) + + return + } + + // Mark token as used and verify user + await prisma.$transaction([ + prisma.verificationToken.update({ + where: { id: verificationToken.id }, + data: { status: 'USED' }, + }), + prisma.user.update({ + where: { id: verificationToken.userId }, + data: { isVerified: true }, + }), + ]) + + logger.info(`[Auth] Email verified for user ${verificationToken.userId}`) + + res.status(200).json({ message: 'Email verified successfully' }) + } catch (error) { + console.error('Email verification error:', error) + res + .status(500) + .json({ error: 'Internal server error during verification' }) } - - /** - * @openapi - * /auth/resend-verification: - * post: - * operationId: authResendVerification - * summary: Resend verification email - * description: > - * Always returns 200 with a neutral message to avoid leaking user existence. - * Rate-limited by IP (1 req/min) and per account (5 req/24 h). - * tags: [Auth] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ResendVerificationInput' - * responses: - * 200: - * description: If the account exists, a verification email will be sent. - * 429: - * description: Too many requests — IP or account rate limit reached. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async resendVerification(req: Request, res: Response): Promise { - try { - const validation = resendVerificationSchema.safeParse(req.body) - if (!validation.success) { - // Neutral response regardless - res.status(200).json({ message: 'If the account exists, a verification email has been sent.' }) - - return - } - - const { email } = validation.data - - // IP rate limit check - const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() - || (req.headers['x-real-ip'] as string) - || req.socket.remoteAddress - || 'unknown' - - if (this.isRateLimited(`ip:${ip}`, RESEND_COOLDOWN_MS)) { - res.status(429).json({ error: 'Too many requests. Please try again later.' }) - - return - } - - // Look up user (do not reveal existence) - const user = await prisma.user.findUnique({ where: { email } }) - - if (!user) { - res.status(200).json({ message: 'If the account exists, a verification email has been sent.' }) - - return - } - - if (user.isVerified) { - res.status(200).json({ message: 'If the account exists, a verification email has been sent.' }) - - return - } - - // Account rate limit check - if (this.isAccountLimited(user.id)) { - res.status(429).json({ error: 'Too many requests. Please try again later.' }) - - return - } - - // Cooldown per user - if (this.isRateLimited(`user:${user.id}`, RESEND_COOLDOWN_MS)) { - res.status(429).json({ error: 'Too many requests. Please try again later.' }) - - return - } - - // Revoke existing pending tokens - await prisma.verificationToken.updateMany({ - where: { userId: user.id, status: 'PENDING' }, - data: { status: 'REVOKED' }, - }) - - // Create new token - const { rawToken, tokenHash } = generateVerificationToken() - const expiresAt = new Date(Date.now() + VERIFICATION_TOKEN_EXPIRY_MS) - - await prisma.verificationToken.create({ - data: { - userId: user.id, - tokenHash, - expiresAt, - } - }) - - // Queue email - const { subject, body } = buildVerificationEmail(email, rawToken) - emailService.queueEmail(user.id, email, subject, body).catch(err => - logger.error('[Auth] Failed to queue verification email:', err) - ) - - this.recordAccountRequest(user.id) - - res.status(200).json({ message: 'If the account exists, a verification email has been sent.' }) - } catch (error) { - console.error('Resend verification error:', error) - res.status(500).json({ error: 'Internal server error' }) - } + } + + /** + * @openapi + * /auth/resend-verification: + * post: + * operationId: authResendVerification + * summary: Resend verification email + * description: > + * Always returns 200 with a neutral message to avoid leaking user existence. + * Rate-limited by IP (1 req/min) and per account (5 req/24 h). + * tags: [Auth] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ResendVerificationInput' + * responses: + * 200: + * description: If the account exists, a verification email will be sent. + * 429: + * description: Too many requests — IP or account rate limit reached. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async resendVerification(req: Request, res: Response): Promise { + try { + const validation = resendVerificationSchema.safeParse(req.body) + if (!validation.success) { + // Neutral response regardless + res.status(200).json({ + message: 'If the account exists, a verification email has been sent.', + }) + + return + } + + const { email } = validation.data + + // IP rate limit check + const ip = + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + (req.headers['x-real-ip'] as string) || + req.socket.remoteAddress || + 'unknown' + + if (this.isRateLimited(`ip:${ip}`, RESEND_COOLDOWN_MS)) { + res + .status(429) + .json({ error: 'Too many requests. Please try again later.' }) + + return + } + + // Look up user (do not reveal existence) + const user = await prisma.user.findUnique({ where: { email } }) + + if (!user) { + res.status(200).json({ + message: 'If the account exists, a verification email has been sent.', + }) + + return + } + + if (user.isVerified) { + res.status(200).json({ + message: 'If the account exists, a verification email has been sent.', + }) + + return + } + + // Account rate limit check + if (this.isAccountLimited(user.id)) { + res + .status(429) + .json({ error: 'Too many requests. Please try again later.' }) + + return + } + + // Cooldown per user + if (this.isRateLimited(`user:${user.id}`, RESEND_COOLDOWN_MS)) { + res + .status(429) + .json({ error: 'Too many requests. Please try again later.' }) + + return + } + + // Revoke existing pending tokens + await prisma.verificationToken.updateMany({ + where: { userId: user.id, status: 'PENDING' }, + data: { status: 'REVOKED' }, + }) + + // Create new token + const { rawToken, tokenHash } = generateVerificationToken() + const expiresAt = new Date(Date.now() + VERIFICATION_TOKEN_EXPIRY_MS) + + await prisma.verificationToken.create({ + data: { + userId: user.id, + tokenHash, + expiresAt, + }, + }) + + // Queue email + const { subject, body } = buildVerificationEmail(email, rawToken) + emailService + .queueEmail(user.id, email, subject, body) + .catch((err) => + logger.error('[Auth] Failed to queue verification email:', err), + ) + + this.recordAccountRequest(user.id) + + res.status(200).json({ + message: 'If the account exists, a verification email has been sent.', + }) + } catch (error) { + console.error('Resend verification error:', error) + res.status(500).json({ error: 'Internal server error' }) } - - /** - * @openapi - * /auth/login: - * post: - * operationId: authLogin - * summary: Log in and receive a JWT - * description: Rate-limited to 10 requests per 15 minutes per IP. - * tags: [Auth] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/LoginInput' - * responses: - * 200: - * description: Login successful - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/AuthResponse' - * 400: - * description: Validation failed - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 401: - * description: Invalid credentials - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async login(req: Request, res: Response): Promise { - try { - const validation = loginSchema.safeParse(req.body) - if (!validation.success) { - res.status(400).json({ - error: 'Validation failed', - details: validation.error.format() - }) - - return - } - - const { email, password } = validation.data - - const user = await prisma.user.findUnique({ - where: { email } - }) - - if (!user) { - res.status(401).json({ error: 'Invalid credentials' }) - - return - } - - const isMatch = await comparePassword(password, user.password) - if (!isMatch) { - res.status(401).json({ error: 'Invalid credentials' }) - - return - } - - const statusError = await this.getAccountStatusError(user) - if (statusError) { - res.status(statusError.statusCode).json(statusError.body) - - return - } - - // Transparently upgrade the stored hash if it was made under a - // weaker cost factor than the current configuration. - const passwordUpdate = needsRehash(user.password) - ? { password: await hashPassword(password) } - : {} - - await prisma.user.update({ - where: { id: user.id }, - data: { ...passwordUpdate, lastLoginAt: new Date() } - }) - - const session = await refreshTokenService.issueSession({ - userId: user.id, - role: user.role, - ...this.clientContext(req), - }) - - res.status(200).json({ - message: 'Login successful', - ...this.tokenPayload(session), - user: { - id: user.id, - email: user.email, - username: user.username, - role: user.role - } - }) - } catch (error) { - console.error('Login error:', error) - res.status(500).json({ error: 'Internal server error during login' }) - } + } + + /** + * @openapi + * /auth/login: + * post: + * operationId: authLogin + * summary: Log in and receive a JWT + * description: Rate-limited to 10 requests per 15 minutes per IP. + * tags: [Auth] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LoginInput' + * responses: + * 200: + * description: Login successful + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthResponse' + * 400: + * description: Validation failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Invalid credentials + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async login(req: Request, res: Response): Promise { + try { + const validation = loginSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const { email, password } = validation.data + + const user = await prisma.user.findUnique({ + where: { email }, + }) + + if (!user) { + res.status(401).json({ error: 'Invalid credentials' }) + + return + } + + const isMatch = await comparePassword(password, user.password) + if (!isMatch) { + res.status(401).json({ error: 'Invalid credentials' }) + + return + } + + const statusError = await this.getAccountStatusError(user) + if (statusError) { + res.status(statusError.statusCode).json(statusError.body) + + return + } + + // Transparently upgrade the stored hash if it was made under a + // weaker cost factor than the current configuration. + const passwordUpdate = needsRehash(user.password) + ? { password: await hashPassword(password) } + : {} + + await prisma.user.update({ + where: { id: user.id }, + data: { ...passwordUpdate, lastLoginAt: new Date() }, + }) + + const session = await refreshTokenService.issueSession({ + userId: user.id, + role: user.role, + ...this.clientContext(req), + }) + + res.status(200).json({ + message: 'Login successful', + ...this.tokenPayload(session), + user: { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + }, + }) + } catch (error) { + console.error('Login error:', error) + res.status(500).json({ error: 'Internal server error during login' }) } - - /** - * @openapi - * /auth/refresh: - * post: - * operationId: authRefresh - * summary: Rotate a refresh token for a new access/refresh pair - * description: > - * Consumes the presented refresh token and issues a new short-lived - * access token plus a new opaque refresh token in the same family. - * Presenting a refresh token that has already been rotated (replay) - * revokes the entire family and returns 401 REFRESH_REUSE_DETECTED. - * - * The refresh token may be sent in the JSON body (`refreshToken`) - * or via an httpOnly `refresh_token` cookie. - * tags: [Auth] - * security: [] - * requestBody: - * required: false - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RefreshTokenInput' - * responses: - * 200: - * description: Token rotated successfully. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/TokenResponse' - * 400: - * description: Validation failed or refresh token missing. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 401: - * description: Invalid, expired, revoked, or replayed refresh token. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async refresh(req: Request, res: Response): Promise { - try { - const validation = refreshTokenSchema.safeParse(req.body ?? {}) - if (!validation.success) { - res.status(400).json({ - error: 'Validation failed', - details: validation.error.format(), - }) - - return - } - - const refreshToken = this.readRefreshToken(req) - if (!refreshToken) { - res.status(400).json({ error: 'refreshToken is required' }) - - return - } - - const result = await refreshTokenService.rotate(refreshToken, this.clientContext(req)) - - switch (result.kind) { - case 'ok': - res.status(200).json({ - message: 'Token refreshed successfully', - ...this.tokenPayload(result), - }) - - return - - case 'reuse': - res.status(401).json({ - error: 'Refresh token reuse detected; the session has been revoked', - code: 'REFRESH_REUSE_DETECTED', - }) - - return - - case 'expired': - res.status(401).json({ error: 'Refresh token expired', code: 'REFRESH_EXPIRED' }) - - return - - case 'revoked': - res.status(401).json({ error: 'Refresh token revoked', code: 'REFRESH_REVOKED' }) - - return - - case 'invalid': - res.status(401).json({ error: 'Invalid refresh token', code: 'REFRESH_INVALID' }) - - return - } - } catch (error) { - logger.error('[Auth] refresh error:', error) - res.status(500).json({ error: 'Internal server error during refresh' }) - } + } + + /** + * @openapi + * /auth/refresh: + * post: + * operationId: authRefresh + * summary: Rotate a refresh token for a new access/refresh pair + * description: > + * Consumes the presented refresh token and issues a new short-lived + * access token plus a new opaque refresh token in the same family. + * Presenting a refresh token that has already been rotated (replay) + * revokes the entire family and returns 401 REFRESH_REUSE_DETECTED. + * + * The refresh token may be sent in the JSON body (`refreshToken`) + * or via an httpOnly `refresh_token` cookie. + * tags: [Auth] + * security: [] + * requestBody: + * required: false + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RefreshTokenInput' + * responses: + * 200: + * description: Token rotated successfully. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TokenResponse' + * 400: + * description: Validation failed or refresh token missing. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Invalid, expired, revoked, or replayed refresh token. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async refresh(req: Request, res: Response): Promise { + try { + const validation = refreshTokenSchema.safeParse(req.body ?? {}) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const refreshToken = this.readRefreshToken(req) + if (!refreshToken) { + res.status(400).json({ error: 'refreshToken is required' }) + + return + } + + const result = await refreshTokenService.rotate( + refreshToken, + this.clientContext(req), + ) + + switch (result.kind) { + case 'ok': + res.status(200).json({ + message: 'Token refreshed successfully', + ...this.tokenPayload(result), + }) + + return + + case 'reuse': + res.status(401).json({ + error: 'Refresh token reuse detected; the session has been revoked', + code: 'REFRESH_REUSE_DETECTED', + }) + + return + + case 'expired': + res + .status(401) + .json({ error: 'Refresh token expired', code: 'REFRESH_EXPIRED' }) + + return + + case 'revoked': + res + .status(401) + .json({ error: 'Refresh token revoked', code: 'REFRESH_REVOKED' }) + + return + + case 'invalid': + res + .status(401) + .json({ error: 'Invalid refresh token', code: 'REFRESH_INVALID' }) + + return + } + } catch (error) { + logger.error('[Auth] refresh error:', error) + res.status(500).json({ error: 'Internal server error during refresh' }) } - - /** - * @openapi - * /auth/logout: - * post: - * operationId: authLogout - * summary: Log out the current session - * description: > - * Revokes the session identified by the presented refresh token, so - * that token (and any token in its family) can no longer be used. - * The refresh token may be sent in the JSON body (`refreshToken`) or - * via an httpOnly `refresh_token` cookie. Idempotent. - * tags: [Auth] - * security: [] - * requestBody: - * required: false - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RefreshTokenInput' - * responses: - * 200: - * description: Session revoked (or already revoked). - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/LogoutResponse' - * 400: - * description: Validation failed or refresh token missing. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async logout(req: Request, res: Response): Promise { - try { - const validation = refreshTokenSchema.safeParse(req.body ?? {}) - if (!validation.success) { - res.status(400).json({ - error: 'Validation failed', - details: validation.error.format(), - }) - - return - } - - const refreshToken = this.readRefreshToken(req) - if (!refreshToken) { - res.status(400).json({ error: 'refreshToken is required' }) - - return - } - - const result = await refreshTokenService.revokeByRefreshToken(refreshToken, this.clientContext(req)) - - res.status(200).json({ - message: 'Logged out successfully', - revokedCount: result.revokedCount, - }) - } catch (error) { - logger.error('[Auth] logout error:', error) - res.status(500).json({ error: 'Internal server error during logout' }) - } + } + + /** + * @openapi + * /auth/logout: + * post: + * operationId: authLogout + * summary: Log out the current session + * description: > + * Revokes the session identified by the presented refresh token, so + * that token (and any token in its family) can no longer be used. + * The refresh token may be sent in the JSON body (`refreshToken`) or + * via an httpOnly `refresh_token` cookie. Idempotent. + * tags: [Auth] + * security: [] + * requestBody: + * required: false + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RefreshTokenInput' + * responses: + * 200: + * description: Session revoked (or already revoked). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LogoutResponse' + * 400: + * description: Validation failed or refresh token missing. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async logout(req: Request, res: Response): Promise { + try { + const validation = refreshTokenSchema.safeParse(req.body ?? {}) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const refreshToken = this.readRefreshToken(req) + if (!refreshToken) { + res.status(400).json({ error: 'refreshToken is required' }) + + return + } + + const result = await refreshTokenService.revokeByRefreshToken( + refreshToken, + this.clientContext(req), + ) + + res.status(200).json({ + message: 'Logged out successfully', + revokedCount: result.revokedCount, + }) + } catch (error) { + logger.error('[Auth] logout error:', error) + res.status(500).json({ error: 'Internal server error during logout' }) } - - /** - * @openapi - * /auth/logout/all: - * post: - * operationId: authLogoutAll - * summary: Log out all sessions for the user - * description: > - * Revokes every session (and their refresh-token families) for the - * user identified by the presented refresh token. The refresh token - * may be sent in the JSON body (`refreshToken`) or via an httpOnly - * `refresh_token` cookie. Idempotent. - * tags: [Auth] - * security: [] - * requestBody: - * required: false - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RefreshTokenInput' - * responses: - * 200: - * description: All sessions revoked. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/LogoutResponse' - * 400: - * description: Validation failed or refresh token missing. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async logoutAll(req: Request, res: Response): Promise { - try { - const validation = refreshTokenSchema.safeParse(req.body ?? {}) - if (!validation.success) { - res.status(400).json({ - error: 'Validation failed', - details: validation.error.format(), - }) - - return - } - - const refreshToken = this.readRefreshToken(req) - if (!refreshToken) { - res.status(400).json({ error: 'refreshToken is required' }) - - return - } - - const result = await refreshTokenService.revokeAllByRefreshToken(refreshToken, this.clientContext(req)) - - res.status(200).json({ - message: 'All sessions logged out', - revokedCount: result.revokedCount, - }) - } catch (error) { - logger.error('[Auth] logoutAll error:', error) - res.status(500).json({ error: 'Internal server error during logout' }) - } + } + + /** + * @openapi + * /auth/logout/all: + * post: + * operationId: authLogoutAll + * summary: Log out all sessions for the user + * description: > + * Revokes every session (and their refresh-token families) for the + * user identified by the presented refresh token. The refresh token + * may be sent in the JSON body (`refreshToken`) or via an httpOnly + * `refresh_token` cookie. Idempotent. + * tags: [Auth] + * security: [] + * requestBody: + * required: false + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RefreshTokenInput' + * responses: + * 200: + * description: All sessions revoked. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LogoutResponse' + * 400: + * description: Validation failed or refresh token missing. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async logoutAll(req: Request, res: Response): Promise { + try { + const validation = refreshTokenSchema.safeParse(req.body ?? {}) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const refreshToken = this.readRefreshToken(req) + if (!refreshToken) { + res.status(400).json({ error: 'refreshToken is required' }) + + return + } + + const result = await refreshTokenService.revokeAllByRefreshToken( + refreshToken, + this.clientContext(req), + ) + + res.status(200).json({ + message: 'All sessions logged out', + revokedCount: result.revokedCount, + }) + } catch (error) { + logger.error('[Auth] logoutAll error:', error) + res.status(500).json({ error: 'Internal server error during logout' }) } - - /** - * @openapi - * /auth/forgot-password: - * post: - * operationId: authForgotPassword - * summary: Request a password-reset email - * description: > - * Always returns 200 to avoid leaking user existence. - * Token expires in 30 minutes. Rate-limited by IP and per account. - * tags: [Auth] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ForgotPasswordInput' - * responses: - * 200: - * description: If the account exists, a password reset email will be sent. - * 429: - * description: Too many requests. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async forgotPassword(req: Request, res: Response): Promise { - try { - const validation = forgotPasswordSchema.safeParse(req.body) - if (!validation.success) { - res.status(200).json({ message: 'If the account exists, a password reset email has been sent.' }) - - return - } - - const { email } = validation.data - - const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() - || (req.headers['x-real-ip'] as string) - || req.socket.remoteAddress - || 'unknown' - - if (this.isRateLimited(`reset:ip:${ip}`, RESET_PASSWORD_COOLDOWN_MS)) { - res.status(429).json({ error: 'Too many requests. Please try again later.' }) - - return - } - - const user = await prisma.user.findUnique({ where: { email } }) - if (!user) { - res.status(200).json({ message: 'If the account exists, a password reset email has been sent.' }) - - return - } - - if (this.isResetPasswordAccountLimited(user.id)) { - res.status(429).json({ error: 'Too many requests. Please try again later.' }) - - return - } - - if (this.isRateLimited(`reset:user:${user.id}`, RESET_PASSWORD_COOLDOWN_MS)) { - res.status(429).json({ error: 'Too many requests. Please try again later.' }) - - return - } - - await prisma.verificationToken.updateMany({ - where: { userId: user.id, status: 'PENDING', type: 'PASSWORD_RESET' }, - data: { status: 'REVOKED' }, - }) - - const { rawToken, tokenHash } = generateVerificationToken() - const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_EXPIRY_MS) - - await prisma.verificationToken.create({ - data: { - userId: user.id, - tokenHash, - type: 'PASSWORD_RESET', - expiresAt, - }, - }) - - const { subject, body } = buildPasswordResetEmail(email, rawToken) - emailService.queueEmail(user.id, email, subject, body, 'PASSWORD_RESET').catch(err => - logger.error('[Auth] Failed to queue password reset email:', err) - ) - - this.recordResetPasswordAccountRequest(user.id) - - res.status(200).json({ message: 'If the account exists, a password reset email has been sent.' }) - } catch (error) { - console.error('Forgot password error:', error) - res.status(500).json({ error: 'Internal server error' }) - } + } + + /** + * @openapi + * /auth/forgot-password: + * post: + * operationId: authForgotPassword + * summary: Request a password-reset email + * description: > + * Always returns 200 to avoid leaking user existence. + * Token expires in 30 minutes. Rate-limited by IP and per account. + * tags: [Auth] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ForgotPasswordInput' + * responses: + * 200: + * description: If the account exists, a password reset email will be sent. + * 429: + * description: Too many requests. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async forgotPassword(req: Request, res: Response): Promise { + try { + const validation = forgotPasswordSchema.safeParse(req.body) + if (!validation.success) { + res.status(200).json({ + message: + 'If the account exists, a password reset email has been sent.', + }) + + return + } + + const { email } = validation.data + + const ip = + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + (req.headers['x-real-ip'] as string) || + req.socket.remoteAddress || + 'unknown' + + if (this.isRateLimited(`reset:ip:${ip}`, RESET_PASSWORD_COOLDOWN_MS)) { + res + .status(429) + .json({ error: 'Too many requests. Please try again later.' }) + + return + } + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user) { + res.status(200).json({ + message: + 'If the account exists, a password reset email has been sent.', + }) + + return + } + + if (this.isResetPasswordAccountLimited(user.id)) { + res + .status(429) + .json({ error: 'Too many requests. Please try again later.' }) + + return + } + + if ( + this.isRateLimited(`reset:user:${user.id}`, RESET_PASSWORD_COOLDOWN_MS) + ) { + res + .status(429) + .json({ error: 'Too many requests. Please try again later.' }) + + return + } + + await prisma.verificationToken.updateMany({ + where: { userId: user.id, status: 'PENDING', type: 'PASSWORD_RESET' }, + data: { status: 'REVOKED' }, + }) + + const { rawToken, tokenHash } = generateVerificationToken() + const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_EXPIRY_MS) + + await prisma.verificationToken.create({ + data: { + userId: user.id, + tokenHash, + type: 'PASSWORD_RESET', + expiresAt, + }, + }) + + const { subject, body } = buildPasswordResetEmail(email, rawToken) + emailService + .queueEmail(user.id, email, subject, body, 'PASSWORD_RESET') + .catch((err) => + logger.error('[Auth] Failed to queue password reset email:', err), + ) + + this.recordResetPasswordAccountRequest(user.id) + + res.status(200).json({ + message: 'If the account exists, a password reset email has been sent.', + }) + } catch (error) { + console.error('Forgot password error:', error) + res.status(500).json({ error: 'Internal server error' }) } - - /** - * @openapi - * /auth/reset-password: - * post: - * operationId: authResetPassword - * summary: Reset password using a token from the reset email - * description: > - * On success, all existing sessions are revoked and all pending - * verification tokens are cancelled. - * tags: [Auth] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ResetPasswordInput' - * responses: - * 200: - * description: Password reset successful. - * 400: - * description: Invalid, expired, or already-used token; or weak password. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async resetPassword(req: Request, res: Response): Promise { - try { - const validation = resetPasswordSchema.safeParse(req.body) - if (!validation.success) { - res.status(400).json({ error: 'Invalid token or password' }) - - return - } - - const { token, newPassword } = validation.data - - if (!/^[0-9a-f]{64}$/i.test(token)) { - res.status(400).json({ error: 'Invalid token' }) - - return - } - - const tokenHash = crypto.createHash('sha256').update(token).digest('hex') - - const resetToken = await prisma.verificationToken.findFirst({ - where: { tokenHash, type: 'PASSWORD_RESET' }, - include: { user: true }, - }) - - if (!resetToken) { - res.status(400).json({ error: 'Invalid token' }) - - return - } - - if (resetToken.status === 'USED' || resetToken.status === 'REVOKED') { - res.status(400).json({ error: 'Invalid token' }) - - return - } - - if (new Date() > resetToken.expiresAt) { - await prisma.verificationToken.update({ - where: { id: resetToken.id }, - data: { status: 'REVOKED' }, - }) - res.status(400).json({ error: 'Token expired' }) - - return - } - - const hashedPassword = await hashPassword(newPassword) - - const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() - || (req.headers['x-real-ip'] as string) - || req.socket.remoteAddress - || 'unknown' - const userAgent = req.headers['user-agent'] || 'unknown' - - await prisma.$transaction([ - prisma.verificationToken.update({ - where: { id: resetToken.id }, - data: { status: 'USED' }, - }), - prisma.user.update({ - where: { id: resetToken.userId }, - data: { password: hashedPassword }, - }), - prisma.verificationToken.updateMany({ - where: { userId: resetToken.userId, status: 'PENDING' }, - data: { status: 'REVOKED' }, - }), - prisma.session.updateMany({ - where: { userId: resetToken.userId, isRevoked: false }, - data: { isRevoked: true, revokedAt: new Date() }, - }), - prisma.auditLog.create({ - data: { - userId: resetToken.userId, - action: 'PASSWORD_RESET', - ipAddress: ip, - userAgent: userAgent, - metadata: JSON.stringify({}), - }, - }), - ]) - - logger.info(`[Auth] Password reset for user ${resetToken.userId}`) - res.status(200).json({ message: 'Password reset successful' }) - } catch (error) { - console.error('Reset password error:', error) - res.status(500).json({ error: 'Internal server error' }) - } + } + + /** + * @openapi + * /auth/reset-password: + * post: + * operationId: authResetPassword + * summary: Reset password using a token from the reset email + * description: > + * On success, all existing sessions are revoked and all pending + * verification tokens are cancelled. + * tags: [Auth] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ResetPasswordInput' + * responses: + * 200: + * description: Password reset successful. + * 400: + * description: Invalid, expired, or already-used token; or weak password. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async resetPassword(req: Request, res: Response): Promise { + try { + const validation = resetPasswordSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ error: 'Invalid token or password' }) + + return + } + + const { token, newPassword } = validation.data + + if (!/^[0-9a-f]{64}$/i.test(token)) { + res.status(400).json({ error: 'Invalid token' }) + + return + } + + const tokenHash = crypto.createHash('sha256').update(token).digest('hex') + + const resetToken = await prisma.verificationToken.findFirst({ + where: { tokenHash, type: 'PASSWORD_RESET' }, + include: { user: true }, + }) + + if (!resetToken) { + res.status(400).json({ error: 'Invalid token' }) + + return + } + + if (resetToken.status === 'USED' || resetToken.status === 'REVOKED') { + res.status(400).json({ error: 'Invalid token' }) + + return + } + + if (new Date() > resetToken.expiresAt) { + await prisma.verificationToken.update({ + where: { id: resetToken.id }, + data: { status: 'REVOKED' }, + }) + res.status(400).json({ error: 'Token expired' }) + + return + } + + const hashedPassword = await hashPassword(newPassword) + + const ip = + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + (req.headers['x-real-ip'] as string) || + req.socket.remoteAddress || + 'unknown' + const userAgent = req.headers['user-agent'] || 'unknown' + + await prisma.$transaction([ + prisma.verificationToken.update({ + where: { id: resetToken.id }, + data: { status: 'USED' }, + }), + prisma.user.update({ + where: { id: resetToken.userId }, + data: { password: hashedPassword }, + }), + prisma.verificationToken.updateMany({ + where: { userId: resetToken.userId, status: 'PENDING' }, + data: { status: 'REVOKED' }, + }), + prisma.session.updateMany({ + where: { userId: resetToken.userId, isRevoked: false }, + data: { isRevoked: true, revokedAt: new Date() }, + }), + prisma.auditLog.create({ + data: { + userId: resetToken.userId, + action: 'PASSWORD_RESET', + ipAddress: ip, + userAgent: userAgent, + metadata: JSON.stringify({}), + }, + }), + ]) + + logger.info(`[Auth] Password reset for user ${resetToken.userId}`) + res.status(200).json({ message: 'Password reset successful' }) + } catch (error) { + console.error('Reset password error:', error) + res.status(500).json({ error: 'Internal server error' }) } - - /** - * @openapi - * /auth/otp/request: - * post: - * operationId: authOtpRequest - * summary: Request a phone OTP code (login or phone verification) - * description: > - * Without a Bearer token, requests a LOGIN code for a phone number - * already verified on some account. Always returns 200 with the - * same message regardless of whether the number is registered, to - * avoid leaking phone existence. - * - * With a Bearer token, requests a PHONE_VERIFICATION code to attach - * that phone number to the caller's own account. - * - * Rate-limited by IP, phone (1/min, 5/hour), and, if a `deviceId` - * is supplied, by device (10/hour). - * tags: [Auth] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/OtpRequestInput' - * responses: - * 200: - * description: A code has been sent, or the request was silently ignored (LOGIN, unregistered/unverified phone). - * 400: - * description: Validation failed or phone number is not in E.164 format. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 409: - * description: Phone number is already verified on a different account. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 429: - * description: Too many requests — IP, phone, or device rate limit reached. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async requestOtp(req: Request, res: Response): Promise { - try { - const validation = otpRequestSchema.safeParse(req.body) - if (!validation.success) { - res.status(400).json({ - error: 'Validation failed', - details: validation.error.format() - }) - - return - } - - const { phone, deviceId } = validation.data - const normalizedPhone = normalizePhone(phone) - - if (!normalizedPhone) { - res.status(400).json({ error: 'Phone number must be in E.164 format, e.g. +2348012345678' }) - - return - } - - if (deviceId && this.isDeviceOtpLimited(deviceId)) { - res.status(429).json({ error: 'Too many requests. Please try again later.' }) - - return - } - - const authenticatedUserId = req.user?.id - const purpose: OtpPurpose = authenticatedUserId ? 'PHONE_VERIFICATION' : 'LOGIN' - - if (this.isRateLimited(`otp:phone:${purpose}:${normalizedPhone}`, OTP_PHONE_COOLDOWN_MS) - || this.isPhoneOtpLimited(normalizedPhone, purpose)) { - res.status(429).json({ error: 'Too many requests. Please try again later.' }) - - return - } - - let targetUserId: string - - if (authenticatedUserId) { - const conflict = await prisma.user.findFirst({ - where: { - phone: normalizedPhone, - phoneVerifiedAt: { not: null }, - NOT: { id: authenticatedUserId }, - }, - }) - - if (conflict) { - res.status(409).json({ error: 'Phone number is already verified on another account' }) - - return - } - - targetUserId = authenticatedUserId - } else { - const user = await prisma.user.findUnique({ where: { phone: normalizedPhone } }) - - if (!user || !user.phoneVerifiedAt || user.status !== 'ACTIVE') { - res.status(200).json({ message: 'If this phone number is registered, a verification code has been sent.' }) - - return - } - - targetUserId = user.id - } - - this.recordPhoneOtpRequest(normalizedPhone, purpose) - if (deviceId) { - this.recordDeviceOtpRequest(deviceId) - } - - const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() - || (req.headers['x-real-ip'] as string) - || req.socket.remoteAddress - || 'unknown' - - await otpService.requestChallenge(normalizedPhone, purpose, targetUserId, { ip, deviceId }) - - res.status(200).json( - purpose === 'PHONE_VERIFICATION' - ? { message: 'Verification code sent.' } - : { message: 'If this phone number is registered, a verification code has been sent.' } - ) - } catch (error) { - console.error('OTP request error:', error) - res.status(500).json({ error: 'Internal server error' }) + } + + /** + * @openapi + * /auth/otp/request: + * post: + * operationId: authOtpRequest + * summary: Request a phone OTP code (login or phone verification) + * description: > + * Without a Bearer token, requests a LOGIN code for a phone number + * already verified on some account. Always returns 200 with the + * same message regardless of whether the number is registered, to + * avoid leaking phone existence. + * + * With a Bearer token, requests a PHONE_VERIFICATION code to attach + * that phone number to the caller's own account. + * + * Rate-limited by IP, phone (1/min, 5/hour), and, if a `deviceId` + * is supplied, by device (10/hour). + * tags: [Auth] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OtpRequestInput' + * responses: + * 200: + * description: A code has been sent, or the request was silently ignored (LOGIN, unregistered/unverified phone). + * 400: + * description: Validation failed or phone number is not in E.164 format. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 409: + * description: Phone number is already verified on a different account. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 429: + * description: Too many requests — IP, phone, or device rate limit reached. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async requestOtp(req: Request, res: Response): Promise { + try { + const validation = otpRequestSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const { phone, deviceId } = validation.data + const normalizedPhone = normalizePhone(phone) + + if (!normalizedPhone) { + res.status(400).json({ + error: 'Phone number must be in E.164 format, e.g. +2348012345678', + }) + + return + } + + if (deviceId && this.isDeviceOtpLimited(deviceId)) { + res + .status(429) + .json({ error: 'Too many requests. Please try again later.' }) + + return + } + + const authenticatedUserId = req.user?.id + const purpose: OtpPurpose = authenticatedUserId + ? 'PHONE_VERIFICATION' + : 'LOGIN' + + if ( + this.isRateLimited( + `otp:phone:${purpose}:${normalizedPhone}`, + OTP_PHONE_COOLDOWN_MS, + ) || + this.isPhoneOtpLimited(normalizedPhone, purpose) + ) { + res + .status(429) + .json({ error: 'Too many requests. Please try again later.' }) + + return + } + + let targetUserId: string + + if (authenticatedUserId) { + const conflict = await prisma.user.findFirst({ + where: { + phone: normalizedPhone, + phoneVerifiedAt: { not: null }, + NOT: { id: authenticatedUserId }, + }, + }) + + if (conflict) { + res.status(409).json({ + error: 'Phone number is already verified on another account', + }) + + return } - } - /** - * @openapi - * /auth/otp/verify: - * post: - * operationId: authOtpVerify - * summary: Verify a phone OTP code (completes login or phone verification) - * description: > - * Without a Bearer token, verifies a LOGIN code and returns a JWT, - * identical in shape to POST /auth/login. With a Bearer token, - * verifies a PHONE_VERIFICATION code and marks the phone verified - * on the caller's account. - * - * Codes are single-use, expire after 5 minutes, and the challenge - * locks after 5 wrong attempts (request a new code to retry). - * tags: [Auth] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/OtpVerifyInput' - * responses: - * 200: - * description: Verified — login response (LOGIN) or confirmation message (PHONE_VERIFICATION). - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/AuthResponse' - * 400: - * description: Validation failed, malformed phone, or invalid/expired code. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 401: - * description: Invalid credentials (tombstoned account; LOGIN purpose only). - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 403: - * description: Account is deactivated or pending deletion (LOGIN purpose only). - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/AccountStatusError' - * 429: - * description: Too many wrong attempts — the challenge is locked; request a new code. - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - */ - async verifyOtp(req: Request, res: Response): Promise { - try { - const validation = otpVerifySchema.safeParse(req.body) - if (!validation.success) { - res.status(400).json({ - error: 'Validation failed', - details: validation.error.format() - }) - - return - } - - const { phone, code } = validation.data - const normalizedPhone = normalizePhone(phone) - - if (!normalizedPhone) { - res.status(400).json({ error: 'Invalid or expired code' }) - - return - } - - const authenticatedUserId = req.user?.id - const purpose: OtpPurpose = authenticatedUserId ? 'PHONE_VERIFICATION' : 'LOGIN' - - const result = await otpService.verifyChallenge(normalizedPhone, code, purpose, authenticatedUserId) - - if (!result.ok) { - if (result.reason === 'locked') { - res.status(429).json({ error: 'Too many attempts. Please request a new code.' }) - - return - } - - res.status(400).json({ error: 'Invalid or expired code' }) - - return - } - - if (purpose === 'PHONE_VERIFICATION') { - await prisma.user.update({ - where: { id: result.userId }, - data: { phone: normalizedPhone, phoneVerifiedAt: new Date() }, - }) - - res.status(200).json({ message: 'Phone number verified successfully' }) - - return - } - - const user = await prisma.user.findUnique({ where: { id: result.userId } }) - - if (!user) { - res.status(401).json({ error: 'Invalid credentials' }) - - return - } - - const statusError = await this.getAccountStatusError(user) - if (statusError) { - res.status(statusError.statusCode).json(statusError.body) - - return - } - - await prisma.user.update({ - where: { id: user.id }, - data: { lastLoginAt: new Date() } - }) - - const session = await refreshTokenService.issueSession({ - userId: user.id, - role: user.role, - ...this.clientContext(req), - }) - - res.status(200).json({ - message: 'Login successful', - ...this.tokenPayload(session), - user: { - id: user.id, - email: user.email, - username: user.username, - role: user.role - } - }) - } catch (error) { - console.error('OTP verify error:', error) - res.status(500).json({ error: 'Internal server error' }) - } - } + targetUserId = authenticatedUserId + } else { + const user = await prisma.user.findUnique({ + where: { phone: normalizedPhone }, + }) - private async getAccountStatusError(user: { id: string; status: string }): Promise<{ statusCode: number; body: Record } | null> { - if (user.status === 'DELETED') { - // Tombstoned account — indistinguishable from unknown credentials - return { statusCode: 401, body: { error: 'Invalid credentials' } } - } + if (!user || !user.phoneVerifiedAt || user.status !== 'ACTIVE') { + res.status(200).json({ + message: + 'If this phone number is registered, a verification code has been sent.', + }) - if (user.status === 'DEACTIVATED') { - return { - statusCode: 403, - body: { error: 'Account is deactivated', code: 'ACCOUNT_DEACTIVATED' }, - } + return } - if (user.status === 'PENDING_DELETION') { - const deletionRequest = await prisma.accountDeletionRequest.findFirst({ - where: { userId: user.id, status: 'pending' }, - select: { scheduledFor: true }, - }) - - return { - statusCode: 403, - body: { - error: 'Account is scheduled for deletion', - code: 'ACCOUNT_PENDING_DELETION', - scheduledFor: deletionRequest?.scheduledFor ?? null, - }, - } + targetUserId = user.id + } + + this.recordPhoneOtpRequest(normalizedPhone, purpose) + if (deviceId) { + this.recordDeviceOtpRequest(deviceId) + } + + const ip = + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + (req.headers['x-real-ip'] as string) || + req.socket.remoteAddress || + 'unknown' + + await otpService.requestChallenge( + normalizedPhone, + purpose, + targetUserId, + { ip, deviceId }, + ) + + res.status(200).json( + purpose === 'PHONE_VERIFICATION' + ? { message: 'Verification code sent.' } + : { + message: + 'If this phone number is registered, a verification code has been sent.', + }, + ) + } catch (error) { + console.error('OTP request error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } + + /** + * @openapi + * /auth/otp/verify: + * post: + * operationId: authOtpVerify + * summary: Verify a phone OTP code (completes login or phone verification) + * description: > + * Without a Bearer token, verifies a LOGIN code and returns a JWT, + * identical in shape to POST /auth/login. With a Bearer token, + * verifies a PHONE_VERIFICATION code and marks the phone verified + * on the caller's account. + * + * Codes are single-use, expire after 5 minutes, and the challenge + * locks after 5 wrong attempts (request a new code to retry). + * tags: [Auth] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OtpVerifyInput' + * responses: + * 200: + * description: Verified — login response (LOGIN) or confirmation message (PHONE_VERIFICATION). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthResponse' + * 400: + * description: Validation failed, malformed phone, or invalid/expired code. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Invalid credentials (tombstoned account; LOGIN purpose only). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Account is deactivated or pending deletion (LOGIN purpose only). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AccountStatusError' + * 429: + * description: Too many wrong attempts — the challenge is locked; request a new code. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + async verifyOtp(req: Request, res: Response): Promise { + try { + const validation = otpVerifySchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format(), + }) + + return + } + + const { phone, code } = validation.data + const normalizedPhone = normalizePhone(phone) + + if (!normalizedPhone) { + res.status(400).json({ error: 'Invalid or expired code' }) + + return + } + + const authenticatedUserId = req.user?.id + const purpose: OtpPurpose = authenticatedUserId + ? 'PHONE_VERIFICATION' + : 'LOGIN' + + const result = await otpService.verifyChallenge( + normalizedPhone, + code, + purpose, + authenticatedUserId, + ) + + if (!result.ok) { + if (result.reason === 'locked') { + res + .status(429) + .json({ error: 'Too many attempts. Please request a new code.' }) + + return } - return null + res.status(400).json({ error: 'Invalid or expired code' }) + + return + } + + if (purpose === 'PHONE_VERIFICATION') { + await prisma.user.update({ + where: { id: result.userId }, + data: { phone: normalizedPhone, phoneVerifiedAt: new Date() }, + }) + + res.status(200).json({ message: 'Phone number verified successfully' }) + + return + } + + const user = await prisma.user.findUnique({ + where: { id: result.userId }, + }) + + if (!user) { + res.status(401).json({ error: 'Invalid credentials' }) + + return + } + + const statusError = await this.getAccountStatusError(user) + if (statusError) { + res.status(statusError.statusCode).json(statusError.body) + + return + } + + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }) + + const session = await refreshTokenService.issueSession({ + userId: user.id, + role: user.role, + ...this.clientContext(req), + }) + + res.status(200).json({ + message: 'Login successful', + ...this.tokenPayload(session), + user: { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + }, + }) + } catch (error) { + console.error('OTP verify error:', error) + res.status(500).json({ error: 'Internal server error' }) } - - private clientContext(req: Request): { userAgent?: string; ipAddress?: string } { - return { - ipAddress: this.getClientIp(req), - userAgent: this.getUserAgent(req), - } + } + + private async getAccountStatusError(user: { + id: string + status: string + }): Promise<{ statusCode: number; body: Record } | null> { + if (user.status === 'DELETED') { + // Tombstoned account — indistinguishable from unknown credentials + return { statusCode: 401, body: { error: 'Invalid credentials' } } } - private getClientIp(req: Request): string { - return (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() - || (req.headers['x-real-ip'] as string) - || req.socket.remoteAddress - || 'unknown' + if (user.status === 'DEACTIVATED') { + return { + statusCode: 403, + body: { error: 'Account is deactivated', code: 'ACCOUNT_DEACTIVATED' }, + } } - private getUserAgent(req: Request): string | undefined { - return req.headers['user-agent'] + if (user.status === 'PENDING_DELETION') { + const deletionRequest = await prisma.accountDeletionRequest.findFirst({ + where: { userId: user.id, status: 'pending' }, + select: { scheduledFor: true }, + }) + + return { + statusCode: 403, + body: { + error: 'Account is scheduled for deletion', + code: 'ACCOUNT_PENDING_DELETION', + scheduledFor: deletionRequest?.scheduledFor ?? null, + }, + } } - /** - * Resolve the refresh token from the JSON body first, then from the - * httpOnly `refresh_token` cookie. Returns null when neither is present. - */ - private readRefreshToken(req: Request): string | null { - const bodyToken = req.body?.refreshToken - if (typeof bodyToken === 'string' && bodyToken.length > 0) { - return bodyToken - } + return null + } - const cookieToken = parseCookieHeader(req.headers.cookie)['refresh_token'] - if (cookieToken && cookieToken.length > 0) { - return cookieToken - } + private clientContext(req: Request): { + userAgent?: string + ipAddress?: string + } { + return { + ipAddress: this.getClientIp(req), + userAgent: this.getUserAgent(req), + } + } + + private getClientIp(req: Request): string { + return ( + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + (req.headers['x-real-ip'] as string) || + req.socket.remoteAddress || + 'unknown' + ) + } + + private getUserAgent(req: Request): string | undefined { + return req.headers['user-agent'] + } + + /** + * Resolve the refresh token from the JSON body first, then from the + * httpOnly `refresh_token` cookie. Returns null when neither is present. + */ + private readRefreshToken(req: Request): string | null { + const bodyToken = req.body?.refreshToken + if (typeof bodyToken === 'string' && bodyToken.length > 0) { + return bodyToken + } - return null + const cookieToken = parseCookieHeader(req.headers.cookie)['refresh_token'] + if (cookieToken && cookieToken.length > 0) { + return cookieToken } - private tokenPayload(result: { accessToken: string; refreshToken: string; expiresIn: number }): { - accessToken: string - refreshToken: string - expiresIn: number - tokenType: string - } { - return { - accessToken: result.accessToken, - refreshToken: result.refreshToken, - expiresIn: result.expiresIn, - tokenType: 'Bearer', - } + return null + } + + private tokenPayload(result: { + accessToken: string + refreshToken: string + expiresIn: number + }): { + accessToken: string + refreshToken: string + expiresIn: number + tokenType: string + } { + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + expiresIn: result.expiresIn, + tokenType: 'Bearer', } + } - private isRateLimited(key: string, windowMs: number): boolean { - const now = Date.now() - const resetTime = resendCooldowns.get(key) - if (resetTime && resetTime > now) { - return true - } - resendCooldowns.set(key, now + windowMs) + private isRateLimited(key: string, windowMs: number): boolean { + const now = Date.now() + const resetTime = resendCooldowns.get(key) + if (resetTime && resetTime > now) { + return true + } + resendCooldowns.set(key, now + windowMs) + + return false + } + + private isAccountLimited(userId: string): boolean { + const now = Date.now() + const record = resendAccountCounts.get(userId) - return false + if (!record || record.resetAt < now) { + return false } - private isAccountLimited(userId: string): boolean { - const now = Date.now() - const record = resendAccountCounts.get(userId) + return record.count >= RESEND_ACCOUNT_LIMIT + } - if (!record || record.resetAt < now) { - return false - } + private recordAccountRequest(userId: string): void { + const now = Date.now() + const record = resendAccountCounts.get(userId) - return record.count >= RESEND_ACCOUNT_LIMIT + if (!record || record.resetAt < now) { + resendAccountCounts.set(userId, { + count: 1, + resetAt: now + RESEND_ACCOUNT_WINDOW_MS, + }) + } else { + record.count++ } + } - private recordAccountRequest(userId: string): void { - const now = Date.now() - const record = resendAccountCounts.get(userId) + private isResetPasswordAccountLimited(userId: string): boolean { + const now = Date.now() + const record = resetPasswordAccountCounts.get(userId) - if (!record || record.resetAt < now) { - resendAccountCounts.set(userId, { count: 1, resetAt: now + RESEND_ACCOUNT_WINDOW_MS }) - } else { - record.count++ - } + if (!record || record.resetAt < now) { + return false } - private isResetPasswordAccountLimited(userId: string): boolean { - const now = Date.now() - const record = resetPasswordAccountCounts.get(userId) + return record.count >= RESET_PASSWORD_ACCOUNT_LIMIT + } - if (!record || record.resetAt < now) { - return false - } + private recordResetPasswordAccountRequest(userId: string): void { + const now = Date.now() + const record = resetPasswordAccountCounts.get(userId) - return record.count >= RESET_PASSWORD_ACCOUNT_LIMIT + if (!record || record.resetAt < now) { + resetPasswordAccountCounts.set(userId, { + count: 1, + resetAt: now + RESET_PASSWORD_ACCOUNT_WINDOW_MS, + }) + } else { + record.count++ } + } - private recordResetPasswordAccountRequest(userId: string): void { - const now = Date.now() - const record = resetPasswordAccountCounts.get(userId) + private isPhoneOtpLimited(phone: string, purpose: string): boolean { + const now = Date.now() + const record = otpPhoneCounts.get(`${purpose}:${phone}`) - if (!record || record.resetAt < now) { - resetPasswordAccountCounts.set(userId, { count: 1, resetAt: now + RESET_PASSWORD_ACCOUNT_WINDOW_MS }) - } else { - record.count++ - } + if (!record || record.resetAt < now) { + return false } - private isPhoneOtpLimited(phone: string, purpose: string): boolean { - const now = Date.now() - const record = otpPhoneCounts.get(`${purpose}:${phone}`) + return record.count >= OTP_PHONE_LIMIT + } - if (!record || record.resetAt < now) { - return false - } + private recordPhoneOtpRequest(phone: string, purpose: string): void { + const now = Date.now() + const key = `${purpose}:${phone}` + const record = otpPhoneCounts.get(key) - return record.count >= OTP_PHONE_LIMIT + if (!record || record.resetAt < now) { + otpPhoneCounts.set(key, { count: 1, resetAt: now + OTP_PHONE_WINDOW_MS }) + } else { + record.count++ } + } - private recordPhoneOtpRequest(phone: string, purpose: string): void { - const now = Date.now() - const key = `${purpose}:${phone}` - const record = otpPhoneCounts.get(key) + private isDeviceOtpLimited(deviceId: string): boolean { + const now = Date.now() + const record = otpDeviceCounts.get(deviceId) - if (!record || record.resetAt < now) { - otpPhoneCounts.set(key, { count: 1, resetAt: now + OTP_PHONE_WINDOW_MS }) - } else { - record.count++ - } + if (!record || record.resetAt < now) { + return false } - private isDeviceOtpLimited(deviceId: string): boolean { - const now = Date.now() - const record = otpDeviceCounts.get(deviceId) + return record.count >= OTP_DEVICE_LIMIT + } - if (!record || record.resetAt < now) { - return false - } - - return record.count >= OTP_DEVICE_LIMIT - } + private recordDeviceOtpRequest(deviceId: string): void { + const now = Date.now() + const record = otpDeviceCounts.get(deviceId) - private recordDeviceOtpRequest(deviceId: string): void { - const now = Date.now() - const record = otpDeviceCounts.get(deviceId) - - if (!record || record.resetAt < now) { - otpDeviceCounts.set(deviceId, { count: 1, resetAt: now + OTP_DEVICE_WINDOW_MS }) - } else { - record.count++ - } + if (!record || record.resetAt < now) { + otpDeviceCounts.set(deviceId, { + count: 1, + resetAt: now + OTP_DEVICE_WINDOW_MS, + }) + } else { + record.count++ } + } } diff --git a/src/controllers/avatar.controller.ts b/src/controllers/avatar.controller.ts index d91ce6c2..da69bb47 100644 --- a/src/controllers/avatar.controller.ts +++ b/src/controllers/avatar.controller.ts @@ -1,6 +1,9 @@ import { Request, Response } from 'express' import { z } from 'zod' -import { AvatarService, AvatarValidationError } from '../services/avatar.service' +import { + AvatarService, + AvatarValidationError, +} from '../services/avatar.service' import { InMemoryStorageProvider } from '../services/storage/in-memory-storage' import { AVATAR_MAX_BYTES } from '../types/avatar.types' diff --git a/src/controllers/consent.controller.ts b/src/controllers/consent.controller.ts index 43608418..674d831d 100644 --- a/src/controllers/consent.controller.ts +++ b/src/controllers/consent.controller.ts @@ -5,20 +5,28 @@ import { CONSENT_PURPOSES, CONSENT_SOURCES } from '../types/consent.types' const grantConsentSchema = z.object({ purpose: z.enum(CONSENT_PURPOSES, { - errorMap: () => ({ message: `Purpose must be one of: ${CONSENT_PURPOSES.join(', ')}` }), + errorMap: () => ({ + message: `Purpose must be one of: ${CONSENT_PURPOSES.join(', ')}`, + }), }), policyVersion: z.string().min(1), source: z.enum(CONSENT_SOURCES, { - errorMap: () => ({ message: `Source must be one of: ${CONSENT_SOURCES.join(', ')}` }), + errorMap: () => ({ + message: `Source must be one of: ${CONSENT_SOURCES.join(', ')}`, + }), }), }) const withdrawConsentSchema = z.object({ purpose: z.enum(CONSENT_PURPOSES, { - errorMap: () => ({ message: `Purpose must be one of: ${CONSENT_PURPOSES.join(', ')}` }), + errorMap: () => ({ + message: `Purpose must be one of: ${CONSENT_PURPOSES.join(', ')}`, + }), }), source: z.enum(CONSENT_SOURCES, { - errorMap: () => ({ message: `Source must be one of: ${CONSENT_SOURCES.join(', ')}` }), + errorMap: () => ({ + message: `Source must be one of: ${CONSENT_SOURCES.join(', ')}`, + }), }), }) @@ -94,7 +102,10 @@ export class ConsentController { return } - const history = await consentService.getHistory(userId, validation.data.purpose) + const history = await consentService.getHistory( + userId, + validation.data.purpose, + ) res.status(200).json({ data: history }) } catch (error) { @@ -140,7 +151,9 @@ export class ConsentController { const record = await consentService.grant(userId, validation.data) - res.status(200).json({ message: 'Consent granted successfully', data: record }) + res + .status(200) + .json({ message: 'Consent granted successfully', data: record }) } catch (error) { console.error('Grant consent error:', error) res.status(500).json({ error: 'Internal server error' }) @@ -198,7 +211,10 @@ export class ConsentController { return } - res.status(200).json({ message: 'Consent withdrawn successfully', data: result.record }) + res.status(200).json({ + message: 'Consent withdrawn successfully', + data: result.record, + }) } catch (error) { console.error('Withdraw consent error:', error) res.status(500).json({ error: 'Internal server error' }) diff --git a/src/controllers/credential.controller.ts b/src/controllers/credential.controller.ts index 2c3f914e..8894c5c8 100644 --- a/src/controllers/credential.controller.ts +++ b/src/controllers/credential.controller.ts @@ -1,6 +1,10 @@ import { Request, Response } from 'express' import { asyncHandler } from '../middleware/error.middleware' -import { BadRequestError, NotFoundError, UnauthorizedError } from '../utils/errors' +import { + BadRequestError, + NotFoundError, + UnauthorizedError, +} from '../utils/errors' import { prisma } from '../config/database' export class CredentialController { diff --git a/src/controllers/employer.controller.ts b/src/controllers/employer.controller.ts index e2c12ff9..625fd310 100644 --- a/src/controllers/employer.controller.ts +++ b/src/controllers/employer.controller.ts @@ -16,9 +16,15 @@ const searchQuerySchema = z.object({ .filter(Boolean) : [], ), - location: z.string().optional().transform((value) => value?.trim().toLowerCase()), + location: z + .string() + .optional() + .transform((value) => value?.trim().toLowerCase()), credentials: z.enum(['any', 'verified', 'none']).optional().default('any'), - search: z.string().optional().transform((value) => value?.trim()), + search: z + .string() + .optional() + .transform((value) => value?.trim()), }) const contactBodySchema = z.object({ @@ -132,8 +138,10 @@ function profileFromCandidate(candidate: CandidateRecord) { candidate.completions.length > 0 ? Number( ( - candidate.completions.reduce((sum, completion) => sum + completion.score, 0) / - candidate.completions.length + candidate.completions.reduce( + (sum, completion) => sum + completion.score, + 0, + ) / candidate.completions.length ).toFixed(2), ) : 0, @@ -233,7 +241,8 @@ export const searchTalent = async (req: Request, res: Response) => { const { page, limit, skills, location, credentials, search } = parsed.data const employerPlan = getEmployerPlan(req) - const maxLimit = PLAN_MAX_SEARCH_LIMIT[employerPlan] ?? PLAN_MAX_SEARCH_LIMIT.starter + const maxLimit = + PLAN_MAX_SEARCH_LIMIT[employerPlan] ?? PLAN_MAX_SEARCH_LIMIT.starter if (limit > maxLimit) { return res.status(400).json({ message: `Current plan allows up to ${maxLimit} results per page`, @@ -296,7 +305,10 @@ export const searchTalent = async (req: Request, res: Response) => { }) .filter((candidate) => { if (credentials === 'any') return true - if (credentials === 'verified') return candidate.credentials.some((credential) => Boolean(credential.onChainId)) + if (credentials === 'verified') + return candidate.credentials.some((credential) => + Boolean(credential.onChainId), + ) return candidate.credentials.length === 0 }) @@ -316,7 +328,9 @@ export const searchTalent = async (req: Request, res: Response) => { skills: profile.skills, completions: profile.completions, averageScore: profile.averageScore, - verifiedCredentialCount: profile.verifiedCredentials.filter((credential) => credential.verified).length, + verifiedCredentialCount: profile.verifiedCredentials.filter( + (credential) => credential.verified, + ).length, } }) @@ -541,14 +555,16 @@ export const contactCandidate = async (req: Request, res: Response) => { where: { id: 'system-employer-outreach-log' }, update: { url: 'https://internal.learnault/employer-outreach', - description: 'System log endpoint for employer candidate outreach attempts', + description: + 'System log endpoint for employer candidate outreach attempts', isActive: true, events: 'employer.contact_attempt', }, create: { id: 'system-employer-outreach-log', url: 'https://internal.learnault/employer-outreach', - description: 'System log endpoint for employer candidate outreach attempts', + description: + 'System log endpoint for employer candidate outreach attempts', secret: null, isActive: true, events: 'employer.contact_attempt', diff --git a/src/controllers/module.controller.ts b/src/controllers/module.controller.ts index 9692a165..ea7e55d5 100644 --- a/src/controllers/module.controller.ts +++ b/src/controllers/module.controller.ts @@ -10,19 +10,26 @@ const notificationService = new NotificationService() // Query parameter schemas for validation const listModulesSchema = z.object({ - page: z.string().optional().transform(val => val ? parseInt(val) : 1), - limit: z.string().optional().transform(val => val ? parseInt(val) : 10), + page: z + .string() + .optional() + .transform((val) => (val ? parseInt(val) : 1)), + limit: z + .string() + .optional() + .transform((val) => (val ? parseInt(val) : 10)), category: z.string().optional(), difficulty: z.string().optional(), search: z.string().optional(), }) - const completeModuleSchema = z.object({ - quizAnswers: z.array(z.object({ - questionId: z.string(), - answer: z.string(), - })), + quizAnswers: z.array( + z.object({ + questionId: z.string(), + answer: z.string(), + }), + ), }) /** @@ -77,7 +84,7 @@ export const listModules = async (req: Request, res: Response) => { if (!queryValidation.success) { return res.status(400).json({ message: 'Invalid query parameters', - errors: queryValidation.error.errors + errors: queryValidation.error.errors, }) } @@ -98,7 +105,7 @@ export const listModules = async (req: Request, res: Response) => { if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, - { description: { contains: search, mode: 'insensitive' } } + { description: { contains: search, mode: 'insensitive' } }, ] } @@ -114,10 +121,10 @@ export const listModules = async (req: Request, res: Response) => { include: { _count: { select: { - completions: true - } - } - } + completions: true, + }, + }, + }, }) // If user is authenticated, include their progress @@ -125,14 +132,14 @@ export const listModules = async (req: Request, res: Response) => { if (req.user) { const userCompletions = await prisma.completion.findMany({ where: { userId: req.user.id }, - select: { moduleId: true, score: true, completedAt: true } + select: { moduleId: true, score: true, completedAt: true }, }) - + userCompletions.forEach((completion: any) => { userProgress[completion.moduleId] = { completed: true, score: completion.score, - completedAt: completion.completedAt + completedAt: completion.completedAt, } }) } @@ -148,7 +155,7 @@ export const listModules = async (req: Request, res: Response) => { createdAt: module.createdAt, updatedAt: module.updatedAt, completionCount: module._count.completions, - userProgress: userProgress[module.id] || null + userProgress: userProgress[module.id] || null, })) res.json({ @@ -159,10 +166,9 @@ export const listModules = async (req: Request, res: Response) => { total, totalPages: Math.ceil(total / limit), hasNext: page * limit < total, - hasPrev: page > 1 - } + hasPrev: page > 1, + }, }) - } catch (error) { console.error('Error listing modules:', error) res.status(500).json({ message: 'Internal server error' }) @@ -208,10 +214,10 @@ export const getModuleById = async (req: Request, res: Response) => { include: { _count: { select: { - completions: true - } - } - } + completions: true, + }, + }, + }, }) if (!module) { @@ -225,16 +231,16 @@ export const getModuleById = async (req: Request, res: Response) => { where: { userId_moduleId: { userId: req.user.id, - moduleId: id - } - } + moduleId: id, + }, + }, }) - + if (completion) { userProgress = { completed: true, score: completion.score, - completedAt: completion.completedAt + completedAt: completion.completedAt, } } } @@ -249,11 +255,10 @@ export const getModuleById = async (req: Request, res: Response) => { createdAt: module.createdAt, updatedAt: module.updatedAt, completionCount: module._count.completions, - userProgress + userProgress, } res.json(response) - } catch (error) { console.error('Error getting module:', error) res.status(500).json({ message: 'Internal server error' }) @@ -321,7 +326,7 @@ export const startModule = async (req: Request, res: Response) => { // Check if module exists const module = await prisma.module.findUnique({ - where: { id } + where: { id }, }) if (!module) { @@ -333,16 +338,15 @@ export const startModule = async (req: Request, res: Response) => { where: { userId_moduleId: { userId: req.user.id, - moduleId: id - } - } + moduleId: id, + }, + }, }) if (existingCompletion) { return res.status(400).json({ message: 'Module already started or completed', - status: - existingCompletion.score >= 0 ? 'completed' : 'in_progress', + status: existingCompletion.score >= 0 ? 'completed' : 'in_progress', }) } @@ -358,9 +362,8 @@ export const startModule = async (req: Request, res: Response) => { res.status(201).json({ message: 'Module started successfully', completionId: completion.id, - startedAt: completion.createdAt + startedAt: completion.createdAt, }) - } catch (error) { console.error('Error starting module:', error) res.status(500).json({ message: 'Internal server error' }) @@ -426,11 +429,11 @@ export const completeModule = async (req: Request, res: Response) => { const { id } = req.params const bodyValidation = completeModuleSchema.safeParse(req.body) - + if (!bodyValidation.success) { return res.status(400).json({ message: 'Invalid request body', - errors: bodyValidation.error.errors + errors: bodyValidation.error.errors, }) } @@ -438,7 +441,7 @@ export const completeModule = async (req: Request, res: Response) => { // Check if module exists const module = await prisma.module.findUnique({ - where: { id } + where: { id }, }) if (!module) { @@ -450,13 +453,15 @@ export const completeModule = async (req: Request, res: Response) => { where: { userId_moduleId: { userId: req.user.id, - moduleId: id - } - } + moduleId: id, + }, + }, }) if (!completion) { - return res.status(400).json({ message: 'Module must be started before completion' }) + return res + .status(400) + .json({ message: 'Module must be started before completion' }) } if (completion.score >= 0) { @@ -474,13 +479,13 @@ export const completeModule = async (req: Request, res: Response) => { where: { userId_moduleId: { userId: req.user.id, - moduleId: id - } + moduleId: id, + }, }, data: { score, - completedAt: new Date() - } + completedAt: new Date(), + }, }) // Check reward eligibility (score >= 70%) @@ -494,20 +499,24 @@ export const completeModule = async (req: Request, res: Response) => { userId: req.user.id, amount: module.reward, type: 'reward', - status: 'pending' - } + status: 'pending', + }, }) } // Fire push notification for quiz pass/fail (non-blocking) - notificationService.queueNotification( - req.user.id, - 'quizPassFail', - isEligibleForReward ? 'Quiz Passed!' : 'Quiz Completed', - isEligibleForReward - ? `Great job! You scored ${score}% on "${module.title}" and earned ${module.reward} XLM.` - : `You scored ${score}% on "${module.title}". Keep practicing to earn rewards!` - ).catch(err => console.error('[Notifications] Quiz notification error:', err)) + notificationService + .queueNotification( + req.user.id, + 'quizPassFail', + isEligibleForReward ? 'Quiz Passed!' : 'Quiz Completed', + isEligibleForReward + ? `Great job! You scored ${score}% on "${module.title}" and earned ${module.reward} XLM.` + : `You scored ${score}% on "${module.title}". Keep practicing to earn rewards!`, + ) + .catch((err) => + console.error('[Notifications] Quiz notification error:', err), + ) res.json({ message: 'Module completed successfully', @@ -515,9 +524,8 @@ export const completeModule = async (req: Request, res: Response) => { isEligibleForReward, reward: isEligibleForReward ? module.reward : 0, rewardTransaction: rewardTransaction?.id, - completedAt: updatedCompletion.completedAt + completedAt: updatedCompletion.completedAt, }) - } catch (error) { console.error('Error completing module:', error) res.status(500).json({ message: 'Internal server error' }) diff --git a/src/controllers/notification.controller.ts b/src/controllers/notification.controller.ts index 9594d402..cfd2390c 100644 --- a/src/controllers/notification.controller.ts +++ b/src/controllers/notification.controller.ts @@ -8,17 +8,21 @@ const notificationService = new NotificationService() const registerDeviceSchema = z.object({ token: z.string().min(1, 'Device token is required'), platform: z.enum(['ios', 'android', 'web'], { - errorMap: () => ({ message: 'Platform must be "ios", "android", or "web"' }) - }) + errorMap: () => ({ + message: 'Platform must be "ios", "android", or "web"', + }), + }), }) const updatePreferencesSchema = z .object({ rewardReceipt: z.boolean().optional(), quizPassFail: z.boolean().optional(), - streakReminders: z.boolean().optional() + streakReminders: z.boolean().optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + message: 'At least one preference field is required', }) - .refine(data => Object.keys(data).length > 0, { message: 'At least one preference field is required' }) export class NotificationController { /** @@ -65,18 +69,22 @@ export class NotificationController { if (!validation.success) { res.status(400).json({ error: 'Validation failed', - details: validation.error.format() + details: validation.error.format(), }) return } const { token, platform } = validation.data - const deviceToken = await notificationService.registerDeviceToken(userId, token, platform) + const deviceToken = await notificationService.registerDeviceToken( + userId, + token, + platform, + ) res.status(201).json({ message: 'Device token registered successfully', - data: deviceToken + data: deviceToken, }) } catch (error) { console.error('Register device token error:', error) @@ -128,17 +136,20 @@ export class NotificationController { if (!validation.success) { res.status(400).json({ error: 'Validation failed', - details: validation.error.format() + details: validation.error.format(), }) return } - const prefs = await notificationService.updateUserPreferences(userId, validation.data) + const prefs = await notificationService.updateUserPreferences( + userId, + validation.data, + ) res.status(200).json({ message: 'Preferences updated successfully', - data: prefs + data: prefs, }) } catch (error) { console.error('Update notification preferences error:', error) @@ -203,7 +214,7 @@ export class NotificationController { const logs = await prisma.notificationLog.findMany({ where: { userId, - ...(status ? { status } : {}) + ...(status ? { status } : {}), }, orderBy: { createdAt: 'desc' }, take: limit, @@ -215,13 +226,13 @@ export class NotificationController { status: true, error: true, attemptCount: true, - createdAt: true - } + createdAt: true, + }, }) res.status(200).json({ data: logs, - count: logs.length + count: logs.length, }) } catch (error) { console.error('Get delivery status error:', error) diff --git a/src/controllers/onboarding.controller.ts b/src/controllers/onboarding.controller.ts index d42a04f7..f8a732f7 100644 --- a/src/controllers/onboarding.controller.ts +++ b/src/controllers/onboarding.controller.ts @@ -5,7 +5,9 @@ import { ONBOARDING_STEPS } from '../types/onboarding.types' const saveStepSchema = z.object({ step: z.enum(ONBOARDING_STEPS, { - errorMap: () => ({ message: `Step must be one of: ${ONBOARDING_STEPS.join(', ')}` }), + errorMap: () => ({ + message: `Step must be one of: ${ONBOARDING_STEPS.join(', ')}`, + }), }), }) @@ -79,15 +81,23 @@ export class OnboardingController { return } - const result = await onboardingService.saveStep(userId, validation.data.step) + const result = await onboardingService.saveStep( + userId, + validation.data.step, + ) if (result.kind === 'already-completed') { - res.status(409).json({ error: 'Onboarding is already completed', data: result.progress }) + res.status(409).json({ + error: 'Onboarding is already completed', + data: result.progress, + }) return } - res.status(200).json({ message: 'Step saved successfully', data: result.progress }) + res + .status(200) + .json({ message: 'Step saved successfully', data: result.progress }) } catch (error) { console.error('Save onboarding step error:', error) res.status(500).json({ error: 'Internal server error' }) @@ -122,7 +132,10 @@ export class OnboardingController { const result = await onboardingService.complete(userId) if (result.kind === 'incomplete-steps') { - res.status(409).json({ error: 'Required onboarding steps are missing', missingSteps: result.missingSteps }) + res.status(409).json({ + error: 'Required onboarding steps are missing', + missingSteps: result.missingSteps, + }) return } @@ -133,7 +146,10 @@ export class OnboardingController { return } - res.status(200).json({ message: 'Onboarding completed successfully', data: result.progress }) + res.status(200).json({ + message: 'Onboarding completed successfully', + data: result.progress, + }) } catch (error) { console.error('Complete onboarding error:', error) res.status(500).json({ error: 'Internal server error' }) diff --git a/src/controllers/preference.controller.ts b/src/controllers/preference.controller.ts index df35f071..b2e9d5ba 100644 --- a/src/controllers/preference.controller.ts +++ b/src/controllers/preference.controller.ts @@ -22,29 +22,50 @@ const isValidTimezone = (value: string): boolean => { const updatePreferencesSchema = z .object({ - locale: z.enum(SUPPORTED_LOCALES, { - errorMap: () => ({ message: `Locale must be one of: ${SUPPORTED_LOCALES.join(', ')}` }), - }).optional(), - timezone: z.string().refine(isValidTimezone, { message: 'Invalid IANA timezone' }).optional(), + locale: z + .enum(SUPPORTED_LOCALES, { + errorMap: () => ({ + message: `Locale must be one of: ${SUPPORTED_LOCALES.join(', ')}`, + }), + }) + .optional(), + timezone: z + .string() + .refine(isValidTimezone, { message: 'Invalid IANA timezone' }) + .optional(), lowDataMode: z.boolean().optional(), highContrast: z.boolean().optional(), reduceMotion: z.boolean().optional(), screenReaderOptimized: z.boolean().optional(), - textSize: z.enum(TEXT_SIZES, { - errorMap: () => ({ message: `Text size must be one of: ${TEXT_SIZES.join(', ')}` }), - }).optional(), - preferredDifficulty: z.enum(DIFFICULTY_LEVELS, { - errorMap: () => ({ message: `Difficulty must be one of: ${DIFFICULTY_LEVELS.join(', ')}` }), - }).optional(), + textSize: z + .enum(TEXT_SIZES, { + errorMap: () => ({ + message: `Text size must be one of: ${TEXT_SIZES.join(', ')}`, + }), + }) + .optional(), + preferredDifficulty: z + .enum(DIFFICULTY_LEVELS, { + errorMap: () => ({ + message: `Difficulty must be one of: ${DIFFICULTY_LEVELS.join(', ')}`, + }), + }) + .optional(), preferredCategories: z.array(z.string().min(1)).max(50).optional(), - profileVisibility: z.enum(PROFILE_VISIBILITIES, { - errorMap: () => ({ message: `Profile visibility must be one of: ${PROFILE_VISIBILITIES.join(', ')}` }), - }).optional(), + profileVisibility: z + .enum(PROFILE_VISIBILITIES, { + errorMap: () => ({ + message: `Profile visibility must be one of: ${PROFILE_VISIBILITIES.join(', ')}`, + }), + }) + .optional(), analyticsConsent: z.boolean().optional(), dataSharingConsent: z.boolean().optional(), }) .strict() - .refine(data => Object.keys(data).length > 0, { message: 'At least one preference field is required' }) + .refine((data) => Object.keys(data).length > 0, { + message: 'At least one preference field is required', + }) export class PreferenceController { /** @@ -147,7 +168,10 @@ export class PreferenceController { return } - const preferences = await preferenceService.updatePreferences(userId, validation.data) + const preferences = await preferenceService.updatePreferences( + userId, + validation.data, + ) res.status(200).json({ message: 'Preferences updated successfully', diff --git a/src/controllers/profile.controller.ts b/src/controllers/profile.controller.ts index 7ba176d9..811b7262 100644 --- a/src/controllers/profile.controller.ts +++ b/src/controllers/profile.controller.ts @@ -85,7 +85,11 @@ export class ProfileController { return } - await profileService.updateProfileAudited(userId, validation.data, requestAuditContext(req)) + await profileService.updateProfileAudited( + userId, + validation.data, + requestAuditContext(req), + ) const profile = await profileService.getOwnerView(userId) res.status(200).json({ @@ -129,9 +133,10 @@ export class ProfileController { return } - const profile = req.user?.role === 'employer' - ? await profileService.getEmployerView(id) - : await profileService.getPublicView(id) + const profile = + req.user?.role === 'employer' + ? await profileService.getEmployerView(id) + : await profileService.getPublicView(id) if (!profile) { res.status(404).json({ error: 'Profile not found' }) diff --git a/src/controllers/referral.controller.ts b/src/controllers/referral.controller.ts index 2fab5173..39ce6a9c 100644 --- a/src/controllers/referral.controller.ts +++ b/src/controllers/referral.controller.ts @@ -2,7 +2,12 @@ import { Request, Response } from 'express' import { randomBytes } from 'crypto' import prisma from '../config/database' import { asyncHandler } from '../middleware/error.middleware' -import { BadRequestError, ConflictError, NotFoundError, UnauthorizedError } from '../utils/errors' +import { + BadRequestError, + ConflictError, + NotFoundError, + UnauthorizedError, +} from '../utils/errors' const REFERRAL_BONUS_AMOUNT = 5.0 const CODE_BYTES = 4 @@ -40,32 +45,36 @@ export class ReferralController { * schema: * $ref: '#/components/schemas/ErrorResponse' */ - generateCode = asyncHandler(async (req: Request, res: Response): Promise => { - const userId = (req as any).user?.id - if (!userId) throw new UnauthorizedError('User ID not found') + generateCode = asyncHandler( + async (req: Request, res: Response): Promise => { + const userId = (req as any).user?.id + if (!userId) throw new UnauthorizedError('User ID not found') - const existing = await prisma.referralCode.findUnique({ where: { userId } }) - if (existing) { - res.status(200).json({ - success: true, - message: 'Referral code already exists', - data: { code: existing.code }, + const existing = await prisma.referralCode.findUnique({ + where: { userId }, }) + if (existing) { + res.status(200).json({ + success: true, + message: 'Referral code already exists', + data: { code: existing.code }, + }) - return - } + return + } - const code = await this.generateUniqueCode() - const referralCode = await prisma.referralCode.create({ - data: { code, userId }, - }) + const code = await this.generateUniqueCode() + const referralCode = await prisma.referralCode.create({ + data: { code, userId }, + }) - res.status(201).json({ - success: true, - message: 'Referral code generated successfully', - data: { code: referralCode.code }, - }) - }) + res.status(201).json({ + success: true, + message: 'Referral code generated successfully', + data: { code: referralCode.code }, + }) + }, + ) /** * @openapi @@ -108,44 +117,52 @@ export class ReferralController { * schema: * $ref: '#/components/schemas/ErrorResponse' */ - applyCode = asyncHandler(async (req: Request, res: Response): Promise => { - const userId = (req as any).user?.id - if (!userId) throw new UnauthorizedError('User ID not found') + applyCode = asyncHandler( + async (req: Request, res: Response): Promise => { + const userId = (req as any).user?.id + if (!userId) throw new UnauthorizedError('User ID not found') - const { code } = req.body - if (!code || typeof code !== 'string') { - throw new BadRequestError('Referral code is required') - } + const { code } = req.body + if (!code || typeof code !== 'string') { + throw new BadRequestError('Referral code is required') + } - const referralCode = await prisma.referralCode.findUnique({ where: { code } }) - if (!referralCode) throw new NotFoundError('Referral code not found') + const referralCode = await prisma.referralCode.findUnique({ + where: { code }, + }) + if (!referralCode) throw new NotFoundError('Referral code not found') - if (referralCode.userId === userId) { - throw new BadRequestError('Self-referrals are not allowed') - } + if (referralCode.userId === userId) { + throw new BadRequestError('Self-referrals are not allowed') + } - const alreadyReferred = await prisma.referral.findUnique({ where: { referreeId: userId } }) - if (alreadyReferred) throw new ConflictError('You have already used a referral code') + const alreadyReferred = await prisma.referral.findUnique({ + where: { referreeId: userId }, + }) + if (alreadyReferred) + throw new ConflictError('You have already used a referral code') - const alreadyUsedThisCode = await prisma.referral.findFirst({ - where: { referrerId: referralCode.userId, referreeId: userId }, - }) - if (alreadyUsedThisCode) throw new ConflictError('This referral code has already been applied') + const alreadyUsedThisCode = await prisma.referral.findFirst({ + where: { referrerId: referralCode.userId, referreeId: userId }, + }) + if (alreadyUsedThisCode) + throw new ConflictError('This referral code has already been applied') - const referral = await prisma.referral.create({ - data: { - referrerId: referralCode.userId, - referreeId: userId, - codeId: referralCode.id, - }, - }) + const referral = await prisma.referral.create({ + data: { + referrerId: referralCode.userId, + referreeId: userId, + codeId: referralCode.id, + }, + }) - res.status(201).json({ - success: true, - message: 'Referral code applied successfully', - data: { referralId: referral.id }, - }) - }) + res.status(201).json({ + success: true, + message: 'Referral code applied successfully', + data: { referralId: referral.id }, + }) + }, + ) /** * @openapi @@ -170,41 +187,44 @@ export class ReferralController { * schema: * $ref: '#/components/schemas/ErrorResponse' */ - getStats = asyncHandler(async (req: Request, res: Response): Promise => { - const userId = (req as any).user?.id - if (!userId) throw new UnauthorizedError('User ID not found') - - const referrals = await prisma.referral.findMany({ - where: { referrerId: userId }, - include: { - referree: { - select: { completions: { take: 1 } }, + getStats = asyncHandler( + async (req: Request, res: Response): Promise => { + const userId = (req as any).user?.id + if (!userId) throw new UnauthorizedError('User ID not found') + + const referrals = await prisma.referral.findMany({ + where: { referrerId: userId }, + include: { + referree: { + select: { completions: { take: 1 } }, + }, }, - }, - }) + }) - type ReferralRow = (typeof referrals)[number] + type ReferralRow = (typeof referrals)[number] - const totalReferrals = referrals.length - const activeReferrals = referrals.filter( - (r: ReferralRow) => r.referree.completions.length > 0, - ).length - const paidBonuses = referrals.filter((r: ReferralRow) => r.bonusPaid) - const earnedBonuses = paidBonuses.reduce( - (sum: number, r: ReferralRow) => sum + (r.bonusAmount ?? 0), - 0, - ) + const totalReferrals = referrals.length + const activeReferrals = referrals.filter( + (r: ReferralRow) => r.referree.completions.length > 0, + ).length + const paidBonuses = referrals.filter((r: ReferralRow) => r.bonusPaid) + const earnedBonuses = paidBonuses.reduce( + (sum: number, r: ReferralRow) => sum + (r.bonusAmount ?? 0), + 0, + ) - res.status(200).json({ - success: true, - data: { - totalReferrals, - activeReferrals, - earnedBonuses, - pendingBonuses: (totalReferrals - paidBonuses.length) * REFERRAL_BONUS_AMOUNT, - }, - }) - }) + res.status(200).json({ + success: true, + data: { + totalReferrals, + activeReferrals, + earnedBonuses, + pendingBonuses: + (totalReferrals - paidBonuses.length) * REFERRAL_BONUS_AMOUNT, + }, + }) + }, + ) /** * Called internally when a referree completes their first module to unlock the referrer bonus. @@ -216,7 +236,9 @@ export class ReferralController { if (!referral || referral.bonusPaid) return - const completionCount = await prisma.completion.count({ where: { userId: referreeId } }) + const completionCount = await prisma.completion.count({ + where: { userId: referreeId }, + }) if (completionCount < 1) return await prisma.referral.update({ diff --git a/src/controllers/reward.controller.ts b/src/controllers/reward.controller.ts index ce34eb43..1d8d9a80 100644 --- a/src/controllers/reward.controller.ts +++ b/src/controllers/reward.controller.ts @@ -277,7 +277,9 @@ export class RewardController { : null if (!amountString) { - throw new BadRequestError('Amount must be a numeric string (e.g. "5.0000000")') + throw new BadRequestError( + 'Amount must be a numeric string (e.g. "5.0000000")', + ) } // Parse the XLM string into exact stroops — throws MoneyError on bad format diff --git a/src/controllers/session.controller.ts b/src/controllers/session.controller.ts index 2ac5ba40..fc5c033b 100644 --- a/src/controllers/session.controller.ts +++ b/src/controllers/session.controller.ts @@ -1,6 +1,9 @@ import { Request, Response } from 'express' import { sessionService } from '../services/session.service' -import { sessionListQuerySchema, sessionIdParamSchema } from '../schemas/session.schema' +import { + sessionListQuerySchema, + sessionIdParamSchema, +} from '../schemas/session.schema' import logger from '../utils/logger' // ── Helpers ─────────────────────────────────────────────────────────────── @@ -26,7 +29,8 @@ async function resolveCurrentSessionId(req: Request): Promise { select: { id: true, userId: true, isRevoked: true }, }) - if (!session || session.isRevoked || session.userId !== req.user?.id) return null + if (!session || session.isRevoked || session.userId !== req.user?.id) + return null return session.id } catch { @@ -126,7 +130,7 @@ export class SessionController { userId, currentSessionId, page, - limit + limit, ) const totalPages = Math.ceil(total / limit) @@ -228,7 +232,7 @@ export class SessionController { userId, sessionId, currentSessionId, - context(req) + context(req), ) switch (result.kind) { @@ -239,7 +243,8 @@ export class SessionController { case 'current_session': res.status(400).json({ - error: 'Cannot revoke your current session. Use POST /v1/auth/logout instead.', + error: + 'Cannot revoke your current session. Use POST /v1/auth/logout instead.', code: 'CURRENT_SESSION', }) @@ -302,7 +307,11 @@ export class SessionController { const userId = req.user!.id const currentSessionId = await resolveCurrentSessionId(req) - const result = await sessionService.revokeAll(userId, currentSessionId, context(req)) + const result = await sessionService.revokeAll( + userId, + currentSessionId, + context(req), + ) res.status(200).json({ message: diff --git a/src/controllers/sync.controller.ts b/src/controllers/sync.controller.ts index 676e9705..19d5fd04 100644 --- a/src/controllers/sync.controller.ts +++ b/src/controllers/sync.controller.ts @@ -75,70 +75,112 @@ export class SyncController { * schema: * $ref: '#/components/schemas/ErrorResponse' */ - syncProgress = asyncHandler(async (req: Request, res: Response): Promise => { - const userId = (req as any).user?.id - if (!userId) throw new UnauthorizedError('User ID not found') + syncProgress = asyncHandler( + async (req: Request, res: Response): Promise => { + const userId = (req as any).user?.id + if (!userId) throw new UnauthorizedError('User ID not found') + + const { events } = req.body + if (!Array.isArray(events) || events.length === 0) { + throw new BadRequestError('events must be a non-empty array') + } - const { events } = req.body - if (!Array.isArray(events) || events.length === 0) { - throw new BadRequestError('events must be a non-empty array') - } + const results: SyncResult[] = [] - const results: SyncResult[] = [] + for (const event of events as ProgressEvent[]) { + const { + idempotencyKey, + deviceId, + moduleId, + progressPercent, + clientTimestamp, + syncVersion, + } = event + + if ( + !idempotencyKey || + !deviceId || + !moduleId || + progressPercent === undefined || + !clientTimestamp || + syncVersion === undefined + ) { + results.push({ + idempotencyKey: idempotencyKey ?? 'unknown', + status: 'rejected', + reason: 'Missing required fields', + }) + continue + } - for (const event of events as ProgressEvent[]) { - const { idempotencyKey, deviceId, moduleId, progressPercent, clientTimestamp, syncVersion } = event + if ( + typeof progressPercent !== 'number' || + progressPercent < 0 || + progressPercent > 100 + ) { + results.push({ + idempotencyKey, + status: 'rejected', + reason: 'progressPercent must be between 0 and 100', + }) + continue + } - if (!idempotencyKey || !deviceId || !moduleId || progressPercent === undefined || !clientTimestamp || syncVersion === undefined) { - results.push({ idempotencyKey: idempotencyKey ?? 'unknown', status: 'rejected', reason: 'Missing required fields' }) - continue - } + const clientTs = new Date(clientTimestamp) + if (isNaN(clientTs.getTime())) { + results.push({ + idempotencyKey, + status: 'rejected', + reason: 'Invalid clientTimestamp format', + }) + continue + } - if (typeof progressPercent !== 'number' || progressPercent < 0 || progressPercent > 100) { - results.push({ idempotencyKey, status: 'rejected', reason: 'progressPercent must be between 0 and 100' }) - continue - } + const existing = await prisma.syncEvent.findUnique({ + where: { idempotencyKey }, + }) + if (existing) { + results.push({ idempotencyKey, status: 'skipped' }) + continue + } - const clientTs = new Date(clientTimestamp) - if (isNaN(clientTs.getTime())) { - results.push({ idempotencyKey, status: 'rejected', reason: 'Invalid clientTimestamp format' }) - continue - } + const latestForModule = await prisma.syncEvent.findFirst({ + where: { + userId, + payload: { contains: moduleId }, + eventType: 'progress', + }, + orderBy: { syncVersion: 'desc' }, + }) - const existing = await prisma.syncEvent.findUnique({ where: { idempotencyKey } }) - if (existing) { - results.push({ idempotencyKey, status: 'skipped' }) - continue - } + if (latestForModule && latestForModule.syncVersion > syncVersion) { + results.push({ + idempotencyKey, + status: 'skipped', + reason: 'Stale sync version — a newer version already applied', + }) + continue + } - const latestForModule = await prisma.syncEvent.findFirst({ - where: { userId, payload: { contains: moduleId }, eventType: 'progress' }, - orderBy: { syncVersion: 'desc' }, - }) + await prisma.syncEvent.create({ + data: { + idempotencyKey, + userId, + deviceId, + eventType: 'progress', + payload: JSON.stringify({ moduleId, progressPercent }), + clientTimestamp: clientTs, + syncVersion, + status: 'applied', + }, + }) - if (latestForModule && latestForModule.syncVersion > syncVersion) { - results.push({ idempotencyKey, status: 'skipped', reason: 'Stale sync version — a newer version already applied' }) - continue + results.push({ idempotencyKey, status: 'applied' }) } - await prisma.syncEvent.create({ - data: { - idempotencyKey, - userId, - deviceId, - eventType: 'progress', - payload: JSON.stringify({ moduleId, progressPercent }), - clientTimestamp: clientTs, - syncVersion, - status: 'applied', - }, - }) - - results.push({ idempotencyKey, status: 'applied' }) - } - - res.status(200).json({ success: true, data: { results } }) - }) + res.status(200).json({ success: true, data: { results } }) + }, + ) /** * @openapi @@ -185,97 +227,138 @@ export class SyncController { * schema: * $ref: '#/components/schemas/ErrorResponse' */ - syncCompletions = asyncHandler(async (req: Request, res: Response): Promise => { - const userId = (req as any).user?.id - if (!userId) throw new UnauthorizedError('User ID not found') - - const { events } = req.body - if (!Array.isArray(events) || events.length === 0) { - throw new BadRequestError('events must be a non-empty array') - } - - const results: SyncResult[] = [] - - for (const event of events as CompletionEvent[]) { - const { idempotencyKey, deviceId, moduleId, score, clientTimestamp, syncVersion } = event - - if (!idempotencyKey || !deviceId || !moduleId || score === undefined || !clientTimestamp || syncVersion === undefined) { - results.push({ idempotencyKey: idempotencyKey ?? 'unknown', status: 'rejected', reason: 'Missing required fields' }) - continue + syncCompletions = asyncHandler( + async (req: Request, res: Response): Promise => { + const userId = (req as any).user?.id + if (!userId) throw new UnauthorizedError('User ID not found') + + const { events } = req.body + if (!Array.isArray(events) || events.length === 0) { + throw new BadRequestError('events must be a non-empty array') } - if (typeof score !== 'number' || score < 0 || score > 100) { - results.push({ idempotencyKey, status: 'rejected', reason: 'score must be between 0 and 100' }) - continue - } + const results: SyncResult[] = [] - const clientTs = new Date(clientTimestamp) - if (isNaN(clientTs.getTime())) { - results.push({ idempotencyKey, status: 'rejected', reason: 'Invalid clientTimestamp format' }) - continue - } + for (const event of events as CompletionEvent[]) { + const { + idempotencyKey, + deviceId, + moduleId, + score, + clientTimestamp, + syncVersion, + } = event + + if ( + !idempotencyKey || + !deviceId || + !moduleId || + score === undefined || + !clientTimestamp || + syncVersion === undefined + ) { + results.push({ + idempotencyKey: idempotencyKey ?? 'unknown', + status: 'rejected', + reason: 'Missing required fields', + }) + continue + } - const existing = await prisma.syncEvent.findUnique({ where: { idempotencyKey } }) - if (existing) { - results.push({ idempotencyKey, status: 'skipped' }) - continue - } + if (typeof score !== 'number' || score < 0 || score > 100) { + results.push({ + idempotencyKey, + status: 'rejected', + reason: 'score must be between 0 and 100', + }) + continue + } - const module = await prisma.module.findUnique({ where: { id: moduleId } }) - if (!module) { - results.push({ idempotencyKey, status: 'rejected', reason: 'Module not found' }) - continue - } + const clientTs = new Date(clientTimestamp) + if (isNaN(clientTs.getTime())) { + results.push({ + idempotencyKey, + status: 'rejected', + reason: 'Invalid clientTimestamp format', + }) + continue + } - const alreadyCompleted = await prisma.completion.findUnique({ - where: { userId_moduleId: { userId, moduleId } }, - }) + const existing = await prisma.syncEvent.findUnique({ + where: { idempotencyKey }, + }) + if (existing) { + results.push({ idempotencyKey, status: 'skipped' }) + continue + } - if (alreadyCompleted) { - if (score <= alreadyCompleted.score) { - await prisma.syncEvent.create({ - data: { - idempotencyKey, - userId, - deviceId, - eventType: 'completion', - payload: JSON.stringify({ moduleId, score }), - clientTimestamp: clientTs, - syncVersion, - status: 'skipped', - rejectionReason: 'Existing completion has equal or higher score', - }, + const module = await prisma.module.findUnique({ + where: { id: moduleId }, + }) + if (!module) { + results.push({ + idempotencyKey, + status: 'rejected', + reason: 'Module not found', }) - results.push({ idempotencyKey, status: 'skipped', reason: 'Existing completion has equal or higher score' }) continue } - await prisma.completion.update({ + const alreadyCompleted = await prisma.completion.findUnique({ where: { userId_moduleId: { userId, moduleId } }, - data: { score }, - }) - } else { - await prisma.completion.create({ - data: { userId, moduleId, score }, }) - } - await prisma.syncEvent.create({ - data: { - idempotencyKey, - userId, - deviceId, - eventType: 'completion', - payload: JSON.stringify({ moduleId, score }), - clientTimestamp: clientTs, - syncVersion, - status: 'applied', - }, - }) + if (alreadyCompleted) { + if (score <= alreadyCompleted.score) { + await prisma.syncEvent.create({ + data: { + idempotencyKey, + userId, + deviceId, + eventType: 'completion', + payload: JSON.stringify({ moduleId, score }), + clientTimestamp: clientTs, + syncVersion, + status: 'skipped', + rejectionReason: + 'Existing completion has equal or higher score', + }, + }) + results.push({ + idempotencyKey, + status: 'skipped', + reason: 'Existing completion has equal or higher score', + }) + continue + } + + await prisma.completion.update({ + where: { userId_moduleId: { userId, moduleId } }, + data: { score }, + }) + } else { + await prisma.completion.create({ + data: { userId, moduleId, score }, + }) + } + + await prisma.syncEvent.create({ + data: { + idempotencyKey, + userId, + deviceId, + eventType: 'completion', + payload: JSON.stringify({ moduleId, score }), + clientTimestamp: clientTs, + syncVersion, + status: 'applied', + }, + }) - results.push({ idempotencyKey, status: 'applied' }) - } + results.push({ idempotencyKey, status: 'applied' }) + } - res.status(200).json({ success: true, data: { results } }) - }) + res.status(200).json({ success: true, data: { results } }) + }, + ) } diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index f5efd847..aaf2408b 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -156,7 +156,7 @@ export class UserController { await profileService.updateProfileAudited( userId, validation.data, - requestAuditContext(req) + requestAuditContext(req), ) const aggregate = await profileService.getOwnerAccountProfile(userId) @@ -166,7 +166,9 @@ export class UserController { return } - res.status(200).json({ message: 'Profile updated successfully', data: aggregate }) + res + .status(200) + .json({ message: 'Profile updated successfully', data: aggregate }) } catch (error) { logger.error('Update profile error:', error) res.status(500).json({ error: 'Internal server error' }) @@ -308,7 +310,7 @@ export class UserController { userId, validation.data.currentPassword, validation.data.newPassword, - requestAuditContext(req) + requestAuditContext(req), ) if (result.kind === 'not-found') { @@ -320,13 +322,17 @@ export class UserController { // 401, not 400: a wrong current password is a failed re-authentication, // and the body that carried it was perfectly well-formed. if (result.kind === 'invalid-password') { - res.status(401).json({ error: 'Current password is incorrect', code: 'STEP_UP_FAILED' }) + res.status(401).json({ + error: 'Current password is incorrect', + code: 'STEP_UP_FAILED', + }) return } res.status(200).json({ - message: 'Password updated successfully. All sessions have been signed out.', + message: + 'Password updated successfully. All sessions have been signed out.', revokedSessionCount: result.revokedSessionCount, }) } catch (error) { @@ -405,7 +411,7 @@ export class UserController { const result = await userAccountService.updateWalletAddress( userId, validation.data.walletAddress, - requestAuditContext(req) + requestAuditContext(req), ) if (result.kind === 'not-found') { diff --git a/src/controllers/wallet-status.controller.ts b/src/controllers/wallet-status.controller.ts index 75d31cf6..ae3d589d 100644 --- a/src/controllers/wallet-status.controller.ts +++ b/src/controllers/wallet-status.controller.ts @@ -44,7 +44,10 @@ export class WalletStatusController { } try { - const { entries, nextCursor } = await this.service.getHistory(req.user!.id, parsed.data) + const { entries, nextCursor } = await this.service.getHistory( + req.user!.id, + parsed.data, + ) res.status(200).json({ success: true, data: entries, @@ -63,12 +66,17 @@ export class WalletStatusController { private respondWithError(res: Response, error: unknown): void { if (error instanceof WalletStatusError) { const statusCode = PROVIDER_ERROR_STATUS[error.code] ?? 500 - res.status(statusCode).json({ success: false, error: { code: error.code, message: error.message } }) + res.status(statusCode).json({ + success: false, + error: { code: error.code, message: error.message }, + }) return } logger.error('[WalletStatusController] Unexpected error:', error) - res.status(500).json({ success: false, error: { code: 'INTERNAL_SERVER_ERROR' } }) + res + .status(500) + .json({ success: false, error: { code: 'INTERNAL_SERVER_ERROR' } }) } } diff --git a/src/jobs/handler-registrations.ts b/src/jobs/handler-registrations.ts index 6bc439ed..fb13bfde 100644 --- a/src/jobs/handler-registrations.ts +++ b/src/jobs/handler-registrations.ts @@ -25,7 +25,7 @@ export interface RegisterHandlersOptions { } export function registerOutboxHandlers( - options: RegisterHandlersOptions = {} + options: RegisterHandlersOptions = {}, ): OutboxHandlerRegistry { const prisma = options.prisma ?? defaultPrisma const registry = options.registry ?? getOutboxHandlerRegistry() @@ -43,9 +43,9 @@ export function registerOutboxHandlers( new WalletProvisioningOutboxHandler( repository, new InMemoryEnvelopeKms(), - new SdkStellarKeypairGenerator() - ) - ) + new SdkStellarKeypairGenerator(), + ), + ), ) registry.assertHandlersFor(EMITTED_EVENT_TYPES) diff --git a/src/jobs/user-created.handler.ts b/src/jobs/user-created.handler.ts index 725432c0..6d97c721 100644 --- a/src/jobs/user-created.handler.ts +++ b/src/jobs/user-created.handler.ts @@ -4,7 +4,10 @@ import type { OutboxEventHandlerContext, OutboxEventHandlerResult, } from '../lib/transactions/types' -import { createOutboxService, OutboxService } from '../lib/transactions/outbox.service' +import { + createOutboxService, + OutboxService, +} from '../lib/transactions/outbox.service' import type { WalletProvisioningRepository } from '../services/wallet-provisioning.repository' export interface UserCreatedPayload { @@ -36,10 +39,15 @@ export class UserCreatedHandler implements OutboxEventHandler { this.outbox = options.outboxService ?? createOutboxService(prisma) } - async handle(context: OutboxEventHandlerContext): Promise { + async handle( + context: OutboxEventHandlerContext, + ): Promise { const payload = context.payload as UserCreatedPayload - const wallet = await this.repository.reserveEligibleWallet(payload.userId, this.network) + const wallet = await this.repository.reserveEligibleWallet( + payload.userId, + this.network, + ) const alreadyRequested = await this.prisma.outboxEvent.findFirst({ where: { diff --git a/src/jobs/wallet-provisioning-requested.handler.ts b/src/jobs/wallet-provisioning-requested.handler.ts index b1d2dbf5..8a61fa0e 100644 --- a/src/jobs/wallet-provisioning-requested.handler.ts +++ b/src/jobs/wallet-provisioning-requested.handler.ts @@ -19,13 +19,15 @@ export class WalletProvisioningRequestedHandler implements OutboxEventHandler { constructor(private readonly handler: WalletProvisioningOutboxHandler) {} - async handle(context: OutboxEventHandlerContext): Promise { + async handle( + context: OutboxEventHandlerContext, + ): Promise { const payload = context.payload as WalletProvisioningRequestedPayload const result = await this.handler.handleWallet(payload.walletId) if (result.kind === 'retry-scheduled') { throw new Error( - `Wallet ${payload.walletId} provisioning failed with ${result.failureCode}` + `Wallet ${payload.walletId} provisioning failed with ${result.failureCode}`, ) } diff --git a/src/jobs/wallet-provisioning.handler.ts b/src/jobs/wallet-provisioning.handler.ts index 3708b50d..0a89bfd3 100644 --- a/src/jobs/wallet-provisioning.handler.ts +++ b/src/jobs/wallet-provisioning.handler.ts @@ -52,7 +52,9 @@ export class WalletProvisioningOutboxHandler { return this.handleClaimed(claimed) } - async handleWallet(walletId: string): Promise { + async handleWallet( + walletId: string, + ): Promise { const claimed = await this.repository.claimByWalletId( walletId, this.now(), diff --git a/src/lib/transactions/README.md b/src/lib/transactions/README.md index 6570ba0d..7c166d1e 100644 --- a/src/lib/transactions/README.md +++ b/src/lib/transactions/README.md @@ -66,6 +66,7 @@ CREATE INDEX on outbox_events(aggregateId, aggregateType); -- Trace aggregate hi ``` **Status Lifecycle:** + - `PENDING` → Event emitted, waiting for workers to publish - `PROCESSING` → Worker is publishing (JobAttempts being created) - `PUBLISHED` → All deliveries succeeded @@ -103,6 +104,7 @@ CREATE INDEX on job_attempts(status, leasedUntil); -- Find abandoned leases ``` **Status Lifecycle:** + - `PENDING` → Job waiting to be leased - `LEASED` → Worker holds leaseToken; currently processing - `COMPLETED` → Job succeeded; idempotency key set @@ -131,33 +133,33 @@ CREATE INDEX on rolled_back_records(createdAt); -- For periodic cleanup ### 1. Write Domain Changes + Events Atomically ```typescript -const outboxService = createOutboxService(prisma); +const outboxService = createOutboxService(prisma) // In your controller or service: const result = await prisma.$transaction(async (tx) => { // Make domain change const user = await tx.user.create({ - data: { email: "user@example.com", role: "LEARNER" }, - }); + data: { email: 'user@example.com', role: 'LEARNER' }, + }) // Write outbox event in same transaction const event = await outboxService.createEvent(tx, { aggregateId: user.id, - aggregateType: "User", - eventType: "UserCreated", + aggregateType: 'User', + eventType: 'UserCreated', eventVersion: 1, payload: { userId: user.id, email: user.email }, - source: "api.auth.register", - }); + source: 'api.auth.register', + }) // Define jobs that should process this event await outboxService.createJobAttempts(tx, event.id, [ - { jobType: "email.send", jobName: "Send welcome email" }, - { jobType: "notification.push", jobName: "Send push notification" }, - ]); + { jobType: 'email.send', jobName: 'Send welcome email' }, + { jobType: 'notification.push', jobName: 'Send push notification' }, + ]) - return { user, event }; -}); + return { user, event } +}) // ✅ If transaction succeeds: user and event both persisted // ✅ If transaction rolls back: neither user nor event are created @@ -166,44 +168,36 @@ const result = await prisma.$transaction(async (tx) => { ### 2. Worker Leases Jobs ```typescript -const jobLeaseService = createJobLeaseService(prisma); +const jobLeaseService = createJobLeaseService(prisma) async function emailWorker() { while (true) { // Lease a job (only one worker gets it) const lease = await jobLeaseService.leaseJob({ - jobType: "email.send", + jobType: 'email.send', maxLeaseMs: 30000, // Hold lease for 30 seconds - }); + }) if (!lease) { // No jobs available; sleep and retry - await sleep(5000); - continue; + await sleep(5000) + continue } try { // Process the job - const emailPayload = lease.payload as any; - const txHash = await sendWelcomeEmail(emailPayload.email); + const emailPayload = lease.payload as any + const txHash = await sendWelcomeEmail(emailPayload.email) // Mark as completed - await jobLeaseService.completeJob( - lease.jobId, - lease.leaseToken, - { - success: true, - idempotencyKey: `email_${lease.payload.userId}_${Date.now()}`, - result: { messageId: txHash }, - } - ); + await jobLeaseService.completeJob(lease.jobId, lease.leaseToken, { + success: true, + idempotencyKey: `email_${lease.payload.userId}_${Date.now()}`, + result: { messageId: txHash }, + }) } catch (error) { // Mark as failed (will retry with exponential backoff) - await jobLeaseService.failJob( - lease.jobId, - lease.leaseToken, - error - ); + await jobLeaseService.failJob(lease.jobId, lease.leaseToken, error) } } } @@ -213,13 +207,16 @@ async function emailWorker() { ```typescript // Run periodically (e.g., every 5 minutes) to reclaim abandoned leases -const jobLeaseService = createJobLeaseService(prisma); +const jobLeaseService = createJobLeaseService(prisma) async function leaseRecoverySchedule() { - setInterval(async () => { - const recovered = await jobLeaseService.recoverAbandonedLeases(); - logger.info(`Recovered ${recovered} abandoned leases`); - }, 5 * 60 * 1000); + setInterval( + async () => { + const recovered = await jobLeaseService.recoverAbandonedLeases() + logger.info(`Recovered ${recovered} abandoned leases`) + }, + 5 * 60 * 1000, + ) } ``` @@ -227,11 +224,14 @@ async function leaseRecoverySchedule() { ```typescript // Get jobs that permanently failed -const deadLetterJobs = await jobLeaseService.getDeadLetterJobs(100); +const deadLetterJobs = await jobLeaseService.getDeadLetterJobs(100) for (const job of deadLetterJobs) { - logger.warn(`Job ${job.id} failed after ${job.attempt} attempts:`, job.lastError); - + logger.warn( + `Job ${job.id} failed after ${job.attempt} attempts:`, + job.lastError, + ) + // After operator fixes the issue: // await jobLeaseService.resetJobForRetry(job.id); } @@ -285,16 +285,17 @@ Attempt 4: availableAt = now + 8000ms (8s backoff) ``` Configuration per job type: + ```typescript await outboxService.createJobAttempts(tx, eventId, [ { - jobType: "stellar.transfer", - jobName: "Transfer XLM", + jobType: 'stellar.transfer', + jobName: 'Transfer XLM', maxAttempts: 5, - backoffBaseMs: 2000, // Start with 2 second delay - backoffMultiplier: 2.0, // Double each time + backoffBaseMs: 2000, // Start with 2 second delay + backoffMultiplier: 2.0, // Double each time }, -]); +]) ``` ### ✅ Abandoned Lease Recovery @@ -317,19 +318,16 @@ Workers drain in-flight work before exiting: ```typescript // On SIGTERM signal: async function gracefulShutdown() { - console.log("Graceful shutdown: completing in-flight jobs..."); - + console.log('Graceful shutdown: completing in-flight jobs...') + // Worker loop checks this flag - SHUTDOWN_REQUESTED = true; - + SHUTDOWN_REQUESTED = true + // Wait for current batch to complete (max 30 seconds) - await Promise.race([ - activeJobs.complete(), - setTimeout(() => {}, 30000), - ]); - - await prisma.$disconnect(); - process.exit(0); + await Promise.race([activeJobs.complete(), setTimeout(() => {}, 30000)]) + + await prisma.$disconnect() + process.exit(0) } ``` @@ -338,29 +336,26 @@ async function gracefulShutdown() { Use `EventSchemaRegistry` to validate event payloads: ```typescript -import { - getEventSchemaRegistry, - createEventSchema, -} from "@/lib/transactions"; -import { z } from "zod"; +import { getEventSchemaRegistry, createEventSchema } from '@/lib/transactions' +import { z } from 'zod' // Register schemas -const registry = getEventSchemaRegistry(); +const registry = getEventSchemaRegistry() registry.register( createEventSchema( - "UserCreated", + 'UserCreated', 1, z.object({ userId: z.string().uuid(), email: z.string().email(), - role: z.enum(["ADMIN", "LEARNER", "INSTRUCTOR"]), - }) - ) -); + role: z.enum(['ADMIN', 'LEARNER', 'INSTRUCTOR']), + }), + ), +) // Validate events -await registry.validate("UserCreated", 1, payload); +await registry.validate('UserCreated', 1, payload) ``` ## Testing @@ -368,22 +363,26 @@ await registry.validate("UserCreated", 1, payload); Three comprehensive test suites cover: ### 1. Rollback Tests + - Verify rolled-back domain changes emit no events - Workers skip ROLLED_BACK events - Concurrent rollback + lease attempts are handled correctly ### 2. Duplicate Delivery Tests + - Idempotency keys prevent duplicate side effects - Completed jobs are recognized and skipped - External calls are made only once ### 3. Crash and Retry Tests + - Abandoned leases are recovered after expiration - Exponential backoff prevents thundering herd - Max attempts are enforced before dead-lettering - Dead-letter jobs can be manually recovered Run tests: + ```bash pnpm test src/lib/transactions/ ``` @@ -396,15 +395,15 @@ All tables are heavily indexed for worker polling: ```sql -- Find PENDING jobs ready to lease -CREATE INDEX job_attempts_status_availableAt_idx +CREATE INDEX job_attempts_status_availableAt_idx ON job_attempts(status, availableAt); -- Find abandoned leases -CREATE INDEX job_attempts_status_leasedUntil_idx +CREATE INDEX job_attempts_status_leasedUntil_idx ON job_attempts(status, leasedUntil); -- Find PENDING events for publishing -CREATE INDEX outbox_events_status_publishedAt_idx +CREATE INDEX outbox_events_status_publishedAt_idx ON outbox_events(status, publishedAt); ``` @@ -415,9 +414,9 @@ Workers query with `LIMIT` to avoid full-table scans: ```typescript // Good: polling with LIMIT const lease = await jobLeaseService.leaseJob({ - jobType: "email.send", + jobType: 'email.send', maxLeaseMs: 30000, -}); +}) // This only scans first few rows before finding a PENDING job ``` @@ -430,10 +429,10 @@ Periodically archive completed events and jobs: // After 30 days, archive published events await prisma.outboxEvent.deleteMany({ where: { - status: "PUBLISHED", + status: 'PUBLISHED', publishedAt: { lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, }, -}); +}) ``` ## Troubleshooting @@ -445,9 +444,10 @@ await prisma.outboxEvent.deleteMany({ **Cause**: Worker crashed without releasing lease. **Fix**: + ```typescript -const recovered = await jobLeaseService.recoverAbandonedLeases(); -console.log(`Recovered ${recovered} abandoned leases`); +const recovered = await jobLeaseService.recoverAbandonedLeases() +console.log(`Recovered ${recovered} abandoned leases`) ``` ### Dead-Letter Accumulation @@ -457,13 +457,14 @@ console.log(`Recovered ${recovered} abandoned leases`); **Cause**: Transient issue (network, database, service down) exhausted retries. **Fix**: + 1. Identify root cause from `job.lastError` 2. Fix underlying issue 3. Reset jobs for retry: ```typescript - const deadLetterJobs = await jobLeaseService.getDeadLetterJobs(100); + const deadLetterJobs = await jobLeaseService.getDeadLetterJobs(100) for (const job of deadLetterJobs) { - await jobLeaseService.resetJobForRetry(job.id); + await jobLeaseService.resetJobForRetry(job.id) } ``` @@ -474,16 +475,17 @@ console.log(`Recovered ${recovered} abandoned leases`); **Cause**: No workers running for specific jobType. **Fix**: + 1. Verify worker is running: `ps aux | grep worker` 2. Check worker logs for errors 3. Manually check job status: ```typescript - const jobs = await jobLeaseService.getJobsForEvent(eventId); - console.log(jobs); + const jobs = await jobLeaseService.getJobsForEvent(eventId) + console.log(jobs) ``` ## References - [Transactional Outbox Pattern - Chris Richardson](https://microservices.io/patterns/data/transactional-outbox.html) - [Event Sourcing - Martin Fowler](https://martinfowler.com/eaaDev/EventSourcing.html) -- [Lease-based Concurrency Control](https://en.wikipedia.org/wiki/Lease_(computer_science)) +- [Lease-based Concurrency Control]() diff --git a/src/lib/transactions/event-schema.ts b/src/lib/transactions/event-schema.ts index 79bdef8d..583a3862 100644 --- a/src/lib/transactions/event-schema.ts +++ b/src/lib/transactions/event-schema.ts @@ -55,14 +55,14 @@ export class EventSchemaRegistry { async validate( eventType: string, eventVersion: number, - payload: unknown + payload: unknown, ): Promise { const key = `${eventType}:v${eventVersion}` const schema = this.schemas.get(key) if (!schema) { throw new Error( - `No schema registered for ${eventType} version ${eventVersion}` + `No schema registered for ${eventType} version ${eventVersion}`, ) } @@ -71,7 +71,7 @@ export class EventSchemaRegistry { } catch (error) { const message = error instanceof Error ? error.message : String(error) const err = new Error( - `Event payload validation failed for ${eventType} v${eventVersion}: ${message}` + `Event payload validation failed for ${eventType} v${eventVersion}: ${message}`, ) if (error instanceof Error) { err.cause = error @@ -301,7 +301,7 @@ export function registerBuiltInSchemas(): void { export function createEventSchema( eventType: string, version: number, - zodSchema: z.ZodSchema + zodSchema: z.ZodSchema, ): EventSchema { return { eventType, diff --git a/src/lib/transactions/handler-registry.ts b/src/lib/transactions/handler-registry.ts index 7cb55d0f..5501a6e7 100644 --- a/src/lib/transactions/handler-registry.ts +++ b/src/lib/transactions/handler-registry.ts @@ -12,7 +12,7 @@ export class UnknownEventTypeError extends Error { constructor(handlerName: string, eventType: string, eventVersion: number) { super( `Outbox handler "${handlerName}" targets ${eventType} v${eventVersion}, ` + - 'which has no registered event schema' + 'which has no registered event schema', ) this.name = 'UnknownEventTypeError' } @@ -22,7 +22,7 @@ export class UnhandledEventTypeError extends Error { constructor(missing: string[]) { super( `No outbox handler registered for emitted event type(s): ${missing.join(', ')}. ` + - 'Register a handler or stop emitting the event.' + 'Register a handler or stop emitting the event.', ) this.name = 'UnhandledEventTypeError' } @@ -36,7 +36,9 @@ export class OutboxHandlerRegistry { private readonly byEvent = new Map() private readonly byName = new Map() - constructor(private readonly schemas: EventSchemaRegistry = getEventSchemaRegistry()) {} + constructor( + private readonly schemas: EventSchemaRegistry = getEventSchemaRegistry(), + ) {} register(handler: OutboxEventHandler): void { if (this.byName.has(handler.name)) { @@ -44,7 +46,11 @@ export class OutboxHandlerRegistry { } if (!this.schemas.has(handler.eventType, handler.eventVersion)) { - throw new UnknownEventTypeError(handler.name, handler.eventType, handler.eventVersion) + throw new UnknownEventTypeError( + handler.name, + handler.eventType, + handler.eventVersion, + ) } const key = keyOf(handler.eventType, handler.eventVersion) @@ -67,7 +73,11 @@ export class OutboxHandlerRegistry { return [...this.byName.keys()].sort() } - describe(): Array<{ eventType: string; eventVersion: number; handlers: string[] }> { + describe(): Array<{ + eventType: string + eventVersion: number + handlers: string[] + }> { return [...this.byEvent.entries()] .map(([key, handlers]) => { const [eventType, version] = key.split(':v') @@ -75,16 +85,18 @@ export class OutboxHandlerRegistry { return { eventType, eventVersion: Number(version), - handlers: handlers.map(h => h.name).sort(), + handlers: handlers.map((h) => h.name).sort(), } }) .sort((a, b) => a.eventType.localeCompare(b.eventType)) } - assertHandlersFor(emitted: Array<{ eventType: string; eventVersion: number }>): void { + assertHandlersFor( + emitted: Array<{ eventType: string; eventVersion: number }>, + ): void { const missing = emitted - .filter(e => this.handlersFor(e.eventType, e.eventVersion).length === 0) - .map(e => keyOf(e.eventType, e.eventVersion)) + .filter((e) => this.handlersFor(e.eventType, e.eventVersion).length === 0) + .map((e) => keyOf(e.eventType, e.eventVersion)) if (missing.length > 0) { throw new UnhandledEventTypeError(missing) diff --git a/src/lib/transactions/job-lease.service.ts b/src/lib/transactions/job-lease.service.ts index 3f3fae6a..3868811c 100644 --- a/src/lib/transactions/job-lease.service.ts +++ b/src/lib/transactions/job-lease.service.ts @@ -156,7 +156,7 @@ export class JobLeaseService { async completeJob( jobId: string, leaseToken: string, - result: JobResult + result: JobResult, ): Promise { await this.prisma.$transaction(async (tx) => { // Verify lease token and update job to COMPLETED @@ -177,7 +177,7 @@ export class JobLeaseService { if (updated.count === 0) { throw new Error( - `Job ${jobId} lease mismatch or already completed (token: ${leaseToken})` + `Job ${jobId} lease mismatch or already completed (token: ${leaseToken})`, ) } @@ -228,10 +228,12 @@ export class JobLeaseService { async failJob( jobId: string, leaseToken: string, - error: Error | string + error: Error | string, ): Promise { const errorMessage = - error instanceof Error ? `${error.message}\n${error.stack}` : String(error) + error instanceof Error + ? `${error.message}\n${error.stack}` + : String(error) await this.prisma.$transaction(async (tx) => { // Get current job to check attempt count @@ -245,7 +247,7 @@ export class JobLeaseService { if (job.leaseToken !== leaseToken) { throw new Error( - `Job ${jobId} lease mismatch (provided: ${leaseToken}, held: ${job.leaseToken})` + `Job ${jobId} lease mismatch (provided: ${leaseToken}, held: ${job.leaseToken})`, ) } @@ -271,7 +273,8 @@ export class JobLeaseService { }) } else { // Calculate exponential backoff - const delayMs = job.backoffBaseMs * Math.pow(job.backoffMultiplier, nextAttempt) + const delayMs = + job.backoffBaseMs * Math.pow(job.backoffMultiplier, nextAttempt) const availableAt = new Date(Date.now() + delayMs) // Retry with backoff @@ -317,7 +320,7 @@ export class JobLeaseService { } async acquireQueueLease( - options: AcquireQueueLeaseOptions + options: AcquireQueueLeaseOptions, ): Promise { const leaseMs = options.leaseMs ?? DEFAULT_QUEUE_LEASE_MS const leaseToken = randomUUID() @@ -359,7 +362,7 @@ export class JobLeaseService { async renewQueueLease( queueName: string, leaseToken: string, - leaseMs: number = DEFAULT_QUEUE_LEASE_MS + leaseMs: number = DEFAULT_QUEUE_LEASE_MS, ): Promise { const updated = await this.prisma.$executeRaw` UPDATE "queue_leases" @@ -372,7 +375,10 @@ export class JobLeaseService { return updated > 0 } - async releaseQueueLease(queueName: string, leaseToken: string): Promise { + async releaseQueueLease( + queueName: string, + leaseToken: string, + ): Promise { const updated = await this.prisma.$executeRaw` UPDATE "queue_leases" SET "leaseToken" = NULL, diff --git a/src/lib/transactions/outbox.service.ts b/src/lib/transactions/outbox.service.ts index 622df510..589b45ea 100644 --- a/src/lib/transactions/outbox.service.ts +++ b/src/lib/transactions/outbox.service.ts @@ -9,11 +9,7 @@ */ import { PrismaClient, Prisma } from '@prisma/client' -import { - CreateOutboxEventOptions, - OutboxEvent, - JobConfig, -} from './types.js' +import { CreateOutboxEventOptions, OutboxEvent, JobConfig } from './types.js' export class OutboxService { constructor(private prisma: PrismaClient) {} @@ -46,7 +42,7 @@ export class OutboxService { */ async createEvent( tx: Prisma.TransactionClient, - options: CreateOutboxEventOptions + options: CreateOutboxEventOptions, ): Promise { return tx.outboxEvent.create({ data: { @@ -76,7 +72,7 @@ export class OutboxService { async createJobAttempts( tx: Prisma.TransactionClient, eventId: string, - jobs: JobConfig[] + jobs: JobConfig[], ): Promise { for (const job of jobs) { await tx.jobAttempt.create({ @@ -190,7 +186,7 @@ export class OutboxService { async getEventsByAggregate( aggregateId: string, aggregateType: string, - limit: number = 100 + limit: number = 100, ): Promise { return this.prisma.outboxEvent.findMany({ where: { aggregateId, aggregateType }, diff --git a/src/lib/transactions/tests/event-schema.test.ts b/src/lib/transactions/tests/event-schema.test.ts index 6bf019ca..6f942b5c 100644 --- a/src/lib/transactions/tests/event-schema.test.ts +++ b/src/lib/transactions/tests/event-schema.test.ts @@ -4,10 +4,7 @@ import { describe, it, expect } from 'vitest' import { z } from 'zod' -import { - EventSchemaRegistry, - createEventSchema, -} from '../event-schema' +import { EventSchemaRegistry, createEventSchema } from '../event-schema' describe('EventSchemaRegistry', () => { it('should create a new registry instance', () => { @@ -23,7 +20,7 @@ describe('EventSchemaRegistry', () => { z.object({ userId: z.string().uuid(), email: z.string().email(), - }) + }), ) registry.register(schema) @@ -37,7 +34,7 @@ describe('EventSchemaRegistry', () => { const schema = createEventSchema( 'UserCreated', 1, - z.object({ userId: z.string() }) + z.object({ userId: z.string() }), ) registry.register(schema) @@ -49,12 +46,12 @@ describe('EventSchemaRegistry', () => { const schema1 = createEventSchema( 'UserCreated', 1, - z.object({ userId: z.string() }) + z.object({ userId: z.string() }), ) const schema2 = createEventSchema( 'UserUpdated', 1, - z.object({ userId: z.string() }) + z.object({ userId: z.string() }), ) registry.register(schema1) @@ -72,7 +69,7 @@ describe('EventSchemaRegistry', () => { z.object({ userId: z.string().uuid(), email: z.string().email(), - }) + }), ) registry.register(schema) @@ -94,7 +91,7 @@ describe('EventSchemaRegistry', () => { z.object({ userId: z.string().uuid(), email: z.string().email(), - }) + }), ) registry.register(schema) @@ -134,12 +131,12 @@ describe('EventSchemaRegistry', () => { const schemaV1 = createEventSchema( 'UserCreated', 1, - z.object({ userId: z.string() }) + z.object({ userId: z.string() }), ) const schemaV2 = createEventSchema( 'UserCreated', 2, - z.object({ userId: z.string(), email: z.string() }) + z.object({ userId: z.string(), email: z.string() }), ) registry.register(schemaV1) diff --git a/src/lib/transactions/types.ts b/src/lib/transactions/types.ts index ded1a7a0..ab128ccb 100644 --- a/src/lib/transactions/types.ts +++ b/src/lib/transactions/types.ts @@ -183,7 +183,9 @@ export interface OutboxEventHandler { maxAttempts?: number backoffBaseMs?: number backoffMultiplier?: number - handle(context: OutboxEventHandlerContext): Promise + handle( + context: OutboxEventHandlerContext, + ): Promise } export interface AcquireQueueLeaseOptions { diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index 9f53053c..cf8c6830 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -1,248 +1,262 @@ -import { NextFunction, Request, Response } from 'express' - -import jwt from 'jsonwebtoken' -import prisma from '../config/database' -import { verifyAccessToken } from '../config/jwt' - -export type UserRole = 'learner' | 'employer'; - -export interface JwtPayload { - id: string; - email: string; - role: UserRole; - iat?: number; - exp?: number; -} - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Express { - interface Request { - user?: JwtPayload; - } - } -} - -/** - * Strict authentication — rejects requests without a valid JWT. - * Delegates to verifyAccessToken(), which pins algorithm, issuer, - * audience, and resolves the signing key via the token's kid header. - */ -export const authenticate = ( - req: Request, - res: Response, - next: NextFunction -): void => { - const authHeader = req.headers.authorization - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - res.status(401).json({ message: 'Authorization token required' }) - - return - } - - const token = authHeader.split(' ')[1] - - try { - const decoded = verifyAccessToken(token) as unknown as JwtPayload - req.user = decoded - next() - } catch (err) { - if (err instanceof jwt.TokenExpiredError) { - res.status(401).json({ message: 'Token has expired' }) - - return - } - if (err instanceof jwt.JsonWebTokenError) { - res.status(401).json({ message: 'Invalid token' }) - - return - } - res.status(500).json({ message: 'Internal server error during authentication' }) - } -} - -/** - * Optional authentication — attaches user to req if token is present and valid, - * but does not block requests without a token. - */ -export const optionalAuthenticate = ( - req: Request, - res: Response, - next: NextFunction -): void => { - const authHeader = req.headers.authorization - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return next() - } - - const token = authHeader.split(' ')[1] - - try { - const decoded = verifyAccessToken(token) as unknown as JwtPayload - req.user = decoded - } catch { /** */ } - - next() -} - -/** - * Account-status gate — must be used after `authenticate`. - * JWTs are stateless, so tokens issued before deactivation or a deletion - * request stay verifiable until expiry; this middleware checks the current - * account status in the database and only lets ACTIVE accounts through. - */ -export const requireActiveAccount = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - if (!req.user) { - res.status(401).json({ message: 'Authentication required' }) - - return - } - - try { - const user = await prisma.user.findUnique({ - where: { id: req.user.id }, - select: { status: true }, - }) - - if (!user || user.status === 'DELETED') { - res.status(401).json({ message: 'Account not found' }) - - return - } - - if (user.status === 'DEACTIVATED') { - res.status(403).json({ - message: 'Account is deactivated', - code: 'ACCOUNT_DEACTIVATED', - }) - - return - } - - if (user.status === 'PENDING_DELETION') { - res.status(403).json({ - message: 'Account is scheduled for deletion', - code: 'ACCOUNT_PENDING_DELETION', - }) - - return - } - - next() - } catch { - res.status(500).json({ message: 'Internal server error during account status check' }) - } -} - -/** - * Verified-email gate — must be used after `authenticate`. - * Blocks operations that the platform's verification policy (see - * docs/AUTH_POLICY.md) requires a confirmed email address for. - */ -export const requireVerifiedEmail = async ( - req: Request, - res: Response, - next: NextFunction -): Promise => { - if (!req.user) { - res.status(401).json({ message: 'Authentication required' }) - - return - } - - try { - const user = await prisma.user.findUnique({ - where: { id: req.user.id }, - select: { isVerified: true }, - }) - - if (!user) { - res.status(401).json({ message: 'Account not found' }) - - return - } - - if (!user.isVerified) { - res.status(403).json({ - message: 'This action requires a verified email address', - code: 'EMAIL_NOT_VERIFIED', - }) - - return - } - - next() - } catch { - res.status(500).json({ message: 'Internal server error during verification check' }) - } -} - -/** - * Role-based authorization — must be used after `authenticate`. - * Re-reads the user's role and status from the database rather than - * trusting the JWT claim: a role change or account status change must - * take effect immediately, not only once the old token expires. - */ -export const authorize = (...roles: UserRole[]) => { - return async (req: Request, res: Response, next: NextFunction): Promise => { - if (!req.user) { - res.status(401).json({ message: 'Authentication required' }) - - return - } - - try { - const current = await prisma.user.findUnique({ - where: { id: req.user.id }, - select: { role: true, status: true }, - }) - - if (!current || current.status === 'DELETED') { - res.status(401).json({ message: 'Account not found' }) - - return - } - - if (current.status === 'DEACTIVATED') { - res.status(403).json({ - message: 'Account is deactivated', - code: 'ACCOUNT_DEACTIVATED', - }) - - return - } - - if (current.status === 'PENDING_DELETION') { - res.status(403).json({ - message: 'Account is scheduled for deletion', - code: 'ACCOUNT_PENDING_DELETION', - }) - - return - } - - const persistedRole = current.role as UserRole - - if (!roles.includes(persistedRole)) { - res.status(403).json({ - message: `Access denied. Requires one of the following roles: ${roles.join(', ')}`, - }) - - return - } - - // Keep the persisted role in sync for downstream handlers, - // in case it drifted from the (now-stale) JWT claim. - req.user.role = persistedRole - next() - } catch { - res.status(500).json({ message: 'Internal server error during authorization' }) - } - } -} +import { NextFunction, Request, Response } from 'express' + +import jwt from 'jsonwebtoken' +import prisma from '../config/database' +import { verifyAccessToken } from '../config/jwt' + +export type UserRole = 'learner' | 'employer' + +export interface JwtPayload { + id: string + email: string + role: UserRole + iat?: number + exp?: number +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + user?: JwtPayload + } + } +} + +/** + * Strict authentication — rejects requests without a valid JWT. + * Delegates to verifyAccessToken(), which pins algorithm, issuer, + * audience, and resolves the signing key via the token's kid header. + */ +export const authenticate = ( + req: Request, + res: Response, + next: NextFunction, +): void => { + const authHeader = req.headers.authorization + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ message: 'Authorization token required' }) + + return + } + + const token = authHeader.split(' ')[1] + + try { + const decoded = verifyAccessToken(token) as unknown as JwtPayload + req.user = decoded + next() + } catch (err) { + if (err instanceof jwt.TokenExpiredError) { + res.status(401).json({ message: 'Token has expired' }) + + return + } + if (err instanceof jwt.JsonWebTokenError) { + res.status(401).json({ message: 'Invalid token' }) + + return + } + res + .status(500) + .json({ message: 'Internal server error during authentication' }) + } +} + +/** + * Optional authentication — attaches user to req if token is present and valid, + * but does not block requests without a token. + */ +export const optionalAuthenticate = ( + req: Request, + res: Response, + next: NextFunction, +): void => { + const authHeader = req.headers.authorization + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return next() + } + + const token = authHeader.split(' ')[1] + + try { + const decoded = verifyAccessToken(token) as unknown as JwtPayload + req.user = decoded + } catch { + /** */ + } + + next() +} + +/** + * Account-status gate — must be used after `authenticate`. + * JWTs are stateless, so tokens issued before deactivation or a deletion + * request stay verifiable until expiry; this middleware checks the current + * account status in the database and only lets ACTIVE accounts through. + */ +export const requireActiveAccount = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + if (!req.user) { + res.status(401).json({ message: 'Authentication required' }) + + return + } + + try { + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { status: true }, + }) + + if (!user || user.status === 'DELETED') { + res.status(401).json({ message: 'Account not found' }) + + return + } + + if (user.status === 'DEACTIVATED') { + res.status(403).json({ + message: 'Account is deactivated', + code: 'ACCOUNT_DEACTIVATED', + }) + + return + } + + if (user.status === 'PENDING_DELETION') { + res.status(403).json({ + message: 'Account is scheduled for deletion', + code: 'ACCOUNT_PENDING_DELETION', + }) + + return + } + + next() + } catch { + res + .status(500) + .json({ message: 'Internal server error during account status check' }) + } +} + +/** + * Verified-email gate — must be used after `authenticate`. + * Blocks operations that the platform's verification policy (see + * docs/AUTH_POLICY.md) requires a confirmed email address for. + */ +export const requireVerifiedEmail = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + if (!req.user) { + res.status(401).json({ message: 'Authentication required' }) + + return + } + + try { + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { isVerified: true }, + }) + + if (!user) { + res.status(401).json({ message: 'Account not found' }) + + return + } + + if (!user.isVerified) { + res.status(403).json({ + message: 'This action requires a verified email address', + code: 'EMAIL_NOT_VERIFIED', + }) + + return + } + + next() + } catch { + res + .status(500) + .json({ message: 'Internal server error during verification check' }) + } +} + +/** + * Role-based authorization — must be used after `authenticate`. + * Re-reads the user's role and status from the database rather than + * trusting the JWT claim: a role change or account status change must + * take effect immediately, not only once the old token expires. + */ +export const authorize = (...roles: UserRole[]) => { + return async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + if (!req.user) { + res.status(401).json({ message: 'Authentication required' }) + + return + } + + try { + const current = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { role: true, status: true }, + }) + + if (!current || current.status === 'DELETED') { + res.status(401).json({ message: 'Account not found' }) + + return + } + + if (current.status === 'DEACTIVATED') { + res.status(403).json({ + message: 'Account is deactivated', + code: 'ACCOUNT_DEACTIVATED', + }) + + return + } + + if (current.status === 'PENDING_DELETION') { + res.status(403).json({ + message: 'Account is scheduled for deletion', + code: 'ACCOUNT_PENDING_DELETION', + }) + + return + } + + const persistedRole = current.role as UserRole + + if (!roles.includes(persistedRole)) { + res.status(403).json({ + message: `Access denied. Requires one of the following roles: ${roles.join(', ')}`, + }) + + return + } + + // Keep the persisted role in sync for downstream handlers, + // in case it drifted from the (now-stale) JWT claim. + req.user.role = persistedRole + next() + } catch { + res + .status(500) + .json({ message: 'Internal server error during authorization' }) + } + } +} diff --git a/src/middleware/error.middleware.ts b/src/middleware/error.middleware.ts index 6d994f53..397d477b 100644 --- a/src/middleware/error.middleware.ts +++ b/src/middleware/error.middleware.ts @@ -40,7 +40,7 @@ export const errorHandler = ( req: Request, res: Response, // eslint-disable-next-line @typescript-eslint/no-unused-vars - next?: NextFunction + next?: NextFunction, ): void => { let error = err @@ -64,8 +64,7 @@ export const errorHandler = ( } const statusCode = (error as AppError).statusCode || 500 - const code = - (error as AppError).code || mapStatusCodeToErrorCode(statusCode) + const code = (error as AppError).code || mapStatusCodeToErrorCode(statusCode) const isDevelopment = env.NODE_ENV === 'development' const errorResponse: any = { @@ -104,7 +103,7 @@ export const errorHandler = ( export const notFoundHandler = ( req: Request, res: Response, - next: NextFunction + next: NextFunction, ): void => { const notFound = new NotFoundError(`Cannot ${req.method} ${req.path}`) const requestId = req.requestId || 'unknown' @@ -125,7 +124,7 @@ export const notFoundHandler = ( * Prevents unhandled promise rejections */ export const asyncHandler = ( - fn: (req: Request, res: Response, next: NextFunction) => Promise + fn: (req: Request, res: Response, next: NextFunction) => Promise, ) => { return (req: Request, res: Response, next: NextFunction) => { Promise.resolve(fn(req, res, next)).catch((error) => { @@ -144,4 +143,4 @@ export const asyncHandler = ( next(error) }) } -} \ No newline at end of file +} diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index caec39df..1eae746b 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,14 +1,10 @@ -import { Request, Response } from 'express' - -export const errorHandler = ( - err: any, - req: Request, - res: Response, -) => { - const statusCode = err.status || 500 - - res.status(statusCode).json({ - success: false, - message: err.message || 'Internal Server Error' - }) -} \ No newline at end of file +import { Request, Response } from 'express' + +export const errorHandler = (err: any, req: Request, res: Response) => { + const statusCode = err.status || 500 + + res.status(statusCode).json({ + success: false, + message: err.message || 'Internal Server Error', + }) +} diff --git a/src/middleware/rate-limit.middleware.ts b/src/middleware/rate-limit.middleware.ts index 6e719bcc..e9246d18 100644 --- a/src/middleware/rate-limit.middleware.ts +++ b/src/middleware/rate-limit.middleware.ts @@ -1,134 +1,149 @@ -import { NextFunction, Request, Response } from 'express' - -import { env } from '../config/env' - -interface RateLimitOptions { - windowMs: number; - max: number; - message?: string; - skipSuccessfulRequests?: boolean; - skipFailedRequests?: boolean; -} - -interface RateLimitData { - count: number; - resetTime: number; -} - -function createStore () { - return new Map() -} - -function getClientIP (req: Request): string { - return (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || - (req.headers['x-real-ip'] as string) || - req.connection.remoteAddress || - req.socket.remoteAddress || - 'unknown' -} - -function createRateLimiter (options: RateLimitOptions, store: Map, weakStore?: WeakMap) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { windowMs, max, message = 'Too many requests, please try again later.', skipSuccessfulRequests = false, skipFailedRequests = false } = options - const isTest = process.env.NODE_ENV === 'test' - - return (req: Request, res: Response, next: NextFunction) => { - const key = `${getClientIP(req)}:${req.originalUrl}` - const now = Date.now() - let data = isTest && weakStore ? weakStore.get(req) : store.get(key) - - if (!data || data.resetTime < now) { - data = { count: 0, resetTime: now + windowMs } - } - - data.count++ - - if (data.count > max) { - res.set({ - 'X-RateLimit-Limit': max.toString(), - 'X-RateLimit-Remaining': '0', - 'X-RateLimit-Reset': new Date(data.resetTime).toISOString(), - 'Retry-After': Math.ceil((data.resetTime - now) / 1000).toString(), - }) - - return res.status(429).json({ error: message }) - } - - if (isTest && weakStore) { - weakStore.set(req, data) - } else { - store.set(key, data) - } - - res.set({ - 'X-RateLimit-Limit': max.toString(), - 'X-RateLimit-Remaining': (max - data.count).toString(), - 'X-RateLimit-Reset': new Date(data.resetTime).toISOString(), - }) - - next() - } -} - -// General rate limiter for all routes -export const generalLimiter = createRateLimiter( - { - windowMs: env.RATE_LIMIT_GENERAL_WINDOW_MS, - max: env.RATE_LIMIT_GENERAL_MAX, - }, - createStore(), - new WeakMap() -) - -// Strict limiter for auth endpoints -export const authLimiter = createRateLimiter( - { - windowMs: env.RATE_LIMIT_AUTH_WINDOW_MS, - max: env.RATE_LIMIT_AUTH_MAX, - }, - createStore(), - new WeakMap() -) - -// Strict limiter for phone OTP endpoints (request + verify) -export const otpLimiter = createRateLimiter( - { - windowMs: env.RATE_LIMIT_OTP_WINDOW_MS, - max: env.RATE_LIMIT_OTP_MAX, - }, - createStore(), - new WeakMap() -) - -// Employer-specific limiter with higher limits -export const employerLimiter = createRateLimiter( - { - windowMs: env.RATE_LIMIT_EMPLOYER_WINDOW_MS, - max: env.RATE_LIMIT_EMPLOYER_MAX, - }, - createStore(), - new WeakMap() -) - -// Authenticated users limiter with higher limits -export const authenticatedLimiter = createRateLimiter( - { - windowMs: env.RATE_LIMIT_AUTHENTICATED_WINDOW_MS, - max: env.RATE_LIMIT_AUTHENTICATED_MAX, - }, - createStore(), - new WeakMap() -) - -// Middleware to choose limiter based on user type -export function dynamicRateLimiter (req: Request, res: Response, next: NextFunction) { - // Assuming req.user is set by auth middleware - const user = (req as any).user - if (user && user.role === 'employer') { - return employerLimiter(req, res, next) - } else if (user) { - return authenticatedLimiter(req, res, next) - } else { - return generalLimiter(req, res, next) - } -} +import { NextFunction, Request, Response } from 'express' + +import { env } from '../config/env' + +interface RateLimitOptions { + windowMs: number + max: number + message?: string + skipSuccessfulRequests?: boolean + skipFailedRequests?: boolean +} + +interface RateLimitData { + count: number + resetTime: number +} + +function createStore() { + return new Map() +} + +function getClientIP(req: Request): string { + return ( + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + (req.headers['x-real-ip'] as string) || + req.connection.remoteAddress || + req.socket.remoteAddress || + 'unknown' + ) +} + +function createRateLimiter( + options: RateLimitOptions, + store: Map, + weakStore?: WeakMap, +) { + const { + windowMs, + max, + message = 'Too many requests, please try again later.', + skipSuccessfulRequests = false, + skipFailedRequests = false, + } = options + const isTest = process.env.NODE_ENV === 'test' + + return (req: Request, res: Response, next: NextFunction) => { + const key = `${getClientIP(req)}:${req.originalUrl}` + const now = Date.now() + let data = isTest && weakStore ? weakStore.get(req) : store.get(key) + + if (!data || data.resetTime < now) { + data = { count: 0, resetTime: now + windowMs } + } + + data.count++ + + if (data.count > max) { + res.set({ + 'X-RateLimit-Limit': max.toString(), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': new Date(data.resetTime).toISOString(), + 'Retry-After': Math.ceil((data.resetTime - now) / 1000).toString(), + }) + + return res.status(429).json({ error: message }) + } + + if (isTest && weakStore) { + weakStore.set(req, data) + } else { + store.set(key, data) + } + + res.set({ + 'X-RateLimit-Limit': max.toString(), + 'X-RateLimit-Remaining': (max - data.count).toString(), + 'X-RateLimit-Reset': new Date(data.resetTime).toISOString(), + }) + + next() + } +} + +// General rate limiter for all routes +export const generalLimiter = createRateLimiter( + { + windowMs: env.RATE_LIMIT_GENERAL_WINDOW_MS, + max: env.RATE_LIMIT_GENERAL_MAX, + }, + createStore(), + new WeakMap(), +) + +// Strict limiter for auth endpoints +export const authLimiter = createRateLimiter( + { + windowMs: env.RATE_LIMIT_AUTH_WINDOW_MS, + max: env.RATE_LIMIT_AUTH_MAX, + }, + createStore(), + new WeakMap(), +) + +// Strict limiter for phone OTP endpoints (request + verify) +export const otpLimiter = createRateLimiter( + { + windowMs: env.RATE_LIMIT_OTP_WINDOW_MS, + max: env.RATE_LIMIT_OTP_MAX, + }, + createStore(), + new WeakMap(), +) + +// Employer-specific limiter with higher limits +export const employerLimiter = createRateLimiter( + { + windowMs: env.RATE_LIMIT_EMPLOYER_WINDOW_MS, + max: env.RATE_LIMIT_EMPLOYER_MAX, + }, + createStore(), + new WeakMap(), +) + +// Authenticated users limiter with higher limits +export const authenticatedLimiter = createRateLimiter( + { + windowMs: env.RATE_LIMIT_AUTHENTICATED_WINDOW_MS, + max: env.RATE_LIMIT_AUTHENTICATED_MAX, + }, + createStore(), + new WeakMap(), +) + +// Middleware to choose limiter based on user type +export function dynamicRateLimiter( + req: Request, + res: Response, + next: NextFunction, +) { + // Assuming req.user is set by auth middleware + const user = (req as any).user + if (user && user.role === 'employer') { + return employerLimiter(req, res, next) + } else if (user) { + return authenticatedLimiter(req, res, next) + } else { + return generalLimiter(req, res, next) + } +} diff --git a/src/middleware/request-context.ts b/src/middleware/request-context.ts index e205803a..58b7f4ea 100644 --- a/src/middleware/request-context.ts +++ b/src/middleware/request-context.ts @@ -27,7 +27,7 @@ declare global { export const requestContext = ( req: Request, res: Response, - next: NextFunction + next: NextFunction, ): void => { // Generate or use existing request ID const requestId = (req.headers['x-request-id'] as string) || randomUUID() diff --git a/src/middleware/validation.middleware.ts b/src/middleware/validation.middleware.ts index 9291ebec..3364eaeb 100644 --- a/src/middleware/validation.middleware.ts +++ b/src/middleware/validation.middleware.ts @@ -14,8 +14,8 @@ const formatZodMessages = (issues: any[] = []) => { if (msg === 'Required') { if (path === 'currentPassword') return 'Current password is required' if (path === 'newPassword') return 'New password is required' - -return 'Required' + + return 'Required' } return msg @@ -35,7 +35,7 @@ export const commonSchemas = { .regex(/(?=.*\d)/, 'Password must contain at least one number') .regex( /(?=.*[@$!%*?&])/, - 'Password must contain at least one special character' + 'Password must contain at least one special character', ), id: z.string().uuid('Invalid ID format'), username: z @@ -44,7 +44,7 @@ export const commonSchemas = { .max(30, 'Username must be less than 30 characters') .regex( /^[a-zA-Z0-9_]+$/, - 'Username can only contain letters, numbers, and underscores' + 'Username can only contain letters, numbers, and underscores', ), walletAddress: z .string() @@ -150,10 +150,7 @@ export const validateProfileUpdate = validate({ .string() .max(50, 'Last name must be less than 50 characters') .optional(), - bio: z - .string() - .max(500, 'Bio must be less than 500 characters') - .optional(), + bio: z.string().max(500, 'Bio must be less than 500 characters').optional(), avatar: commonSchemas.url.optional(), }), }) @@ -170,7 +167,7 @@ export const validatePasswordChange = validate({ { message: 'New password must be different from current password', path: ['newPassword'], - } + }, ), }) @@ -178,4 +175,4 @@ export const validateWalletAddress = validate({ body: z.object({ walletAddress: commonSchemas.walletAddress, }), -}) \ No newline at end of file +}) diff --git a/src/middleware/versioning.middleware.ts b/src/middleware/versioning.middleware.ts index e66cfcdd..d7a3bc5a 100644 --- a/src/middleware/versioning.middleware.ts +++ b/src/middleware/versioning.middleware.ts @@ -11,7 +11,7 @@ export interface DeprecationOptions { export const apiVersionHeader = ( req: Request, res: Response, - next: NextFunction + next: NextFunction, ): void => { res.setHeader('X-API-Version', 'v1') next() @@ -22,7 +22,7 @@ export const apiVersionHeader = ( */ export const setDeprecationHeaders = ( res: Response, - options: DeprecationOptions = {} + options: DeprecationOptions = {}, ): void => { res.setHeader('Deprecation', 'true') diff --git a/src/routes/health.routes.ts b/src/routes/health.routes.ts index 19160d5a..85967b8c 100644 --- a/src/routes/health.routes.ts +++ b/src/routes/health.routes.ts @@ -99,7 +99,9 @@ router.get('/ready', async (req: Request, res: Response) => { checks.database = 'ok' } catch (error) { checks.database = 'error' - errors.push(`Database connection failed: ${error instanceof Error ? error.message : 'unknown error'}`) + errors.push( + `Database connection failed: ${error instanceof Error ? error.message : 'unknown error'}`, + ) isReady = false } diff --git a/src/routes/v1/account.routes.ts b/src/routes/v1/account.routes.ts index 14c92855..592962ce 100644 --- a/src/routes/v1/account.routes.ts +++ b/src/routes/v1/account.routes.ts @@ -1,6 +1,9 @@ import { Router } from 'express' import { AccountController } from '../../controllers/account.controller' -import { authenticate, requireActiveAccount } from '../../middleware/auth.middleware' +import { + authenticate, + requireActiveAccount, +} from '../../middleware/auth.middleware' import { authLimiter } from '../../middleware/rate-limit.middleware' const router: Router = Router() @@ -11,55 +14,89 @@ const accountController = new AccountController() * @desc Request an asynchronous data export * @access Private (active accounts only) */ -router.post('/export', authenticate, requireActiveAccount, accountController.requestExport.bind(accountController)) +router.post( + '/export', + authenticate, + requireActiveAccount, + accountController.requestExport.bind(accountController), +) /** * @route GET /api/v1/account/export/:id * @desc Get export request status (allowed while deactivated/pending deletion) * @access Private */ -router.get('/export/:id', authenticate, accountController.getExportStatus.bind(accountController)) +router.get( + '/export/:id', + authenticate, + accountController.getExportStatus.bind(accountController), +) /** * @route GET /api/v1/account/export/:id/download * @desc Download a ready export artifact (allowed while deactivated/pending deletion) * @access Private */ -router.get('/export/:id/download', authenticate, accountController.downloadExport.bind(accountController)) +router.get( + '/export/:id/download', + authenticate, + accountController.downloadExport.bind(accountController), +) /** * @route POST /api/v1/account/deactivate * @desc Deactivate account (step-up: password re-entry) * @access Private */ -router.post('/deactivate', authenticate, accountController.deactivate.bind(accountController)) +router.post( + '/deactivate', + authenticate, + accountController.deactivate.bind(accountController), +) /** * @route POST /api/v1/account/reactivate * @desc Reactivate a deactivated account (deactivated accounts cannot log in) * @access Public (credentialed) */ -router.post('/reactivate', authLimiter, accountController.reactivate.bind(accountController)) +router.post( + '/reactivate', + authLimiter, + accountController.reactivate.bind(accountController), +) /** * @route POST /api/v1/account/deletion * @desc Request account deletion with cooling-off window (step-up: password re-entry) * @access Private (active accounts only) */ -router.post('/deletion', authenticate, requireActiveAccount, accountController.requestDeletion.bind(accountController)) +router.post( + '/deletion', + authenticate, + requireActiveAccount, + accountController.requestDeletion.bind(accountController), +) /** * @route GET /api/v1/account/deletion * @desc Get latest deletion request status (allowed while pending deletion) * @access Private */ -router.get('/deletion', authenticate, accountController.getDeletionStatus.bind(accountController)) +router.get( + '/deletion', + authenticate, + accountController.getDeletionStatus.bind(accountController), +) /** * @route POST /api/v1/account/deletion/cancel * @desc Cancel a pending deletion request (accounts pending deletion cannot log in) * @access Public (credentialed) */ -router.post('/deletion/cancel', authLimiter, accountController.cancelDeletion.bind(accountController)) +router.post( + '/deletion/cancel', + authLimiter, + accountController.cancelDeletion.bind(accountController), +) export default router diff --git a/src/routes/v1/auth.routes.ts b/src/routes/v1/auth.routes.ts index 083f6176..5f715c87 100644 --- a/src/routes/v1/auth.routes.ts +++ b/src/routes/v1/auth.routes.ts @@ -11,7 +11,11 @@ const authController = new AuthController() * @desc Register a new user * @access Public */ -router.post('/register', authLimiter, authController.register.bind(authController)) +router.post( + '/register', + authLimiter, + authController.register.bind(authController), +) /** * @route POST /api/v1/auth/login @@ -53,34 +57,56 @@ router.post('/verify-email', authController.verifyEmail.bind(authController)) * @desc Resend verification email * @access Public */ -router.post('/resend-verification', authLimiter, authController.resendVerification.bind(authController)) +router.post( + '/resend-verification', + authLimiter, + authController.resendVerification.bind(authController), +) /** * @route POST /api/v1/auth/forgot-password * @desc Request password reset email * @access Public */ -router.post('/forgot-password', authLimiter, authController.forgotPassword.bind(authController)) +router.post( + '/forgot-password', + authLimiter, + authController.forgotPassword.bind(authController), +) /** * @route POST /api/v1/auth/reset-password * @desc Reset password with token * @access Public */ -router.post('/reset-password', authLimiter, authController.resetPassword.bind(authController)) +router.post( + '/reset-password', + authLimiter, + authController.resetPassword.bind(authController), +) /** * @route POST /api/v1/auth/otp/request * @desc Request a phone OTP code (login if unauthenticated, phone verification if authenticated) * @access Public (optional Bearer token) */ -router.post('/otp/request', otpLimiter, optionalAuthenticate, authController.requestOtp.bind(authController)) +router.post( + '/otp/request', + otpLimiter, + optionalAuthenticate, + authController.requestOtp.bind(authController), +) /** * @route POST /api/v1/auth/otp/verify * @desc Verify a phone OTP code (completes login if unauthenticated, phone verification if authenticated) * @access Public (optional Bearer token) */ -router.post('/otp/verify', otpLimiter, optionalAuthenticate, authController.verifyOtp.bind(authController)) +router.post( + '/otp/verify', + otpLimiter, + optionalAuthenticate, + authController.verifyOtp.bind(authController), +) export default router diff --git a/src/routes/v1/avatar.routes.ts b/src/routes/v1/avatar.routes.ts index b8fc856c..a4f51874 100644 --- a/src/routes/v1/avatar.routes.ts +++ b/src/routes/v1/avatar.routes.ts @@ -23,29 +23,20 @@ router.post( * @desc Finalize an uploaded avatar (validate, produce variants, promote) * @access Private */ -router.post( - '/finalize', - avatarController.finalize.bind(avatarController), -) +router.post('/finalize', avatarController.finalize.bind(avatarController)) /** * @route GET /api/v1/users/me/avatar * @desc Get the current avatar with variant URLs * @access Private */ -router.get( - '/', - avatarController.getCurrentAvatar.bind(avatarController), -) +router.get('/', avatarController.getCurrentAvatar.bind(avatarController)) /** * @route DELETE /api/v1/users/me/avatar * @desc Delete the current avatar and all variants * @access Private */ -router.delete( - '/', - avatarController.deleteAvatar.bind(avatarController), -) +router.delete('/', avatarController.deleteAvatar.bind(avatarController)) export default router diff --git a/src/routes/v1/consent.routes.ts b/src/routes/v1/consent.routes.ts index ee333b67..66f2a2a2 100644 --- a/src/routes/v1/consent.routes.ts +++ b/src/routes/v1/consent.routes.ts @@ -5,12 +5,28 @@ import { authenticate } from '../../middleware/auth.middleware' const router: Router = Router() const consentController = new ConsentController() -router.get('/', authenticate, consentController.getCurrent.bind(consentController)) +router.get( + '/', + authenticate, + consentController.getCurrent.bind(consentController), +) -router.get('/history', authenticate, consentController.getHistory.bind(consentController)) +router.get( + '/history', + authenticate, + consentController.getHistory.bind(consentController), +) -router.post('/grant', authenticate, consentController.grant.bind(consentController)) +router.post( + '/grant', + authenticate, + consentController.grant.bind(consentController), +) -router.post('/withdraw', authenticate, consentController.withdraw.bind(consentController)) +router.post( + '/withdraw', + authenticate, + consentController.withdraw.bind(consentController), +) export default router diff --git a/src/routes/v1/credentials.routes.ts b/src/routes/v1/credentials.routes.ts index 2c8b4869..e7786992 100644 --- a/src/routes/v1/credentials.routes.ts +++ b/src/routes/v1/credentials.routes.ts @@ -31,7 +31,7 @@ router.get( '/', authenticate, validate({ query: credentialQuerySchema }), - credentialController.getUserCredentials + credentialController.getUserCredentials, ) // GET /credentials/verify/:onChainId - Public verification endpoint @@ -39,7 +39,7 @@ router.get( router.get( '/verify/:onChainId', validate({ params: onChainIdSchema }), - credentialController.verifyCredential + credentialController.verifyCredential, ) // GET /credentials/:id - Get single credential by ID @@ -48,7 +48,7 @@ router.get( '/:id', authenticate, validate({ params: credentialIdSchema }), - credentialController.getCredentialById + credentialController.getCredentialById, ) export default router diff --git a/src/routes/v1/employer.routes.ts b/src/routes/v1/employer.routes.ts index e9ec3611..0c2d86a4 100644 --- a/src/routes/v1/employer.routes.ts +++ b/src/routes/v1/employer.routes.ts @@ -1,13 +1,26 @@ import { Router } from 'express' -import { contactCandidate, getCandidateProfile, searchTalent } from '../../controllers/employer.controller' -import { authenticate, authorize, requireVerifiedEmail } from '../../middleware/auth.middleware' +import { + contactCandidate, + getCandidateProfile, + searchTalent, +} from '../../controllers/employer.controller' +import { + authenticate, + authorize, + requireVerifiedEmail, +} from '../../middleware/auth.middleware' import { employerLimiter } from '../../middleware/rate-limit.middleware' const router: Router = Router() // requireVerifiedEmail: employer actions touch candidate PII, so the // employer's own email must be confirmed — see docs/AUTH_POLICY.md. -router.use(authenticate, authorize('employer'), requireVerifiedEmail, employerLimiter) +router.use( + authenticate, + authorize('employer'), + requireVerifiedEmail, + employerLimiter, +) // GET /employer/search - search talent with filters router.get('/search', searchTalent) diff --git a/src/routes/v1/modules.routes.ts b/src/routes/v1/modules.routes.ts index 7000cff9..36b03204 100644 --- a/src/routes/v1/modules.routes.ts +++ b/src/routes/v1/modules.routes.ts @@ -1,6 +1,14 @@ import { Router } from 'express' -import { authenticate, optionalAuthenticate } from '../../middleware/auth.middleware' -import { listModules, getModuleById, startModule, completeModule } from '../../controllers/module.controller' +import { + authenticate, + optionalAuthenticate, +} from '../../middleware/auth.middleware' +import { + listModules, + getModuleById, + startModule, + completeModule, +} from '../../controllers/module.controller' const router: Router = Router() @@ -20,4 +28,4 @@ router.post('/:id/start', authenticate, startModule) // Requires authentication router.post('/:id/complete', authenticate, completeModule) -export default router \ No newline at end of file +export default router diff --git a/src/routes/v1/notifications.routes.ts b/src/routes/v1/notifications.routes.ts index 670fe1b9..6310f5ba 100644 --- a/src/routes/v1/notifications.routes.ts +++ b/src/routes/v1/notifications.routes.ts @@ -10,20 +10,32 @@ const notificationController = new NotificationController() * @desc Register a device token for push notifications * @access Private */ -router.post('/devices', authenticate, notificationController.registerDevice.bind(notificationController)) +router.post( + '/devices', + authenticate, + notificationController.registerDevice.bind(notificationController), +) /** * @route PATCH /api/v1/notifications/preferences * @desc Update notification preferences * @access Private */ -router.patch('/preferences', authenticate, notificationController.updatePreferences.bind(notificationController)) +router.patch( + '/preferences', + authenticate, + notificationController.updatePreferences.bind(notificationController), +) /** * @route GET /api/v1/notifications/delivery-status * @desc Get delivery status logs for the authenticated user * @access Private */ -router.get('/delivery-status', authenticate, notificationController.getDeliveryStatus.bind(notificationController)) +router.get( + '/delivery-status', + authenticate, + notificationController.getDeliveryStatus.bind(notificationController), +) export default router diff --git a/src/routes/v1/onboarding.routes.ts b/src/routes/v1/onboarding.routes.ts index 6cd57fbf..64405aea 100644 --- a/src/routes/v1/onboarding.routes.ts +++ b/src/routes/v1/onboarding.routes.ts @@ -5,10 +5,22 @@ import { authenticate } from '../../middleware/auth.middleware' const router: Router = Router() const onboardingController = new OnboardingController() -router.get('/', authenticate, onboardingController.getProgress.bind(onboardingController)) +router.get( + '/', + authenticate, + onboardingController.getProgress.bind(onboardingController), +) -router.post('/steps', authenticate, onboardingController.saveStep.bind(onboardingController)) +router.post( + '/steps', + authenticate, + onboardingController.saveStep.bind(onboardingController), +) -router.post('/complete', authenticate, onboardingController.complete.bind(onboardingController)) +router.post( + '/complete', + authenticate, + onboardingController.complete.bind(onboardingController), +) export default router diff --git a/src/routes/v1/rewards.routes.ts b/src/routes/v1/rewards.routes.ts index 3c4df0de..7dc36e4e 100644 --- a/src/routes/v1/rewards.routes.ts +++ b/src/routes/v1/rewards.routes.ts @@ -1,6 +1,9 @@ import { Router } from 'express' import { RewardController } from '../../controllers/reward.controller' -import { authenticate, requireVerifiedEmail } from '../../middleware/auth.middleware' +import { + authenticate, + requireVerifiedEmail, +} from '../../middleware/auth.middleware' const router: Router = Router() const rewardController = new RewardController() diff --git a/src/routes/v1/sessions.routes.ts b/src/routes/v1/sessions.routes.ts index 2c556a9e..565baa4e 100644 --- a/src/routes/v1/sessions.routes.ts +++ b/src/routes/v1/sessions.routes.ts @@ -1,6 +1,9 @@ import { Router } from 'express' import { SessionController } from '../../controllers/session.controller' -import { authenticate, requireActiveAccount } from '../../middleware/auth.middleware' +import { + authenticate, + requireActiveAccount, +} from '../../middleware/auth.middleware' const router: Router = Router() const sessionController = new SessionController() @@ -13,10 +16,7 @@ router.use(authenticate, requireActiveAccount) * @desc List the authenticated user's active sessions (paginated) * @access Private (active accounts only) */ -router.get( - '/', - sessionController.listSessions.bind(sessionController) -) +router.get('/', sessionController.listSessions.bind(sessionController)) /** * @route DELETE /api/v1/sessions @@ -25,7 +25,7 @@ router.get( */ router.delete( '/', - sessionController.revokeAllOtherSessions.bind(sessionController) + sessionController.revokeAllOtherSessions.bind(sessionController), ) /** @@ -35,7 +35,7 @@ router.delete( */ router.delete( '/:sessionId', - sessionController.revokeSession.bind(sessionController) + sessionController.revokeSession.bind(sessionController), ) export default router diff --git a/src/routes/v1/users.routes.ts b/src/routes/v1/users.routes.ts index 5845c20c..06eb5a25 100644 --- a/src/routes/v1/users.routes.ts +++ b/src/routes/v1/users.routes.ts @@ -2,7 +2,10 @@ import express, { Router } from 'express' import { UserController } from '../../controllers/user.controller' import { PreferenceController } from '../../controllers/preference.controller' import { ProfileController } from '../../controllers/profile.controller' -import { authenticate, optionalAuthenticate } from '../../middleware/auth.middleware' +import { + authenticate, + optionalAuthenticate, +} from '../../middleware/auth.middleware' import avatarRoutes from './avatar.routes' const router: express.Router = Router() @@ -16,25 +19,61 @@ const profileController = new ProfileController() // middleware is deliberately not mounted here: it validated the mock-era // firstName/lastName/bio/avatar body, none of which is a persisted column. -router.get('/me', authenticate, userController.getCurrentUser.bind(userController)) +router.get( + '/me', + authenticate, + userController.getCurrentUser.bind(userController), +) -router.patch('/me', authenticate, userController.updateProfile.bind(userController)) +router.patch( + '/me', + authenticate, + userController.updateProfile.bind(userController), +) -router.get('/me/preferences', authenticate, preferenceController.getPreferences.bind(preferenceController)) +router.get( + '/me/preferences', + authenticate, + preferenceController.getPreferences.bind(preferenceController), +) -router.patch('/me/preferences', authenticate, preferenceController.updatePreferences.bind(preferenceController)) +router.patch( + '/me/preferences', + authenticate, + preferenceController.updatePreferences.bind(preferenceController), +) -router.get('/me/profile', authenticate, profileController.getMyProfile.bind(profileController)) +router.get( + '/me/profile', + authenticate, + profileController.getMyProfile.bind(profileController), +) -router.patch('/me/profile', authenticate, profileController.updateMyProfile.bind(profileController)) +router.patch( + '/me/profile', + authenticate, + profileController.updateMyProfile.bind(profileController), +) -router.patch('/password', authenticate, userController.changePassword.bind(userController)) +router.patch( + '/password', + authenticate, + userController.changePassword.bind(userController), +) -router.patch('/wallet', authenticate, userController.updateWalletAddress.bind(userController)) +router.patch( + '/wallet', + authenticate, + userController.updateWalletAddress.bind(userController), +) router.use('/me/avatar', avatarRoutes) -router.get('/:id/profile', optionalAuthenticate, profileController.getProfileById.bind(profileController)) +router.get( + '/:id/profile', + optionalAuthenticate, + profileController.getProfileById.bind(profileController), +) router.get('/:id', userController.getUserById.bind(userController)) diff --git a/src/routes/v1/wallet.routes.ts b/src/routes/v1/wallet.routes.ts index bacfb5d6..5d2a0f7f 100644 --- a/src/routes/v1/wallet.routes.ts +++ b/src/routes/v1/wallet.routes.ts @@ -3,7 +3,10 @@ import { WalletStatusController } from '../../controllers/wallet-status.controll import { WalletStatusService } from '../../services/wallet-status.service' import { PrismaWalletProvisioningRepository } from '../../services/wallet-provisioning.repository' import { stellarService } from '../../services/stellar.service' -import { authenticate, requireActiveAccount } from '../../middleware/auth.middleware' +import { + authenticate, + requireActiveAccount, +} from '../../middleware/auth.middleware' import prisma from '../../config/database' const repository = new PrismaWalletProvisioningRepository(prisma) diff --git a/src/schemas/account.schema.ts b/src/schemas/account.schema.ts index de347f8a..4cc4e7a3 100644 --- a/src/schemas/account.schema.ts +++ b/src/schemas/account.schema.ts @@ -1,29 +1,32 @@ import { z } from 'zod' export const deactivateSchema = z.object({ - password: z.string().min(1, 'Password is required'), + password: z.string().min(1, 'Password is required'), }) export const reactivateSchema = z.object({ - email: z.string().email('Invalid email address'), - password: z.string().min(1, 'Password is required'), + email: z.string().email('Invalid email address'), + password: z.string().min(1, 'Password is required'), }) export const requestDeletionSchema = z.object({ - password: z.string().min(1, 'Password is required'), - reason: z.string().max(500, 'Reason must be at most 500 characters').optional(), + password: z.string().min(1, 'Password is required'), + reason: z + .string() + .max(500, 'Reason must be at most 500 characters') + .optional(), }) export const cancelDeletionSchema = z.object({ - email: z.string().email('Invalid email address'), - password: z.string().min(1, 'Password is required'), + email: z.string().email('Invalid email address'), + password: z.string().min(1, 'Password is required'), }) export const exportIdParamSchema = z.object({ - id: z.string().uuid('Invalid export id'), + id: z.string().uuid('Invalid export id'), }) -export type DeactivateInput = z.infer; -export type ReactivateInput = z.infer; -export type RequestDeletionInput = z.infer; -export type CancelDeletionInput = z.infer; +export type DeactivateInput = z.infer +export type ReactivateInput = z.infer +export type RequestDeletionInput = z.infer +export type CancelDeletionInput = z.infer diff --git a/src/schemas/api.schema.ts b/src/schemas/api.schema.ts index ca4f0871..bd6a47f8 100644 --- a/src/schemas/api.schema.ts +++ b/src/schemas/api.schema.ts @@ -17,14 +17,15 @@ export const isoDateSchema = z .string() .datetime('Invalid ISO 8601 UTC date string format') -export const sortOrderSchema = z - .nativeEnum(SortOrder) - .default(SortOrder.DESC) +export const sortOrderSchema = z.nativeEnum(SortOrder).default(SortOrder.DESC) export const assetAmountSchema = z.object({ amount: z .string() - .regex(/^\d+(\.\d+)?$/, 'Amount must be a numeric decimal or integer string'), + .regex( + /^\d+(\.\d+)?$/, + 'Amount must be a numeric decimal or integer string', + ), assetCode: z.string().min(1, 'Asset code is required'), issuer: z.string().nullable().optional(), }) @@ -61,7 +62,9 @@ export const createPaginatedQuerySchema = (customFields?: ZodRawShape) => { return pagePaginationSchema.extend(customFields || {}) } -export const createCursorPaginatedQuerySchema = (customFields?: ZodRawShape) => { +export const createCursorPaginatedQuerySchema = ( + customFields?: ZodRawShape, +) => { return cursorPaginationSchema.extend(customFields || {}) } @@ -87,8 +90,8 @@ export const createPaginationMeta = ({ version?: string }): PaginationMeta => { const totalPages = Math.ceil(total / limit) || 0 - -return { + + return { page, limit, total, @@ -132,11 +135,11 @@ export const createCursorPaginationMeta = ({ export const createSuccessEnvelope = ( data: T, message?: string, - meta?: Partial + meta?: Partial, ): ApiResponse => { const now = new Date().toISOString() - -return { + + return { success: true, data, message, @@ -152,7 +155,7 @@ return { export const createPaginatedEnvelope = ( data: T[], meta: PaginationMeta, - message?: string + message?: string, ): PaginatedResponse => { return { success: true, @@ -166,7 +169,7 @@ export const createPaginatedEnvelope = ( export const createCursorPaginatedEnvelope = ( data: T[], meta: CursorPaginationMeta, - message?: string + message?: string, ): CursorPaginatedResponse => { return { success: true, diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index c4785524..a9d0dcff 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -6,50 +6,51 @@ import { isStrongPassword } from '../utils/password' // wherever a new credential is set (register, reset) so the rule can't // drift between entry points. const strongPassword = z - .string() - .min(8, 'Password must be at least 8 characters long') - .refine(isStrongPassword, { - message: 'Password must include an uppercase letter, a lowercase letter, a number, and a symbol', - }) + .string() + .min(8, 'Password must be at least 8 characters long') + .refine(isStrongPassword, { + message: + 'Password must include an uppercase letter, a lowercase letter, a number, and a symbol', + }) export const registerSchema = z.object({ - email: z.string().email('Invalid email address'), - password: strongPassword, - username: z.string().min(3, 'Username must be at least 3 characters long'), - role: z.nativeEnum(UserRole).optional().default(UserRole.LEARNER), + email: z.string().email('Invalid email address'), + password: strongPassword, + username: z.string().min(3, 'Username must be at least 3 characters long'), + role: z.nativeEnum(UserRole).optional().default(UserRole.LEARNER), }) export const loginSchema = z.object({ - email: z.string().email('Invalid email address'), - password: z.string(), + email: z.string().email('Invalid email address'), + password: z.string(), }) export const verifyEmailSchema = z.object({ - token: z.string().min(1, 'Token is required'), + token: z.string().min(1, 'Token is required'), }) export const resendVerificationSchema = z.object({ - email: z.string().email('Invalid email address'), + email: z.string().email('Invalid email address'), }) export const forgotPasswordSchema = z.object({ - email: z.string().email('Invalid email address'), + email: z.string().email('Invalid email address'), }) export const resetPasswordSchema = z.object({ - token: z.string().min(1, 'Token is required'), - newPassword: strongPassword, + token: z.string().min(1, 'Token is required'), + newPassword: strongPassword, }) export const otpRequestSchema = z.object({ - phone: z.string().min(1, 'Phone number is required'), - deviceId: z.string().min(1).optional(), + phone: z.string().min(1, 'Phone number is required'), + deviceId: z.string().min(1).optional(), }) export const otpVerifySchema = z.object({ - phone: z.string().min(1, 'Phone number is required'), - code: z.string().length(6, 'Code must be 6 digits'), - deviceId: z.string().min(1).optional(), + phone: z.string().min(1, 'Phone number is required'), + code: z.string().length(6, 'Code must be 6 digits'), + deviceId: z.string().min(1).optional(), }) /** @@ -59,15 +60,15 @@ export const otpVerifySchema = z.object({ * present. */ export const refreshTokenSchema = z.object({ - refreshToken: z.string().min(1, 'refreshToken is required').optional(), + refreshToken: z.string().min(1, 'refreshToken is required').optional(), }) -export type RegisterInput = z.infer; -export type LoginInput = z.infer; -export type VerifyEmailInput = z.infer; -export type ResendVerificationInput = z.infer; -export type ForgotPasswordInput = z.infer; -export type ResetPasswordInput = z.infer; -export type OtpRequestInput = z.infer; -export type OtpVerifyInput = z.infer; -export type RefreshTokenInput = z.infer; +export type RegisterInput = z.infer +export type LoginInput = z.infer +export type VerifyEmailInput = z.infer +export type ResendVerificationInput = z.infer +export type ForgotPasswordInput = z.infer +export type ResetPasswordInput = z.infer +export type OtpRequestInput = z.infer +export type OtpVerifyInput = z.infer +export type RefreshTokenInput = z.infer diff --git a/src/schemas/profile.schema.ts b/src/schemas/profile.schema.ts index 68ab0dc8..7d90f4a7 100644 --- a/src/schemas/profile.schema.ts +++ b/src/schemas/profile.schema.ts @@ -25,27 +25,31 @@ export const profileUpdateFieldsShape = { languages: z.array(z.string().min(1)).max(20).optional(), level: z .enum(LEARNER_LEVELS, { - errorMap: () => ({ message: `Level must be one of: ${LEARNER_LEVELS.join(', ')}` }), + errorMap: () => ({ + message: `Level must be one of: ${LEARNER_LEVELS.join(', ')}`, + }), }) .optional(), interests: z.array(z.string().min(1)).max(50).optional(), goals: z.array(z.string().min(1)).max(20).optional(), visibility: z .enum(PROFILE_VISIBILITIES, { - errorMap: () => ({ message: `Visibility must be one of: ${PROFILE_VISIBILITIES.join(', ')}` }), + errorMap: () => ({ + message: `Visibility must be one of: ${PROFILE_VISIBILITIES.join(', ')}`, + }), }) .optional(), } as const /** Field names an owner is allowed to write, derived from the schema itself. */ export const OWNER_UPDATABLE_PROFILE_FIELDS = Object.keys( - profileUpdateFieldsShape + profileUpdateFieldsShape, ) as readonly (keyof typeof profileUpdateFieldsShape)[] export const updateProfileSchema = z .object(profileUpdateFieldsShape) .strict() - .refine(data => Object.keys(data).length > 0, { + .refine((data) => Object.keys(data).length > 0, { message: 'At least one profile field is required', }) @@ -55,7 +59,7 @@ export const changePasswordSchema = z newPassword: commonSchemas.password, }) .strict() - .refine(data => data.currentPassword !== data.newPassword, { + .refine((data) => data.currentPassword !== data.newPassword, { message: 'New password must be different from current password', path: ['newPassword'], }) @@ -70,6 +74,6 @@ export const userIdParamSchema = z.object({ id: z.string().uuid('Invalid user id'), }) -export type UpdateProfileInput = z.infer; -export type ChangePasswordInput = z.infer; -export type UpdateWalletInput = z.infer; +export type UpdateProfileInput = z.infer +export type ChangePasswordInput = z.infer +export type UpdateWalletInput = z.infer diff --git a/src/schemas/session.schema.ts b/src/schemas/session.schema.ts index ef6e544f..5096cb67 100644 --- a/src/schemas/session.schema.ts +++ b/src/schemas/session.schema.ts @@ -9,12 +9,12 @@ export const sessionListQuerySchema = z.object({ page: z .string() .optional() - .transform(v => (v ? parseInt(v, 10) : 1)) + .transform((v) => (v ? parseInt(v, 10) : 1)) .pipe(z.number().int().min(1, 'page must be >= 1')), limit: z .string() .optional() - .transform(v => (v ? parseInt(v, 10) : 20)) + .transform((v) => (v ? parseInt(v, 10) : 20)) .pipe(z.number().int().min(1).max(100, 'limit must be <= 100')), }) diff --git a/src/server.ts b/src/server.ts index 0c582154..9b95922d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,10 +3,16 @@ import app from './app' import { schedulerConfig } from './config/scheduler' import logger from './utils/logger' import prisma from './config/database' -import { createScheduledJobRunner, ScheduledJobRunner } from './workers/scheduled-job-runner' +import { + createScheduledJobRunner, + ScheduledJobRunner, +} from './workers/scheduled-job-runner' const PORT = process.env.PORT || 5000 -const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || '30000', 10) +const SHUTDOWN_TIMEOUT_MS = parseInt( + process.env.SHUTDOWN_TIMEOUT_MS || '30000', + 10, +) const server: Server = app.listen(PORT, () => { logger.info(`Server running on port ${PORT}`) @@ -19,7 +25,7 @@ if (schedulerConfig.inProcess) { scheduler = createScheduledJobRunner({ prisma }) scheduler.start() logger.info( - `In-process scheduler enabled for queues: ${scheduler.registeredQueues.join(', ') || 'none'}` + `In-process scheduler enabled for queues: ${scheduler.registeredQueues.join(', ') || 'none'}`, ) } @@ -39,7 +45,9 @@ async function gracefulShutdown(signal: string): Promise { // Set a hard deadline for shutdown const shutdownTimer = setTimeout(() => { - logger.error(`Shutdown timeout (${SHUTDOWN_TIMEOUT_MS}ms) exceeded, forcing exit`) + logger.error( + `Shutdown timeout (${SHUTDOWN_TIMEOUT_MS}ms) exceeded, forcing exit`, + ) process.exit(1) }, SHUTDOWN_TIMEOUT_MS) diff --git a/src/services/account-lifecycle.service.ts b/src/services/account-lifecycle.service.ts index 7f12498a..9c05dcc7 100644 --- a/src/services/account-lifecycle.service.ts +++ b/src/services/account-lifecycle.service.ts @@ -11,7 +11,10 @@ import { RequestContext, } from '../types/account.types' -const ACTIVE_DELETION_STATUSES = [DeletionStatus.PENDING, DeletionStatus.PROCESSING] +const ACTIVE_DELETION_STATUSES = [ + DeletionStatus.PENDING, + DeletionStatus.PROCESSING, +] export interface DeletionRequestRecord { id: string @@ -27,8 +30,7 @@ export interface DeletionRequestRecord { } export type DeactivateResult = - | { kind: 'deactivated' } - | { kind: 'conflict'; status: string } + { kind: 'deactivated' } | { kind: 'conflict'; status: string } export type RequestDeletionResult = | { kind: 'created'; request: DeletionRequestRecord } @@ -40,7 +42,11 @@ export type CancelDeletionResult = | { kind: 'none' } export class AccountLifecycleService { - async deactivate(userId: string, currentStatus: string, ctx: RequestContext): Promise { + async deactivate( + userId: string, + currentStatus: string, + ctx: RequestContext, + ): Promise { if (currentStatus !== AccountStatus.ACTIVE) { return { kind: 'conflict', status: currentStatus } } @@ -48,13 +54,20 @@ export class AccountLifecycleService { await prisma.$transaction([ prisma.user.update({ where: { id: userId }, - data: { status: AccountStatus.DEACTIVATED, statusChangedAt: new Date() }, + data: { + status: AccountStatus.DEACTIVATED, + statusChangedAt: new Date(), + }, }), prisma.session.updateMany({ where: { userId, isRevoked: false }, data: { isRevoked: true, revokedAt: new Date() }, }), - auditService.op({ userId, action: AuditAction.ACCOUNT_DEACTIVATED, ...ctx }), + auditService.op({ + userId, + action: AuditAction.ACCOUNT_DEACTIVATED, + ...ctx, + }), ]) return { kind: 'deactivated' } @@ -66,14 +79,18 @@ export class AccountLifecycleService { where: { id: userId }, data: { status: AccountStatus.ACTIVE, statusChangedAt: new Date() }, }), - auditService.op({ userId, action: AuditAction.ACCOUNT_REACTIVATED, ...ctx }), + auditService.op({ + userId, + action: AuditAction.ACCOUNT_REACTIVATED, + ...ctx, + }), ]) } async requestDeletion( userId: string, reason: string | undefined, - ctx: RequestContext + ctx: RequestContext, ): Promise { const existing = await prisma.accountDeletionRequest.findFirst({ where: { userId, status: { in: ACTIVE_DELETION_STATUSES } }, @@ -83,16 +100,26 @@ export class AccountLifecycleService { return { kind: 'duplicate', request: existing as DeletionRequestRecord } } - const scheduledFor = new Date(Date.now() + env.DELETION_COOLING_OFF_DAYS * 24 * 60 * 60_000) + const scheduledFor = new Date( + Date.now() + env.DELETION_COOLING_OFF_DAYS * 24 * 60 * 60_000, + ) try { const [request] = await prisma.$transaction([ prisma.accountDeletionRequest.create({ - data: { userId, status: DeletionStatus.PENDING, reason: reason ?? null, scheduledFor }, + data: { + userId, + status: DeletionStatus.PENDING, + reason: reason ?? null, + scheduledFor, + }, }), prisma.user.update({ where: { id: userId }, - data: { status: AccountStatus.PENDING_DELETION, statusChangedAt: new Date() }, + data: { + status: AccountStatus.PENDING_DELETION, + statusChangedAt: new Date(), + }, }), prisma.session.updateMany({ where: { userId, isRevoked: false }, @@ -109,7 +136,10 @@ export class AccountLifecycleService { return { kind: 'created', request: request as DeletionRequestRecord } } catch (error: any) { // Partial unique index uq_active_deletion_per_user: concurrent request won the race - if (error?.code === 'P2002' || /uq_active_deletion_per_user/.test(error?.message ?? '')) { + if ( + error?.code === 'P2002' || + /uq_active_deletion_per_user/.test(error?.message ?? '') + ) { const winner = await prisma.accountDeletionRequest.findFirst({ where: { userId, status: { in: ACTIVE_DELETION_STATUSES } }, }) @@ -121,14 +151,19 @@ export class AccountLifecycleService { } } - async getLatestDeletionRequest(userId: string): Promise { + async getLatestDeletionRequest( + userId: string, + ): Promise { return (await prisma.accountDeletionRequest.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' }, })) as DeletionRequestRecord | null } - async cancelDeletion(userId: string, ctx: RequestContext): Promise { + async cancelDeletion( + userId: string, + ctx: RequestContext, + ): Promise { // Status-guarded update is the race protection: if finalization already // claimed the request (processing/completed), count is 0 and we lose. const cancelled = await prisma.accountDeletionRequest.updateMany({ @@ -140,7 +175,8 @@ export class AccountLifecycleService { const latest = await this.getLatestDeletionRequest(userId) if ( latest && - (latest.status === DeletionStatus.PROCESSING || latest.status === DeletionStatus.COMPLETED) + (latest.status === DeletionStatus.PROCESSING || + latest.status === DeletionStatus.COMPLETED) ) { return { kind: 'finalized' } } @@ -153,7 +189,11 @@ export class AccountLifecycleService { where: { id: userId }, data: { status: AccountStatus.ACTIVE, statusChangedAt: new Date() }, }), - auditService.op({ userId, action: AuditAction.DELETION_CANCELLED, ...ctx }), + auditService.op({ + userId, + action: AuditAction.DELETION_CANCELLED, + ...ctx, + }), ]) const request = await this.getLatestDeletionRequest(userId) @@ -196,7 +236,7 @@ export class AccountLifecycleService { } catch (error: any) { await this.handleFinalizationFailure( request as DeletionRequestRecord, - error?.message ?? 'Deletion finalization error' + error?.message ?? 'Deletion finalization error', ) } } @@ -213,7 +253,10 @@ export class AccountLifecycleService { * - Retain + scrub: audit logs (keep action/createdAt, drop ip/UA/metadata). * - User row: anonymized in place (tombstone) so retained FKs stay valid. */ - private async finalizeDeletion(requestId: string, userId: string): Promise { + private async finalizeDeletion( + requestId: string, + userId: string, + ): Promise { const tombstoneSuffix = userId.replace(/-/g, '').slice(0, 12) await prisma.$transaction([ @@ -250,7 +293,11 @@ export class AccountLifecycleService { }), prisma.accountDeletionRequest.update({ where: { id: requestId }, - data: { status: DeletionStatus.COMPLETED, completedAt: new Date(), error: null }, + data: { + status: DeletionStatus.COMPLETED, + completedAt: new Date(), + error: null, + }, }), auditService.op({ userId, @@ -259,10 +306,15 @@ export class AccountLifecycleService { }), ]) - logger.info(`[AccountLifecycleService] Deletion request ${requestId} finalized`) + logger.info( + `[AccountLifecycleService] Deletion request ${requestId} finalized`, + ) } - private async handleFinalizationFailure(request: DeletionRequestRecord, error: string): Promise { + private async handleFinalizationFailure( + request: DeletionRequestRecord, + error: string, + ): Promise { const nextAttemptCount = request.attemptCount + 1 if (nextAttemptCount >= request.maxAttempts) { @@ -271,7 +323,7 @@ export class AccountLifecycleService { data: { status: DeletionStatus.FAILED, error }, }) logger.error( - `[AccountLifecycleService] Deletion ${request.id} dead-lettered after ${nextAttemptCount} attempts: ${error}` + `[AccountLifecycleService] Deletion ${request.id} dead-lettered after ${nextAttemptCount} attempts: ${error}`, ) } else { const backoffMinutes = Math.pow(5, nextAttemptCount - 1) @@ -293,7 +345,10 @@ export class AccountLifecycleService { for (const result of results) { if (result.status === 'rejected') { - logger.error('[AccountLifecycleService] Sweep task failed:', result.reason) + logger.error( + '[AccountLifecycleService] Sweep task failed:', + result.reason, + ) } } } diff --git a/src/services/asset-validation.service.ts b/src/services/asset-validation.service.ts index e292743a..d859df02 100644 --- a/src/services/asset-validation.service.ts +++ b/src/services/asset-validation.service.ts @@ -3,12 +3,15 @@ import { AVATAR_MAX_BYTES, AVATAR_MIN_BYTES, } from '../types/avatar.types' -import { sniffMimeType, extractImageDimensions } from './storage/in-memory-storage' +import { + sniffMimeType, + extractImageDimensions, +} from './storage/in-memory-storage' export interface ValidationResult { ok: boolean detectedMime: string | null - dimensions: { width: number, height: number } | null + dimensions: { width: number; height: number } | null error?: string } @@ -31,31 +34,61 @@ export function validateAvatarBytes( // ── Size check ──────────────────────────────────────────────── const actualSize = sizeBytes ?? data.length if (actualSize < AVATAR_MIN_BYTES) { - return { ok: false, detectedMime: null, dimensions: null, error: 'File too small (minimum 1 KB)' } + return { + ok: false, + detectedMime: null, + dimensions: null, + error: 'File too small (minimum 1 KB)', + } } if (actualSize > AVATAR_MAX_BYTES) { - return { ok: false, detectedMime: null, dimensions: null, error: 'File too large (maximum 5 MB)' } + return { + ok: false, + detectedMime: null, + dimensions: null, + error: 'File too large (maximum 5 MB)', + } } if (data.length > AVATAR_MAX_BYTES) { - return { ok: false, detectedMime: null, dimensions: null, error: 'File too large (maximum 5 MB)' } + return { + ok: false, + detectedMime: null, + dimensions: null, + error: 'File too large (maximum 5 MB)', + } } // ── MIME sniff from magic bytes ─────────────────────────────── const detectedMime = sniffMimeType(data) if (!detectedMime) { - return { ok: false, detectedMime: null, dimensions: null, error: 'Unrecognised image format' } + return { + ok: false, + detectedMime: null, + dimensions: null, + error: 'Unrecognised image format', + } } // ── Allowlist check ─────────────────────────────────────────── - if (!(AVATAR_ALLOWED_MIME_TYPES as readonly string[]).includes(detectedMime)) { - return { ok: false, detectedMime, dimensions: null, error: `Unsupported image type: ${detectedMime}` } + if ( + !(AVATAR_ALLOWED_MIME_TYPES as readonly string[]).includes(detectedMime) + ) { + return { + ok: false, + detectedMime, + dimensions: null, + error: `Unsupported image type: ${detectedMime}`, + } } // ── MIME spoofing detection ─────────────────────────────────── // If the client declared a type that doesn't match what we sniffed, // reject — this catches cases where the extension or Content-Type // header was tampered with. - const declaredNormalised = declaredContentType.split(';')[0].trim().toLowerCase() + const declaredNormalised = declaredContentType + .split(';')[0] + .trim() + .toLowerCase() if (declaredNormalised !== detectedMime) { return { ok: false, diff --git a/src/services/avatar.service.ts b/src/services/avatar.service.ts index e5167a0c..548eff87 100644 --- a/src/services/avatar.service.ts +++ b/src/services/avatar.service.ts @@ -13,7 +13,7 @@ import type { import type { StorageProvider } from '../types/avatar.types' import { validateAvatarBytes } from './asset-validation.service' -const VARIANT_SPECS: Array<{ label: string, suffix: string }> = [ +const VARIANT_SPECS: Array<{ label: string; suffix: string }> = [ { label: 'original', suffix: '' }, { label: 'thumb', suffix: '_thumb' }, { label: 'medium', suffix: '_medium' }, @@ -35,8 +35,12 @@ export class AvatarService { sizeBytes?: number, ): Promise { const normalisedMime = contentType.split(';')[0].trim().toLowerCase() - if (!(AVATAR_ALLOWED_MIME_TYPES as readonly string[]).includes(normalisedMime)) { - throw new AvatarValidationError(`Unsupported content type: ${contentType}`) + if ( + !(AVATAR_ALLOWED_MIME_TYPES as readonly string[]).includes(normalisedMime) + ) { + throw new AvatarValidationError( + `Unsupported content type: ${contentType}`, + ) } if (sizeBytes !== undefined && sizeBytes > AVATAR_MAX_BYTES) { @@ -44,7 +48,9 @@ export class AvatarService { } const id = crypto.randomUUID() - const safeName = (originalName ?? 'avatar').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80) + const safeName = (originalName ?? 'avatar') + .replace(/[^a-zA-Z0-9._-]/g, '_') + .slice(0, 80) const storageKey = `avatars/${userId}/${id}/${safeName}` const intent = await this.storage.createSignedUpload( @@ -89,7 +95,9 @@ export class AvatarService { sha256?: string, ): Promise { // ── Load the PENDING avatar and verify ownership ───────────── - const avatar = await prisma.avatar.findUnique({ where: { id: uploadKey.split('/')[2] } }) + const avatar = await prisma.avatar.findUnique({ + where: { id: uploadKey.split('/')[2] }, + }) if (!avatar) { throw new AvatarValidationError('Upload not found', 404) } @@ -107,18 +115,28 @@ export class AvatarService { if (sha256) { const actual = crypto.createHash('sha256').update(data).digest('hex') if (actual !== sha256) { - await this.markFailed(avatar.id, 'Integrity check failed (SHA-256 mismatch)') + await this.markFailed( + avatar.id, + 'Integrity check failed (SHA-256 mismatch)', + ) throw new AvatarValidationError('Integrity check failed', 422) } } // ── Server-side validation ─────────────────────────────────── - const validation = validateAvatarBytes(data, avatar.contentType, avatar.originalBytes || undefined) + const validation = validateAvatarBytes( + data, + avatar.contentType, + avatar.originalBytes || undefined, + ) if (!validation.ok) { await this.markFailed(avatar.id, validation.error ?? 'Validation failed') - throw new AvatarValidationError(validation.error ?? 'Validation failed', 422) + throw new AvatarValidationError( + validation.error ?? 'Validation failed', + 422, + ) } // ── Produce variants ───────────────────────────────────────── @@ -171,7 +189,10 @@ export class AvatarService { await prisma.learnerProfile.upsert({ where: { userId }, update: { avatarUrl: this.storage.getServingUrl(avatar.storageKey) }, - create: { userId, avatarUrl: this.storage.getServingUrl(avatar.storageKey) }, + create: { + userId, + avatarUrl: this.storage.getServingUrl(avatar.storageKey), + }, }) // Clean up retired avatar objects @@ -205,7 +226,9 @@ export class AvatarService { } // Delete variant objects from storage - const variants = await prisma.avatarVariant.findMany({ where: { avatarId: avatar.id } }) + const variants = await prisma.avatarVariant.findMany({ + where: { avatarId: avatar.id }, + }) for (const v of variants) { await this.storage.deleteObject(v.storageKey) } @@ -224,7 +247,9 @@ export class AvatarService { /** * Return the current avatar for a user. */ - async getCurrentAvatar(userId: string): Promise { + async getCurrentAvatar( + userId: string, + ): Promise { const avatar = await prisma.avatar.findFirst({ where: { userId, status: 'ACTIVE' }, include: { variants: true }, diff --git a/src/services/consent.service.ts b/src/services/consent.service.ts index 0dfe3670..c193e737 100644 --- a/src/services/consent.service.ts +++ b/src/services/consent.service.ts @@ -14,8 +14,13 @@ export type WithdrawConsentResult = | { kind: 'required-cannot-withdraw' } export class ConsentService { - async grant(userId: string, data: GrantConsentData): Promise { - const required = (REQUIRED_CONSENT_PURPOSES as readonly string[]).includes(data.purpose) + async grant( + userId: string, + data: GrantConsentData, + ): Promise { + const required = (REQUIRED_CONSENT_PURPOSES as readonly string[]).includes( + data.purpose, + ) return prisma.consentRecord.create({ data: { @@ -30,10 +35,16 @@ export class ConsentService { }) as unknown as ConsentRecordEntry } - async withdraw(userId: string, data: WithdrawConsentData): Promise { + async withdraw( + userId: string, + data: WithdrawConsentData, + ): Promise { const latest = await this.getCurrentForPurpose(userId, data.purpose) - if (!latest || !canTransition(CONSENT_TRANSITIONS, latest.status, 'withdrawn')) { + if ( + !latest || + !canTransition(CONSENT_TRANSITIONS, latest.status, 'withdrawn') + ) { return { kind: 'not-granted' } } @@ -53,10 +64,16 @@ export class ConsentService { }, }) - return { kind: 'withdrawn', record: record as unknown as ConsentRecordEntry } + return { + kind: 'withdrawn', + record: record as unknown as ConsentRecordEntry, + } } - async getCurrentForPurpose(userId: string, purpose: string): Promise { + async getCurrentForPurpose( + userId: string, + purpose: string, + ): Promise { return prisma.consentRecord.findFirst({ where: { userId, purpose }, orderBy: { createdAt: 'desc' }, @@ -71,7 +88,10 @@ export class ConsentService { }) as unknown as ConsentRecordEntry[] } - async getHistory(userId: string, purpose?: string): Promise { + async getHistory( + userId: string, + purpose?: string, + ): Promise { return prisma.consentRecord.findMany({ where: { userId, ...(purpose ? { purpose } : {}) }, orderBy: { createdAt: 'asc' }, @@ -80,9 +100,11 @@ export class ConsentService { async hasAllRequiredGranted(userId: string): Promise { const current = await this.getCurrent(userId) - const byPurpose = new Map(current.map(entry => [entry.purpose, entry])) + const byPurpose = new Map(current.map((entry) => [entry.purpose, entry])) - return REQUIRED_CONSENT_PURPOSES.every(purpose => byPurpose.get(purpose)?.status === 'granted') + return REQUIRED_CONSENT_PURPOSES.every( + (purpose) => byPurpose.get(purpose)?.status === 'granted', + ) } } diff --git a/src/services/data-export.service.ts b/src/services/data-export.service.ts index eaabe5bd..b23b1669 100644 --- a/src/services/data-export.service.ts +++ b/src/services/data-export.service.ts @@ -42,11 +42,18 @@ export class DataExportService { let request: ExportRequestRecord try { request = (await prisma.dataExportRequest.create({ - data: { userId, status: ExportStatus.PENDING, nextAttemptAt: new Date() }, + data: { + userId, + status: ExportStatus.PENDING, + nextAttemptAt: new Date(), + }, })) as ExportRequestRecord } catch (error: any) { // Partial unique index uq_active_export_per_user: a concurrent request won the race - if (error?.code === 'P2002' || /uq_active_export_per_user/.test(error?.message ?? '')) { + if ( + error?.code === 'P2002' || + /uq_active_export_per_user/.test(error?.message ?? '') + ) { const winner = await prisma.dataExportRequest.findFirst({ where: { userId, status: { in: ACTIVE_EXPORT_STATUSES } }, }) @@ -57,12 +64,19 @@ export class DataExportService { throw error } - await auditService.record({ userId, action: AuditAction.EXPORT_REQUESTED, metadata: { requestId: request.id } }) + await auditService.record({ + userId, + action: AuditAction.EXPORT_REQUESTED, + metadata: { requestId: request.id }, + }) return { kind: 'created', request } } - async getExportStatus(userId: string, id: string): Promise { + async getExportStatus( + userId: string, + id: string, + ): Promise { // Scoped to the requesting user: other users' export ids behave as not found return (await prisma.dataExportRequest.findFirst({ where: { id, userId }, @@ -98,12 +112,18 @@ export class DataExportService { try { await this.generateExport(request.id, request.userId) } catch (error: any) { - await this.handleFailure(request as ExportRequestRecord, error?.message ?? 'Export generation error') + await this.handleFailure( + request as ExportRequestRecord, + error?.message ?? 'Export generation error', + ) } } } - private async generateExport(requestId: string, userId: string): Promise { + private async generateExport( + requestId: string, + userId: string, + ): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -145,10 +165,18 @@ export class DataExportService { include: { module: { select: { title: true } } }, }), prisma.transaction.findMany({ where: { userId } }), - prisma.referralCode.findFirst({ where: { userId }, select: { code: true, createdAt: true } }), + prisma.referralCode.findFirst({ + where: { userId }, + select: { code: true, createdAt: true }, + }), prisma.referral.findMany({ where: { referrerId: userId }, - select: { referreeId: true, bonusPaid: true, bonusAmount: true, createdAt: true }, + select: { + referreeId: true, + bonusPaid: true, + bonusAmount: true, + createdAt: true, + }, }), prisma.referral.findFirst({ where: { referreeId: userId }, @@ -169,7 +197,11 @@ export class DataExportService { }), prisma.notificationPreference.findFirst({ where: { userId }, - select: { rewardReceipt: true, quizPassFail: true, streakReminders: true }, + select: { + rewardReceipt: true, + quizPassFail: true, + streakReminders: true, + }, }), prisma.notificationLog.findMany({ where: { userId }, @@ -178,7 +210,13 @@ export class DataExportService { // Session metadata only — never token/refreshToken prisma.session.findMany({ where: { userId }, - select: { userAgent: true, ipAddress: true, createdAt: true, expiresAt: true, isRevoked: true }, + select: { + userAgent: true, + ipAddress: true, + createdAt: true, + expiresAt: true, + isRevoked: true, + }, }), prisma.auditLog.findMany({ where: { userId }, @@ -221,7 +259,9 @@ export class DataExportService { }, }) - const expiresAt = new Date(Date.now() + env.EXPORT_TTL_DAYS * 24 * 60 * 60_000) + const expiresAt = new Date( + Date.now() + env.EXPORT_TTL_DAYS * 24 * 60 * 60_000, + ) await prisma.dataExportRequest.update({ where: { id: requestId }, @@ -235,18 +275,25 @@ export class DataExportService { }, }) - await auditService.record({ userId, action: AuditAction.EXPORT_READY, metadata: { requestId } }) + await auditService.record({ + userId, + action: AuditAction.EXPORT_READY, + metadata: { requestId }, + }) await emailService.queueEmail( userId, user.email, 'Your Learnault data export is ready', this.buildExportReadyEmail(user.username, expiresAt), - 'DATA_EXPORT' + 'DATA_EXPORT', ) } - private async handleFailure(request: ExportRequestRecord, error: string): Promise { + private async handleFailure( + request: ExportRequestRecord, + error: string, + ): Promise { const nextAttemptCount = request.attemptCount + 1 if (nextAttemptCount >= request.maxAttempts) { @@ -254,7 +301,9 @@ export class DataExportService { where: { id: request.id }, data: { status: ExportStatus.FAILED, error }, }) - logger.error(`[DataExportService] Export ${request.id} dead-lettered after ${nextAttemptCount} attempts: ${error}`) + logger.error( + `[DataExportService] Export ${request.id} dead-lettered after ${nextAttemptCount} attempts: ${error}`, + ) } else { const backoffMinutes = Math.pow(5, nextAttemptCount - 1) const nextAttemptAt = new Date(Date.now() + backoffMinutes * 60_000) @@ -273,7 +322,9 @@ export class DataExportService { }) if (result.count > 0) { - logger.info(`[DataExportService] Purged ${result.count} expired export artifact(s)`) + logger.info( + `[DataExportService] Purged ${result.count} expired export artifact(s)`, + ) } return result.count @@ -284,7 +335,11 @@ export class DataExportService { where: { id, userId, downloadedAt: null }, data: { downloadedAt: new Date() }, }) - await auditService.record({ userId, action: AuditAction.EXPORT_DOWNLOADED, metadata: { requestId: id } }) + await auditService.record({ + userId, + action: AuditAction.EXPORT_DOWNLOADED, + metadata: { requestId: id }, + }) } private buildExportReadyEmail(username: string, expiresAt: Date): string { diff --git a/src/services/email.service.ts b/src/services/email.service.ts index 5973e0fa..f595a962 100644 --- a/src/services/email.service.ts +++ b/src/services/email.service.ts @@ -25,7 +25,7 @@ export class EmailService { to: string, subject: string, body: string, - type: string = 'EMAIL_VERIFICATION' + type: string = 'EMAIL_VERIFICATION', ): Promise { const delivery = await prisma.emailDelivery.create({ data: { @@ -66,7 +66,7 @@ export class EmailService { // TODO: Replace with real email provider (SendGrid, SES, etc.) // In development, log to console logger.info( - `[EmailService] Sending email to=${delivery.to} subject="${delivery.subject}"` + `[EmailService] Sending email to=${delivery.to} subject="${delivery.subject}"`, ) await prisma.emailDelivery.update({ @@ -74,11 +74,17 @@ export class EmailService { data: { status: 'sent', sentAt: new Date() }, }) } catch (error: any) { - await this.handleFailure(delivery, error.message ?? 'Email provider error') + await this.handleFailure( + delivery, + error.message ?? 'Email provider error', + ) } } - private async handleFailure(delivery: EmailDeliveryRecord, error: string): Promise { + private async handleFailure( + delivery: EmailDeliveryRecord, + error: string, + ): Promise { const nextAttemptCount = delivery.attemptCount + 1 if (nextAttemptCount >= delivery.maxAttempts) { diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index cdc6cfc0..962b27fe 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -29,11 +29,13 @@ const initFirebase = () => { const serviceAccount = process.env.FIREBASE_SERVICE_ACCOUNT_KEY if (serviceAccount) { admin.initializeApp({ - credential: admin.credential.cert(JSON.parse(serviceAccount)) + credential: admin.credential.cert(JSON.parse(serviceAccount)), }) console.log('[NotificationService] Firebase Admin initialized.') } else { - console.warn('[NotificationService] FIREBASE_SERVICE_ACCOUNT_KEY not set. Push notifications will be simulated.') + console.warn( + '[NotificationService] FIREBASE_SERVICE_ACCOUNT_KEY not set. Push notifications will be simulated.', + ) } firebaseInitialized = true } catch (e) { @@ -49,7 +51,7 @@ export class NotificationService { return prisma.deviceToken.upsert({ where: { token }, update: { userId, platform }, - create: { userId, token, platform } + create: { userId, token, platform }, }) } @@ -62,12 +64,12 @@ export class NotificationService { rewardReceipt?: boolean quizPassFail?: boolean streakReminders?: boolean - } + }, ) { return prisma.notificationPreference.upsert({ where: { userId }, update: preferences, - create: { userId, ...preferences } + create: { userId, ...preferences }, }) } @@ -79,9 +81,11 @@ export class NotificationService { userId: string, type: 'rewardReceipt' | 'quizPassFail' | 'streakReminders', title: string, - body: string + body: string, ): Promise { - const prefs = await prisma.notificationPreference.findUnique({ where: { userId } }) + const prefs = await prisma.notificationPreference.findUnique({ + where: { userId }, + }) // Default to enabled when no preference row exists const isEnabled = prefs ? Boolean(prefs[type as keyof typeof prefs]) : true @@ -91,7 +95,14 @@ export class NotificationService { } const log = await prisma.notificationLog.create({ - data: { userId, type, title, body, status: 'pending', nextAttemptAt: new Date() } + data: { + userId, + type, + title, + body, + status: 'pending', + nextAttemptAt: new Date(), + }, }) return log as unknown as NotificationLog @@ -107,11 +118,11 @@ export class NotificationService { where: { status: 'pending', nextAttemptAt: { lte: new Date() }, - attemptCount: { lt: 5 } + attemptCount: { lt: 5 }, }, include: { - user: { include: { deviceTokens: true } } - } + user: { include: { deviceTokens: true } }, + }, }) for (const log of pendingLogs) { @@ -123,15 +134,17 @@ export class NotificationService { // Increment attempt counter first await prisma.notificationLog.update({ where: { id: log.id }, - data: { attemptCount: { increment: 1 } } + data: { attemptCount: { increment: 1 } }, }) - const tokens: string[] = (log.user?.deviceTokens ?? []).map((dt: any) => dt.token) + const tokens: string[] = (log.user?.deviceTokens ?? []).map( + (dt: any) => dt.token, + ) if (tokens.length === 0) { await prisma.notificationLog.update({ where: { id: log.id }, - data: { status: 'failed', error: 'No device tokens found for user' } + data: { status: 'failed', error: 'No device tokens found for user' }, }) return @@ -141,7 +154,7 @@ export class NotificationService { if (admin.apps.length > 0) { const response = await admin.messaging().sendEachForMulticast({ notification: { title: log.title, body: log.body }, - tokens + tokens, }) if (response.failureCount > 0) { @@ -153,14 +166,14 @@ export class NotificationService { } else { await prisma.notificationLog.update({ where: { id: log.id }, - data: { status: 'success' } + data: { status: 'success' }, }) } } else { // Simulated success when Firebase is not configured (development mode) await prisma.notificationLog.update({ where: { id: log.id }, - data: { status: 'success' } + data: { status: 'success' }, }) } } catch (error: any) { @@ -168,14 +181,17 @@ export class NotificationService { } } - private async handleFailure(log: NotificationLog, error: string): Promise { + private async handleFailure( + log: NotificationLog, + error: string, + ): Promise { const nextAttemptCount = log.attemptCount + 1 if (nextAttemptCount >= log.maxAttempts) { // Dead-letter: exhausted all retries await prisma.notificationLog.update({ where: { id: log.id }, - data: { status: 'dead-letter', error } + data: { status: 'dead-letter', error }, }) } else { // Exponential backoff: 1min, 5min, 25min… @@ -184,7 +200,7 @@ export class NotificationService { await prisma.notificationLog.update({ where: { id: log.id }, - data: { error, nextAttemptAt } + data: { error, nextAttemptAt }, }) } } diff --git a/src/services/onboarding.service.ts b/src/services/onboarding.service.ts index c2dbd085..0d4aaa63 100644 --- a/src/services/onboarding.service.ts +++ b/src/services/onboarding.service.ts @@ -32,11 +32,19 @@ export class OnboardingService { }) as unknown as OnboardingProgressRecord } - async saveStep(userId: string, step: OnboardingStep): Promise { - const existing = await prisma.onboardingProgress.findUnique({ where: { userId } }) + async saveStep( + userId: string, + step: OnboardingStep, + ): Promise { + const existing = await prisma.onboardingProgress.findUnique({ + where: { userId }, + }) if (existing && existing.status === 'completed') { - return { kind: 'already-completed', progress: existing as unknown as OnboardingProgressRecord } + return { + kind: 'already-completed', + progress: existing as unknown as OnboardingProgressRecord, + } } const completedSteps = existing @@ -54,7 +62,10 @@ export class OnboardingService { }, }) - return { kind: 'saved', progress: progress as unknown as OnboardingProgressRecord } + return { + kind: 'saved', + progress: progress as unknown as OnboardingProgressRecord, + } } async resume(userId: string): Promise { @@ -68,12 +79,15 @@ export class OnboardingService { return { kind: 'already-completed', progress } } - const missingSteps = REQUIRED_ONBOARDING_STEPS.filter(step => !progress.completedSteps.includes(step)) + const missingSteps = REQUIRED_ONBOARDING_STEPS.filter( + (step) => !progress.completedSteps.includes(step), + ) if (missingSteps.length > 0) { return { kind: 'incomplete-steps', missingSteps: [...missingSteps] } } - const requiredConsentsGranted = await consentService.hasAllRequiredGranted(userId) + const requiredConsentsGranted = + await consentService.hasAllRequiredGranted(userId) if (!requiredConsentsGranted) { return { kind: 'missing-required-consent' } } @@ -83,7 +97,10 @@ export class OnboardingService { data: { status: 'completed', completedAt: new Date() }, }) - return { kind: 'completed', progress: updated as unknown as OnboardingProgressRecord } + return { + kind: 'completed', + progress: updated as unknown as OnboardingProgressRecord, + } } } diff --git a/src/services/otp.service.ts b/src/services/otp.service.ts index 943be030..aa97d30c 100644 --- a/src/services/otp.service.ts +++ b/src/services/otp.service.ts @@ -18,24 +18,27 @@ export function normalizePhone(input: string): string | null { } function generateCode(): string { - return crypto.randomInt(0, 10 ** OTP_CODE_LENGTH).toString().padStart(OTP_CODE_LENGTH, '0') + return crypto + .randomInt(0, 10 ** OTP_CODE_LENGTH) + .toString() + .padStart(OTP_CODE_LENGTH, '0') } function hashCode(code: string, phone: string): string { return crypto.createHash('sha256').update(`${phone}:${code}`).digest('hex') } -export type OtpVerifyFailureReason = 'not_found' | 'expired' | 'locked' | 'mismatch' +export type OtpVerifyFailureReason = + 'not_found' | 'expired' | 'locked' | 'mismatch' export type OtpVerifyResult = - | { ok: true; userId: string } - | { ok: false; reason: OtpVerifyFailureReason } + { ok: true; userId: string } | { ok: false; reason: OtpVerifyFailureReason } export class OtpService { async requestChallenge( phone: string, purpose: OtpPurpose, userId: string, - context: { ip?: string; deviceId?: string } = {} + context: { ip?: string; deviceId?: string } = {}, ): Promise { await prisma.otpChallenge.updateMany({ where: { userId, purpose, status: 'PENDING' }, @@ -61,11 +64,13 @@ export class OtpService { const provider = getSmsProvider() const result = await provider.send( phone, - `Your Learnault verification code is ${code}. It expires in 5 minutes.` + `Your Learnault verification code is ${code}. It expires in 5 minutes.`, ) if (!result.success) { - logger.error(`[OtpService] SMS send failed for phone=${phone}: ${result.error}`) + logger.error( + `[OtpService] SMS send failed for phone=${phone}: ${result.error}`, + ) } } @@ -73,7 +78,7 @@ export class OtpService { phone: string, code: string, purpose: OtpPurpose, - expectedUserId?: string + expectedUserId?: string, ): Promise { const challenge = await prisma.otpChallenge.findFirst({ where: { phone, purpose, status: 'PENDING' }, diff --git a/src/services/preference.service.ts b/src/services/preference.service.ts index 87a57e42..a2bde3cf 100644 --- a/src/services/preference.service.ts +++ b/src/services/preference.service.ts @@ -1,5 +1,8 @@ import prisma from '../config/database' -import { PRIVACY_IMPACTING_FIELDS, UpdateLearnerPreferencesData } from '../types/preference.types' +import { + PRIVACY_IMPACTING_FIELDS, + UpdateLearnerPreferencesData, +} from '../types/preference.types' export class PreferenceService { /** @@ -24,7 +27,9 @@ export class PreferenceService { * the same user cannot create duplicate rows or silently drop a write. */ async updatePreferences(userId: string, data: UpdateLearnerPreferencesData) { - const before = await prisma.learnerPreference.findUnique({ where: { userId } }) + const before = await prisma.learnerPreference.findUnique({ + where: { userId }, + }) const updated = await prisma.learnerPreference.upsert({ where: { userId }, @@ -41,17 +46,18 @@ export class PreferenceService { userId: string, before: { [key: string]: unknown } | null, after: { [key: string]: unknown }, - changedFields: UpdateLearnerPreferencesData + changedFields: UpdateLearnerPreferencesData, ): Promise { - const entries = PRIVACY_IMPACTING_FIELDS - .filter(field => field in changedFields) - .map(field => ({ + const entries = PRIVACY_IMPACTING_FIELDS.filter( + (field) => field in changedFields, + ) + .map((field) => ({ userId, field, oldValue: before ? String(before[field]) : null, newValue: String(after[field]), })) - .filter(entry => entry.oldValue !== entry.newValue) + .filter((entry) => entry.oldValue !== entry.newValue) if (entries.length === 0) { return diff --git a/src/services/profile-serializer.ts b/src/services/profile-serializer.ts index 4dce0af1..0ce262ed 100644 --- a/src/services/profile-serializer.ts +++ b/src/services/profile-serializer.ts @@ -29,10 +29,16 @@ function isFilled(value: unknown): boolean { return true } -export function computeProfileCompletion(profile: LearnerProfileRecord): ProfileCompletion { - const missingFields = PROFILE_COMPLETION_FIELDS.filter(field => !isFilled(profile[field])) +export function computeProfileCompletion( + profile: LearnerProfileRecord, +): ProfileCompletion { + const missingFields = PROFILE_COMPLETION_FIELDS.filter( + (field) => !isFilled(profile[field]), + ) const filledCount = PROFILE_COMPLETION_FIELDS.length - missingFields.length - const percent = Math.round((filledCount / PROFILE_COMPLETION_FIELDS.length) * 100) + const percent = Math.round( + (filledCount / PROFILE_COMPLETION_FIELDS.length) * 100, + ) return { percent, missingFields: [...missingFields] } } @@ -47,7 +53,9 @@ export function computeProfileCompletion(profile: LearnerProfileRecord): Profile * response anyway, because TypeScript checks the declared type and not the * object that actually arrives at runtime. */ -export function toProfileRecord(profile: LearnerProfileRecord): LearnerProfileRecord { +export function toProfileRecord( + profile: LearnerProfileRecord, +): LearnerProfileRecord { return { id: profile.id, userId: profile.userId, @@ -66,11 +74,18 @@ export function toProfileRecord(profile: LearnerProfileRecord): LearnerProfileRe } } -export function toOwnerProfile(profile: LearnerProfileRecord): OwnerProfileView { - return { ...toProfileRecord(profile), completion: computeProfileCompletion(profile) } +export function toOwnerProfile( + profile: LearnerProfileRecord, +): OwnerProfileView { + return { + ...toProfileRecord(profile), + completion: computeProfileCompletion(profile), + } } -export function toEmployerProfile(profile: LearnerProfileRecord): EmployerProfileView { +export function toEmployerProfile( + profile: LearnerProfileRecord, +): EmployerProfileView { if (VISIBILITY_RANK[profile.visibility] < VISIBILITY_RANK.employer) { return { id: profile.id, visible: false } } @@ -90,7 +105,9 @@ export function toEmployerProfile(profile: LearnerProfileRecord): EmployerProfil } } -export function toPublicProfile(profile: LearnerProfileRecord): PublicProfileView { +export function toPublicProfile( + profile: LearnerProfileRecord, +): PublicProfileView { if (VISIBILITY_RANK[profile.visibility] < VISIBILITY_RANK.public) { return { id: profile.id, visible: false } } @@ -111,7 +128,7 @@ export function toPublicProfile(profile: LearnerProfileRecord): PublicProfileVie // view through a public or employer-facing endpoint. export function toPrivateProfile( profile: LearnerProfileRecord, - account: AccountPrivateFields + account: AccountPrivateFields, ): PrivateProfileView { return { ...toProfileRecord(profile), ...account } } @@ -157,7 +174,7 @@ export function isDisclosureAllowed(input: { } const dataSharing = input.consents.find( - consent => consent.purpose === DISCLOSURE_CONSENT_PURPOSE + (consent) => consent.purpose === DISCLOSURE_CONSENT_PURPOSE, ) return dataSharing?.status !== 'withdrawn' @@ -171,7 +188,10 @@ export function isDisclosureAllowed(input: { * profile from a withdrawn consent from a deactivated account. Distinguishable * refusals would leak the very state they refuse to disclose. */ -export function redactedProfile(profileId: string): { id: string; visible: false } { +export function redactedProfile(profileId: string): { + id: string + visible: false +} { return { id: profileId, visible: false } } @@ -216,7 +236,7 @@ export function toOnboardingSummary(progress: { // Computed rather than stored, for the same reason completion is: it can // then never disagree with `completedSteps`. requiredStepsRemaining: REQUIRED_ONBOARDING_STEPS.filter( - step => !progress.completedSteps.includes(step) + (step) => !progress.completedSteps.includes(step), ), startedAt: progress.startedAt, completedAt: progress.completedAt, diff --git a/src/services/profile.service.ts b/src/services/profile.service.ts index 2fb9fdf4..e3905b20 100644 --- a/src/services/profile.service.ts +++ b/src/services/profile.service.ts @@ -44,7 +44,10 @@ export class ProfileService { }) as unknown as LearnerProfileRecord } - async updateProfile(userId: string, data: UpdateLearnerProfileData): Promise { + async updateProfile( + userId: string, + data: UpdateLearnerProfileData, + ): Promise { return prisma.learnerProfile.upsert({ where: { userId }, update: data, @@ -66,7 +69,7 @@ export class ProfileService { async updateProfileAudited( userId: string, data: UpdateLearnerProfileData, - context: AuditContext + context: AuditContext, ): Promise { return auditedMutation({ action: 'learner_profile.updated', @@ -77,13 +80,13 @@ export class ProfileService { ipAddress: context.ipAddress, userAgent: context.userAgent, metadata: { userId, fields: Object.keys(data).sort() }, - mutate: tx => + mutate: (tx) => tx.learnerProfile.upsert({ where: { userId }, update: data, create: { userId, ...data }, }) as unknown as Promise, - resolveTargetId: profile => profile.id, + resolveTargetId: (profile) => profile.id, }) } @@ -100,7 +103,9 @@ export class ProfileService { * Returns null for an unknown or tombstoned account, so the route answers 404 * rather than materialising a profile row for a user that no longer exists. */ - async getOwnerAccountProfile(userId: string): Promise { + async getOwnerAccountProfile( + userId: string, + ): Promise { const account = await prisma.user.findUnique({ where: { id: userId }, select: ACCOUNT_SELECT, @@ -125,8 +130,11 @@ export class ProfileService { profile, onboarding, consents, - requiredConsentsGranted: REQUIRED_CONSENT_PURPOSES.every(purpose => - consents.some(consent => consent.purpose === purpose && consent.status === 'granted') + requiredConsentsGranted: REQUIRED_CONSENT_PURPOSES.every((purpose) => + consents.some( + (consent) => + consent.purpose === purpose && consent.status === 'granted', + ), ), }) } @@ -185,12 +193,15 @@ export class ProfileService { * maps to 404. A loaded context with `allowed: false` means the profile exists * but must be redacted — a distinction the caller keeps to itself. */ - private async disclosureContext(userId: string): Promise< - { profile: LearnerProfileRecord; allowed: boolean } | null - > { + private async disclosureContext( + userId: string, + ): Promise<{ profile: LearnerProfileRecord; allowed: boolean } | null> { const [profile, account, consents] = await Promise.all([ prisma.learnerProfile.findFirst({ where: { userId } }), - prisma.user.findUnique({ where: { id: userId }, select: { status: true } }), + prisma.user.findUnique({ + where: { id: userId }, + select: { status: true }, + }), prisma.consentRecord.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, diff --git a/src/services/refresh-token.service.ts b/src/services/refresh-token.service.ts index 06060767..f3821d80 100644 --- a/src/services/refresh-token.service.ts +++ b/src/services/refresh-token.service.ts @@ -79,7 +79,10 @@ export class RefreshTokenService { userAgent?: string ipAddress?: string }): Promise { - const accessToken = issueAccessToken({ id: params.userId, role: params.role }) + const accessToken = issueAccessToken({ + id: params.userId, + role: params.role, + }) const refreshToken = generateOpaqueToken() const tokenHash = hashToken(refreshToken) const now = new Date() @@ -137,7 +140,10 @@ export class RefreshTokenService { * concurrent refreshes of the same token cannot both win — the loser is * treated exactly like a replay. */ - async rotate(rawToken: string, ctx: RefreshContext = {}): Promise { + async rotate( + rawToken: string, + ctx: RefreshContext = {}, + ): Promise { const tokenHash = hashToken(rawToken) const found = (await prisma.refreshToken.findUnique({ where: { tokenHash }, @@ -162,7 +168,12 @@ export class RefreshTokenService { // Replay signal: this token was already consumed by a previous rotation. if (found.status === 'ROTATED') { - await this.revokeFamily(found.familyId, found.sessionId, found.session.userId, ctx) + await this.revokeFamily( + found.familyId, + found.sessionId, + found.session.userId, + ctx, + ) return { kind: 'reuse' } } @@ -191,12 +202,20 @@ export class RefreshTokenService { if (claimed.count === 0) { // We lost the race: someone else rotated this token first → replay. - await this.revokeFamily(found.familyId, found.sessionId, found.session.userId, ctx) + await this.revokeFamily( + found.familyId, + found.sessionId, + found.session.userId, + ctx, + ) return { kind: 'reuse' } } - const accessToken = issueAccessToken({ id: found.session.userId, role: found.session.user.role }) + const accessToken = issueAccessToken({ + id: found.session.userId, + role: found.session.user.role, + }) const refreshToken = generateOpaqueToken() const newTokenHash = hashToken(refreshToken) const newRefreshTokenId = crypto.randomUUID() @@ -232,7 +251,10 @@ export class RefreshTokenService { * (logout-current). Idempotent: unknown or already-revoked tokens are a * no-op rather than an error, so the endpoint does not leak token validity. */ - async revokeByRefreshToken(rawToken: string, ctx: RefreshContext = {}): Promise { + async revokeByRefreshToken( + rawToken: string, + ctx: RefreshContext = {}, + ): Promise { const tokenHash = hashToken(rawToken) const found = await prisma.refreshToken.findUnique({ where: { tokenHash }, @@ -247,7 +269,13 @@ export class RefreshTokenService { return { revokedCount: 0 } } - await this.revokeFamily(found.familyId, found.sessionId, found.session.userId, ctx, SessionAuditAction.SESSION_LOGGED_OUT) + await this.revokeFamily( + found.familyId, + found.sessionId, + found.session.userId, + ctx, + SessionAuditAction.SESSION_LOGGED_OUT, + ) return { revokedCount: 1 } } @@ -256,7 +284,10 @@ export class RefreshTokenService { * Revoke every session belonging to the user identified by the given * refresh token (logout-all). Idempotent and neutral on unknown tokens. */ - async revokeAllByRefreshToken(rawToken: string, ctx: RefreshContext = {}): Promise { + async revokeAllByRefreshToken( + rawToken: string, + ctx: RefreshContext = {}, + ): Promise { const tokenHash = hashToken(rawToken) const found = await prisma.refreshToken.findUnique({ where: { tokenHash }, @@ -271,14 +302,17 @@ export class RefreshTokenService { } /** Revoke all sessions (and their refresh-token families) for a user. */ - async revokeAllForUser(userId: string, ctx: RefreshContext = {}): Promise { + async revokeAllForUser( + userId: string, + ctx: RefreshContext = {}, + ): Promise { const now = new Date() const sessions = await prisma.session.findMany({ where: { userId, isRevoked: false }, select: { id: true }, }) - const sessionIds = sessions.map(s => s.id) + const sessionIds = sessions.map((s) => s.id) if (sessionIds.length === 0) { return { revokedCount: 0 } @@ -313,7 +347,7 @@ export class RefreshTokenService { sessionId: string, userId: string, ctx: RefreshContext, - action: string = SessionAuditAction.REFRESH_REUSE_DETECTED + action: string = SessionAuditAction.REFRESH_REUSE_DETECTED, ): Promise { await prisma.$transaction([ prisma.refreshToken.updateMany({ diff --git a/src/services/reward.service.ts b/src/services/reward.service.ts index 83d72266..a0149097 100644 --- a/src/services/reward.service.ts +++ b/src/services/reward.service.ts @@ -12,10 +12,7 @@ import { // ─── Types ──────────────────────────────────────────────────────────────────── export type ModuleDifficulty = - | 'beginner' - | 'intermediate' - | 'advanced' - | 'expert' + 'beginner' | 'intermediate' | 'advanced' | 'expert' export interface Module { id: string diff --git a/src/services/session.service.ts b/src/services/session.service.ts index a178abdb..b643632b 100644 --- a/src/services/session.service.ts +++ b/src/services/session.service.ts @@ -1,6 +1,11 @@ import prisma from '../config/database' import { auditService } from './audit.service' -import { SessionAuditAction, SessionView, RevokeOneResult, RevokeAllResult } from '../types/session.types' +import { + SessionAuditAction, + SessionView, + RevokeOneResult, + RevokeAllResult, +} from '../types/session.types' import type { AuditEntry } from '../types/account.types' // ── Helpers ─────────────────────────────────────────────────────────────── @@ -33,7 +38,9 @@ export function redactIp(raw: string | null | undefined): string | null { * Truncate a fingerprint to its first 8 hex characters so it can be used * as a device-change signal without leaking the full device fingerprint. */ -export function redactFingerprint(raw: string | null | undefined): string | null { +export function redactFingerprint( + raw: string | null | undefined, +): string | null { if (!raw) return null return raw.slice(0, 8) @@ -55,7 +62,7 @@ export function toSessionView( lastUsedAt: Date | null expiresAt: Date }, - currentSessionId: string | null + currentSessionId: string | null, ): SessionView { return { id: session.id, @@ -85,12 +92,12 @@ export class SessionService { userId: string, currentSessionId: string | null, page: number, - limit: number + limit: number, ): Promise<{ sessions: SessionView[]; total: number }> { const now = new Date() const skip = (page - 1) * limit - const [rows, total] = await prisma.$transaction([ + const [rows, total] = (await prisma.$transaction([ prisma.session.findMany({ where: { userId, isRevoked: false, expiresAt: { gt: now } }, orderBy: [ @@ -116,28 +123,31 @@ export class SessionService { prisma.session.count({ where: { userId, isRevoked: false, expiresAt: { gt: now } }, }), - ]) as [Array<{ - id: string - deviceName: string | null - browser: string | null - os: string | null - country: string | null - city: string | null - createdAt: Date - lastUsedAt: Date | null - expiresAt: Date - }>, number] + ])) as [ + Array<{ + id: string + deviceName: string | null + browser: string | null + os: string | null + country: string | null + city: string | null + createdAt: Date + lastUsedAt: Date | null + expiresAt: Date + }>, + number, + ] // Sort so current session bubbles to the top within the page result set const sorted = currentSessionId ? [ - ...rows.filter(s => s.id === currentSessionId), - ...rows.filter(s => s.id !== currentSessionId), + ...rows.filter((s) => s.id === currentSessionId), + ...rows.filter((s) => s.id !== currentSessionId), ] : rows return { - sessions: sorted.map(s => toSessionView(s, currentSessionId)), + sessions: sorted.map((s) => toSessionView(s, currentSessionId)), total, } } @@ -171,7 +181,7 @@ export class SessionService { userId: string, sessionId: string, currentSessionId: string | null, - auditContext: Pick + auditContext: Pick, ): Promise { const session = await prisma.session.findUnique({ where: { id: sessionId }, @@ -213,7 +223,7 @@ export class SessionService { async revokeAll( userId: string, currentSessionId: string | null, - auditContext: Pick + auditContext: Pick, ): Promise { const now = new Date() @@ -234,7 +244,10 @@ export class SessionService { await auditService.record({ userId, action: SessionAuditAction.SESSION_ALL_REVOKED, - metadata: { revokedCount: count, keptCurrentSession: !!currentSessionId }, + metadata: { + revokedCount: count, + keptCurrentSession: !!currentSessionId, + }, ...auditContext, }) } diff --git a/src/services/stellar-funding.service.ts b/src/services/stellar-funding.service.ts index 95f46ae1..1dbfb171 100644 --- a/src/services/stellar-funding.service.ts +++ b/src/services/stellar-funding.service.ts @@ -69,8 +69,8 @@ export class StellarFundingService { if (record.status === 'submitted') { await this.reconcile(record) - -return + + return } const alreadyFunded = await this.checkAlreadyFunded(record) @@ -79,8 +79,8 @@ return const sourceSecret = process.env.STELLAR_FUNDING_SOURCE_SECRET if (!sourceSecret) { await this.handleFailure(record, 'Funding source secret not configured') - -return + + return } try { @@ -91,11 +91,7 @@ return memo: 'Account funding', }) - await this.markConfirmed( - record, - result.hash, - result.ledger - ) + await this.markConfirmed(record, result.hash, result.ledger) } catch (err) { if (err instanceof StellarServiceError) { if (err.code === 'TRANSACTION_TIMEOUT') { @@ -106,17 +102,20 @@ return error: 'Transaction submitted, awaiting confirmation', }, }) - -return + + return } if ( err.code === 'PAYMENT_ERROR' && this.isInsufficientFundsError(err) ) { - await this.handleFailure(record, 'Insufficient funding source balance') - -return + await this.handleFailure( + record, + 'Insufficient funding source balance', + ) + + return } } @@ -132,16 +131,16 @@ return if (record.transactionHash) { try { const succeeded = await this.stellarService.verifyTransaction( - record.transactionHash + record.transactionHash, ) if (succeeded) { await this.markConfirmed( record, record.transactionHash, - record.ledger ?? undefined + record.ledger ?? undefined, ) - -return + + return } } catch { // verification failed, fall through to retry @@ -150,33 +149,33 @@ return await this.handleFailure( record, - 'Reconciliation: funding not confirmed, retrying' + 'Reconciliation: funding not confirmed, retrying', ) } private async checkAlreadyFunded( - record: StellarFundingRecord + record: StellarFundingRecord, ): Promise { try { const balance = await this.stellarService.getNativeBalance( - record.publicKey + record.publicKey, ) if (parseFloat(balance) >= parseFloat(stellarConfig.funding.minBalance)) { await this.markConfirmed(record) - -return true + + return true } } catch { // balance check failed, continue to submit } - -return false + + return false } private async markConfirmed( record: StellarFundingRecord, transactionHash?: string, - ledger?: number + ledger?: number, ): Promise { const data: Record = { status: 'confirmed', @@ -193,7 +192,7 @@ return false private async handleFailure( record: StellarFundingRecord, - error: string + error: string, ): Promise { const nextAttemptCount = record.retryCount + 1 @@ -205,11 +204,9 @@ return false } else { const backoffMinutes = Math.pow( stellarConfig.funding.backoffBaseMinutes, - nextAttemptCount - 1 - ) - const nextAttemptAt = new Date( - Date.now() + backoffMinutes * 60_000 + nextAttemptCount - 1, ) + const nextAttemptAt = new Date(Date.now() + backoffMinutes * 60_000) await prisma.stellarFunding.update({ where: { id: record.id }, @@ -225,8 +222,8 @@ return false private isInsufficientFundsError(err: StellarServiceError): boolean { const causeMessage = err.cause instanceof Error ? err.cause.message : String(err.cause ?? '') - -return ( + + return ( causeMessage.includes('op_underfunded') || causeMessage.includes('insufficient') ) diff --git a/src/services/stellar.service.ts b/src/services/stellar.service.ts index d94d88dc..699c3230 100644 --- a/src/services/stellar.service.ts +++ b/src/services/stellar.service.ts @@ -1,743 +1,788 @@ -/** - * stellar.service.ts - * - * Service layer for all Stellar blockchain interactions. - * - * Compatible with @stellar/stellar-sdk v11.x - * where the Soroban RPC namespace is `rpc`, not `SorobanRpc`. - * - * Env vars: - * STELLAR_NETWORK=testnet | mainnet (default: testnet) - * SOROBAN_CONTRACT_ID=C... (your deployed credential contract) - */ - -import { - Asset, - BASE_FEE, - Contract, - Horizon, - Keypair, - Memo, - Networks, - Operation, - TransactionBuilder, - nativeToScVal, - rpc, - scValToNative, -} from '@stellar/stellar-sdk' - -// --------------------------------------------------------------------------- -// Local type aliases — keeps the rest of the file readable -// --------------------------------------------------------------------------- - -export type RpcServer = rpc.Server; -export type SimulateTransactionResponse = rpc.Api.SimulateTransactionResponse; -export type GetTransactionResponse = rpc.Api.GetTransactionResponse; - -// --------------------------------------------------------------------------- -// Balance row shape returned from Horizon via rpc.Server.getAccount() -// --------------------------------------------------------------------------- - -interface NativeBalance { - asset_type: 'native'; - balance: string; -} - -interface IssuedBalance { - asset_type: 'credit_alphanum4' | 'credit_alphanum12'; - asset_code: string; - asset_issuer: string; - balance: string; - limit: string; -} - -export type HorizonBalance = NativeBalance | IssuedBalance; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -export interface StellarWallet { - publicKey: string; - secretKey: string; -} - -export interface AccountBalance { - asset: string; - balance: string; - limit?: string; -} - -export interface AccountBalanceDetail { - assetType: 'native' | 'credit_alphanum4' | 'credit_alphanum12'; - assetCode: string; - issuer: string | null; - amount: string; -} - -export interface AccountSnapshot { - found: boolean; - lastModifiedTime: string | null; - balances: AccountBalanceDetail[]; -} - -export interface PaymentHistoryRecord { - id: string; - pagingToken: string; - createdAt: string; - transactionHash: string; - transactionSuccessful: boolean; - ledger: number | null; - type: string; - from: string | null; - to: string | null; - assetType: string; - assetCode: string; - issuer: string | null; - amount: string | null; - memo: string | null; - memoType: string | null; -} - -export interface PaymentHistoryPage { - records: PaymentHistoryRecord[]; - nextCursor: string | null; -} - -export interface PaymentOptions { - sourceSecret: string; - destinationPublicKey: string; - amount: string; - asset?: Asset; - memo?: string; -} - -export interface PaymentResult { - hash: string; - ledger: number; - successful: boolean; -} - -export interface CredentialData { - recipientPublicKey: string; - credentialType: string; - data: Record; - expiresAt?: number; -} - -export interface CredentialResult { - contractId: string; - transactionHash: string; - credentialId: string; -} - -export interface VerificationResult { - isValid: boolean; - credentialId: string; - issuer: string; - recipient: string; - credentialType: string; - issuedAt: number; - expiresAt?: number; - data: Record; -} - -// --------------------------------------------------------------------------- -// Network configuration -// --------------------------------------------------------------------------- - -type NetworkName = 'testnet' | 'mainnet'; - -const NETWORK_CONFIG: Record< - NetworkName, - { networkPassphrase: string; rpcUrl: string; horizonUrl: string } -> = { - testnet: { - networkPassphrase: Networks.TESTNET, - rpcUrl: 'https://soroban-testnet.stellar.org', - horizonUrl: 'https://horizon-testnet.stellar.org', - }, - mainnet: { - networkPassphrase: Networks.PUBLIC, - rpcUrl: 'https://mainnet.stellar.validationcloud.io/v1/[your-key]', - horizonUrl: 'https://horizon.stellar.org', - }, -} - -// --------------------------------------------------------------------------- -// Custom error -// --------------------------------------------------------------------------- - -export class StellarServiceError extends Error { - constructor( - message: string, - public readonly code: string, - public readonly cause?: unknown - ) { - super(message) - this.name = 'StellarServiceError' - } -} - -// --------------------------------------------------------------------------- -// StellarService -// --------------------------------------------------------------------------- - -export class StellarService { - private readonly server: rpc.Server - private readonly horizonServer: Horizon.Server - private readonly networkPassphrase: string - private readonly contractId: string - private readonly network: NetworkName - - constructor( - network: NetworkName = (process.env.STELLAR_NETWORK as NetworkName) ?? 'testnet', - contractId: string = process.env.SOROBAN_CONTRACT_ID ?? '' - ) { - this.network = network - const config = NETWORK_CONFIG[network] - this.networkPassphrase = config.networkPassphrase - this.contractId = contractId - - this.server = new rpc.Server(config.rpcUrl, { - allowHttp: network === 'testnet', - }) - this.horizonServer = new Horizon.Server(config.horizonUrl, { - allowHttp: network === 'testnet', - }) - } - - // ── Wallet generation ───────────────────────────────────────────────────── - - generateWallet (): StellarWallet { - try { - const keypair = Keypair.random() - - return { - publicKey: keypair.publicKey(), - secretKey: keypair.secret(), - } - } catch (err) { - throw new StellarServiceError( - 'Failed to generate Stellar wallet', - 'WALLET_GENERATION_ERROR', - err - ) - } - } - - /** Fund a testnet account via Friendbot (testnet only). */ - async fundTestnetAccount (publicKey: string): Promise { - if (this.network !== 'testnet') { - throw new StellarServiceError( - 'Friendbot is only available on testnet', - 'INVALID_NETWORK' - ) - } - try { - const res = await fetch( - `https://friendbot.stellar.org?addr=${encodeURIComponent(publicKey)}` - ) - if (!res.ok) { - throw new Error(`Friendbot returned ${res.status}`) - } - } catch (err) { - if (err instanceof StellarServiceError) throw err - throw new StellarServiceError( - 'Failed to fund testnet account via Friendbot', - 'FRIENDBOT_ERROR', - err - ) - } - } - - // ── Balances ────────────────────────────────────────────────────────────── - - async getBalances (publicKey: string): Promise { - try { - const account = await this.horizonServer.loadAccount(publicKey) - - return account.balances.map((b) => { - const assetName = - b.asset_type === 'native' - ? 'XLM' - : `${(b as { asset_code: string }).asset_code}:${(b as { asset_issuer: string }).asset_issuer - }` - - return { - asset: assetName, - balance: b.balance, - limit: - b.asset_type !== 'native' - ? (b as { limit: string }).limit - : undefined, - } - }) - } catch (err) { - throw new StellarServiceError( - `Failed to fetch balances for ${publicKey}`, - 'BALANCE_FETCH_ERROR', - err - ) - } - } - - async getNativeBalance (publicKey: string): Promise { - const balances = await this.getBalances(publicKey) - - return balances.find((b) => b.asset === 'XLM')?.balance ?? '0' - } - - /** - * Load the account's exact balances plus its Horizon last-modified time. - * A 404 (account not yet funded on-ledger) is a normal, non-error state and - * resolves to `found: false` with no balances rather than throwing. - */ - async getAccountSnapshot (publicKey: string): Promise { - try { - const account = await this.horizonServer.loadAccount(publicKey) - const balances: AccountBalanceDetail[] = account.balances.map((b) => { - if (b.asset_type === 'native') { - return { assetType: 'native', assetCode: 'XLM', issuer: null, amount: b.balance } - } - const issued = b as unknown as IssuedBalance - - return { - assetType: issued.asset_type, - assetCode: issued.asset_code, - issuer: issued.asset_issuer, - amount: issued.balance, - } - }) - - return { - found: true, - lastModifiedTime: - (account as unknown as { last_modified_time?: string }).last_modified_time ?? null, - balances, - } - } catch (err) { - if (isHorizonNotFound(err)) { - return { found: false, lastModifiedTime: null, balances: [] } - } - if (isHorizonTimeout(err)) { - throw new StellarServiceError('Horizon request timed out', 'HORIZON_TIMEOUT', err) - } - throw new StellarServiceError('Horizon is unavailable', 'HORIZON_UNAVAILABLE', err) - } - } - - /** - * Cursor-paginated payment history for an account (payments, path payments, - * and account-creation credits), using Horizon's own paging_token as the - * cursor so results stay stable under concurrent ledger writes. - */ - async getPaymentHistory ( - publicKey: string, - options: { cursor?: string; limit?: number; order?: 'asc' | 'desc' } = {} - ): Promise { - const limit = options.limit ?? 20 - - try { - let builder = this.horizonServer - .payments() - .forAccount(publicKey) - .order(options.order ?? 'desc') - .limit(limit) - .join('transactions') - - if (options.cursor) builder = builder.cursor(options.cursor) - - const page = await builder.call() - const relevant = page.records.filter((record) => - HISTORY_OPERATION_TYPES.has((record as { type: string }).type) - ) - - return { - records: relevant.map(toPaymentHistoryRecord), - nextCursor: - page.records.length > 0 - ? (page.records[page.records.length - 1] as { paging_token: string }).paging_token - : null, - } - } catch (err) { - if (isHorizonNotFound(err)) return { records: [], nextCursor: null } - if (isHorizonTimeout(err)) { - throw new StellarServiceError('Horizon request timed out', 'HORIZON_TIMEOUT', err) - } - throw new StellarServiceError('Horizon is unavailable', 'HORIZON_UNAVAILABLE', err) - } - } - - // ── Payments ────────────────────────────────────────────────────────────── - - /** Alias kept for test compatibility. */ - async sendPaymentWithOptions (options: PaymentOptions): Promise { - return this.sendPayment(options) - } - - async sendPayment (options: PaymentOptions): Promise { - const { sourceSecret, destinationPublicKey, amount, memo } = options - const asset = options.asset ?? Asset.native() - - try { - const sourceKeypair = Keypair.fromSecret(sourceSecret) - const sourcePublicKey = sourceKeypair.publicKey() - - // Load source account (needed for sequence number) - const sourceAccount = await this.horizonServer.loadAccount(sourcePublicKey) - - // Make sure destination exists (create it if sending XLM and it doesn't exist) - let destinationExists = true - try { - await this.horizonServer.loadAccount(destinationPublicKey) - } catch { - destinationExists = false - } - - const builder = new TransactionBuilder(sourceAccount, { - fee: BASE_FEE, - networkPassphrase: this.networkPassphrase, - }) - - if (!destinationExists && asset === Asset.native()) { - builder.addOperation( - Operation.createAccount({ - destination: destinationPublicKey, - startingBalance: amount, - }) - ) - } else { - builder.addOperation( - Operation.payment({ - destination: destinationPublicKey, - asset, - amount, - }) - ) - } - - if (memo) builder.addMemo(Memo.text(memo)) - - const transaction = builder.setTimeout(30).build() - transaction.sign(sourceKeypair) - - const response = await this.horizonServer.submitTransaction(transaction) - - if (!response.successful) { - throw new Error( - `Transaction failed: ${JSON.stringify(response)}` - ) - } - - // Poll for confirmation - const _confirmed = await this.waitForTransaction(response.hash) - - // For regular payments, Horizon response includes ledger - // For Soroban transactions, we'll need to poll the RPC server - let ledger = 0 - if ('ledger' in response && response.ledger) { - ledger = response.ledger - } else { - // Try to get from RPC server for Soroban transactions - try { - const rpcResult = await this.waitForTransaction(response.hash) - if ('ledger' in rpcResult && rpcResult.ledger) { - ledger = rpcResult.ledger - } - } catch { - // Fallback to 0 if we can't get the ledger - ledger = 0 - } - } - - return { - hash: response.hash, - ledger: ledger, - successful: response.successful, - } - } catch (err) { - if (err instanceof StellarServiceError) throw err - throw new StellarServiceError( - 'Payment transaction failed', - 'PAYMENT_ERROR', - err - ) - } - } - - // ── Credential issuance ─────────────────────────────────────────────────── - - async issueCredential ( - issuerSecret: string, - credential: CredentialData - ): Promise { - if (!this.contractId) { - throw new StellarServiceError( - 'No Soroban contract ID configured', - 'CONTRACT_NOT_CONFIGURED' - ) - } - - try { - const issuerKeypair = Keypair.fromSecret(issuerSecret) - const issuerAccount = await this.horizonServer.loadAccount( - issuerKeypair.publicKey() - ) - - const contract = new Contract(this.contractId) - - const args = [ - nativeToScVal(credential.recipientPublicKey, { type: 'address' }), - nativeToScVal(credential.credentialType, { type: 'string' }), - nativeToScVal(JSON.stringify(credential.data), { type: 'string' }), - nativeToScVal(credential.expiresAt ?? 0, { type: 'u64' }), - ] - - const transaction = new TransactionBuilder(issuerAccount, { - fee: BASE_FEE, - networkPassphrase: this.networkPassphrase, - }) - .addOperation(contract.call('issue_credential', ...args)) - .setTimeout(30) - .build() - - const simResult: SimulateTransactionResponse = - await this.server.simulateTransaction(transaction) - - if (rpc.Api.isSimulationError(simResult)) { - throw new Error(`Simulation failed: ${simResult.error}`) - } - - // assembleTransaction lives on the `rpc` namespace in v11 - const assembledTx = rpc.assembleTransaction(transaction, simResult).build() - assembledTx.sign(issuerKeypair) - - const sendResult = await this.server.sendTransaction(assembledTx) - const confirmed = await this.waitForTransaction(sendResult.hash) - - return { - contractId: this.contractId, - transactionHash: sendResult.hash, - credentialId: this.extractReturnValue(confirmed), - } - } catch (err) { - if (err instanceof StellarServiceError) throw err - throw new StellarServiceError( - 'Credential issuance failed', - 'CREDENTIAL_ISSUANCE_ERROR', - err - ) - } - } - - // ── Credential verification ─────────────────────────────────────────────── - - async verifyCredential (credentialId: string): Promise { - if (!this.contractId) { - throw new StellarServiceError( - 'No Soroban contract ID configured', - 'CONTRACT_NOT_CONFIGURED' - ) - } - - try { - const contract = new Contract(this.contractId) - const operation = contract.call( - 'verify_credential', - nativeToScVal(credentialId, { type: 'string' }) - ) - - // For read-only calls we simulate without signing - const dummyAccount = await this.horizonServer.loadAccount( - // Use a well-known testnet account for simulation if no source available - 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN' - ) - - const tx = new TransactionBuilder(dummyAccount, { - fee: BASE_FEE, - networkPassphrase: this.networkPassphrase, - }) - .addOperation(operation) - .setTimeout(30) - .build() - - const simResult: SimulateTransactionResponse = - await this.server.simulateTransaction(tx) - - if (rpc.Api.isSimulationError(simResult)) { - throw new Error(`Verification simulation failed: ${simResult.error}`) - } - - const returnVal = - rpc.Api.isSimulationSuccess(simResult) && simResult.result - ? scValToNative(simResult.result.retval) - : null - - if (!returnVal) { - return { - isValid: false, - credentialId, - issuer: '', - recipient: '', - credentialType: '', - issuedAt: 0, - data: {}, - } - } - - const parsed = returnVal as Record - - return { - isValid: true, - credentialId, - issuer: String(parsed.issuer ?? ''), - recipient: String(parsed.recipient ?? ''), - credentialType: String(parsed.credential_type ?? ''), - issuedAt: Number(parsed.issued_at ?? 0), - expiresAt: parsed.expires_at ? Number(parsed.expires_at) : undefined, - data: parsed.data ? JSON.parse(String(parsed.data)) : {}, - } - } catch (err) { - if (err instanceof StellarServiceError) throw err - throw new StellarServiceError( - 'Credential verification failed', - 'CREDENTIAL_VERIFICATION_ERROR', - err - ) - } - } - - // ── Transaction status check ────────────────────────────────────────────── - - async verifyTransaction (hash: string): Promise { - try { - const result = await this.server.getTransaction(hash) - - return result.status === rpc.Api.GetTransactionStatus.SUCCESS - } catch (err) { - throw new StellarServiceError( - `Failed to verify transaction ${hash}`, - 'TRANSACTION_VERIFY_ERROR', - err - ) - } - } - - // ── Private helpers ─────────────────────────────────────────────────────── - - private async waitForTransaction ( - hash: string, - maxAttempts = 20, - intervalMs = 2000 - ): Promise { - for (let i = 0; i < maxAttempts; i++) { - await new Promise((r) => setTimeout(r, intervalMs)) - const result = await this.server.getTransaction(hash) - - if (result.status !== rpc.Api.GetTransactionStatus.NOT_FOUND) { - return result - } - } - throw new StellarServiceError( - `Transaction ${hash} not confirmed after ${maxAttempts} attempts`, - 'TRANSACTION_TIMEOUT' - ) - } - - private extractReturnValue ( - txResult: rpc.Api.GetTransactionResponse - ): string { - try { - if ( - txResult.status === rpc.Api.GetTransactionStatus.SUCCESS && - txResult.returnValue - ) { - const native = scValToNative(txResult.returnValue) - - return String(native) - } - } catch { - // fall through - } - - return `cred_${Date.now()}` - } -} - -// --------------------------------------------------------------------------- -// Payment history helpers -// --------------------------------------------------------------------------- - -const HISTORY_OPERATION_TYPES = new Set([ - 'payment', - 'create_account', - 'path_payment_strict_receive', - 'path_payment_strict_send', -]) - -const MAX_MEMO_LENGTH = 256 - -/** Text memos are free-form user input; hash/id/return memos are opaque public identifiers already. */ -function applyMemoPolicy (memoType: string | null, memo: string | null): string | null { - if (!memo) return null - if (memoType === 'text') { - // eslint-disable-next-line no-control-regex - const sanitized = memo.replace(/[\x00-\x1F\x7F]/g, '').slice(0, MAX_MEMO_LENGTH) - - return sanitized.length > 0 ? sanitized : null - } - - return memo -} - -function toPaymentHistoryRecord (record: unknown): PaymentHistoryRecord { - const r = record as Record - const transaction = (r.transaction ?? undefined) as Record | undefined - const memoType = (transaction?.memo_type as string | undefined) ?? null - const rawMemo = (transaction?.memo as string | undefined) ?? null - const assetType = (r.asset_type as string | undefined) ?? 'native' - - return { - id: String(r.id), - pagingToken: String(r.paging_token), - createdAt: String(r.created_at), - transactionHash: String(r.transaction_hash), - transactionSuccessful: r.transaction_successful !== false, - ledger: typeof transaction?.ledger_attr === 'number' ? (transaction.ledger_attr as number) : null, - type: String(r.type), - from: (r.from as string | undefined) ?? (r.funder as string | undefined) ?? null, - to: (r.to as string | undefined) ?? (r.account as string | undefined) ?? null, - assetType, - assetCode: assetType === 'native' ? 'XLM' : String(r.asset_code ?? ''), - issuer: (r.asset_issuer as string | undefined) ?? null, - amount: (r.amount as string | undefined) ?? (r.starting_balance as string | undefined) ?? null, - memo: applyMemoPolicy(memoType, rawMemo), - memoType, - } -} - -function isHorizonNotFound (err: unknown): boolean { - const status = (err as { response?: { status?: number } } | undefined)?.response?.status - - return status === 404 -} - -function isHorizonTimeout (err: unknown): boolean { - const code = (err as { code?: string } | undefined)?.code - const name = (err as { name?: string } | undefined)?.name - const message = err instanceof Error ? err.message.toLowerCase() : '' - - return ( - code === 'ETIMEDOUT' || - code === 'ECONNABORTED' || - name === 'TimeoutError' || - message.includes('timeout') - ) -} - -// --------------------------------------------------------------------------- -// Singleton export -// --------------------------------------------------------------------------- - -export const stellarService = new StellarService() \ No newline at end of file +/** + * stellar.service.ts + * + * Service layer for all Stellar blockchain interactions. + * + * Compatible with @stellar/stellar-sdk v11.x + * where the Soroban RPC namespace is `rpc`, not `SorobanRpc`. + * + * Env vars: + * STELLAR_NETWORK=testnet | mainnet (default: testnet) + * SOROBAN_CONTRACT_ID=C... (your deployed credential contract) + */ + +import { + Asset, + BASE_FEE, + Contract, + Horizon, + Keypair, + Memo, + Networks, + Operation, + TransactionBuilder, + nativeToScVal, + rpc, + scValToNative, +} from '@stellar/stellar-sdk' + +// --------------------------------------------------------------------------- +// Local type aliases — keeps the rest of the file readable +// --------------------------------------------------------------------------- + +export type RpcServer = rpc.Server +export type SimulateTransactionResponse = rpc.Api.SimulateTransactionResponse +export type GetTransactionResponse = rpc.Api.GetTransactionResponse + +// --------------------------------------------------------------------------- +// Balance row shape returned from Horizon via rpc.Server.getAccount() +// --------------------------------------------------------------------------- + +interface NativeBalance { + asset_type: 'native' + balance: string +} + +interface IssuedBalance { + asset_type: 'credit_alphanum4' | 'credit_alphanum12' + asset_code: string + asset_issuer: string + balance: string + limit: string +} + +export type HorizonBalance = NativeBalance | IssuedBalance + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface StellarWallet { + publicKey: string + secretKey: string +} + +export interface AccountBalance { + asset: string + balance: string + limit?: string +} + +export interface AccountBalanceDetail { + assetType: 'native' | 'credit_alphanum4' | 'credit_alphanum12' + assetCode: string + issuer: string | null + amount: string +} + +export interface AccountSnapshot { + found: boolean + lastModifiedTime: string | null + balances: AccountBalanceDetail[] +} + +export interface PaymentHistoryRecord { + id: string + pagingToken: string + createdAt: string + transactionHash: string + transactionSuccessful: boolean + ledger: number | null + type: string + from: string | null + to: string | null + assetType: string + assetCode: string + issuer: string | null + amount: string | null + memo: string | null + memoType: string | null +} + +export interface PaymentHistoryPage { + records: PaymentHistoryRecord[] + nextCursor: string | null +} + +export interface PaymentOptions { + sourceSecret: string + destinationPublicKey: string + amount: string + asset?: Asset + memo?: string +} + +export interface PaymentResult { + hash: string + ledger: number + successful: boolean +} + +export interface CredentialData { + recipientPublicKey: string + credentialType: string + data: Record + expiresAt?: number +} + +export interface CredentialResult { + contractId: string + transactionHash: string + credentialId: string +} + +export interface VerificationResult { + isValid: boolean + credentialId: string + issuer: string + recipient: string + credentialType: string + issuedAt: number + expiresAt?: number + data: Record +} + +// --------------------------------------------------------------------------- +// Network configuration +// --------------------------------------------------------------------------- + +type NetworkName = 'testnet' | 'mainnet' + +const NETWORK_CONFIG: Record< + NetworkName, + { networkPassphrase: string; rpcUrl: string; horizonUrl: string } +> = { + testnet: { + networkPassphrase: Networks.TESTNET, + rpcUrl: 'https://soroban-testnet.stellar.org', + horizonUrl: 'https://horizon-testnet.stellar.org', + }, + mainnet: { + networkPassphrase: Networks.PUBLIC, + rpcUrl: 'https://mainnet.stellar.validationcloud.io/v1/[your-key]', + horizonUrl: 'https://horizon.stellar.org', + }, +} + +// --------------------------------------------------------------------------- +// Custom error +// --------------------------------------------------------------------------- + +export class StellarServiceError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly cause?: unknown, + ) { + super(message) + this.name = 'StellarServiceError' + } +} + +// --------------------------------------------------------------------------- +// StellarService +// --------------------------------------------------------------------------- + +export class StellarService { + private readonly server: rpc.Server + private readonly horizonServer: Horizon.Server + private readonly networkPassphrase: string + private readonly contractId: string + private readonly network: NetworkName + + constructor( + network: NetworkName = (process.env.STELLAR_NETWORK as NetworkName) ?? + 'testnet', + contractId: string = process.env.SOROBAN_CONTRACT_ID ?? '', + ) { + this.network = network + const config = NETWORK_CONFIG[network] + this.networkPassphrase = config.networkPassphrase + this.contractId = contractId + + this.server = new rpc.Server(config.rpcUrl, { + allowHttp: network === 'testnet', + }) + this.horizonServer = new Horizon.Server(config.horizonUrl, { + allowHttp: network === 'testnet', + }) + } + + // ── Wallet generation ───────────────────────────────────────────────────── + + generateWallet(): StellarWallet { + try { + const keypair = Keypair.random() + + return { + publicKey: keypair.publicKey(), + secretKey: keypair.secret(), + } + } catch (err) { + throw new StellarServiceError( + 'Failed to generate Stellar wallet', + 'WALLET_GENERATION_ERROR', + err, + ) + } + } + + /** Fund a testnet account via Friendbot (testnet only). */ + async fundTestnetAccount(publicKey: string): Promise { + if (this.network !== 'testnet') { + throw new StellarServiceError( + 'Friendbot is only available on testnet', + 'INVALID_NETWORK', + ) + } + try { + const res = await fetch( + `https://friendbot.stellar.org?addr=${encodeURIComponent(publicKey)}`, + ) + if (!res.ok) { + throw new Error(`Friendbot returned ${res.status}`) + } + } catch (err) { + if (err instanceof StellarServiceError) throw err + throw new StellarServiceError( + 'Failed to fund testnet account via Friendbot', + 'FRIENDBOT_ERROR', + err, + ) + } + } + + // ── Balances ────────────────────────────────────────────────────────────── + + async getBalances(publicKey: string): Promise { + try { + const account = await this.horizonServer.loadAccount(publicKey) + + return account.balances.map((b) => { + const assetName = + b.asset_type === 'native' + ? 'XLM' + : `${(b as { asset_code: string }).asset_code}:${ + (b as { asset_issuer: string }).asset_issuer + }` + + return { + asset: assetName, + balance: b.balance, + limit: + b.asset_type !== 'native' + ? (b as { limit: string }).limit + : undefined, + } + }) + } catch (err) { + throw new StellarServiceError( + `Failed to fetch balances for ${publicKey}`, + 'BALANCE_FETCH_ERROR', + err, + ) + } + } + + async getNativeBalance(publicKey: string): Promise { + const balances = await this.getBalances(publicKey) + + return balances.find((b) => b.asset === 'XLM')?.balance ?? '0' + } + + /** + * Load the account's exact balances plus its Horizon last-modified time. + * A 404 (account not yet funded on-ledger) is a normal, non-error state and + * resolves to `found: false` with no balances rather than throwing. + */ + async getAccountSnapshot(publicKey: string): Promise { + try { + const account = await this.horizonServer.loadAccount(publicKey) + const balances: AccountBalanceDetail[] = account.balances.map((b) => { + if (b.asset_type === 'native') { + return { + assetType: 'native', + assetCode: 'XLM', + issuer: null, + amount: b.balance, + } + } + const issued = b as unknown as IssuedBalance + + return { + assetType: issued.asset_type, + assetCode: issued.asset_code, + issuer: issued.asset_issuer, + amount: issued.balance, + } + }) + + return { + found: true, + lastModifiedTime: + (account as unknown as { last_modified_time?: string }) + .last_modified_time ?? null, + balances, + } + } catch (err) { + if (isHorizonNotFound(err)) { + return { found: false, lastModifiedTime: null, balances: [] } + } + if (isHorizonTimeout(err)) { + throw new StellarServiceError( + 'Horizon request timed out', + 'HORIZON_TIMEOUT', + err, + ) + } + throw new StellarServiceError( + 'Horizon is unavailable', + 'HORIZON_UNAVAILABLE', + err, + ) + } + } + + /** + * Cursor-paginated payment history for an account (payments, path payments, + * and account-creation credits), using Horizon's own paging_token as the + * cursor so results stay stable under concurrent ledger writes. + */ + async getPaymentHistory( + publicKey: string, + options: { cursor?: string; limit?: number; order?: 'asc' | 'desc' } = {}, + ): Promise { + const limit = options.limit ?? 20 + + try { + let builder = this.horizonServer + .payments() + .forAccount(publicKey) + .order(options.order ?? 'desc') + .limit(limit) + .join('transactions') + + if (options.cursor) builder = builder.cursor(options.cursor) + + const page = await builder.call() + const relevant = page.records.filter((record) => + HISTORY_OPERATION_TYPES.has((record as { type: string }).type), + ) + + return { + records: relevant.map(toPaymentHistoryRecord), + nextCursor: + page.records.length > 0 + ? ( + page.records[page.records.length - 1] as { + paging_token: string + } + ).paging_token + : null, + } + } catch (err) { + if (isHorizonNotFound(err)) return { records: [], nextCursor: null } + if (isHorizonTimeout(err)) { + throw new StellarServiceError( + 'Horizon request timed out', + 'HORIZON_TIMEOUT', + err, + ) + } + throw new StellarServiceError( + 'Horizon is unavailable', + 'HORIZON_UNAVAILABLE', + err, + ) + } + } + + // ── Payments ────────────────────────────────────────────────────────────── + + /** Alias kept for test compatibility. */ + async sendPaymentWithOptions( + options: PaymentOptions, + ): Promise { + return this.sendPayment(options) + } + + async sendPayment(options: PaymentOptions): Promise { + const { sourceSecret, destinationPublicKey, amount, memo } = options + const asset = options.asset ?? Asset.native() + + try { + const sourceKeypair = Keypair.fromSecret(sourceSecret) + const sourcePublicKey = sourceKeypair.publicKey() + + // Load source account (needed for sequence number) + const sourceAccount = + await this.horizonServer.loadAccount(sourcePublicKey) + + // Make sure destination exists (create it if sending XLM and it doesn't exist) + let destinationExists = true + try { + await this.horizonServer.loadAccount(destinationPublicKey) + } catch { + destinationExists = false + } + + const builder = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + + if (!destinationExists && asset === Asset.native()) { + builder.addOperation( + Operation.createAccount({ + destination: destinationPublicKey, + startingBalance: amount, + }), + ) + } else { + builder.addOperation( + Operation.payment({ + destination: destinationPublicKey, + asset, + amount, + }), + ) + } + + if (memo) builder.addMemo(Memo.text(memo)) + + const transaction = builder.setTimeout(30).build() + transaction.sign(sourceKeypair) + + const response = await this.horizonServer.submitTransaction(transaction) + + if (!response.successful) { + throw new Error(`Transaction failed: ${JSON.stringify(response)}`) + } + + // Poll for confirmation + const _confirmed = await this.waitForTransaction(response.hash) + + // For regular payments, Horizon response includes ledger + // For Soroban transactions, we'll need to poll the RPC server + let ledger = 0 + if ('ledger' in response && response.ledger) { + ledger = response.ledger + } else { + // Try to get from RPC server for Soroban transactions + try { + const rpcResult = await this.waitForTransaction(response.hash) + if ('ledger' in rpcResult && rpcResult.ledger) { + ledger = rpcResult.ledger + } + } catch { + // Fallback to 0 if we can't get the ledger + ledger = 0 + } + } + + return { + hash: response.hash, + ledger: ledger, + successful: response.successful, + } + } catch (err) { + if (err instanceof StellarServiceError) throw err + throw new StellarServiceError( + 'Payment transaction failed', + 'PAYMENT_ERROR', + err, + ) + } + } + + // ── Credential issuance ─────────────────────────────────────────────────── + + async issueCredential( + issuerSecret: string, + credential: CredentialData, + ): Promise { + if (!this.contractId) { + throw new StellarServiceError( + 'No Soroban contract ID configured', + 'CONTRACT_NOT_CONFIGURED', + ) + } + + try { + const issuerKeypair = Keypair.fromSecret(issuerSecret) + const issuerAccount = await this.horizonServer.loadAccount( + issuerKeypair.publicKey(), + ) + + const contract = new Contract(this.contractId) + + const args = [ + nativeToScVal(credential.recipientPublicKey, { type: 'address' }), + nativeToScVal(credential.credentialType, { type: 'string' }), + nativeToScVal(JSON.stringify(credential.data), { type: 'string' }), + nativeToScVal(credential.expiresAt ?? 0, { type: 'u64' }), + ] + + const transaction = new TransactionBuilder(issuerAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call('issue_credential', ...args)) + .setTimeout(30) + .build() + + const simResult: SimulateTransactionResponse = + await this.server.simulateTransaction(transaction) + + if (rpc.Api.isSimulationError(simResult)) { + throw new Error(`Simulation failed: ${simResult.error}`) + } + + // assembleTransaction lives on the `rpc` namespace in v11 + const assembledTx = rpc + .assembleTransaction(transaction, simResult) + .build() + assembledTx.sign(issuerKeypair) + + const sendResult = await this.server.sendTransaction(assembledTx) + const confirmed = await this.waitForTransaction(sendResult.hash) + + return { + contractId: this.contractId, + transactionHash: sendResult.hash, + credentialId: this.extractReturnValue(confirmed), + } + } catch (err) { + if (err instanceof StellarServiceError) throw err + throw new StellarServiceError( + 'Credential issuance failed', + 'CREDENTIAL_ISSUANCE_ERROR', + err, + ) + } + } + + // ── Credential verification ─────────────────────────────────────────────── + + async verifyCredential(credentialId: string): Promise { + if (!this.contractId) { + throw new StellarServiceError( + 'No Soroban contract ID configured', + 'CONTRACT_NOT_CONFIGURED', + ) + } + + try { + const contract = new Contract(this.contractId) + const operation = contract.call( + 'verify_credential', + nativeToScVal(credentialId, { type: 'string' }), + ) + + // For read-only calls we simulate without signing + const dummyAccount = await this.horizonServer.loadAccount( + // Use a well-known testnet account for simulation if no source available + 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', + ) + + const tx = new TransactionBuilder(dummyAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build() + + const simResult: SimulateTransactionResponse = + await this.server.simulateTransaction(tx) + + if (rpc.Api.isSimulationError(simResult)) { + throw new Error(`Verification simulation failed: ${simResult.error}`) + } + + const returnVal = + rpc.Api.isSimulationSuccess(simResult) && simResult.result + ? scValToNative(simResult.result.retval) + : null + + if (!returnVal) { + return { + isValid: false, + credentialId, + issuer: '', + recipient: '', + credentialType: '', + issuedAt: 0, + data: {}, + } + } + + const parsed = returnVal as Record + + return { + isValid: true, + credentialId, + issuer: String(parsed.issuer ?? ''), + recipient: String(parsed.recipient ?? ''), + credentialType: String(parsed.credential_type ?? ''), + issuedAt: Number(parsed.issued_at ?? 0), + expiresAt: parsed.expires_at ? Number(parsed.expires_at) : undefined, + data: parsed.data ? JSON.parse(String(parsed.data)) : {}, + } + } catch (err) { + if (err instanceof StellarServiceError) throw err + throw new StellarServiceError( + 'Credential verification failed', + 'CREDENTIAL_VERIFICATION_ERROR', + err, + ) + } + } + + // ── Transaction status check ────────────────────────────────────────────── + + async verifyTransaction(hash: string): Promise { + try { + const result = await this.server.getTransaction(hash) + + return result.status === rpc.Api.GetTransactionStatus.SUCCESS + } catch (err) { + throw new StellarServiceError( + `Failed to verify transaction ${hash}`, + 'TRANSACTION_VERIFY_ERROR', + err, + ) + } + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private async waitForTransaction( + hash: string, + maxAttempts = 20, + intervalMs = 2000, + ): Promise { + for (let i = 0; i < maxAttempts; i++) { + await new Promise((r) => setTimeout(r, intervalMs)) + const result = await this.server.getTransaction(hash) + + if (result.status !== rpc.Api.GetTransactionStatus.NOT_FOUND) { + return result + } + } + throw new StellarServiceError( + `Transaction ${hash} not confirmed after ${maxAttempts} attempts`, + 'TRANSACTION_TIMEOUT', + ) + } + + private extractReturnValue(txResult: rpc.Api.GetTransactionResponse): string { + try { + if ( + txResult.status === rpc.Api.GetTransactionStatus.SUCCESS && + txResult.returnValue + ) { + const native = scValToNative(txResult.returnValue) + + return String(native) + } + } catch { + // fall through + } + + return `cred_${Date.now()}` + } +} + +// --------------------------------------------------------------------------- +// Payment history helpers +// --------------------------------------------------------------------------- + +const HISTORY_OPERATION_TYPES = new Set([ + 'payment', + 'create_account', + 'path_payment_strict_receive', + 'path_payment_strict_send', +]) + +const MAX_MEMO_LENGTH = 256 + +/** Text memos are free-form user input; hash/id/return memos are opaque public identifiers already. */ +function applyMemoPolicy( + memoType: string | null, + memo: string | null, +): string | null { + if (!memo) return null + if (memoType === 'text') { + const sanitized = memo + .replace(/[\x00-\x1F\x7F]/g, '') + .slice(0, MAX_MEMO_LENGTH) + + return sanitized.length > 0 ? sanitized : null + } + + return memo +} + +function toPaymentHistoryRecord(record: unknown): PaymentHistoryRecord { + const r = record as Record + const transaction = (r.transaction ?? undefined) as + Record | undefined + const memoType = (transaction?.memo_type as string | undefined) ?? null + const rawMemo = (transaction?.memo as string | undefined) ?? null + const assetType = (r.asset_type as string | undefined) ?? 'native' + + return { + id: String(r.id), + pagingToken: String(r.paging_token), + createdAt: String(r.created_at), + transactionHash: String(r.transaction_hash), + transactionSuccessful: r.transaction_successful !== false, + ledger: + typeof transaction?.ledger_attr === 'number' + ? (transaction.ledger_attr as number) + : null, + type: String(r.type), + from: + (r.from as string | undefined) ?? + (r.funder as string | undefined) ?? + null, + to: + (r.to as string | undefined) ?? (r.account as string | undefined) ?? null, + assetType, + assetCode: assetType === 'native' ? 'XLM' : String(r.asset_code ?? ''), + issuer: (r.asset_issuer as string | undefined) ?? null, + amount: + (r.amount as string | undefined) ?? + (r.starting_balance as string | undefined) ?? + null, + memo: applyMemoPolicy(memoType, rawMemo), + memoType, + } +} + +function isHorizonNotFound(err: unknown): boolean { + const status = (err as { response?: { status?: number } } | undefined) + ?.response?.status + + return status === 404 +} + +function isHorizonTimeout(err: unknown): boolean { + const code = (err as { code?: string } | undefined)?.code + const name = (err as { name?: string } | undefined)?.name + const message = err instanceof Error ? err.message.toLowerCase() : '' + + return ( + code === 'ETIMEDOUT' || + code === 'ECONNABORTED' || + name === 'TimeoutError' || + message.includes('timeout') + ) +} + +// --------------------------------------------------------------------------- +// Singleton export +// --------------------------------------------------------------------------- + +export const stellarService = new StellarService() diff --git a/src/services/storage/in-memory-storage.ts b/src/services/storage/in-memory-storage.ts index 00b87b0a..70de0d8d 100644 --- a/src/services/storage/in-memory-storage.ts +++ b/src/services/storage/in-memory-storage.ts @@ -1,4 +1,8 @@ -import type { ImageDimensions, SignedUploadUrl, StorageProvider } from '../../types/avatar.types' +import type { + ImageDimensions, + SignedUploadUrl, + StorageProvider, +} from '../../types/avatar.types' /** * In-memory storage provider for development and testing. @@ -35,7 +39,11 @@ export class InMemoryStorageProvider implements StorageProvider { return Buffer.from(buf) } - async writeBytes(storageKey: string, data: Buffer, _contentType: string): Promise { + async writeBytes( + storageKey: string, + data: Buffer, + _contentType: string, + ): Promise { this.objects.set(storageKey, Buffer.from(data)) } @@ -72,7 +80,9 @@ export class InMemoryStorageProvider implements StorageProvider { // ── Minimal image dimension extraction (no external deps) ───────── -const PNG_IHDR_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) +const PNG_IHDR_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]) const GIF87A = Buffer.from('GIF87a') const GIF89A = Buffer.from('GIF89a') const WEBP_RIFF = Buffer.from('RIFF') @@ -97,7 +107,10 @@ export function extractImageDimensions(data: Buffer): ImageDimensions | null { } // GIF — dimensions at bytes 6-9 - if (data.subarray(0, 6).equals(GIF87A) || data.subarray(0, 6).equals(GIF89A)) { + if ( + data.subarray(0, 6).equals(GIF87A) || + data.subarray(0, 6).equals(GIF89A) + ) { const width = data.readUInt16LE(6) const height = data.readUInt16LE(8) @@ -110,7 +123,10 @@ export function extractImageDimensions(data: Buffer): ImageDimensions | null { } // WebP — RIFF....WEBP - if (data.subarray(0, 4).equals(WEBP_RIFF) && data.subarray(8, 12).equals(WEBP_WEBP)) { + if ( + data.subarray(0, 4).equals(WEBP_RIFF) && + data.subarray(8, 12).equals(WEBP_WEBP) + ) { return parseWebpDimensions(data) } @@ -126,10 +142,10 @@ function parseJpegDimensions(data: Buffer): ImageDimensions | null { const marker = data[offset + 1] // SOF0–SOF3, SOF5–SOF7, SOF9–SOF11, SOF13–SOF15 if ( - (marker >= 0xc0 && marker <= 0xc3) - || (marker >= 0xc5 && marker <= 0xc7) - || (marker >= 0xc9 && marker <= 0xcb) - || (marker >= 0xcd && marker <= 0xcf) + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf) ) { if (offset + 9 >= data.length) { return null @@ -197,8 +213,11 @@ function parseWebpDimensions(data: Buffer): ImageDimensions | null { // ── MIME sniffing from magic bytes ──────────────────────────────── -const MIME_SIGNATURES: Array<{ bytes: Uint8Array, mime: string }> = [ - { bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), mime: 'image/png' }, +const MIME_SIGNATURES: Array<{ bytes: Uint8Array; mime: string }> = [ + { + bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + mime: 'image/png', + }, { bytes: new Uint8Array([0xff, 0xd8, 0xff]), mime: 'image/jpeg' }, { bytes: new Uint8Array([0x47, 0x49, 0x46, 0x38]), mime: 'image/gif' }, { bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]), mime: 'image/webp' }, // RIFF container (WebP) diff --git a/src/services/user-account.service.ts b/src/services/user-account.service.ts index 41a9597a..9c3f7361 100644 --- a/src/services/user-account.service.ts +++ b/src/services/user-account.service.ts @@ -46,7 +46,7 @@ export class UserAccountService { userId: string, currentPassword: string, newPassword: string, - context: AuditContext + context: AuditContext, ): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, @@ -71,14 +71,17 @@ export class UserAccountService { requestId: context.requestId, ipAddress: context.ipAddress, userAgent: context.userAgent, - mutate: async tx => { - await tx.user.update({ where: { id: userId }, data: { password: passwordHash } }) + mutate: async (tx) => { + await tx.user.update({ + where: { id: userId }, + data: { password: passwordHash }, + }) const sessions = await tx.session.findMany({ where: { userId, isRevoked: false }, select: { id: true }, }) - const sessionIds = sessions.map(session => session.id) + const sessionIds = sessions.map((session) => session.id) if (sessionIds.length === 0) { return 0 @@ -97,7 +100,7 @@ export class UserAccountService { }, // The password itself never appears here, and cannot: `redaction.ts` // denies every `*password*` key. The count is the reviewable part. - resolveMetadata: count => ({ revokedSessionCount: count }), + resolveMetadata: (count) => ({ revokedSessionCount: count }), }) return { kind: 'changed', revokedSessionCount } @@ -115,7 +118,7 @@ export class UserAccountService { async updateWalletAddress( userId: string, walletAddress: string, - context: AuditContext + context: AuditContext, ): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, @@ -151,8 +154,12 @@ export class UserAccountService { // A Stellar *public* key is not a secret; the corresponding seed never // touches this path. Recording it is what makes a hijacked payout // address traceable afterwards. - metadata: { walletAddress, hadPreviousAddress: user.walletAddress !== null }, - mutate: tx => tx.user.update({ where: { id: userId }, data: { walletAddress } }), + metadata: { + walletAddress, + hadPreviousAddress: user.walletAddress !== null, + }, + mutate: (tx) => + tx.user.update({ where: { id: userId }, data: { walletAddress } }), }) } catch (error) { if (isUniqueViolation(error)) { diff --git a/src/services/wallet-provisioning.repository.ts b/src/services/wallet-provisioning.repository.ts index 02155ba2..c5d8822f 100644 --- a/src/services/wallet-provisioning.repository.ts +++ b/src/services/wallet-provisioning.repository.ts @@ -13,7 +13,10 @@ export const CUSTODIAL_WALLET_CONSENT_PURPOSE = 'custodial_wallet' export interface WalletProvisioningRepository { reserveEligibleWallet(userId: string, network: string): Promise getByUserId(userId: string): Promise - claimNext(now: Date, leaseMs: number): Promise + claimNext( + now: Date, + leaseMs: number, + ): Promise claimByWalletId( walletId: string, now: Date, @@ -23,14 +26,14 @@ export interface WalletProvisioningRepository { walletId: string, leaseToken: string, material: StoredStellarKey, - now: Date + now: Date, ): Promise recordFailure( walletId: string, leaseToken: string, code: WalletProvisioningFailureCode, retryAt: Date | null, - now: Date + now: Date, ): Promise } @@ -39,7 +42,10 @@ type DbClient = PrismaClient | Prisma.TransactionClient export class PrismaWalletProvisioningRepository implements WalletProvisioningRepository { constructor(private readonly prisma: PrismaClient) {} - async reserveEligibleWallet(userId: string, network: string): Promise { + async reserveEligibleWallet( + userId: string, + network: string, + ): Promise { return this.prisma.$transaction(async (tx) => { await this.assertEligible(tx, userId) @@ -80,7 +86,9 @@ export class PrismaWalletProvisioningRepository implements WalletProvisioningRep await tx.auditLog.create({ data: { userId, - action: before ? 'WALLET_PROVISIONING_REQUEUED' : 'WALLET_PROVISIONING_RESERVED', + action: before + ? 'WALLET_PROVISIONING_REQUEUED' + : 'WALLET_PROVISIONING_RESERVED', metadata: JSON.stringify({ walletId: wallet.id, network }), }, }) @@ -91,10 +99,15 @@ export class PrismaWalletProvisioningRepository implements WalletProvisioningRep } async getByUserId(userId: string): Promise { - return this.prisma.wallet.findUnique({ where: { userId } }) as unknown as Promise + return this.prisma.wallet.findUnique({ + where: { userId }, + }) as unknown as Promise } - async claimNext(now: Date, leaseMs: number): Promise { + async claimNext( + now: Date, + leaseMs: number, + ): Promise { const candidates = await this.prisma.walletProvisioningJob.findMany({ where: this.claimableWhere(now), orderBy: [{ availableAt: 'asc' }, { createdAt: 'asc' }], @@ -164,7 +177,10 @@ export class PrismaWalletProvisioningRepository implements WalletProvisioningRep data: { userId: job.wallet.userId, action: 'WALLET_PROVISIONING_ATTEMPTED', - metadata: JSON.stringify({ walletId: job.walletId, attempt: job.attempts }), + metadata: JSON.stringify({ + walletId: job.walletId, + attempt: job.attempts, + }), }, }) @@ -179,11 +195,15 @@ export class PrismaWalletProvisioningRepository implements WalletProvisioningRep walletId: string, leaseToken: string, material: StoredStellarKey, - now: Date + now: Date, ): Promise { return this.prisma.$transaction(async (tx) => { - const job = await tx.walletProvisioningJob.findUniqueOrThrow({ where: { walletId } }) - const wallet = await tx.wallet.findUniqueOrThrow({ where: { id: walletId } }) + const job = await tx.walletProvisioningJob.findUniqueOrThrow({ + where: { walletId }, + }) + const wallet = await tx.wallet.findUniqueOrThrow({ + where: { id: walletId }, + }) if (wallet.status === 'ACTIVE') return wallet as unknown as WalletRecord if (job.status !== 'PROCESSING' || job.leaseToken !== leaseToken) { @@ -252,7 +272,7 @@ export class PrismaWalletProvisioningRepository implements WalletProvisioningRep leaseToken: string, code: WalletProvisioningFailureCode, retryAt: Date | null, - now: Date + now: Date, ): Promise { await this.prisma.$transaction(async (tx) => { const job = await tx.walletProvisioningJob.findUniqueOrThrow({ @@ -284,8 +304,14 @@ export class PrismaWalletProvisioningRepository implements WalletProvisioningRep await tx.auditLog.create({ data: { userId: job.wallet.userId, - action: terminal ? 'WALLET_PROVISIONING_FAILED' : 'WALLET_PROVISIONING_RETRY_SCHEDULED', - metadata: JSON.stringify({ walletId, failureCode: code, attempt: job.attempts }), + action: terminal + ? 'WALLET_PROVISIONING_FAILED' + : 'WALLET_PROVISIONING_RETRY_SCHEDULED', + metadata: JSON.stringify({ + walletId, + failureCode: code, + attempt: job.attempts, + }), }, }) }) diff --git a/src/services/wallet-self-custody-export.service.ts b/src/services/wallet-self-custody-export.service.ts index 07a3ccbf..22777301 100644 --- a/src/services/wallet-self-custody-export.service.ts +++ b/src/services/wallet-self-custody-export.service.ts @@ -42,7 +42,10 @@ export class WalletSelfCustodyExportService { throw new WalletExportError('ACKNOWLEDGEMENT_REQUIRED') } - const verified = await this.stepUp.verifyPassword(input.userId, input.password) + const verified = await this.stepUp.verifyPassword( + input.userId, + input.password, + ) if (!verified) { await this.audit.record({ userId: input.userId, @@ -113,14 +116,22 @@ export class WalletSelfCustodyExportService { ) if (!completed) { await this.repository.releaseClaim(claim.authorizationId) - await this.recordFailure(input.userId, claim.walletId, 'transition_failed') + await this.recordFailure( + input.userId, + claim.walletId, + 'transition_failed', + ) throw new WalletExportError('CUSTODY_TRANSITION_FAILED') } try { await this.kms.deleteStellarSecret(claim.opaqueReference) } catch { - await this.recordFailure(input.userId, claim.walletId, 'kms_delete_failed') + await this.recordFailure( + input.userId, + claim.walletId, + 'kms_delete_failed', + ) // The secret is deliberately not returned while the managed copy exists. throw new WalletExportError('KMS_DELETE_FAILED') } diff --git a/src/services/wallet-status.service.ts b/src/services/wallet-status.service.ts index 4d674571..ab5b34b1 100644 --- a/src/services/wallet-status.service.ts +++ b/src/services/wallet-status.service.ts @@ -18,7 +18,7 @@ export interface WalletStatusStellarProvider { getAccountSnapshot(publicKey: string): Promise getPaymentHistory( publicKey: string, - options?: { cursor?: string; limit?: number; order?: 'asc' | 'desc' } + options?: { cursor?: string; limit?: number; order?: 'asc' | 'desc' }, ): Promise } @@ -32,7 +32,13 @@ export class WalletStatusService { async getStatus(userId: string): Promise { const wallet = await this.repository.getByUserId(userId) if (!wallet) { - return { status: 'NOT_PROVISIONED', network: null, custody: null, publicKey: null, provisionedAt: null } + return { + status: 'NOT_PROVISIONED', + network: null, + custody: null, + publicKey: null, + provisionedAt: null, + } } return { @@ -40,7 +46,9 @@ export class WalletStatusService { network: wallet.network, custody: wallet.custody, publicKey: wallet.status === 'ACTIVE' ? wallet.publicKey : null, - provisionedAt: wallet.provisionedAt ? wallet.provisionedAt.toISOString() : null, + provisionedAt: wallet.provisionedAt + ? wallet.provisionedAt.toISOString() + : null, } } @@ -67,14 +75,20 @@ export class WalletStatusService { const direction = options.direction ?? 'all' const page = await this.wrapProviderErrors(() => - this.stellar.getPaymentHistory(publicKey, { cursor: options.cursor, limit }), + this.stellar.getPaymentHistory(publicKey, { + cursor: options.cursor, + limit, + }), ) const entries = page.records .map((record) => ({ id: record.id, - direction: (record.to === publicKey ? 'incoming' : 'outgoing') as WalletHistoryDirection, - status: (record.transactionSuccessful ? 'success' : 'failed') as 'success' | 'failed', + direction: (record.to === publicKey + ? 'incoming' + : 'outgoing') as WalletHistoryDirection, + status: (record.transactionSuccessful ? 'success' : 'failed') as + 'success' | 'failed', assetType: record.assetType, assetCode: record.assetCode, issuer: record.issuer, @@ -114,9 +128,12 @@ export class WalletStatusService { } } -function toStatusValue(walletStatus: WalletRecord['status']): WalletStatusView['status'] { +function toStatusValue( + walletStatus: WalletRecord['status'], +): WalletStatusView['status'] { if (walletStatus === 'ACTIVE') return 'ACTIVE' - if (walletStatus === 'DISABLED' || walletStatus === 'FAILED') return 'UNAVAILABLE' + if (walletStatus === 'DISABLED' || walletStatus === 'FAILED') + return 'UNAVAILABLE' return 'PENDING' } diff --git a/src/services/webhook.service.ts b/src/services/webhook.service.ts index f0c69b5b..4aa0d932 100644 --- a/src/services/webhook.service.ts +++ b/src/services/webhook.service.ts @@ -1,182 +1,199 @@ import type { WebhookDelivery, WebhookEndpoint } from '@prisma/client' import prisma from '../config/database' -import { WebhookEndpointCreate, WebhookEventType, WebhookPayload } from '../types/webhook.types' +import { + WebhookEndpointCreate, + WebhookEventType, + WebhookPayload, +} from '../types/webhook.types' import crypto from 'crypto' export class WebhookService { - /** - * Register a new webhook endpoint. - */ - async registerEndpoint (data: WebhookEndpointCreate): Promise { - return prisma.webhookEndpoint.create({ - data: { - url: data.url, - secret: data.secret || crypto.randomBytes(32).toString('hex'), - events: data.events.join(','), - description: data.description, - }, - }) - } - - /** - * Queue an event for all registered endpoints interested in the event type. - */ - async queueEvent (eventType: WebhookEventType, data: any): Promise { - const endpoints = await prisma.webhookEndpoint.findMany({ - where: { - isActive: true, - events: { - contains: eventType, - }, - }, - }) - - if (endpoints.length === 0) return - - const timestamp = new Date().toISOString() - - const _deliveries = await Promise.all( - endpoints.map((endpoint: any) => { - const payload: WebhookPayload = { - eventId: crypto.randomUUID(), - eventType, - timestamp, - data, - } - - return prisma.webhookDelivery.create({ - data: { - endpointId: endpoint.id, - eventType, - payload: JSON.stringify(payload), - status: 'pending', - nextAttemptAt: new Date(), - }, - }) - }) - ) - } + /** + * Register a new webhook endpoint. + */ + async registerEndpoint( + data: WebhookEndpointCreate, + ): Promise { + return prisma.webhookEndpoint.create({ + data: { + url: data.url, + secret: data.secret || crypto.randomBytes(32).toString('hex'), + events: data.events.join(','), + description: data.description, + }, + }) + } + + /** + * Queue an event for all registered endpoints interested in the event type. + */ + async queueEvent(eventType: WebhookEventType, data: any): Promise { + const endpoints = await prisma.webhookEndpoint.findMany({ + where: { + isActive: true, + events: { + contains: eventType, + }, + }, + }) + + if (endpoints.length === 0) return + + const timestamp = new Date().toISOString() + + const _deliveries = await Promise.all( + endpoints.map((endpoint: any) => { + const payload: WebhookPayload = { + eventId: crypto.randomUUID(), + eventType, + timestamp, + data, + } - /** - * Process pending remains in the queue. - */ - async processQueue (): Promise { - const pendingDeliveries = await prisma.webhookDelivery.findMany({ - where: { - status: 'pending', - nextAttemptAt: { - lte: new Date(), - }, - attemptCount: { - lt: 5, - }, - }, - include: { - endpoint: true, - }, + return prisma.webhookDelivery.create({ + data: { + endpointId: endpoint.id, + eventType, + payload: JSON.stringify(payload), + status: 'pending', + nextAttemptAt: new Date(), + }, }) - - for (const delivery of pendingDeliveries) { - await this.sendWebhook(delivery) - } + }), + ) + } + + /** + * Process pending remains in the queue. + */ + async processQueue(): Promise { + const pendingDeliveries = await prisma.webhookDelivery.findMany({ + where: { + status: 'pending', + nextAttemptAt: { + lte: new Date(), + }, + attemptCount: { + lt: 5, + }, + }, + include: { + endpoint: true, + }, + }) + + for (const delivery of pendingDeliveries) { + await this.sendWebhook(delivery) } - - private async sendWebhook (delivery: WebhookDelivery & { endpoint: WebhookEndpoint }): Promise { - const { endpoint, payload, eventType } = delivery - + } + + private async sendWebhook( + delivery: WebhookDelivery & { endpoint: WebhookEndpoint }, + ): Promise { + const { endpoint, payload, eventType } = delivery + + await prisma.webhookDelivery.update({ + where: { id: delivery.id }, + data: { + attemptCount: { increment: 1 }, + lastAttemptAt: new Date(), + }, + }) + + try { + const signature = this.generateSignature(payload, endpoint.secret || '') + + const response = await fetch(endpoint.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Learnault-Signature': signature, + 'X-Learnault-Event': eventType, + }, + body: payload, + signal: AbortSignal.timeout(10000), + }) + + const responseBody = await response.text() + + if (response.ok) { await prisma.webhookDelivery.update({ - where: { id: delivery.id }, - data: { - attemptCount: { increment: 1 }, - lastAttemptAt: new Date() - }, + where: { id: delivery.id }, + data: { + status: 'success', + statusCode: response.status, + responseBody: responseBody.slice(0, 1000), + }, }) - - try { - const signature = this.generateSignature(payload, endpoint.secret || '') - - const response = await fetch(endpoint.url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Learnault-Signature': signature, - 'X-Learnault-Event': eventType, - }, - body: payload, - signal: AbortSignal.timeout(10000), - }) - - const responseBody = await response.text() - - if (response.ok) { - await prisma.webhookDelivery.update({ - where: { id: delivery.id }, - data: { - status: 'success', - statusCode: response.status, - responseBody: responseBody.slice(0, 1000), - }, - }) - } else { - await this.handleFailure(delivery, `HTTP ${response.status}: ${responseBody.slice(0, 200)}`, response.status) - } - } catch (error: any) { - await this.handleFailure(delivery, error.message || 'Network error') - } - } - - private generateSignature (payload: string, secret: string): string { - return crypto - .createHmac('sha256', secret) - .update(payload) - .digest('hex') + } else { + await this.handleFailure( + delivery, + `HTTP ${response.status}: ${responseBody.slice(0, 200)}`, + response.status, + ) + } + } catch (error: any) { + await this.handleFailure(delivery, error.message || 'Network error') } - - private async handleFailure (delivery: WebhookDelivery, error: string, statusCode?: number): Promise { - const nextAttemptCount = delivery.attemptCount + 1 - - if (nextAttemptCount >= delivery.maxAttempts) { - await prisma.webhookDelivery.update({ - where: { id: delivery.id }, - data: { - status: 'failed', - error, - statusCode, - }, - }) - - await this.checkEndpointHealth(delivery.endpointId) - } else { - const backoffMinutes = Math.pow(5, nextAttemptCount - 1) - const nextAttemptAt = new Date(Date.now() + backoffMinutes * 60000) - - await prisma.webhookDelivery.update({ - where: { id: delivery.id }, - data: { - error, - statusCode, - nextAttemptAt, - }, - }) - } + } + + private generateSignature(payload: string, secret: string): string { + return crypto.createHmac('sha256', secret).update(payload).digest('hex') + } + + private async handleFailure( + delivery: WebhookDelivery, + error: string, + statusCode?: number, + ): Promise { + const nextAttemptCount = delivery.attemptCount + 1 + + if (nextAttemptCount >= delivery.maxAttempts) { + await prisma.webhookDelivery.update({ + where: { id: delivery.id }, + data: { + status: 'failed', + error, + statusCode, + }, + }) + + await this.checkEndpointHealth(delivery.endpointId) + } else { + const backoffMinutes = Math.pow(5, nextAttemptCount - 1) + const nextAttemptAt = new Date(Date.now() + backoffMinutes * 60000) + + await prisma.webhookDelivery.update({ + where: { id: delivery.id }, + data: { + error, + statusCode, + nextAttemptAt, + }, + }) } - - private async checkEndpointHealth (endpointId: string): Promise { - const recentDeliveries = await prisma.webhookDelivery.findMany({ - where: { endpointId }, - orderBy: { createdAt: 'desc' }, - take: 10, - }) - - const failureCount = recentDeliveries.filter((d: WebhookDelivery) => d.status === 'failed').length - - if (failureCount >= 10) { - await prisma.webhookEndpoint.update({ - where: { id: endpointId }, - data: { isActive: false }, - }) - console.warn(`[Webhook] Deactivating endpoint ${endpointId} due to repeated failures.`) - } + } + + private async checkEndpointHealth(endpointId: string): Promise { + const recentDeliveries = await prisma.webhookDelivery.findMany({ + where: { endpointId }, + orderBy: { createdAt: 'desc' }, + take: 10, + }) + + const failureCount = recentDeliveries.filter( + (d: WebhookDelivery) => d.status === 'failed', + ).length + + if (failureCount >= 10) { + await prisma.webhookEndpoint.update({ + where: { id: endpointId }, + data: { isActive: false }, + }) + console.warn( + `[Webhook] Deactivating endpoint ${endpointId} due to repeated failures.`, + ) } + } } diff --git a/src/types/account.types.ts b/src/types/account.types.ts index 7acfcf79..eed4e673 100644 --- a/src/types/account.types.ts +++ b/src/types/account.types.ts @@ -7,7 +7,8 @@ export const AccountStatus = { DELETED: 'DELETED', } as const -export type AccountStatusValue = (typeof AccountStatus)[keyof typeof AccountStatus] +export type AccountStatusValue = + (typeof AccountStatus)[keyof typeof AccountStatus] export const ExportStatus = { PENDING: 'pending', @@ -27,7 +28,8 @@ export const DeletionStatus = { FAILED: 'failed', } as const -export type DeletionStatusValue = (typeof DeletionStatus)[keyof typeof DeletionStatus] +export type DeletionStatusValue = + (typeof DeletionStatus)[keyof typeof DeletionStatus] export const AuditAction = { EXPORT_REQUESTED: 'EXPORT_REQUESTED', diff --git a/src/types/api.types.ts b/src/types/api.types.ts index 1f25ba3c..0ae2ddf9 100644 --- a/src/types/api.types.ts +++ b/src/types/api.types.ts @@ -15,9 +15,9 @@ export enum ErrorCode { // ── Request Metadata Context ──────────────────────────────────── export interface RequestMetadata { - requestId: string; - timestamp: string; - version: string; + requestId: string + timestamp: string + version: string } // ── Pagination Types ───────────────────────────────────────────── @@ -28,161 +28,161 @@ export enum SortOrder { } export interface PaginationParams { - page?: number; - limit?: number; - sortBy?: string; - sortOrder?: SortOrder; + page?: number + limit?: number + sortBy?: string + sortOrder?: SortOrder } export interface CursorPaginationParams { - cursor?: string; - limit?: number; - sortBy?: string; - sortOrder?: SortOrder; + cursor?: string + limit?: number + sortBy?: string + sortOrder?: SortOrder } export interface PaginationMeta { - page: number; - limit: number; - total: number; - totalPages: number; - hasNextPage: boolean; - hasPrevPage: boolean; - requestId?: string; - timestamp?: string; - version?: string; + page: number + limit: number + total: number + totalPages: number + hasNextPage: boolean + hasPrevPage: boolean + requestId?: string + timestamp?: string + version?: string } export interface CursorPaginationMeta { - cursor?: string; - nextCursor: string | null; - hasMore: boolean; - limit: number; - requestId?: string; - timestamp?: string; - version?: string; + cursor?: string + nextCursor: string | null + hasMore: boolean + limit: number + requestId?: string + timestamp?: string + version?: string } // ── API Response Wrappers ────────────────────────────────── export interface ApiResponse { - success: boolean; - data: T; - message?: string; - meta?: RequestMetadata; - timestamp?: string; + success: boolean + data: T + message?: string + meta?: RequestMetadata + timestamp?: string } export interface PaginatedResponse { - success: boolean; - data: T[]; - meta: PaginationMeta; - message?: string; - timestamp?: string; + success: boolean + data: T[] + meta: PaginationMeta + message?: string + timestamp?: string } export interface CursorPaginatedResponse { - success: boolean; - data: T[]; - meta: CursorPaginationMeta; - message?: string; - timestamp?: string; + success: boolean + data: T[] + meta: CursorPaginationMeta + message?: string + timestamp?: string } // ── API Error Envelopes ────────────────────────────────── export interface ApiErrorDetail { - code: string | ErrorCode; - message: string; - details?: Record; - stack?: string[]; + code: string | ErrorCode + message: string + details?: Record + stack?: string[] request?: { - method: string; - path: string; - headers?: Record; - }; + method: string + path: string + headers?: Record + } } export interface ApiErrorResponse { - success: false; - error: ApiErrorDetail; - requestId?: string; - timestamp: string; + success: false + error: ApiErrorDetail + requestId?: string + timestamp: string } export interface ApiValidationErrorResponse { - success: false; + success: false error: { - code: ErrorCode.VALIDATION_ERROR | string; - message: string; - details: Record; - }; - requestId?: string; - timestamp: string; + code: ErrorCode.VALIDATION_ERROR | string + message: string + details: Record + } + requestId?: string + timestamp: string } // Legacy alias maintained for existing code compatibility -export type ApiError = ApiErrorResponse; +export type ApiError = ApiErrorResponse // ── Financial & Asset Serialization Types ────────────────────── export interface AssetAmount { - amount: string; - assetCode: string; - issuer?: string | null; + amount: string + assetCode: string + issuer?: string | null } export interface StellarAsset { - code: string; - issuer?: string | null; - isNative: boolean; + code: string + issuer?: string | null + isNative: boolean } // ── Auth Request / Response Types ────────────────────────────── export interface LoginRequest { - email: string; - password: string; + email: string + password: string } export interface LoginResponse { - accessToken: string; - refreshToken: string; - expiresIn: number; - user: import('./user.types').User; + accessToken: string + refreshToken: string + expiresIn: number + user: import('./user.types').User } export interface RefreshTokenRequest { - refreshToken: string; + refreshToken: string } export interface RefreshTokenResponse { - accessToken: string; - expiresIn: number; + accessToken: string + expiresIn: number } export interface ForgotPasswordRequest { - email: string; + email: string } export interface ResetPasswordRequest { - token: string; - newPassword: string; - confirmPassword: string; + token: string + newPassword: string + confirmPassword: string } // ── Shared Utility Types ─────────────────────────────────── -export type Nullable = T | null; -export type Optional = T | undefined; -export type ID = string; -export type ISODateString = string; +export type Nullable = T | null +export type Optional = T | undefined +export type ID = string +export type ISODateString = string export interface IdParam { - id: ID; + id: ID } export interface DateRangeParams { - fromDate?: ISODateString; - toDate?: ISODateString; + fromDate?: ISODateString + toDate?: ISODateString } diff --git a/src/types/avatar.types.ts b/src/types/avatar.types.ts index 6ef59f08..eeae5b38 100644 --- a/src/types/avatar.types.ts +++ b/src/types/avatar.types.ts @@ -1,6 +1,11 @@ // ── Avatar lifecycle statuses ───────────────────────────────────── -export const AVATAR_STATUSES = ['PENDING', 'PROCESSING', 'ACTIVE', 'FAILED'] as const +export const AVATAR_STATUSES = [ + 'PENDING', + 'PROCESSING', + 'ACTIVE', + 'FAILED', +] as const export type AvatarStatus = (typeof AVATAR_STATUSES)[number] export const AVATAR_SCAN_RESULTS = ['clean', 'rejected', 'error'] as const @@ -14,7 +19,7 @@ export type AvatarVariantLabel = (typeof AVARIANT_LABELS)[number] // ── Upload constraints ──────────────────────────────────────────── export const AVATAR_MAX_BYTES = 5 * 1024 * 1024 // 5 MB -export const AVATAR_MIN_BYTES = 1 * 1024 // 1 KB +export const AVATAR_MIN_BYTES = 1 * 1024 // 1 KB export const AVATAR_INTENT_TTL_MS = 15 * 60 * 1000 // 15 minutes export const AVATAR_ALLOWED_MIME_TYPES = [ @@ -120,13 +125,22 @@ export interface ImageDimensions { export interface StorageProvider { /** Generate a signed upload URL for a user-scoped object. */ - createSignedUpload(userId: string, key: string, contentType: string, expiresMs: number): Promise + createSignedUpload( + userId: string, + key: string, + contentType: string, + expiresMs: number, + ): Promise /** Read raw bytes from a stored object. */ readBytes(storageKey: string): Promise /** Write raw bytes (for variants produced by the processing pipeline). */ - writeBytes(storageKey: string, data: Buffer, contentType: string): Promise + writeBytes( + storageKey: string, + data: Buffer, + contentType: string, + ): Promise /** Delete an object and all its variants. */ deleteObject(storageKey: string): Promise diff --git a/src/types/consent.types.ts b/src/types/consent.types.ts index 8358d6c8..10dccfe5 100644 --- a/src/types/consent.types.ts +++ b/src/types/consent.types.ts @@ -8,7 +8,7 @@ export const CONSENT_PURPOSES = [ 'data_sharing', 'custodial_wallet', ] as const -export type ConsentPurpose = typeof CONSENT_PURPOSES[number] +export type ConsentPurpose = (typeof CONSENT_PURPOSES)[number] export const REQUIRED_CONSENT_PURPOSES: readonly ConsentPurpose[] = [ 'terms_of_service', @@ -16,7 +16,7 @@ export const REQUIRED_CONSENT_PURPOSES: readonly ConsentPurpose[] = [ ] export const CONSENT_STATUSES = ['granted', 'withdrawn'] as const -export type ConsentStatus = typeof CONSENT_STATUSES[number] +export type ConsentStatus = (typeof CONSENT_STATUSES)[number] export const CONSENT_TRANSITIONS: TransitionMap = { granted: ['withdrawn'], @@ -24,7 +24,7 @@ export const CONSENT_TRANSITIONS: TransitionMap = { } export const CONSENT_SOURCES = ['onboarding', 'settings', 'api'] as const -export type ConsentSource = typeof CONSENT_SOURCES[number] +export type ConsentSource = (typeof CONSENT_SOURCES)[number] export interface ConsentRecordEntry { id: string diff --git a/src/types/credential.types.ts b/src/types/credential.types.ts index 0222bab3..1ec7e2ef 100644 --- a/src/types/credential.types.ts +++ b/src/types/credential.types.ts @@ -21,67 +21,67 @@ export enum VerificationStatus { } export interface Credential { - id: string; - userId: string; - moduleId: string; - type: CredentialType; - status: CredentialStatus; - title: string; - description: string; - issuedAt: string; - expiresAt?: string; - revokedAt?: string; - revokedReason?: string; - blockchainTxHash?: string; - metadataUrl?: string; - imageUrl?: string; + id: string + userId: string + moduleId: string + type: CredentialType + status: CredentialStatus + title: string + description: string + issuedAt: string + expiresAt?: string + revokedAt?: string + revokedReason?: string + blockchainTxHash?: string + metadataUrl?: string + imageUrl?: string } export interface Verification { - id: string; - credentialId: string; - requestedBy?: string; - status: VerificationStatus; - verifiedAt?: string; - expiresAt?: string; - verificationUrl: string; - checksum: string; - attempts: number; - lastCheckedAt?: string; + id: string + credentialId: string + requestedBy?: string + status: VerificationStatus + verifiedAt?: string + expiresAt?: string + verificationUrl: string + checksum: string + attempts: number + lastCheckedAt?: string } export interface CredentialWithVerification extends Credential { - verification?: Verification; - holderName: string; - moduleName: string; + verification?: Verification + holderName: string + moduleName: string } // Request types export interface IssueCredentialRequest { - userId: string; - moduleId: string; - type: CredentialType; - title: string; - description: string; - expiresAt?: string; - metadataUrl?: string; - imageUrl?: string; + userId: string + moduleId: string + type: CredentialType + title: string + description: string + expiresAt?: string + metadataUrl?: string + imageUrl?: string } export interface RevokeCredentialRequest { - reason: string; + reason: string } export interface VerifyCredentialRequest { - credentialId: string; - requestedBy?: string; + credentialId: string + requestedBy?: string } export interface CredentialFilterParams { - userId?: string; - moduleId?: string; - type?: CredentialType; - status?: CredentialStatus; - fromDate?: string; - toDate?: string; + userId?: string + moduleId?: string + type?: CredentialType + status?: CredentialStatus + fromDate?: string + toDate?: string } diff --git a/src/types/module.types.ts b/src/types/module.types.ts index 889d2fb6..574b8187 100644 --- a/src/types/module.types.ts +++ b/src/types/module.types.ts @@ -28,74 +28,74 @@ export enum EnrollmentStatus { } export interface Module { - id: string; - title: string; - description: string; - difficulty: Difficulty; - category: Category; - status: ModuleStatus; - authorId: string; - estimatedMinutes: number; - pointsReward: number; - prerequisiteIds: string[]; - tags: string[]; - createdAt: string; - updatedAt: string; - publishedAt?: string; + id: string + title: string + description: string + difficulty: Difficulty + category: Category + status: ModuleStatus + authorId: string + estimatedMinutes: number + pointsReward: number + prerequisiteIds: string[] + tags: string[] + createdAt: string + updatedAt: string + publishedAt?: string } export interface ModuleWithProgress extends Module { - enrollment?: Enrollment; - completionRate: number; - totalEnrollments: number; + enrollment?: Enrollment + completionRate: number + totalEnrollments: number } export interface Enrollment { - id: string; - userId: string; - moduleId: string; - status: EnrollmentStatus; - progressPercent: number; - startedAt: string; - completedAt?: string; - score?: number; + id: string + userId: string + moduleId: string + status: EnrollmentStatus + progressPercent: number + startedAt: string + completedAt?: string + score?: number } // Request types export interface CreateModuleRequest { - title: string; - description: string; - difficulty: Difficulty; - category: Category; - estimatedMinutes: number; - pointsReward: number; - prerequisiteIds?: string[]; - tags?: string[]; + title: string + description: string + difficulty: Difficulty + category: Category + estimatedMinutes: number + pointsReward: number + prerequisiteIds?: string[] + tags?: string[] } export interface UpdateModuleRequest { - title?: string; - description?: string; - difficulty?: Difficulty; - category?: Category; - status?: ModuleStatus; - estimatedMinutes?: number; - pointsReward?: number; - prerequisiteIds?: string[]; - tags?: string[]; + title?: string + description?: string + difficulty?: Difficulty + category?: Category + status?: ModuleStatus + estimatedMinutes?: number + pointsReward?: number + prerequisiteIds?: string[] + tags?: string[] } export interface UpdateProgressRequest { - progressPercent: number; - status?: EnrollmentStatus; - score?: number; + progressPercent: number + status?: EnrollmentStatus + score?: number } export interface ModuleFilterParams { - difficulty?: Difficulty; - category?: Category; - status?: ModuleStatus; - authorId?: string; - tag?: string; - search?: string; + difficulty?: Difficulty + category?: Category + status?: ModuleStatus + authorId?: string + tag?: string + search?: string } diff --git a/src/types/onboarding.types.ts b/src/types/onboarding.types.ts index fd69239e..327da0cb 100644 --- a/src/types/onboarding.types.ts +++ b/src/types/onboarding.types.ts @@ -1,12 +1,19 @@ import { TransitionMap } from '../utils/transitions' -export const ONBOARDING_STEPS = ['profile_basics', 'consent', 'preferences'] as const -export type OnboardingStep = typeof ONBOARDING_STEPS[number] +export const ONBOARDING_STEPS = [ + 'profile_basics', + 'consent', + 'preferences', +] as const +export type OnboardingStep = (typeof ONBOARDING_STEPS)[number] -export const REQUIRED_ONBOARDING_STEPS: readonly OnboardingStep[] = ['profile_basics', 'consent'] +export const REQUIRED_ONBOARDING_STEPS: readonly OnboardingStep[] = [ + 'profile_basics', + 'consent', +] export const ONBOARDING_STATUSES = ['in_progress', 'completed'] as const -export type OnboardingStatus = typeof ONBOARDING_STATUSES[number] +export type OnboardingStatus = (typeof ONBOARDING_STATUSES)[number] export const ONBOARDING_TRANSITIONS: TransitionMap = { in_progress: ['completed'], diff --git a/src/types/preference.types.ts b/src/types/preference.types.ts index fb33ee57..2843f6b0 100644 --- a/src/types/preference.types.ts +++ b/src/types/preference.types.ts @@ -1,17 +1,32 @@ export const SUPPORTED_LOCALES = [ - 'en-US', 'en-GB', 'fr-FR', 'es-ES', 'pt-BR', 'sw-KE', 'ar-SA', 'de-DE', + 'en-US', + 'en-GB', + 'fr-FR', + 'es-ES', + 'pt-BR', + 'sw-KE', + 'ar-SA', + 'de-DE', ] as const -export type SupportedLocale = typeof SUPPORTED_LOCALES[number] +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number] export const TEXT_SIZES = ['small', 'medium', 'large', 'extra_large'] as const -export type TextSize = typeof TEXT_SIZES[number] +export type TextSize = (typeof TEXT_SIZES)[number] -export const DIFFICULTY_LEVELS = ['beginner', 'intermediate', 'advanced'] as const -export type DifficultyLevel = typeof DIFFICULTY_LEVELS[number] +export const DIFFICULTY_LEVELS = [ + 'beginner', + 'intermediate', + 'advanced', +] as const +export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number] -export const PROFILE_VISIBILITIES = ['public', 'private', 'connections'] as const -export type ProfileVisibility = typeof PROFILE_VISIBILITIES[number] +export const PROFILE_VISIBILITIES = [ + 'public', + 'private', + 'connections', +] as const +export type ProfileVisibility = (typeof PROFILE_VISIBILITIES)[number] export interface LearnerPreferences { id: string @@ -48,8 +63,5 @@ export interface UpdateLearnerPreferencesData { } // Privacy-impacting fields are audited whenever they change. -export const PRIVACY_IMPACTING_FIELDS: readonly (keyof UpdateLearnerPreferencesData)[] = [ - 'profileVisibility', - 'analyticsConsent', - 'dataSharingConsent', -] +export const PRIVACY_IMPACTING_FIELDS: readonly (keyof UpdateLearnerPreferencesData)[] = + ['profileVisibility', 'analyticsConsent', 'dataSharingConsent'] diff --git a/src/types/profile.types.ts b/src/types/profile.types.ts index 483adccc..7665d4d1 100644 --- a/src/types/profile.types.ts +++ b/src/types/profile.types.ts @@ -1,8 +1,13 @@ -export const LEARNER_LEVELS = ['beginner', 'intermediate', 'advanced', 'expert'] as const -export type LearnerLevel = typeof LEARNER_LEVELS[number] +export const LEARNER_LEVELS = [ + 'beginner', + 'intermediate', + 'advanced', + 'expert', +] as const +export type LearnerLevel = (typeof LEARNER_LEVELS)[number] export const PROFILE_VISIBILITIES = ['private', 'employer', 'public'] as const -export type ProfileVisibility = typeof PROFILE_VISIBILITIES[number] +export type ProfileVisibility = (typeof PROFILE_VISIBILITIES)[number] // Ordered from most to least restrictive. A profile's `visibility` is the // widest audience allowed to see its non-private fields; the owner can @@ -61,14 +66,36 @@ export interface OwnerProfileView extends LearnerProfileRecord { } export type EmployerProfileView = - | (Pick & { visible: true }) + | (Pick< + LearnerProfileRecord, + | 'id' + | 'displayName' + | 'bio' + | 'avatarUrl' + | 'country' + | 'timezone' + | 'languages' + | 'level' + | 'interests' + | 'goals' + > & { visible: true }) | { id: string; visible: false } export type PublicProfileView = - | (Pick & { visible: true }) + | (Pick< + LearnerProfileRecord, + | 'id' + | 'displayName' + | 'bio' + | 'avatarUrl' + | 'country' + | 'level' + | 'interests' + > & { visible: true }) | { id: string; visible: false } -export interface PrivateProfileView extends LearnerProfileRecord, AccountPrivateFields {} +export interface PrivateProfileView + extends LearnerProfileRecord, AccountPrivateFields {} // ── Owner account/profile aggregate ──────────────────────────────────────── diff --git a/src/types/reward.types.ts b/src/types/reward.types.ts index 46a59008..ac8aacd7 100644 --- a/src/types/reward.types.ts +++ b/src/types/reward.types.ts @@ -34,65 +34,65 @@ export enum TransactionReason { /** API-layer transaction shape. `amount` is a 7-decimal XLM string. */ export interface Transaction { - id: string; - userId: string; - type: TransactionType; - status: TransactionStatus; - reason: TransactionReason; + id: string + userId: string + type: TransactionType + status: TransactionStatus + reason: TransactionReason /** 7-decimal XLM string, e.g. "5.0000000". Never a JavaScript number. */ - amount: string; + amount: string /** 7-decimal XLM string. */ - balanceBefore: string; + balanceBefore: string /** 7-decimal XLM string. */ - balanceAfter: string; - referenceId?: string; - referenceType?: string; - note?: string; - createdAt: string; - completedAt?: string; + balanceAfter: string + referenceId?: string + referenceType?: string + note?: string + createdAt: string + completedAt?: string } /** API-layer balance shape. All amounts are 7-decimal XLM strings. */ export interface Balance { - userId: string; + userId: string /** 7-decimal XLM string. */ - available: string; + available: string /** 7-decimal XLM string. */ - pending: string; + pending: string /** 7-decimal XLM string. */ - lifetime: string; - updatedAt: string; + lifetime: string + updatedAt: string } export interface RewardSummary { - balance: Balance; - recentTransactions: Transaction[]; + balance: Balance + recentTransactions: Transaction[] /** 7-decimal XLM string. */ - earnedThisMonth: string; + earnedThisMonth: string /** 7-decimal XLM string. */ - spentThisMonth: string; + spentThisMonth: string } // Request types export interface CreateTransactionRequest { - userId: string; - type: TransactionType; - reason: TransactionReason; + userId: string + type: TransactionType + reason: TransactionReason /** 7-decimal XLM string submitted by the caller. */ - amount: string; - referenceId?: string; - referenceType?: string; - note?: string; + amount: string + referenceId?: string + referenceType?: string + note?: string } export interface TransactionFilterParams { - type?: TransactionType; - status?: TransactionStatus; - reason?: TransactionReason; - fromDate?: string; - toDate?: string; + type?: TransactionType + status?: TransactionStatus + reason?: TransactionReason + fromDate?: string + toDate?: string /** 7-decimal XLM string (lower bound). */ - minAmount?: string; + minAmount?: string /** 7-decimal XLM string (upper bound). */ - maxAmount?: string; + maxAmount?: string } diff --git a/src/types/session.types.ts b/src/types/session.types.ts index 346ac98e..64862c6d 100644 --- a/src/types/session.types.ts +++ b/src/types/session.types.ts @@ -66,4 +66,5 @@ export const SessionAuditAction = { REFRESH_REUSE_DETECTED: 'REFRESH_REUSE_DETECTED', } as const -export type SessionAuditActionValue = (typeof SessionAuditAction)[keyof typeof SessionAuditAction] +export type SessionAuditActionValue = + (typeof SessionAuditAction)[keyof typeof SessionAuditAction] diff --git a/src/types/user.types.ts b/src/types/user.types.ts index 4f13fb31..c87ea749 100644 --- a/src/types/user.types.ts +++ b/src/types/user.types.ts @@ -26,37 +26,37 @@ export enum UserStatus { * a value, and do not add new ones here. */ export interface User { - id: string; - email: string; - username: string; - firstName?: string; - lastName?: string; - bio?: string; - avatar?: string; - walletAddress?: string; - role: UserRole; - status: UserStatus; - isActive: boolean; - createdAt: Date; - updatedAt: Date; - lastLoginAt?: Date; + id: string + email: string + username: string + firstName?: string + lastName?: string + bio?: string + avatar?: string + walletAddress?: string + role: UserRole + status: UserStatus + isActive: boolean + createdAt: Date + updatedAt: Date + lastLoginAt?: Date } export interface UserProfile extends User { - totalCredentials: number; - totalPoints: number; - completedModules: number; + totalCredentials: number + totalPoints: number + completedModules: number } // ── Request types ────────────────────────────────────────── export interface CreateUserData { - email: string; - username: string; - password: string; - firstName?: string; - lastName?: string; - role?: UserRole; + email: string + username: string + password: string + firstName?: string + lastName?: string + role?: UserRole } // `UpdateUserData`, `ChangePasswordData`, `UpdateWalletData` and @@ -67,16 +67,16 @@ export interface CreateUserData { // in types/profile.types.ts, so a shape and its validation can no longer drift. export interface UpdateUserRoleData { - role: UserRole; + role: UserRole } export interface UpdateUserStatusData { - status: UserStatus; + status: UserStatus } export interface UserFilterParams { - role?: UserRole; - status?: UserStatus; - search?: string; - isActive?: boolean; + role?: UserRole + status?: UserStatus + search?: string + isActive?: boolean } diff --git a/src/types/wallet-provisioning.types.ts b/src/types/wallet-provisioning.types.ts index b396149a..b95cf486 100644 --- a/src/types/wallet-provisioning.types.ts +++ b/src/types/wallet-provisioning.types.ts @@ -25,18 +25,27 @@ export const WALLET_TRANSITIONS: TransitionMap = { DISABLED: [], } as const -export function canTransitionWallet(from: WalletStatus, to: WalletStatus): boolean { +export function canTransitionWallet( + from: WalletStatus, + to: WalletStatus, +): boolean { return canTransition(WALLET_TRANSITIONS, from, to) } export class InvalidWalletTransitionError extends Error { - constructor(readonly from: WalletStatus, readonly to: WalletStatus) { + constructor( + readonly from: WalletStatus, + readonly to: WalletStatus, + ) { super(`Cannot transition wallet status from '${from}' to '${to}'`) this.name = 'InvalidWalletTransitionError' } } -export function assertValidWalletTransition(from: WalletStatus, to: WalletStatus): void { +export function assertValidWalletTransition( + from: WalletStatus, + to: WalletStatus, +): void { if (!canTransitionWallet(from, to)) { throw new InvalidWalletTransitionError(from, to) } @@ -123,9 +132,7 @@ export function toPublicWallet(wallet: WalletRecord): PublicWallet { } export type WalletEligibilityCode = - | 'USER_NOT_FOUND' - | 'USER_NOT_VERIFIED' - | 'CUSTODIAL_CONSENT_REQUIRED' + 'USER_NOT_FOUND' | 'USER_NOT_VERIFIED' | 'CUSTODIAL_CONSENT_REQUIRED' export class WalletEligibilityError extends Error { constructor(readonly code: WalletEligibilityCode) { diff --git a/src/types/wallet-self-custody-export.types.ts b/src/types/wallet-self-custody-export.types.ts index 7e753c21..19670b17 100644 --- a/src/types/wallet-self-custody-export.types.ts +++ b/src/types/wallet-self-custody-export.types.ts @@ -37,7 +37,10 @@ export interface WalletExportAuthorizationRepository { sessionId: string now: Date }): Promise - completeMigration(authorizationId: string, completedAt: Date): Promise + completeMigration( + authorizationId: string, + completedAt: Date, + ): Promise releaseClaim(authorizationId: string): Promise } diff --git a/src/types/wallet-status.types.ts b/src/types/wallet-status.types.ts index a87d02f8..73d89960 100644 --- a/src/types/wallet-status.types.ts +++ b/src/types/wallet-status.types.ts @@ -1,4 +1,5 @@ -export type WalletStatusValue = 'NOT_PROVISIONED' | 'PENDING' | 'ACTIVE' | 'UNAVAILABLE' +export type WalletStatusValue = + 'NOT_PROVISIONED' | 'PENDING' | 'ACTIVE' | 'UNAVAILABLE' export interface WalletStatusView { status: WalletStatusValue @@ -50,10 +51,14 @@ export interface WalletHistoryPageView { nextCursor: string | null } -export type WalletStatusErrorCode = 'WALLET_NOT_FOUND' | 'HORIZON_TIMEOUT' | 'HORIZON_UNAVAILABLE' +export type WalletStatusErrorCode = + 'WALLET_NOT_FOUND' | 'HORIZON_TIMEOUT' | 'HORIZON_UNAVAILABLE' export class WalletStatusError extends Error { - constructor(readonly code: WalletStatusErrorCode, message?: string) { + constructor( + readonly code: WalletStatusErrorCode, + message?: string, + ) { super(message ?? code) this.name = 'WalletStatusError' } diff --git a/src/types/webhook.types.ts b/src/types/webhook.types.ts index d2d6da26..0b1cc850 100644 --- a/src/types/webhook.types.ts +++ b/src/types/webhook.types.ts @@ -1,21 +1,18 @@ export type WebhookEventType = - | 'module.completed' - | 'reward.issued' - | 'user.registered' - | 'system.test'; + 'module.completed' | 'reward.issued' | 'user.registered' | 'system.test' export interface WebhookPayload { - eventId: string; - eventType: WebhookEventType; - timestamp: string; - data: any; + eventId: string + eventType: WebhookEventType + timestamp: string + data: any } export interface WebhookEndpointCreate { - url: string; - secret?: string; - events: WebhookEventType[]; - description?: string; + url: string + secret?: string + events: WebhookEventType[] + description?: string } -export type WebhookStatus = 'pending' | 'success' | 'failed'; +export type WebhookStatus = 'pending' | 'success' | 'failed' diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts index 71afda8b..86b37b53 100644 --- a/src/utils/cookies.ts +++ b/src/utils/cookies.ts @@ -7,7 +7,9 @@ * cookie attributes (Path/Domain/SameSite/Expires), which never appear on the * request side anyway. */ -export function parseCookieHeader(header: string | undefined): Record { +export function parseCookieHeader( + header: string | undefined, +): Record { const cookies: Record = {} if (!header) { diff --git a/src/utils/date.ts b/src/utils/date.ts index 558c88d7..516db854 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,6 +1,6 @@ export interface FormatDateOptions extends Intl.DateTimeFormatOptions { - locale?: string; - timeZone?: string; + locale?: string + timeZone?: string } /** @@ -12,13 +12,14 @@ export interface FormatDateOptions extends Intl.DateTimeFormatOptions { */ export function formatDate( date: Date | string | number, - opts: FormatDateOptions = {} + opts: FormatDateOptions = {}, ): string { const { locale = 'en-US', timeZone, ...rest } = opts - const d = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date + const d = + typeof date === 'string' || typeof date === 'number' ? new Date(date) : date const formatter = new Intl.DateTimeFormat(locale, { timeZone, ...rest }) - -return formatter.format(d) + + return formatter.format(d) } /** diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 715c656e..f2fdbab6 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -13,7 +13,7 @@ export class AppError extends Error { message: string, statusCode: number = 500, code: ErrorCode | string = ErrorCode.INTERNAL_SERVER_ERROR, - isOperational: boolean = true + isOperational: boolean = true, ) { super(message) this.statusCode = statusCode @@ -82,7 +82,7 @@ export class ValidationError extends AppError { constructor( message: string = 'Validation failed', - errors?: Record + errors?: Record, ) { super(message, 422, ErrorCode.VALIDATION_ERROR) this.errors = errors diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 457804e9..e0ad90ce 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -1,7 +1,7 @@ import jwt, { SignOptions, VerifyOptions } from 'jsonwebtoken' export interface JWTPayload { - [key: string]: any; + [key: string]: any } /** @@ -10,7 +10,7 @@ export interface JWTPayload { export function signToken( payload: JWTPayload, secret: string, - options: SignOptions = {} + options: SignOptions = {}, ): string { return jwt.sign(payload, secret, options) } @@ -21,7 +21,7 @@ export function signToken( export function verifyToken( token: string, secret: string, - options: VerifyOptions = {} + options: VerifyOptions = {}, ): JWTPayload { return jwt.verify(token, secret, options) as JWTPayload } @@ -30,5 +30,5 @@ export function verifyToken( * Decode a JWT without verifying signature. Returns null if invalid. */ export function decodeToken(token: string): JWTPayload | null { - return (jwt.decode(token) as JWTPayload | null) + return jwt.decode(token) as JWTPayload | null } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 43a605ed..309e403c 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,13 +1,7 @@ import configLogger from '../config/logger' export type LogLevel = - | 'error' - | 'warn' - | 'info' - | 'http' - | 'verbose' - | 'debug' - | 'silly'; + 'error' | 'warn' | 'info' | 'http' | 'verbose' | 'debug' | 'silly' const logger = { error: (message: string, meta?: any) => configLogger.error(message, meta), diff --git a/src/utils/money.ts b/src/utils/money.ts index dc858002..4e6ed5cd 100644 --- a/src/utils/money.ts +++ b/src/utils/money.ts @@ -82,8 +82,7 @@ export function xlmStringToStroops(xlmString: string): bigint { const [wholePart = '0', fracPart = ''] = abs.split('.') const paddedFrac = fracPart.padEnd(7, '0') - const result = - BigInt(wholePart) * STROOPS_PER_XLM + BigInt(paddedFrac) + const result = BigInt(wholePart) * STROOPS_PER_XLM + BigInt(paddedFrac) const signed = isNegative ? -result : result assertInRange(signed) @@ -176,7 +175,10 @@ export function multiplyStroops( denominator: bigint, ): bigint { if (denominator === 0n) { - throw new MoneyError('Division by zero in multiplyStroops', 'DIVIDE_BY_ZERO') + throw new MoneyError( + 'Division by zero in multiplyStroops', + 'DIVIDE_BY_ZERO', + ) } const result = (stroops * numerator) / denominator @@ -217,10 +219,7 @@ export function formatStroops(stroops: bigint, assetCode = 'XLM'): string { */ export function assertInRange(stroops: bigint): void { if (stroops < 0n) { - throw new MoneyError( - `Stroop value ${stroops} is negative`, - 'OUT_OF_RANGE', - ) + throw new MoneyError(`Stroop value ${stroops} is negative`, 'OUT_OF_RANGE') } if (stroops > MAX_STROOPS) { @@ -235,11 +234,7 @@ export function assertInRange(stroops: bigint): void { * Return `true` when `stroops` is a non-negative BigInt within range. */ export function isValidStroopAmount(stroops: unknown): stroops is bigint { - return ( - typeof stroops === 'bigint' && - stroops >= 0n && - stroops <= MAX_STROOPS - ) + return typeof stroops === 'bigint' && stroops >= 0n && stroops <= MAX_STROOPS } // --------------------------------------------------------------------------- diff --git a/src/utils/number.ts b/src/utils/number.ts index 9cb20ad4..45b24a88 100644 --- a/src/utils/number.ts +++ b/src/utils/number.ts @@ -10,7 +10,7 @@ export function formatCurrency( value: number, currency = 'USD', locale = 'en-US', - decimals = 2 + decimals = 2, ): string { const formatter = new Intl.NumberFormat(locale, { style: 'currency', @@ -18,6 +18,6 @@ export function formatCurrency( minimumFractionDigits: decimals, maximumFractionDigits: decimals, }) - -return formatter.format(value) + + return formatter.format(value) } diff --git a/src/utils/password.ts b/src/utils/password.ts index 12b6eca7..bf4dce31 100644 --- a/src/utils/password.ts +++ b/src/utils/password.ts @@ -20,8 +20,8 @@ export function isStrongPassword(password: string): boolean { const hasLower = /[a-z]/.test(password) const hasNumber = /[0-9]/.test(password) const hasSymbol = /[^A-Za-z0-9]/.test(password) - -return ( + + return ( password.length >= minLength && hasUpper && hasLower && @@ -32,14 +32,14 @@ return ( export async function hashPassword( password: string, - saltRounds = getConfiguredSaltRounds() + saltRounds = getConfiguredSaltRounds(), ): Promise { return bcrypt.hash(password, saltRounds) } export async function comparePassword( password: string, - hashed: string + hashed: string, ): Promise { return bcrypt.compare(password, hashed) } diff --git a/src/utils/string.ts b/src/utils/string.ts index 45c0f67c..3d2c97e6 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -19,9 +19,9 @@ export function slugify(input: string): string { export function truncate( input: string, maxLength: number, - ellipsis = '...' + ellipsis = '...', ): string { if (input.length <= maxLength) return input - -return input.slice(0, maxLength - ellipsis.length) + ellipsis + + return input.slice(0, maxLength - ellipsis.length) + ellipsis } diff --git a/src/utils/transitions.ts b/src/utils/transitions.ts index 68fb91aa..9e80cae7 100644 --- a/src/utils/transitions.ts +++ b/src/utils/transitions.ts @@ -1,5 +1,9 @@ export type TransitionMap = Readonly> -export function canTransition(map: TransitionMap, from: S, to: S): boolean { +export function canTransition( + map: TransitionMap, + from: S, + to: S, +): boolean { return map[from]?.includes(to) ?? false } diff --git a/src/workers/outbox-relay.ts b/src/workers/outbox-relay.ts index 4be70387..582b4591 100644 --- a/src/workers/outbox-relay.ts +++ b/src/workers/outbox-relay.ts @@ -8,7 +8,10 @@ import { getOutboxHandlerRegistry, OutboxHandlerRegistry, } from '../lib/transactions/handler-registry' -import { createJobLeaseService, JobLeaseService } from '../lib/transactions/job-lease.service' +import { + createJobLeaseService, + JobLeaseService, +} from '../lib/transactions/job-lease.service' import logger from '../utils/logger' export interface OutboxRelayOptions { @@ -63,7 +66,10 @@ export class OutboxRelay { return { materialized, dispatched, failed, unhandled } } - async materializePending(): Promise<{ materialized: number; unhandled: number }> { + async materializePending(): Promise<{ + materialized: number + unhandled: number + }> { const pending = (await this.prisma.outboxEvent.findMany({ where: { status: 'PENDING', jobAttempts: { none: {} } }, orderBy: { createdAt: 'asc' }, @@ -82,12 +88,15 @@ export class OutboxRelay { let unhandled = 0 for (const event of pending) { - const handlers = this.handlers.handlersFor(event.eventType, event.eventVersion) + const handlers = this.handlers.handlersFor( + event.eventType, + event.eventVersion, + ) if (handlers.length === 0) { unhandled += 1 this.log.error( - `[relay] no handler registered for ${event.eventType} v${event.eventVersion}; dead-lettering event ${event.id}` + `[relay] no handler registered for ${event.eventType} v${event.eventVersion}; dead-lettering event ${event.id}`, ) await this.prisma.outboxEvent.update({ where: { id: event.id }, @@ -97,7 +106,7 @@ export class OutboxRelay { } await this.prisma.$transaction( - handlers.map(handler => + handlers.map((handler) => this.prisma.jobAttempt.create({ data: { outboxEventId: event.id, @@ -110,13 +119,13 @@ export class OutboxRelay { backoffMultiplier: handler.backoffMultiplier ?? 2.0, availableAt: new Date(), }, - }) - ) + }), + ), ) materialized += 1 this.log.info( - `[relay] materialized ${handlers.length} job(s) for ${event.eventType} v${event.eventVersion} (${event.id})` + `[relay] materialized ${handlers.length} job(s) for ${event.eventType} v${event.eventVersion} (${event.id})`, ) } @@ -143,11 +152,16 @@ export class OutboxRelay { return { dispatched, failed } } - private async dispatchOne(jobType: string): Promise<'ok' | 'failed' | 'idle'> { + private async dispatchOne( + jobType: string, + ): Promise<'ok' | 'failed' | 'idle'> { const handler = this.handlers.handlerByName(jobType) if (!handler) return 'idle' - const lease = await this.leases.leaseJob({ jobType, maxLeaseMs: this.leaseMs }) + const lease = await this.leases.leaseJob({ + jobType, + maxLeaseMs: this.leaseMs, + }) if (!lease) return 'idle' const job = await this.prisma.jobAttempt.findUnique({ @@ -156,7 +170,11 @@ export class OutboxRelay { }) if (!job?.outboxEvent) { - await this.leases.failJob(lease.jobId, lease.leaseToken, 'Outbox event missing for job') + await this.leases.failJob( + lease.jobId, + lease.leaseToken, + 'Outbox event missing for job', + ) return 'failed' } @@ -164,7 +182,11 @@ export class OutboxRelay { const event = job.outboxEvent as unknown as EventRow try { - await this.schemas.validate(event.eventType, event.eventVersion, lease.payload) + await this.schemas.validate( + event.eventType, + event.eventVersion, + lease.payload, + ) const result = await handler.handle({ eventId: event.id, @@ -183,14 +205,14 @@ export class OutboxRelay { }) this.log.info( - `[relay] dispatched ${event.eventType} v${event.eventVersion} -> ${handler.name} (${event.id})` + `[relay] dispatched ${event.eventType} v${event.eventVersion} -> ${handler.name} (${event.id})`, ) return 'ok' } catch (error) { const message = error instanceof Error ? error.message : String(error) this.log.error( - `[relay] handler ${handler.name} failed for ${event.eventType} (${event.id}) attempt ${lease.attempt + 1}: ${message}` + `[relay] handler ${handler.name} failed for ${event.eventType} (${event.id}) attempt ${lease.attempt + 1}: ${message}`, ) await this.leases.failJob(lease.jobId, lease.leaseToken, error as Error) @@ -213,12 +235,18 @@ export class OutboxRelay { data: { status: 'PENDING' }, }) - this.log.info(`[relay] replayed event ${eventId} (${jobs.length} job(s) reset)`) + this.log.info( + `[relay] replayed event ${eventId} (${jobs.length} job(s) reset)`, + ) return jobs.length } - async deadLetterEvents(limit = 100): Promise> { + async deadLetterEvents( + limit = 100, + ): Promise< + Array<{ id: string; eventType: string; lastError: string | null }> + > { const events = await this.prisma.outboxEvent.findMany({ where: { status: 'DEAD_LETTER' }, orderBy: { createdAt: 'asc' }, @@ -234,7 +262,7 @@ export class OutboxRelay { }, }) - return events.map(event => ({ + return events.map((event) => ({ id: event.id, eventType: event.eventType, lastError: event.jobAttempts[0]?.lastError ?? null, @@ -242,6 +270,8 @@ export class OutboxRelay { } } -export function createOutboxRelay(options: OutboxRelayOptions = {}): OutboxRelay { +export function createOutboxRelay( + options: OutboxRelayOptions = {}, +): OutboxRelay { return new OutboxRelay(options) } diff --git a/src/workers/outbox-replay.ts b/src/workers/outbox-replay.ts index 280091aa..2fcca6cd 100644 --- a/src/workers/outbox-replay.ts +++ b/src/workers/outbox-replay.ts @@ -6,7 +6,10 @@ import { createOutboxRelay } from './outbox-relay' async function main(): Promise { const [command, ...args] = process.argv.slice(2) - const relay = createOutboxRelay({ prisma, handlers: registerOutboxHandlers({ prisma }) }) + const relay = createOutboxRelay({ + prisma, + handlers: registerOutboxHandlers({ prisma }), + }) if (command === 'list') { const events = await relay.deadLetterEvents() @@ -15,7 +18,9 @@ async function main(): Promise { logger.info('[replay] no dead-lettered events') } else { for (const event of events) { - logger.info(`[replay] ${event.id} ${event.eventType} ${event.lastError ?? ''}`) + logger.info( + `[replay] ${event.id} ${event.eventType} ${event.lastError ?? ''}`, + ) } } diff --git a/src/workers/queue-metrics.ts b/src/workers/queue-metrics.ts index 1f775f86..bdd0b387 100644 --- a/src/workers/queue-metrics.ts +++ b/src/workers/queue-metrics.ts @@ -79,7 +79,9 @@ export class QueueMetricsRegistry { } snapshot(): QueueMetricsSnapshot[] { - return [...this.queues.values()].sort((a, b) => a.queue.localeCompare(b.queue)) + return [...this.queues.values()].sort((a, b) => + a.queue.localeCompare(b.queue), + ) } reset(): void { @@ -103,7 +105,10 @@ export class QueueMetricsRegistry { if (sample.outcome === 'failed') { logger.error('[scheduler] queue tick failed', meta) } else if (sample.outcome === 'skipped') { - logger.debug('[scheduler] queue tick skipped (lease held elsewhere)', meta) + logger.debug( + '[scheduler] queue tick skipped (lease held elsewhere)', + meta, + ) } else { logger.info('[scheduler] queue tick', meta) } diff --git a/src/workers/queue-registry.ts b/src/workers/queue-registry.ts index f20852e7..e44d7f8f 100644 --- a/src/workers/queue-registry.ts +++ b/src/workers/queue-registry.ts @@ -33,7 +33,7 @@ interface CountableDelegate { function delegateDepth( delegate: CountableDelegate, - options: DelegateDepthOptions + options: DelegateDepthOptions, ): () => Promise { const dueField = options.dueField ?? 'nextAttemptAt' @@ -54,7 +54,11 @@ function delegateDepth( }), ]) - return { depth, due, oldestDueAt: (oldest?.[dueField] as Date | null) ?? null } + return { + depth, + due, + oldestDueAt: (oldest?.[dueField] as Date | null) ?? null, + } } } @@ -65,14 +69,19 @@ export interface QueueRegistryDeps { outboxRelay?: OutboxRelay } -export function createDefaultQueues(deps: QueueRegistryDeps = {}): ScheduledQueue[] { +export function createDefaultQueues( + deps: QueueRegistryDeps = {}, +): ScheduledQueue[] { const prisma = deps.prisma ?? defaultPrisma - const notificationService = deps.notificationService ?? new NotificationService() + const notificationService = + deps.notificationService ?? new NotificationService() const webhookService = deps.webhookService ?? new WebhookService() - const outboxRelay = deps.outboxRelay ?? createOutboxRelay({ - prisma, - handlers: registerOutboxHandlers({ prisma }), - }) + const outboxRelay = + deps.outboxRelay ?? + createOutboxRelay({ + prisma, + handlers: registerOutboxHandlers({ prisma }), + }) const db = prisma as unknown as Record @@ -80,17 +89,23 @@ export function createDefaultQueues(deps: QueueRegistryDeps = {}): ScheduledQueu { name: 'email', drain: () => emailService.processQueue(), - inspect: delegateDepth(db.emailDelivery, { pending: { status: 'pending' } }), + inspect: delegateDepth(db.emailDelivery, { + pending: { status: 'pending' }, + }), }, { name: 'notification', drain: () => notificationService.processQueue(), - inspect: delegateDepth(db.notificationLog, { pending: { status: 'pending' } }), + inspect: delegateDepth(db.notificationLog, { + pending: { status: 'pending' }, + }), }, { name: 'webhook', drain: () => webhookService.processQueue(), - inspect: delegateDepth(db.webhookDelivery, { pending: { status: 'pending' } }), + inspect: delegateDepth(db.webhookDelivery, { + pending: { status: 'pending' }, + }), }, { name: 'stellar-funding', @@ -116,10 +131,14 @@ export function createDefaultQueues(deps: QueueRegistryDeps = {}): ScheduledQueu }, inspect: async () => { const [depth, oldest] = await Promise.all([ - (prisma as unknown as { outboxEvent: CountableDelegate }).outboxEvent.count({ + ( + prisma as unknown as { outboxEvent: CountableDelegate } + ).outboxEvent.count({ where: { status: 'PENDING' }, }), - (prisma as unknown as { outboxEvent: CountableDelegate }).outboxEvent.findFirst({ + ( + prisma as unknown as { outboxEvent: CountableDelegate } + ).outboxEvent.findFirst({ where: { status: 'PENDING' }, orderBy: { createdAt: 'asc' }, select: { createdAt: true }, diff --git a/src/workers/scheduled-job-runner.ts b/src/workers/scheduled-job-runner.ts index 9d1946b9..c136c24b 100644 --- a/src/workers/scheduled-job-runner.ts +++ b/src/workers/scheduled-job-runner.ts @@ -1,9 +1,17 @@ import type { PrismaClient } from '@prisma/client' import defaultPrisma from '../config/database' import { schedulerConfig, type SchedulerConfig } from '../config/scheduler' -import { createJobLeaseService, JobLeaseService } from '../lib/transactions/job-lease.service' +import { + createJobLeaseService, + JobLeaseService, +} from '../lib/transactions/job-lease.service' import logger from '../utils/logger' -import { queueMetrics, QueueMetricsRegistry, type QueueDepthSnapshot, type TickOutcome } from './queue-metrics' +import { + queueMetrics, + QueueMetricsRegistry, + type QueueDepthSnapshot, + type TickOutcome, +} from './queue-metrics' import { createDefaultQueues, type ScheduledQueue } from './queue-registry' export type QueueLeaseApi = Pick< @@ -35,14 +43,16 @@ export class ScheduledJobRunner { constructor(options: ScheduledJobRunnerOptions) { this.config = options.config ?? schedulerConfig - this.queues = options.queues.filter(queue => this.config.isEnabled(queue.name)) + this.queues = options.queues.filter((queue) => + this.config.isEnabled(queue.name), + ) this.leaseService = options.leaseService this.metrics = options.metrics ?? queueMetrics this.log = options.log ?? logger } get registeredQueues(): string[] { - return this.queues.map(queue => queue.name) + return this.queues.map((queue) => queue.name) } start(): void { @@ -58,7 +68,7 @@ export class ScheduledJobRunner { for (const queue of this.queues) { this.log.info( - `[scheduler] registered queue "${queue.name}" (every ${this.config.intervalFor(queue.name)}ms)` + `[scheduler] registered queue "${queue.name}" (every ${this.config.intervalFor(queue.name)}ms)`, ) this.schedule(queue, 0) } @@ -88,13 +98,13 @@ export class ScheduledJobRunner { this.log.info(`[scheduler] draining ${pending.length} in-flight tick(s)`) const drained = await this.withDeadline( Promise.allSettled(pending), - this.config.shutdownTimeoutMs + this.config.shutdownTimeoutMs, ) if (!drained) { this.log.warn( `[scheduler] shutdown deadline (${this.config.shutdownTimeoutMs}ms) reached; ` + - 'remaining leases expire on their own' + 'remaining leases expire on their own', ) } } @@ -151,13 +161,19 @@ export class ScheduledJobRunner { return this.record(queue, 'skipped', 0, before, lagMs) } - const heartbeat = setInterval(() => { - void this.leaseService - .renewQueueLease(queue.name, lease.leaseToken, leaseMs) - .catch(error => - this.log.warn(`[scheduler] failed to renew lease for "${queue.name}"`, error) - ) - }, Math.max(1_000, Math.floor(leaseMs / 2))) + const heartbeat = setInterval( + () => { + void this.leaseService + .renewQueueLease(queue.name, lease.leaseToken, leaseMs) + .catch((error) => + this.log.warn( + `[scheduler] failed to renew lease for "${queue.name}"`, + error, + ), + ) + }, + Math.max(1_000, Math.floor(leaseMs / 2)), + ) const startedAt = Date.now() @@ -166,13 +182,23 @@ export class ScheduledJobRunner { return this.record(queue, 'ran', Date.now() - startedAt, before, lagMs) } catch (error) { - return this.record(queue, 'failed', Date.now() - startedAt, before, lagMs, error) + return this.record( + queue, + 'failed', + Date.now() - startedAt, + before, + lagMs, + error, + ) } finally { clearInterval(heartbeat) await this.leaseService .releaseQueueLease(queue.name, lease.leaseToken) - .catch(error => - this.log.warn(`[scheduler] failed to release lease for "${queue.name}"`, error) + .catch((error) => + this.log.warn( + `[scheduler] failed to release lease for "${queue.name}"`, + error, + ), ) } } @@ -193,7 +219,7 @@ export class ScheduledJobRunner { durationMs: number, depth: QueueDepthSnapshot, lagMs: number, - error?: unknown + error?: unknown, ): TickOutcome { this.metrics.record({ queue: queue.name, @@ -208,10 +234,13 @@ export class ScheduledJobRunner { return outcome } - private async withDeadline(work: Promise, timeoutMs: number): Promise { + private async withDeadline( + work: Promise, + timeoutMs: number, + ): Promise { let timer: NodeJS.Timeout | undefined - const deadline = new Promise(resolve => { + const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs) }) @@ -228,7 +257,9 @@ function toMessage(error: unknown): string { } export function createScheduledJobRunner( - overrides: Partial & { prisma?: PrismaClient } = {} + overrides: Partial & { + prisma?: PrismaClient + } = {}, ): ScheduledJobRunner { const prisma = overrides.prisma ?? defaultPrisma diff --git a/src/workers/scheduler.worker.ts b/src/workers/scheduler.worker.ts index 47660c5d..8062a761 100644 --- a/src/workers/scheduler.worker.ts +++ b/src/workers/scheduler.worker.ts @@ -10,7 +10,9 @@ let isShuttingDown = false async function gracefulShutdown(signal: string): Promise { if (isShuttingDown) { - logger.warn('[scheduler] shutdown already in progress, ignoring additional signal') + logger.warn( + '[scheduler] shutdown already in progress, ignoring additional signal', + ) return } @@ -51,12 +53,14 @@ process.on('unhandledRejection', (reason: unknown) => { logger.info( `[scheduler] starting runner ${schedulerConfig.ownerId} ` + - `(base interval ${schedulerConfig.intervalMs}ms, lease ${schedulerConfig.leaseMs}ms)` + `(base interval ${schedulerConfig.intervalMs}ms, lease ${schedulerConfig.leaseMs}ms)`, ) runner.start() if (runner.registeredQueues.length === 0) { - logger.error('[scheduler] no queues registered; check SCHEDULER_QUEUES / SCHEDULER_DISABLED_QUEUES') + logger.error( + '[scheduler] no queues registered; check SCHEDULER_QUEUES / SCHEDULER_DISABLED_QUEUES', + ) process.exit(1) } diff --git a/swagger-server.ts b/swagger-server.ts index b4aaa516..83800c51 100644 --- a/swagger-server.ts +++ b/swagger-server.ts @@ -15,7 +15,8 @@ const options: swaggerJsdoc.Options = { info: { title: 'Learnault API Documentation', version: '1.0.0', - description: 'Comprehensive API documentation for Learnault - a decentralized learn-to-earn platform on Stellar', + description: + 'Comprehensive API documentation for Learnault - a decentralized learn-to-earn platform on Stellar', contact: { name: 'Learnault Contributors', url: 'https://github.com/learnault/learnault', diff --git a/tests/account-lifecycle.service.test.ts b/tests/account-lifecycle.service.test.ts index 16ba43a4..46612fcf 100644 --- a/tests/account-lifecycle.service.test.ts +++ b/tests/account-lifecycle.service.test.ts @@ -19,7 +19,11 @@ vi.mock('../src/config/database', () => ({ credential: { deleteMany: vi.fn(), findMany: vi.fn() }, referral: { deleteMany: vi.fn(), findMany: vi.fn(), findFirst: vi.fn() }, stellarFunding: { deleteMany: vi.fn() }, - dataExportRequest: { deleteMany: vi.fn(), findMany: vi.fn(), updateMany: vi.fn() }, + dataExportRequest: { + deleteMany: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + }, accountDeletionRequest: { findFirst: vi.fn(), findMany: vi.fn(), @@ -55,7 +59,9 @@ describe('AccountLifecycleService', () => { beforeEach(() => { vi.resetAllMocks() service = new AccountLifecycleService() - vi.mocked(prisma.$transaction).mockImplementation((args: any[]) => Promise.all(args)) + vi.mocked(prisma.$transaction).mockImplementation((args: any[]) => + Promise.all(args), + ) vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any) }) @@ -72,7 +78,11 @@ describe('AccountLifecycleService', () => { it('treats a unique-index violation from a concurrent request as a duplicate', async () => { vi.mocked(prisma.accountDeletionRequest.findFirst) .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ id: 'del-winner', userId: 'user-1', status: 'pending' } as any) + .mockResolvedValueOnce({ + id: 'del-winner', + userId: 'user-1', + status: 'pending', + } as any) vi.mocked(prisma.$transaction).mockRejectedValue({ code: 'P2002' }) const result = await service.requestDeletion('user-1', undefined, {}) @@ -84,8 +94,12 @@ describe('AccountLifecycleService', () => { describe('processDue — finalization matrix', () => { it('applies the deletion/anonymization matrix and completes the request', async () => { - vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([dueRequest] as any) - vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([ + dueRequest, + ] as any) + vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ + count: 1, + } as any) await service.processDue() @@ -102,7 +116,9 @@ describe('AccountLifecycleService', () => { prisma.referralCode, prisma.dataExportRequest, ]) { - expect(model.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } }) + expect(model.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + }) } // Retained models: financial/on-chain records must NOT be deleted @@ -124,7 +140,9 @@ describe('AccountLifecycleService', () => { // User row anonymized in place (tombstone), never hard-deleted const userUpdate = vi.mocked(prisma.user.update).mock.calls[0][0] as any expect(userUpdate.where).toEqual({ id: 'user-1' }) - expect(userUpdate.data.email).toMatch(/^deleted\+[a-z0-9]+@anon\.invalid$/) + expect(userUpdate.data.email).toMatch( + /^deleted\+[a-z0-9]+@anon\.invalid$/, + ) expect(userUpdate.data.username).toMatch(/^deleted_[a-z0-9]+$/) expect(userUpdate.data.password).toMatch(/^[0-9a-f]{64}$/) expect(userUpdate.data.walletAddress).toBeNull() @@ -137,18 +155,22 @@ describe('AccountLifecycleService', () => { expect.objectContaining({ where: { id: 'del-1' }, data: expect.objectContaining({ status: 'completed' }), - }) + }), ) expect(prisma.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: 'DELETION_COMPLETED' }), - }) + }), ) }) it('skips rows another runner already claimed', async () => { - vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([dueRequest] as any) - vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 0 } as any) + vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([ + dueRequest, + ] as any) + vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ + count: 0, + } as any) await service.processDue() @@ -158,7 +180,9 @@ describe('AccountLifecycleService', () => { it('re-running after completion is a no-op (idempotent)', async () => { // Completed requests no longer match the pending filter - vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([] as any) + vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue( + [] as any, + ) await service.processDue() @@ -166,13 +190,18 @@ describe('AccountLifecycleService', () => { }) it('returns the request to pending with backoff on finalization failure', async () => { - vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([dueRequest] as any) - vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([ + dueRequest, + ] as any) + vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ + count: 1, + } as any) vi.mocked(prisma.$transaction).mockRejectedValue(new Error('db down')) await service.processDue() - const retryCall = vi.mocked(prisma.accountDeletionRequest.updateMany).mock.calls[1][0] as any + const retryCall = vi.mocked(prisma.accountDeletionRequest.updateMany).mock + .calls[1][0] as any expect(retryCall.where).toEqual({ id: 'del-1', status: 'processing' }) expect(retryCall.data.status).toBe('pending') expect(retryCall.data.error).toBe('db down') @@ -183,21 +212,28 @@ describe('AccountLifecycleService', () => { vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([ { ...dueRequest, attemptCount: 4 }, ] as any) - vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ + count: 1, + } as any) vi.mocked(prisma.$transaction).mockRejectedValue(new Error('db down')) await service.processDue() - const failCall = vi.mocked(prisma.accountDeletionRequest.updateMany).mock.calls[1][0] as any + const failCall = vi.mocked(prisma.accountDeletionRequest.updateMany).mock + .calls[1][0] as any expect(failCall.data.status).toBe('failed') }) }) describe('cancelDeletion', () => { it('reports finalized when the finalizer won the race', async () => { - vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 0 } as any) + vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ + count: 0, + } as any) vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue({ - id: 'del-1', userId: 'user-1', status: 'processing', + id: 'del-1', + userId: 'user-1', + status: 'processing', } as any) const result = await service.cancelDeletion('user-1', {}) @@ -207,7 +243,9 @@ describe('AccountLifecycleService', () => { }) it('reports none when no request exists', async () => { - vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 0 } as any) + vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ + count: 0, + } as any) vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue(null) const result = await service.cancelDeletion('user-1', {}) diff --git a/tests/account.controller.test.ts b/tests/account.controller.test.ts index 8cb25d77..03f421ea 100644 --- a/tests/account.controller.test.ts +++ b/tests/account.controller.test.ts @@ -64,7 +64,8 @@ import bcrypt from 'bcryptjs' import jwt from 'jsonwebtoken' import { emailService } from '../src/services/email.service' -const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) +const flushPromises = () => + new Promise((resolve) => setTimeout(resolve, 0)) const DAY_MS = 24 * 60 * 60 * 1000 @@ -106,11 +107,19 @@ describe('AccountController', () => { // Quiet defaults for the background lifecycle sweep vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([] as any) - vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue([] as any) - vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 0 } as any) + vi.mocked(prisma.accountDeletionRequest.findMany).mockResolvedValue( + [] as any, + ) + vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ + count: 0, + } as any) vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any) - vi.mocked(prisma.$transaction).mockImplementation((args: any[]) => Promise.all(args)) - vi.mocked(emailService.queueEmail).mockResolvedValue({ id: 'email-1' } as any) + vi.mocked(prisma.$transaction).mockImplementation((args: any[]) => + Promise.all(args), + ) + vi.mocked(emailService.queueEmail).mockResolvedValue({ + id: 'email-1', + } as any) vi.mocked(jwt.sign as any).mockReturnValue('mock_token') }) @@ -118,7 +127,10 @@ describe('AccountController', () => { it('accepts a new export request with 202', async () => { vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue(null) vi.mocked(prisma.dataExportRequest.create).mockResolvedValue({ - id: 'exp-1', userId: 'user-1', status: 'pending', createdAt: new Date(), + id: 'exp-1', + userId: 'user-1', + status: 'pending', + createdAt: new Date(), } as any) await controller.requestExport(req as Request, res as Response) @@ -126,13 +138,15 @@ describe('AccountController', () => { expect(res.status).toHaveBeenCalledWith(202) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ id: 'exp-1', status: 'pending' }) + expect.objectContaining({ id: 'exp-1', status: 'pending' }), ) }) it('returns 409 with the existing request id on duplicate', async () => { vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue({ - id: 'exp-existing', userId: 'user-1', status: 'processing', + id: 'exp-existing', + userId: 'user-1', + status: 'processing', } as any) await controller.requestExport(req as Request, res as Response) @@ -140,7 +154,7 @@ describe('AccountController', () => { expect(res.status).toHaveBeenCalledWith(409) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ existingRequestId: 'exp-existing' }) + expect.objectContaining({ existingRequestId: 'exp-existing' }), ) expect(prisma.dataExportRequest.create).not.toHaveBeenCalled() }) @@ -148,21 +162,27 @@ describe('AccountController', () => { it('returns 409 when a concurrent request wins the unique-index race', async () => { vi.mocked(prisma.dataExportRequest.findFirst) .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ id: 'exp-winner', userId: 'user-1', status: 'pending' } as any) - vi.mocked(prisma.dataExportRequest.create).mockRejectedValue({ code: 'P2002' }) + .mockResolvedValueOnce({ + id: 'exp-winner', + userId: 'user-1', + status: 'pending', + } as any) + vi.mocked(prisma.dataExportRequest.create).mockRejectedValue({ + code: 'P2002', + }) await controller.requestExport(req as Request, res as Response) await flushPromises() expect(res.status).toHaveBeenCalledWith(409) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ existingRequestId: 'exp-winner' }) + expect.objectContaining({ existingRequestId: 'exp-winner' }), ) }) }) describe('getExportStatus', () => { - it('scopes the lookup to the requesting user and 404s on other users\' requests', async () => { + it("scopes the lookup to the requesting user and 404s on other users' requests", async () => { req.params = { id: '123e4567-e89b-42d3-a456-426614174000' } vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue(null) @@ -210,7 +230,10 @@ describe('AccountController', () => { it('sends a ready artifact as a JSON attachment and marks it downloaded', async () => { req.params = { id: exportId } - const artifact = JSON.stringify({ exportVersion: 1, data: { profile: { id: 'user-1' } } }) + const artifact = JSON.stringify({ + exportVersion: 1, + data: { profile: { id: 'user-1' } }, + }) vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue({ id: exportId, userId: 'user-1', @@ -219,25 +242,36 @@ describe('AccountController', () => { expiresAt: new Date(Date.now() + DAY_MS), downloadedAt: null, } as any) - vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ + count: 1, + } as any) await controller.downloadExport(req as Request, res as Response) - expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json') + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'application/json', + ) expect(res.setHeader).toHaveBeenCalledWith( 'Content-Disposition', - `attachment; filename="learnault-export-${exportId}.json"` + `attachment; filename="learnault-export-${exportId}.json"`, ) expect(res.send).toHaveBeenCalledWith(artifact) expect(prisma.dataExportRequest.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: exportId, userId: 'user-1', downloadedAt: null } }) + expect.objectContaining({ + where: { id: exportId, userId: 'user-1', downloadedAt: null }, + }), ) }) it('returns 409 while the export is still processing', async () => { req.params = { id: exportId } vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue({ - id: exportId, userId: 'user-1', status: 'processing', artifact: null, expiresAt: null, + id: exportId, + userId: 'user-1', + status: 'processing', + artifact: null, + expiresAt: null, } as any) await controller.downloadExport(req as Request, res as Response) @@ -248,7 +282,11 @@ describe('AccountController', () => { it('returns 410 for an expired export', async () => { req.params = { id: exportId } vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue({ - id: exportId, userId: 'user-1', status: 'expired', artifact: null, expiresAt: new Date(0), + id: exportId, + userId: 'user-1', + status: 'expired', + artifact: null, + expiresAt: new Date(0), } as any) await controller.downloadExport(req as Request, res as Response) @@ -296,12 +334,12 @@ describe('AccountController', () => { expect(res.status).toHaveBeenCalledWith(401) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ code: 'STEP_UP_FAILED' }) + expect.objectContaining({ code: 'STEP_UP_FAILED' }), ) expect(prisma.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: 'STEP_UP_FAILED' }), - }) + }), ) expect(prisma.$transaction).not.toHaveBeenCalled() }) @@ -309,7 +347,8 @@ describe('AccountController', () => { it('returns 409 when the account is already deactivated', async () => { req.body = { password: 'correct' } vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...activeUser, status: 'DEACTIVATED', + ...activeUser, + status: 'DEACTIVATED', } as any) vi.mocked(bcrypt.compare as any).mockResolvedValue(true) @@ -329,12 +368,12 @@ describe('AccountController', () => { expect.objectContaining({ where: { id: 'user-1' }, data: expect.objectContaining({ status: 'DEACTIVATED' }), - }) + }), ) expect(prisma.session.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: 'user-1', isRevoked: false }, - }) + }), ) expect(res.status).toHaveBeenCalledWith(200) }) @@ -344,7 +383,8 @@ describe('AccountController', () => { it('reactivates a deactivated account and returns a fresh token', async () => { req.body = { email: 'test@example.com', password: 'correct' } vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...activeUser, status: 'DEACTIVATED', + ...activeUser, + status: 'DEACTIVATED', } as any) vi.mocked(bcrypt.compare as any).mockResolvedValue(true) @@ -353,18 +393,19 @@ describe('AccountController', () => { expect(prisma.user.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'ACTIVE' }), - }) + }), ) expect(res.status).toHaveBeenCalledWith(200) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ token: 'mock_token' }) + expect.objectContaining({ token: 'mock_token' }), ) }) it('returns a neutral 401 on bad credentials', async () => { req.body = { email: 'test@example.com', password: 'wrong' } vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...activeUser, status: 'DEACTIVATED', + ...activeUser, + status: 'DEACTIVATED', } as any) vi.mocked(bcrypt.compare as any).mockResolvedValue(false) @@ -377,7 +418,8 @@ describe('AccountController', () => { it('returns a neutral 401 for tombstoned (deleted) accounts', async () => { req.body = { email: 'test@example.com', password: 'correct' } vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...activeUser, status: 'DELETED', + ...activeUser, + status: 'DELETED', } as any) await controller.reactivate(req as Request, res as Response) @@ -389,7 +431,8 @@ describe('AccountController', () => { it('returns 409 when the account is pending deletion', async () => { req.body = { email: 'test@example.com', password: 'correct' } vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...activeUser, status: 'PENDING_DELETION', + ...activeUser, + status: 'PENDING_DELETION', } as any) vi.mocked(bcrypt.compare as any).mockResolvedValue(true) @@ -397,7 +440,7 @@ describe('AccountController', () => { expect(res.status).toHaveBeenCalledWith(409) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ code: 'ACCOUNT_PENDING_DELETION' }) + expect.objectContaining({ code: 'ACCOUNT_PENDING_DELETION' }), ) }) }) @@ -410,21 +453,30 @@ describe('AccountController', () => { vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue(null) const scheduledFor = new Date(Date.now() + 30 * DAY_MS) vi.mocked(prisma.accountDeletionRequest.create).mockResolvedValue({ - id: 'del-1', userId: 'user-1', status: 'pending', scheduledFor, + id: 'del-1', + userId: 'user-1', + status: 'pending', + scheduledFor, } as any) await controller.requestDeletion(req as Request, res as Response) await flushPromises() // Cooling-off window: scheduledFor persisted ≈ now + 30 days (default) - const createArg = vi.mocked(prisma.accountDeletionRequest.create).mock.calls[0][0] as any - const deltaDays = (createArg.data.scheduledFor.getTime() - Date.now()) / DAY_MS + const createArg = vi.mocked(prisma.accountDeletionRequest.create).mock + .calls[0][0] as any + const deltaDays = + (createArg.data.scheduledFor.getTime() - Date.now()) / DAY_MS expect(deltaDays).toBeGreaterThan(29.9) expect(deltaDays).toBeLessThan(30.1) expect(res.status).toHaveBeenCalledWith(202) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ id: 'del-1', status: 'pending', scheduledFor }) + expect.objectContaining({ + id: 'del-1', + status: 'pending', + scheduledFor, + }), ) expect(emailService.queueEmail).toHaveBeenCalled() }) @@ -435,14 +487,20 @@ describe('AccountController', () => { vi.mocked(bcrypt.compare as any).mockResolvedValue(true) const scheduledFor = new Date(Date.now() + 10 * DAY_MS) vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue({ - id: 'del-existing', userId: 'user-1', status: 'pending', scheduledFor, + id: 'del-existing', + userId: 'user-1', + status: 'pending', + scheduledFor, } as any) await controller.requestDeletion(req as Request, res as Response) expect(res.status).toHaveBeenCalledWith(409) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ existingRequestId: 'del-existing', scheduledFor }) + expect.objectContaining({ + existingRequestId: 'del-existing', + scheduledFor, + }), ) expect(prisma.accountDeletionRequest.create).not.toHaveBeenCalled() }) @@ -475,12 +533,18 @@ describe('AccountController', () => { it('cancels a pending deletion and restores the account', async () => { req.body = { email: 'test@example.com', password: 'correct' } vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...activeUser, status: 'PENDING_DELETION', + ...activeUser, + status: 'PENDING_DELETION', } as any) vi.mocked(bcrypt.compare as any).mockResolvedValue(true) - vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ + count: 1, + } as any) vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue({ - id: 'del-1', userId: 'user-1', status: 'cancelled', cancelledAt: new Date(), + id: 'del-1', + userId: 'user-1', + status: 'cancelled', + cancelledAt: new Date(), } as any) await controller.cancelDeletion(req as Request, res as Response) @@ -489,12 +553,12 @@ describe('AccountController', () => { expect(prisma.accountDeletionRequest.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: 'user-1', status: 'pending' }, - }) + }), ) expect(prisma.user.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'ACTIVE' }), - }) + }), ) expect(res.status).toHaveBeenCalledWith(200) expect(emailService.queueEmail).toHaveBeenCalled() @@ -503,12 +567,17 @@ describe('AccountController', () => { it('returns 410 when finalization already won the race', async () => { req.body = { email: 'test@example.com', password: 'correct' } vi.mocked(prisma.user.findUnique).mockResolvedValue({ - ...activeUser, status: 'PENDING_DELETION', + ...activeUser, + status: 'PENDING_DELETION', } as any) vi.mocked(bcrypt.compare as any).mockResolvedValue(true) - vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 0 } as any) + vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ + count: 0, + } as any) vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue({ - id: 'del-1', userId: 'user-1', status: 'completed', + id: 'del-1', + userId: 'user-1', + status: 'completed', } as any) await controller.cancelDeletion(req as Request, res as Response) @@ -521,7 +590,9 @@ describe('AccountController', () => { req.body = { email: 'test@example.com', password: 'correct' } vi.mocked(prisma.user.findUnique).mockResolvedValue(activeUser as any) vi.mocked(bcrypt.compare as any).mockResolvedValue(true) - vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ count: 0 } as any) + vi.mocked(prisma.accountDeletionRequest.updateMany).mockResolvedValue({ + count: 0, + } as any) vi.mocked(prisma.accountDeletionRequest.findFirst).mockResolvedValue(null) await controller.cancelDeletion(req as Request, res as Response) diff --git a/tests/asset-validation.service.test.ts b/tests/asset-validation.service.test.ts index addfb8c3..ae5d7781 100644 --- a/tests/asset-validation.service.test.ts +++ b/tests/asset-validation.service.test.ts @@ -7,17 +7,19 @@ import { validateAvatarBytes } from '../src/services/asset-validation.service' /** 1×1 red PNG */ function makePng(width = 1, height = 1): Buffer { // Minimal PNG: signature + IHDR + IDAT + IEND - const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const signature = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]) // IHDR chunk const ihdrData = Buffer.alloc(13) - ihdrData.writeUInt32BE(width, 0) // width - ihdrData.writeUInt32BE(height, 4) // height - ihdrData.writeUInt8(8, 8) // bit depth - ihdrData.writeUInt8(2, 9) // color type (RGB) - ihdrData.writeUInt8(0, 10) // compression - ihdrData.writeUInt8(0, 11) // filter - ihdrData.writeUInt8(0, 12) // interlace + ihdrData.writeUInt32BE(width, 0) // width + ihdrData.writeUInt32BE(height, 4) // height + ihdrData.writeUInt8(8, 8) // bit depth + ihdrData.writeUInt8(2, 9) // color type (RGB) + ihdrData.writeUInt8(0, 10) // compression + ihdrData.writeUInt8(0, 11) // filter + ihdrData.writeUInt8(0, 12) // interlace const ihdrCrc = crc32(Buffer.concat([Buffer.from('IHDR'), ihdrData])) const ihdr = Buffer.alloc(25) ihdr.writeUInt32BE(13, 0) // length @@ -26,7 +28,9 @@ function makePng(width = 1, height = 1): Buffer { ihdr.writeUInt32BE(ihdrCrc, 21) // IDAT chunk (empty compressed data — just valid enough for dimension parsing) - const idatData = Buffer.from([0x08, 0xd7, 0x01, 0x04, 0x00, 0xfb, 0xff, 0xfd, 0x02, 0x40, 0x02]) + const idatData = Buffer.from([ + 0x08, 0xd7, 0x01, 0x04, 0x00, 0xfb, 0xff, 0xfd, 0x02, 0x40, 0x02, + ]) const idatCrc = crc32(Buffer.concat([Buffer.from('IDAT'), idatData])) const idat = Buffer.alloc(4 + 4 + idatData.length + 4) idat.writeUInt32BE(idatData.length, 0) @@ -54,7 +58,7 @@ function makeJpeg(width = 100, height = 50): Buffer { buf.writeUInt8(0xff, 2) buf.writeUInt8(0xc0, 3) // SOF0 buf.writeUInt16BE(17, 4) // segment length - buf.writeUInt8(8, 6) // precision + buf.writeUInt8(8, 6) // precision buf.writeUInt16BE(height, 7) buf.writeUInt16BE(width, 9) @@ -75,7 +79,7 @@ function makeGif(width = 20, height = 10): Buffer { function makeWebp(width = 80, height = 60): Buffer { const buf = Buffer.alloc(50) buf.write('RIFF', 0, 'ascii') - buf.writeUInt32LE(38, 4) // file size + buf.writeUInt32LE(38, 4) // file size buf.write('WEBP', 8, 'ascii') buf.write('VP8 ', 12, 'ascii') buf.writeUInt32LE(30, 16) // chunk size diff --git a/tests/audit.service.test.ts b/tests/audit.service.test.ts index 54a8271b..9b71677e 100644 --- a/tests/audit.service.test.ts +++ b/tests/audit.service.test.ts @@ -50,7 +50,7 @@ describe('AuditService', () => { vi.mocked(prisma.auditLog.create).mockRejectedValue(new Error('db down')) await expect( - service.record({ userId: 'user-1', action: 'EXPORT_REQUESTED' }) + service.record({ userId: 'user-1', action: 'EXPORT_REQUESTED' }), ).resolves.toBeUndefined() expect(logger.error).toHaveBeenCalled() diff --git a/tests/audit/archive.test.ts b/tests/audit/archive.test.ts index fccf868b..8a096b4b 100644 --- a/tests/audit/archive.test.ts +++ b/tests/audit/archive.test.ts @@ -18,7 +18,7 @@ import { async function forwardedArgs( model: string | undefined, operation: string, - args: unknown + args: unknown, ): Promise> { const query = vi.fn().mockResolvedValue(null) await excludeArchivedFromReads({ model, operation, args, query }) @@ -52,9 +52,11 @@ describe('archive semantics', () => { describe('default exclusion', () => { it('hides archived rows from findMany on an archivable model', async () => { - expect(await forwardedArgs('Module', 'findMany', { where: { category: 'stellar' } })).toEqual( - { where: { category: 'stellar', archivedAt: null } } - ) + expect( + await forwardedArgs('Module', 'findMany', { + where: { category: 'stellar' }, + }), + ).toEqual({ where: { category: 'stellar', archivedAt: null } }) }) it('adds the filter when there is no where clause at all', async () => { @@ -69,14 +71,18 @@ describe('archive semantics', () => { }) }) - it.each(['findFirst', 'findFirstOrThrow', 'findMany', 'count', 'aggregate', 'groupBy'])( - 'filters %s', - async (operation) => { - const args = await forwardedArgs('Module', operation, {}) - - expect(args.where).toEqual({ archivedAt: null }) - } - ) + it.each([ + 'findFirst', + 'findFirstOrThrow', + 'findMany', + 'count', + 'aggregate', + 'groupBy', + ])('filters %s', async (operation) => { + const args = await forwardedArgs('Module', operation, {}) + + expect(args.where).toEqual({ archivedAt: null }) + }) it('preserves other arguments while injecting the filter', async () => { const args = await forwardedArgs('Module', 'findMany', { @@ -97,7 +103,11 @@ describe('archive semantics', () => { describe('exemptions', () => { it('leaves non-archivable models alone', async () => { // Injecting archivedAt here would reference a column that does not exist. - expect(await forwardedArgs('User', 'findMany', { where: { status: 'ACTIVE' } })).toEqual({ + expect( + await forwardedArgs('User', 'findMany', { + where: { status: 'ACTIVE' }, + }), + ).toEqual({ where: { status: 'ACTIVE' }, }) }) @@ -105,28 +115,42 @@ describe('archive semantics', () => { it('leaves findUnique alone, so a point lookup by id still resolves', async () => { // A silent filter here would turn a found row into null and read as // "deleted" to code that has the id in hand. - expect(await forwardedArgs('Module', 'findUnique', { where: { id: 'm-1' } })).toEqual({ + expect( + await forwardedArgs('Module', 'findUnique', { where: { id: 'm-1' } }), + ).toEqual({ where: { id: 'm-1' }, }) }) it('leaves writes alone, so archive and restore can see their own row', async () => { - for (const operation of ['update', 'updateMany', 'delete', 'deleteMany', 'upsert']) { - expect(await forwardedArgs('Module', operation, { where: { id: 'm-1' } })).toEqual({ + for (const operation of [ + 'update', + 'updateMany', + 'delete', + 'deleteMany', + 'upsert', + ]) { + expect( + await forwardedArgs('Module', operation, { where: { id: 'm-1' } }), + ).toEqual({ where: { id: 'm-1' }, }) } }) it('leaves raw and model-less operations alone', async () => { - expect(await forwardedArgs(undefined, 'findMany', { where: { id: 'x' } })).toEqual({ + expect( + await forwardedArgs(undefined, 'findMany', { where: { id: 'x' } }), + ).toEqual({ where: { id: 'x' }, }) }) it('stands down when the caller filters on archivedAt explicitly', async () => { expect( - await forwardedArgs('Module', 'findMany', { where: { archivedAt: { not: null } } }) + await forwardedArgs('Module', 'findMany', { + where: { archivedAt: { not: null } }, + }), ).toEqual({ where: { archivedAt: { not: null } } }) }) @@ -141,9 +165,13 @@ describe('archive semantics', () => { }) it('stands down when archivedAt appears inside a combinator', async () => { - const where = { OR: [{ archivedAt: null }, { archivedAt: { gt: new Date(0) } }] } + const where = { + OR: [{ archivedAt: null }, { archivedAt: { gt: new Date(0) } }], + } - expect(await forwardedArgs('Module', 'findMany', { where })).toEqual({ where }) + expect(await forwardedArgs('Module', 'findMany', { where })).toEqual({ + where, + }) }) }) @@ -156,13 +184,22 @@ describe('archive semantics', () => { expect(mentionsArchivedAt({ archivedAt: undefined })).toBe(true) }) - it.each(['AND', 'OR', 'NOT'])('detects the key nested under %s', (combinator) => { - expect(mentionsArchivedAt({ [combinator]: [{ archivedAt: null }] })).toBe(true) - expect(mentionsArchivedAt({ [combinator]: { archivedAt: null } })).toBe(true) - }) + it.each(['AND', 'OR', 'NOT'])( + 'detects the key nested under %s', + (combinator) => { + expect( + mentionsArchivedAt({ [combinator]: [{ archivedAt: null }] }), + ).toBe(true) + expect(mentionsArchivedAt({ [combinator]: { archivedAt: null } })).toBe( + true, + ) + }, + ) it('returns false for an unrelated clause', () => { - expect(mentionsArchivedAt({ status: 'ACTIVE', AND: [{ title: 'x' }] })).toBe(false) + expect( + mentionsArchivedAt({ status: 'ACTIVE', AND: [{ title: 'x' }] }), + ).toBe(false) }) it('returns false for empty and non-object input', () => { @@ -250,7 +287,10 @@ describe('archive semantics', () => { describe('archivedPurgeCutoff', () => { it('subtracts the retention window', () => { expect( - archivedPurgeCutoff(365, new Date('2026-08-24T00:00:00.000Z'))?.toISOString() + archivedPurgeCutoff( + 365, + new Date('2026-08-24T00:00:00.000Z'), + )?.toISOString(), ).toBe('2025-08-24T00:00:00.000Z') }) diff --git a/tests/audit/audit-event.service.test.ts b/tests/audit/audit-event.service.test.ts index 8b77397a..9446e688 100644 --- a/tests/audit/audit-event.service.test.ts +++ b/tests/audit/audit-event.service.test.ts @@ -87,7 +87,7 @@ describe('AuditEventService', () => { action: 'transaction.created', actor: { type: ActorType.WORKER, id: 'reward' }, target: { type: 'Transaction', id: 't-1' }, - }).recordClass + }).recordClass, ).toBe(RecordClass.IMMUTABLE) expect( @@ -95,7 +95,7 @@ describe('AuditEventService', () => { action: 'module.archived', actor: { type: ActorType.ADMIN, id: 'a-1' }, target: { type: 'Module', id: 'm-1' }, - }).recordClass + }).recordClass, ).toBe(RecordClass.ARCHIVABLE) }) @@ -106,7 +106,7 @@ describe('AuditEventService', () => { actor: { type: ActorType.SYSTEM }, target: { type: 'Unknown' }, recordClass: RecordClass.DELETABLE, - }).recordClass + }).recordClass, ).toBe(RecordClass.DELETABLE) }) @@ -155,7 +155,8 @@ describe('AuditEventService', () => { action: 'login.succeeded', actor: { type: ActorType.USER, id: 'u-1' }, target: { type: 'Session', id: 's-1' }, - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.6099.109', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.6099.109', }) expect(row.userAgentFamily).toBe('Chrome') @@ -170,7 +171,8 @@ describe('AuditEventService', () => { target: { type: 'Wallet', id: 'w-1' }, metadata: { walletId: 'w-1', - secretSeed: 'SBQWY3DNPFWGSZTFNV4WQZLBOJ7SFQNDBQFXHTOYIY5QYVCSFRCFUKPP', + secretSeed: + 'SBQWY3DNPFWGSZTFNV4WQZLBOJ7SFQNDBQFXHTOYIY5QYVCSFRCFUKPP', email: 'learner@example.com', }, }) @@ -189,7 +191,11 @@ describe('AuditEventService', () => { metadata: { from: 'RESERVED', to: 'ACTIVE', attempt: 2 }, }) - expect(JSON.parse(row.metadata!)).toEqual({ from: 'RESERVED', to: 'ACTIVE', attempt: 2 }) + expect(JSON.parse(row.metadata!)).toEqual({ + from: 'RESERVED', + to: 'ACTIVE', + attempt: 2, + }) }) }) @@ -213,21 +219,25 @@ describe('AuditEventService', () => { }) it('swallows a write failure — standalone auditing must not break the caller', async () => { - vi.mocked(prisma.auditEvent.create).mockRejectedValue(new Error('db down')) + vi.mocked(prisma.auditEvent.create).mockRejectedValue( + new Error('db down'), + ) await expect( service.record({ action: 'login.failed', actor: { type: ActorType.ANONYMOUS }, target: { type: 'User' }, - }) + }), ).resolves.toBeUndefined() expect(logger.error).toHaveBeenCalled() }) it('does not log the metadata when a write fails', async () => { - vi.mocked(prisma.auditEvent.create).mockRejectedValue(new Error('db down')) + vi.mocked(prisma.auditEvent.create).mockRejectedValue( + new Error('db down'), + ) await service.record({ action: 'login.failed', @@ -261,14 +271,18 @@ describe('AuditEventService', () => { }) it('propagates a failure so the surrounding transaction rolls back', async () => { - const tx = { auditEvent: { create: vi.fn().mockRejectedValue(new Error('constraint')) } } + const tx = { + auditEvent: { + create: vi.fn().mockRejectedValue(new Error('constraint')), + }, + } await expect( service.recordWithin(tx, { action: 'user.anonymized', actor: { type: ActorType.SYSTEM, id: 'sweep' }, target: { type: 'User', id: 'u-1' }, - }) + }), ).rejects.toThrow('constraint') }) }) @@ -303,25 +317,35 @@ describe('AuditEventService', () => { const executeRawUnsafe = vi.fn().mockResolvedValue(0) const executeRaw = vi.fn().mockResolvedValue(12) - vi.mocked(prisma.$transaction).mockImplementation( - (async (callback: (tx: unknown) => Promise) => - callback({ $executeRawUnsafe: executeRawUnsafe, $executeRaw: executeRaw })) as never - ) + vi.mocked(prisma.$transaction).mockImplementation((async ( + callback: (tx: unknown) => Promise, + ) => + callback({ + $executeRawUnsafe: executeRawUnsafe, + $executeRaw: executeRaw, + })) as never) - const deleted = await service.purgeExpired(new Date('2026-08-24T00:00:00.000Z')) + const deleted = await service.purgeExpired( + new Date('2026-08-24T00:00:00.000Z'), + ) expect(deleted).toBe(12) - expect(executeRawUnsafe).toHaveBeenCalledWith(`SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'`) + expect(executeRawUnsafe).toHaveBeenCalledWith( + `SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'`, + ) expect(executeRaw).toHaveBeenCalledOnce() }) it('deletes by timestamp only, so no single event can be targeted', async () => { const executeRaw = vi.fn().mockResolvedValue(0) - vi.mocked(prisma.$transaction).mockImplementation( - (async (callback: (tx: unknown) => Promise) => - callback({ $executeRawUnsafe: vi.fn(), $executeRaw: executeRaw })) as never - ) + vi.mocked(prisma.$transaction).mockImplementation((async ( + callback: (tx: unknown) => Promise, + ) => + callback({ + $executeRawUnsafe: vi.fn(), + $executeRaw: executeRaw, + })) as never) await service.purgeExpired(new Date('2026-08-24T00:00:00.000Z')) @@ -352,7 +376,11 @@ describe('AuditEventService', () => { await service.list({ actorId: 'admin-1', targetType: 'User', from, to }) expect(prisma.auditEvent.findMany).toHaveBeenCalledWith({ - where: { actorId: 'admin-1', targetType: 'User', occurredAt: { gte: from, lte: to } }, + where: { + actorId: 'admin-1', + targetType: 'User', + occurredAt: { gte: from, lte: to }, + }, orderBy: { occurredAt: 'desc' }, take: 50, skip: 0, @@ -365,7 +393,7 @@ describe('AuditEventService', () => { await service.list() expect(prisma.auditEvent.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: {} }) + expect.objectContaining({ where: {} }), ) }) @@ -375,7 +403,7 @@ describe('AuditEventService', () => { await service.list({ take: 100_000 }) expect(prisma.auditEvent.findMany).toHaveBeenCalledWith( - expect.objectContaining({ take: 200 }) + expect.objectContaining({ take: 200 }), ) }) @@ -385,7 +413,7 @@ describe('AuditEventService', () => { await service.list({ take: 0 }) expect(prisma.auditEvent.findMany).toHaveBeenCalledWith( - expect.objectContaining({ take: 1 }) + expect.objectContaining({ take: 1 }), ) }) }) diff --git a/tests/audit/audited-mutation.test.ts b/tests/audit/audited-mutation.test.ts index b607540e..199f830d 100644 --- a/tests/audit/audited-mutation.test.ts +++ b/tests/audit/audited-mutation.test.ts @@ -36,16 +36,19 @@ function fakeTransaction() { const tx = { auditEvent: { create: auditCreate } } - vi.mocked(prisma.$transaction).mockImplementation( - (async (callback: (client: unknown) => Promise) => callback(tx)) as never - ) + vi.mocked(prisma.$transaction).mockImplementation((async ( + callback: (client: unknown) => Promise, + ) => callback(tx)) as never) return { calls, auditCreate, tx } } /** The audit row a fake transaction received. */ -function auditRow(auditCreate: ReturnType): Record { - return (auditCreate.mock.calls[0][0] as { data: Record }).data +function auditRow( + auditCreate: ReturnType, +): Record { + return (auditCreate.mock.calls[0][0] as { data: Record }) + .data } describe('auditedMutation', () => { @@ -94,11 +97,12 @@ describe('auditedMutation', () => { }) it('propagates a failed audit write, so an unaudited change cannot land', async () => { - const auditCreate = vi.fn().mockRejectedValue(new Error('audit constraint')) - vi.mocked(prisma.$transaction).mockImplementation( - (async (callback: (client: unknown) => Promise) => - callback({ auditEvent: { create: auditCreate } })) as never - ) + const auditCreate = vi + .fn() + .mockRejectedValue(new Error('audit constraint')) + vi.mocked(prisma.$transaction).mockImplementation((async ( + callback: (client: unknown) => Promise, + ) => callback({ auditEvent: { create: auditCreate } })) as never) await expect( auditedMutation({ @@ -106,7 +110,7 @@ describe('auditedMutation', () => { actor: { type: ActorType.WORKER, id: 'provisioning' }, target: { type: 'Wallet', id: 'w-1' }, mutate: async () => ({ id: 'w-1' }), - }) + }), ).rejects.toThrow('audit constraint') }) @@ -121,7 +125,7 @@ describe('auditedMutation', () => { mutate: async () => { throw new Error('lease lost') }, - }) + }), ).rejects.toThrow('lease lost') // Auditing a change that never happened is as wrong as missing one. @@ -231,7 +235,10 @@ describe('auditedMutation', () => { const metadata = auditRow(auditCreate).metadata as string expect(metadata).not.toContain('hunter2') - expect(JSON.parse(metadata)).toMatchObject({ newPassword: REDACTED, method: 'reset-link' }) + expect(JSON.parse(metadata)).toMatchObject({ + newPassword: REDACTED, + method: 'reset-link', + }) }) it('hashes the IP and coarsens the User-Agent it is given', async () => { @@ -278,7 +285,7 @@ describe('auditedMutation', () => { actor: { type: ActorType.ADMIN, id: 'admin-1', role: 'ADMIN' }, target: { type: 'User', id: 'learner-2' }, mutate, - }) + }), ).rejects.toThrow(AuditPolicyError) // Rejected before anything ran, so there is nothing to roll back. @@ -297,7 +304,7 @@ describe('auditedMutation', () => { target: { type: 'User', id: 'learner-2' }, reason: ' ', mutate: async () => null, - }) + }), ).rejects.toThrow(AuditPolicyError) }) @@ -310,7 +317,7 @@ describe('auditedMutation', () => { actor: systemActor('lifecycle-sweep'), target: { type: 'DataExportRequest', id: 'e-1' }, mutate: async () => null, - }) + }), ).resolves.toBeNull() await expect( @@ -319,7 +326,7 @@ describe('auditedMutation', () => { actor: workerActor('wallet-provisioning'), target: { type: 'Wallet', id: 'w-1' }, mutate: async () => null, - }) + }), ).resolves.toBeNull() }) @@ -332,7 +339,7 @@ describe('auditedMutation', () => { actor: { type: ActorType.USER }, target: { type: 'User', id: 'u-1' }, mutate: async () => null, - }) + }), ).rejects.toThrow(/unattributable/) }) @@ -345,7 +352,7 @@ describe('auditedMutation', () => { actor: systemActor('sweep'), target: { type: 'User', id: 'u-1' }, mutate: async () => null, - }) + }), ).rejects.toThrow(AuditPolicyError) await expect( @@ -354,7 +361,7 @@ describe('auditedMutation', () => { actor: systemActor('sweep'), target: { type: '' }, mutate: async () => null, - }) + }), ).rejects.toThrow(AuditPolicyError) }) }) @@ -420,7 +427,7 @@ describe('auditedArchive', () => { reason: 'mistake', actor: { type: ActorType.ADMIN, id: 'admin-1', role: 'ADMIN' }, archive, - }) + }), ).rejects.toThrow(/IMMUTABLE, not ARCHIVABLE/) expect(archive).not.toHaveBeenCalled() @@ -434,7 +441,7 @@ describe('auditedArchive', () => { reason: 'because', actor: systemActor('sweep'), archive: async () => null, - }) + }), ).rejects.toThrow(/no rule in the lifecycle matrix/) }) @@ -448,7 +455,7 @@ describe('auditedArchive', () => { reason: ' ', actor: systemActor('sweep'), archive: async () => null, - }) + }), ).rejects.toThrow(/requires a reason/) }) }) @@ -490,7 +497,7 @@ describe('auditedRestore', () => { reason: 'x', actor: systemActor('sweep'), restore: async () => null, - }) + }), ).rejects.toThrow(/MUTABLE, not ARCHIVABLE/) }) }) @@ -503,7 +510,7 @@ describe('actorFromRequest', () => { requestId: 'req-1', ip: '203.0.113.5', headers: { 'user-agent': 'curl/8.4.0' }, - }) + }), ).toEqual({ actor: { type: ActorType.USER, id: 'u-1', role: 'LEARNER' }, requestId: 'req-1', @@ -514,7 +521,9 @@ describe('actorFromRequest', () => { it('builds an ADMIN actor from a staff request', () => { // It is the actor's authority that decides the scrutiny, not the endpoint. - expect(actorFromRequest({ actor: { id: 'a-1', role: 'ADMIN' } }).actor).toEqual({ + expect( + actorFromRequest({ actor: { id: 'a-1', role: 'ADMIN' } }).actor, + ).toEqual({ type: ActorType.ADMIN, id: 'a-1', role: 'ADMIN', @@ -539,7 +548,7 @@ describe('actorFromRequest', () => { it('ignores a non-string User-Agent header', () => { expect( - actorFromRequest({ headers: { 'user-agent': ['a', 'b'] } }).userAgent + actorFromRequest({ headers: { 'user-agent': ['a', 'b'] } }).userAgent, ).toBeNull() }) }) diff --git a/tests/audit/classification.test.ts b/tests/audit/classification.test.ts index 356dc424..173b64a3 100644 --- a/tests/audit/classification.test.ts +++ b/tests/audit/classification.test.ts @@ -14,7 +14,10 @@ import { DataCategory, ErasureAction, RecordClass } from '../../src/audit/types' /** Model names declared in prisma/schema.prisma. */ function schemaModels(): string[] { - const schema = readFileSync(join(process.cwd(), 'prisma', 'schema.prisma'), 'utf8') + const schema = readFileSync( + join(process.cwd(), 'prisma', 'schema.prisma'), + 'utf8', + ) const matches = schema.matchAll(/^model\s+(\w+)\s*\{/gm) return [...matches].map((match) => match[1]) @@ -23,7 +26,9 @@ function schemaModels(): string[] { describe('lifecycle classification', () => { describe('coverage', () => { it('classifies every model in the Prisma schema', () => { - const unclassified = schemaModels().filter((model) => !lifecycleRuleFor(model)) + const unclassified = schemaModels().filter( + (model) => !lifecycleRuleFor(model), + ) // A model with no rule has no retention, no erasure behaviour and no // audit requirement. Add it to src/audit/classification.ts and document @@ -72,7 +77,8 @@ describe('lifecycle classification', () => { const wrong = lifecycleRules() .filter( (rule) => - rule.recordClass === RecordClass.ARCHIVABLE && rule.retentionAnchor !== 'archivedAt' + rule.recordClass === RecordClass.ARCHIVABLE && + rule.retentionAnchor !== 'archivedAt', ) .map((rule) => rule.model) @@ -90,14 +96,18 @@ describe('lifecycle classification', () => { describe('policy invariants', () => { it('retains money records for the statutory window and never deletes them on erasure', () => { - const money = lifecycleRules().filter((rule) => rule.category === DataCategory.MONEY) + const money = lifecycleRules().filter( + (rule) => rule.category === DataCategory.MONEY, + ) expect(money.length).toBeGreaterThan(0) for (const rule of money) { // A ledger a subject can erase is not a ledger. Money rows either // survive erasure outright or disappear with their parent. - expect([ErasureAction.RETAIN, ErasureAction.CASCADE]).toContain(rule.onErasure) + expect([ErasureAction.RETAIN, ErasureAction.CASCADE]).toContain( + rule.onErasure, + ) if (rule.retentionDays !== null) { expect(rule.retentionDays).toBeGreaterThanOrEqual(Retention.ONE_YEAR) @@ -107,7 +117,7 @@ describe('lifecycle classification', () => { it('keeps credentials verifiable indefinitely', () => { const credentials = lifecycleRules().filter( - (rule) => rule.category === DataCategory.CREDENTIAL + (rule) => rule.category === DataCategory.CREDENTIAL, ) expect(credentials.length).toBeGreaterThan(0) @@ -119,7 +129,9 @@ describe('lifecycle classification', () => { }) it('retains consent proof beyond the account it describes', () => { - const consent = lifecycleRules().filter((rule) => rule.category === DataCategory.CONSENT) + const consent = lifecycleRules().filter( + (rule) => rule.category === DataCategory.CONSENT, + ) expect(consent.length).toBeGreaterThan(0) @@ -132,7 +144,7 @@ describe('lifecycle classification', () => { it('bounds how long security events are kept', () => { const security = lifecycleRules().filter( - (rule) => rule.category === DataCategory.SECURITY + (rule) => rule.category === DataCategory.SECURITY, ) expect(security.length).toBeGreaterThan(0) @@ -155,19 +167,29 @@ describe('lifecycle classification', () => { const shortest = Math.min( ...lifecycleRules() .map((rule) => rule.retentionDays) - .filter((days): days is number => days !== null) + .filter((days): days is number => days !== null), ) - expect(lifecycleRuleFor('DataExportRequest')!.retentionDays).toBe(shortest) + expect(lifecycleRuleFor('DataExportRequest')!.retentionDays).toBe( + shortest, + ) }) it('audits every money, credential and consent mutation', () => { - const sensitive = [DataCategory.MONEY, DataCategory.CREDENTIAL, DataCategory.CONSENT] + const sensitive = [ + DataCategory.MONEY, + DataCategory.CREDENTIAL, + DataCategory.CONSENT, + ] // Append-only history tables are exempt: a row in one of them *is* the // audit record, and auditing it would only produce a second row saying // the first was written. - const selfAuditing = new Set(['AuditEvent', 'AuditLog', 'PreferenceAuditLog']) + const selfAuditing = new Set([ + 'AuditEvent', + 'AuditLog', + 'PreferenceAuditLog', + ]) const unaudited = lifecycleRules() .filter((rule) => sensitive.includes(rule.category) && !rule.audited) @@ -206,26 +228,39 @@ describe('lifecycle classification', () => { }) it('declares archivedAt on every archivable model in the schema', () => { - const schema = readFileSync(join(process.cwd(), 'prisma', 'schema.prisma'), 'utf8') + const schema = readFileSync( + join(process.cwd(), 'prisma', 'schema.prisma'), + 'utf8', + ) for (const model of modelsInClass(RecordClass.ARCHIVABLE)) { - const block = schema.match(new RegExp(`model\\s+${model}\\s*\\{([\\s\\S]*?)\\n\\}`)) + const block = schema.match( + new RegExp(`model\\s+${model}\\s*\\{([\\s\\S]*?)\\n\\}`), + ) expect(block, `model ${model} not found in schema`).not.toBeNull() - expect(block![1], `${model} is ARCHIVABLE but has no archivedAt`).toContain('archivedAt') + expect( + block![1], + `${model} is ARCHIVABLE but has no archivedAt`, + ).toContain('archivedAt') expect(block![1]).toContain('archivedById') expect(block![1]).toContain('archivedReason') } }) it('does not declare archive columns on models outside the archivable class', () => { - const schema = readFileSync(join(process.cwd(), 'prisma', 'schema.prisma'), 'utf8') + const schema = readFileSync( + join(process.cwd(), 'prisma', 'schema.prisma'), + 'utf8', + ) const archivable = new Set(modelsInClass(RecordClass.ARCHIVABLE)) const unexpected = lifecycleRules() .filter((rule) => !archivable.has(rule.model)) .filter((rule) => { - const block = schema.match(new RegExp(`model\\s+${rule.model}\\s*\\{([\\s\\S]*?)\\n\\}`)) + const block = schema.match( + new RegExp(`model\\s+${rule.model}\\s*\\{([\\s\\S]*?)\\n\\}`), + ) return block ? /^\s*archivedAt\s/m.test(block[1]) : false }) @@ -255,7 +290,7 @@ describe('lifecycle classification', () => { it('subtracts the retention window from now', () => { expect(retentionCutoff('EmailDelivery', now)?.toISOString()).toBe( - '2026-07-25T00:00:00.000Z' + '2026-07-25T00:00:00.000Z', ) }) @@ -270,7 +305,8 @@ describe('lifecycle classification', () => { it('puts the audit-event cutoff seven years back', () => { const cutoff = retentionCutoff('AuditEvent', now)! - const years = (now.getTime() - cutoff.getTime()) / (365.25 * 24 * 60 * 60_000) + const years = + (now.getTime() - cutoff.getTime()) / (365.25 * 24 * 60 * 60_000) expect(years).toBeCloseTo(7, 1) }) diff --git a/tests/audit/redaction.test.ts b/tests/audit/redaction.test.ts index 41887256..602ff4d3 100644 --- a/tests/audit/redaction.test.ts +++ b/tests/audit/redaction.test.ts @@ -81,16 +81,26 @@ describe('audit redaction', () => { describe('value deny-list', () => { it('denies a Stellar secret seed', () => { - expect(isDeniedValue('SBQWY3DNPFWGSZTFNV4WQZLBOJ7SFQNDBQFXHTOYIY5QYVCSFRCFUKPP')).toBe(true) + expect( + isDeniedValue( + 'SBQWY3DNPFWGSZTFNV4WQZLBOJ7SFQNDBQFXHTOYIY5QYVCSFRCFUKPP', + ), + ).toBe(true) }) it('denies a Stellar public key', () => { - expect(isDeniedValue('GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGQFKKWXR6DOSJBV7STMAQSMTGG')).toBe(true) + expect( + isDeniedValue( + 'GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGQFKKWXR6DOSJBV7STMAQSMTGG', + ), + ).toBe(true) }) it('denies a JWT', () => { expect( - isDeniedValue('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U') + isDeniedValue( + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U', + ), ).toBe(true) }) @@ -152,7 +162,10 @@ describe('audit redaction', () => { }) it('replaces a denied key without inspecting its value', () => { - const { value, redactedPaths } = redactMetadata({ password: 'hunter2', userId: 'u-1' }) + const { value, redactedPaths } = redactMetadata({ + password: 'hunter2', + userId: 'u-1', + }) expect(value).toMatchObject({ password: REDACTED, userId: 'u-1' }) expect(redactedPaths).toEqual(['password']) @@ -174,7 +187,9 @@ describe('audit redaction', () => { recipients: [{ email: 'a@b.com' }, { email: 'c@d.com' }], }) - expect((value!.session as Record).ipAddress).toBe(REDACTED) + expect((value!.session as Record).ipAddress).toBe( + REDACTED, + ) expect(redactedPaths).toContain('session.ipAddress') expect(redactedPaths).toContain('recipients[0].email') expect(redactedPaths).toContain('recipients[1].email') @@ -205,13 +220,15 @@ describe('audit redaction', () => { ids: Array.from({ length: 100 }, (_, index) => `id-${index}`), }) - expect((value!.ids as unknown[]).length).toBe(RedactionLimits.maxArrayLength) + expect((value!.ids as unknown[]).length).toBe( + RedactionLimits.maxArrayLength, + ) expect(truncated).toBe(true) }) it('caps object breadth', () => { const wide = Object.fromEntries( - Array.from({ length: 100 }, (_, index) => [`k${index}`, index]) + Array.from({ length: 100 }, (_, index) => [`k${index}`, index]), ) const { value, truncated } = redactMetadata(wide) @@ -222,7 +239,9 @@ describe('audit redaction', () => { it('truncates an over-long string', () => { const { value, truncated } = redactMetadata({ note: 'x'.repeat(1000) }) - expect(value!.note).toBe(`${'x'.repeat(RedactionLimits.maxStringLength)}${TRUNCATED}`) + expect(value!.note).toBe( + `${'x'.repeat(RedactionLimits.maxStringLength)}${TRUNCATED}`, + ) expect(truncated).toBe(true) }) @@ -245,7 +264,8 @@ describe('audit redaction', () => { it('renders a Date as an ISO timestamp', () => { expect( - redactMetadata({ archivedAt: new Date('2026-08-24T10:00:00.000Z') }).value + redactMetadata({ archivedAt: new Date('2026-08-24T10:00:00.000Z') }) + .value, ).toEqual({ archivedAt: '2026-08-24T10:00:00.000Z' }) }) @@ -268,7 +288,9 @@ describe('audit redaction', () => { }) it('normalizes a non-finite number to null', () => { - expect(redactMetadata({ ratio: Number.NaN, size: Infinity }).value).toEqual({ + expect( + redactMetadata({ ratio: Number.NaN, size: Infinity }).value, + ).toEqual({ ratio: null, size: null, }) @@ -282,7 +304,10 @@ describe('audit redaction', () => { }) it('produces parseable JSON with secrets already replaced', () => { - const serialized = serializeMetadata({ password: 'hunter2', userId: 'u-1' })! + const serialized = serializeMetadata({ + password: 'hunter2', + userId: 'u-1', + })! const parsed = JSON.parse(serialized) expect(parsed).toMatchObject({ password: REDACTED, userId: 'u-1' }) @@ -296,7 +321,7 @@ describe('audit redaction', () => { expect(() => JSON.parse(serialized)).not.toThrow() expect(Buffer.byteLength(serialized)).toBeLessThanOrEqual( - RedactionLimits.maxSerializedBytes + RedactionLimits.maxSerializedBytes, ) }) @@ -324,12 +349,14 @@ describe('audit redaction', () => { }) it('is stable, so events from one source can be correlated', () => { - expect(hashIpAddress('203.0.113.42', secret)).toBe(hashIpAddress('203.0.113.42', secret)) + expect(hashIpAddress('203.0.113.42', secret)).toBe( + hashIpAddress('203.0.113.42', secret), + ) }) it('separates different addresses', () => { expect(hashIpAddress('203.0.113.42', secret)).not.toBe( - hashIpAddress('203.0.113.43', secret) + hashIpAddress('203.0.113.43', secret), ) }) @@ -337,12 +364,14 @@ describe('audit redaction', () => { // The point of HMAC over a bare digest: the IPv4 space is small enough // that an unkeyed hash is reversible by enumeration. expect(hashIpAddress('203.0.113.42', 'secret-a')).not.toBe( - hashIpAddress('203.0.113.42', 'secret-b') + hashIpAddress('203.0.113.42', 'secret-b'), ) }) it('ignores surrounding whitespace', () => { - expect(hashIpAddress(' 203.0.113.42 ', secret)).toBe(hashIpAddress('203.0.113.42', secret)) + expect(hashIpAddress(' 203.0.113.42 ', secret)).toBe( + hashIpAddress('203.0.113.42', secret), + ) }) }) @@ -353,8 +382,14 @@ describe('audit redaction', () => { }) it.each([ - ['Mozilla/5.0 (Windows NT 10.0) Chrome/120.0.0.0 Safari/537.36', 'Chrome'], - ['Mozilla/5.0 (Windows NT 10.0) Chrome/120 Safari/537.36 Edg/120.0', 'Edge'], + [ + 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0.0.0 Safari/537.36', + 'Chrome', + ], + [ + 'Mozilla/5.0 (Windows NT 10.0) Chrome/120 Safari/537.36 Edg/120.0', + 'Edge', + ], ['Mozilla/5.0 (X11; Linux) Firefox/121.0', 'Firefox'], ['Mozilla/5.0 (Macintosh) Version/17.0 Safari/605.1.15', 'Safari'], ['okhttp/4.12.0', 'Android'], @@ -367,7 +402,9 @@ describe('audit redaction', () => { }) it('resolves Edge before Chrome, which it also advertises', () => { - expect(userAgentFamily('Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0')).toBe('Edge') + expect( + userAgentFamily('Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'), + ).toBe('Edge') }) it('falls back to Other for an unrecognized agent', () => { @@ -375,7 +412,9 @@ describe('audit redaction', () => { }) it('discards the version and platform detail it was given', () => { - const family = userAgentFamily('Mozilla/5.0 (Windows NT 10.0; Win64) Chrome/120.0.6099.109')! + const family = userAgentFamily( + 'Mozilla/5.0 (Windows NT 10.0; Win64) Chrome/120.0.6099.109', + )! expect(family).toBe('Chrome') expect(family).not.toContain('120') diff --git a/tests/auth.controller.test.ts b/tests/auth.controller.test.ts index bd494352..78c0610f 100644 --- a/tests/auth.controller.test.ts +++ b/tests/auth.controller.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { Request, Response } from 'express' -import { AuthController, resendCooldowns, resendAccountCounts, otpPhoneCounts, otpDeviceCounts } from '../src/controllers/auth.controller' +import { + AuthController, + resendCooldowns, + resendAccountCounts, + otpPhoneCounts, + otpDeviceCounts, +} from '../src/controllers/auth.controller' import prisma from '../src/config/database' import bcrypt from 'bcryptjs' import { emailService } from '../src/services/email.service' @@ -8,1267 +14,1410 @@ import { otpService } from '../src/services/otp.service' import { refreshTokenService } from '../src/services/refresh-token.service' const mockTokenHash = 'abc123def456hash' -const mockRawToken = 'aaabbbcccddd00112233445566778899aabbccddeeff00112233445566778899' +const mockRawToken = + 'aaabbbcccddd00112233445566778899aabbccddeeff00112233445566778899' const { mockDb } = vi.hoisted(() => { - const mockDb: any = { - user: { - findFirst: vi.fn(), - findUnique: vi.fn(), - create: vi.fn(), - update: vi.fn(), - }, - verificationToken: { - findFirst: vi.fn(), - create: vi.fn(), - update: vi.fn(), - updateMany: vi.fn(), - }, - emailDelivery: { - create: vi.fn(), - }, - accountDeletionRequest: { - findFirst: vi.fn(), - }, - session: { - updateMany: vi.fn(), - }, - auditLog: { - create: vi.fn(), - }, - outboxEvent: { - create: vi.fn(), - }, - } + const mockDb: any = { + user: { + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + verificationToken: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + emailDelivery: { + create: vi.fn(), + }, + accountDeletionRequest: { + findFirst: vi.fn(), + }, + session: { + updateMany: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + outboxEvent: { + create: vi.fn(), + }, + } - mockDb.$transaction = vi.fn((arg: any) => - typeof arg === 'function' ? arg(mockDb) : Promise.all(arg) - ) + mockDb.$transaction = vi.fn((arg: any) => + typeof arg === 'function' ? arg(mockDb) : Promise.all(arg), + ) - return { mockDb } + return { mockDb } }) vi.mock('../src/config/database', () => ({ default: mockDb })) vi.mock('bcryptjs', () => ({ - default: { - genSalt: vi.fn().mockResolvedValue('salt'), - hash: vi.fn().mockResolvedValue('hashed_password'), - compare: vi.fn(), - }, + default: { + genSalt: vi.fn().mockResolvedValue('salt'), + hash: vi.fn().mockResolvedValue('hashed_password'), + compare: vi.fn(), + }, })) vi.mock('jsonwebtoken', () => ({ - default: { - sign: vi.fn().mockReturnValue('mock_token'), - }, + default: { + sign: vi.fn().mockReturnValue('mock_token'), + }, })) vi.mock('crypto', () => ({ - default: { - randomBytes: vi.fn(() => Buffer.from(mockRawToken, 'hex')), - createHash: vi.fn(() => ({ - update: vi.fn().mockReturnThis(), - digest: vi.fn(() => mockTokenHash), - })), - }, + default: { + randomBytes: vi.fn(() => Buffer.from(mockRawToken, 'hex')), + createHash: vi.fn(() => ({ + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => mockTokenHash), + })), + }, })) vi.mock('../src/services/email.service', () => ({ - emailService: { - queueEmail: vi.fn().mockResolvedValue({ id: 'email1' }), - }, + emailService: { + queueEmail: vi.fn().mockResolvedValue({ id: 'email1' }), + }, })) vi.mock('../src/services/otp.service', async () => { - const actual = await vi.importActual('../src/services/otp.service') - - return { - ...actual, - otpService: { - requestChallenge: vi.fn().mockResolvedValue(undefined), - verifyChallenge: vi.fn(), - }, - } + const actual = await vi.importActual< + typeof import('../src/services/otp.service') + >('../src/services/otp.service') + + return { + ...actual, + otpService: { + requestChallenge: vi.fn().mockResolvedValue(undefined), + verifyChallenge: vi.fn(), + }, + } }) vi.mock('../src/services/refresh-token.service', () => ({ - refreshTokenService: { - issueSession: vi.fn(), - rotate: vi.fn(), - revokeByRefreshToken: vi.fn(), - revokeAllByRefreshToken: vi.fn(), - }, + refreshTokenService: { + issueSession: vi.fn(), + rotate: vi.fn(), + revokeByRefreshToken: vi.fn(), + revokeAllByRefreshToken: vi.fn(), + }, })) describe('AuthController', () => { - let authController: AuthController - let mockRequest: Partial - let mockResponse: Partial - - beforeEach(() => { - authController = new AuthController() - mockRequest = { - headers: {}, - socket: { remoteAddress: '127.0.0.1' } as any, - } - mockResponse = { - json: vi.fn(), - status: vi.fn().mockReturnThis(), - } - resendCooldowns.clear() - resendAccountCounts.clear() - otpPhoneCounts.clear() - otpDeviceCounts.clear() - vi.clearAllMocks() - - vi.mocked(refreshTokenService.issueSession).mockResolvedValue({ - sessionId: 'session-1', - accessToken: 'mock_access_token', - refreshToken: 'mock_refresh_token', - expiresIn: 900, - }) + let authController: AuthController + let mockRequest: Partial + let mockResponse: Partial + + beforeEach(() => { + authController = new AuthController() + mockRequest = { + headers: {}, + socket: { remoteAddress: '127.0.0.1' } as any, + } + mockResponse = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } + resendCooldowns.clear() + resendAccountCounts.clear() + otpPhoneCounts.clear() + otpDeviceCounts.clear() + vi.clearAllMocks() + + vi.mocked(refreshTokenService.issueSession).mockResolvedValue({ + sessionId: 'session-1', + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + expiresIn: 900, }) - - describe('register', () => { - it('should register a new user successfully and issue verification token', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'Password123!', - username: 'testuser', - } - - const mockUser = { - id: '1', - email: 'test@example.com', - username: 'testuser', - role: 'LEARNER', - } - - ;(prisma.user.findFirst as any).mockResolvedValue(null) - ;(prisma.user.create as any).mockResolvedValue(mockUser) - ;(prisma.verificationToken.create as any).mockResolvedValue({ - id: 'vt1', - userId: '1', - tokenHash: mockTokenHash, - expiresAt: new Date(Date.now() + 86400000), - }) - - await authController.register(mockRequest as Request, mockResponse as Response) - - expect(prisma.user.create).toHaveBeenCalled() - expect(prisma.verificationToken.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - userId: '1', - tokenHash: mockTokenHash, - }), - }) - ) - expect(emailService.queueEmail).toHaveBeenCalledWith( - '1', - 'test@example.com', - expect.any(String), - expect.any(String) - ) - expect(mockResponse.status).toHaveBeenCalledWith(201) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'User registered successfully', - accessToken: 'mock_access_token', - refreshToken: 'mock_refresh_token', - expiresIn: 900, - tokenType: 'Bearer', - }) - ) - expect(refreshTokenService.issueSession).toHaveBeenCalledWith( - expect.objectContaining({ userId: '1', role: 'LEARNER' }) - ) - }) - - it('should return 400 for invalid input', async () => { - mockRequest.body = { - email: 'invalid-email', - password: 'short', - } - - await authController.register(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'Validation failed', - }) - ) - }) - - it('should return 400 for a password that meets length but not complexity', async () => { - mockRequest.body = { - email: 'test@example.com', - // 8+ chars but no uppercase/symbol — fails isStrongPassword - password: 'alllowercase123', - username: 'testuser', - } - - await authController.register(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'Validation failed' }) - ) - expect(prisma.user.create).not.toHaveBeenCalled() - }) - - it('should return 409 if user already exists', async () => { - mockRequest.body = { - email: 'exists@example.com', - password: 'Password123!', - username: 'exists', - } - - ;(prisma.user.findFirst as any).mockResolvedValue({ id: '1' }) - - await authController.register(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(409) - expect(mockResponse.json).toHaveBeenCalledWith({ - error: 'User with this email or username already exists', - }) - }) - - it('should never store or log the raw token', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'Password123!', - username: 'testuser', - } - - const consoleSpy = vi.spyOn(console, 'error') - - ;(prisma.user.findFirst as any).mockResolvedValue(null) - ;(prisma.user.create as any).mockResolvedValue({ - id: '1', - email: 'test@example.com', - username: 'testuser', - role: 'LEARNER', - }) - ;(prisma.verificationToken.create as any).mockResolvedValue({ - id: 'vt1', - userId: '1', - tokenHash: mockTokenHash, - expiresAt: new Date(Date.now() + 86400000), - }) - - const createSpy = prisma.verificationToken.create as any - - await authController.register(mockRequest as Request, mockResponse as Response) - - const createCallArgs = createSpy.mock.calls[0][0] - expect(createCallArgs.data).not.toHaveProperty('token') - expect(createCallArgs.data.tokenHash).toBe(mockTokenHash) - expect(createCallArgs.data.tokenHash).not.toBe(mockRawToken) - expect(consoleSpy).not.toHaveBeenCalledWith( - expect.stringContaining(mockRawToken) - ) - }) + }) + + describe('register', () => { + it('should register a new user successfully and issue verification token', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + username: 'testuser', + } + + const mockUser = { + id: '1', + email: 'test@example.com', + username: 'testuser', + role: 'LEARNER', + } + + ;(prisma.user.findFirst as any).mockResolvedValue(null) + ;(prisma.user.create as any).mockResolvedValue(mockUser) + ;(prisma.verificationToken.create as any).mockResolvedValue({ + id: 'vt1', + userId: '1', + tokenHash: mockTokenHash, + expiresAt: new Date(Date.now() + 86400000), + }) + + await authController.register( + mockRequest as Request, + mockResponse as Response, + ) + + expect(prisma.user.create).toHaveBeenCalled() + expect(prisma.verificationToken.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: '1', + tokenHash: mockTokenHash, + }), + }), + ) + expect(emailService.queueEmail).toHaveBeenCalledWith( + '1', + 'test@example.com', + expect.any(String), + expect.any(String), + ) + expect(mockResponse.status).toHaveBeenCalledWith(201) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User registered successfully', + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + expiresIn: 900, + tokenType: 'Bearer', + }), + ) + expect(refreshTokenService.issueSession).toHaveBeenCalledWith( + expect.objectContaining({ userId: '1', role: 'LEARNER' }), + ) }) - describe('verifyEmail', () => { - it('should verify email with a valid token', async () => { - mockRequest.body = { token: mockRawToken } - - const mockToken = { - id: 'vt1', - userId: '1', - tokenHash: mockTokenHash, - status: 'PENDING', - expiresAt: new Date(Date.now() + 3600000), - } - - ;(prisma.verificationToken.findFirst as any).mockResolvedValue(mockToken) - ;(prisma.verificationToken.update as any).mockResolvedValue({ - ...mockToken, - status: 'USED', - }) - ;(prisma.user.update as any).mockResolvedValue({ id: '1', isVerified: true }) - ;(prisma.$transaction as any).mockImplementation(async (arg: any) => - typeof arg === 'function' ? arg(prisma) : await Promise.all(arg) - ) - - await authController.verifyEmail( - mockRequest as Request, - mockResponse as Response - ) - - expect(prisma.$transaction).toHaveBeenCalled() - expect(prisma.verificationToken.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'vt1' }, - data: { status: 'USED' }, - }) - ) - expect(prisma.user.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: '1' }, - data: { isVerified: true }, - }) - ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ - message: 'Email verified successfully', - }) - }) - - it('should return 400 for malformed token', async () => { - mockRequest.body = { token: 'not-a-hex-string' } - - await authController.verifyEmail( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ - error: 'Invalid token', - }) - }) - - it('should return 400 for token with wrong length', async () => { - mockRequest.body = { token: 'abcdef' } - - await authController.verifyEmail( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ - error: 'Invalid token', - }) - }) - - it('should return 400 for non-existent token', async () => { - mockRequest.body = { token: mockRawToken } - - ;(prisma.verificationToken.findFirst as any).mockResolvedValue(null) - - await authController.verifyEmail( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ - error: 'Invalid token', - }) - }) - - it('should return 200 for already used token (idempotent)', async () => { - mockRequest.body = { token: mockRawToken } - - const mockToken = { - id: 'vt1', - userId: '1', - tokenHash: mockTokenHash, - status: 'USED', - expiresAt: new Date(Date.now() + 3600000), - } - - ;(prisma.verificationToken.findFirst as any).mockResolvedValue(mockToken) - - await authController.verifyEmail( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ - message: 'Email already verified', - }) - }) - - it('should return 400 for revoked token', async () => { - mockRequest.body = { token: mockRawToken } - - const mockToken = { - id: 'vt1', - userId: '1', - tokenHash: mockTokenHash, - status: 'REVOKED', - expiresAt: new Date(Date.now() + 3600000), - } - - ;(prisma.verificationToken.findFirst as any).mockResolvedValue(mockToken) - - await authController.verifyEmail( - mockRequest as Request, - mockResponse as Response - ) + it('should return 400 for invalid input', async () => { + mockRequest.body = { + email: 'invalid-email', + password: 'short', + } + + await authController.register( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Validation failed', + }), + ) + }) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ - error: 'Invalid token', - }) - }) + it('should return 400 for a password that meets length but not complexity', async () => { + mockRequest.body = { + email: 'test@example.com', + // 8+ chars but no uppercase/symbol — fails isStrongPassword + password: 'alllowercase123', + username: 'testuser', + } + + await authController.register( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Validation failed' }), + ) + expect(prisma.user.create).not.toHaveBeenCalled() + }) - it('should return 400 for expired token and mark as revoked', async () => { - mockRequest.body = { token: mockRawToken } - - const mockToken = { - id: 'vt1', - userId: '1', - tokenHash: mockTokenHash, - status: 'PENDING', - expiresAt: new Date(Date.now() - 3600000), - } - - ;(prisma.verificationToken.findFirst as any).mockResolvedValue(mockToken) - ;(prisma.verificationToken.update as any).mockResolvedValue({ - ...mockToken, - status: 'REVOKED', - }) - - await authController.verifyEmail( - mockRequest as Request, - mockResponse as Response - ) - - expect(prisma.verificationToken.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'vt1' }, - data: { status: 'REVOKED' }, - }) - ) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ - error: 'Token expired', - }) - }) + it('should return 409 if user already exists', async () => { + mockRequest.body = { + email: 'exists@example.com', + password: 'Password123!', + username: 'exists', + } - it('should return 400 for empty token body', async () => { - mockRequest.body = { token: '' } + ;(prisma.user.findFirst as any).mockResolvedValue({ id: '1' }) - await authController.verifyEmail( - mockRequest as Request, - mockResponse as Response - ) + await authController.register( + mockRequest as Request, + mockResponse as Response, + ) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ - error: 'Invalid token', - }) - }) + expect(mockResponse.status).toHaveBeenCalledWith(409) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'User with this email or username already exists', + }) }) - describe('resendVerification', () => { - it('should return neutral response when email does not exist', async () => { - mockRequest.body = { email: 'nonexistent@example.com' } - - ;(prisma.user.findUnique as any).mockResolvedValue(null) - - await authController.resendVerification( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ - message: - 'If the account exists, a verification email has been sent.', - }) - }) + it('should never store or log the raw token', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + username: 'testuser', + } + + const consoleSpy = vi.spyOn(console, 'error') + + ;(prisma.user.findFirst as any).mockResolvedValue(null) + ;(prisma.user.create as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + username: 'testuser', + role: 'LEARNER', + }) + ;(prisma.verificationToken.create as any).mockResolvedValue({ + id: 'vt1', + userId: '1', + tokenHash: mockTokenHash, + expiresAt: new Date(Date.now() + 86400000), + }) + + const createSpy = prisma.verificationToken.create as any + + await authController.register( + mockRequest as Request, + mockResponse as Response, + ) + + const createCallArgs = createSpy.mock.calls[0][0] + expect(createCallArgs.data).not.toHaveProperty('token') + expect(createCallArgs.data.tokenHash).toBe(mockTokenHash) + expect(createCallArgs.data.tokenHash).not.toBe(mockRawToken) + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining(mockRawToken), + ) + }) + }) + + describe('verifyEmail', () => { + it('should verify email with a valid token', async () => { + mockRequest.body = { token: mockRawToken } + + const mockToken = { + id: 'vt1', + userId: '1', + tokenHash: mockTokenHash, + status: 'PENDING', + expiresAt: new Date(Date.now() + 3600000), + } + + ;(prisma.verificationToken.findFirst as any).mockResolvedValue(mockToken) + ;(prisma.verificationToken.update as any).mockResolvedValue({ + ...mockToken, + status: 'USED', + }) + ;(prisma.user.update as any).mockResolvedValue({ + id: '1', + isVerified: true, + }) + ;(prisma.$transaction as any).mockImplementation(async (arg: any) => + typeof arg === 'function' ? arg(prisma) : await Promise.all(arg), + ) + + await authController.verifyEmail( + mockRequest as Request, + mockResponse as Response, + ) + + expect(prisma.$transaction).toHaveBeenCalled() + expect(prisma.verificationToken.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'vt1' }, + data: { status: 'USED' }, + }), + ) + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: '1' }, + data: { isVerified: true }, + }), + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Email verified successfully', + }) + }) - it('should return neutral response when account already verified', async () => { - mockRequest.body = { email: 'verified@example.com' } + it('should return 400 for malformed token', async () => { + mockRequest.body = { token: 'not-a-hex-string' } - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '1', - isVerified: true, - }) + await authController.verifyEmail( + mockRequest as Request, + mockResponse as Response, + ) - await authController.resendVerification( - mockRequest as Request, - mockResponse as Response - ) + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid token', + }) + }) - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ - message: - 'If the account exists, a verification email has been sent.', - }) - }) + it('should return 400 for token with wrong length', async () => { + mockRequest.body = { token: 'abcdef' } - it('should return neutral response for invalid email format', async () => { - mockRequest.body = { email: 'not-an-email' } + await authController.verifyEmail( + mockRequest as Request, + mockResponse as Response, + ) - await authController.resendVerification( - mockRequest as Request, - mockResponse as Response - ) + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid token', + }) + }) - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ - message: - 'If the account exists, a verification email has been sent.', - }) - }) + it('should return 400 for non-existent token', async () => { + mockRequest.body = { token: mockRawToken } - it('should resend verification for valid unverified account', async () => { - mockRequest.body = { email: 'unverified@example.com' } - - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '1', - email: 'unverified@example.com', - isVerified: false, - }) - ;(prisma.verificationToken.updateMany as any).mockResolvedValue({ - count: 1, - }) - ;(prisma.verificationToken.create as any).mockResolvedValue({ - id: 'vt2', - userId: '1', - tokenHash: mockTokenHash, - expiresAt: new Date(Date.now() + 86400000), - }) - - await authController.resendVerification( - mockRequest as Request, - mockResponse as Response - ) - - expect(prisma.verificationToken.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { userId: '1', status: 'PENDING' }, - data: { status: 'REVOKED' }, - }) - ) - expect(prisma.verificationToken.create).toHaveBeenCalled() - expect(emailService.queueEmail).toHaveBeenCalledWith( - '1', - 'unverified@example.com', - expect.any(String), - expect.any(String) - ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - }) + ;(prisma.verificationToken.findFirst as any).mockResolvedValue(null) - it('should apply IP rate limiting', async () => { - mockRequest.body = { email: 'test@example.com' } - - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '1', - isVerified: false, - }) - ;(prisma.verificationToken.updateMany as any).mockResolvedValue({ - count: 1, - }) - ;(prisma.verificationToken.create as any).mockResolvedValue({ - id: 'vt2', - }) - - await authController.resendVerification( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(200) - - // Second call within cooldown - mockRequest.body = { email: 'other@example.com' } - vi.clearAllMocks() - - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '2', - isVerified: false, - }) - - await authController.resendVerification( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(429) - expect(mockResponse.json).toHaveBeenCalledWith({ - error: 'Too many requests. Please try again later.', - }) - }) + await authController.verifyEmail( + mockRequest as Request, + mockResponse as Response, + ) - it('should not expose whether an account exists through response message', async () => { - mockRequest.body = { email: 'any@example.com' } - - ;(prisma.user.findUnique as any).mockResolvedValue(null) - - await authController.resendVerification( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.json).toHaveBeenCalledWith({ - message: - 'If the account exists, a verification email has been sent.', - }) - - vi.clearAllMocks() - resendCooldowns.clear() - resendAccountCounts.clear() - mockRequest.body = { email: 'exists@example.com' } - - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '1', - isVerified: false, - }) - ;(prisma.verificationToken.updateMany as any).mockResolvedValue({ - count: 1, - }) - ;(prisma.verificationToken.create as any).mockResolvedValue({ - id: 'vt2', - }) - - await authController.resendVerification( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.json).toHaveBeenCalledWith({ - message: - 'If the account exists, a verification email has been sent.', - }) - }) + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid token', + }) }) - describe('login', () => { - it('should login successfully with valid credentials', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'Password123!', - } - - const mockUser = { - id: '1', - email: 'test@example.com', - password: 'hashed_password', - username: 'testuser', - role: 'LEARNER', - } - - ;(prisma.user.findUnique as any).mockResolvedValue(mockUser) - ;(bcrypt.compare as any).mockResolvedValue(true) - ;(prisma.user.update as any).mockResolvedValue(mockUser) - - await authController.login( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Login successful', - accessToken: 'mock_access_token', - refreshToken: 'mock_refresh_token', - expiresIn: 900, - tokenType: 'Bearer', - }) - ) - expect(refreshTokenService.issueSession).toHaveBeenCalledWith( - expect.objectContaining({ userId: '1', role: 'LEARNER' }) - ) - }) - - it('should return 403 with ACCOUNT_DEACTIVATED for deactivated accounts', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'Password123!', - } - - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '1', - email: 'test@example.com', - password: 'hashed_password', - username: 'testuser', - role: 'LEARNER', - status: 'DEACTIVATED', - }) - ;(bcrypt.compare as any).mockResolvedValue(true) - - await authController.login( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(403) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' }) - ) - }) + it('should return 200 for already used token (idempotent)', async () => { + mockRequest.body = { token: mockRawToken } - it('should return 403 with scheduledFor for accounts pending deletion', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'Password123!', - } - - const scheduledFor = new Date('2026-08-18T00:00:00Z') - - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '1', - email: 'test@example.com', - password: 'hashed_password', - username: 'testuser', - role: 'LEARNER', - status: 'PENDING_DELETION', - }) - ;(bcrypt.compare as any).mockResolvedValue(true) - ;(prisma.accountDeletionRequest.findFirst as any).mockResolvedValue({ scheduledFor }) - - await authController.login( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(403) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - code: 'ACCOUNT_PENDING_DELETION', - scheduledFor, - }) - ) - }) - - it('should return a neutral 401 for deleted (tombstoned) accounts', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'Password123!', - } - - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '1', - email: 'deleted+abc@anon.invalid', - password: 'tombstone', - username: 'deleted_abc', - role: 'LEARNER', - status: 'DELETED', - }) - ;(bcrypt.compare as any).mockResolvedValue(true) - - await authController.login( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(401) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid credentials' }) - }) + const mockToken = { + id: 'vt1', + userId: '1', + tokenHash: mockTokenHash, + status: 'USED', + expiresAt: new Date(Date.now() + 3600000), + } - it('should return 401 for invalid credentials', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'wrong_password', - } - - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '1', - password: 'hashed', - }) - ;(bcrypt.compare as any).mockResolvedValue(false) - - await authController.login( - mockRequest as Request, - mockResponse as Response - ) - - expect(mockResponse.status).toHaveBeenCalledWith(401) - expect(mockResponse.json).toHaveBeenCalledWith({ - error: 'Invalid credentials', - }) - }) + ;(prisma.verificationToken.findFirst as any).mockResolvedValue(mockToken) - it('transparently upgrades a hash stored below the current bcrypt cost', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'Password123!', - } - - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '1', - email: 'test@example.com', - // cost 10 — below the default configured cost of 12 - password: '$2b$10$abcdefghijklmnopqrstuv', - username: 'testuser', - role: 'LEARNER', - status: 'ACTIVE', - }) - ;(bcrypt.compare as any).mockResolvedValue(true) - ;(prisma.user.update as any).mockResolvedValue({}) - - await authController.login( - mockRequest as Request, - mockResponse as Response - ) - - expect(prisma.user.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: '1' }, - data: expect.objectContaining({ password: 'hashed_password' }), - }) - ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - }) + await authController.verifyEmail( + mockRequest as Request, + mockResponse as Response, + ) - it('does not rewrite a hash that already meets the current bcrypt cost', async () => { - mockRequest.body = { - email: 'test@example.com', - password: 'Password123!', - } - - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: '1', - email: 'test@example.com', - // cost 12 — matches the default configured cost, no upgrade needed - password: '$2b$12$abcdefghijklmnopqrstuv', - username: 'testuser', - role: 'LEARNER', - status: 'ACTIVE', - }) - ;(bcrypt.compare as any).mockResolvedValue(true) - ;(prisma.user.update as any).mockResolvedValue({}) - - await authController.login( - mockRequest as Request, - mockResponse as Response - ) - - const updateCallArgs = (prisma.user.update as any).mock.calls[0][0] - expect(updateCallArgs.data).not.toHaveProperty('password') - }) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Email already verified', + }) }) - describe('refresh', () => { - it('rotates a valid refresh token and returns a new access/refresh pair', async () => { - mockRequest.body = { refreshToken: 'old-refresh-token' } - - vi.mocked(refreshTokenService.rotate).mockResolvedValue({ - kind: 'ok', - accessToken: 'new_access_token', - refreshToken: 'new_refresh_token', - expiresIn: 900, - }) - - await authController.refresh(mockRequest as Request, mockResponse as Response) - - expect(refreshTokenService.rotate).toHaveBeenCalledWith( - 'old-refresh-token', - expect.objectContaining({ ipAddress: '127.0.0.1' }) - ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - accessToken: 'new_access_token', - refreshToken: 'new_refresh_token', - expiresIn: 900, - tokenType: 'Bearer', - }) - ) - }) - - it('reads the refresh token from the httpOnly cookie when the body is absent', async () => { - mockRequest.body = {} - mockRequest.headers = { cookie: 'refresh_token=cookie-refresh-token' } + it('should return 400 for revoked token', async () => { + mockRequest.body = { token: mockRawToken } - vi.mocked(refreshTokenService.rotate).mockResolvedValue({ - kind: 'ok', - accessToken: 'new_access_token', - refreshToken: 'new_refresh_token', - expiresIn: 900, - }) + const mockToken = { + id: 'vt1', + userId: '1', + tokenHash: mockTokenHash, + status: 'REVOKED', + expiresAt: new Date(Date.now() + 3600000), + } - await authController.refresh(mockRequest as Request, mockResponse as Response) + ;(prisma.verificationToken.findFirst as any).mockResolvedValue(mockToken) - expect(refreshTokenService.rotate).toHaveBeenCalledWith( - 'cookie-refresh-token', - expect.anything() - ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - }) - - it('returns 400 when no refresh token is provided', async () => { - mockRequest.body = {} - - await authController.refresh(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'refreshToken is required' }) - expect(refreshTokenService.rotate).not.toHaveBeenCalled() - }) + await authController.verifyEmail( + mockRequest as Request, + mockResponse as Response, + ) - it('returns 401 REFRESH_REUSE_DETECTED when a replayed token is detected', async () => { - mockRequest.body = { refreshToken: 'replayed-token' } + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid token', + }) + }) - vi.mocked(refreshTokenService.rotate).mockResolvedValue({ kind: 'reuse' }) + it('should return 400 for expired token and mark as revoked', async () => { + mockRequest.body = { token: mockRawToken } + + const mockToken = { + id: 'vt1', + userId: '1', + tokenHash: mockTokenHash, + status: 'PENDING', + expiresAt: new Date(Date.now() - 3600000), + } + + ;(prisma.verificationToken.findFirst as any).mockResolvedValue(mockToken) + ;(prisma.verificationToken.update as any).mockResolvedValue({ + ...mockToken, + status: 'REVOKED', + }) + + await authController.verifyEmail( + mockRequest as Request, + mockResponse as Response, + ) + + expect(prisma.verificationToken.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'vt1' }, + data: { status: 'REVOKED' }, + }), + ) + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Token expired', + }) + }) - await authController.refresh(mockRequest as Request, mockResponse as Response) + it('should return 400 for empty token body', async () => { + mockRequest.body = { token: '' } - expect(mockResponse.status).toHaveBeenCalledWith(401) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ code: 'REFRESH_REUSE_DETECTED' }) - ) - }) + await authController.verifyEmail( + mockRequest as Request, + mockResponse as Response, + ) - it('returns 401 for invalid, expired, and revoked tokens', async () => { - const cases = [ - { kind: 'invalid' as const, code: 'REFRESH_INVALID' }, - { kind: 'expired' as const, code: 'REFRESH_EXPIRED' }, - { kind: 'revoked' as const, code: 'REFRESH_REVOKED' }, - ] - - for (const c of cases) { - vi.clearAllMocks() - mockRequest.body = { refreshToken: 'some-token' } - vi.mocked(refreshTokenService.rotate).mockResolvedValue({ kind: c.kind }) - - await authController.refresh(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(401) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ code: c.code }) - ) - } - }) + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid token', + }) }) + }) - describe('logout', () => { - it('revokes the session identified by the refresh token', async () => { - mockRequest.body = { refreshToken: 'current-refresh-token' } + describe('resendVerification', () => { + it('should return neutral response when email does not exist', async () => { + mockRequest.body = { email: 'nonexistent@example.com' } - vi.mocked(refreshTokenService.revokeByRefreshToken).mockResolvedValue({ revokedCount: 1 }) + ;(prisma.user.findUnique as any).mockResolvedValue(null) - await authController.logout(mockRequest as Request, mockResponse as Response) + await authController.resendVerification( + mockRequest as Request, + mockResponse as Response, + ) - expect(refreshTokenService.revokeByRefreshToken).toHaveBeenCalledWith( - 'current-refresh-token', - expect.anything() - ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ - message: 'Logged out successfully', - revokedCount: 1, - }) - }) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'If the account exists, a verification email has been sent.', + }) + }) - it('reads the refresh token from the cookie when the body is absent', async () => { - mockRequest.body = {} - mockRequest.headers = { cookie: 'refresh_token=cookie-refresh-token' } + it('should return neutral response when account already verified', async () => { + mockRequest.body = { email: 'verified@example.com' } - vi.mocked(refreshTokenService.revokeByRefreshToken).mockResolvedValue({ revokedCount: 1 }) + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + isVerified: true, + }) - await authController.logout(mockRequest as Request, mockResponse as Response) + await authController.resendVerification( + mockRequest as Request, + mockResponse as Response, + ) - expect(refreshTokenService.revokeByRefreshToken).toHaveBeenCalledWith( - 'cookie-refresh-token', - expect.anything() - ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - }) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'If the account exists, a verification email has been sent.', + }) + }) - it('returns 400 when no refresh token is provided', async () => { - mockRequest.body = {} + it('should return neutral response for invalid email format', async () => { + mockRequest.body = { email: 'not-an-email' } - await authController.logout(mockRequest as Request, mockResponse as Response) + await authController.resendVerification( + mockRequest as Request, + mockResponse as Response, + ) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'refreshToken is required' }) - }) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'If the account exists, a verification email has been sent.', + }) }) - describe('logoutAll', () => { - it('revokes all sessions for the user identified by the refresh token', async () => { - mockRequest.body = { refreshToken: 'any-refresh-token' } - - vi.mocked(refreshTokenService.revokeAllByRefreshToken).mockResolvedValue({ revokedCount: 3 }) - - await authController.logoutAll(mockRequest as Request, mockResponse as Response) + it('should resend verification for valid unverified account', async () => { + mockRequest.body = { email: 'unverified@example.com' } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + email: 'unverified@example.com', + isVerified: false, + }) + ;(prisma.verificationToken.updateMany as any).mockResolvedValue({ + count: 1, + }) + ;(prisma.verificationToken.create as any).mockResolvedValue({ + id: 'vt2', + userId: '1', + tokenHash: mockTokenHash, + expiresAt: new Date(Date.now() + 86400000), + }) + + await authController.resendVerification( + mockRequest as Request, + mockResponse as Response, + ) + + expect(prisma.verificationToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: '1', status: 'PENDING' }, + data: { status: 'REVOKED' }, + }), + ) + expect(prisma.verificationToken.create).toHaveBeenCalled() + expect(emailService.queueEmail).toHaveBeenCalledWith( + '1', + 'unverified@example.com', + expect.any(String), + expect.any(String), + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + }) - expect(refreshTokenService.revokeAllByRefreshToken).toHaveBeenCalledWith( - 'any-refresh-token', - expect.anything() - ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ - message: 'All sessions logged out', - revokedCount: 3, - }) - }) + it('should apply IP rate limiting', async () => { + mockRequest.body = { email: 'test@example.com' } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + isVerified: false, + }) + ;(prisma.verificationToken.updateMany as any).mockResolvedValue({ + count: 1, + }) + ;(prisma.verificationToken.create as any).mockResolvedValue({ + id: 'vt2', + }) + + await authController.resendVerification( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(200) + + // Second call within cooldown + mockRequest.body = { email: 'other@example.com' } + vi.clearAllMocks() + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '2', + isVerified: false, + }) + + await authController.resendVerification( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(429) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Too many requests. Please try again later.', + }) + }) - it('returns 400 when no refresh token is provided', async () => { - mockRequest.body = {} + it('should not expose whether an account exists through response message', async () => { + mockRequest.body = { email: 'any@example.com' } + + ;(prisma.user.findUnique as any).mockResolvedValue(null) + + await authController.resendVerification( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'If the account exists, a verification email has been sent.', + }) + + vi.clearAllMocks() + resendCooldowns.clear() + resendAccountCounts.clear() + mockRequest.body = { email: 'exists@example.com' } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + isVerified: false, + }) + ;(prisma.verificationToken.updateMany as any).mockResolvedValue({ + count: 1, + }) + ;(prisma.verificationToken.create as any).mockResolvedValue({ + id: 'vt2', + }) + + await authController.resendVerification( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'If the account exists, a verification email has been sent.', + }) + }) + }) + + describe('login', () => { + it('should login successfully with valid credentials', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + const mockUser = { + id: '1', + email: 'test@example.com', + password: 'hashed_password', + username: 'testuser', + role: 'LEARNER', + } + + ;(prisma.user.findUnique as any).mockResolvedValue(mockUser) + ;(bcrypt.compare as any).mockResolvedValue(true) + ;(prisma.user.update as any).mockResolvedValue(mockUser) + + await authController.login( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Login successful', + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + expiresIn: 900, + tokenType: 'Bearer', + }), + ) + expect(refreshTokenService.issueSession).toHaveBeenCalledWith( + expect.objectContaining({ userId: '1', role: 'LEARNER' }), + ) + }) - await authController.logoutAll(mockRequest as Request, mockResponse as Response) + it('should return 403 with ACCOUNT_DEACTIVATED for deactivated accounts', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + password: 'hashed_password', + username: 'testuser', + role: 'LEARNER', + status: 'DEACTIVATED', + }) + ;(bcrypt.compare as any).mockResolvedValue(true) + + await authController.login( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(403) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' }), + ) + }) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'refreshToken is required' }) - }) + it('should return 403 with scheduledFor for accounts pending deletion', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + const scheduledFor = new Date('2026-08-18T00:00:00Z') + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + password: 'hashed_password', + username: 'testuser', + role: 'LEARNER', + status: 'PENDING_DELETION', + }) + ;(bcrypt.compare as any).mockResolvedValue(true) + ;(prisma.accountDeletionRequest.findFirst as any).mockResolvedValue({ + scheduledFor, + }) + + await authController.login( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(403) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ACCOUNT_PENDING_DELETION', + scheduledFor, + }), + ) }) - describe('resetPassword', () => { - const validToken = 'a'.repeat(64) + it('should return a neutral 401 for deleted (tombstoned) accounts', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + email: 'deleted+abc@anon.invalid', + password: 'tombstone', + username: 'deleted_abc', + role: 'LEARNER', + status: 'DELETED', + }) + ;(bcrypt.compare as any).mockResolvedValue(true) + + await authController.login( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(401) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid credentials', + }) + }) - it('should return 400 for a new password that fails the strength policy', async () => { - mockRequest.body = { token: validToken, newPassword: 'alllowercase123' } + it('should return 401 for invalid credentials', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'wrong_password', + } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + password: 'hashed', + }) + ;(bcrypt.compare as any).mockResolvedValue(false) + + await authController.login( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(401) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid credentials', + }) + }) - await authController.resetPassword(mockRequest as Request, mockResponse as Response) + it('transparently upgrades a hash stored below the current bcrypt cost', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + // cost 10 — below the default configured cost of 12 + password: '$2b$10$abcdefghijklmnopqrstuv', + username: 'testuser', + role: 'LEARNER', + status: 'ACTIVE', + }) + ;(bcrypt.compare as any).mockResolvedValue(true) + ;(prisma.user.update as any).mockResolvedValue({}) + + await authController.login( + mockRequest as Request, + mockResponse as Response, + ) + + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: '1' }, + data: expect.objectContaining({ password: 'hashed_password' }), + }), + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + }) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(prisma.$transaction).not.toHaveBeenCalled() - }) + it('does not rewrite a hash that already meets the current bcrypt cost', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + // cost 12 — matches the default configured cost, no upgrade needed + password: '$2b$12$abcdefghijklmnopqrstuv', + username: 'testuser', + role: 'LEARNER', + status: 'ACTIVE', + }) + ;(bcrypt.compare as any).mockResolvedValue(true) + ;(prisma.user.update as any).mockResolvedValue({}) + + await authController.login( + mockRequest as Request, + mockResponse as Response, + ) + + const updateCallArgs = (prisma.user.update as any).mock.calls[0][0] + expect(updateCallArgs.data).not.toHaveProperty('password') + }) + }) + + describe('refresh', () => { + it('rotates a valid refresh token and returns a new access/refresh pair', async () => { + mockRequest.body = { refreshToken: 'old-refresh-token' } + + vi.mocked(refreshTokenService.rotate).mockResolvedValue({ + kind: 'ok', + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + expiresIn: 900, + }) + + await authController.refresh( + mockRequest as Request, + mockResponse as Response, + ) + + expect(refreshTokenService.rotate).toHaveBeenCalledWith( + 'old-refresh-token', + expect.objectContaining({ ipAddress: '127.0.0.1' }), + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + expiresIn: 900, + tokenType: 'Bearer', + }), + ) + }) - it('should reset the password, revoke sessions, and revoke pending tokens on success', async () => { - mockRequest.body = { token: validToken, newPassword: 'NewStr0ng!Pass' } - mockRequest.headers = { 'user-agent': 'vitest' } - mockRequest.socket = { remoteAddress: '127.0.0.1' } as any + it('reads the refresh token from the httpOnly cookie when the body is absent', async () => { + mockRequest.body = {} + mockRequest.headers = { cookie: 'refresh_token=cookie-refresh-token' } + + vi.mocked(refreshTokenService.rotate).mockResolvedValue({ + kind: 'ok', + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + expiresIn: 900, + }) + + await authController.refresh( + mockRequest as Request, + mockResponse as Response, + ) + + expect(refreshTokenService.rotate).toHaveBeenCalledWith( + 'cookie-refresh-token', + expect.anything(), + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + }) - ;(prisma.verificationToken.findFirst as any).mockResolvedValue({ - id: 'vt1', - userId: 'u1', - status: 'PENDING', - type: 'PASSWORD_RESET', - expiresAt: new Date(Date.now() + 60000), - }) + it('returns 400 when no refresh token is provided', async () => { + mockRequest.body = {} - await authController.resetPassword(mockRequest as Request, mockResponse as Response) + await authController.refresh( + mockRequest as Request, + mockResponse as Response, + ) - expect(prisma.$transaction).toHaveBeenCalled() - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Password reset successful' }) - }) + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'refreshToken is required', + }) + expect(refreshTokenService.rotate).not.toHaveBeenCalled() + }) - it('should return 400 for an expired token', async () => { - mockRequest.body = { token: validToken, newPassword: 'NewStr0ng!Pass' } + it('returns 401 REFRESH_REUSE_DETECTED when a replayed token is detected', async () => { + mockRequest.body = { refreshToken: 'replayed-token' } - ;(prisma.verificationToken.findFirst as any).mockResolvedValue({ - id: 'vt1', - userId: 'u1', - status: 'PENDING', - type: 'PASSWORD_RESET', - expiresAt: new Date(Date.now() - 60000), - }) + vi.mocked(refreshTokenService.rotate).mockResolvedValue({ kind: 'reuse' }) - await authController.resetPassword(mockRequest as Request, mockResponse as Response) + await authController.refresh( + mockRequest as Request, + mockResponse as Response, + ) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Token expired' }) - }) + expect(mockResponse.status).toHaveBeenCalledWith(401) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'REFRESH_REUSE_DETECTED' }), + ) }) - describe('requestOtp', () => { - it('should reject a malformed phone number', async () => { - mockRequest.body = { phone: '08012345678' } - - await authController.requestOtp(mockRequest as Request, mockResponse as Response) + it('returns 401 for invalid, expired, and revoked tokens', async () => { + const cases = [ + { kind: 'invalid' as const, code: 'REFRESH_INVALID' }, + { kind: 'expired' as const, code: 'REFRESH_EXPIRED' }, + { kind: 'revoked' as const, code: 'REFRESH_REVOKED' }, + ] - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(otpService.requestChallenge).not.toHaveBeenCalled() + for (const c of cases) { + vi.clearAllMocks() + mockRequest.body = { refreshToken: 'some-token' } + vi.mocked(refreshTokenService.rotate).mockResolvedValue({ + kind: c.kind, }) - it('LOGIN: returns a generic 200 without creating a challenge for an unregistered phone', async () => { - mockRequest.body = { phone: '+2348012345678' } + await authController.refresh( + mockRequest as Request, + mockResponse as Response, + ) - ;(prisma.user.findUnique as any).mockResolvedValue(null) + expect(mockResponse.status).toHaveBeenCalledWith(401) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ code: c.code }), + ) + } + }) + }) + + describe('logout', () => { + it('revokes the session identified by the refresh token', async () => { + mockRequest.body = { refreshToken: 'current-refresh-token' } + + vi.mocked(refreshTokenService.revokeByRefreshToken).mockResolvedValue({ + revokedCount: 1, + }) + + await authController.logout( + mockRequest as Request, + mockResponse as Response, + ) + + expect(refreshTokenService.revokeByRefreshToken).toHaveBeenCalledWith( + 'current-refresh-token', + expect.anything(), + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Logged out successfully', + revokedCount: 1, + }) + }) - await authController.requestOtp(mockRequest as Request, mockResponse as Response) + it('reads the refresh token from the cookie when the body is absent', async () => { + mockRequest.body = {} + mockRequest.headers = { cookie: 'refresh_token=cookie-refresh-token' } - expect(otpService.requestChallenge).not.toHaveBeenCalled() - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ - message: 'If this phone number is registered, a verification code has been sent.', - }) - }) + vi.mocked(refreshTokenService.revokeByRefreshToken).mockResolvedValue({ + revokedCount: 1, + }) - it('LOGIN: returns the same generic 200 for a phone that has not been verified', async () => { - mockRequest.body = { phone: '+2348012345678' } + await authController.logout( + mockRequest as Request, + mockResponse as Response, + ) - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: 'user1', - status: 'ACTIVE', - phoneVerifiedAt: null, - }) + expect(refreshTokenService.revokeByRefreshToken).toHaveBeenCalledWith( + 'cookie-refresh-token', + expect.anything(), + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + }) - await authController.requestOtp(mockRequest as Request, mockResponse as Response) + it('returns 400 when no refresh token is provided', async () => { + mockRequest.body = {} - expect(otpService.requestChallenge).not.toHaveBeenCalled() - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ - message: 'If this phone number is registered, a verification code has been sent.', - }) - }) + await authController.logout( + mockRequest as Request, + mockResponse as Response, + ) - it('LOGIN: requests a challenge for a verified, active phone', async () => { - mockRequest.body = { phone: '+2348012345678' } + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'refreshToken is required', + }) + }) + }) + + describe('logoutAll', () => { + it('revokes all sessions for the user identified by the refresh token', async () => { + mockRequest.body = { refreshToken: 'any-refresh-token' } + + vi.mocked(refreshTokenService.revokeAllByRefreshToken).mockResolvedValue({ + revokedCount: 3, + }) + + await authController.logoutAll( + mockRequest as Request, + mockResponse as Response, + ) + + expect(refreshTokenService.revokeAllByRefreshToken).toHaveBeenCalledWith( + 'any-refresh-token', + expect.anything(), + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'All sessions logged out', + revokedCount: 3, + }) + }) - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: 'user1', - status: 'ACTIVE', - phoneVerifiedAt: new Date(), - }) + it('returns 400 when no refresh token is provided', async () => { + mockRequest.body = {} - await authController.requestOtp(mockRequest as Request, mockResponse as Response) + await authController.logoutAll( + mockRequest as Request, + mockResponse as Response, + ) - expect(otpService.requestChallenge).toHaveBeenCalledWith( - '+2348012345678', - 'LOGIN', - 'user1', - expect.objectContaining({ ip: '127.0.0.1' }) - ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - }) + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'refreshToken is required', + }) + }) + }) - it('PHONE_VERIFICATION: requests a challenge for the authenticated caller', async () => { - mockRequest.body = { phone: '+2348012345678' } - ;(mockRequest as any).user = { id: 'user1', role: 'learner' } + describe('resetPassword', () => { + const validToken = 'a'.repeat(64) - ;(prisma.user.findFirst as any).mockResolvedValue(null) + it('should return 400 for a new password that fails the strength policy', async () => { + mockRequest.body = { token: validToken, newPassword: 'alllowercase123' } - await authController.requestOtp(mockRequest as Request, mockResponse as Response) + await authController.resetPassword( + mockRequest as Request, + mockResponse as Response, + ) - expect(otpService.requestChallenge).toHaveBeenCalledWith( - '+2348012345678', - 'PHONE_VERIFICATION', - 'user1', - expect.anything() - ) - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Verification code sent.' }) - }) + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(prisma.$transaction).not.toHaveBeenCalled() + }) - it('PHONE_VERIFICATION: rejects a phone already verified on another account', async () => { - mockRequest.body = { phone: '+2348012345678' } - ;(mockRequest as any).user = { id: 'user1', role: 'learner' } + it('should reset the password, revoke sessions, and revoke pending tokens on success', async () => { + mockRequest.body = { token: validToken, newPassword: 'NewStr0ng!Pass' } + mockRequest.headers = { 'user-agent': 'vitest' } + mockRequest.socket = { remoteAddress: '127.0.0.1' } as any + + ;(prisma.verificationToken.findFirst as any).mockResolvedValue({ + id: 'vt1', + userId: 'u1', + status: 'PENDING', + type: 'PASSWORD_RESET', + expiresAt: new Date(Date.now() + 60000), + }) + + await authController.resetPassword( + mockRequest as Request, + mockResponse as Response, + ) + + expect(prisma.$transaction).toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Password reset successful', + }) + }) - ;(prisma.user.findFirst as any).mockResolvedValue({ id: 'user2' }) + it('should return 400 for an expired token', async () => { + mockRequest.body = { token: validToken, newPassword: 'NewStr0ng!Pass' } - await authController.requestOtp(mockRequest as Request, mockResponse as Response) + ;(prisma.verificationToken.findFirst as any).mockResolvedValue({ + id: 'vt1', + userId: 'u1', + status: 'PENDING', + type: 'PASSWORD_RESET', + expiresAt: new Date(Date.now() - 60000), + }) - expect(otpService.requestChallenge).not.toHaveBeenCalled() - expect(mockResponse.status).toHaveBeenCalledWith(409) - }) + await authController.resetPassword( + mockRequest as Request, + mockResponse as Response, + ) - it('should rate-limit repeated requests for the same phone', async () => { - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: 'user1', - status: 'ACTIVE', - phoneVerifiedAt: new Date(), - }) + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Token expired' }) + }) + }) - mockRequest.body = { phone: '+2348012345678' } - await authController.requestOtp(mockRequest as Request, mockResponse as Response) - expect(mockResponse.status).toHaveBeenCalledWith(200) + describe('requestOtp', () => { + it('should reject a malformed phone number', async () => { + mockRequest.body = { phone: '08012345678' } - vi.clearAllMocks() - mockRequest.body = { phone: '+2348012345678' } - await authController.requestOtp(mockRequest as Request, mockResponse as Response) + await authController.requestOtp( + mockRequest as Request, + mockResponse as Response, + ) - expect(mockResponse.status).toHaveBeenCalledWith(429) - expect(otpService.requestChallenge).not.toHaveBeenCalled() - }) + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(otpService.requestChallenge).not.toHaveBeenCalled() + }) - it('should rate-limit repeated requests from the same device regardless of phone', async () => { - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: 'user1', - status: 'ACTIVE', - phoneVerifiedAt: new Date(), - }) + it('LOGIN: returns a generic 200 without creating a challenge for an unregistered phone', async () => { + mockRequest.body = { phone: '+2348012345678' } - otpDeviceCounts.set('device-1', { count: 10, resetAt: Date.now() + 60_000 }) + ;(prisma.user.findUnique as any).mockResolvedValue(null) - mockRequest.body = { phone: '+2348012345678', deviceId: 'device-1' } - await authController.requestOtp(mockRequest as Request, mockResponse as Response) + await authController.requestOtp( + mockRequest as Request, + mockResponse as Response, + ) - expect(mockResponse.status).toHaveBeenCalledWith(429) - expect(otpService.requestChallenge).not.toHaveBeenCalled() - }) + expect(otpService.requestChallenge).not.toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: + 'If this phone number is registered, a verification code has been sent.', + }) }) - describe('verifyOtp', () => { - it('should return 400 for an invalid/expired code', async () => { - mockRequest.body = { phone: '+2348012345678', code: '000000' } - - ;(otpService.verifyChallenge as any).mockResolvedValue({ ok: false, reason: 'mismatch' }) + it('LOGIN: returns the same generic 200 for a phone that has not been verified', async () => { + mockRequest.body = { phone: '+2348012345678' } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + status: 'ACTIVE', + phoneVerifiedAt: null, + }) + + await authController.requestOtp( + mockRequest as Request, + mockResponse as Response, + ) + + expect(otpService.requestChallenge).not.toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: + 'If this phone number is registered, a verification code has been sent.', + }) + }) - await authController.verifyOtp(mockRequest as Request, mockResponse as Response) + it('LOGIN: requests a challenge for a verified, active phone', async () => { + mockRequest.body = { phone: '+2348012345678' } + + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + status: 'ACTIVE', + phoneVerifiedAt: new Date(), + }) + + await authController.requestOtp( + mockRequest as Request, + mockResponse as Response, + ) + + expect(otpService.requestChallenge).toHaveBeenCalledWith( + '+2348012345678', + 'LOGIN', + 'user1', + expect.objectContaining({ ip: '127.0.0.1' }), + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + }) - expect(mockResponse.status).toHaveBeenCalledWith(400) - expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid or expired code' }) - }) + it('PHONE_VERIFICATION: requests a challenge for the authenticated caller', async () => { + mockRequest.body = { phone: '+2348012345678' } + ;(mockRequest as any).user = { id: 'user1', role: 'learner' } + + ;(prisma.user.findFirst as any).mockResolvedValue(null) + + await authController.requestOtp( + mockRequest as Request, + mockResponse as Response, + ) + + expect(otpService.requestChallenge).toHaveBeenCalledWith( + '+2348012345678', + 'PHONE_VERIFICATION', + 'user1', + expect.anything(), + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Verification code sent.', + }) + }) - it('should return 429 once the challenge is locked', async () => { - mockRequest.body = { phone: '+2348012345678', code: '000000' } + it('PHONE_VERIFICATION: rejects a phone already verified on another account', async () => { + mockRequest.body = { phone: '+2348012345678' } + ;(mockRequest as any).user = { id: 'user1', role: 'learner' } - ;(otpService.verifyChallenge as any).mockResolvedValue({ ok: false, reason: 'locked' }) + ;(prisma.user.findFirst as any).mockResolvedValue({ id: 'user2' }) - await authController.verifyOtp(mockRequest as Request, mockResponse as Response) + await authController.requestOtp( + mockRequest as Request, + mockResponse as Response, + ) - expect(mockResponse.status).toHaveBeenCalledWith(429) - }) + expect(otpService.requestChallenge).not.toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(409) + }) - it('LOGIN: issues a JWT on a correct code for an active account', async () => { - mockRequest.body = { phone: '+2348012345678', code: '123456' } - - ;(otpService.verifyChallenge as any).mockResolvedValue({ ok: true, userId: 'user1' }) - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: 'user1', - email: 'test@example.com', - username: 'testuser', - role: 'LEARNER', - status: 'ACTIVE', - }) - ;(prisma.user.update as any).mockResolvedValue({}) - - await authController.verifyOtp(mockRequest as Request, mockResponse as Response) - - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Login successful', - accessToken: 'mock_access_token', - refreshToken: 'mock_refresh_token', - }) - ) - expect(refreshTokenService.issueSession).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user1', role: 'LEARNER' }) - ) - }) + it('should rate-limit repeated requests for the same phone', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + status: 'ACTIVE', + phoneVerifiedAt: new Date(), + }) + + mockRequest.body = { phone: '+2348012345678' } + await authController.requestOtp( + mockRequest as Request, + mockResponse as Response, + ) + expect(mockResponse.status).toHaveBeenCalledWith(200) + + vi.clearAllMocks() + mockRequest.body = { phone: '+2348012345678' } + await authController.requestOtp( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(429) + expect(otpService.requestChallenge).not.toHaveBeenCalled() + }) - it('LOGIN: blocks a deactivated account the same way as password login', async () => { - mockRequest.body = { phone: '+2348012345678', code: '123456' } + it('should rate-limit repeated requests from the same device regardless of phone', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + status: 'ACTIVE', + phoneVerifiedAt: new Date(), + }) + + otpDeviceCounts.set('device-1', { + count: 10, + resetAt: Date.now() + 60_000, + }) + + mockRequest.body = { phone: '+2348012345678', deviceId: 'device-1' } + await authController.requestOtp( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(429) + expect(otpService.requestChallenge).not.toHaveBeenCalled() + }) + }) + + describe('verifyOtp', () => { + it('should return 400 for an invalid/expired code', async () => { + mockRequest.body = { phone: '+2348012345678', code: '000000' } + + ;(otpService.verifyChallenge as any).mockResolvedValue({ + ok: false, + reason: 'mismatch', + }) + + await authController.verifyOtp( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid or expired code', + }) + }) - ;(otpService.verifyChallenge as any).mockResolvedValue({ ok: true, userId: 'user1' }) - ;(prisma.user.findUnique as any).mockResolvedValue({ - id: 'user1', - status: 'DEACTIVATED', - }) + it('should return 429 once the challenge is locked', async () => { + mockRequest.body = { phone: '+2348012345678', code: '000000' } - await authController.verifyOtp(mockRequest as Request, mockResponse as Response) + ;(otpService.verifyChallenge as any).mockResolvedValue({ + ok: false, + reason: 'locked', + }) - expect(mockResponse.status).toHaveBeenCalledWith(403) - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' }) - ) - }) + await authController.verifyOtp( + mockRequest as Request, + mockResponse as Response, + ) - it('PHONE_VERIFICATION: marks the phone verified on the authenticated caller', async () => { - mockRequest.body = { phone: '+2348012345678', code: '123456' } - ;(mockRequest as any).user = { id: 'user1', role: 'learner' } + expect(mockResponse.status).toHaveBeenCalledWith(429) + }) - ;(otpService.verifyChallenge as any).mockResolvedValue({ ok: true, userId: 'user1' }) - ;(prisma.user.update as any).mockResolvedValue({}) + it('LOGIN: issues a JWT on a correct code for an active account', async () => { + mockRequest.body = { phone: '+2348012345678', code: '123456' } + + ;(otpService.verifyChallenge as any).mockResolvedValue({ + ok: true, + userId: 'user1', + }) + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + email: 'test@example.com', + username: 'testuser', + role: 'LEARNER', + status: 'ACTIVE', + }) + ;(prisma.user.update as any).mockResolvedValue({}) + + await authController.verifyOtp( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Login successful', + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + }), + ) + expect(refreshTokenService.issueSession).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user1', role: 'LEARNER' }), + ) + }) - await authController.verifyOtp(mockRequest as Request, mockResponse as Response) + it('LOGIN: blocks a deactivated account the same way as password login', async () => { + mockRequest.body = { phone: '+2348012345678', code: '123456' } + + ;(otpService.verifyChallenge as any).mockResolvedValue({ + ok: true, + userId: 'user1', + }) + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'user1', + status: 'DEACTIVATED', + }) + + await authController.verifyOtp( + mockRequest as Request, + mockResponse as Response, + ) + + expect(mockResponse.status).toHaveBeenCalledWith(403) + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' }), + ) + }) - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: 'user1' }, - data: { phone: '+2348012345678', phoneVerifiedAt: expect.any(Date) }, - }) - expect(mockResponse.status).toHaveBeenCalledWith(200) - expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Phone number verified successfully' }) - }) + it('PHONE_VERIFICATION: marks the phone verified on the authenticated caller', async () => { + mockRequest.body = { phone: '+2348012345678', code: '123456' } + ;(mockRequest as any).user = { id: 'user1', role: 'learner' } + + ;(otpService.verifyChallenge as any).mockResolvedValue({ + ok: true, + userId: 'user1', + }) + ;(prisma.user.update as any).mockResolvedValue({}) + + await authController.verifyOtp( + mockRequest as Request, + mockResponse as Response, + ) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user1' }, + data: { phone: '+2348012345678', phoneVerifiedAt: expect.any(Date) }, + }) + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Phone number verified successfully', + }) }) + }) }) diff --git a/tests/avatar.controller.test.ts b/tests/avatar.controller.test.ts index 9dc10637..13056e6c 100644 --- a/tests/avatar.controller.test.ts +++ b/tests/avatar.controller.test.ts @@ -92,7 +92,11 @@ describe('AvatarController', () => { }) it('returns 201 on success', async () => { - req.body = { contentType: 'image/jpeg', originalName: 'photo.jpg', sizeBytes: 50_000 } + req.body = { + contentType: 'image/jpeg', + originalName: 'photo.jpg', + sizeBytes: 50_000, + } mockCreateUploadIntent.mockResolvedValue({ uploadKey: 'key', uploadUrl: 'url', @@ -109,12 +113,16 @@ describe('AvatarController', () => { it('maps AvatarValidationError to its status code', async () => { req.body = { contentType: 'application/pdf' } - mockCreateUploadIntent.mockRejectedValue(new AvatarValidationError('Unsupported content type', 422)) + mockCreateUploadIntent.mockRejectedValue( + new AvatarValidationError('Unsupported content type', 422), + ) await controller.createUploadIntent(req, res) expect(res.status).toHaveBeenCalledWith(422) - expect(res.json).toHaveBeenCalledWith({ error: 'Unsupported content type' }) + expect(res.json).toHaveBeenCalledWith({ + error: 'Unsupported content type', + }) }) it('returns 500 on unexpected error', async () => { @@ -171,7 +179,9 @@ describe('AvatarController', () => { it('maps 403 Forbidden for cross-user access', async () => { req.body = { uploadKey: 'key' } - mockFinalize.mockRejectedValue(new AvatarValidationError('Forbidden', 403)) + mockFinalize.mockRejectedValue( + new AvatarValidationError('Forbidden', 403), + ) await controller.finalize(req, res) @@ -180,7 +190,9 @@ describe('AvatarController', () => { it('maps 404 for missing upload', async () => { req.body = { uploadKey: 'key' } - mockFinalize.mockRejectedValue(new AvatarValidationError('Upload not found', 404)) + mockFinalize.mockRejectedValue( + new AvatarValidationError('Upload not found', 404), + ) await controller.finalize(req, res) @@ -189,7 +201,9 @@ describe('AvatarController', () => { it('maps 422 for validation failure', async () => { req.body = { uploadKey: 'key' } - mockFinalize.mockRejectedValue(new AvatarValidationError('MIME mismatch', 422)) + mockFinalize.mockRejectedValue( + new AvatarValidationError('MIME mismatch', 422), + ) await controller.finalize(req, res) @@ -215,7 +229,9 @@ describe('AvatarController', () => { }) it('returns 404 when no avatar exists', async () => { - mockDeleteAvatar.mockRejectedValue(new AvatarValidationError('No active avatar', 404)) + mockDeleteAvatar.mockRejectedValue( + new AvatarValidationError('No active avatar', 404), + ) await controller.deleteAvatar(req, res) @@ -244,14 +260,18 @@ describe('AvatarController', () => { it('returns 200 with avatar data', async () => { mockGetCurrentAvatar.mockResolvedValue({ id: 'avatar-1', - variants: [{ label: 'original', url: '/storage/key', width: 100, height: 80 }], + variants: [ + { label: 'original', url: '/storage/key', width: 100, height: 80 }, + ], createdAt: '2026-01-01T00:00:00.000Z', }) await controller.getCurrentAvatar(req, res) expect(res.status).toHaveBeenCalledWith(200) - expect(res.json).toHaveBeenCalledWith({ data: expect.objectContaining({ id: 'avatar-1' }) }) + expect(res.json).toHaveBeenCalledWith({ + data: expect.objectContaining({ id: 'avatar-1' }), + }) }) }) }) diff --git a/tests/avatar.service.test.ts b/tests/avatar.service.test.ts index 56d64e54..cdef551e 100644 --- a/tests/avatar.service.test.ts +++ b/tests/avatar.service.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { AvatarService, AvatarValidationError } from '../src/services/avatar.service' +import { + AvatarService, + AvatarValidationError, +} from '../src/services/avatar.service' import { InMemoryStorageProvider } from '../src/services/storage/in-memory-storage' import { AVATAR_MAX_BYTES } from '../src/types/avatar.types' @@ -112,9 +115,18 @@ describe('AvatarService', () => { describe('createUploadIntent', () => { it('creates a PENDING avatar row and returns upload metadata', async () => { - mockCreate.mockResolvedValue({ id: 'avatar-1', userId, status: 'PENDING' }) + mockCreate.mockResolvedValue({ + id: 'avatar-1', + userId, + status: 'PENDING', + }) - const result = await service.createUploadIntent(userId, 'image/jpeg', 'photo.jpg', 50_000) + const result = await service.createUploadIntent( + userId, + 'image/jpeg', + 'photo.jpg', + 50_000, + ) expect(result.uploadKey).toContain(userId) expect(result.maxBytes).toBe(AVATAR_MAX_BYTES) @@ -141,12 +153,21 @@ describe('AvatarService', () => { it('rejects when sizeBytes exceeds the limit', async () => { await expect( - service.createUploadIntent(userId, 'image/png', undefined, AVATAR_MAX_BYTES + 1), + service.createUploadIntent( + userId, + 'image/png', + undefined, + AVATAR_MAX_BYTES + 1, + ), ).rejects.toThrow(AvatarValidationError) }) it('normalises Content-Type parameters', async () => { - mockCreate.mockResolvedValue({ id: 'avatar-1', userId, status: 'PENDING' }) + mockCreate.mockResolvedValue({ + id: 'avatar-1', + userId, + status: 'PENDING', + }) await service.createUploadIntent(userId, 'image/png; charset=binary') expect(mockCreate).toHaveBeenCalledWith( @@ -195,7 +216,7 @@ describe('AvatarService', () => { it('produces three variants in storage', async () => { await service.finalize(userId, uploadKey) - expect(storage.has(`${uploadKey}`)).toBe(true) // original + expect(storage.has(`${uploadKey}`)).toBe(true) // original expect(storage.has(`${uploadKey}_thumb`)).toBe(true) expect(storage.has(`${uploadKey}_medium`)).toBe(true) }) @@ -203,7 +224,9 @@ describe('AvatarService', () => { it('rejects if avatar not found', async () => { mockFindUnique.mockResolvedValue(null) - await expect(service.finalize(userId, uploadKey)).rejects.toThrow('not found') + await expect(service.finalize(userId, uploadKey)).rejects.toThrow( + 'not found', + ) }) it('rejects cross-user finalization', async () => { @@ -216,7 +239,9 @@ describe('AvatarService', () => { createdAt: new Date(), }) - await expect(service.finalize(userId, uploadKey)).rejects.toThrow('Forbidden') + await expect(service.finalize(userId, uploadKey)).rejects.toThrow( + 'Forbidden', + ) }) it('rejects double finalization (already ACTIVE)', async () => { @@ -229,19 +254,25 @@ describe('AvatarService', () => { createdAt: new Date(), }) - await expect(service.finalize(userId, uploadKey)).rejects.toThrow('already ACTIVE') + await expect(service.finalize(userId, uploadKey)).rejects.toThrow( + 'already ACTIVE', + ) }) it('marks avatar as FAILED on validation failure', async () => { // Put invalid data that looks like PNG header but is garbage storage.put(uploadKey, Buffer.alloc(2048, 0xff)) - await expect(service.finalize(userId, uploadKey)).rejects.toThrow(AvatarValidationError) + await expect(service.finalize(userId, uploadKey)).rejects.toThrow( + AvatarValidationError, + ) }) it('verifies SHA-256 integrity when provided', async () => { const crypto = await import('crypto') - const data = storage.has(uploadKey) ? await storage.readBytes(uploadKey) : makeValidPng() + const data = storage.has(uploadKey) + ? await storage.readBytes(uploadKey) + : makeValidPng() const correctHash = crypto.createHash('sha256').update(data).digest('hex') const result = await service.finalize(userId, uploadKey, correctHash) @@ -257,7 +288,11 @@ describe('AvatarService', () => { it('retires the previously active avatar', async () => { const oldAvatarId = 'old-avatar' mockFindMany.mockResolvedValue([ - { id: oldAvatarId, storageKey: `avatars/${userId}/${oldAvatarId}/old.png`, status: 'PENDING' }, + { + id: oldAvatarId, + storageKey: `avatars/${userId}/${oldAvatarId}/old.png`, + status: 'PENDING', + }, ]) await service.finalize(userId, uploadKey) @@ -308,8 +343,16 @@ describe('AvatarService', () => { status: 'ACTIVE', }) mockFindMany.mockResolvedValue([ - { avatarId, storageKey: `avatars/${userId}/${avatarId}/pic.png`, label: 'original' }, - { avatarId, storageKey: `avatars/${userId}/${avatarId}/pic.png_thumb`, label: 'thumb' }, + { + avatarId, + storageKey: `avatars/${userId}/${avatarId}/pic.png`, + label: 'original', + }, + { + avatarId, + storageKey: `avatars/${userId}/${avatarId}/pic.png_thumb`, + label: 'thumb', + }, ]) mockDelete.mockResolvedValue({}) @@ -327,14 +370,18 @@ describe('AvatarService', () => { it('throws 404 when no active avatar exists', async () => { mockFindFirst.mockResolvedValue(null) - await expect(service.deleteAvatar(userId)).rejects.toThrow('No active avatar') + await expect(service.deleteAvatar(userId)).rejects.toThrow( + 'No active avatar', + ) }) - it('prevents deleting another user\'s avatar', async () => { + it("prevents deleting another user's avatar", async () => { // The query is scoped to userId, so a different user simply gets no result mockFindFirst.mockResolvedValue(null) - await expect(service.deleteAvatar('other-user')).rejects.toThrow('No active avatar') + await expect(service.deleteAvatar('other-user')).rejects.toThrow( + 'No active avatar', + ) }) }) @@ -353,8 +400,18 @@ describe('AvatarService', () => { status: 'ACTIVE', createdAt: new Date('2026-01-01'), variants: [ - { label: 'original', storageKey: `avatars/${userId}/${avatarId}/pic.png`, width: 400, height: 300 }, - { label: 'thumb', storageKey: `avatars/${userId}/${avatarId}/pic.png_thumb`, width: 80, height: 60 }, + { + label: 'original', + storageKey: `avatars/${userId}/${avatarId}/pic.png`, + width: 400, + height: 300, + }, + { + label: 'thumb', + storageKey: `avatars/${userId}/${avatarId}/pic.png_thumb`, + width: 80, + height: 60, + }, ], }) diff --git a/tests/consent.controller.test.ts b/tests/consent.controller.test.ts index 2d2d36da..fc888891 100644 --- a/tests/consent.controller.test.ts +++ b/tests/consent.controller.test.ts @@ -1,12 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ConsentController } from '../src/controllers/consent.controller' -const { mockGetCurrent, mockGetHistory, mockGrant, mockWithdraw } = vi.hoisted(() => ({ - mockGetCurrent: vi.fn(), - mockGetHistory: vi.fn(), - mockGrant: vi.fn(), - mockWithdraw: vi.fn(), -})) +const { mockGetCurrent, mockGetHistory, mockGrant, mockWithdraw } = vi.hoisted( + () => ({ + mockGetCurrent: vi.fn(), + mockGetHistory: vi.fn(), + mockGrant: vi.fn(), + mockWithdraw: vi.fn(), + }), +) vi.mock('../src/services/consent.service', () => ({ consentService: { @@ -42,7 +44,9 @@ describe('ConsentController', () => { }) it('returns current consents on success', async () => { - mockGetCurrent.mockResolvedValue([{ purpose: 'terms_of_service', status: 'granted' }]) + mockGetCurrent.mockResolvedValue([ + { purpose: 'terms_of_service', status: 'granted' }, + ]) await controller.getCurrent(req, res) @@ -72,7 +76,11 @@ describe('ConsentController', () => { describe('grant', () => { it('returns 400 on an invalid purpose', async () => { - req.body = { purpose: 'not_a_purpose', policyVersion: 'v1', source: 'onboarding' } + req.body = { + purpose: 'not_a_purpose', + policyVersion: 'v1', + source: 'onboarding', + } await controller.grant(req, res) @@ -88,12 +96,20 @@ describe('ConsentController', () => { }) it('grants consent with a valid payload', async () => { - req.body = { purpose: 'analytics', policyVersion: 'v1', source: 'onboarding' } + req.body = { + purpose: 'analytics', + policyVersion: 'v1', + source: 'onboarding', + } mockGrant.mockResolvedValue({ purpose: 'analytics', status: 'granted' }) await controller.grant(req, res) - expect(mockGrant).toHaveBeenCalledWith('user1', { purpose: 'analytics', policyVersion: 'v1', source: 'onboarding' }) + expect(mockGrant).toHaveBeenCalledWith('user1', { + purpose: 'analytics', + policyVersion: 'v1', + source: 'onboarding', + }) expect(res.status).toHaveBeenCalledWith(200) }) }) @@ -127,7 +143,10 @@ describe('ConsentController', () => { it('withdraws optional consent', async () => { req.body = { purpose: 'analytics', source: 'settings' } - mockWithdraw.mockResolvedValue({ kind: 'withdrawn', record: { status: 'withdrawn' } }) + mockWithdraw.mockResolvedValue({ + kind: 'withdrawn', + record: { status: 'withdrawn' }, + }) await controller.withdraw(req, res) diff --git a/tests/consent.service.test.ts b/tests/consent.service.test.ts index 797cdfde..c4d014f9 100644 --- a/tests/consent.service.test.ts +++ b/tests/consent.service.test.ts @@ -27,30 +27,68 @@ describe('ConsentService', () => { describe('grant', () => { it('marks required purposes as required', async () => { - mockCreate.mockResolvedValue({ id: 'c1', purpose: 'terms_of_service', required: true, status: 'granted' }) + mockCreate.mockResolvedValue({ + id: 'c1', + purpose: 'terms_of_service', + required: true, + status: 'granted', + }) - await service.grant('user1', { purpose: 'terms_of_service', policyVersion: 'v1', source: 'onboarding' }) + await service.grant('user1', { + purpose: 'terms_of_service', + policyVersion: 'v1', + source: 'onboarding', + }) expect(mockCreate).toHaveBeenCalledWith({ - data: expect.objectContaining({ purpose: 'terms_of_service', required: true, status: 'granted' }), + data: expect.objectContaining({ + purpose: 'terms_of_service', + required: true, + status: 'granted', + }), }) }) it('marks optional purposes as not required', async () => { - mockCreate.mockResolvedValue({ id: 'c2', purpose: 'marketing_emails', required: false, status: 'granted' }) + mockCreate.mockResolvedValue({ + id: 'c2', + purpose: 'marketing_emails', + required: false, + status: 'granted', + }) - await service.grant('user1', { purpose: 'marketing_emails', policyVersion: 'v1', source: 'settings' }) + await service.grant('user1', { + purpose: 'marketing_emails', + policyVersion: 'v1', + source: 'settings', + }) expect(mockCreate).toHaveBeenCalledWith({ - data: expect.objectContaining({ purpose: 'marketing_emails', required: false }), + data: expect.objectContaining({ + purpose: 'marketing_emails', + required: false, + }), }) }) it('records a fresh row even when a grant for the purpose already exists (versioned re-consent)', async () => { - mockCreate.mockResolvedValue({ id: 'c3', purpose: 'analytics', required: false, status: 'granted' }) + mockCreate.mockResolvedValue({ + id: 'c3', + purpose: 'analytics', + required: false, + status: 'granted', + }) - await service.grant('user1', { purpose: 'analytics', policyVersion: 'v2', source: 'settings' }) - await service.grant('user1', { purpose: 'analytics', policyVersion: 'v2', source: 'settings' }) + await service.grant('user1', { + purpose: 'analytics', + policyVersion: 'v2', + source: 'settings', + }) + await service.grant('user1', { + purpose: 'analytics', + policyVersion: 'v2', + source: 'settings', + }) expect(mockCreate).toHaveBeenCalledTimes(2) }) @@ -60,37 +98,72 @@ describe('ConsentService', () => { it('returns not-granted when there is no prior consent', async () => { mockFindFirst.mockResolvedValue(null) - const result = await service.withdraw('user1', { purpose: 'analytics', source: 'settings' }) + const result = await service.withdraw('user1', { + purpose: 'analytics', + source: 'settings', + }) expect(result).toEqual({ kind: 'not-granted' }) expect(mockCreate).not.toHaveBeenCalled() }) it('returns not-granted when the latest record is already withdrawn', async () => { - mockFindFirst.mockResolvedValue({ purpose: 'analytics', required: false, status: 'withdrawn' }) + mockFindFirst.mockResolvedValue({ + purpose: 'analytics', + required: false, + status: 'withdrawn', + }) - const result = await service.withdraw('user1', { purpose: 'analytics', source: 'settings' }) + const result = await service.withdraw('user1', { + purpose: 'analytics', + source: 'settings', + }) expect(result).toEqual({ kind: 'not-granted' }) }) it('blocks withdrawal of required consent', async () => { - mockFindFirst.mockResolvedValue({ purpose: 'terms_of_service', required: true, status: 'granted', policyVersion: 'v1' }) + mockFindFirst.mockResolvedValue({ + purpose: 'terms_of_service', + required: true, + status: 'granted', + policyVersion: 'v1', + }) - const result = await service.withdraw('user1', { purpose: 'terms_of_service', source: 'settings' }) + const result = await service.withdraw('user1', { + purpose: 'terms_of_service', + source: 'settings', + }) expect(result).toEqual({ kind: 'required-cannot-withdraw' }) expect(mockCreate).not.toHaveBeenCalled() }) it('withdraws granted optional consent, carrying forward its policy version', async () => { - mockFindFirst.mockResolvedValue({ purpose: 'marketing_emails', required: false, status: 'granted', policyVersion: 'v3' }) - mockCreate.mockResolvedValue({ id: 'c4', purpose: 'marketing_emails', status: 'withdrawn', policyVersion: 'v3' }) + mockFindFirst.mockResolvedValue({ + purpose: 'marketing_emails', + required: false, + status: 'granted', + policyVersion: 'v3', + }) + mockCreate.mockResolvedValue({ + id: 'c4', + purpose: 'marketing_emails', + status: 'withdrawn', + policyVersion: 'v3', + }) - const result = await service.withdraw('user1', { purpose: 'marketing_emails', source: 'settings' }) + const result = await service.withdraw('user1', { + purpose: 'marketing_emails', + source: 'settings', + }) expect(mockCreate).toHaveBeenCalledWith({ - data: expect.objectContaining({ purpose: 'marketing_emails', status: 'withdrawn', policyVersion: 'v3' }), + data: expect.objectContaining({ + purpose: 'marketing_emails', + status: 'withdrawn', + policyVersion: 'v3', + }), }) expect(result.kind).toBe('withdrawn') }) @@ -116,7 +189,9 @@ describe('ConsentService', () => { }) it('is false when a required purpose has never been addressed', async () => { - mockFindMany.mockResolvedValue([{ purpose: 'terms_of_service', status: 'granted' }]) + mockFindMany.mockResolvedValue([ + { purpose: 'terms_of_service', status: 'granted' }, + ]) expect(await service.hasAllRequiredGranted('user1')).toBe(false) }) diff --git a/tests/contract/api-conventions.test.ts b/tests/contract/api-conventions.test.ts index 62fabc2b..91b2e57d 100644 --- a/tests/contract/api-conventions.test.ts +++ b/tests/contract/api-conventions.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { Request, Response, NextFunction } from 'express' -import { - ErrorCode, - SortOrder, -} from '../../src/types/api.types' +import { ErrorCode, SortOrder } from '../../src/types/api.types' import { pagePaginationSchema, cursorPaginationSchema, @@ -144,7 +141,7 @@ describe('API Conventions & Contract Standard Suite', () => { const cursorEnvelope = createCursorPaginatedEnvelope( [{ logId: 'log-1' }], - cursorMeta + cursorMeta, ) expect(cursorEnvelope.success).toBe(true) expect(cursorEnvelope.meta.hasMore).toBe(true) @@ -180,11 +177,9 @@ describe('API Conventions & Contract Standard Suite', () => { describe('3. Serialization Rules (ISO Dates, Asset Amounts, UUIDs)', () => { it('validates ISO 8601 UTC date strings', () => { expect(isoDateSchema.safeParse('2026-07-25T14:00:00.000Z').success).toBe( - true - ) - expect(isoDateSchema.safeParse('2026-07-25 14:00:00').success).toBe( - false + true, ) + expect(isoDateSchema.safeParse('2026-07-25 14:00:00').success).toBe(false) }) it('validates exact financial and token asset amounts', () => { @@ -218,7 +213,7 @@ describe('API Conventions & Contract Standard Suite', () => { error, mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(statusMock).toHaveBeenCalledWith(400) @@ -241,7 +236,7 @@ describe('API Conventions & Contract Standard Suite', () => { error, mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(statusMock).toHaveBeenCalledWith(404) @@ -257,7 +252,7 @@ describe('API Conventions & Contract Standard Suite', () => { error, mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(statusMock).toHaveBeenCalledWith(422) @@ -288,11 +283,7 @@ describe('API Conventions & Contract Standard Suite', () => { mockRequest.body = { email: 'invalid-date' } - middleware( - mockRequest as Request, - mockResponse as Response, - mockNext - ) + middleware(mockRequest as Request, mockResponse as Response, mockNext) expect(statusMock).toHaveBeenCalledWith(400) const res = jsonMock.mock.calls[0][0] @@ -307,7 +298,7 @@ describe('API Conventions & Contract Standard Suite', () => { apiVersionHeader( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(setHeaderMock).toHaveBeenCalledWith('X-API-Version', 'v1') @@ -323,11 +314,11 @@ describe('API Conventions & Contract Standard Suite', () => { expect(setHeaderMock).toHaveBeenCalledWith('Deprecation', 'true') expect(setHeaderMock).toHaveBeenCalledWith( 'Sunset', - 'Sun, 31 Dec 2026 23:59:59 GMT' + 'Sun, 31 Dec 2026 23:59:59 GMT', ) expect(setHeaderMock).toHaveBeenCalledWith( 'Link', - '; rel="sunset"' + '; rel="sunset"', ) }) @@ -336,16 +327,12 @@ describe('API Conventions & Contract Standard Suite', () => { sunsetDate: 'Sun, 31 Dec 2026 23:59:59 GMT', }) - middleware( - mockRequest as Request, - mockResponse as Response, - mockNext - ) + middleware(mockRequest as Request, mockResponse as Response, mockNext) expect(setHeaderMock).toHaveBeenCalledWith('Deprecation', 'true') expect(setHeaderMock).toHaveBeenCalledWith( 'Sunset', - 'Sun, 31 Dec 2026 23:59:59 GMT' + 'Sun, 31 Dec 2026 23:59:59 GMT', ) expect(mockNext).toHaveBeenCalled() }) diff --git a/tests/contract/openapi.test.ts b/tests/contract/openapi.test.ts index a33c0222..4046a32a 100644 --- a/tests/contract/openapi.test.ts +++ b/tests/contract/openapi.test.ts @@ -12,13 +12,15 @@ const spec = specs as unknown as Spec /** Every `$ref` string anywhere in the document. */ function collectRefs(node: unknown, found: string[] = []): string[] { if (Array.isArray(node)) { - node.forEach(child => collectRefs(child, found)) + node.forEach((child) => collectRefs(child, found)) return found } if (node && typeof node === 'object') { - for (const [key, value] of Object.entries(node as Record)) { + for (const [key, value] of Object.entries( + node as Record, + )) { if (key === '$ref' && typeof value === 'string') { found.push(value) } else { @@ -39,9 +41,9 @@ describe('OpenAPI document', () => { it('resolves every component $ref', () => { const dangling = [...new Set(collectRefs(spec))] - .filter(ref => ref.startsWith('#/components/schemas/')) - .map(ref => ref.replace('#/components/schemas/', '')) - .filter(name => !(name in spec.components.schemas)) + .filter((ref) => ref.startsWith('#/components/schemas/')) + .map((ref) => ref.replace('#/components/schemas/', '')) + .filter((name) => !(name in spec.components.schemas)) expect(dangling).toEqual([]) }) @@ -82,19 +84,28 @@ describe('OpenAPI document', () => { it('keeps private account fields out of the documented public profile', () => { const publicProfile = JSON.stringify(spec.components.schemas.PublicProfile) - for (const leak of ['email', 'password', 'walletAddress', 'isVerified', 'phoneVerifiedAt', 'status']) { + for (const leak of [ + 'email', + 'password', + 'walletAddress', + 'isVerified', + 'phoneVerifiedAt', + 'status', + ]) { expect(publicProfile).not.toContain(leak) } }) it('marks the public profile read as unauthenticated and the owner reads as bearer-authenticated', () => { - expect((spec.paths['/users/{id}'].get as { security: unknown[] }).security).toEqual([]) - expect((spec.paths['/users/me'].get as { security: unknown[] }).security).toEqual([ - { bearerAuth: [] }, - ]) - expect((spec.paths['/users/me'].patch as { security: unknown[] }).security).toEqual([ - { bearerAuth: [] }, - ]) + expect( + (spec.paths['/users/{id}'].get as { security: unknown[] }).security, + ).toEqual([]) + expect( + (spec.paths['/users/me'].get as { security: unknown[] }).security, + ).toEqual([{ bearerAuth: [] }]) + expect( + (spec.paths['/users/me'].patch as { security: unknown[] }).security, + ).toEqual([{ bearerAuth: [] }]) }) it('closes the profile update body so undocumented fields cannot be sent', () => { @@ -105,14 +116,25 @@ describe('OpenAPI document', () => { expect(updateInput.additionalProperties).toBe(false) - for (const forbidden of ['status', 'isVerified', 'role', 'userId', 'id', 'password', 'email']) { + for (const forbidden of [ + 'status', + 'isVerified', + 'role', + 'userId', + 'id', + 'password', + 'email', + ]) { expect(updateInput.properties).not.toHaveProperty(forbidden) } }) it('documents the conflict response on the wallet update', () => { - const responses = (spec.paths['/users/wallet'].patch as { responses: Record }) - .responses + const responses = ( + spec.paths['/users/wallet'].patch as { + responses: Record + } + ).responses expect(responses).toHaveProperty('409') expect(responses).toHaveProperty('401') diff --git a/tests/data-export.service.test.ts b/tests/data-export.service.test.ts index 4d70011e..065a5183 100644 --- a/tests/data-export.service.test.ts +++ b/tests/data-export.service.test.ts @@ -63,22 +63,47 @@ function mockUserData() { lastLoginAt: null, } as any) vi.mocked(prisma.completion.findMany).mockResolvedValue([ - { moduleId: 'm1', module: { title: 'Module One' }, score: 90, completedAt: new Date() }, + { + moduleId: 'm1', + module: { title: 'Module One' }, + score: 90, + completedAt: new Date(), + }, ] as any) vi.mocked(prisma.credential.findMany).mockResolvedValue([ - { moduleId: 'm1', module: { title: 'Module One' }, onChainId: 'chain-1', issuedAt: new Date() }, + { + moduleId: 'm1', + module: { title: 'Module One' }, + onChainId: 'chain-1', + issuedAt: new Date(), + }, ] as any) vi.mocked(prisma.transaction.findMany).mockResolvedValue([ - { id: 't1', amount: 10, type: 'reward', status: 'completed', createdAt: new Date() }, + { + id: 't1', + amount: 10, + type: 'reward', + status: 'completed', + createdAt: new Date(), + }, ] as any) - vi.mocked(prisma.referralCode.findFirst).mockResolvedValue({ code: 'REF1', createdAt: new Date() } as any) + vi.mocked(prisma.referralCode.findFirst).mockResolvedValue({ + code: 'REF1', + createdAt: new Date(), + } as any) vi.mocked(prisma.referral.findMany).mockResolvedValue([] as any) vi.mocked(prisma.referral.findFirst).mockResolvedValue(null) vi.mocked(prisma.syncEvent.findMany).mockResolvedValue([] as any) vi.mocked(prisma.notificationPreference.findFirst).mockResolvedValue(null) vi.mocked(prisma.notificationLog.findMany).mockResolvedValue([] as any) vi.mocked(prisma.session.findMany).mockResolvedValue([ - { userAgent: 'ua', ipAddress: '1.2.3.4', createdAt: new Date(), expiresAt: new Date(), isRevoked: false }, + { + userAgent: 'ua', + ipAddress: '1.2.3.4', + createdAt: new Date(), + expiresAt: new Date(), + isRevoked: false, + }, ] as any) vi.mocked(prisma.auditLog.findMany).mockResolvedValue([ { action: 'LOGIN', createdAt: new Date() }, @@ -92,31 +117,42 @@ describe('DataExportService', () => { vi.resetAllMocks() service = new DataExportService() vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any) - vi.mocked(emailService.queueEmail).mockResolvedValue({ id: 'email-1' } as any) + vi.mocked(emailService.queueEmail).mockResolvedValue({ + id: 'email-1', + } as any) }) describe('processQueue', () => { it('skips generation when another runner already claimed the row', async () => { - vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([pendingRow] as any) - vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 0 } as any) + vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([ + pendingRow, + ] as any) + vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ + count: 0, + } as any) await service.processQueue() expect(prisma.dataExportRequest.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 'exp-1', status: 'pending' } }) + expect.objectContaining({ where: { id: 'exp-1', status: 'pending' } }), ) expect(prisma.user.findUnique).not.toHaveBeenCalled() expect(prisma.dataExportRequest.update).not.toHaveBeenCalled() }) it('generates a redacted artifact and marks the request ready with an expiry', async () => { - vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([pendingRow] as any) - vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([ + pendingRow, + ] as any) + vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ + count: 1, + } as any) mockUserData() await service.processQueue() - const updateArg = vi.mocked(prisma.dataExportRequest.update).mock.calls[0][0] as any + const updateArg = vi.mocked(prisma.dataExportRequest.update).mock + .calls[0][0] as any expect(updateArg.data.status).toBe('ready') // Redaction: no credentials/secrets anywhere in the artifact @@ -133,7 +169,8 @@ describe('DataExportService', () => { expect(parsed.data.completions[0].moduleTitle).toBe('Module One') // Time-bounded: expiresAt ≈ now + EXPORT_TTL_DAYS (default 7) - const deltaDays = (updateArg.data.expiresAt.getTime() - Date.now()) / DAY_MS + const deltaDays = + (updateArg.data.expiresAt.getTime() - Date.now()) / DAY_MS expect(deltaDays).toBeGreaterThan(6.9) expect(deltaDays).toBeLessThan(7.1) @@ -142,18 +179,23 @@ describe('DataExportService', () => { 'test@example.com', expect.any(String), expect.any(String), - 'DATA_EXPORT' + 'DATA_EXPORT', ) }) it('backs off and returns the request to pending on failure', async () => { - vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([pendingRow] as any) - vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([ + pendingRow, + ] as any) + vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ + count: 1, + } as any) vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error('db down')) await service.processQueue() - const updateArg = vi.mocked(prisma.dataExportRequest.update).mock.calls[0][0] as any + const updateArg = vi.mocked(prisma.dataExportRequest.update).mock + .calls[0][0] as any expect(updateArg.data.status).toBe('pending') expect(updateArg.data.error).toBe('db down') expect(updateArg.data.nextAttemptAt).toBeInstanceOf(Date) @@ -164,12 +206,15 @@ describe('DataExportService', () => { vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([ { ...pendingRow, attemptCount: 4 }, ] as any) - vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ + count: 1, + } as any) vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error('db down')) await service.processQueue() - const updateArg = vi.mocked(prisma.dataExportRequest.update).mock.calls[0][0] as any + const updateArg = vi.mocked(prisma.dataExportRequest.update).mock + .calls[0][0] as any expect(updateArg.data.status).toBe('failed') }) @@ -186,7 +231,9 @@ describe('DataExportService', () => { describe('purgeExpired', () => { it('expires ready requests past their expiry and nulls the artifact', async () => { - vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ count: 2 } as any) + vi.mocked(prisma.dataExportRequest.updateMany).mockResolvedValue({ + count: 2, + } as any) const purged = await service.purgeExpired() @@ -202,8 +249,14 @@ describe('DataExportService', () => { it('treats a unique-index violation from a concurrent create as a duplicate', async () => { vi.mocked(prisma.dataExportRequest.findFirst) .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ id: 'exp-winner', userId: 'user-1', status: 'pending' } as any) - vi.mocked(prisma.dataExportRequest.create).mockRejectedValue({ code: 'P2002' }) + .mockResolvedValueOnce({ + id: 'exp-winner', + userId: 'user-1', + status: 'pending', + } as any) + vi.mocked(prisma.dataExportRequest.create).mockRejectedValue({ + code: 'P2002', + }) vi.mocked(prisma.dataExportRequest.findMany).mockResolvedValue([] as any) const result = await service.requestExport('user-1') diff --git a/tests/email.service.test.ts b/tests/email.service.test.ts index e4a27b04..c27f9126 100644 --- a/tests/email.service.test.ts +++ b/tests/email.service.test.ts @@ -4,193 +4,190 @@ import prisma from '../src/config/database' import logger from '../src/utils/logger' vi.mock('../src/config/database', () => ({ - default: { - emailDelivery: { - create: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, + default: { + emailDelivery: { + create: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), }, + }, })) vi.mock('../src/utils/logger', () => ({ - default: { - info: vi.fn(), - error: vi.fn(), - }, + default: { + info: vi.fn(), + error: vi.fn(), + }, })) describe('EmailService', () => { - let emailService: EmailService + let emailService: EmailService + + beforeEach(() => { + emailService = new EmailService() + vi.clearAllMocks() + }) + + describe('queueEmail', () => { + it('should create a pending email delivery record without draining the queue', async () => { + const mockDelivery = { + id: 'del1', + userId: 'user1', + to: 'test@example.com', + subject: 'Verify your email', + body: '...', + type: 'EMAIL_VERIFICATION', + status: 'pending', + nextAttemptAt: new Date(), + } + + ;(prisma.emailDelivery.create as any).mockResolvedValue(mockDelivery) + ;(prisma.emailDelivery.findMany as any).mockResolvedValue([]) + + const result = await emailService.queueEmail( + 'user1', + 'test@example.com', + 'Verify your email', + '...', + ) + + expect(prisma.emailDelivery.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 'user1', + to: 'test@example.com', + subject: 'Verify your email', + body: '...', + status: 'pending', + }), + }), + ) + expect(result).toEqual(mockDelivery) + expect(prisma.emailDelivery.findMany).not.toHaveBeenCalled() + }) + }) + + describe('processQueue', () => { + it('should process pending email deliveries', async () => { + const mockDelivery = { + id: 'del1', + userId: 'user1', + to: 'test@example.com', + subject: 'Verify', + body: '', + type: 'EMAIL_VERIFICATION', + status: 'pending', + attemptCount: 0, + maxAttempts: 5, + nextAttemptAt: new Date(), + lastAttemptAt: null, + sentAt: null, + error: null, + } + + ;(prisma.emailDelivery.findMany as any).mockResolvedValue([mockDelivery]) + ;(prisma.emailDelivery.update as any).mockResolvedValue({ + ...mockDelivery, + status: 'sent', + }) + + await emailService.processQueue() + + expect(prisma.emailDelivery.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'del1' }, + data: expect.objectContaining({ + attemptCount: { increment: 1 }, + lastAttemptAt: expect.any(Date), + }), + }), + ) + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Sending email'), + ) + }) - beforeEach(() => { - emailService = new EmailService() - vi.clearAllMocks() + it('should dead-letter after exhausting max attempts', async () => { + const mockDelivery = { + id: 'del1', + userId: 'user1', + to: 'test@example.com', + subject: 'Verify', + body: '', + type: 'EMAIL_VERIFICATION', + status: 'pending', + attemptCount: 4, + maxAttempts: 5, + nextAttemptAt: new Date(), + lastAttemptAt: null, + sentAt: null, + error: null, + } + + ;(prisma.emailDelivery.findMany as any).mockResolvedValue([mockDelivery]) + ;(prisma.emailDelivery.update as any) + .mockResolvedValueOnce(mockDelivery) // increment attemptCount + .mockRejectedValueOnce(new Error('Send failed')) // sent update fails + .mockResolvedValue({}) // handleFailure update + + await emailService.processQueue() + + // Should try to dead-letter after sent update fails + const updateCalls = (prisma.emailDelivery.update as any).mock.calls + const deadLetterCall = updateCalls.find( + (call: any) => call[0]?.data?.status === 'dead-letter', + ) + expect(deadLetterCall).toBeTruthy() + expect(deadLetterCall[0].data.error).toBe('Send failed') }) - describe('queueEmail', () => { - it('should create a pending email delivery record without draining the queue', async () => { - const mockDelivery = { - id: 'del1', - userId: 'user1', - to: 'test@example.com', - subject: 'Verify your email', - body: '...', - type: 'EMAIL_VERIFICATION', - status: 'pending', - nextAttemptAt: new Date(), - } - - ;(prisma.emailDelivery.create as any).mockResolvedValue(mockDelivery) - ;(prisma.emailDelivery.findMany as any).mockResolvedValue([]) - - const result = await emailService.queueEmail( - 'user1', - 'test@example.com', - 'Verify your email', - '...' - ) - - expect(prisma.emailDelivery.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - userId: 'user1', - to: 'test@example.com', - subject: 'Verify your email', - body: '...', - status: 'pending', - }), - }) - ) - expect(result).toEqual(mockDelivery) - expect(prisma.emailDelivery.findMany).not.toHaveBeenCalled() - }) + it('should apply exponential backoff on failure with retries remaining', async () => { + const mockDelivery = { + id: 'del1', + userId: 'user1', + to: 'test@example.com', + subject: 'Verify', + body: '', + type: 'EMAIL_VERIFICATION', + status: 'pending', + attemptCount: 1, + maxAttempts: 5, + nextAttemptAt: new Date(), + lastAttemptAt: null, + sentAt: null, + error: null, + } + + ;(prisma.emailDelivery.findMany as any).mockResolvedValue([mockDelivery]) + ;(prisma.emailDelivery.update as any) + .mockResolvedValueOnce(mockDelivery) // increment attemptCount + .mockRejectedValueOnce(new Error('Send failed')) // sent update fails + .mockResolvedValue({}) // handleFailure update + + await emailService.processQueue() + + const updateCalls = (prisma.emailDelivery.update as any).mock.calls + const backoffCall = updateCalls.find( + (call: any) => call[0]?.data?.nextAttemptAt, + ) + expect(backoffCall).toBeTruthy() + expect(backoffCall[0].data.error).toBe('Send failed') }) - describe('processQueue', () => { - it('should process pending email deliveries', async () => { - const mockDelivery = { - id: 'del1', - userId: 'user1', - to: 'test@example.com', - subject: 'Verify', - body: '', - type: 'EMAIL_VERIFICATION', - status: 'pending', - attemptCount: 0, - maxAttempts: 5, - nextAttemptAt: new Date(), - lastAttemptAt: null, - sentAt: null, - error: null, - } - - ;(prisma.emailDelivery.findMany as any).mockResolvedValue([ - mockDelivery, - ]) - ;(prisma.emailDelivery.update as any).mockResolvedValue({ ...mockDelivery, status: 'sent' }) - - await emailService.processQueue() - - expect(prisma.emailDelivery.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'del1' }, - data: expect.objectContaining({ - attemptCount: { increment: 1 }, - lastAttemptAt: expect.any(Date), - }), - }) - ) - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('Sending email') - ) - }) - - it('should dead-letter after exhausting max attempts', async () => { - const mockDelivery = { - id: 'del1', - userId: 'user1', - to: 'test@example.com', - subject: 'Verify', - body: '', - type: 'EMAIL_VERIFICATION', - status: 'pending', - attemptCount: 4, - maxAttempts: 5, - nextAttemptAt: new Date(), - lastAttemptAt: null, - sentAt: null, - error: null, - } - - ;(prisma.emailDelivery.findMany as any).mockResolvedValue([ - mockDelivery, - ]) - ;(prisma.emailDelivery.update as any) - .mockResolvedValueOnce(mockDelivery) // increment attemptCount - .mockRejectedValueOnce(new Error('Send failed')) // sent update fails - .mockResolvedValue({}) // handleFailure update - - await emailService.processQueue() - - // Should try to dead-letter after sent update fails - const updateCalls = (prisma.emailDelivery.update as any).mock.calls - const deadLetterCall = updateCalls.find( - (call: any) => call[0]?.data?.status === 'dead-letter' - ) - expect(deadLetterCall).toBeTruthy() - expect(deadLetterCall[0].data.error).toBe('Send failed') - }) - - it('should apply exponential backoff on failure with retries remaining', async () => { - const mockDelivery = { - id: 'del1', - userId: 'user1', - to: 'test@example.com', - subject: 'Verify', - body: '', - type: 'EMAIL_VERIFICATION', - status: 'pending', - attemptCount: 1, - maxAttempts: 5, - nextAttemptAt: new Date(), - lastAttemptAt: null, - sentAt: null, - error: null, - } - - ;(prisma.emailDelivery.findMany as any).mockResolvedValue([ - mockDelivery, - ]) - ;(prisma.emailDelivery.update as any) - .mockResolvedValueOnce(mockDelivery) // increment attemptCount - .mockRejectedValueOnce(new Error('Send failed')) // sent update fails - .mockResolvedValue({}) // handleFailure update - - await emailService.processQueue() - - const updateCalls = (prisma.emailDelivery.update as any).mock.calls - const backoffCall = updateCalls.find( - (call: any) => call[0]?.data?.nextAttemptAt - ) - expect(backoffCall).toBeTruthy() - expect(backoffCall[0].data.error).toBe('Send failed') - }) - - it('should only fetch deliveries that are due for retry', async () => { - ;(prisma.emailDelivery.findMany as any).mockResolvedValue([]) - - await emailService.processQueue() - - expect(prisma.emailDelivery.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - status: 'pending', - nextAttemptAt: { lte: expect.any(Date) }, - attemptCount: { lt: 5 }, - }), - }) - ) - }) + it('should only fetch deliveries that are due for retry', async () => { + ;(prisma.emailDelivery.findMany as any).mockResolvedValue([]) + + await emailService.processQueue() + + expect(prisma.emailDelivery.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'pending', + nextAttemptAt: { lte: expect.any(Date) }, + attemptCount: { lt: 5 }, + }), + }), + ) }) + }) }) diff --git a/tests/error.middleware.test.ts b/tests/error.middleware.test.ts index 129f113d..eb685577 100644 --- a/tests/error.middleware.test.ts +++ b/tests/error.middleware.test.ts @@ -234,7 +234,7 @@ describe('Error Handling Middleware', () => { message: 'Test error', path: '/api/test', method: 'GET', - }) + }), ) }) @@ -287,7 +287,7 @@ describe('Error Handling Middleware', () => { message: 'Not Found', path: '/api/test', method: 'GET', - }) + }), ) }) @@ -358,7 +358,7 @@ describe('Error Handling Middleware', () => { expect.objectContaining({ message: 'Async error caught', error: 'Database error', - }) + }), ) }) @@ -433,4 +433,4 @@ describe('Error Handling Middleware', () => { }) }) }) -}) \ No newline at end of file +}) diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index dd818dda..f38e9d28 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -43,9 +43,7 @@ export async function dropWorkerSchema( } } -export async function dropAllWorkerSchemas( - databaseUrl: string, -): Promise { +export async function dropAllWorkerSchemas(databaseUrl: string): Promise { const pool = new Pool({ connectionString: databaseUrl }) try { const result = await pool.query( @@ -81,11 +79,14 @@ export async function truncateAllTables( } export function applyMigrations(databaseUrl: string): void { - execSync('npx tsx node_modules/prisma/build/index.js db push --accept-data-loss', { - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: 'pipe', - cwd: process.cwd(), - }) + execSync( + 'npx tsx node_modules/prisma/build/index.js db push --accept-data-loss', + { + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + cwd: process.cwd(), + }, + ) } export function runMigrations(databaseUrl: string): void { diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index 368ec080..712ab84a 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -1,14 +1,16 @@ import type { PrismaClient } from '@prisma/client' -export function buildUser(overrides: Partial<{ - email: string - username: string - password: string - role: 'LEARNER' | 'ADMIN' | 'INSTRUCTOR' - isVerified: boolean - walletAddress: string | null - status: string -}> = {}): { +export function buildUser( + overrides: Partial<{ + email: string + username: string + password: string + role: 'LEARNER' | 'ADMIN' | 'INSTRUCTOR' + isVerified: boolean + walletAddress: string | null + status: string + }> = {}, +): { email: string username: string password: string @@ -16,11 +18,10 @@ export function buildUser(overrides: Partial<{ isVerified: boolean walletAddress: string | null status: string - } { - - const uniqueSuffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}` +} { + const uniqueSuffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - return { + return { email: `test_${uniqueSuffix}@example.com`, username: `testuser_${uniqueSuffix}`, password: '$2a$10$dummy_hash_for_testing_purposes_only', @@ -47,13 +48,15 @@ export async function createUser( return prisma.user.create({ data: buildUser(overrides) }) } -export function buildModule(overrides: Partial<{ - title: string - description: string - category: string - difficulty: string - reward: number -}> = {}) { +export function buildModule( + overrides: Partial<{ + title: string + description: string + category: string + difficulty: string + reward: number + }> = {}, +) { const uniqueSuffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}` return { diff --git a/tests/in-memory-storage.test.ts b/tests/in-memory-storage.test.ts index 76b347ad..634c14f4 100644 --- a/tests/in-memory-storage.test.ts +++ b/tests/in-memory-storage.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { InMemoryStorageProvider, sniffMimeType, extractImageDimensions } from '../src/services/storage/in-memory-storage' +import { + InMemoryStorageProvider, + sniffMimeType, + extractImageDimensions, +} from '../src/services/storage/in-memory-storage' describe('InMemoryStorageProvider', () => { let provider: InMemoryStorageProvider @@ -56,7 +60,9 @@ describe('InMemoryStorageProvider', () => { }) it('throws on missing key', async () => { - await expect(provider.readBytes('nonexistent')).rejects.toThrow('not found') + await expect(provider.readBytes('nonexistent')).rejects.toThrow( + 'not found', + ) }) }) @@ -100,7 +106,9 @@ describe('InMemoryStorageProvider', () => { describe('sniffMimeType', () => { it('detects PNG', () => { - const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]) + const buf = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, + ]) expect(sniffMimeType(buf)).toBe('image/png') }) @@ -134,8 +142,14 @@ describe('extractImageDimensions', () => { // Build minimal PNG with 300×200 dimensions const buf = Buffer.alloc(64) // PNG signature (8 bytes) + IHDR length (4) + IHDR type (4) + data starts at 16 - buf[0] = 0x89; buf[1] = 0x50; buf[2] = 0x4e; buf[3] = 0x47 - buf[4] = 0x0d; buf[5] = 0x0a; buf[6] = 0x1a; buf[7] = 0x0a + buf[0] = 0x89 + buf[1] = 0x50 + buf[2] = 0x4e + buf[3] = 0x47 + buf[4] = 0x0d + buf[5] = 0x0a + buf[6] = 0x1a + buf[7] = 0x0a buf.writeUInt32BE(300, 16) // width buf.writeUInt32BE(200, 20) // height const dims = extractImageDimensions(buf) @@ -144,11 +158,13 @@ describe('extractImageDimensions', () => { it('extracts JPEG dimensions from SOF marker', () => { const buf = Buffer.alloc(64) - buf[0] = 0xff; buf[1] = 0xd8 // SOI - buf[2] = 0xff; buf[3] = 0xc0 // SOF0 + buf[0] = 0xff + buf[1] = 0xd8 // SOI + buf[2] = 0xff + buf[3] = 0xc0 // SOF0 buf.writeUInt16BE(17, 4) // segment length - buf.writeUInt8(8, 6) // precision - buf.writeUInt16BE(50, 7) // height + buf.writeUInt8(8, 6) // precision + buf.writeUInt16BE(50, 7) // height buf.writeUInt16BE(100, 9) // width const dims = extractImageDimensions(buf) expect(dims).toEqual({ width: 100, height: 50 }) diff --git a/tests/integration/audit-immutability.test.ts b/tests/integration/audit-immutability.test.ts index 099b1242..72f0af4d 100644 --- a/tests/integration/audit-immutability.test.ts +++ b/tests/integration/audit-immutability.test.ts @@ -22,7 +22,7 @@ const MIGRATION = join( 'prisma', 'migrations', '20260824090000_auditable_data_lifecycle', - 'migration.sql' + 'migration.sql', ) let pool: Pool | undefined @@ -40,11 +40,15 @@ let available = false async function applyImmutabilityDdl(db: Pool): Promise { const sql = readFileSync(MIGRATION, 'utf8') - const start = sql.indexOf('CREATE OR REPLACE FUNCTION "audit_events_reject_mutation"') + const start = sql.indexOf( + 'CREATE OR REPLACE FUNCTION "audit_events_reject_mutation"', + ) const end = sql.indexOf('-- ARCHIVE COLUMNS') if (start === -1 || end === -1 || end <= start) { - throw new Error('Could not locate the immutability DDL in the migration file') + throw new Error( + 'Could not locate the immutability DDL in the migration file', + ) } await db.query(sql.slice(start, end)) @@ -57,7 +61,7 @@ async function insertEvent(db: Pool, action = 'test.event'): Promise { `INSERT INTO "audit_events" ("id", "actorType", "action", "recordClass", "targetType", "targetId") VALUES ($1, 'SYSTEM', $2, 'IMMUTABLE', 'User', $3)`, - [id, action, randomUUID()] + [id, action, randomUUID()], ) return id @@ -67,7 +71,10 @@ beforeAll(async () => { const connectionString = process.env.DATABASE_URL if (!connectionString) return - const candidate = new Pool({ connectionString, connectionTimeoutMillis: 3000 }) + const candidate = new Pool({ + connectionString, + connectionTimeoutMillis: 3000, + }) try { await candidate.query('SELECT 1 FROM "audit_events" LIMIT 1') @@ -84,145 +91,165 @@ afterAll(async () => { // The purge setting is the only sanctioned way to remove rows, so cleanup has // to use it too — which is itself a small confirmation that it works. - await pool.query(`SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'`).catch(() => undefined) + await pool + .query(`SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'`) + .catch(() => undefined) await pool .query( `BEGIN; SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'; - DELETE FROM "audit_events" WHERE "action" LIKE 'test.%'; COMMIT;` + DELETE FROM "audit_events" WHERE "action" LIKE 'test.%'; COMMIT;`, ) .catch(() => undefined) await pool.end().catch(() => undefined) }) -describe.skipIf(!process.env.DATABASE_URL)('audit_events immutability (database)', () => { - it('accepts an append', async () => { - if (!available) return expect(available).toBe(false) - - const id = await insertEvent(pool!) - const { rows } = await pool!.query('SELECT "id" FROM "audit_events" WHERE "id" = $1', [id]) - - expect(rows).toHaveLength(1) - }) - - it('rejects an UPDATE', async () => { - if (!available) return expect(available).toBe(false) - - const id = await insertEvent(pool!) +describe.skipIf(!process.env.DATABASE_URL)( + 'audit_events immutability (database)', + () => { + it('accepts an append', async () => { + if (!available) return expect(available).toBe(false) - await expect( - pool!.query('UPDATE "audit_events" SET "reason" = $1 WHERE "id" = $2', ['tampered', id]) - ).rejects.toThrow(/immutable/i) - }) + const id = await insertEvent(pool!) + const { rows } = await pool!.query( + 'SELECT "id" FROM "audit_events" WHERE "id" = $1', + [id], + ) - it('rejects an UPDATE even inside the purge escape hatch', async () => { - if (!available) return expect(available).toBe(false) + expect(rows).toHaveLength(1) + }) - const id = await insertEvent(pool!) - const client = await pool!.connect() + it('rejects an UPDATE', async () => { + if (!available) return expect(available).toBe(false) - try { - await client.query('BEGIN') - await client.query(`SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'`) + const id = await insertEvent(pool!) - // The escape hatch exists for the retention purge only. It must not - // become a way to edit history. await expect( - client.query('UPDATE "audit_events" SET "reason" = $1 WHERE "id" = $2', ['x', id]) + pool!.query('UPDATE "audit_events" SET "reason" = $1 WHERE "id" = $2', [ + 'tampered', + id, + ]), ).rejects.toThrow(/immutable/i) - } finally { - await client.query('ROLLBACK').catch(() => undefined) - client.release() - } - }) + }) - it('rejects a DELETE without the purge setting', async () => { - if (!available) return expect(available).toBe(false) + it('rejects an UPDATE even inside the purge escape hatch', async () => { + if (!available) return expect(available).toBe(false) - const id = await insertEvent(pool!) + const id = await insertEvent(pool!) + const client = await pool!.connect() - await expect( - pool!.query('DELETE FROM "audit_events" WHERE "id" = $1', [id]) - ).rejects.toThrow(/retention purge/i) - }) + try { + await client.query('BEGIN') + await client.query(`SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'`) - it('rejects a TRUNCATE, which bypasses row-level triggers', async () => { - if (!available) return expect(available).toBe(false) + // The escape hatch exists for the retention purge only. It must not + // become a way to edit history. + await expect( + client.query( + 'UPDATE "audit_events" SET "reason" = $1 WHERE "id" = $2', + ['x', id], + ), + ).rejects.toThrow(/immutable/i) + } finally { + await client.query('ROLLBACK').catch(() => undefined) + client.release() + } + }) - await expect(pool!.query('TRUNCATE TABLE "audit_events"')).rejects.toThrow( - /may not be truncated/i - ) - }) - - it('allows a DELETE when the retention purge sets the session variable', async () => { - if (!available) return expect(available).toBe(false) + it('rejects a DELETE without the purge setting', async () => { + if (!available) return expect(available).toBe(false) - const id = await insertEvent(pool!) - const client = await pool!.connect() + const id = await insertEvent(pool!) - try { - await client.query('BEGIN') - await client.query(`SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'`) - const result = await client.query('DELETE FROM "audit_events" WHERE "id" = $1', [id]) - await client.query('COMMIT') - - expect(result.rowCount).toBe(1) - } finally { - await client.query('ROLLBACK').catch(() => undefined) - client.release() - } - }) - - it('confines the purge setting to its own transaction', async () => { - if (!available) return expect(available).toBe(false) - - const id = await insertEvent(pool!) - const client = await pool!.connect() + await expect( + pool!.query('DELETE FROM "audit_events" WHERE "id" = $1', [id]), + ).rejects.toThrow(/retention purge/i) + }) - try { - // SET LOCAL, not SET: the permission must not leak to later statements on - // a pooled connection that some unrelated request picks up next. - await client.query('BEGIN') - await client.query(`SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'`) - await client.query('COMMIT') + it('rejects a TRUNCATE, which bypasses row-level triggers', async () => { + if (!available) return expect(available).toBe(false) await expect( - client.query('DELETE FROM "audit_events" WHERE "id" = $1', [id]) - ).rejects.toThrow(/retention purge/i) - } finally { - client.release() - } - }) -}) + pool!.query('TRUNCATE TABLE "audit_events"'), + ).rejects.toThrow(/may not be truncated/i) + }) + + it('allows a DELETE when the retention purge sets the session variable', async () => { + if (!available) return expect(available).toBe(false) + + const id = await insertEvent(pool!) + const client = await pool!.connect() + + try { + await client.query('BEGIN') + await client.query(`SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'`) + const result = await client.query( + 'DELETE FROM "audit_events" WHERE "id" = $1', + [id], + ) + await client.query('COMMIT') + + expect(result.rowCount).toBe(1) + } finally { + await client.query('ROLLBACK').catch(() => undefined) + client.release() + } + }) + + it('confines the purge setting to its own transaction', async () => { + if (!available) return expect(available).toBe(false) + + const id = await insertEvent(pool!) + const client = await pool!.connect() + + try { + // SET LOCAL, not SET: the permission must not leak to later statements on + // a pooled connection that some unrelated request picks up next. + await client.query('BEGIN') + await client.query(`SET LOCAL "${AUDIT_PURGE_SETTING}" = 'on'`) + await client.query('COMMIT') + + await expect( + client.query('DELETE FROM "audit_events" WHERE "id" = $1', [id]), + ).rejects.toThrow(/retention purge/i) + } finally { + client.release() + } + }) + }, +) -describe.skipIf(!process.env.DATABASE_URL)('archive constraints (database)', () => { - it('rejects an archived row with no reason', async () => { - if (!available) return expect(available).toBe(false) +describe.skipIf(!process.env.DATABASE_URL)( + 'archive constraints (database)', + () => { + it('rejects an archived row with no reason', async () => { + if (!available) return expect(available).toBe(false) - // The check constraint is what makes "archive behaviour is deterministic" - // true for writers that skip the helper in src/audit/audited-mutation.ts. - await expect( - pool!.query( - `INSERT INTO "Module" + // The check constraint is what makes "archive behaviour is deterministic" + // true for writers that skip the helper in src/audit/audited-mutation.ts. + await expect( + pool!.query( + `INSERT INTO "Module" ("id", "title", "description", "category", "difficulty", "archivedAt", "updatedAt") VALUES ($1, 't', 'd', 'c', 'easy', now(), now())`, - [randomUUID()] - ) - ).rejects.toThrow(/archive_reason_check/i) - }) + [randomUUID()], + ), + ).rejects.toThrow(/archive_reason_check/i) + }) - it('accepts an archived row that states a reason', async () => { - if (!available) return expect(available).toBe(false) + it('accepts an archived row that states a reason', async () => { + if (!available) return expect(available).toBe(false) - const id = randomUUID() + const id = randomUUID() - await pool!.query( - `INSERT INTO "Module" + await pool!.query( + `INSERT INTO "Module" ("id", "title", "description", "category", "difficulty", "archivedAt", "archivedReason", "updatedAt") VALUES ($1, 't', 'd', 'c', 'easy', now(), 'superseded', now())`, - [id] - ) + [id], + ) - await pool!.query('DELETE FROM "Module" WHERE "id" = $1', [id]) - }) -}) + await pool!.query('DELETE FROM "Module" WHERE "id" = $1', [id]) + }) + }, +) diff --git a/tests/integration/cleanup.test.ts b/tests/integration/cleanup.test.ts index e24d2c6f..13426144 100644 --- a/tests/integration/cleanup.test.ts +++ b/tests/integration/cleanup.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect } from 'vitest' -import { - getWorkerSchemaName, - buildWorkerDatabaseUrl, -} from '../helpers/db' +import { getWorkerSchemaName, buildWorkerDatabaseUrl } from '../helpers/db' describe('Database cleanup utilities', () => { describe('getWorkerSchemaName', () => { diff --git a/tests/integration/guard.test.ts b/tests/integration/guard.test.ts index 027c1f6a..9301218d 100644 --- a/tests/integration/guard.test.ts +++ b/tests/integration/guard.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect } from 'vitest' -import { - validateTestDatabaseUrl, - UnsafeDatabaseError, -} from '../helpers/guard' +import { validateTestDatabaseUrl, UnsafeDatabaseError } from '../helpers/guard' describe('Database safety guard', () => { it('rejects production database URLs', () => { @@ -42,7 +39,9 @@ describe('Database safety guard', () => { it('throws UnsafeDatabaseError with descriptive message', () => { try { - validateTestDatabaseUrl('postgresql://user:pass@prod.example.com:5432/mydb') + validateTestDatabaseUrl( + 'postgresql://user:pass@prod.example.com:5432/mydb', + ) expect.fail('Should have thrown') } catch (err) { expect(err).toBeInstanceOf(UnsafeDatabaseError) diff --git a/tests/integration/isolation.test.ts b/tests/integration/isolation.test.ts index e0b1494b..cbf1919c 100644 --- a/tests/integration/isolation.test.ts +++ b/tests/integration/isolation.test.ts @@ -6,7 +6,11 @@ import { Pool } from 'pg' async function isDatabaseAvailable(): Promise { try { const url = validateTestDatabaseUrl() - const pool = new Pool({ connectionString: url, max: 1, connectionTimeoutMillis: 3000 }) + const pool = new Pool({ + connectionString: url, + max: 1, + connectionTimeoutMillis: 3000, + }) const client = await pool.connect() client.release() await pool.end() @@ -62,11 +66,7 @@ describe.runIf(dbAvailable)('Database isolation', () => { }) describe('Isolation helpers', () => { - it('withIsolation rolls back transaction', () => { + it('withIsolation rolls back transaction', () => {}) - }) - - it('createIsolatedTest wraps test function', () => { - - }) + it('createIsolatedTest wraps test function', () => {}) }) diff --git a/tests/integration/profile-api.test.ts b/tests/integration/profile-api.test.ts index cb8bb6c3..9f33f39f 100644 --- a/tests/integration/profile-api.test.ts +++ b/tests/integration/profile-api.test.ts @@ -1,4 +1,12 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest' +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, + vi, +} from 'vitest' import { Pool } from 'pg' import request from 'supertest' import { validateTestDatabaseUrl } from '../helpers/guard' @@ -17,7 +25,11 @@ import { validateTestDatabaseUrl } from '../helpers/guard' async function isDatabaseAvailable(): Promise { try { const url = validateTestDatabaseUrl() - const pool = new Pool({ connectionString: url, max: 1, connectionTimeoutMillis: 3000 }) + const pool = new Pool({ + connectionString: url, + max: 1, + connectionTimeoutMillis: 3000, + }) const client = await pool.connect() client.release() await pool.end() @@ -70,7 +82,11 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { }, HOOK_TIMEOUT_MS) async function createLearner( - overrides: Partial<{ status: string; password: string; walletAddress: string | null }> = {} + overrides: Partial<{ + status: string + password: string + walletAddress: string | null + }> = {}, ) { uniqueSuffix += 1 const plaintext = overrides.password ?? 'Str0ng!Pass' @@ -107,7 +123,10 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { }) it('rejects PATCH /users/me without a token', async () => { - await request(app).patch('/api/v1/users/me').send({ displayName: 'Ada' }).expect(401) + await request(app) + .patch('/api/v1/users/me') + .send({ displayName: 'Ada' }) + .expect(401) }) it('rejects an invalid token', async () => { @@ -124,7 +143,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { it('serves GET /users/{id} to an anonymous caller', async () => { const { user } = await createLearner() - await prisma.learnerProfile.create({ data: { userId: user.id, visibility: 'public' } }) + await prisma.learnerProfile.create({ + data: { userId: user.id, visibility: 'public' }, + }) await request(app).get(`/api/v1/users/${user.id}`).expect(200) }) @@ -136,7 +157,10 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { it('returns real persisted data, not a fixture', async () => { const { user, auth } = await createLearner() - const response = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + const response = await request(app) + .get('/api/v1/users/me') + .set('Authorization', auth) + .expect(200) expect(response.body.data.account.id).toBe(user.id) expect(response.body.data.account.email).toBe(user.email) @@ -147,7 +171,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { it('never returns the password hash', async () => { const { auth } = await createLearner() - const response = await request(app).get('/api/v1/users/me').set('Authorization', auth) + const response = await request(app) + .get('/api/v1/users/me') + .set('Authorization', auth) expect(JSON.stringify(response.body)).not.toContain('$2') expect(response.body.data.account).not.toHaveProperty('password') @@ -156,40 +182,74 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { it('returns only the documented profile fields, not the archive bookkeeping', async () => { const { auth } = await createLearner() - const response = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + const response = await request(app) + .get('/api/v1/users/me') + .set('Authorization', auth) + .expect(200) expect(Object.keys(response.body.data.profile).sort()).toEqual([ - 'avatarUrl', 'bio', 'country', 'createdAt', 'displayName', 'goals', 'id', - 'interests', 'languages', 'level', 'timezone', 'updatedAt', 'userId', 'visibility', + 'avatarUrl', + 'bio', + 'country', + 'createdAt', + 'displayName', + 'goals', + 'id', + 'interests', + 'languages', + 'level', + 'timezone', + 'updatedAt', + 'userId', + 'visibility', ]) }) it('creates the profile row on first access and reports 0% completion', async () => { const { user, auth } = await createLearner() - const response = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + const response = await request(app) + .get('/api/v1/users/me') + .set('Authorization', auth) + .expect(200) expect(response.body.data.completion.percent).toBe(0) - expect(response.body.data.completion.missingFields).toContain('displayName') - expect(await prisma.learnerProfile.findUnique({ where: { userId: user.id } })).not.toBeNull() + expect(response.body.data.completion.missingFields).toContain( + 'displayName', + ) + expect( + await prisma.learnerProfile.findUnique({ where: { userId: user.id } }), + ).not.toBeNull() }) it('returns onboarding state and outstanding required steps', async () => { const { user, auth } = await createLearner() await prisma.onboardingProgress.create({ - data: { userId: user.id, currentStep: 'profile_basics', completedSteps: ['profile_basics'] }, + data: { + userId: user.id, + currentStep: 'profile_basics', + completedSteps: ['profile_basics'], + }, }) - const response = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + const response = await request(app) + .get('/api/v1/users/me') + .set('Authorization', auth) + .expect(200) expect(response.body.data.onboarding.status).toBe('in_progress') - expect(response.body.data.onboarding.requiredStepsRemaining).toEqual(['consent']) + expect(response.body.data.onboarding.requiredStepsRemaining).toEqual([ + 'consent', + ]) }) it('reports null onboarding for a learner who never started', async () => { const { auth } = await createLearner() - const response = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + const response = await request(app) + .get('/api/v1/users/me') + .set('Authorization', auth) + .expect(200) expect(response.body.data.onboarding).toBeNull() }) @@ -209,7 +269,10 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { }, }) - const partial = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + const partial = await request(app) + .get('/api/v1/users/me') + .set('Authorization', auth) + .expect(200) expect(partial.body.data.requiredConsentsGranted).toBe(false) await prisma.consentRecord.create({ @@ -224,7 +287,10 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { }, }) - const complete = await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(200) + const complete = await request(app) + .get('/api/v1/users/me') + .set('Authorization', auth) + .expect(200) expect(complete.body.data.requiredConsentsGranted).toBe(true) expect(complete.body.data.consents).toHaveLength(2) }) @@ -233,14 +299,23 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { const { user, auth } = await createLearner() await prisma.user.delete({ where: { id: user.id } }) - await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(404) + await request(app) + .get('/api/v1/users/me') + .set('Authorization', auth) + .expect(404) }) it('returns 404 for a tombstoned account', async () => { const { user, auth } = await createLearner() - await prisma.user.update({ where: { id: user.id }, data: { status: 'DELETED' } }) + await prisma.user.update({ + where: { id: user.id }, + data: { status: 'DELETED' }, + }) - await request(app).get('/api/v1/users/me').set('Authorization', auth).expect(404) + await request(app) + .get('/api/v1/users/me') + .set('Authorization', auth) + .expect(404) }) }) @@ -262,7 +337,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { .send({ bio: 'Building on Stellar' }) .expect(200) - const stored = await prisma.learnerProfile.findUnique({ where: { userId: user.id } }) + const stored = await prisma.learnerProfile.findUnique({ + where: { userId: user.id }, + }) expect(stored?.displayName).toBe('Ada Lovelace') expect(stored?.country).toBe('NG') @@ -275,11 +352,18 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { const response = await request(app) .patch('/api/v1/users/me') .set('Authorization', auth) - .send({ displayName: 'Ada', bio: 'hi', country: 'NG', timezone: 'Africa/Lagos' }) + .send({ + displayName: 'Ada', + bio: 'hi', + country: 'NG', + timezone: 'Africa/Lagos', + }) .expect(200) expect(response.body.data.completion.percent).toBe(50) - expect(response.body.data.completion.missingFields).not.toContain('displayName') + expect(response.body.data.completion.missingFields).not.toContain( + 'displayName', + ) }) it.each([ @@ -294,15 +378,25 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { const { user, auth } = await createLearner() const before = await prisma.user.findUnique({ where: { id: user.id } }) - await request(app).patch('/api/v1/users/me').set('Authorization', auth).send(body).expect(400) + await request(app) + .patch('/api/v1/users/me') + .set('Authorization', auth) + .send(body) + .expect(400) - expect(await prisma.user.findUnique({ where: { id: user.id } })).toEqual(before) + expect(await prisma.user.findUnique({ where: { id: user.id } })).toEqual( + before, + ) }) it('rejects an empty body', async () => { const { auth } = await createLearner() - await request(app).patch('/api/v1/users/me').set('Authorization', auth).send({}).expect(400) + await request(app) + .patch('/api/v1/users/me') + .set('Authorization', auth) + .send({}) + .expect(400) }) it('rejects an out-of-range value without writing a partial update', async () => { @@ -314,7 +408,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { .send({ displayName: 'Ada', level: 'wizard' }) .expect(400) - expect(await prisma.learnerProfile.findUnique({ where: { userId: user.id } })).toBeNull() + expect( + await prisma.learnerProfile.findUnique({ where: { userId: user.id } }), + ).toBeNull() }) it('cannot touch another learner’s profile', async () => { @@ -347,8 +443,13 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { .send({ displayName: 'Ada Lovelace', bio: 'private medical history' }) .expect(200) - const profile = await prisma.learnerProfile.findUnique({ where: { userId: user.id } }) - const events = await auditEventsFor(profile!.id, 'learner_profile.updated') + const profile = await prisma.learnerProfile.findUnique({ + where: { userId: user.id }, + }) + const events = await auditEventsFor( + profile!.id, + 'learner_profile.updated', + ) expect(events).toHaveLength(1) expect(events[0].actorType).toBe('USER') @@ -382,7 +483,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { }) it('returns 404 for an unknown learner', async () => { - await request(app).get('/api/v1/users/00000000-0000-4000-8000-000000000000').expect(404) + await request(app) + .get('/api/v1/users/00000000-0000-4000-8000-000000000000') + .expect(404) }) it('serves the public subset for a public profile', async () => { @@ -401,9 +504,15 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { }, }) - const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + const response = await request(app) + .get(`/api/v1/users/${user.id}`) + .expect(200) - expect(response.body.data).toMatchObject({ visible: true, displayName: 'Ada', country: 'NG' }) + expect(response.body.data).toMatchObject({ + visible: true, + displayName: 'Ada', + country: 'NG', + }) expect(response.body.data).not.toHaveProperty('goals') expect(response.body.data).not.toHaveProperty('timezone') expect(response.body.data).not.toHaveProperty('languages') @@ -415,7 +524,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { data: { userId: user.id, displayName: 'Ada', visibility: 'public' }, }) - const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + const response = await request(app) + .get(`/api/v1/users/${user.id}`) + .expect(200) const body = JSON.stringify(response.body) expect(body).not.toContain(user.email) @@ -434,7 +545,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { data: { userId: user.id, displayName: 'Ada', visibility: 'private' }, }) - const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + const response = await request(app) + .get(`/api/v1/users/${user.id}`) + .expect(200) expect(response.body.data).toEqual({ id: profile.id, visible: false }) }) @@ -445,7 +558,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { data: { userId: user.id, displayName: 'Ada', visibility: 'employer' }, }) - const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + const response = await request(app) + .get(`/api/v1/users/${user.id}`) + .expect(200) expect(response.body.data.visible).toBe(false) expect(response.body.data).not.toHaveProperty('displayName') @@ -468,7 +583,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { }, }) - const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + const response = await request(app) + .get(`/api/v1/users/${user.id}`) + .expect(200) expect(response.body.data.visible).toBe(false) }) @@ -490,24 +607,28 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { }, }) - const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + const response = await request(app) + .get(`/api/v1/users/${user.id}`) + .expect(200) expect(response.body.data.visible).toBe(true) }) it.each(['DEACTIVATED', 'PENDING_DELETION'])( 'redacts a public profile for a %s account', - async status => { + async (status) => { const { user } = await createLearner() await prisma.learnerProfile.create({ data: { userId: user.id, displayName: 'Ada', visibility: 'public' }, }) await prisma.user.update({ where: { id: user.id }, data: { status } }) - const response = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + const response = await request(app) + .get(`/api/v1/users/${user.id}`) + .expect(200) expect(response.body.data.visible).toBe(false) - } + }, ) it('excludes an archived profile entirely', async () => { @@ -535,7 +656,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { .get(`/api/v1/users/${user.id}`) .set('Authorization', auth) .expect(200) - const anonymous = await request(app).get(`/api/v1/users/${user.id}`).expect(200) + const anonymous = await request(app) + .get(`/api/v1/users/${user.id}`) + .expect(200) expect(asOwner.body).toEqual(anonymous.body) expect(asOwner.body.data.visible).toBe(false) @@ -612,9 +735,14 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { expect(after?.password).not.toBe(before?.password) expect(after?.password).not.toBe('Another1!Pass') - expect((await prisma.session.findUnique({ where: { id: session.id } }))?.isRevoked).toBe(true) expect( - await prisma.refreshToken.count({ where: { sessionId: session.id, status: 'REVOKED' } }) + (await prisma.session.findUnique({ where: { id: session.id } })) + ?.isRevoked, + ).toBe(true) + expect( + await prisma.refreshToken.count({ + where: { sessionId: session.id, status: 'REVOKED' }, + }), ).toBe(1) }) @@ -669,14 +797,19 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { .send({ walletAddress: PUBLIC_KEY_A }) .expect(200) - expect((await prisma.user.findUnique({ where: { id: user.id } }))?.walletAddress).toBe( - PUBLIC_KEY_A - ) - expect(await auditEventsFor(user.id, 'user.wallet_address_changed')).toHaveLength(1) + expect( + (await prisma.user.findUnique({ where: { id: user.id } })) + ?.walletAddress, + ).toBe(PUBLIC_KEY_A) + expect( + await auditEventsFor(user.id, 'user.wallet_address_changed'), + ).toHaveLength(1) }) it('is idempotent and writes no second audit event', async () => { - const { user, auth } = await createLearner({ walletAddress: PUBLIC_KEY_A }) + const { user, auth } = await createLearner({ + walletAddress: PUBLIC_KEY_A, + }) const response = await request(app) .patch('/api/v1/users/wallet') @@ -685,7 +818,9 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { .expect(200) expect(response.body.message).toBe('Wallet address unchanged') - expect(await auditEventsFor(user.id, 'user.wallet_address_changed')).toHaveLength(0) + expect( + await auditEventsFor(user.id, 'user.wallet_address_changed'), + ).toHaveLength(0) }) it('returns 409 when the address is already claimed by another account', async () => { @@ -699,12 +834,18 @@ describe.runIf(dbAvailable)('Identity and profile API', () => { .expect(409) expect(response.body.code).toBe('WALLET_ADDRESS_TAKEN') - expect((await prisma.user.findUnique({ where: { id: user.id } }))?.walletAddress).toBeNull() + expect( + (await prisma.user.findUnique({ where: { id: user.id } })) + ?.walletAddress, + ).toBeNull() }) it('returns 404 for a tombstoned account', async () => { const { user, auth } = await createLearner() - await prisma.user.update({ where: { id: user.id }, data: { status: 'DELETED' } }) + await prisma.user.update({ + where: { id: user.id }, + data: { status: 'DELETED' }, + }) await request(app) .patch('/api/v1/users/wallet') diff --git a/tests/lib/handler-registry.test.ts b/tests/lib/handler-registry.test.ts index b9405263..a427caf1 100644 --- a/tests/lib/handler-registry.test.ts +++ b/tests/lib/handler-registry.test.ts @@ -11,7 +11,7 @@ import type { OutboxEventHandler } from '../../src/lib/transactions/types' function handler( name: string, eventType = 'UserCreated', - eventVersion = 1 + eventVersion = 1, ): OutboxEventHandler { return { name, eventType, eventVersion, handle: async () => undefined } } @@ -22,8 +22,16 @@ describe('OutboxHandlerRegistry', () => { beforeEach(() => { schemas = new EventSchemaRegistry() - schemas.register({ eventType: 'UserCreated', version: 1, validate: () => undefined }) - schemas.register({ eventType: 'WalletProvisioned', version: 1, validate: () => undefined }) + schemas.register({ + eventType: 'UserCreated', + version: 1, + validate: () => undefined, + }) + schemas.register({ + eventType: 'WalletProvisioned', + version: 1, + validate: () => undefined, + }) registry = new OutboxHandlerRegistry(schemas) }) @@ -40,7 +48,10 @@ describe('OutboxHandlerRegistry', () => { registry.register(handler('a')) registry.register(handler('b')) - expect(registry.handlersFor('UserCreated', 1).map(h => h.name)).toEqual(['a', 'b']) + expect(registry.handlersFor('UserCreated', 1).map((h) => h.name)).toEqual([ + 'a', + 'b', + ]) expect(registry.registeredNames()).toEqual(['a', 'b']) }) @@ -48,19 +59,19 @@ describe('OutboxHandlerRegistry', () => { registry.register(handler('a')) expect(() => registry.register(handler('a', 'WalletProvisioned'))).toThrow( - DuplicateHandlerError + DuplicateHandlerError, ) }) it('rejects a handler for an event type with no registered schema', () => { expect(() => registry.register(handler('a', 'NeverDeclared'))).toThrow( - UnknownEventTypeError + UnknownEventTypeError, ) }) it('rejects a handler for a version the schema registry does not know', () => { expect(() => registry.register(handler('a', 'UserCreated', 7))).toThrow( - UnknownEventTypeError + UnknownEventTypeError, ) }) @@ -71,17 +82,21 @@ describe('OutboxHandlerRegistry', () => { registry.assertHandlersFor([ { eventType: 'UserCreated', eventVersion: 1 }, { eventType: 'WalletProvisioned', eventVersion: 1 }, - ]) + ]), ).toThrow(UnhandledEventTypeError) expect(() => - registry.assertHandlersFor([{ eventType: 'UserCreated', eventVersion: 1 }]) + registry.assertHandlersFor([ + { eventType: 'UserCreated', eventVersion: 1 }, + ]), ).not.toThrow() }) it('names the missing event types in the startup error', () => { expect(() => - registry.assertHandlersFor([{ eventType: 'WalletProvisioned', eventVersion: 1 }]) + registry.assertHandlersFor([ + { eventType: 'WalletProvisioned', eventVersion: 1 }, + ]), ).toThrow(/WalletProvisioned:v1/) }) diff --git a/tests/lib/job-lease-queue.test.ts b/tests/lib/job-lease-queue.test.ts index 60f90207..6fff0ce9 100644 --- a/tests/lib/job-lease-queue.test.ts +++ b/tests/lib/job-lease-queue.test.ts @@ -15,7 +15,10 @@ describe('JobLeaseService queue leases', () => { beforeEach(() => { queryRaw = vi.fn() executeRaw = vi.fn() - service = new JobLeaseService({ $queryRaw: queryRaw, $executeRaw: executeRaw } as any) + service = new JobLeaseService({ + $queryRaw: queryRaw, + $executeRaw: executeRaw, + } as any) }) describe('acquireQueueLease', () => { @@ -71,10 +74,14 @@ describe('JobLeaseService queue leases', () => { describe('renewQueueLease', () => { it('reports success only when the row still carries this token', async () => { executeRaw.mockResolvedValueOnce(1) - await expect(service.renewQueueLease('email', 'token-a', 5_000)).resolves.toBe(true) + await expect( + service.renewQueueLease('email', 'token-a', 5_000), + ).resolves.toBe(true) executeRaw.mockResolvedValueOnce(0) - await expect(service.renewQueueLease('email', 'stale-token')).resolves.toBe(false) + await expect( + service.renewQueueLease('email', 'stale-token'), + ).resolves.toBe(false) const values = executeRaw.mock.calls[0].slice(1) expect(values).toContain('email') @@ -86,7 +93,9 @@ describe('JobLeaseService queue leases', () => { it('clears the lease scoped to the holding token', async () => { executeRaw.mockResolvedValue(1) - await expect(service.releaseQueueLease('data-export', 'token-a')).resolves.toBe(true) + await expect( + service.releaseQueueLease('data-export', 'token-a'), + ).resolves.toBe(true) const sql = sqlOf(executeRaw.mock.calls[0]) expect(sql).toContain('"leaseToken" = NULL') @@ -100,7 +109,9 @@ describe('JobLeaseService queue leases', () => { it('does not release a lease a successor now holds', async () => { executeRaw.mockResolvedValue(0) - await expect(service.releaseQueueLease('data-export', 'stale')).resolves.toBe(false) + await expect( + service.releaseQueueLease('data-export', 'stale'), + ).resolves.toBe(false) }) }) }) diff --git a/tests/mock-user-scan.test.ts b/tests/mock-user-scan.test.ts index 4cad3df6..3d5973a7 100644 --- a/tests/mock-user-scan.test.ts +++ b/tests/mock-user-scan.test.ts @@ -35,7 +35,7 @@ function source(file: string): string { } describe('mock user helper scan', () => { - it.each(USER_ROUTE_SOURCES)('%s declares no mock user literal', file => { + it.each(USER_ROUTE_SOURCES)('%s declares no mock user literal', (file) => { // `mockUser`, `const mock…= {`, and the sentinel values the old helpers // returned. Comments naming the removed mocks are fine; a literal is not. expect(source(file)).not.toMatch(/\bmockUser\b/) @@ -44,16 +44,19 @@ describe('mock user helper scan', () => { expect(source(file)).not.toMatch(/GABC123456789/) }) - it.each(USER_ROUTE_SOURCES)('%s contains no not-implemented stub', file => { + it.each(USER_ROUTE_SOURCES)('%s contains no not-implemented stub', (file) => { expect(source(file)).not.toMatch(/Not implemented/i) expect(source(file)).not.toMatch(/throw new Error\(\s*['"`]TODO/i) }) - it.each(REMOVED_MOCK_HELPERS)('the %s helper is gone from the user controller', helper => { - expect(source('src/controllers/user.controller.ts')).not.toMatch( - new RegExp(`(private|async)\\s+${helper}\\s*\\(`) - ) - }) + it.each(REMOVED_MOCK_HELPERS)( + 'the %s helper is gone from the user controller', + (helper) => { + expect(source('src/controllers/user.controller.ts')).not.toMatch( + new RegExp(`(private|async)\\s+${helper}\\s*\\(`), + ) + }, + ) it('reads users through Prisma-backed services rather than in-controller literals', () => { const controller = source('src/controllers/user.controller.ts') @@ -78,19 +81,25 @@ describe('mock user helper scan', () => { // which is a persisted column. It stays exported for its existing tests but // must not be mounted — so the check is on the import, not on a prose // mention of the name in a comment explaining why it is absent. - const routeFiles = ['src/routes/v1/users.routes.ts', 'src/routes/v1/avatar.routes.ts'] + const routeFiles = [ + 'src/routes/v1/users.routes.ts', + 'src/routes/v1/avatar.routes.ts', + ] for (const file of routeFiles) { const codeLines = source(file) .split('\n') - .filter(line => !line.trim().startsWith('//')) + .filter((line) => !line.trim().startsWith('//')) expect(codeLines.join('\n')).not.toMatch(/validateProfileUpdate/) } }) it('never selects the password column into a profile or account read', () => { - const services = ['src/services/profile.service.ts', 'src/services/profile-serializer.ts'] + const services = [ + 'src/services/profile.service.ts', + 'src/services/profile-serializer.ts', + ] for (const file of services) { expect(source(file)).not.toMatch(/password:\s*true/) diff --git a/tests/notification.controller.test.ts b/tests/notification.controller.test.ts index dcf84640..a18656a9 100644 --- a/tests/notification.controller.test.ts +++ b/tests/notification.controller.test.ts @@ -7,7 +7,7 @@ const { mockUpdateUserPreferences, mockQueueNotification, mockProcessQueue, - mockPrisma + mockPrisma, } = vi.hoisted(() => ({ mockRegisterDeviceToken: vi.fn(), mockUpdateUserPreferences: vi.fn(), @@ -15,9 +15,9 @@ const { mockProcessQueue: vi.fn(), mockPrisma: { notificationLog: { - findMany: vi.fn() - } - } + findMany: vi.fn(), + }, + }, })) vi.mock('../src/services/notification.service', () => ({ @@ -26,11 +26,11 @@ vi.mock('../src/services/notification.service', () => ({ updateUserPreferences = mockUpdateUserPreferences queueNotification = mockQueueNotification processQueue = mockProcessQueue - } + }, })) vi.mock('../src/config/database', () => ({ - default: mockPrisma + default: mockPrisma, })) describe('NotificationController', () => { @@ -44,11 +44,11 @@ describe('NotificationController', () => { req = { user: { id: 'user1' }, body: {}, - query: {} + query: {}, } res = { status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis() + json: vi.fn().mockReturnThis(), } }) @@ -60,7 +60,9 @@ describe('NotificationController', () => { await controller.registerDevice(req, res) expect(res.status).toHaveBeenCalledWith(201) - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: { id: 'dt1' } })) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ data: { id: 'dt1' } }), + ) }) it('should return 400 on invalid body', async () => { @@ -98,7 +100,9 @@ describe('NotificationController', () => { await controller.getDeliveryStatus(req, res) expect(res.status).toHaveBeenCalledWith(200) - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ count: 1 })) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ count: 1 }), + ) }) }) }) diff --git a/tests/notification.service.test.ts b/tests/notification.service.test.ts index a11f2da3..040f91c7 100644 --- a/tests/notification.service.test.ts +++ b/tests/notification.service.test.ts @@ -3,7 +3,9 @@ import { NotificationService } from '../src/services/notification.service' // Use vi.hoisted to mock dependencies before they are imported by the service const { mockSendEachForMulticast, mockAdmin } = vi.hoisted(() => { - const mockSendEachForMulticast = vi.fn().mockResolvedValue({ failureCount: 0, responses: [] }) + const mockSendEachForMulticast = vi + .fn() + .mockResolvedValue({ failureCount: 0, responses: [] }) return { mockSendEachForMulticast, @@ -11,12 +13,12 @@ const { mockSendEachForMulticast, mockAdmin } = vi.hoisted(() => { apps: [{ name: 'mock-app' }], initializeApp: vi.fn(), credential: { - cert: vi.fn().mockReturnValue({}) + cert: vi.fn().mockReturnValue({}), }, messaging: vi.fn().mockReturnValue({ - sendEachForMulticast: mockSendEachForMulticast - }) - } + sendEachForMulticast: mockSendEachForMulticast, + }), + }, } }) @@ -26,22 +28,22 @@ vi.mock('firebase-admin', () => ({ ...mockAdmin, default: mockAdmin })) const { mockPrisma } = vi.hoisted(() => ({ mockPrisma: { deviceToken: { - upsert: vi.fn() + upsert: vi.fn(), }, notificationPreference: { upsert: vi.fn(), - findUnique: vi.fn() + findUnique: vi.fn(), }, notificationLog: { create: vi.fn(), findMany: vi.fn(), - update: vi.fn() - } - } + update: vi.fn(), + }, + }, })) vi.mock('../src/config/database', () => ({ - default: mockPrisma + default: mockPrisma, })) describe('NotificationService', () => { @@ -50,7 +52,9 @@ describe('NotificationService', () => { beforeEach(() => { vi.clearAllMocks() service = new NotificationService() - process.env.FIREBASE_SERVICE_ACCOUNT_KEY = JSON.stringify({ project_id: 'test' }) + process.env.FIREBASE_SERVICE_ACCOUNT_KEY = JSON.stringify({ + project_id: 'test', + }) }) afterEach(() => { @@ -63,7 +67,7 @@ describe('NotificationService', () => { expect(mockPrisma.deviceToken.upsert).toHaveBeenCalledWith({ where: { token: 'token1' }, update: { userId: 'user1', platform: 'ios' }, - create: { userId: 'user1', token: 'token1', platform: 'ios' } + create: { userId: 'user1', token: 'token1', platform: 'ios' }, }) }) }) @@ -74,26 +78,40 @@ describe('NotificationService', () => { expect(mockPrisma.notificationPreference.upsert).toHaveBeenCalledWith({ where: { userId: 'user1' }, update: { rewardReceipt: true }, - create: { userId: 'user1', rewardReceipt: true } + create: { userId: 'user1', rewardReceipt: true }, }) }) }) describe('queueNotification', () => { it('should create a pending log if enabled', async () => { - mockPrisma.notificationPreference.findUnique.mockResolvedValue({ rewardReceipt: true }) + mockPrisma.notificationPreference.findUnique.mockResolvedValue({ + rewardReceipt: true, + }) mockPrisma.notificationLog.create.mockResolvedValue({ id: 'log1' }) - const result = await service.queueNotification('user1', 'rewardReceipt', 'Title', 'Body') + const result = await service.queueNotification( + 'user1', + 'rewardReceipt', + 'Title', + 'Body', + ) expect(mockPrisma.notificationLog.create).toHaveBeenCalled() expect(result).toBeDefined() }) it('should return null if disabled', async () => { - mockPrisma.notificationPreference.findUnique.mockResolvedValue({ rewardReceipt: false }) + mockPrisma.notificationPreference.findUnique.mockResolvedValue({ + rewardReceipt: false, + }) - const result = await service.queueNotification('user1', 'rewardReceipt', 'Title', 'Body') + const result = await service.queueNotification( + 'user1', + 'rewardReceipt', + 'Title', + 'Body', + ) expect(mockPrisma.notificationLog.create).not.toHaveBeenCalled() expect(result).toBeNull() @@ -106,7 +124,7 @@ describe('NotificationService', () => { id: 'log1', title: 'T', body: 'B', - user: { deviceTokens: [{ token: 't1' }] } + user: { deviceTokens: [{ token: 't1' }] }, } mockPrisma.notificationLog.findMany.mockResolvedValue([mockLog]) @@ -116,8 +134,8 @@ describe('NotificationService', () => { expect(mockPrisma.notificationLog.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'log1' }, - data: { status: 'success' } - }) + data: { status: 'success' }, + }), ) }) @@ -126,7 +144,7 @@ describe('NotificationService', () => { id: 'log1', title: 'T', body: 'B', - user: { deviceTokens: [] } + user: { deviceTokens: [] }, } mockPrisma.notificationLog.findMany.mockResolvedValue([mockLog]) @@ -134,8 +152,8 @@ describe('NotificationService', () => { expect(mockPrisma.notificationLog.update).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ status: 'failed' }) - }) + data: expect.objectContaining({ status: 'failed' }), + }), ) }) }) diff --git a/tests/onboarding.controller.test.ts b/tests/onboarding.controller.test.ts index 4d63bfe6..a72cb999 100644 --- a/tests/onboarding.controller.test.ts +++ b/tests/onboarding.controller.test.ts @@ -84,7 +84,10 @@ describe('OnboardingController', () => { it('saves a valid step', async () => { req.body = { step: 'profile_basics' } - mockSaveStep.mockResolvedValue({ kind: 'saved', progress: { currentStep: 'profile_basics' } }) + mockSaveStep.mockResolvedValue({ + kind: 'saved', + progress: { currentStep: 'profile_basics' }, + }) await controller.saveStep(req, res) @@ -94,7 +97,10 @@ describe('OnboardingController', () => { it('returns 409 when onboarding is already completed', async () => { req.body = { step: 'preferences' } - mockSaveStep.mockResolvedValue({ kind: 'already-completed', progress: { status: 'completed' } }) + mockSaveStep.mockResolvedValue({ + kind: 'already-completed', + progress: { status: 'completed' }, + }) await controller.saveStep(req, res) @@ -121,7 +127,10 @@ describe('OnboardingController', () => { }) it('returns 409 when required steps are missing', async () => { - mockComplete.mockResolvedValue({ kind: 'incomplete-steps', missingSteps: ['consent'] }) + mockComplete.mockResolvedValue({ + kind: 'incomplete-steps', + missingSteps: ['consent'], + }) await controller.complete(req, res) @@ -137,7 +146,10 @@ describe('OnboardingController', () => { }) it('returns 200 on successful completion', async () => { - mockComplete.mockResolvedValue({ kind: 'completed', progress: { status: 'completed' } }) + mockComplete.mockResolvedValue({ + kind: 'completed', + progress: { status: 'completed' }, + }) await controller.complete(req, res) diff --git a/tests/onboarding.service.test.ts b/tests/onboarding.service.test.ts index b347c9ae..526fb88b 100644 --- a/tests/onboarding.service.test.ts +++ b/tests/onboarding.service.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { OnboardingService } from '../src/services/onboarding.service' -const { mockUpsert, mockFindUnique, mockUpdate, mockHasAllRequiredGranted } = vi.hoisted(() => ({ - mockUpsert: vi.fn(), - mockFindUnique: vi.fn(), - mockUpdate: vi.fn(), - mockHasAllRequiredGranted: vi.fn(), -})) +const { mockUpsert, mockFindUnique, mockUpdate, mockHasAllRequiredGranted } = + vi.hoisted(() => ({ + mockUpsert: vi.fn(), + mockFindUnique: vi.fn(), + mockUpdate: vi.fn(), + mockHasAllRequiredGranted: vi.fn(), + })) vi.mock('../src/config/database', () => ({ default: { @@ -54,7 +55,12 @@ describe('OnboardingService', () => { expect(mockUpsert).toHaveBeenCalledWith({ where: { userId: 'user1' }, update: {}, - create: { userId: 'user1', version: 'v1', currentStep: 'profile_basics', completedSteps: [] }, + create: { + userId: 'user1', + version: 'v1', + currentStep: 'profile_basics', + completedSteps: [], + }, }) expect(result.status).toBe('in_progress') }) @@ -63,38 +69,68 @@ describe('OnboardingService', () => { describe('saveStep', () => { it('creates a new row on first save', async () => { mockFindUnique.mockResolvedValue(null) - mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics'] }) + mockUpsert.mockResolvedValue({ + ...baseProgress, + completedSteps: ['profile_basics'], + }) const result = await service.saveStep('user1', 'profile_basics') expect(mockUpsert).toHaveBeenCalledWith({ where: { userId: 'user1' }, - update: { currentStep: 'profile_basics', completedSteps: ['profile_basics'] }, - create: { userId: 'user1', version: 'v1', currentStep: 'profile_basics', completedSteps: ['profile_basics'] }, + update: { + currentStep: 'profile_basics', + completedSteps: ['profile_basics'], + }, + create: { + userId: 'user1', + version: 'v1', + currentStep: 'profile_basics', + completedSteps: ['profile_basics'], + }, }) expect(result.kind).toBe('saved') }) it('does not duplicate a step that is saved twice (idempotent)', async () => { - mockFindUnique.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics'] }) + mockFindUnique.mockResolvedValue({ + ...baseProgress, + completedSteps: ['profile_basics'], + }) mockUpsert.mockResolvedValue(baseProgress) await service.saveStep('user1', 'profile_basics') - expect(mockUpsert).toHaveBeenCalledWith(expect.objectContaining({ - update: { currentStep: 'profile_basics', completedSteps: ['profile_basics'] }, - })) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: { + currentStep: 'profile_basics', + completedSteps: ['profile_basics'], + }, + }), + ) }) it('appends a new step onto existing progress', async () => { - mockFindUnique.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics'] }) - mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics', 'consent'] }) + mockFindUnique.mockResolvedValue({ + ...baseProgress, + completedSteps: ['profile_basics'], + }) + mockUpsert.mockResolvedValue({ + ...baseProgress, + completedSteps: ['profile_basics', 'consent'], + }) await service.saveStep('user1', 'consent') - expect(mockUpsert).toHaveBeenCalledWith(expect.objectContaining({ - update: { currentStep: 'consent', completedSteps: ['profile_basics', 'consent'] }, - })) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: { + currentStep: 'consent', + completedSteps: ['profile_basics', 'consent'], + }, + }), + ) }) it('refuses to modify a completed onboarding record', async () => { @@ -118,16 +154,25 @@ describe('OnboardingService', () => { }) it('blocks completion when required steps are missing', async () => { - mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics'] }) + mockUpsert.mockResolvedValue({ + ...baseProgress, + completedSteps: ['profile_basics'], + }) const result = await service.complete('user1') - expect(result).toEqual({ kind: 'incomplete-steps', missingSteps: ['consent'] }) + expect(result).toEqual({ + kind: 'incomplete-steps', + missingSteps: ['consent'], + }) expect(mockHasAllRequiredGranted).not.toHaveBeenCalled() }) it('blocks completion when required consent is missing', async () => { - mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics', 'consent'] }) + mockUpsert.mockResolvedValue({ + ...baseProgress, + completedSteps: ['profile_basics', 'consent'], + }) mockHasAllRequiredGranted.mockResolvedValue(false) const result = await service.complete('user1') @@ -137,9 +182,16 @@ describe('OnboardingService', () => { }) it('completes when all required steps and consents are satisfied', async () => { - mockUpsert.mockResolvedValue({ ...baseProgress, completedSteps: ['profile_basics', 'consent'] }) + mockUpsert.mockResolvedValue({ + ...baseProgress, + completedSteps: ['profile_basics', 'consent'], + }) mockHasAllRequiredGranted.mockResolvedValue(true) - mockUpdate.mockResolvedValue({ ...baseProgress, status: 'completed', completedAt: new Date() }) + mockUpdate.mockResolvedValue({ + ...baseProgress, + status: 'completed', + completedAt: new Date(), + }) const result = await service.complete('user1') diff --git a/tests/otp.service.test.ts b/tests/otp.service.test.ts index 0e9a477e..aff734a0 100644 --- a/tests/otp.service.test.ts +++ b/tests/otp.service.test.ts @@ -4,209 +4,255 @@ import prisma from '../src/config/database' import { getSmsProvider } from '../src/services/sms/sms-provider.factory' vi.mock('../src/config/database', () => ({ - default: { - otpChallenge: { - updateMany: vi.fn(), - create: vi.fn(), - findFirst: vi.fn(), - update: vi.fn(), - }, + default: { + otpChallenge: { + updateMany: vi.fn(), + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), }, + }, })) vi.mock('../src/services/sms/sms-provider.factory', () => ({ - getSmsProvider: vi.fn(), + getSmsProvider: vi.fn(), })) vi.mock('../src/utils/logger', () => ({ - default: { - info: vi.fn(), - error: vi.fn(), - }, + default: { + info: vi.fn(), + error: vi.fn(), + }, })) describe('normalizePhone', () => { - it('accepts E.164 phone numbers', () => { - expect(normalizePhone('+2348012345678')).toBe('+2348012345678') - expect(normalizePhone(' +14155552671 ')).toBe('+14155552671') + it('accepts E.164 phone numbers', () => { + expect(normalizePhone('+2348012345678')).toBe('+2348012345678') + expect(normalizePhone(' +14155552671 ')).toBe('+14155552671') + }) + + it('rejects numbers without a leading +', () => { + expect(normalizePhone('2348012345678')).toBeNull() + }) + + it('rejects numbers that are too short or too long', () => { + expect(normalizePhone('+1234567')).toBeNull() + expect(normalizePhone('+1234567890123456')).toBeNull() + }) + + it('rejects non-numeric input', () => { + expect(normalizePhone('+abc4567890')).toBeNull() + }) +}) + +describe('OtpService', () => { + let otpService: OtpService + let mockSend: ReturnType + + beforeEach(() => { + otpService = new OtpService() + mockSend = vi + .fn() + .mockResolvedValue({ success: true, providerMessageId: 'mock_1' }) + ;(getSmsProvider as any).mockReturnValue({ send: mockSend }) + vi.clearAllMocks() + mockSend.mockResolvedValue({ success: true, providerMessageId: 'mock_1' }) + ;(getSmsProvider as any).mockReturnValue({ send: mockSend }) + }) + + describe('requestChallenge', () => { + it('revokes prior pending challenges and creates a fresh one', async () => { + ;(prisma.otpChallenge.updateMany as any).mockResolvedValue({ count: 1 }) + ;(prisma.otpChallenge.create as any).mockResolvedValue({ id: 'c1' }) + + await otpService.requestChallenge('+2348012345678', 'LOGIN', 'user1', { + ip: '1.2.3.4', + }) + + expect(prisma.otpChallenge.updateMany).toHaveBeenCalledWith({ + where: { userId: 'user1', purpose: 'LOGIN', status: 'PENDING' }, + data: { status: 'REVOKED' }, + }) + expect(prisma.otpChallenge.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 'user1', + phone: '+2348012345678', + purpose: 'LOGIN', + requestIp: '1.2.3.4', + }), + }), + ) + expect(mockSend).toHaveBeenCalledWith( + '+2348012345678', + expect.stringContaining('expires in 5 minutes'), + ) + }) + + it('never sends the raw code hash unhashed to the database', async () => { + ;(prisma.otpChallenge.updateMany as any).mockResolvedValue({ count: 0 }) + ;(prisma.otpChallenge.create as any).mockResolvedValue({ id: 'c1' }) + + await otpService.requestChallenge( + '+2348012345678', + 'PHONE_VERIFICATION', + 'user1', + {}, + ) + + const createArgs = (prisma.otpChallenge.create as any).mock.calls[0][0] + expect(createArgs.data.codeHash).toMatch(/^[0-9a-f]{64}$/) + + const smsBody = mockSend.mock.calls[0][1] as string + const codeInSms = smsBody.match(/code is (\d{6})/)?.[1] + expect(codeInSms).toBeTruthy() + expect(createArgs.data.codeHash).not.toBe(codeInSms) }) + }) + + describe('verifyChallenge', () => { + function buildChallenge(overrides: Partial = {}) { + return { + id: 'c1', + userId: 'user1', + phone: '+2348012345678', + purpose: 'LOGIN', + codeHash: 'deadbeef', + status: 'PENDING', + attempts: 0, + maxAttempts: 5, + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + ...overrides, + } + } + + it('returns not_found when no pending challenge exists', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue(null) + + const result = await otpService.verifyChallenge( + '+2348012345678', + '123456', + 'LOGIN', + ) + + expect(result).toEqual({ ok: false, reason: 'not_found' }) + }) + + it('returns not_found when the challenge belongs to a different user (authenticated flow)', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ userId: 'someone-else' }), + ) - it('rejects numbers without a leading +', () => { - expect(normalizePhone('2348012345678')).toBeNull() + const result = await otpService.verifyChallenge( + '+2348012345678', + '123456', + 'PHONE_VERIFICATION', + 'user1', + ) + + expect(result).toEqual({ ok: false, reason: 'not_found' }) }) - it('rejects numbers that are too short or too long', () => { - expect(normalizePhone('+1234567')).toBeNull() - expect(normalizePhone('+1234567890123456')).toBeNull() + it('marks the challenge expired and returns expired', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ expiresAt: new Date(Date.now() - 1000) }), + ) + ;(prisma.otpChallenge.update as any).mockResolvedValue({}) + + const result = await otpService.verifyChallenge( + '+2348012345678', + '123456', + 'LOGIN', + ) + + expect(result).toEqual({ ok: false, reason: 'expired' }) + expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { status: 'EXPIRED' }, + }) }) - it('rejects non-numeric input', () => { - expect(normalizePhone('+abc4567890')).toBeNull() + it('locks a challenge that already exhausted its attempts', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ attempts: 5, maxAttempts: 5 }), + ) + ;(prisma.otpChallenge.update as any).mockResolvedValue({}) + + const result = await otpService.verifyChallenge( + '+2348012345678', + '123456', + 'LOGIN', + ) + + expect(result).toEqual({ ok: false, reason: 'locked' }) + expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { status: 'LOCKED' }, + }) }) -}) -describe('OtpService', () => { - let otpService: OtpService - let mockSend: ReturnType - - beforeEach(() => { - otpService = new OtpService() - mockSend = vi.fn().mockResolvedValue({ success: true, providerMessageId: 'mock_1' }) - ;(getSmsProvider as any).mockReturnValue({ send: mockSend }) - vi.clearAllMocks() - mockSend.mockResolvedValue({ success: true, providerMessageId: 'mock_1' }) - ;(getSmsProvider as any).mockReturnValue({ send: mockSend }) + it('increments attempts on a wrong code without locking below the threshold', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ attempts: 1, maxAttempts: 5 }), + ) + ;(prisma.otpChallenge.update as any).mockResolvedValue({}) + + const result = await otpService.verifyChallenge( + '+2348012345678', + '000000', + 'LOGIN', + ) + + expect(result).toEqual({ ok: false, reason: 'mismatch' }) + expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { attempts: 2 }, + }) }) - describe('requestChallenge', () => { - it('revokes prior pending challenges and creates a fresh one', async () => { - ;(prisma.otpChallenge.updateMany as any).mockResolvedValue({ count: 1 }) - ;(prisma.otpChallenge.create as any).mockResolvedValue({ id: 'c1' }) - - await otpService.requestChallenge('+2348012345678', 'LOGIN', 'user1', { ip: '1.2.3.4' }) - - expect(prisma.otpChallenge.updateMany).toHaveBeenCalledWith({ - where: { userId: 'user1', purpose: 'LOGIN', status: 'PENDING' }, - data: { status: 'REVOKED' }, - }) - expect(prisma.otpChallenge.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - userId: 'user1', - phone: '+2348012345678', - purpose: 'LOGIN', - requestIp: '1.2.3.4', - }), - }) - ) - expect(mockSend).toHaveBeenCalledWith('+2348012345678', expect.stringContaining('expires in 5 minutes')) - }) - - it('never sends the raw code hash unhashed to the database', async () => { - ;(prisma.otpChallenge.updateMany as any).mockResolvedValue({ count: 0 }) - ;(prisma.otpChallenge.create as any).mockResolvedValue({ id: 'c1' }) - - await otpService.requestChallenge('+2348012345678', 'PHONE_VERIFICATION', 'user1', {}) - - const createArgs = (prisma.otpChallenge.create as any).mock.calls[0][0] - expect(createArgs.data.codeHash).toMatch(/^[0-9a-f]{64}$/) - - const smsBody = mockSend.mock.calls[0][1] as string - const codeInSms = smsBody.match(/code is (\d{6})/)?.[1] - expect(codeInSms).toBeTruthy() - expect(createArgs.data.codeHash).not.toBe(codeInSms) - }) + it('locks out on the attempt that reaches maxAttempts', async () => { + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ attempts: 4, maxAttempts: 5 }), + ) + ;(prisma.otpChallenge.update as any).mockResolvedValue({}) + + const result = await otpService.verifyChallenge( + '+2348012345678', + '000000', + 'LOGIN', + ) + + expect(result).toEqual({ ok: false, reason: 'locked' }) + expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { attempts: 5, status: 'LOCKED' }, + }) }) - describe('verifyChallenge', () => { - function buildChallenge (overrides: Partial = {}) { - return { - id: 'c1', - userId: 'user1', - phone: '+2348012345678', - purpose: 'LOGIN', - codeHash: 'deadbeef', - status: 'PENDING', - attempts: 0, - maxAttempts: 5, - expiresAt: new Date(Date.now() + 60_000), - createdAt: new Date(), - ...overrides, - } - } - - it('returns not_found when no pending challenge exists', async () => { - ;(prisma.otpChallenge.findFirst as any).mockResolvedValue(null) - - const result = await otpService.verifyChallenge('+2348012345678', '123456', 'LOGIN') - - expect(result).toEqual({ ok: false, reason: 'not_found' }) - }) - - it('returns not_found when the challenge belongs to a different user (authenticated flow)', async () => { - ;(prisma.otpChallenge.findFirst as any).mockResolvedValue(buildChallenge({ userId: 'someone-else' })) - - const result = await otpService.verifyChallenge('+2348012345678', '123456', 'PHONE_VERIFICATION', 'user1') - - expect(result).toEqual({ ok: false, reason: 'not_found' }) - }) - - it('marks the challenge expired and returns expired', async () => { - ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( - buildChallenge({ expiresAt: new Date(Date.now() - 1000) }) - ) - ;(prisma.otpChallenge.update as any).mockResolvedValue({}) - - const result = await otpService.verifyChallenge('+2348012345678', '123456', 'LOGIN') - - expect(result).toEqual({ ok: false, reason: 'expired' }) - expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ - where: { id: 'c1' }, - data: { status: 'EXPIRED' }, - }) - }) - - it('locks a challenge that already exhausted its attempts', async () => { - ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( - buildChallenge({ attempts: 5, maxAttempts: 5 }) - ) - ;(prisma.otpChallenge.update as any).mockResolvedValue({}) - - const result = await otpService.verifyChallenge('+2348012345678', '123456', 'LOGIN') - - expect(result).toEqual({ ok: false, reason: 'locked' }) - expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ - where: { id: 'c1' }, - data: { status: 'LOCKED' }, - }) - }) - - it('increments attempts on a wrong code without locking below the threshold', async () => { - ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( - buildChallenge({ attempts: 1, maxAttempts: 5 }) - ) - ;(prisma.otpChallenge.update as any).mockResolvedValue({}) - - const result = await otpService.verifyChallenge('+2348012345678', '000000', 'LOGIN') - - expect(result).toEqual({ ok: false, reason: 'mismatch' }) - expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ - where: { id: 'c1' }, - data: { attempts: 2 }, - }) - }) - - it('locks out on the attempt that reaches maxAttempts', async () => { - ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( - buildChallenge({ attempts: 4, maxAttempts: 5 }) - ) - ;(prisma.otpChallenge.update as any).mockResolvedValue({}) - - const result = await otpService.verifyChallenge('+2348012345678', '000000', 'LOGIN') - - expect(result).toEqual({ ok: false, reason: 'locked' }) - expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ - where: { id: 'c1' }, - data: { attempts: 5, status: 'LOCKED' }, - }) - }) - - it('consumes the challenge and returns ok on a correct code', async () => { - const crypto = await import('crypto') - const correctHash = crypto.createHash('sha256').update('+2348012345678:123456').digest('hex') - - ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( - buildChallenge({ codeHash: correctHash }) - ) - ;(prisma.otpChallenge.update as any).mockResolvedValue({}) - - const result = await otpService.verifyChallenge('+2348012345678', '123456', 'LOGIN') - - expect(result).toEqual({ ok: true, userId: 'user1' }) - expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ - where: { id: 'c1' }, - data: { status: 'CONSUMED', consumedAt: expect.any(Date) }, - }) - }) + it('consumes the challenge and returns ok on a correct code', async () => { + const crypto = await import('crypto') + const correctHash = crypto + .createHash('sha256') + .update('+2348012345678:123456') + .digest('hex') + + ;(prisma.otpChallenge.findFirst as any).mockResolvedValue( + buildChallenge({ codeHash: correctHash }), + ) + ;(prisma.otpChallenge.update as any).mockResolvedValue({}) + + const result = await otpService.verifyChallenge( + '+2348012345678', + '123456', + 'LOGIN', + ) + + expect(result).toEqual({ ok: true, userId: 'user1' }) + expect(prisma.otpChallenge.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { status: 'CONSUMED', consumedAt: expect.any(Date) }, + }) }) + }) }) diff --git a/tests/preference.controller.test.ts b/tests/preference.controller.test.ts index 64ce7c6f..98c74a68 100644 --- a/tests/preference.controller.test.ts +++ b/tests/preference.controller.test.ts @@ -43,7 +43,9 @@ describe('PreferenceController', () => { await controller.getPreferences(req, res) expect(res.status).toHaveBeenCalledWith(200) - expect(res.json).toHaveBeenCalledWith({ data: { userId: 'user1', locale: 'en-US' } }) + expect(res.json).toHaveBeenCalledWith({ + data: { userId: 'user1', locale: 'en-US' }, + }) }) it('returns 500 on unexpected error', async () => { @@ -99,11 +101,17 @@ describe('PreferenceController', () => { it('accepts a partial update and preserves omitted fields', async () => { req.body = { lowDataMode: true } - mockUpdatePreferences.mockResolvedValue({ userId: 'user1', lowDataMode: true, locale: 'en-US' }) + mockUpdatePreferences.mockResolvedValue({ + userId: 'user1', + lowDataMode: true, + locale: 'en-US', + }) await controller.updatePreferences(req, res) - expect(mockUpdatePreferences).toHaveBeenCalledWith('user1', { lowDataMode: true }) + expect(mockUpdatePreferences).toHaveBeenCalledWith('user1', { + lowDataMode: true, + }) expect(res.status).toHaveBeenCalledWith(200) }) diff --git a/tests/preference.service.test.ts b/tests/preference.service.test.ts index 8b0705a1..22c5df9a 100644 --- a/tests/preference.service.test.ts +++ b/tests/preference.service.test.ts @@ -45,10 +45,20 @@ describe('PreferenceService', () => { describe('updatePreferences', () => { it('preserves omitted fields via a partial upsert', async () => { - mockFindUnique.mockResolvedValue({ userId: 'user1', locale: 'en-US', profileVisibility: 'public' }) - mockUpsert.mockResolvedValue({ userId: 'user1', locale: 'fr-FR', profileVisibility: 'public' }) + mockFindUnique.mockResolvedValue({ + userId: 'user1', + locale: 'en-US', + profileVisibility: 'public', + }) + mockUpsert.mockResolvedValue({ + userId: 'user1', + locale: 'fr-FR', + profileVisibility: 'public', + }) - const result = await service.updatePreferences('user1', { locale: 'fr-FR' as any }) + const result = await service.updatePreferences('user1', { + locale: 'fr-FR' as any, + }) expect(mockUpsert).toHaveBeenCalledWith({ where: { userId: 'user1' }, @@ -59,13 +69,28 @@ describe('PreferenceService', () => { }) it('audits privacy-impacting changes when the value actually changes', async () => { - mockFindUnique.mockResolvedValue({ userId: 'user1', profileVisibility: 'public' }) - mockUpsert.mockResolvedValue({ userId: 'user1', profileVisibility: 'private' }) + mockFindUnique.mockResolvedValue({ + userId: 'user1', + profileVisibility: 'public', + }) + mockUpsert.mockResolvedValue({ + userId: 'user1', + profileVisibility: 'private', + }) - await service.updatePreferences('user1', { profileVisibility: 'private' as any }) + await service.updatePreferences('user1', { + profileVisibility: 'private' as any, + }) expect(mockCreateMany).toHaveBeenCalledWith({ - data: [{ userId: 'user1', field: 'profileVisibility', oldValue: 'public', newValue: 'private' }], + data: [ + { + userId: 'user1', + field: 'profileVisibility', + oldValue: 'public', + newValue: 'private', + }, + ], }) }) @@ -79,7 +104,10 @@ describe('PreferenceService', () => { }) it('does not audit when a privacy field is submitted but unchanged', async () => { - mockFindUnique.mockResolvedValue({ userId: 'user1', analyticsConsent: true }) + mockFindUnique.mockResolvedValue({ + userId: 'user1', + analyticsConsent: true, + }) mockUpsert.mockResolvedValue({ userId: 'user1', analyticsConsent: true }) await service.updatePreferences('user1', { analyticsConsent: true }) @@ -89,12 +117,22 @@ describe('PreferenceService', () => { it('records null oldValue on first-ever write (no prior row)', async () => { mockFindUnique.mockResolvedValue(null) - mockUpsert.mockResolvedValue({ userId: 'user1', dataSharingConsent: true }) + mockUpsert.mockResolvedValue({ + userId: 'user1', + dataSharingConsent: true, + }) await service.updatePreferences('user1', { dataSharingConsent: true }) expect(mockCreateMany).toHaveBeenCalledWith({ - data: [{ userId: 'user1', field: 'dataSharingConsent', oldValue: null, newValue: 'true' }], + data: [ + { + userId: 'user1', + field: 'dataSharingConsent', + oldValue: null, + newValue: 'true', + }, + ], }) }) }) diff --git a/tests/profile-serializer.test.ts b/tests/profile-serializer.test.ts index 93556861..4d55e187 100644 --- a/tests/profile-serializer.test.ts +++ b/tests/profile-serializer.test.ts @@ -13,7 +13,10 @@ import { toProfileRecord, toPublicProfile, } from '../src/services/profile-serializer' -import { AccountSummary, LearnerProfileRecord } from '../src/types/profile.types' +import { + AccountSummary, + LearnerProfileRecord, +} from '../src/types/profile.types' const baseProfile: LearnerProfileRecord = { id: 'profile1', @@ -34,7 +37,10 @@ const baseProfile: LearnerProfileRecord = { describe('computeProfileCompletion', () => { it('is 100% when every tracked field is filled', () => { - expect(computeProfileCompletion(baseProfile)).toEqual({ percent: 100, missingFields: [] }) + expect(computeProfileCompletion(baseProfile)).toEqual({ + percent: 100, + missingFields: [], + }) }) it('is deterministic for the same input', () => { @@ -61,7 +67,14 @@ describe('computeProfileCompletion', () => { expect(result.percent).toBe(0) expect(result.missingFields).toEqual([ - 'displayName', 'bio', 'avatarUrl', 'country', 'timezone', 'languages', 'interests', 'goals', + 'displayName', + 'bio', + 'avatarUrl', + 'country', + 'timezone', + 'languages', + 'interests', + 'goals', ]) }) @@ -74,7 +87,10 @@ describe('computeProfileCompletion', () => { describe('toOwnerProfile', () => { it('always returns every field regardless of visibility', () => { - const privateProfile: LearnerProfileRecord = { ...baseProfile, visibility: 'private' } + const privateProfile: LearnerProfileRecord = { + ...baseProfile, + visibility: 'private', + } const view = toOwnerProfile(privateProfile) @@ -110,14 +126,17 @@ describe('toProfileRecord', () => { } as unknown as LearnerProfileRecord expect(Object.keys(toProfileRecord(loaded)).sort()).toEqual( - Object.keys(baseProfile).sort() + Object.keys(baseProfile).sort(), ) }) }) describe('toEmployerProfile', () => { it('redacts fields when visibility is private', () => { - const profile: LearnerProfileRecord = { ...baseProfile, visibility: 'private' } + const profile: LearnerProfileRecord = { + ...baseProfile, + visibility: 'private', + } const view = toEmployerProfile(profile) @@ -125,7 +144,10 @@ describe('toEmployerProfile', () => { }) it('exposes fields when visibility is employer', () => { - const profile: LearnerProfileRecord = { ...baseProfile, visibility: 'employer' } + const profile: LearnerProfileRecord = { + ...baseProfile, + visibility: 'employer', + } const view = toEmployerProfile(profile) @@ -142,7 +164,10 @@ describe('toEmployerProfile', () => { }) it('never includes account-private fields', () => { - const view = toEmployerProfile({ ...baseProfile, visibility: 'employer' }) as Record + const view = toEmployerProfile({ + ...baseProfile, + visibility: 'employer', + }) as Record expect(view).not.toHaveProperty('status') expect(view).not.toHaveProperty('isVerified') @@ -152,9 +177,15 @@ describe('toEmployerProfile', () => { describe('toPublicProfile', () => { it('redacts fields unless visibility is public', () => { - const employerOnly: LearnerProfileRecord = { ...baseProfile, visibility: 'employer' } + const employerOnly: LearnerProfileRecord = { + ...baseProfile, + visibility: 'employer', + } - expect(toPublicProfile(employerOnly)).toEqual({ id: employerOnly.id, visible: false }) + expect(toPublicProfile(employerOnly)).toEqual({ + id: employerOnly.id, + visible: false, + }) }) it('exposes only the public-safe subset when visibility is public', () => { @@ -194,7 +225,9 @@ describe('toPrivateProfile', () => { describe('isDisclosureAllowed', () => { it('allows disclosure for an active account with no data-sharing record', () => { - expect(isDisclosureAllowed({ accountStatus: 'ACTIVE', consents: [] })).toBe(true) + expect(isDisclosureAllowed({ accountStatus: 'ACTIVE', consents: [] })).toBe( + true, + ) }) it('allows disclosure while data-sharing consent is granted', () => { @@ -202,7 +235,7 @@ describe('isDisclosureAllowed', () => { isDisclosureAllowed({ accountStatus: 'ACTIVE', consents: [{ purpose: 'data_sharing', status: 'granted' }], - }) + }), ).toBe(true) }) @@ -211,7 +244,7 @@ describe('isDisclosureAllowed', () => { isDisclosureAllowed({ accountStatus: 'ACTIVE', consents: [{ purpose: 'data_sharing', status: 'withdrawn' }], - }) + }), ).toBe(false) }) @@ -220,26 +253,29 @@ describe('isDisclosureAllowed', () => { isDisclosureAllowed({ accountStatus: 'ACTIVE', consents: [{ purpose: 'marketing_emails', status: 'withdrawn' }], - }) + }), ).toBe(true) }) it.each(['DEACTIVATED', 'PENDING_DELETION', 'DELETED'])( 'refuses disclosure for a %s account even with consent granted', - status => { + (status) => { expect( isDisclosureAllowed({ accountStatus: status, consents: [{ purpose: 'data_sharing', status: 'granted' }], - }) + }), ).toBe(false) - } + }, ) }) describe('redactedProfile', () => { it('is indistinguishable from a below-threshold redaction, so a refusal leaks nothing', () => { - const belowThreshold = toPublicProfile({ ...baseProfile, visibility: 'private' }) + const belowThreshold = toPublicProfile({ + ...baseProfile, + visibility: 'private', + }) expect(redactedProfile(baseProfile.id)).toEqual(belowThreshold) }) @@ -263,7 +299,10 @@ const baseAccount: AccountSummary = { describe('toAccountSummary', () => { it('never carries the password column, even when it is present on the input', () => { - const withSecret = { ...baseAccount, password: '$2b$12$hash' } as unknown as AccountSummary + const withSecret = { + ...baseAccount, + password: '$2b$12$hash', + } as unknown as AccountSummary const view = toAccountSummary(withSecret) as Record @@ -277,7 +316,9 @@ describe('toAccountSummary', () => { internalRiskScore: 99, } as unknown as AccountSummary - expect(toAccountSummary(withNewColumn)).not.toHaveProperty('internalRiskScore') + expect(toAccountSummary(withNewColumn)).not.toHaveProperty( + 'internalRiskScore', + ) }) }) @@ -292,7 +333,9 @@ describe('toOnboardingSummary', () => { } it('computes the required steps still outstanding', () => { - expect(toOnboardingSummary(progress).requiredStepsRemaining).toEqual(['consent']) + expect(toOnboardingSummary(progress).requiredStepsRemaining).toEqual([ + 'consent', + ]) }) it('reports nothing outstanding once every required step is done', () => { @@ -389,7 +432,10 @@ describe('toOwnerAccountProfile', () => { it('never carries the password column', () => { const view = toOwnerAccountProfile({ - account: { ...baseAccount, password: 'secret' } as unknown as AccountSummary, + account: { + ...baseAccount, + password: 'secret', + } as unknown as AccountSummary, profile: baseProfile, onboarding: null, consents: [], diff --git a/tests/profile.controller.test.ts b/tests/profile.controller.test.ts index 62e8ba4a..15f66d28 100644 --- a/tests/profile.controller.test.ts +++ b/tests/profile.controller.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ProfileController } from '../src/controllers/profile.controller' -const { mockGetOwnerView, mockUpdateProfileAudited, mockGetEmployerView, mockGetPublicView } = vi.hoisted(() => ({ +const { + mockGetOwnerView, + mockUpdateProfileAudited, + mockGetEmployerView, + mockGetPublicView, +} = vi.hoisted(() => ({ mockGetOwnerView: vi.fn(), mockUpdateProfileAudited: vi.fn(), mockGetEmployerView: vi.fn(), @@ -25,7 +30,14 @@ describe('ProfileController', () => { beforeEach(() => { vi.clearAllMocks() controller = new ProfileController() - req = { user: { id: 'user1', role: 'learner' }, body: {}, params: {}, headers: {}, requestId: 'req-1', ip: '203.0.113.7' } + req = { + user: { id: 'user1', role: 'learner' }, + body: {}, + params: {}, + headers: {}, + requestId: 'req-1', + ip: '203.0.113.7', + } res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), @@ -47,7 +59,9 @@ describe('ProfileController', () => { await controller.getMyProfile(req, res) expect(res.status).toHaveBeenCalledWith(200) - expect(res.json).toHaveBeenCalledWith({ data: { id: 'profile1', displayName: 'Ada' } }) + expect(res.json).toHaveBeenCalledWith({ + data: { id: 'profile1', displayName: 'Ada' }, + }) }) it('returns 500 on unexpected error', async () => { @@ -103,7 +117,10 @@ describe('ProfileController', () => { it('accepts a partial update', async () => { req.body = { displayName: 'Ada' } - mockUpdateProfileAudited.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) + mockUpdateProfileAudited.mockResolvedValue({ + id: 'profile1', + displayName: 'Ada', + }) mockGetOwnerView.mockResolvedValue({ id: 'profile1', displayName: 'Ada' }) await controller.updateMyProfile(req, res) @@ -111,7 +128,7 @@ describe('ProfileController', () => { expect(mockUpdateProfileAudited).toHaveBeenCalledWith( 'user1', { displayName: 'Ada' }, - expect.anything() + expect.anything(), ) expect(res.status).toHaveBeenCalledWith(200) }) diff --git a/tests/profile.service.test.ts b/tests/profile.service.test.ts index f229a9d8..6517f7bf 100644 --- a/tests/profile.service.test.ts +++ b/tests/profile.service.test.ts @@ -108,14 +108,17 @@ function fakeTransaction(result: unknown = baseProfile) { callback({ auditEvent: { create: auditCreate }, learnerProfile: { upsert: profileUpsert }, - }) + }), ) return { calls, auditCreate, profileUpsert } } -function auditRow(auditCreate: ReturnType): Record { - return (auditCreate.mock.calls[0][0] as { data: Record }).data +function auditRow( + auditCreate: ReturnType, +): Record { + return (auditCreate.mock.calls[0][0] as { data: Record }) + .data } describe('ProfileService', () => { @@ -145,7 +148,9 @@ describe('ProfileService', () => { it('preserves omitted fields via a partial upsert', async () => { mockUpsert.mockResolvedValue({ ...baseProfile, displayName: 'New Name' }) - const result = await service.updateProfile('user1', { displayName: 'New Name' }) + const result = await service.updateProfile('user1', { + displayName: 'New Name', + }) expect(mockUpsert).toHaveBeenCalledWith({ where: { userId: 'user1' }, @@ -160,7 +165,11 @@ describe('ProfileService', () => { it('writes the profile change and its audit event in one transaction', async () => { const { calls, auditCreate, profileUpsert } = fakeTransaction() - await service.updateProfileAudited('user1', { displayName: 'Ada' }, ownerContext) + await service.updateProfileAudited( + 'user1', + { displayName: 'Ada' }, + ownerContext, + ) expect(mockTransaction).toHaveBeenCalledOnce() expect(profileUpsert).toHaveBeenCalledWith({ @@ -177,7 +186,11 @@ describe('ProfileService', () => { it('attributes the event to the owner and the affected profile row', async () => { const { auditCreate } = fakeTransaction() - await service.updateProfileAudited('user1', { displayName: 'Ada' }, ownerContext) + await service.updateProfileAudited( + 'user1', + { displayName: 'Ada' }, + ownerContext, + ) expect(auditRow(auditCreate)).toMatchObject({ action: 'learner_profile.updated', @@ -196,7 +209,7 @@ describe('ProfileService', () => { await service.updateProfileAudited( 'user1', { bio: 'my private medical history', displayName: 'Ada Lovelace' }, - ownerContext + ownerContext, ) const metadata = auditRow(auditCreate).metadata as string @@ -211,7 +224,11 @@ describe('ProfileService', () => { mockTransaction.mockRejectedValue(new Error('audit trail unavailable')) await expect( - service.updateProfileAudited('user1', { displayName: 'Ada' }, ownerContext) + service.updateProfileAudited( + 'user1', + { displayName: 'Ada' }, + ownerContext, + ), ).rejects.toThrow('audit trail unavailable') }) @@ -222,8 +239,8 @@ describe('ProfileService', () => { service.updateProfileAudited( 'user1', { displayName: 'Ada' }, - { ...ownerContext, actor: { type: ActorType.USER } } - ) + { ...ownerContext, actor: { type: ActorType.USER } }, + ), ).rejects.toThrow(/unattributable/) expect(mockTransaction).not.toHaveBeenCalled() }) @@ -254,7 +271,10 @@ describe('ProfileService', () => { }) it('returns null for a tombstoned account instead of materialising a profile', async () => { - mockUserFindUnique.mockResolvedValue({ ...baseAccount, status: 'DELETED' }) + mockUserFindUnique.mockResolvedValue({ + ...baseAccount, + status: 'DELETED', + }) expect(await service.getOwnerAccountProfile('user1')).toBeNull() expect(mockUpsert).not.toHaveBeenCalled() @@ -304,16 +324,44 @@ describe('ProfileService', () => { it('reports required consents as granted only when every required purpose is granted', async () => { mockUserFindUnique.mockResolvedValue(baseAccount) mockConsentFindMany.mockResolvedValue([ - { purpose: 'terms_of_service', status: 'granted', required: true, policyVersion: '1', grantedAt: new Date(), withdrawnAt: null }, - { purpose: 'privacy_policy', status: 'withdrawn', required: true, policyVersion: '1', grantedAt: null, withdrawnAt: new Date() }, + { + purpose: 'terms_of_service', + status: 'granted', + required: true, + policyVersion: '1', + grantedAt: new Date(), + withdrawnAt: null, + }, + { + purpose: 'privacy_policy', + status: 'withdrawn', + required: true, + policyVersion: '1', + grantedAt: null, + withdrawnAt: new Date(), + }, ]) const partial = await service.getOwnerAccountProfile('user1') expect(partial?.requiredConsentsGranted).toBe(false) mockConsentFindMany.mockResolvedValue([ - { purpose: 'terms_of_service', status: 'granted', required: true, policyVersion: '1', grantedAt: new Date(), withdrawnAt: null }, - { purpose: 'privacy_policy', status: 'granted', required: true, policyVersion: '1', grantedAt: new Date(), withdrawnAt: null }, + { + purpose: 'terms_of_service', + status: 'granted', + required: true, + policyVersion: '1', + grantedAt: new Date(), + withdrawnAt: null, + }, + { + purpose: 'privacy_policy', + status: 'granted', + required: true, + policyVersion: '1', + grantedAt: new Date(), + withdrawnAt: null, + }, ]) const complete = await service.getOwnerAccountProfile('user1') @@ -343,20 +391,37 @@ describe('ProfileService', () => { it('redacts fields when visibility is below employer', async () => { mockFindFirst.mockResolvedValue(baseProfile) - expect(await service.getEmployerView('user1')).toEqual({ id: 'profile1', visible: false }) + expect(await service.getEmployerView('user1')).toEqual({ + id: 'profile1', + visible: false, + }) }) it('exposes the employer subset when visibility allows it', async () => { - mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'employer' }) + mockFindFirst.mockResolvedValue({ + ...baseProfile, + visibility: 'employer', + }) - expect(await service.getEmployerView('user1')).toMatchObject({ visible: true, displayName: 'Ada' }) + expect(await service.getEmployerView('user1')).toMatchObject({ + visible: true, + displayName: 'Ada', + }) }) it('redacts an employer-visible profile once data-sharing consent is withdrawn', async () => { - mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'employer' }) - mockConsentFindMany.mockResolvedValue([{ purpose: 'data_sharing', status: 'withdrawn' }]) + mockFindFirst.mockResolvedValue({ + ...baseProfile, + visibility: 'employer', + }) + mockConsentFindMany.mockResolvedValue([ + { purpose: 'data_sharing', status: 'withdrawn' }, + ]) - expect(await service.getEmployerView('user1')).toEqual({ id: 'profile1', visible: false }) + expect(await service.getEmployerView('user1')).toEqual({ + id: 'profile1', + visible: false, + }) }) }) @@ -378,7 +443,10 @@ describe('ProfileService', () => { it('redacts fields when visibility is below public', async () => { mockFindFirst.mockResolvedValue(baseProfile) - expect(await service.getPublicView('user1')).toEqual({ id: 'profile1', visible: false }) + expect(await service.getPublicView('user1')).toEqual({ + id: 'profile1', + visible: false, + }) }) it('exposes the public subset when visibility is public', async () => { @@ -393,7 +461,10 @@ describe('ProfileService', () => { it('never leaks account-private fields, even for a fully public profile', async () => { mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'public' }) - const result = (await service.getPublicView('user1')) as Record + const result = (await service.getPublicView('user1')) as Record< + string, + unknown + > expect(result).not.toHaveProperty('userId') expect(result).not.toHaveProperty('status') @@ -405,19 +476,30 @@ describe('ProfileService', () => { it('redacts a public profile once data-sharing consent is withdrawn', async () => { mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'public' }) - mockConsentFindMany.mockResolvedValue([{ purpose: 'data_sharing', status: 'withdrawn' }]) + mockConsentFindMany.mockResolvedValue([ + { purpose: 'data_sharing', status: 'withdrawn' }, + ]) - expect(await service.getPublicView('user1')).toEqual({ id: 'profile1', visible: false }) + expect(await service.getPublicView('user1')).toEqual({ + id: 'profile1', + visible: false, + }) }) it.each(['DEACTIVATED', 'PENDING_DELETION'])( 'redacts a public profile for a %s account', - async status => { - mockFindFirst.mockResolvedValue({ ...baseProfile, visibility: 'public' }) + async (status) => { + mockFindFirst.mockResolvedValue({ + ...baseProfile, + visibility: 'public', + }) mockUserFindUnique.mockResolvedValue({ status }) - expect(await service.getPublicView('user1')).toEqual({ id: 'profile1', visible: false }) - } + expect(await service.getPublicView('user1')).toEqual({ + id: 'profile1', + visible: false, + }) + }, ) }) @@ -431,11 +513,19 @@ describe('ProfileService', () => { it('joins account-private fields onto the full profile', async () => { mockUpsert.mockResolvedValue(baseProfile) - mockUserFindUnique.mockResolvedValue({ status: 'ACTIVE', isVerified: true, phoneVerifiedAt: null }) + mockUserFindUnique.mockResolvedValue({ + status: 'ACTIVE', + isVerified: true, + phoneVerifiedAt: null, + }) const result = await service.getPrivateView('user1') - expect(result).toMatchObject({ displayName: 'Ada', status: 'ACTIVE', isVerified: true }) + expect(result).toMatchObject({ + displayName: 'Ada', + status: 'ACTIVE', + isVerified: true, + }) }) }) }) diff --git a/tests/referral.controller.test.ts b/tests/referral.controller.test.ts index 954bd392..890b4de4 100644 --- a/tests/referral.controller.test.ts +++ b/tests/referral.controller.test.ts @@ -26,7 +26,8 @@ vi.mock('../src/config/database', () => ({ import prisma from '../src/config/database' -const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) +const flushPromises = () => + new Promise((resolve) => setTimeout(resolve, 0)) interface AuthRequest extends Request { user?: { id: string; email: string; role: string } @@ -40,7 +41,10 @@ describe('ReferralController', () => { beforeEach(() => { controller = new ReferralController() - req = { user: { id: 'user-1', email: 'test@example.com', role: 'LEARNER' }, body: {} } + req = { + user: { id: 'user-1', email: 'test@example.com', role: 'LEARNER' }, + body: {}, + } res = { json: vi.fn(), status: vi.fn().mockReturnThis() } next = vi.fn() vi.clearAllMocks() @@ -49,7 +53,11 @@ describe('ReferralController', () => { describe('generateCode', () => { it('returns existing code if user already has one', async () => { vi.mocked(prisma.referralCode.findUnique).mockResolvedValue({ - id: 'rc-1', code: 'ABCD1234', userId: 'user-1', createdAt: new Date(), referrals: [], + id: 'rc-1', + code: 'ABCD1234', + userId: 'user-1', + createdAt: new Date(), + referrals: [], } as any) controller.generateCode(req as Request, res as Response, next) @@ -62,9 +70,14 @@ describe('ReferralController', () => { }) it('creates and returns a new code if none exists', async () => { - vi.mocked(prisma.referralCode.findUnique).mockResolvedValueOnce(null).mockResolvedValueOnce(null) + vi.mocked(prisma.referralCode.findUnique) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) vi.mocked(prisma.referralCode.create).mockResolvedValue({ - id: 'rc-2', code: 'NEWCODE1', userId: 'user-1', createdAt: new Date(), + id: 'rc-2', + code: 'NEWCODE1', + userId: 'user-1', + createdAt: new Date(), } as any) controller.generateCode(req as Request, res as Response, next) @@ -80,7 +93,9 @@ describe('ReferralController', () => { controller.generateCode(req as Request, res as Response, next) await flushPromises() - expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'User ID not found' })) + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ message: 'User ID not found' }), + ) }) }) @@ -95,7 +110,9 @@ describe('ReferralController', () => { controller.applyCode(req as Request, res as Response, next) await flushPromises() - expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'Referral code is required' })) + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Referral code is required' }), + ) }) it('throws NotFoundError for unknown code', async () => { @@ -104,46 +121,69 @@ describe('ReferralController', () => { controller.applyCode(req as Request, res as Response, next) await flushPromises() - expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'Referral code not found' })) + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Referral code not found' }), + ) }) it('throws BadRequestError on self-referral', async () => { vi.mocked(prisma.referralCode.findUnique).mockResolvedValue({ - id: 'rc-1', code: 'REFCODE1', userId: 'user-1', createdAt: new Date(), + id: 'rc-1', + code: 'REFCODE1', + userId: 'user-1', + createdAt: new Date(), } as any) controller.applyCode(req as Request, res as Response, next) await flushPromises() - expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'Self-referrals are not allowed' })) + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Self-referrals are not allowed' }), + ) }) it('throws ConflictError if user already used a referral code', async () => { vi.mocked(prisma.referralCode.findUnique).mockResolvedValue({ - id: 'rc-1', code: 'REFCODE1', userId: 'user-2', createdAt: new Date(), + id: 'rc-1', + code: 'REFCODE1', + userId: 'user-2', + createdAt: new Date(), + } as any) + vi.mocked(prisma.referral.findUnique).mockResolvedValue({ + id: 'ref-1', } as any) - vi.mocked(prisma.referral.findUnique).mockResolvedValue({ id: 'ref-1' } as any) controller.applyCode(req as Request, res as Response, next) await flushPromises() - expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'You have already used a referral code' })) + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'You have already used a referral code', + }), + ) }) it('successfully applies a valid referral code', async () => { vi.mocked(prisma.referralCode.findUnique).mockResolvedValue({ - id: 'rc-1', code: 'REFCODE1', userId: 'user-2', createdAt: new Date(), + id: 'rc-1', + code: 'REFCODE1', + userId: 'user-2', + createdAt: new Date(), } as any) vi.mocked(prisma.referral.findUnique).mockResolvedValue(null) vi.mocked(prisma.referral.findFirst).mockResolvedValue(null) - vi.mocked(prisma.referral.create).mockResolvedValue({ id: 'ref-new' } as any) + vi.mocked(prisma.referral.create).mockResolvedValue({ + id: 'ref-new', + } as any) controller.applyCode(req as Request, res as Response, next) await flushPromises() expect(prisma.referral.create).toHaveBeenCalled() expect(res.status).toHaveBeenCalledWith(201) - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true })) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true }), + ) }) }) @@ -186,14 +226,18 @@ describe('ReferralController', () => { controller.getStats(req as Request, res as Response, next) await flushPromises() - expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'User ID not found' })) + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ message: 'User ID not found' }), + ) }) }) describe('processReferralBonus (static)', () => { it('pays bonus when referree completes first module', async () => { vi.mocked(prisma.referral.findUnique).mockResolvedValue({ - id: 'ref-1', referrerId: 'user-2', bonusPaid: false, + id: 'ref-1', + referrerId: 'user-2', + bonusPaid: false, } as any) vi.mocked(prisma.completion.count).mockResolvedValue(1) vi.mocked(prisma.referral.update).mockResolvedValue({} as any) @@ -202,14 +246,18 @@ describe('ReferralController', () => { await ReferralController.processReferralBonus('user-1') expect(prisma.referral.update).toHaveBeenCalledWith( - expect.objectContaining({ data: expect.objectContaining({ bonusPaid: true }) }), + expect.objectContaining({ + data: expect.objectContaining({ bonusPaid: true }), + }), ) expect(prisma.transaction.create).toHaveBeenCalled() }) it('skips bonus if already paid', async () => { vi.mocked(prisma.referral.findUnique).mockResolvedValue({ - id: 'ref-1', referrerId: 'user-2', bonusPaid: true, + id: 'ref-1', + referrerId: 'user-2', + bonusPaid: true, } as any) await ReferralController.processReferralBonus('user-1') @@ -219,7 +267,9 @@ describe('ReferralController', () => { it('skips bonus if no completions yet', async () => { vi.mocked(prisma.referral.findUnique).mockResolvedValue({ - id: 'ref-1', referrerId: 'user-2', bonusPaid: false, + id: 'ref-1', + referrerId: 'user-2', + bonusPaid: false, } as any) vi.mocked(prisma.completion.count).mockResolvedValue(0) diff --git a/tests/refresh-token.service.test.ts b/tests/refresh-token.service.test.ts index 9c1bbc92..20022167 100644 --- a/tests/refresh-token.service.test.ts +++ b/tests/refresh-token.service.test.ts @@ -91,8 +91,12 @@ describe('RefreshTokenService', () => { vi.clearAllMocks() service = new RefreshTokenService() - vi.mocked(issueAccessToken).mockImplementation(({ id }: { id: string }) => `access-token-${id}`) - vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 1 } as any) + vi.mocked(issueAccessToken).mockImplementation( + ({ id }: { id: string }) => `access-token-${id}`, + ) + vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ + count: 1, + } as any) vi.mocked(prisma.session.updateMany).mockResolvedValue({ count: 1 } as any) vi.mocked(prisma.session.create).mockResolvedValue({ id: 'sess-1' } as any) vi.mocked(prisma.refreshToken.create).mockResolvedValue({} as any) @@ -119,7 +123,10 @@ describe('RefreshTokenService', () => { ipAddress: '1.2.3.4', }) - expect(issueAccessToken).toHaveBeenCalledWith({ id: 'user-1', role: 'learner' }) + expect(issueAccessToken).toHaveBeenCalledWith({ + id: 'user-1', + role: 'learner', + }) expect(prisma.$transaction).toHaveBeenCalled() const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown[] @@ -135,7 +142,8 @@ describe('RefreshTokenService', () => { }) // Only the hash of the refresh token is persisted — never the raw value. - const createArgs = vi.mocked(prisma.refreshToken.create).mock.calls[0][0] as any + const createArgs = vi.mocked(prisma.refreshToken.create).mock + .calls[0][0] as any expect(createArgs.data.tokenHash).not.toBe(result.refreshToken) expect(createArgs.data.tokenHash).toBe(hashToken(result.refreshToken)) expect(createArgs.data.status).toBe('ACTIVE') @@ -151,13 +159,19 @@ describe('RefreshTokenService', () => { describe('rotate', () => { it('rotates an ACTIVE token: consumes it and mints a new token in the same family', async () => { - vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue(foundRow() as any) + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( + foundRow() as any, + ) - const result = await service.rotate('raw-refresh-token', { ipAddress: '1.2.3.4' }) + const result = await service.rotate('raw-refresh-token', { + ipAddress: '1.2.3.4', + }) // Looked up by hash expect(prisma.refreshToken.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ where: { tokenHash: hashToken('raw-refresh-token') } }) + expect.objectContaining({ + where: { tokenHash: hashToken('raw-refresh-token') }, + }), ) // Atomic claim @@ -167,7 +181,8 @@ describe('RefreshTokenService', () => { }) // New token is minted in the same family and the session is advanced - const createArgs = vi.mocked(prisma.refreshToken.create).mock.calls[0][0] as any + const createArgs = vi.mocked(prisma.refreshToken.create).mock + .calls[0][0] as any expect(createArgs.data).toMatchObject({ sessionId: 'sess-1', familyId: 'family-1', @@ -177,7 +192,7 @@ describe('RefreshTokenService', () => { expect.objectContaining({ where: { id: 'sess-1' }, data: expect.objectContaining({ token: 'access-token-user-1' }), - }) + }), ) expect(result).toMatchObject({ @@ -201,7 +216,7 @@ describe('RefreshTokenService', () => { it('rejects an expired token without rotating', async () => { vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( - foundRow({ expiresAt: PAST() }) as any + foundRow({ expiresAt: PAST() }) as any, ) const result = await service.rotate('expired-token') @@ -213,7 +228,7 @@ describe('RefreshTokenService', () => { it('rejects a REVOKED token', async () => { vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( - foundRow({ status: 'REVOKED' }) as any + foundRow({ status: 'REVOKED' }) as any, ) expect(await service.rotate('revoked-token')).toEqual({ kind: 'revoked' }) @@ -221,7 +236,15 @@ describe('RefreshTokenService', () => { it('returns revoked without rotating when the session is already revoked', async () => { vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( - foundRow({ session: { id: 'sess-1', userId: 'user-1', isRevoked: true, expiresAt: FUTURE(), user: { id: 'user-1', role: 'learner' } } }) as any + foundRow({ + session: { + id: 'sess-1', + userId: 'user-1', + isRevoked: true, + expiresAt: FUTURE(), + user: { id: 'user-1', role: 'learner' }, + }, + }) as any, ) const result = await service.rotate('token-of-revoked-session') @@ -233,15 +256,25 @@ describe('RefreshTokenService', () => { it('rejects an expired session', async () => { vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( - foundRow({ session: { id: 'sess-1', userId: 'user-1', isRevoked: false, expiresAt: PAST(), user: { id: 'user-1', role: 'learner' } } }) as any + foundRow({ + session: { + id: 'sess-1', + userId: 'user-1', + isRevoked: false, + expiresAt: PAST(), + user: { id: 'user-1', role: 'learner' }, + }, + }) as any, ) - expect(await service.rotate('token-of-expired-session')).toEqual({ kind: 'expired' }) + expect(await service.rotate('token-of-expired-session')).toEqual({ + kind: 'expired', + }) }) it('detects replay of a ROTATED token and revokes the entire family', async () => { vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( - foundRow({ status: 'ROTATED' }) as any + foundRow({ status: 'ROTATED' }) as any, ) const result = await service.rotate('replayed-token') @@ -252,21 +285,25 @@ describe('RefreshTokenService', () => { expect.objectContaining({ where: { familyId: 'family-1', status: { not: 'REVOKED' } }, data: { status: 'REVOKED' }, - }) + }), ) // …and the parent session revoked expect(prisma.session.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'sess-1', isRevoked: false }, data: expect.objectContaining({ isRevoked: true }), - }) + }), ) }) it('treats a lost rotation race as reuse and revokes the family', async () => { - vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue(foundRow() as any) + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( + foundRow() as any, + ) // Another request claimed the ACTIVE→ROTATED transition first. - vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 } as any) + vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ + count: 0, + } as any) const result = await service.rotate('raced-token') @@ -283,14 +320,18 @@ describe('RefreshTokenService', () => { session: { userId: 'user-1' }, } as any) - const result = await service.revokeByRefreshToken('current-token', { ipAddress: '1.2.3.4' }) + const result = await service.revokeByRefreshToken('current-token', { + ipAddress: '1.2.3.4', + }) expect(result).toEqual({ revokedCount: 1 }) expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { familyId: 'family-1', status: { not: 'REVOKED' } } }) + expect.objectContaining({ + where: { familyId: 'family-1', status: { not: 'REVOKED' } }, + }), ) expect(prisma.session.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 'sess-1', isRevoked: false } }) + expect.objectContaining({ where: { id: 'sess-1', isRevoked: false } }), ) }) @@ -309,18 +350,26 @@ describe('RefreshTokenService', () => { vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue({ session: { userId: 'user-1' }, } as any) - vi.mocked(prisma.session.findMany).mockResolvedValue([{ id: 'sess-1' }, { id: 'sess-2' }] as any) + vi.mocked(prisma.session.findMany).mockResolvedValue([ + { id: 'sess-1' }, + { id: 'sess-2' }, + ] as any) const result = await service.revokeAllByRefreshToken('any-token') expect(result).toEqual({ revokedCount: 2 }) expect(prisma.session.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: { in: ['sess-1', 'sess-2'] }, isRevoked: false } }) + expect.objectContaining({ + where: { id: { in: ['sess-1', 'sess-2'] }, isRevoked: false }, + }), ) expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { sessionId: { in: ['sess-1', 'sess-2'] }, status: { not: 'REVOKED' } }, - }) + where: { + sessionId: { in: ['sess-1', 'sess-2'] }, + status: { not: 'REVOKED' }, + }, + }), ) }) @@ -338,7 +387,9 @@ describe('RefreshTokenService', () => { it('is a neutral no-op for an unknown token', async () => { vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue(null) - expect(await service.revokeAllByRefreshToken('unknown')).toEqual({ revokedCount: 0 }) + expect(await service.revokeAllByRefreshToken('unknown')).toEqual({ + revokedCount: 0, + }) }) }) @@ -347,7 +398,7 @@ describe('RefreshTokenService', () => { const { auditService } = await import('../src/services/audit.service') vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( - foundRow({ status: 'ROTATED' }) as any + foundRow({ status: 'ROTATED' }) as any, ) await service.rotate('replayed-token') @@ -356,7 +407,7 @@ describe('RefreshTokenService', () => { expect.objectContaining({ userId: 'user-1', action: SessionAuditAction.REFRESH_REUSE_DETECTED, - }) + }), ) }) @@ -372,7 +423,9 @@ describe('RefreshTokenService', () => { await service.revokeByRefreshToken('current-token') expect(auditService.op).toHaveBeenCalledWith( - expect.objectContaining({ action: SessionAuditAction.SESSION_LOGGED_OUT }) + expect.objectContaining({ + action: SessionAuditAction.SESSION_LOGGED_OUT, + }), ) }) }) diff --git a/tests/services/webhook.service.spec.ts b/tests/services/webhook.service.spec.ts index fd1fc78b..560b306c 100644 --- a/tests/services/webhook.service.spec.ts +++ b/tests/services/webhook.service.spec.ts @@ -3,131 +3,147 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { WebhookService } from '../../src/services/webhook.service' const { mockPrismaInstance } = vi.hoisted(() => ({ - mockPrismaInstance: { - webhookEndpoint: { - create: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, - webhookDelivery: { - create: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, + mockPrismaInstance: { + webhookEndpoint: { + create: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + webhookDelivery: { + create: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), }, + }, })) vi.mock('@prisma/client', () => ({ - PrismaClient: class { - webhookEndpoint = mockPrismaInstance.webhookEndpoint - webhookDelivery = mockPrismaInstance.webhookDelivery - - // src/config/database.ts applies the archive-exclusion extension. This - // mock returns itself: WebhookEndpoint is archivable, but every - // expectation here asserts on the delegate calls rather than on the - // `where` the extension would add. - $extends() { - return this - } - }, + PrismaClient: class { + webhookEndpoint = mockPrismaInstance.webhookEndpoint + webhookDelivery = mockPrismaInstance.webhookDelivery + + // src/config/database.ts applies the archive-exclusion extension. This + // mock returns itself: WebhookEndpoint is archivable, but every + // expectation here asserts on the delegate calls rather than on the + // `where` the extension would add. + $extends() { + return this + } + }, })) // Mock global fetch global.fetch = vi.fn() describe('WebhookService', () => { - let service: WebhookService - - beforeEach(() => { - vi.clearAllMocks() - service = new WebhookService() + let service: WebhookService + + beforeEach(() => { + vi.clearAllMocks() + service = new WebhookService() + }) + + describe('registerEndpoint', () => { + it('should create a new endpoint with a generated secret', async () => { + const data = { + url: 'https://example.com/webhook', + events: ['module.completed' as any], + } + + mockPrismaInstance.webhookEndpoint.create.mockResolvedValue({ + id: '1', + ...data, + secret: 'secret', + }) + + const result = await service.registerEndpoint(data) + + expect(mockPrismaInstance.webhookEndpoint.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + url: data.url, + events: 'module.completed', + }), + }), + ) + expect(result.id).toBe('1') }) - - describe('registerEndpoint', () => { - it('should create a new endpoint with a generated secret', async () => { - const data = { - url: 'https://example.com/webhook', - events: ['module.completed' as any], - } - - mockPrismaInstance.webhookEndpoint.create.mockResolvedValue({ id: '1', ...data, secret: 'secret' }) - - const result = await service.registerEndpoint(data) - - expect(mockPrismaInstance.webhookEndpoint.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - url: data.url, - events: 'module.completed', - }), - }) - ) - expect(result.id).toBe('1') - }) - }) - - describe('queueEvent', () => { - it('should create deliveries for subscribed endpoints', async () => { - mockPrismaInstance.webhookEndpoint.findMany.mockResolvedValue([ - { id: 'ep1', url: 'https://ep1.com', secret: 's1', events: 'module.completed', isActive: true }, - ]) - mockPrismaInstance.webhookDelivery.create.mockResolvedValue({ id: 'd1' }) - mockPrismaInstance.webhookDelivery.findMany.mockResolvedValue([]) - - await service.queueEvent('module.completed', { foo: 'bar' }) - - expect(mockPrismaInstance.webhookDelivery.create).toHaveBeenCalledOnce() - expect(mockPrismaInstance.webhookDelivery.findMany).not.toHaveBeenCalled() - const createCall = mockPrismaInstance.webhookDelivery.create.mock.calls[0][0] - expect(createCall.data.eventType).toBe('module.completed') - expect(JSON.parse(createCall.data.payload).data).toEqual({ foo: 'bar' }) - }) + }) + + describe('queueEvent', () => { + it('should create deliveries for subscribed endpoints', async () => { + mockPrismaInstance.webhookEndpoint.findMany.mockResolvedValue([ + { + id: 'ep1', + url: 'https://ep1.com', + secret: 's1', + events: 'module.completed', + isActive: true, + }, + ]) + mockPrismaInstance.webhookDelivery.create.mockResolvedValue({ id: 'd1' }) + mockPrismaInstance.webhookDelivery.findMany.mockResolvedValue([]) + + await service.queueEvent('module.completed', { foo: 'bar' }) + + expect(mockPrismaInstance.webhookDelivery.create).toHaveBeenCalledOnce() + expect(mockPrismaInstance.webhookDelivery.findMany).not.toHaveBeenCalled() + const createCall = + mockPrismaInstance.webhookDelivery.create.mock.calls[0][0] + expect(createCall.data.eventType).toBe('module.completed') + expect(JSON.parse(createCall.data.payload).data).toEqual({ foo: 'bar' }) }) + }) - describe('signature generation', () => { - it('should generate a valid HMAC SHA256 signature', () => { - const payload = '{"foo":"bar"}' - const secret = 'test-secret' - // @ts-expect-error just ignore for now - const signature = service.generateSignature(payload, secret) + describe('signature generation', () => { + it('should generate a valid HMAC SHA256 signature', () => { + const payload = '{"foo":"bar"}' + const secret = 'test-secret' + // @ts-expect-error just ignore for now + const signature = service.generateSignature(payload, secret) - expect(signature).toBeDefined() - expect(signature).toHaveLength(64) - }) + expect(signature).toBeDefined() + expect(signature).toHaveLength(64) + }) + }) + + describe('retry logic', () => { + it('should calculate exponential backoff', async () => { + const delivery = { id: 'd1', attemptCount: 1, maxAttempts: 5 } + mockPrismaInstance.webhookDelivery.update.mockResolvedValue({}) + + // @ts-expect-error just ignore for now + await service.handleFailure(delivery, 'error') + + expect(mockPrismaInstance.webhookDelivery.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + nextAttemptAt: expect.any(Date), + }), + }), + ) }) - describe('retry logic', () => { - it('should calculate exponential backoff', async () => { - const delivery = { id: 'd1', attemptCount: 1, maxAttempts: 5 } - mockPrismaInstance.webhookDelivery.update.mockResolvedValue({}) - - // @ts-expect-error just ignore for now - await service.handleFailure(delivery, 'error') - - expect(mockPrismaInstance.webhookDelivery.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - nextAttemptAt: expect.any(Date), - }), - }) - ) - }) - - it('should handle terminal failure after max attempts', async () => { - const delivery = { id: 'd1', attemptCount: 4, maxAttempts: 5, endpointId: 'ep1' } - mockPrismaInstance.webhookDelivery.update.mockResolvedValue({}) - mockPrismaInstance.webhookDelivery.findMany.mockResolvedValue([]) - - // @ts-expect-error just ignore for now - await service.handleFailure(delivery, 'max retry error') - - expect(mockPrismaInstance.webhookDelivery.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - status: 'failed', - }), - }) - ) - }) + it('should handle terminal failure after max attempts', async () => { + const delivery = { + id: 'd1', + attemptCount: 4, + maxAttempts: 5, + endpointId: 'ep1', + } + mockPrismaInstance.webhookDelivery.update.mockResolvedValue({}) + mockPrismaInstance.webhookDelivery.findMany.mockResolvedValue([]) + + // @ts-expect-error just ignore for now + await service.handleFailure(delivery, 'max retry error') + + expect(mockPrismaInstance.webhookDelivery.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'failed', + }), + }), + ) }) + }) }) diff --git a/tests/session.controller.test.ts b/tests/session.controller.test.ts index 6c7003ef..e8239473 100644 --- a/tests/session.controller.test.ts +++ b/tests/session.controller.test.ts @@ -47,9 +47,9 @@ vi.mock('../src/utils/logger', () => ({ // Mock the entire session service so controller tests are true unit tests vi.mock('../src/services/session.service', async () => { - const actual = await vi.importActual( - '../src/services/session.service' - ) + const actual = await vi.importActual< + typeof import('../src/services/session.service') + >('../src/services/session.service') return { ...actual, // keep redactIp, redactFingerprint, toSessionView real @@ -67,7 +67,8 @@ import { sessionService } from '../src/services/session.service' // ── Test helpers ───────────────────────────────────────────────────────── -const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)) +const flushPromises = () => + new Promise((resolve) => setTimeout(resolve, 0)) interface AuthRequest extends Partial { user?: { id: string; email: string; role: string } @@ -93,18 +94,20 @@ function makeRes(): Partial { } /** A factory for a minimal SessionView-shaped object */ -function sessionFixture(overrides: Partial<{ - id: string - deviceName: string | null - browser: string | null - os: string | null - country: string | null - city: string | null - createdAt: string - lastUsedAt: string | null - expiresAt: string - isCurrent: boolean -}> = {}) { +function sessionFixture( + overrides: Partial<{ + id: string + deviceName: string | null + browser: string | null + os: string | null + country: string | null + city: string | null + createdAt: string + lastUsedAt: string | null + expiresAt: string + isCurrent: boolean + }> = {}, +) { return { id: 'session-1', deviceName: 'Chrome on Linux', @@ -262,18 +265,16 @@ describe('SessionController', () => { }) it('respects custom page and limit query params', async () => { - vi.mocked(sessionService.list).mockResolvedValue({ sessions: [], total: 50 }) + vi.mocked(sessionService.list).mockResolvedValue({ + sessions: [], + total: 50, + }) const req = makeReq({ query: { page: '3', limit: '10' } }) controller.listSessions(req as Request, res as Response) await flushPromises() - expect(sessionService.list).toHaveBeenCalledWith( - 'user-1', - null, - 3, - 10 - ) + expect(sessionService.list).toHaveBeenCalledWith('user-1', null, 3, 10) const body = vi.mocked(res.json).mock.calls[0][0] as { pagination: Record @@ -290,7 +291,10 @@ describe('SessionController', () => { }) it('hasNext is true when more pages exist', async () => { - vi.mocked(sessionService.list).mockResolvedValue({ sessions: [], total: 100 }) + vi.mocked(sessionService.list).mockResolvedValue({ + sessions: [], + total: 100, + }) const req = makeReq({ query: { page: '1', limit: '10' } }) controller.listSessions(req as Request, res as Response) @@ -310,7 +314,7 @@ describe('SessionController', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'Validation failed' }) + expect.objectContaining({ error: 'Validation failed' }), ) }) @@ -363,12 +367,19 @@ describe('SessionController', () => { const body = vi.mocked(res.json).mock.calls[0][0] as { sessions: { id: string; isCurrent: boolean }[] } - expect(body.sessions.find(s => s.id === 'session-current')?.isCurrent).toBe(true) - expect(body.sessions.find(s => s.id === 'session-other')?.isCurrent).toBe(false) + expect( + body.sessions.find((s) => s.id === 'session-current')?.isCurrent, + ).toBe(true) + expect( + body.sessions.find((s) => s.id === 'session-other')?.isCurrent, + ).toBe(false) }) it('handles an empty session list gracefully', async () => { - vi.mocked(sessionService.list).mockResolvedValue({ sessions: [], total: 0 }) + vi.mocked(sessionService.list).mockResolvedValue({ + sessions: [], + total: 0, + }) controller.listSessions(makeReq() as Request, res as Response) await flushPromises() @@ -397,12 +408,14 @@ describe('SessionController', () => { expect(res.status).toHaveBeenCalledWith(200) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Session revoked successfully' }) + expect.objectContaining({ message: 'Session revoked successfully' }), ) }) it('returns 404 when session is not found', async () => { - vi.mocked(sessionService.revokeOne).mockResolvedValue({ kind: 'not_found' }) + vi.mocked(sessionService.revokeOne).mockResolvedValue({ + kind: 'not_found', + }) const req = makeReq({ params: { sessionId: VALID_SESSION_ID } }) controller.revokeSession(req as Request, res as Response) @@ -413,7 +426,9 @@ describe('SessionController', () => { }) it('returns 404 (not 403) for a cross-user session — prevents session-existence leaking', async () => { - vi.mocked(sessionService.revokeOne).mockResolvedValue({ kind: 'cross_user' }) + vi.mocked(sessionService.revokeOne).mockResolvedValue({ + kind: 'cross_user', + }) const req = makeReq({ params: { sessionId: VALID_SESSION_ID } }) controller.revokeSession(req as Request, res as Response) @@ -426,7 +441,9 @@ describe('SessionController', () => { }) it('returns 400 with code CURRENT_SESSION when caller tries to revoke their own current session', async () => { - vi.mocked(sessionService.revokeOne).mockResolvedValue({ kind: 'current_session' }) + vi.mocked(sessionService.revokeOne).mockResolvedValue({ + kind: 'current_session', + }) const req = makeReq({ params: { sessionId: VALID_SESSION_ID } }) controller.revokeSession(req as Request, res as Response) @@ -434,7 +451,7 @@ describe('SessionController', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ code: 'CURRENT_SESSION' }) + expect.objectContaining({ code: 'CURRENT_SESSION' }), ) }) @@ -445,7 +462,7 @@ describe('SessionController', () => { expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'Validation failed' }) + expect.objectContaining({ error: 'Validation failed' }), ) // Service must NOT have been called expect(sessionService.revokeOne).not.toHaveBeenCalled() @@ -460,7 +477,9 @@ describe('SessionController', () => { }) it('returns 500 when sessionService.revokeOne throws', async () => { - vi.mocked(sessionService.revokeOne).mockRejectedValue(new Error('db failure')) + vi.mocked(sessionService.revokeOne).mockRejectedValue( + new Error('db failure'), + ) const req = makeReq({ params: { sessionId: VALID_SESSION_ID } }) controller.revokeSession(req as Request, res as Response) @@ -493,7 +512,7 @@ describe('SessionController', () => { expect.objectContaining({ ipAddress: '10.0.0.1', userAgent: 'TestAgent/1.0', - }) + }), ) }) }) @@ -502,19 +521,25 @@ describe('SessionController', () => { describe('revokeAllOtherSessions', () => { it('returns 200 with the revoked count', async () => { - vi.mocked(sessionService.revokeAll).mockResolvedValue({ kind: 'ok', revokedCount: 3 }) + vi.mocked(sessionService.revokeAll).mockResolvedValue({ + kind: 'ok', + revokedCount: 3, + }) controller.revokeAllOtherSessions(makeReq() as Request, res as Response) await flushPromises() expect(res.status).toHaveBeenCalledWith(200) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ revokedCount: 3 }) + expect.objectContaining({ revokedCount: 3 }), ) }) it('uses plural message when revokedCount > 1', async () => { - vi.mocked(sessionService.revokeAll).mockResolvedValue({ kind: 'ok', revokedCount: 5 }) + vi.mocked(sessionService.revokeAll).mockResolvedValue({ + kind: 'ok', + revokedCount: 5, + }) controller.revokeAllOtherSessions(makeReq() as Request, res as Response) await flushPromises() @@ -524,7 +549,10 @@ describe('SessionController', () => { }) it('uses singular message when revokedCount === 1', async () => { - vi.mocked(sessionService.revokeAll).mockResolvedValue({ kind: 'ok', revokedCount: 1 }) + vi.mocked(sessionService.revokeAll).mockResolvedValue({ + kind: 'ok', + revokedCount: 1, + }) controller.revokeAllOtherSessions(makeReq() as Request, res as Response) await flushPromises() @@ -534,7 +562,10 @@ describe('SessionController', () => { }) it('returns appropriate message when there are no other sessions', async () => { - vi.mocked(sessionService.revokeAll).mockResolvedValue({ kind: 'ok', revokedCount: 0 }) + vi.mocked(sessionService.revokeAll).mockResolvedValue({ + kind: 'ok', + revokedCount: 0, + }) controller.revokeAllOtherSessions(makeReq() as Request, res as Response) await flushPromises() @@ -561,12 +592,16 @@ describe('SessionController', () => { await flushPromises() expect(res2.status).toHaveBeenCalledWith(200) - const body2 = vi.mocked(res2.json).mock.calls[0][0] as { revokedCount: number } + const body2 = vi.mocked(res2.json).mock.calls[0][0] as { + revokedCount: number + } expect(body2.revokedCount).toBe(0) }) it('returns 500 when sessionService.revokeAll throws', async () => { - vi.mocked(sessionService.revokeAll).mockRejectedValue(new Error('db down')) + vi.mocked(sessionService.revokeAll).mockRejectedValue( + new Error('db down'), + ) controller.revokeAllOtherSessions(makeReq() as Request, res as Response) await flushPromises() @@ -576,7 +611,10 @@ describe('SessionController', () => { }) it('passes userId, currentSessionId, and audit context to service', async () => { - vi.mocked(sessionService.revokeAll).mockResolvedValue({ kind: 'ok', revokedCount: 2 }) + vi.mocked(sessionService.revokeAll).mockResolvedValue({ + kind: 'ok', + revokedCount: 2, + }) const req = makeReq({ ip: '172.16.0.5', @@ -596,7 +634,7 @@ describe('SessionController', () => { expect.objectContaining({ ipAddress: '172.16.0.5', userAgent: 'Mozilla/5.0', - }) + }), ) }) }) @@ -647,7 +685,10 @@ describe('SessionService', () => { const session1 = { ...dbRow, id: 'session-1' } const session2 = { ...dbRow, id: 'session-current' } - vi.mocked(prisma.$transaction).mockResolvedValue([[session1, session2], 2]) + vi.mocked(prisma.$transaction).mockResolvedValue([ + [session1, session2], + 2, + ]) const result = await service.list('user-1', 'session-current', 1, 20) @@ -697,7 +738,12 @@ describe('SessionService', () => { it('returns not_found when session does not exist', async () => { vi.mocked(prisma.session.findUnique).mockResolvedValue(null) - const result = await service.revokeOne('user-1', 'sess-missing', null, ctx) + const result = await service.revokeOne( + 'user-1', + 'sess-missing', + null, + ctx, + ) expect(result.kind).toBe('not_found') }) @@ -751,7 +797,12 @@ describe('SessionService', () => { expiresAt: future, } as any) - const result = await service.revokeOne('user-1', 'sess-current', 'sess-current', ctx) + const result = await service.revokeOne( + 'user-1', + 'sess-current', + 'sess-current', + ctx, + ) expect(result.kind).toBe('current_session') expect(prisma.$transaction).not.toHaveBeenCalled() @@ -782,7 +833,7 @@ describe('SessionService', () => { action: 'SESSION_REVOKED', userId: 'user-1', }), - }) + }), ) }) }) @@ -793,7 +844,9 @@ describe('SessionService', () => { const ctx = { ipAddress: '1.2.3.4', userAgent: 'test' } it('returns ok with the count of revoked sessions', async () => { - vi.mocked(prisma.session.updateMany).mockResolvedValue({ count: 4 } as any) + vi.mocked(prisma.session.updateMany).mockResolvedValue({ + count: 4, + } as any) vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any) const result = await service.revokeAll('user-1', null, ctx) @@ -803,7 +856,9 @@ describe('SessionService', () => { }) it('excludes the current session from bulk revocation', async () => { - vi.mocked(prisma.session.updateMany).mockResolvedValue({ count: 2 } as any) + vi.mocked(prisma.session.updateMany).mockResolvedValue({ + count: 2, + } as any) vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any) await service.revokeAll('user-1', 'session-current', ctx) @@ -813,12 +868,14 @@ describe('SessionService', () => { where: expect.objectContaining({ NOT: { id: { in: ['session-current'] } }, }), - }) + }), ) }) it('writes SESSION_ALL_REVOKED audit entry when sessions were revoked', async () => { - vi.mocked(prisma.session.updateMany).mockResolvedValue({ count: 3 } as any) + vi.mocked(prisma.session.updateMany).mockResolvedValue({ + count: 3, + } as any) vi.mocked(prisma.auditLog.create).mockResolvedValue({} as any) await service.revokeAll('user-1', null, ctx) @@ -829,12 +886,14 @@ describe('SessionService', () => { action: 'SESSION_ALL_REVOKED', userId: 'user-1', }), - }) + }), ) }) it('does NOT write an audit entry when revokedCount is 0', async () => { - vi.mocked(prisma.session.updateMany).mockResolvedValue({ count: 0 } as any) + vi.mocked(prisma.session.updateMany).mockResolvedValue({ + count: 0, + } as any) await service.revokeAll('user-1', null, ctx) @@ -842,7 +901,9 @@ describe('SessionService', () => { }) it('returns revokedCount 0 when all sessions are already revoked', async () => { - vi.mocked(prisma.session.updateMany).mockResolvedValue({ count: 0 } as any) + vi.mocked(prisma.session.updateMany).mockResolvedValue({ + count: 0, + } as any) const result = await service.revokeAll('user-1', null, ctx) @@ -851,7 +912,9 @@ describe('SessionService', () => { }) it('includes metadata about whether the current session was kept', async () => { - vi.mocked(prisma.session.updateMany).mockResolvedValue({ count: 2 } as any) + vi.mocked(prisma.session.updateMany).mockResolvedValue({ + count: 2, + } as any) let auditData: any vi.mocked(prisma.auditLog.create).mockImplementation((args: any) => { diff --git a/tests/setup.ts b/tests/setup.ts index a88abe88..c3957ba0 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -12,15 +12,17 @@ try { const dbUrl = validateTestDatabaseUrl() const schemaName = getWorkerSchemaName() - createWorkerSchema(dbUrl, schemaName).then(() => { - process.env.DATABASE_URL = buildWorkerDatabaseUrl(dbUrl, schemaName) - }).catch((err) => { - console.warn( - '[setup] Could not create worker schema. ' + - 'Integration tests requiring a database will fail. ' + - `Error: ${(err as Error).message}`, - ) - }) + createWorkerSchema(dbUrl, schemaName) + .then(() => { + process.env.DATABASE_URL = buildWorkerDatabaseUrl(dbUrl, schemaName) + }) + .catch((err) => { + console.warn( + '[setup] Could not create worker schema. ' + + 'Integration tests requiring a database will fail. ' + + `Error: ${(err as Error).message}`, + ) + }) } catch (err) { console.warn( '[setup] Database URL validation failed. ' + diff --git a/tests/stellar-funding.service.test.ts b/tests/stellar-funding.service.test.ts index 8fa15732..454181b6 100644 --- a/tests/stellar-funding.service.test.ts +++ b/tests/stellar-funding.service.test.ts @@ -3,13 +3,14 @@ import { StellarFundingService } from '../src/services/stellar-funding.service' import { StellarServiceError } from '../src/services/stellar.service' import type { StellarService } from '../src/services/stellar.service' -const { mockFindUnique, mockCreate, mockFindMany, mockUpdate } = - vi.hoisted(() => ({ +const { mockFindUnique, mockCreate, mockFindMany, mockUpdate } = vi.hoisted( + () => ({ mockFindUnique: vi.fn(), mockCreate: vi.fn(), mockFindMany: vi.fn(), mockUpdate: vi.fn(), - })) + }), +) vi.mock('../src/config/database', () => ({ default: { @@ -32,18 +33,19 @@ vi.mock('../src/config/stellar', () => ({ network: 'testnet', funding: { get amount() { - return mockConfigAmount() -}, + return mockConfigAmount() + }, get minBalance() { - return mockConfigMinBalance() -}, + return mockConfigMinBalance() + }, maxRetries: 5, backoffBaseMinutes: 5, }, }, })) -const PUBLIC_KEY = 'GABCDEF12345678901234567890123456789012345678901234567890123' +const PUBLIC_KEY = + 'GABCDEF12345678901234567890123456789012345678901234567890123' const FUNDING_AMOUNT = '10' describe('StellarFundingService', () => { @@ -116,7 +118,7 @@ describe('StellarFundingService', () => { expect.objectContaining({ where: { id: record.id }, data: expect.objectContaining({ status: 'confirmed' }), - }) + }), ) expect(stellarMock.sendPayment).not.toHaveBeenCalled() }) @@ -143,7 +145,7 @@ describe('StellarFundingService', () => { sourceSecret: 'SFAKE_SECRET_KEY', destinationPublicKey: record.publicKey, amount: FUNDING_AMOUNT, - }) + }), ) expect(mockUpdate).toHaveBeenCalledWith( expect.objectContaining({ @@ -153,7 +155,7 @@ describe('StellarFundingService', () => { transactionHash: 'TXHASH123', ledger: 42, }), - }) + }), ) }) }) @@ -166,8 +168,8 @@ describe('StellarFundingService', () => { vi.mocked(stellarMock.sendPayment).mockRejectedValue( new StellarServiceError( 'Transaction TXHASH123 not confirmed after 20 attempts', - 'TRANSACTION_TIMEOUT' - ) + 'TRANSACTION_TIMEOUT', + ), ) await service.processQueue() @@ -179,7 +181,7 @@ describe('StellarFundingService', () => { status: 'submitted', error: 'Transaction submitted, awaiting confirmation', }), - }) + }), ) }) }) @@ -196,7 +198,7 @@ describe('StellarFundingService', () => { expect.objectContaining({ where: { id: record.id }, data: expect.objectContaining({ status: 'confirmed' }), - }) + }), ) }) @@ -220,7 +222,7 @@ describe('StellarFundingService', () => { transactionHash: 'TXHASH123', ledger: 42, }), - }) + }), ) }) @@ -243,7 +245,7 @@ describe('StellarFundingService', () => { status: 'pending', error: 'Reconciliation: funding not confirmed, retrying', }), - }) + }), ) }) }) @@ -271,7 +273,7 @@ describe('StellarFundingService', () => { expect(mockUpdate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'confirmed' }), - }) + }), ) }) }) @@ -281,7 +283,9 @@ describe('StellarFundingService', () => { const record = makeRecord({ status: 'pending', retryCount: 0 }) mockFindMany.mockResolvedValue([record]) vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') - vi.mocked(stellarMock.sendPayment).mockRejectedValue(new Error('Network error')) + vi.mocked(stellarMock.sendPayment).mockRejectedValue( + new Error('Network error'), + ) await service.processQueue() @@ -292,7 +296,7 @@ describe('StellarFundingService', () => { retryCount: { increment: 1 }, lastAttemptAt: expect.any(Date), }), - }) + }), ) }) @@ -304,13 +308,15 @@ describe('StellarFundingService', () => { }) mockFindMany.mockResolvedValue([record]) vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') - vi.mocked(stellarMock.sendPayment).mockRejectedValue(new Error('Final failure')) + vi.mocked(stellarMock.sendPayment).mockRejectedValue( + new Error('Final failure'), + ) await service.processQueue() const calls = mockUpdate.mock.calls const deadLetterCall = calls.find( - (c: any[]) => c[0]?.data?.status === 'dead-letter' + (c: any[]) => c[0]?.data?.status === 'dead-letter', ) expect(deadLetterCall).toBeDefined() }) @@ -325,7 +331,7 @@ describe('StellarFundingService', () => { where: expect.objectContaining({ retryCount: { lt: 5 }, }), - }) + }), ) }) }) @@ -340,15 +346,14 @@ describe('StellarFundingService', () => { const paymentError = new StellarServiceError( 'Payment transaction failed', 'PAYMENT_ERROR', - cause + cause, ) vi.mocked(stellarMock.sendPayment).mockRejectedValue(paymentError) await service.processQueue() - const errorUpdate = mockUpdate.mock.calls.find( - (c: any[]) => - c[0]?.data?.error?.includes('Insufficient funding source balance') + const errorUpdate = mockUpdate.mock.calls.find((c: any[]) => + c[0]?.data?.error?.includes('Insufficient funding source balance'), ) expect(errorUpdate).toBeDefined() }) @@ -359,10 +364,10 @@ describe('StellarFundingService', () => { const record = makeRecord({ status: 'pending' }) mockFindMany.mockResolvedValue([record]) vi.mocked(stellarMock.getNativeBalance).mockRejectedValue( - new Error('Network timeout') + new Error('Network timeout'), ) vi.mocked(stellarMock.sendPayment).mockRejectedValue( - new Error('Also down') + new Error('Also down'), ) await service.processQueue() @@ -375,13 +380,13 @@ describe('StellarFundingService', () => { mockFindMany.mockResolvedValue([record]) vi.mocked(stellarMock.getNativeBalance).mockResolvedValue('0') vi.mocked(stellarMock.sendPayment).mockRejectedValue( - new Error('Horizon unreachable') + new Error('Horizon unreachable'), ) await service.processQueue() const errorUpdate = mockUpdate.mock.calls.find( - (c: any[]) => c[0]?.data?.error === 'Horizon unreachable' + (c: any[]) => c[0]?.data?.error === 'Horizon unreachable', ) expect(errorUpdate).toBeDefined() }) @@ -405,7 +410,7 @@ describe('StellarFundingService', () => { expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ amount: '25' }), - }) + }), ) }) diff --git a/tests/stellar-wallet-status.service.test.ts b/tests/stellar-wallet-status.service.test.ts index a1bd0f6f..bdecf730 100644 --- a/tests/stellar-wallet-status.service.test.ts +++ b/tests/stellar-wallet-status.service.test.ts @@ -81,26 +81,46 @@ describe('StellarService — wallet status additions', () => { expect(snapshot.found).toBe(true) expect(snapshot.lastModifiedTime).toBe('2026-08-30T00:00:00Z') expect(snapshot.balances).toEqual([ - { assetType: 'native', assetCode: 'XLM', issuer: null, amount: '100.1234567' }, - { assetType: 'credit_alphanum4', assetCode: 'USDC', issuer: 'GCISSUER...', amount: '5.0000001' }, + { + assetType: 'native', + assetCode: 'XLM', + issuer: null, + amount: '100.1234567', + }, + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + issuer: 'GCISSUER...', + amount: '5.0000001', + }, ]) }) it('treats a 404 (unfunded account) as found: false rather than an error', async () => { - const notFound = Object.assign(new Error('not found'), { response: { status: 404 } }) + const notFound = Object.assign(new Error('not found'), { + response: { status: 404 }, + }) mockGetAccount.mockRejectedValue(notFound) const snapshot = await service.getAccountSnapshot('GPUBKEY...') - expect(snapshot).toEqual({ found: false, lastModifiedTime: null, balances: [] }) + expect(snapshot).toEqual({ + found: false, + lastModifiedTime: null, + balances: [], + }) }) it('classifies a timeout as HORIZON_TIMEOUT', async () => { mockGetAccount.mockRejectedValue( - Object.assign(new Error('timeout of 30000ms exceeded'), { code: 'ECONNABORTED' }), + Object.assign(new Error('timeout of 30000ms exceeded'), { + code: 'ECONNABORTED', + }), ) - await expect(service.getAccountSnapshot('GPUBKEY...')).rejects.toMatchObject({ + await expect( + service.getAccountSnapshot('GPUBKEY...'), + ).rejects.toMatchObject({ code: 'HORIZON_TIMEOUT', }) }) @@ -108,7 +128,9 @@ describe('StellarService — wallet status additions', () => { it('classifies other failures as HORIZON_UNAVAILABLE', async () => { mockGetAccount.mockRejectedValue(new Error('ECONNREFUSED')) - await expect(service.getAccountSnapshot('GPUBKEY...')).rejects.toMatchObject({ + await expect( + service.getAccountSnapshot('GPUBKEY...'), + ).rejects.toMatchObject({ code: 'HORIZON_UNAVAILABLE', }) }) @@ -172,7 +194,10 @@ describe('StellarService — wallet status additions', () => { to: 'GRECEIVER', asset_type: 'native', amount: '1.0000000', - transaction: { memo_type: 'text', memo: `bad\x00memo${'x'.repeat(300)}` }, + transaction: { + memo_type: 'text', + memo: `bad\x00memo${'x'.repeat(300)}`, + }, }, ], }) @@ -186,7 +211,13 @@ describe('StellarService — wallet status additions', () => { it('excludes non-payment operation types (e.g. trustline changes)', async () => { mockPaymentsCall.mockResolvedValue({ records: [ - { id: 'op-1', paging_token: 'tok-1', type: 'change_trust', created_at: 'x', transaction_hash: 'h' }, + { + id: 'op-1', + paging_token: 'tok-1', + type: 'change_trust', + created_at: 'x', + transaction_hash: 'h', + }, ], }) @@ -196,7 +227,9 @@ describe('StellarService — wallet status additions', () => { }) it('returns an empty page for a 404 rather than throwing', async () => { - mockPaymentsCall.mockRejectedValue(Object.assign(new Error('not found'), { response: { status: 404 } })) + mockPaymentsCall.mockRejectedValue( + Object.assign(new Error('not found'), { response: { status: 404 } }), + ) const page = await service.getPaymentHistory('GRECEIVER') @@ -206,7 +239,9 @@ describe('StellarService — wallet status additions', () => { it('classifies provider failures as HORIZON_UNAVAILABLE', async () => { mockPaymentsCall.mockRejectedValue(new Error('ECONNRESET')) - await expect(service.getPaymentHistory('GRECEIVER')).rejects.toMatchObject({ + await expect( + service.getPaymentHistory('GRECEIVER'), + ).rejects.toMatchObject({ code: 'HORIZON_UNAVAILABLE', }) }) diff --git a/tests/stellar.service.test.ts b/tests/stellar.service.test.ts index a2c278c6..a0fcb8c6 100644 --- a/tests/stellar.service.test.ts +++ b/tests/stellar.service.test.ts @@ -55,14 +55,14 @@ describe('StellarService', () => { this._pub = pub this._sec = sec } - publicKey () { + publicKey() { return this._pub } - secret () { + secret() { return this._sec } - static random () { + static random() { const seg = () => Math.random().toString(36).slice(2).toUpperCase().padEnd(11, 'A') const pub = ('G' + seg() + seg() + seg() + seg() + seg()).slice(0, 56) @@ -71,44 +71,44 @@ describe('StellarService', () => { return new FakeKeypair(pub, sec) } - static fromSecret (secret: string) { + static fromSecret(secret: string) { return new FakeKeypair(('G' + secret.slice(1)).slice(0, 56), secret) } } // ── FakeTransactionBuilder ───────────────────────────────────────────── class FakeTransactionBuilder { - addOperation (_op: unknown) { + addOperation(_op: unknown) { return this } - addMemo (_m: unknown) { + addMemo(_m: unknown) { return this } - setTimeout (_t: number) { + setTimeout(_t: number) { return this } - build () { + build() { return { sign: vi.fn() } } } // ── FakeServer (MUST use `function`, not arrow) ─────────────────────── - function FakeServer (this: any) { + function FakeServer(this: any) { this.getAccount = mockGetAccount this.sendTransaction = mockSendTransaction this.simulateTransaction = mockSimulateTransaction this.getTransaction = mockGetTransaction } - function FakeHorizonServer (this: any) { + function FakeHorizonServer(this: any) { this.loadAccount = mockGetAccount this.submitTransaction = mockSubmitTransaction } // ── FakeContract (MUST use `function`, not arrow) ───────────────────── - function FakeContract (this: any) { + function FakeContract(this: any) { this.call = vi.fn().mockReturnValue('mock_operation') } @@ -141,15 +141,11 @@ describe('StellarService', () => { })), Api: { - isSimulationError: vi.fn( - (r: unknown) => - Boolean(r && typeof r === 'object' && 'error' in (r as object)) + isSimulationError: vi.fn((r: unknown) => + Boolean(r && typeof r === 'object' && 'error' in (r as object)), ), - isSimulationSuccess: vi.fn( - (r: unknown) => - Boolean( - r && typeof r === 'object' && !('error' in (r as object)) - ) + isSimulationSuccess: vi.fn((r: unknown) => + Boolean(r && typeof r === 'object' && !('error' in (r as object))), ), GetTransactionStatus, }, @@ -179,12 +175,11 @@ describe('StellarService', () => { } }) - // Typed helper for FakeKeypair static methods type FakeKeypairStatic = { - random: () => { publicKey: () => string; secret: () => string }; - fromSecret: (s: string) => { publicKey: () => string; secret: () => string }; - }; + random: () => { publicKey: () => string; secret: () => string } + fromSecret: (s: string) => { publicKey: () => string; secret: () => string } + } const FakeKeypair = StellarSdk.Keypair as unknown as FakeKeypairStatic // --------------------------------------------------------------------------- @@ -229,14 +224,14 @@ describe('StellarService', () => { it('throws StellarServiceError on mainnet', async () => { const mainnetService = new StellarService('mainnet') await expect( - mainnetService.fundTestnetAccount('GABC...') + mainnetService.fundTestnetAccount('GABC...'), ).rejects.toThrow(StellarServiceError) }) it('error code is INVALID_NETWORK on mainnet', async () => { const mainnetService = new StellarService('mainnet') await expect( - mainnetService.fundTestnetAccount('GABC...') + mainnetService.fundTestnetAccount('GABC...'), ).rejects.toMatchObject({ code: 'INVALID_NETWORK' }) }) @@ -245,14 +240,14 @@ describe('StellarService', () => { const { publicKey } = service.generateWallet() await service.fundTestnetAccount(publicKey) expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining(encodeURIComponent(publicKey)) + expect.stringContaining(encodeURIComponent(publicKey)), ) }) it('throws FRIENDBOT_ERROR when friendbot returns non-ok response', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 400 }) await expect( - service.fundTestnetAccount('GPUBKEY...') + service.fundTestnetAccount('GPUBKEY...'), ).rejects.toMatchObject({ code: 'FRIENDBOT_ERROR' }) }) }) @@ -311,7 +306,7 @@ describe('StellarService', () => { expect(await service.getNativeBalance('GPUBKEY...')).toBe('42.0000000') }) - it('returns \'0\' when no native balance exists', async () => { + it("returns '0' when no native balance exists", async () => { mockGetAccount.mockResolvedValue({ balances: [] }) expect(await service.getNativeBalance('GPUBKEY...')).toBe('0') }) @@ -329,9 +324,12 @@ describe('StellarService', () => { sequence: '1234', balances: [{ asset_type: 'native', balance: '1000.0000000' }], incrementSequenceNumber: vi.fn(), - }) + }), ) - mockSubmitTransaction.mockResolvedValue({ successful: true, hash: 'TXHASH123' }) + mockSubmitTransaction.mockResolvedValue({ + successful: true, + hash: 'TXHASH123', + }) mockGetTransaction.mockResolvedValue({ status: 'SUCCESS', ledger: 999 }) }) @@ -366,7 +364,7 @@ describe('StellarService', () => { sourceSecret: sourceKeypair.secret(), destinationPublicKey: FakeKeypair.random().publicKey(), amount: '10', - }) + }), ).rejects.toMatchObject({ code: 'PAYMENT_ERROR' }) }) }) @@ -388,7 +386,10 @@ describe('StellarService', () => { transactionData: 'mock_footprint', minResourceFee: '100', }) - mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'CREDHASH456' }) + mockSendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'CREDHASH456', + }) mockGetTransaction.mockResolvedValue({ status: 'SUCCESS', ledger: 1001, @@ -415,7 +416,7 @@ describe('StellarService', () => { recipientPublicKey: 'GDEST...', credentialType: 'ID', data: {}, - }) + }), ).rejects.toMatchObject({ code: 'CONTRACT_NOT_CONFIGURED' }) }) @@ -426,7 +427,7 @@ describe('StellarService', () => { recipientPublicKey: FakeKeypair.random().publicKey(), credentialType: 'ID', data: {}, - }) + }), ).rejects.toMatchObject({ code: 'CREDENTIAL_ISSUANCE_ERROR' }) }) }) @@ -508,7 +509,9 @@ describe('StellarService', () => { describe('StellarServiceError', () => { it('has name StellarServiceError', () => { - expect(new StellarServiceError('msg', 'CODE').name).toBe('StellarServiceError') + expect(new StellarServiceError('msg', 'CODE').name).toBe( + 'StellarServiceError', + ) }) it('exposes code and message', () => { @@ -519,11 +522,13 @@ describe('StellarService', () => { it('stores the original cause', () => { const cause = new Error('original') - expect(new StellarServiceError('wrapped', 'CODE', cause).cause).toBe(cause) + expect(new StellarServiceError('wrapped', 'CODE', cause).cause).toBe( + cause, + ) }) it('is an instance of Error', () => { expect(new StellarServiceError('test', 'CODE')).toBeInstanceOf(Error) }) }) -}) \ No newline at end of file +}) diff --git a/tests/sync.controller.test.ts b/tests/sync.controller.test.ts index 67099c85..0ae8279d 100644 --- a/tests/sync.controller.test.ts +++ b/tests/sync.controller.test.ts @@ -22,7 +22,8 @@ vi.mock('../src/config/database', () => ({ import prisma from '../src/config/database' -const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) +const flushPromises = () => + new Promise((resolve) => setTimeout(resolve, 0)) interface AuthRequest extends Request { user?: { id: string; email: string; role: string } @@ -73,7 +74,9 @@ describe('SyncController', () => { controller.syncProgress(req as Request, res as Response, next) await flushPromises() - expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'User ID not found' })) + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ message: 'User ID not found' }), + ) }) it('throws BadRequestError when events is not an array', async () => { @@ -82,7 +85,11 @@ describe('SyncController', () => { controller.syncProgress(req as Request, res as Response, next) await flushPromises() - expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'events must be a non-empty array' })) + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'events must be a non-empty array', + }), + ) }) it('rejects event with missing required fields', async () => { @@ -104,12 +111,16 @@ describe('SyncController', () => { await flushPromises() const call = vi.mocked(res.json).mock.calls[0][0] - expect(call.data.results[0]).toEqual(expect.objectContaining({ status: 'rejected' })) + expect(call.data.results[0]).toEqual( + expect.objectContaining({ status: 'rejected' }), + ) }) it('skips duplicate idempotency key', async () => { req.body = { events: [makeEvent()] } - vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue({ id: 'existing' } as any) + vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue({ + id: 'existing', + } as any) controller.syncProgress(req as Request, res as Response, next) await flushPromises() @@ -121,7 +132,9 @@ describe('SyncController', () => { it('skips stale sync version', async () => { req.body = { events: [makeEvent({ syncVersion: 1 })] } vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue(null) - vi.mocked(prisma.syncEvent.findFirst).mockResolvedValue({ syncVersion: 5 } as any) + vi.mocked(prisma.syncEvent.findFirst).mockResolvedValue({ + syncVersion: 5, + } as any) controller.syncProgress(req as Request, res as Response, next) await flushPromises() @@ -172,7 +185,9 @@ describe('SyncController', () => { controller.syncCompletions(req as Request, res as Response, next) await flushPromises() - expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'User ID not found' })) + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ message: 'User ID not found' }), + ) }) it('rejects event for unknown module', async () => { @@ -184,12 +199,19 @@ describe('SyncController', () => { await flushPromises() const call = vi.mocked(res.json).mock.calls[0][0] - expect(call.data.results[0]).toEqual(expect.objectContaining({ status: 'rejected', reason: 'Module not found' })) + expect(call.data.results[0]).toEqual( + expect.objectContaining({ + status: 'rejected', + reason: 'Module not found', + }), + ) }) it('skips duplicate idempotency key', async () => { req.body = { events: [makeCompletionEvent()] } - vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue({ id: 'existing' } as any) + vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue({ + id: 'existing', + } as any) controller.syncCompletions(req as Request, res as Response, next) await flushPromises() @@ -201,8 +223,12 @@ describe('SyncController', () => { it('skips completion if existing score is higher', async () => { req.body = { events: [makeCompletionEvent({ score: 60 })] } vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue(null) - vi.mocked(prisma.module.findUnique).mockResolvedValue({ id: 'module-1' } as any) - vi.mocked(prisma.completion.findUnique).mockResolvedValue({ score: 90 } as any) + vi.mocked(prisma.module.findUnique).mockResolvedValue({ + id: 'module-1', + } as any) + vi.mocked(prisma.completion.findUnique).mockResolvedValue({ + score: 90, + } as any) vi.mocked(prisma.syncEvent.create).mockResolvedValue({} as any) controller.syncCompletions(req as Request, res as Response, next) @@ -216,8 +242,12 @@ describe('SyncController', () => { it('updates completion when new score is higher', async () => { req.body = { events: [makeCompletionEvent({ score: 95 })] } vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue(null) - vi.mocked(prisma.module.findUnique).mockResolvedValue({ id: 'module-1' } as any) - vi.mocked(prisma.completion.findUnique).mockResolvedValue({ score: 70 } as any) + vi.mocked(prisma.module.findUnique).mockResolvedValue({ + id: 'module-1', + } as any) + vi.mocked(prisma.completion.findUnique).mockResolvedValue({ + score: 70, + } as any) vi.mocked(prisma.completion.update).mockResolvedValue({} as any) vi.mocked(prisma.syncEvent.create).mockResolvedValue({} as any) @@ -232,7 +262,9 @@ describe('SyncController', () => { it('creates new completion when none exists', async () => { req.body = { events: [makeCompletionEvent()] } vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue(null) - vi.mocked(prisma.module.findUnique).mockResolvedValue({ id: 'module-1' } as any) + vi.mocked(prisma.module.findUnique).mockResolvedValue({ + id: 'module-1', + } as any) vi.mocked(prisma.completion.findUnique).mockResolvedValue(null) vi.mocked(prisma.completion.create).mockResolvedValue({} as any) vi.mocked(prisma.syncEvent.create).mockResolvedValue({} as any) diff --git a/tests/unit/auth.middleware.test.ts b/tests/unit/auth.middleware.test.ts index 17c0c1ff..68a8d096 100644 --- a/tests/unit/auth.middleware.test.ts +++ b/tests/unit/auth.middleware.test.ts @@ -28,15 +28,25 @@ vi.mock('../../src/config/database', () => ({ })) // Dynamically import AFTER stubbing so the module-level guard sees the value -const { authenticate, optionalAuthenticate, authorize, requireActiveAccount, requireVerifiedEmail } = await import( - '../../src/middleware/auth.middleware' -) +const { + authenticate, + optionalAuthenticate, + authorize, + requireActiveAccount, + requireVerifiedEmail, +} = await import('../../src/middleware/auth.middleware') // ── helpers ─────────────────────────────────────────────────────────────────── -function makeToken ( +function makeToken( payload: Record, - overrides: { expiresIn?: string | number; issuer?: string; audience?: string; keyid?: string; algorithm?: jwt.Algorithm } = {}, + overrides: { + expiresIn?: string | number + issuer?: string + audience?: string + keyid?: string + algorithm?: jwt.Algorithm + } = {}, ): string { return jwt.sign(payload, JWT_SECRET, { expiresIn: overrides.expiresIn ?? '1h', @@ -47,7 +57,7 @@ function makeToken ( } as jwt.SignOptions) } -function makeMocks () { +function makeMocks() { const req = { headers: {} } as Partial const res = { status: vi.fn().mockReturnThis(), @@ -85,7 +95,9 @@ describe('authenticate', () => { authenticate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(401) - expect(res.json).toHaveBeenCalledWith({ message: 'Authorization token required' }) + expect(res.json).toHaveBeenCalledWith({ + message: 'Authorization token required', + }) expect(next).not.toHaveBeenCalled() }) @@ -96,13 +108,18 @@ describe('authenticate', () => { authenticate(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(401) - expect(res.json).toHaveBeenCalledWith({ message: 'Authorization token required' }) + expect(res.json).toHaveBeenCalledWith({ + message: 'Authorization token required', + }) expect(next).not.toHaveBeenCalled() }) it('returns 401 with "Token has expired" for an expired token', () => { const { req, res, next } = makeMocks() - const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { expiresIn: -1 }) + const token = makeToken( + { id: 'u1', email: 'x@y.com', role: 'learner' }, + { expiresIn: -1 }, + ) req.headers = { authorization: `Bearer ${token}` } authenticate(req as Request, res as Response, next) @@ -125,7 +142,10 @@ describe('authenticate', () => { it('returns 401 for a token signed with the wrong issuer', () => { const { req, res, next } = makeMocks() - const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { issuer: 'someone-else' }) + const token = makeToken( + { id: 'u1', email: 'x@y.com', role: 'learner' }, + { issuer: 'someone-else' }, + ) req.headers = { authorization: `Bearer ${token}` } authenticate(req as Request, res as Response, next) @@ -137,7 +157,10 @@ describe('authenticate', () => { it('returns 401 for a token signed with the wrong audience', () => { const { req, res, next } = makeMocks() - const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { audience: 'someone-else' }) + const token = makeToken( + { id: 'u1', email: 'x@y.com', role: 'learner' }, + { audience: 'someone-else' }, + ) req.headers = { authorization: `Bearer ${token}` } authenticate(req as Request, res as Response, next) @@ -149,7 +172,10 @@ describe('authenticate', () => { it('returns 401 for a token signed with an unrecognized key id', () => { const { req, res, next } = makeMocks() - const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { keyid: 'unknown-key' }) + const token = makeToken( + { id: 'u1', email: 'x@y.com', role: 'learner' }, + { keyid: 'unknown-key' }, + ) req.headers = { authorization: `Bearer ${token}` } authenticate(req as Request, res as Response, next) @@ -163,7 +189,10 @@ describe('authenticate', () => { const { req, res, next } = makeMocks() // none-algorithm/HS384 forgery attempt — must be rejected even though // jsonwebtoken itself can produce it. - const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { algorithm: 'HS384' }) + const token = makeToken( + { id: 'u1', email: 'x@y.com', role: 'learner' }, + { algorithm: 'HS384' }, + ) req.headers = { authorization: `Bearer ${token}` } authenticate(req as Request, res as Response, next) @@ -210,7 +239,10 @@ describe('optionalAuthenticate', () => { it('calls next() without blocking when token is expired', () => { const { req, res, next } = makeMocks() - const token = makeToken({ id: 'u1', email: 'x@y.com', role: 'learner' }, { expiresIn: -1 }) + const token = makeToken( + { id: 'u1', email: 'x@y.com', role: 'learner' }, + { expiresIn: -1 }, + ) req.headers = { authorization: `Bearer ${token}` } optionalAuthenticate(req as Request, res as Response, next) @@ -233,8 +265,8 @@ describe('requireActiveAccount', () => { }) it('calls next() for an ACTIVE account', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1' } findUniqueMock.mockResolvedValue({ status: 'ACTIVE' }) await requireActiveAccount(req as Request, res as Response, next) @@ -243,32 +275,36 @@ describe('requireActiveAccount', () => { }) it('returns 403 ACCOUNT_DEACTIVATED for a deactivated account', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1' } findUniqueMock.mockResolvedValue({ status: 'DEACTIVATED' }) await requireActiveAccount(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(403) - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' })) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' }), + ) expect(next).not.toHaveBeenCalled() }) it('returns 403 ACCOUNT_PENDING_DELETION for a pending-deletion account', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1' } findUniqueMock.mockResolvedValue({ status: 'PENDING_DELETION' }) await requireActiveAccount(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(403) - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'ACCOUNT_PENDING_DELETION' })) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'ACCOUNT_PENDING_DELETION' }), + ) expect(next).not.toHaveBeenCalled() }) it('returns 401 "Account not found" for a deleted or missing account', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1' } findUniqueMock.mockResolvedValue(null) await requireActiveAccount(req as Request, res as Response, next) @@ -292,8 +328,8 @@ describe('requireVerifiedEmail', () => { }) it('calls next() when the email is verified', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1' } findUniqueMock.mockResolvedValue({ isVerified: true }) await requireVerifiedEmail(req as Request, res as Response, next) @@ -302,14 +338,16 @@ describe('requireVerifiedEmail', () => { }) it('returns 403 EMAIL_NOT_VERIFIED when the email is unverified', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1' } findUniqueMock.mockResolvedValue({ isVerified: false }) await requireVerifiedEmail(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(403) - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'EMAIL_NOT_VERIFIED' })) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'EMAIL_NOT_VERIFIED' }), + ) expect(next).not.toHaveBeenCalled() }) }) @@ -318,8 +356,8 @@ describe('requireVerifiedEmail', () => { describe('authorize', () => { it('calls next() when the persisted role matches', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } findUniqueMock.mockResolvedValue({ role: 'learner', status: 'ACTIVE' }) await authorize('learner')(req as Request, res as Response, next) @@ -329,25 +367,31 @@ describe('authorize', () => { }) it('calls next() when the persisted role matches one of multiple allowed roles', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } findUniqueMock.mockResolvedValue({ role: 'employer', status: 'ACTIVE' }) - await authorize('learner', 'employer')(req as Request, res as Response, next) + await authorize('learner', 'employer')( + req as Request, + res as Response, + next, + ) expect(next).toHaveBeenCalledOnce() }) it('returns 403 when the persisted role is not in the allowed list', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } findUniqueMock.mockResolvedValue({ role: 'learner', status: 'ACTIVE' }) await authorize('employer')(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(403) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('Access denied') }), + expect.objectContaining({ + message: expect.stringContaining('Access denied'), + }), ) expect(next).not.toHaveBeenCalled() }) @@ -358,15 +402,17 @@ describe('authorize', () => { await authorize('learner')(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(401) - expect(res.json).toHaveBeenCalledWith({ message: 'Authentication required' }) + expect(res.json).toHaveBeenCalledWith({ + message: 'Authentication required', + }) expect(next).not.toHaveBeenCalled() }) it('uses the current persisted role even when the JWT claim is stale', async () => { - const { req, res, next } = makeMocks(); + const { req, res, next } = makeMocks() // Token still claims 'learner', but the account was promoted to // 'employer' in the database after the token was issued. - (req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } + ;(req as any).user = { id: 'u1', email: 'a@b.com', role: 'learner' } findUniqueMock.mockResolvedValue({ role: 'employer', status: 'ACTIVE' }) await authorize('employer')(req as Request, res as Response, next) @@ -376,20 +422,25 @@ describe('authorize', () => { }) it('returns 403 ACCOUNT_DEACTIVATED even if the JWT role would otherwise pass', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } - findUniqueMock.mockResolvedValue({ role: 'employer', status: 'DEACTIVATED' }) + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } + findUniqueMock.mockResolvedValue({ + role: 'employer', + status: 'DEACTIVATED', + }) await authorize('employer')(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(403) - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' })) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'ACCOUNT_DEACTIVATED' }), + ) expect(next).not.toHaveBeenCalled() }) it('returns 401 "Account not found" for a deleted account', async () => { - const { req, res, next } = makeMocks(); - (req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } + const { req, res, next } = makeMocks() + ;(req as any).user = { id: 'u1', email: 'a@b.com', role: 'employer' } findUniqueMock.mockResolvedValue(null) await authorize('employer')(req as Request, res as Response, next) diff --git a/tests/unit/credential.controller.test.ts b/tests/unit/credential.controller.test.ts index 4b7f5ee6..effc77bb 100644 --- a/tests/unit/credential.controller.test.ts +++ b/tests/unit/credential.controller.test.ts @@ -71,16 +71,18 @@ describe('CredentialController', () => { mockRequest.user = { id: 'user-1', email: 'john@example.com' } vi.mocked(prisma.credential.count).mockResolvedValue(1) - vi.mocked(prisma.credential.findMany).mockResolvedValue(mockCredentials as any) + vi.mocked(prisma.credential.findMany).mockResolvedValue( + mockCredentials as any, + ) await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(prisma.credential.count).toHaveBeenCalledWith({ where: { userId: 'user-1' }, @@ -113,7 +115,7 @@ describe('CredentialController', () => { await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(prisma.credential.count).toHaveBeenCalledWith({ @@ -134,7 +136,7 @@ describe('CredentialController', () => { await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(prisma.credential.count).toHaveBeenCalledWith({ @@ -155,11 +157,11 @@ describe('CredentialController', () => { await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Invalid fromDate format' }) + expect.objectContaining({ message: 'Invalid fromDate format' }), ) }) @@ -169,11 +171,11 @@ describe('CredentialController', () => { await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'User ID not found' }) + expect.objectContaining({ message: 'User ID not found' }), ) }) @@ -187,17 +189,17 @@ describe('CredentialController', () => { await credentialController.getUserCredentials( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(prisma.credential.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 5, take: 5, - }) + }), ) expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -209,7 +211,7 @@ describe('CredentialController', () => { hasNextPage: true, hasPrevPage: true, }), - }) + }), ) }) }) @@ -239,12 +241,14 @@ describe('CredentialController', () => { mockRequest.user = { id: 'user-1', email: 'john@example.com' } mockRequest.params = { id: 'cred-1' } - vi.mocked(prisma.credential.findUnique).mockResolvedValue(mockCredential as any) + vi.mocked(prisma.credential.findUnique).mockResolvedValue( + mockCredential as any, + ) await credentialController.getCredentialById( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(prisma.credential.findUnique).toHaveBeenCalledWith({ @@ -270,14 +274,14 @@ describe('CredentialController', () => { await credentialController.getCredentialById( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Credential not found' }) + expect.objectContaining({ message: 'Credential not found' }), ) }) @@ -301,19 +305,23 @@ describe('CredentialController', () => { mockRequest.user = { id: 'user-1', email: 'john@example.com' } mockRequest.params = { id: 'cred-1' } - vi.mocked(prisma.credential.findUnique).mockResolvedValue(mockCredential as any) + vi.mocked(prisma.credential.findUnique).mockResolvedValue( + mockCredential as any, + ) await credentialController.getCredentialById( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'You do not have access to this credential' }) + expect.objectContaining({ + message: 'You do not have access to this credential', + }), ) }) @@ -324,11 +332,11 @@ describe('CredentialController', () => { await credentialController.getCredentialById( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'User ID not found' }) + expect.objectContaining({ message: 'User ID not found' }), ) }) }) @@ -354,12 +362,14 @@ describe('CredentialController', () => { } mockRequest.params = { onChainId: 'chain-1' } - vi.mocked(prisma.credential.findFirst).mockResolvedValue(mockCredential as any) + vi.mocked(prisma.credential.findFirst).mockResolvedValue( + mockCredential as any, + ) await credentialController.verifyCredential( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(prisma.credential.findFirst).toHaveBeenCalledWith({ @@ -402,16 +412,18 @@ describe('CredentialController', () => { mockRequest.params = { onChainId: 'cred-1' } vi.mocked(prisma.credential.findFirst).mockResolvedValue(null) - vi.mocked(prisma.credential.findUnique).mockResolvedValue(mockCredential as any) + vi.mocked(prisma.credential.findUnique).mockResolvedValue( + mockCredential as any, + ) await credentialController.verifyCredential( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(prisma.credential.findFirst).toHaveBeenCalled() expect(prisma.credential.findUnique).toHaveBeenCalledWith({ @@ -422,7 +434,7 @@ describe('CredentialController', () => { expect.objectContaining({ success: true, data: expect.objectContaining({ valid: true }), - }) + }), ) }) @@ -434,14 +446,14 @@ describe('CredentialController', () => { await credentialController.verifyCredential( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)) + await new Promise((resolve) => setTimeout(resolve, 10)) expect(mockNext).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Credential not found or invalid' }) + expect.objectContaining({ message: 'Credential not found or invalid' }), ) }) @@ -463,12 +475,14 @@ describe('CredentialController', () => { mockRequest.user = undefined mockRequest.params = { onChainId: 'chain-1' } - vi.mocked(prisma.credential.findFirst).mockResolvedValue(mockCredential as any) + vi.mocked(prisma.credential.findFirst).mockResolvedValue( + mockCredential as any, + ) await credentialController.verifyCredential( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ) expect(mockResponse.json).toHaveBeenCalled() diff --git a/tests/unit/employer.controller.test.ts b/tests/unit/employer.controller.test.ts index dc2097aa..f0771dc9 100644 --- a/tests/unit/employer.controller.test.ts +++ b/tests/unit/employer.controller.test.ts @@ -1,6 +1,10 @@ import { Request, Response } from 'express' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { contactCandidate, getCandidateProfile, searchTalent } from '../../src/controllers/employer.controller' +import { + contactCandidate, + getCandidateProfile, + searchTalent, +} from '../../src/controllers/employer.controller' import prisma from '../../src/config/database' vi.mock('../../src/config/database', () => ({ @@ -125,7 +129,12 @@ describe('EmployerController', () => { { score: 91, completedAt: new Date('2026-02-01T00:00:00Z'), - module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, + module: { + id: 'm1', + title: 'Stellar Fundamentals', + category: 'blockchain', + difficulty: 'beginner', + }, }, ], credentials: [ @@ -133,7 +142,12 @@ describe('EmployerController', () => { id: 'cred-1', onChainId: 'onchain-abc', issuedAt: new Date('2026-02-03T00:00:00Z'), - module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, + module: { + id: 'm1', + title: 'Stellar Fundamentals', + category: 'blockchain', + difficulty: 'beginner', + }, }, ], }) @@ -166,7 +180,9 @@ describe('EmployerController', () => { await getCandidateProfile(req, res) expect(res.status).toHaveBeenCalledWith(403) - expect(res.json).toHaveBeenCalledWith({ message: 'Candidate profile is private' }) + expect(res.json).toHaveBeenCalledWith({ + message: 'Candidate profile is private', + }) }) it('contactCandidate requires pro plan', async () => { @@ -198,7 +214,9 @@ describe('EmployerController', () => { email: 'alice.learner+seed@learnault.dev', username: 'Alice Learner', }) - ;(prisma.webhookEndpoint.upsert as any).mockResolvedValue({ id: 'system-employer-outreach-log' }) + ;(prisma.webhookEndpoint.upsert as any).mockResolvedValue({ + id: 'system-employer-outreach-log', + }) ;(prisma.webhookDelivery.create as any).mockResolvedValue({ id: 'attempt-1', createdAt: new Date('2026-03-01T10:00:00Z'), @@ -230,7 +248,10 @@ describe('EmployerController', () => { expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ message: 'Candidate outreach recorded', - outreach: expect.objectContaining({ id: 'attempt-1', candidateId: 'cand-1' }), + outreach: expect.objectContaining({ + id: 'attempt-1', + candidateId: 'cand-1', + }), }), ) }) diff --git a/tests/unit/employer.routes.test.ts b/tests/unit/employer.routes.test.ts index f6f96931..172fece9 100644 --- a/tests/unit/employer.routes.test.ts +++ b/tests/unit/employer.routes.test.ts @@ -22,9 +22,10 @@ vi.mock('../../src/config/database', () => ({ })) const { issueAccessToken } = await import('../../src/config/jwt') -const employerRoutes = (await import('../../src/routes/v1/employer.routes')).default +const employerRoutes = (await import('../../src/routes/v1/employer.routes')) + .default -function makeToken (role: 'learner' | 'employer') { +function makeToken(role: 'learner' | 'employer') { return issueAccessToken({ id: 'user-1', role }) } @@ -44,7 +45,11 @@ describe('employer.routes', () => { }) it('restricts access to employer accounts only', async () => { - findUniqueMock.mockResolvedValue({ role: 'learner', status: 'ACTIVE', isVerified: true }) + findUniqueMock.mockResolvedValue({ + role: 'learner', + status: 'ACTIVE', + isVerified: true, + }) const app = express() app.use(express.json()) @@ -58,7 +63,11 @@ describe('employer.routes', () => { }) it('applies employer rate limiter and allows employer role', async () => { - findUniqueMock.mockResolvedValue({ role: 'employer', status: 'ACTIVE', isVerified: true }) + findUniqueMock.mockResolvedValue({ + role: 'employer', + status: 'ACTIVE', + isVerified: true, + }) const app = express() app.use(express.json()) @@ -73,7 +82,11 @@ describe('employer.routes', () => { }) it('rejects an employer with an unverified email', async () => { - findUniqueMock.mockResolvedValue({ role: 'employer', status: 'ACTIVE', isVerified: false }) + findUniqueMock.mockResolvedValue({ + role: 'employer', + status: 'ACTIVE', + isVerified: false, + }) const app = express() app.use(express.json()) diff --git a/tests/unit/errorHandler.test.ts b/tests/unit/errorHandler.test.ts index d614debd..b5c06777 100644 --- a/tests/unit/errorHandler.test.ts +++ b/tests/unit/errorHandler.test.ts @@ -11,8 +11,8 @@ function makeMocks() { json: vi.fn(), } as Partial const next: NextFunction = vi.fn() - -return { req, res, next } + + return { req, res, next } } // ── errorHandler ────────────────────────────────────────────────────────────── @@ -104,4 +104,4 @@ describe('errorHandler', () => { message: 'Forbidden', }) }) -}) \ No newline at end of file +}) diff --git a/tests/unit/jwt-config.test.ts b/tests/unit/jwt-config.test.ts index a4121e0e..2a59d225 100644 --- a/tests/unit/jwt-config.test.ts +++ b/tests/unit/jwt-config.test.ts @@ -25,7 +25,8 @@ describe('config/jwt — key rotation and token pinning', () => { vi.stubEnv('NODE_ENV', 'test') vi.stubEnv('JWT_SECRET', '') - const { issueAccessToken, verifyAccessToken } = await import('../../src/config/jwt') + const { issueAccessToken, verifyAccessToken } = + await import('../../src/config/jwt') const token = issueAccessToken({ id: 'u1', role: 'learner' }) const claims = verifyAccessToken(token) @@ -70,7 +71,10 @@ describe('config/jwt — key rotation and token pinning', () => { expect(claims.id).toBe('u1') - const newToken = rotatedModule.issueAccessToken({ id: 'u2', role: 'employer' }) + const newToken = rotatedModule.issueAccessToken({ + id: 'u2', + role: 'employer', + }) expect(rotatedModule.verifyAccessToken(newToken).id).toBe('u2') }) @@ -80,7 +84,8 @@ describe('config/jwt — key rotation and token pinning', () => { vi.stubEnv('JWT_KEY_ID', 'key-new') vi.stubEnv('JWT_PREVIOUS_KEYS', '') - const { issueAccessToken, verifyAccessToken } = await import('../../src/config/jwt') + const { issueAccessToken, verifyAccessToken } = + await import('../../src/config/jwt') const jwtModule = (await import('jsonwebtoken')).default const forged = jwtModule.sign({ id: 'attacker' }, 'guessed-secret', { keyid: 'never-registered', @@ -91,6 +96,8 @@ describe('config/jwt — key rotation and token pinning', () => { expect(() => verifyAccessToken(forged)).toThrow() // sanity: legitimate tokens from the same module still verify fine - expect(() => verifyAccessToken(issueAccessToken({ id: 'u1', role: 'learner' }))).not.toThrow() + expect(() => + verifyAccessToken(issueAccessToken({ id: 'u1', role: 'learner' })), + ).not.toThrow() }) }) diff --git a/tests/unit/money.test.ts b/tests/unit/money.test.ts index 1fece454..bb20936a 100644 --- a/tests/unit/money.test.ts +++ b/tests/unit/money.test.ts @@ -98,9 +98,7 @@ describe('stroopsToXlmString', () => { it('formats large amounts correctly', () => { // 100_000_000 XLM = 1_000_000_000_000_000 stroops - expect(stroopsToXlmString(1_000_000_000_000_000n)).toBe( - '100000000.0000000', - ) + expect(stroopsToXlmString(1_000_000_000_000_000n)).toBe('100000000.0000000') }) it('throws MoneyError for negative stroops', () => { @@ -401,11 +399,12 @@ describe('reward arithmetic integration', () => { const BASE = 50_000_000n // 5 XLM for beginner it.each([ - ['beginner', [1n, 1n] as [bigint, bigint], 50_000_000n], // 5 XLM - ['intermediate', [3n, 2n] as [bigint, bigint], 75_000_000n], // 7.5 XLM - ['advanced', [2n, 1n] as [bigint, bigint], 100_000_000n], // 10 XLM - ['expert', [3n, 1n] as [bigint, bigint], 150_000_000n], // 15 XLM - ])('%s: multiplyStroops(%s, [%s]) === %s stroops', + ['beginner', [1n, 1n] as [bigint, bigint], 50_000_000n], // 5 XLM + ['intermediate', [3n, 2n] as [bigint, bigint], 75_000_000n], // 7.5 XLM + ['advanced', [2n, 1n] as [bigint, bigint], 100_000_000n], // 10 XLM + ['expert', [3n, 1n] as [bigint, bigint], 150_000_000n], // 15 XLM + ])( + '%s: multiplyStroops(%s, [%s]) === %s stroops', (_diff, [num, den], expected) => { expect(multiplyStroops(BASE, num, den)).toBe(expected) }, diff --git a/tests/unit/rate-limit.test.ts b/tests/unit/rate-limit.test.ts index c9cf7e94..41d3f05d 100644 --- a/tests/unit/rate-limit.test.ts +++ b/tests/unit/rate-limit.test.ts @@ -1,115 +1,123 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { Request, Response, NextFunction } from 'express' -import { generalLimiter, authLimiter, employerLimiter, authenticatedLimiter, dynamicRateLimiter } from '../../src/middleware/rate-limit.middleware' - -// Mock the env -vi.mock('../../src/config/env', () => ({ - env: { - RATE_LIMIT_GENERAL_WINDOW_MS: 900000, // 15 min - RATE_LIMIT_GENERAL_MAX: 100, - RATE_LIMIT_AUTH_WINDOW_MS: 900000, - RATE_LIMIT_AUTH_MAX: 10, - RATE_LIMIT_EMPLOYER_WINDOW_MS: 900000, - RATE_LIMIT_EMPLOYER_MAX: 500, - RATE_LIMIT_AUTHENTICATED_WINDOW_MS: 900000, - RATE_LIMIT_AUTHENTICATED_MAX: 1000, - }, -})) - -describe('Rate Limiting Middleware', () => { - let mockReq: Partial - let mockRes: Partial - let mockNext: NextFunction - - beforeEach(() => { - mockReq = { - headers: {}, - connection: { remoteAddress: '127.0.0.1' }, - socket: { remoteAddress: '127.0.0.1' }, - originalUrl: '/test', - } - mockRes = { - set: vi.fn(), - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } - mockNext = vi.fn() - }) - - describe('General Limiter', () => { - it('should allow requests within limit', () => { - for (let i = 0; i < 100; i++) { - generalLimiter(mockReq as Request, mockRes as Response, mockNext) - } - expect(mockNext).toHaveBeenCalledTimes(100) - expect(mockRes.status).not.toHaveBeenCalled() - }) - - it('should block requests over limit', () => { - for (let i = 0; i < 101; i++) { - generalLimiter(mockReq as Request, mockRes as Response, mockNext) - } - expect(mockNext).toHaveBeenCalledTimes(100) - expect(mockRes.status).toHaveBeenCalledWith(429) - expect(mockRes.json).toHaveBeenCalledWith({ error: 'Too many requests, please try again later.' }) - }) - - it('should set correct headers', () => { - generalLimiter(mockReq as Request, mockRes as Response, mockNext) - expect(mockRes.set).toHaveBeenCalledWith({ - 'X-RateLimit-Limit': '100', - 'X-RateLimit-Remaining': '99', - 'X-RateLimit-Reset': expect.any(String), - }) - }) - }) - - describe('Auth Limiter', () => { - it('should have stricter limits', () => { - for (let i = 0; i < 11; i++) { - authLimiter(mockReq as Request, mockRes as Response, mockNext) - } - expect(mockNext).toHaveBeenCalledTimes(10) - expect(mockRes.status).toHaveBeenCalledWith(429) - }) - }) - - describe('Employer Limiter', () => { - it('should have higher limits', () => { - for (let i = 0; i < 500; i++) { - employerLimiter(mockReq as Request, mockRes as Response, mockNext) - } - expect(mockNext).toHaveBeenCalledTimes(500) - expect(mockRes.status).not.toHaveBeenCalled() - }) - }) - - describe('Authenticated Limiter', () => { - it('should have high limits', () => { - for (let i = 0; i < 1000; i++) { - authenticatedLimiter(mockReq as Request, mockRes as Response, mockNext) - } - expect(mockNext).toHaveBeenCalledTimes(1000) - expect(mockRes.status).not.toHaveBeenCalled() - }) - }) - - describe('Dynamic Rate Limiter', () => { - it('should use general limiter for unauthenticated', () => { - dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) - expect(mockNext).toHaveBeenCalled() - }) - - it('should use authenticated limiter for authenticated users', () => { - (mockReq as any).user = { role: 'user' } - dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) - expect(mockNext).toHaveBeenCalled() - }) - - it('should use employer limiter for employers', () => { - (mockReq as any).user = { role: 'employer' } - dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) - expect(mockNext).toHaveBeenCalled() - }) - }) -}) \ No newline at end of file +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Request, Response, NextFunction } from 'express' +import { + generalLimiter, + authLimiter, + employerLimiter, + authenticatedLimiter, + dynamicRateLimiter, +} from '../../src/middleware/rate-limit.middleware' + +// Mock the env +vi.mock('../../src/config/env', () => ({ + env: { + RATE_LIMIT_GENERAL_WINDOW_MS: 900000, // 15 min + RATE_LIMIT_GENERAL_MAX: 100, + RATE_LIMIT_AUTH_WINDOW_MS: 900000, + RATE_LIMIT_AUTH_MAX: 10, + RATE_LIMIT_EMPLOYER_WINDOW_MS: 900000, + RATE_LIMIT_EMPLOYER_MAX: 500, + RATE_LIMIT_AUTHENTICATED_WINDOW_MS: 900000, + RATE_LIMIT_AUTHENTICATED_MAX: 1000, + }, +})) + +describe('Rate Limiting Middleware', () => { + let mockReq: Partial + let mockRes: Partial + let mockNext: NextFunction + + beforeEach(() => { + mockReq = { + headers: {}, + connection: { remoteAddress: '127.0.0.1' }, + socket: { remoteAddress: '127.0.0.1' }, + originalUrl: '/test', + } + mockRes = { + set: vi.fn(), + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } + mockNext = vi.fn() + }) + + describe('General Limiter', () => { + it('should allow requests within limit', () => { + for (let i = 0; i < 100; i++) { + generalLimiter(mockReq as Request, mockRes as Response, mockNext) + } + expect(mockNext).toHaveBeenCalledTimes(100) + expect(mockRes.status).not.toHaveBeenCalled() + }) + + it('should block requests over limit', () => { + for (let i = 0; i < 101; i++) { + generalLimiter(mockReq as Request, mockRes as Response, mockNext) + } + expect(mockNext).toHaveBeenCalledTimes(100) + expect(mockRes.status).toHaveBeenCalledWith(429) + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Too many requests, please try again later.', + }) + }) + + it('should set correct headers', () => { + generalLimiter(mockReq as Request, mockRes as Response, mockNext) + expect(mockRes.set).toHaveBeenCalledWith({ + 'X-RateLimit-Limit': '100', + 'X-RateLimit-Remaining': '99', + 'X-RateLimit-Reset': expect.any(String), + }) + }) + }) + + describe('Auth Limiter', () => { + it('should have stricter limits', () => { + for (let i = 0; i < 11; i++) { + authLimiter(mockReq as Request, mockRes as Response, mockNext) + } + expect(mockNext).toHaveBeenCalledTimes(10) + expect(mockRes.status).toHaveBeenCalledWith(429) + }) + }) + + describe('Employer Limiter', () => { + it('should have higher limits', () => { + for (let i = 0; i < 500; i++) { + employerLimiter(mockReq as Request, mockRes as Response, mockNext) + } + expect(mockNext).toHaveBeenCalledTimes(500) + expect(mockRes.status).not.toHaveBeenCalled() + }) + }) + + describe('Authenticated Limiter', () => { + it('should have high limits', () => { + for (let i = 0; i < 1000; i++) { + authenticatedLimiter(mockReq as Request, mockRes as Response, mockNext) + } + expect(mockNext).toHaveBeenCalledTimes(1000) + expect(mockRes.status).not.toHaveBeenCalled() + }) + }) + + describe('Dynamic Rate Limiter', () => { + it('should use general limiter for unauthenticated', () => { + dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) + expect(mockNext).toHaveBeenCalled() + }) + + it('should use authenticated limiter for authenticated users', () => { + ;(mockReq as any).user = { role: 'user' } + dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) + expect(mockNext).toHaveBeenCalled() + }) + + it('should use employer limiter for employers', () => { + ;(mockReq as any).user = { role: 'employer' } + dynamicRateLimiter(mockReq as Request, mockRes as Response, mockNext) + expect(mockNext).toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/reward.controller.test.ts b/tests/unit/reward.controller.test.ts index 3955f5f2..e2f2fc11 100644 --- a/tests/unit/reward.controller.test.ts +++ b/tests/unit/reward.controller.test.ts @@ -60,8 +60,8 @@ describe('RewardController', () => { // Balance uses BigInt stroops internally; controller serialises to XLM strings const mockBalance = { availableStroops: 1_005_000_000n, // 100.5 XLM - pendingStroops: 100_000_000n, // 10 XLM - lifetimeStroops: 1_500_000_000n, // 150 XLM + pendingStroops: 100_000_000n, // 10 XLM + lifetimeStroops: 1_500_000_000n, // 150 XLM updatedAt: new Date(), } @@ -82,8 +82,8 @@ describe('RewardController', () => { balance: { // Amounts are serialised to 7-decimal XLM strings at the API boundary available: '100.5000000', - pending: '10.0000000', - lifetime: '150.0000000', + pending: '10.0000000', + lifetime: '150.0000000', }, }), }), @@ -324,7 +324,10 @@ describe('RewardController', () => { await controller.withdraw(mockRequest as any, mockResponse as any, nextFn) // Controller converts XLM string → stroops and passes BigInt to service - expect(hasSufficientBalanceSpy).toHaveBeenCalledWith('user-123', 500_000_000n) + expect(hasSufficientBalanceSpy).toHaveBeenCalledWith( + 'user-123', + 500_000_000n, + ) expect(processWithdrawalSpy).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-123', @@ -406,7 +409,9 @@ describe('RewardController', () => { expect(nextFn).toHaveBeenCalledWith( expect.objectContaining({ - message: expect.stringMatching(/Amount must be greater than 0|Invalid amount/), + message: expect.stringMatching( + /Amount must be greater than 0|Invalid amount/, + ), }), ) }) @@ -437,8 +442,8 @@ describe('RewardController', () => { // getBalance is called to format the error message — return BigInt stroops getBalanceSpy.mockReturnValue({ availableStroops: 500_000_000n, // 50 XLM - pendingStroops: 0n, - lifetimeStroops: 1_000_000_000n, + pendingStroops: 0n, + lifetimeStroops: 1_000_000_000n, }) const nextFn = createNextFunction() diff --git a/tests/unit/reward.service.test.ts b/tests/unit/reward.service.test.ts index f4ccd3cb..dd0c1b38 100644 --- a/tests/unit/reward.service.test.ts +++ b/tests/unit/reward.service.test.ts @@ -40,13 +40,11 @@ describe('RewardService', () => { beforeEach(() => { stellarMock = { - sendPayment: vi - .fn() - .mockResolvedValue({ - hash: MOCK_TX_HASH, - ledger: 123, - successful: true, - }), + sendPayment: vi.fn().mockResolvedValue({ + hash: MOCK_TX_HASH, + ledger: 123, + successful: true, + }), verifyTransaction: vi.fn().mockResolvedValue(true), } as unknown as StellarService @@ -58,23 +56,25 @@ describe('RewardService', () => { describe('calculateReward – base amounts by difficulty', () => { it.each([ - ['beginner', 50_000_000n], // 5 XLM - ['intermediate', 75_000_000n], // 7.5 XLM - ['advanced', 100_000_000n], // 10 XLM - ['expert', 150_000_000n], // 15 XLM - ] as const)('%s difficulty yields correct stroops', (difficulty, expected) => { - const { baseAmountStroops } = service.calculateReward( - makeModule({ difficulty }), - ) - expect(baseAmountStroops).toBe(expected) - }) + ['beginner', 50_000_000n], // 5 XLM + ['intermediate', 75_000_000n], // 7.5 XLM + ['advanced', 100_000_000n], // 10 XLM + ['expert', 150_000_000n], // 15 XLM + ] as const)( + '%s difficulty yields correct stroops', + (difficulty, expected) => { + const { baseAmountStroops } = service.calculateReward( + makeModule({ difficulty }), + ) + expect(baseAmountStroops).toBe(expected) + }, + ) it('applies the correct multiplier from DIFFICULTY_MULTIPLIERS', () => { for (const [diff, [num, den]] of Object.entries(DIFFICULTY_MULTIPLIERS)) { const mod = makeModule({ difficulty: diff as Module['difficulty'] }) const { baseAmountStroops } = service.calculateReward(mod) - const expected = - (BASE_REWARD_STROOPS * num) / den + const expected = (BASE_REWARD_STROOPS * num) / den expect(baseAmountStroops).toBe(expected) } }) @@ -138,8 +138,12 @@ describe('RewardService', () => { }) it('totalAmountStroops includes base + streak + referral', () => { - const { baseAmountStroops, streakBonusStroops, referralBonusStroops, totalAmountStroops } = - service.calculateReward(makeModule(), 3, true) + const { + baseAmountStroops, + streakBonusStroops, + referralBonusStroops, + totalAmountStroops, + } = service.calculateReward(makeModule(), 3, true) expect(totalAmountStroops).toBe( baseAmountStroops + streakBonusStroops + referralBonusStroops, ) @@ -408,9 +412,9 @@ describe('RewardService', () => { describe('hasSufficientBalance', () => { it('returns false for a user with no balance', () => { - expect( - service.hasSufficientBalance('user-empty', 10_000_000n), - ).toBe(false) + expect(service.hasSufficientBalance('user-empty', 10_000_000n)).toBe( + false, + ) }) it('returns true after earning a reward and requesting ≤ available', async () => { @@ -425,10 +429,7 @@ describe('RewardService', () => { await service.claimReward(makeClaim(), makeModule()) const balance = service.getBalance('user-abc') expect( - service.hasSufficientBalance( - 'user-abc', - balance.availableStroops + 1n, - ), + service.hasSufficientBalance('user-abc', balance.availableStroops + 1n), ).toBe(false) }) }) @@ -485,12 +486,14 @@ describe('RewardService', () => { describe('numeric display constants', () => { it('BASE_REWARD_XLM is 5 (numeric display value)', async () => { - const { BASE_REWARD_XLM } = await import('../../src/services/reward.service') + const { BASE_REWARD_XLM } = + await import('../../src/services/reward.service') expect(BASE_REWARD_XLM).toBe(5) }) it('REFERRAL_BONUS_XLM is 2 (numeric display value)', async () => { - const { REFERRAL_BONUS_XLM } = await import('../../src/services/reward.service') + const { REFERRAL_BONUS_XLM } = + await import('../../src/services/reward.service') expect(REFERRAL_BONUS_XLM).toBe(2) }) diff --git a/tests/unit/validation.middleware.test.ts b/tests/unit/validation.middleware.test.ts index 4ae6c083..044b025a 100644 --- a/tests/unit/validation.middleware.test.ts +++ b/tests/unit/validation.middleware.test.ts @@ -15,14 +15,16 @@ const makeMocks = (body = {}, query = {}, params = {}) => { const status = vi.fn().mockReturnValue({ json }) const res: Partial = { status, json } const next: NextFunction = vi.fn() - -return { req, res, next, json, status } + + return { req, res, next, json, status } } describe('commonSchemas', () => { describe('email', () => { it('accepts a valid email address', () => { - expect(commonSchemas.email.safeParse('user@example.com').success).toBe(true) + expect(commonSchemas.email.safeParse('user@example.com').success).toBe( + true, + ) }) it('rejects an invalid email string', () => { @@ -101,7 +103,9 @@ describe('commonSchemas', () => { describe('url', () => { it('accepts valid HTTP/HTTPS URLs', () => { - expect(commonSchemas.url.safeParse('https://example.com').success).toBe(true) + expect(commonSchemas.url.safeParse('https://example.com').success).toBe( + true, + ) }) it('rejects plain strings', () => { @@ -136,7 +140,7 @@ describe('validate factory middleware', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['String must contain at least 5 character(s)'] }, - }) + }), ) expect(next).not.toHaveBeenCalled() }) @@ -153,7 +157,7 @@ describe('validate factory middleware', () => { expect.objectContaining({ message: 'Validation failed', errors: { query: expect.any(Array) }, - }) + }), ) expect(next).not.toHaveBeenCalled() }) @@ -170,7 +174,7 @@ describe('validate factory middleware', () => { expect.objectContaining({ message: 'Validation failed', errors: { params: ['Invalid ID format'] }, - }) + }), ) expect(next).not.toHaveBeenCalled() }) @@ -179,7 +183,10 @@ describe('validate factory middleware', () => { const bodySchema = z.object({ name: z.string().min(5) }) const querySchema = z.object({ limit: z.number() }) const middleware = validate({ body: bodySchema, query: querySchema }) - const { req, res, next } = makeMocks({ name: 'abc' }, { limit: 'not-a-number' }) + const { req, res, next } = makeMocks( + { name: 'abc' }, + { limit: 'not-a-number' }, + ) middleware(req as Request, res as Response, next) @@ -191,7 +198,9 @@ describe('validate factory middleware', () => { }) it('parses and updates req.body when validation passes', () => { - const schema = z.object({ age: z.string().transform((val) => parseInt(val)) }) + const schema = z.object({ + age: z.string().transform((val) => parseInt(val)), + }) const middleware = validate({ body: schema }) const { req, res, next } = makeMocks({ age: '25' }) @@ -238,7 +247,7 @@ describe('validateProfileUpdate', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Username must be at least 3 characters long'] }, - }) + }), ) expect(next).not.toHaveBeenCalled() }) @@ -253,7 +262,7 @@ describe('validateProfileUpdate', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Username must be less than 30 characters'] }, - }) + }), ) }) @@ -267,11 +276,9 @@ describe('validateProfileUpdate', () => { expect.objectContaining({ message: 'Validation failed', errors: { - body: [ - 'Username can only contain letters, numbers, and underscores', - ], + body: ['Username can only contain letters, numbers, and underscores'], }, - }) + }), ) }) @@ -285,7 +292,7 @@ describe('validateProfileUpdate', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['First name must be less than 50 characters'] }, - }) + }), ) }) @@ -299,7 +306,7 @@ describe('validateProfileUpdate', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Last name must be less than 50 characters'] }, - }) + }), ) }) @@ -313,7 +320,7 @@ describe('validateProfileUpdate', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Bio must be less than 500 characters'] }, - }) + }), ) }) @@ -327,7 +334,7 @@ describe('validateProfileUpdate', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Invalid URL format'] }, - }) + }), ) }) @@ -370,7 +377,7 @@ describe('validatePasswordChange', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Current password is required'] }, - }) + }), ) }) @@ -384,7 +391,7 @@ describe('validatePasswordChange', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['New password is required'] }, - }) + }), ) }) @@ -401,7 +408,7 @@ describe('validatePasswordChange', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Password must be at least 8 characters long'] }, - }) + }), ) }) @@ -420,7 +427,7 @@ describe('validatePasswordChange', () => { errors: { body: ['Password must contain at least one lowercase letter'], }, - }) + }), ) }) @@ -439,7 +446,7 @@ describe('validatePasswordChange', () => { errors: { body: ['Password must contain at least one uppercase letter'], }, - }) + }), ) }) @@ -456,7 +463,7 @@ describe('validatePasswordChange', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Password must contain at least one number'] }, - }) + }), ) }) @@ -475,7 +482,7 @@ describe('validatePasswordChange', () => { errors: { body: ['Password must contain at least one special character'], }, - }) + }), ) }) @@ -494,7 +501,7 @@ describe('validatePasswordChange', () => { errors: { body: ['New password must be different from current password'], }, - }) + }), ) }) }) @@ -523,7 +530,7 @@ describe('validateWalletAddress', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Required'] }, - }) + }), ) expect(next).not.toHaveBeenCalled() }) @@ -540,7 +547,7 @@ describe('validateWalletAddress', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Invalid Stellar wallet address format'] }, - }) + }), ) }) @@ -554,7 +561,7 @@ describe('validateWalletAddress', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Invalid Stellar wallet address format'] }, - }) + }), ) }) @@ -570,7 +577,7 @@ describe('validateWalletAddress', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Invalid Stellar wallet address format'] }, - }) + }), ) }) @@ -584,8 +591,8 @@ describe('validateWalletAddress', () => { expect.objectContaining({ message: 'Validation failed', errors: { body: ['Expected string, received number'] }, - }) + }), ) expect(next).not.toHaveBeenCalled() }) -}) \ No newline at end of file +}) diff --git a/tests/unit/wallet-transitions.test.ts b/tests/unit/wallet-transitions.test.ts index 514ca0bf..0232232d 100644 --- a/tests/unit/wallet-transitions.test.ts +++ b/tests/unit/wallet-transitions.test.ts @@ -65,9 +65,11 @@ describe('wallet status transitions and lifecycle guards', () => { InvalidWalletTransitionError, ) expect(() => assertValidWalletTransition('DISABLED', 'ACTIVE')).toThrow( - 'Cannot transition wallet status from \'DISABLED\' to \'ACTIVE\'', + "Cannot transition wallet status from 'DISABLED' to 'ACTIVE'", ) - expect(() => assertValidWalletTransition('ACTIVE', 'EXPORTING')).not.toThrow() + expect(() => + assertValidWalletTransition('ACTIVE', 'EXPORTING'), + ).not.toThrow() }) it('has exhaustive transition entries for every status', () => { diff --git a/tests/user-account.service.test.ts b/tests/user-account.service.test.ts index c336e274..c8318a99 100644 --- a/tests/user-account.service.test.ts +++ b/tests/user-account.service.test.ts @@ -46,7 +46,9 @@ const OTHER_ADDRESS = `G${'B'.repeat(55)}` * A stand-in transaction client covering the tables the mutation touches, so a * test can assert what ran inside the transaction and in what order. */ -function fakeTransaction(options: { sessions?: { id: string }[]; updateResult?: unknown } = {}) { +function fakeTransaction( + options: { sessions?: { id: string }[]; updateResult?: unknown } = {}, +) { const calls: string[] = [] const auditCreate = vi.fn(async () => { calls.push('audit') @@ -80,14 +82,23 @@ function fakeTransaction(options: { sessions?: { id: string }[]; updateResult?: } mockTransaction.mockImplementation( - async (callback: (client: unknown) => Promise) => callback(tx) + async (callback: (client: unknown) => Promise) => callback(tx), ) - return { calls, auditCreate, userUpdate, sessionUpdateMany, refreshTokenUpdateMany } + return { + calls, + auditCreate, + userUpdate, + sessionUpdateMany, + refreshTokenUpdateMany, + } } -function auditRow(auditCreate: ReturnType): Record { - return (auditCreate.mock.calls[0][0] as { data: Record }).data +function auditRow( + auditCreate: ReturnType, +): Record { + return (auditCreate.mock.calls[0][0] as { data: Record }) + .data } describe('UserAccountService', () => { @@ -100,12 +111,18 @@ describe('UserAccountService', () => { }) describe('changePassword', () => { - const account = { id: 'user1', password: '$2b$12$oldhash', status: 'ACTIVE' } + const account = { + id: 'user1', + password: '$2b$12$oldhash', + status: 'ACTIVE', + } it('reports not-found for an unknown account', async () => { mockUserFindUnique.mockResolvedValue(null) - expect(await service.changePassword('missing', 'old', 'New1!pass', context)).toEqual({ + expect( + await service.changePassword('missing', 'old', 'New1!pass', context), + ).toEqual({ kind: 'not-found', }) }) @@ -113,7 +130,9 @@ describe('UserAccountService', () => { it('reports not-found for a tombstoned account', async () => { mockUserFindUnique.mockResolvedValue({ ...account, status: 'DELETED' }) - expect(await service.changePassword('user1', 'old', 'New1!pass', context)).toEqual({ + expect( + await service.changePassword('user1', 'old', 'New1!pass', context), + ).toEqual({ kind: 'not-found', }) }) @@ -122,7 +141,9 @@ describe('UserAccountService', () => { mockUserFindUnique.mockResolvedValue(account) mockCompare.mockResolvedValue(false) - expect(await service.changePassword('user1', 'wrong', 'New1!pass', context)).toEqual({ + expect( + await service.changePassword('user1', 'wrong', 'New1!pass', context), + ).toEqual({ kind: 'invalid-password', }) expect(mockTransaction).not.toHaveBeenCalled() @@ -146,11 +167,17 @@ describe('UserAccountService', () => { it('revokes every live session and refresh token in the same transaction', async () => { mockUserFindUnique.mockResolvedValue(account) mockCompare.mockResolvedValue(true) - const { calls, sessionUpdateMany, refreshTokenUpdateMany } = fakeTransaction({ - sessions: [{ id: 's1' }, { id: 's2' }], - }) - - const result = await service.changePassword('user1', 'old', 'New1!pass', context) + const { calls, sessionUpdateMany, refreshTokenUpdateMany } = + fakeTransaction({ + sessions: [{ id: 's1' }, { id: 's2' }], + }) + + const result = await service.changePassword( + 'user1', + 'old', + 'New1!pass', + context, + ) expect(result).toEqual({ kind: 'changed', revokedSessionCount: 2 }) expect(sessionUpdateMany).toHaveBeenCalledWith({ @@ -175,7 +202,9 @@ describe('UserAccountService', () => { mockCompare.mockResolvedValue(true) const { sessionUpdateMany } = fakeTransaction({ sessions: [] }) - expect(await service.changePassword('user1', 'old', 'New1!pass', context)).toEqual({ + expect( + await service.changePassword('user1', 'old', 'New1!pass', context), + ).toEqual({ kind: 'changed', revokedSessionCount: 0, }) @@ -211,7 +240,7 @@ describe('UserAccountService', () => { mockTransaction.mockRejectedValue(new Error('audit trail unavailable')) await expect( - service.changePassword('user1', 'old', 'New1!pass', context) + service.changePassword('user1', 'old', 'New1!pass', context), ).rejects.toThrow('audit trail unavailable') }) }) @@ -222,7 +251,9 @@ describe('UserAccountService', () => { it('reports not-found for an unknown account', async () => { mockUserFindUnique.mockResolvedValue(null) - expect(await service.updateWalletAddress('missing', VALID_ADDRESS, context)).toEqual({ + expect( + await service.updateWalletAddress('missing', VALID_ADDRESS, context), + ).toEqual({ kind: 'not-found', }) }) @@ -230,15 +261,22 @@ describe('UserAccountService', () => { it('reports not-found for a tombstoned account', async () => { mockUserFindUnique.mockResolvedValue({ ...account, status: 'DELETED' }) - expect(await service.updateWalletAddress('user1', VALID_ADDRESS, context)).toEqual({ + expect( + await service.updateWalletAddress('user1', VALID_ADDRESS, context), + ).toEqual({ kind: 'not-found', }) }) it('is a no-op when the address is already the one on file', async () => { - mockUserFindUnique.mockResolvedValue({ ...account, walletAddress: VALID_ADDRESS }) + mockUserFindUnique.mockResolvedValue({ + ...account, + walletAddress: VALID_ADDRESS, + }) - expect(await service.updateWalletAddress('user1', VALID_ADDRESS, context)).toEqual({ + expect( + await service.updateWalletAddress('user1', VALID_ADDRESS, context), + ).toEqual({ kind: 'unchanged', walletAddress: VALID_ADDRESS, }) @@ -249,7 +287,9 @@ describe('UserAccountService', () => { mockUserFindUnique.mockResolvedValue(account) mockUserFindFirst.mockResolvedValue({ id: 'user2' }) - expect(await service.updateWalletAddress('user1', VALID_ADDRESS, context)).toEqual({ + expect( + await service.updateWalletAddress('user1', VALID_ADDRESS, context), + ).toEqual({ kind: 'conflict', }) expect(mockUserFindFirst).toHaveBeenCalledWith({ @@ -262,9 +302,13 @@ describe('UserAccountService', () => { it('conflicts when a concurrent write wins the unique constraint', async () => { mockUserFindUnique.mockResolvedValue(account) mockUserFindFirst.mockResolvedValue(null) - mockTransaction.mockRejectedValue(Object.assign(new Error('unique'), { code: 'P2002' })) + mockTransaction.mockRejectedValue( + Object.assign(new Error('unique'), { code: 'P2002' }), + ) - expect(await service.updateWalletAddress('user1', VALID_ADDRESS, context)).toEqual({ + expect( + await service.updateWalletAddress('user1', VALID_ADDRESS, context), + ).toEqual({ kind: 'conflict', }) }) @@ -275,16 +319,23 @@ describe('UserAccountService', () => { mockTransaction.mockRejectedValue(new Error('connection reset')) await expect( - service.updateWalletAddress('user1', VALID_ADDRESS, context) + service.updateWalletAddress('user1', VALID_ADDRESS, context), ).rejects.toThrow('connection reset') }) it('persists the address and audits it', async () => { - mockUserFindUnique.mockResolvedValue({ ...account, walletAddress: OTHER_ADDRESS }) + mockUserFindUnique.mockResolvedValue({ + ...account, + walletAddress: OTHER_ADDRESS, + }) mockUserFindFirst.mockResolvedValue(null) const { calls, auditCreate, userUpdate } = fakeTransaction() - const result = await service.updateWalletAddress('user1', VALID_ADDRESS, context) + const result = await service.updateWalletAddress( + 'user1', + VALID_ADDRESS, + context, + ) expect(result).toEqual({ kind: 'updated', walletAddress: VALID_ADDRESS }) expect(userUpdate).toHaveBeenCalledWith({ diff --git a/tests/user.controller.test.ts b/tests/user.controller.test.ts index 889a8ee3..c5aa684b 100644 --- a/tests/user.controller.test.ts +++ b/tests/user.controller.test.ts @@ -56,7 +56,11 @@ const aggregate = { }, profile: { id: 'profile1', userId: USER_ID, displayName: 'Ada' }, completion: { percent: 25, missingFields: ['bio'] }, - onboarding: { status: 'in_progress', currentStep: 'consent', requiredStepsRemaining: ['consent'] }, + onboarding: { + status: 'in_progress', + currentStep: 'consent', + requiredStepsRemaining: ['consent'], + }, consents: [], requiredConsentsGranted: false, } @@ -86,7 +90,7 @@ describe('UserController', () => { const call = (handler: keyof UserController) => (controller[handler] as (r: Request, s: Response) => Promise)( req as Request, - res as Response + res as Response, ) describe('getCurrentUser', () => { @@ -125,7 +129,10 @@ describe('UserController', () => { const body = (res.json as ReturnType).mock.calls[0][0] - expect(body.data.completion).toEqual({ percent: 25, missingFields: ['bio'] }) + expect(body.data.completion).toEqual({ + percent: 25, + missingFields: ['bio'], + }) expect(body.data.onboarding.currentStep).toBe('consent') expect(body.data.requiredConsentsGranted).toBe(false) }) @@ -177,14 +184,17 @@ describe('UserController', () => { ['password', { password: 'Hacked1!pass' }], ['email', { email: 'attacker@example.com' }], ['walletAddress', { walletAddress: VALID_ADDRESS }], - ])('rejects %s: an owner may only write allow-listed profile fields', async (_field, body) => { - req.body = body + ])( + 'rejects %s: an owner may only write allow-listed profile fields', + async (_field, body) => { + req.body = body - await call('updateProfile') + await call('updateProfile') - expect(res.status).toHaveBeenCalledWith(400) - expect(mockUpdateProfileAudited).not.toHaveBeenCalled() - }) + expect(res.status).toHaveBeenCalledWith(400) + expect(mockUpdateProfileAudited).not.toHaveBeenCalled() + }, + ) it('rejects an allowed field carried alongside a forbidden one', async () => { req.body = { displayName: 'Ada', role: 'ADMIN' } @@ -219,7 +229,7 @@ describe('UserController', () => { expect(mockUpdateProfileAudited).toHaveBeenCalledWith( USER_ID, { displayName: 'Ada', interests: ['stellar'] }, - expect.anything() + expect.anything(), ) expect(res.status).toHaveBeenCalledWith(200) expect(res.json).toHaveBeenCalledWith({ @@ -253,7 +263,9 @@ describe('UserController', () => { it('returns 500 when the audited write fails', async () => { req.body = { displayName: 'Ada' } - mockUpdateProfileAudited.mockRejectedValue(new Error('audit trail unavailable')) + mockUpdateProfileAudited.mockRejectedValue( + new Error('audit trail unavailable'), + ) await call('updateProfile') @@ -282,7 +294,11 @@ describe('UserController', () => { it('serves the public view, never the owner view, even to the owner', async () => { req.params = { id: USER_ID } - mockGetPublicView.mockResolvedValue({ id: 'profile1', visible: true, displayName: 'Ada' }) + mockGetPublicView.mockResolvedValue({ + id: 'profile1', + visible: true, + displayName: 'Ada', + }) await call('getUserById') @@ -297,7 +313,9 @@ describe('UserController', () => { await call('getUserById') - expect(res.json).toHaveBeenCalledWith({ data: { id: 'profile2', visible: false } }) + expect(res.json).toHaveBeenCalledWith({ + data: { id: 'profile2', visible: false }, + }) }) it('never emits private account data in the public response', async () => { @@ -315,9 +333,19 @@ describe('UserController', () => { await call('getUserById') - const body = JSON.stringify((res.json as ReturnType).mock.calls[0][0]) + const body = JSON.stringify( + (res.json as ReturnType).mock.calls[0][0], + ) - for (const leak of ['email', 'password', 'walletAddress', 'status', 'isVerified', 'phoneVerifiedAt', 'userId']) { + for (const leak of [ + 'email', + 'password', + 'walletAddress', + 'status', + 'isVerified', + 'phoneVerifiedAt', + 'userId', + ]) { expect(body).not.toContain(leak) } }) @@ -347,8 +375,14 @@ describe('UserController', () => { it.each([ ['a missing current password', { newPassword: 'NewPass1!' }], - ['a weak new password', { currentPassword: 'OldPass1!', newPassword: 'short' }], - ['reusing the current password', { currentPassword: 'NewPass1!', newPassword: 'NewPass1!' }], + [ + 'a weak new password', + { currentPassword: 'OldPass1!', newPassword: 'short' }, + ], + [ + 'reusing the current password', + { currentPassword: 'NewPass1!', newPassword: 'NewPass1!' }, + ], ['an unknown extra field', { ...body, userId: OTHER_ID }], ])('returns 400 for %s', async (_case, invalid) => { req.body = invalid @@ -383,7 +417,10 @@ describe('UserController', () => { it('reports the revoked session count on success', async () => { req.body = body - mockChangePassword.mockResolvedValue({ kind: 'changed', revokedSessionCount: 3 }) + mockChangePassword.mockResolvedValue({ + kind: 'changed', + revokedSessionCount: 3, + }) await call('changePassword') @@ -391,21 +428,28 @@ describe('UserController', () => { USER_ID, 'OldPass1!', 'NewPass1!', - expect.anything() + expect.anything(), ) expect(res.status).toHaveBeenCalledWith(200) - expect((res.json as ReturnType).mock.calls[0][0]).toMatchObject({ + expect( + (res.json as ReturnType).mock.calls[0][0], + ).toMatchObject({ revokedSessionCount: 3, }) }) it('never echoes either password back to the caller', async () => { req.body = body - mockChangePassword.mockResolvedValue({ kind: 'changed', revokedSessionCount: 0 }) + mockChangePassword.mockResolvedValue({ + kind: 'changed', + revokedSessionCount: 0, + }) await call('changePassword') - const responseBody = JSON.stringify((res.json as ReturnType).mock.calls[0][0]) + const responseBody = JSON.stringify( + (res.json as ReturnType).mock.calls[0][0], + ) expect(responseBody).not.toContain('OldPass1!') expect(responseBody).not.toContain('NewPass1!') @@ -437,7 +481,10 @@ describe('UserController', () => { ['a too-short address', { walletAddress: 'GABC123' }], ['a secret seed', { walletAddress: `S${'A'.repeat(55)}` }], ['a missing address', {}], - ['an unknown extra field', { walletAddress: VALID_ADDRESS, userId: OTHER_ID }], + [ + 'an unknown extra field', + { walletAddress: VALID_ADDRESS, userId: OTHER_ID }, + ], ])('returns 400 for %s', async (_case, body) => { req.body = body @@ -454,7 +501,9 @@ describe('UserController', () => { await call('updateWalletAddress') expect(res.status).toHaveBeenCalledWith(409) - expect((res.json as ReturnType).mock.calls[0][0]).toMatchObject({ + expect( + (res.json as ReturnType).mock.calls[0][0], + ).toMatchObject({ code: 'WALLET_ADDRESS_TAKEN', }) }) @@ -470,14 +519,17 @@ describe('UserController', () => { it('persists a valid address for the authenticated owner', async () => { req.body = { walletAddress: VALID_ADDRESS } - mockUpdateWalletAddress.mockResolvedValue({ kind: 'updated', walletAddress: VALID_ADDRESS }) + mockUpdateWalletAddress.mockResolvedValue({ + kind: 'updated', + walletAddress: VALID_ADDRESS, + }) await call('updateWalletAddress') expect(mockUpdateWalletAddress).toHaveBeenCalledWith( USER_ID, VALID_ADDRESS, - expect.anything() + expect.anything(), ) expect(res.status).toHaveBeenCalledWith(200) expect(res.json).toHaveBeenCalledWith({ @@ -488,14 +540,17 @@ describe('UserController', () => { it('is idempotent when the address is already on file', async () => { req.body = { walletAddress: VALID_ADDRESS } - mockUpdateWalletAddress.mockResolvedValue({ kind: 'unchanged', walletAddress: VALID_ADDRESS }) + mockUpdateWalletAddress.mockResolvedValue({ + kind: 'unchanged', + walletAddress: VALID_ADDRESS, + }) await call('updateWalletAddress') expect(res.status).toHaveBeenCalledWith(200) - expect((res.json as ReturnType).mock.calls[0][0].message).toBe( - 'Wallet address unchanged' - ) + expect( + (res.json as ReturnType).mock.calls[0][0].message, + ).toBe('Wallet address unchanged') }) it('returns 500 on an unexpected failure', async () => { diff --git a/tests/wallet-secret-scan.test.ts b/tests/wallet-secret-scan.test.ts index c4e83b1a..0dc6cd8d 100644 --- a/tests/wallet-secret-scan.test.ts +++ b/tests/wallet-secret-scan.test.ts @@ -16,23 +16,31 @@ describe('wallet plaintext secret scan', () => { ? ['ManagedKeyReference', 'Wallet', 'WalletProvisioningJob'] .map( (model) => - source.match(new RegExp(`model ${model} \\{([\\s\\S]*?)\\n\\}`))?.[0] ?? '' + source.match( + new RegExp(`model ${model} \\{([\\s\\S]*?)\\n\\}`), + )?.[0] ?? '', ) .join('\n') : source expect(walletPersistence).not.toMatch( - /\b(secret|seed|private_?key|secret_?key)\b\s+(String|TEXT)/i + /\b(secret|seed|private_?key|secret_?key)\b\s+(String|TEXT)/i, ) }) it('does not embed a Stellar secret in export source or documentation', () => { - const exportSources = files.slice(2).map((file) => readFileSync(file, 'utf8')) + const exportSources = files + .slice(2) + .map((file) => readFileSync(file, 'utf8')) expect(exportSources.join('\n')).not.toMatch(/S[A-Z2-7]{55}/) }) it('keeps the public wallet DTO free of KMS references', () => { - const source = readFileSync('src/types/wallet-provisioning.types.ts', 'utf8') - const publicWallet = source.match(/export interface PublicWallet \{([\s\S]*?)\n\}/)?.[1] ?? '' + const source = readFileSync( + 'src/types/wallet-provisioning.types.ts', + 'utf8', + ) + const publicWallet = + source.match(/export interface PublicWallet \{([\s\S]*?)\n\}/)?.[1] ?? '' expect(publicWallet).not.toMatch(/managedKey|opaqueReference|keyVersion/i) }) }) diff --git a/tests/wallet-self-custody-export.service.test.ts b/tests/wallet-self-custody-export.service.test.ts index 80e8a1a2..bae1b88d 100644 --- a/tests/wallet-self-custody-export.service.test.ts +++ b/tests/wallet-self-custody-export.service.test.ts @@ -28,7 +28,9 @@ class InMemoryAuthorizationRepository implements WalletExportAuthorizationReposi custody = 'MANAGED' walletStatus = 'ACTIVE' - async findEligibleWallet(userId: string): Promise { + async findEligibleWallet( + userId: string, + ): Promise { return this.candidate?.userId === userId && this.custody === 'MANAGED' && this.walletStatus === 'ACTIVE' @@ -147,9 +149,9 @@ describe('step-up self-custody export', () => { ).rejects.toMatchObject({ code: 'ACKNOWLEDGEMENT_REQUIRED' }) stepUp.verifyPassword.mockResolvedValue(false) - await expect( - authorize(service), - ).rejects.toMatchObject({ code: 'STEP_UP_FAILED' }) + await expect(authorize(service)).rejects.toMatchObject({ + code: 'STEP_UP_FAILED', + }) expect(repository.authorizations.size).toBe(0) }) @@ -228,8 +230,12 @@ describe('step-up self-custody export', () => { ), ) - expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(1) - expect(attempts.filter((attempt) => attempt.status === 'rejected')).toHaveLength(19) + expect( + attempts.filter((attempt) => attempt.status === 'fulfilled'), + ).toHaveLength(1) + expect( + attempts.filter((attempt) => attempt.status === 'rejected'), + ).toHaveLength(19) }) it('does not deliver while managed KMS material cannot be deleted', async () => { diff --git a/tests/wallet-status.controller.test.ts b/tests/wallet-status.controller.test.ts index 2c68b7a8..b3e5191b 100644 --- a/tests/wallet-status.controller.test.ts +++ b/tests/wallet-status.controller.test.ts @@ -19,7 +19,9 @@ describe('WalletStatusController', () => { getBalances: vi.fn(), getHistory: vi.fn(), } - controller = new WalletStatusController(service as unknown as WalletStatusService) + controller = new WalletStatusController( + service as unknown as WalletStatusService, + ) req = { user: { id: 'user-1' }, query: {} } res = { status: vi.fn().mockReturnThis(), @@ -35,13 +37,20 @@ describe('WalletStatusController', () => { expect(service.getStatus).toHaveBeenCalledWith('user-1') expect(res.status).toHaveBeenCalledWith(200) - expect(res.json).toHaveBeenCalledWith({ success: true, data: { status: 'ACTIVE' } }) + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: { status: 'ACTIVE' }, + }) }) }) describe('getBalances', () => { it('returns exact balances on success', async () => { - const balances = { publicKey: 'GABC', sourceTime: '2026-08-30T00:00:00Z', balances: [] } + const balances = { + publicKey: 'GABC', + sourceTime: '2026-08-30T00:00:00Z', + balances: [], + } service.getBalances.mockResolvedValue(balances) await controller.getBalances(req, res) @@ -51,7 +60,9 @@ describe('WalletStatusController', () => { }) it('returns 404 without leaking details when the caller has no active wallet', async () => { - service.getBalances.mockRejectedValue(new WalletStatusError('WALLET_NOT_FOUND')) + service.getBalances.mockRejectedValue( + new WalletStatusError('WALLET_NOT_FOUND'), + ) await controller.getBalances(req, res) @@ -63,7 +74,9 @@ describe('WalletStatusController', () => { }) it('maps a Horizon timeout to 504', async () => { - service.getBalances.mockRejectedValue(new WalletStatusError('HORIZON_TIMEOUT', 'timed out')) + service.getBalances.mockRejectedValue( + new WalletStatusError('HORIZON_TIMEOUT', 'timed out'), + ) await controller.getBalances(req, res) @@ -71,7 +84,9 @@ describe('WalletStatusController', () => { }) it('maps Horizon unavailability to 503', async () => { - service.getBalances.mockRejectedValue(new WalletStatusError('HORIZON_UNAVAILABLE', 'down')) + service.getBalances.mockRejectedValue( + new WalletStatusError('HORIZON_UNAVAILABLE', 'down'), + ) await controller.getBalances(req, res) @@ -112,7 +127,10 @@ describe('WalletStatusController', () => { it('returns paginated history with stable cursor metadata', async () => { req.query = { cursor: 'abc', limit: '10', direction: 'incoming' } - service.getHistory.mockResolvedValue({ entries: [{ id: 'op-1' }], nextCursor: 'def' }) + service.getHistory.mockResolvedValue({ + entries: [{ id: 'op-1' }], + nextCursor: 'def', + }) await controller.getHistory(req, res) @@ -135,7 +153,9 @@ describe('WalletStatusController', () => { await controller.getHistory(req, res) expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ meta: expect.objectContaining({ hasMore: false }) }), + expect.objectContaining({ + meta: expect.objectContaining({ hasMore: false }), + }), ) }) }) diff --git a/tests/wallet-status.service.test.ts b/tests/wallet-status.service.test.ts index 5bd4140c..ddf18370 100644 --- a/tests/wallet-status.service.test.ts +++ b/tests/wallet-status.service.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { WalletStatusService, type WalletStatusStellarProvider } from '../src/services/wallet-status.service' +import { + WalletStatusService, + type WalletStatusStellarProvider, +} from '../src/services/wallet-status.service' import { StellarServiceError } from '../src/services/stellar.service' import { WalletStatusError } from '../src/types/wallet-status.types' import type { WalletProvisioningRepository } from '../src/services/wallet-provisioning.repository' @@ -36,14 +39,19 @@ class StubRepository implements Partial { } } -function repositoryFor(record: WalletRecord | null): WalletProvisioningRepository { +function repositoryFor( + record: WalletRecord | null, +): WalletProvisioningRepository { return new StubRepository(record) as unknown as WalletProvisioningRepository } describe('WalletStatusService', () => { describe('getStatus', () => { it('reports NOT_PROVISIONED when no wallet exists', async () => { - const service = new WalletStatusService(repositoryFor(null), {} as WalletStatusStellarProvider) + const service = new WalletStatusService( + repositoryFor(null), + {} as WalletStatusStellarProvider, + ) const status = await service.getStatus(OWNER_ID) @@ -57,8 +65,13 @@ describe('WalletStatusService', () => { }) it('exposes the public key only when the wallet is ACTIVE', async () => { - const repository = repositoryFor(wallet({ status: 'PROVISIONING', publicKey: null })) - const service = new WalletStatusService(repository, {} as WalletStatusStellarProvider) + const repository = repositoryFor( + wallet({ status: 'PROVISIONING', publicKey: null }), + ) + const service = new WalletStatusService( + repository, + {} as WalletStatusStellarProvider, + ) const status = await service.getStatus(OWNER_ID) @@ -77,8 +90,11 @@ describe('WalletStatusService', () => { expect(status.status).toBe('UNAVAILABLE') }) - it('never exposes another user\'s wallet', async () => { - const service = new WalletStatusService(repositoryFor(wallet()), {} as WalletStatusStellarProvider) + it("never exposes another user's wallet", async () => { + const service = new WalletStatusService( + repositoryFor(wallet()), + {} as WalletStatusStellarProvider, + ) const status = await service.getStatus('someone-else') @@ -107,8 +123,18 @@ describe('WalletStatusService', () => { found: true, lastModifiedTime: '2026-08-30T00:00:00Z', balances: [ - { assetType: 'native', assetCode: 'XLM', issuer: null, amount: '123.4567890' }, - { assetType: 'credit_alphanum4', assetCode: 'USDC', issuer: 'GISSUER', amount: '10.0000001' }, + { + assetType: 'native', + assetCode: 'XLM', + issuer: null, + amount: '123.4567890', + }, + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + issuer: 'GISSUER', + amount: '10.0000001', + }, ], } }, @@ -121,14 +147,28 @@ describe('WalletStatusService', () => { expect(balances.publicKey).toBe(PUBLIC_KEY) expect(balances.sourceTime).toBe('2026-08-30T00:00:00Z') expect(balances.balances).toEqual([ - { assetType: 'native', assetCode: 'XLM', issuer: null, amount: '123.4567890' }, - { assetType: 'credit_alphanum4', assetCode: 'USDC', issuer: 'GISSUER', amount: '10.0000001' }, + { + assetType: 'native', + assetCode: 'XLM', + issuer: null, + amount: '123.4567890', + }, + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + issuer: 'GISSUER', + amount: '10.0000001', + }, ]) }) it('does not show an unfunded (not-yet-on-ledger) account as a zero balance error', async () => { const stellar: WalletStatusStellarProvider = { - getAccountSnapshot: async () => ({ found: false, lastModifiedTime: null, balances: [] }), + getAccountSnapshot: async () => ({ + found: false, + lastModifiedTime: null, + balances: [], + }), getPaymentHistory: async () => ({ records: [], nextCursor: null }), } const service = new WalletStatusService(repositoryFor(wallet()), stellar) @@ -141,7 +181,10 @@ describe('WalletStatusService', () => { it('normalizes a Horizon timeout to a stable provider error code', async () => { const stellar: WalletStatusStellarProvider = { getAccountSnapshot: async () => { - throw new StellarServiceError('Horizon request timed out', 'HORIZON_TIMEOUT') + throw new StellarServiceError( + 'Horizon request timed out', + 'HORIZON_TIMEOUT', + ) }, getPaymentHistory: async () => ({ records: [], nextCursor: null }), } @@ -155,13 +198,18 @@ describe('WalletStatusService', () => { it('normalizes Horizon unavailability to a stable provider error code', async () => { const stellar: WalletStatusStellarProvider = { getAccountSnapshot: async () => { - throw new StellarServiceError('Horizon is unavailable', 'HORIZON_UNAVAILABLE') + throw new StellarServiceError( + 'Horizon is unavailable', + 'HORIZON_UNAVAILABLE', + ) }, getPaymentHistory: async () => ({ records: [], nextCursor: null }), } const service = new WalletStatusService(repositoryFor(wallet()), stellar) - await expect(service.getBalances(OWNER_ID)).rejects.toBeInstanceOf(WalletStatusError) + await expect(service.getBalances(OWNER_ID)).rejects.toBeInstanceOf( + WalletStatusError, + ) }) }) @@ -182,9 +230,13 @@ describe('WalletStatusService', () => { memoType: null, } - it('marks records as incoming or outgoing relative to the owner\'s address', async () => { + it("marks records as incoming or outgoing relative to the owner's address", async () => { const stellar: WalletStatusStellarProvider = { - getAccountSnapshot: async () => ({ found: true, lastModifiedTime: null, balances: [] }), + getAccountSnapshot: async () => ({ + found: true, + lastModifiedTime: null, + balances: [], + }), getPaymentHistory: async () => ({ records: [ { ...baseRecord, id: 'op-in', to: PUBLIC_KEY, from: 'GOTHER' }, @@ -197,15 +249,29 @@ describe('WalletStatusService', () => { const page = await service.getHistory(OWNER_ID, {}) - expect(page.entries.map((e) => e.direction)).toEqual(['incoming', 'outgoing']) + expect(page.entries.map((e) => e.direction)).toEqual([ + 'incoming', + 'outgoing', + ]) expect(page.nextCursor).toBe('cursor-2') }) it('reports failed transactions with status "failed" rather than success', async () => { const stellar: WalletStatusStellarProvider = { - getAccountSnapshot: async () => ({ found: true, lastModifiedTime: null, balances: [] }), + getAccountSnapshot: async () => ({ + found: true, + lastModifiedTime: null, + balances: [], + }), getPaymentHistory: async () => ({ - records: [{ ...baseRecord, to: PUBLIC_KEY, from: 'GOTHER', transactionSuccessful: false }], + records: [ + { + ...baseRecord, + to: PUBLIC_KEY, + from: 'GOTHER', + transactionSuccessful: false, + }, + ], nextCursor: null, }), } @@ -218,7 +284,11 @@ describe('WalletStatusService', () => { it('filters by direction when requested', async () => { const stellar: WalletStatusStellarProvider = { - getAccountSnapshot: async () => ({ found: true, lastModifiedTime: null, balances: [] }), + getAccountSnapshot: async () => ({ + found: true, + lastModifiedTime: null, + balances: [], + }), getPaymentHistory: async () => ({ records: [ { ...baseRecord, id: 'op-in', to: PUBLIC_KEY, from: 'GOTHER' }, @@ -238,7 +308,11 @@ describe('WalletStatusService', () => { it('preserves the stable cursor for pagination', async () => { let receivedCursor: string | undefined const stellar: WalletStatusStellarProvider = { - getAccountSnapshot: async () => ({ found: true, lastModifiedTime: null, balances: [] }), + getAccountSnapshot: async () => ({ + found: true, + lastModifiedTime: null, + balances: [], + }), getPaymentHistory: async (_publicKey, options) => { receivedCursor = options?.cursor diff --git a/tests/workers/outbox-relay.test.ts b/tests/workers/outbox-relay.test.ts index bb6bac27..16dc8351 100644 --- a/tests/workers/outbox-relay.test.ts +++ b/tests/workers/outbox-relay.test.ts @@ -8,7 +8,12 @@ vi.mock('../../src/utils/logger', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })) -const silentLog = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } +const silentLog = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +} interface FakeEvent { id: string @@ -36,7 +41,9 @@ class FakeDb { jobs: FakeJob[] = [] private seq = 0 - addEvent(partial: Partial & { eventType: string; payload: unknown }): FakeEvent { + addEvent( + partial: Partial & { eventType: string; payload: unknown }, + ): FakeEvent { const event: FakeEvent = { id: partial.id ?? `evt-${++this.seq}`, eventType: partial.eventType, @@ -54,21 +61,25 @@ class FakeDb { get outboxEvent() { return { findMany: async (args: any) => { - let rows = this.events.filter(e => e.status === args.where.status) + let rows = this.events.filter((e) => e.status === args.where.status) if (args.where.jobAttempts?.none) { - rows = rows.filter(e => !this.jobs.some(j => j.outboxEventId === e.id)) + rows = rows.filter( + (e) => !this.jobs.some((j) => j.outboxEventId === e.id), + ) } return rows.slice(0, args.take ?? rows.length) }, update: async (args: any) => { - const event = this.events.find(e => e.id === args.where.id)! + const event = this.events.find((e) => e.id === args.where.id)! Object.assign(event, args.data) return event }, - count: async () => this.events.filter(e => e.status === 'PENDING').length, - findFirst: async () => this.events.find(e => e.status === 'PENDING') ?? null, + count: async () => + this.events.filter((e) => e.status === 'PENDING').length, + findFirst: async () => + this.events.find((e) => e.status === 'PENDING') ?? null, } } @@ -90,18 +101,20 @@ class FakeDb { return job }, findUnique: async (args: any) => { - const job = this.jobs.find(j => j.id === args.where.id) + const job = this.jobs.find((j) => j.id === args.where.id) if (!job) return null return { ...job, - outboxEvent: this.events.find(e => e.id === job.outboxEventId) ?? null, + outboxEvent: + this.events.find((e) => e.id === job.outboxEventId) ?? null, } }, findMany: async (args: any) => this.jobs.filter( - j => - j.outboxEventId === args.where.outboxEventId && j.status === args.where.status + (j) => + j.outboxEventId === args.where.outboxEventId && + j.status === args.where.status, ), } } @@ -116,11 +129,14 @@ class FakeLeaseService { async leaseJob({ jobType }: { jobType: string }) { const job = this.db.jobs.find( - j => j.jobType === jobType && j.status === 'PENDING' && j.availableAt <= Date.now() + (j) => + j.jobType === jobType && + j.status === 'PENDING' && + j.availableAt <= Date.now(), ) if (!job) return null job.status = 'LEASED' - const event = this.db.events.find(e => e.id === job.outboxEventId)! + const event = this.db.events.find((e) => e.id === job.outboxEventId)! return { jobId: job.id, @@ -132,22 +148,26 @@ class FakeLeaseService { } async completeJob(jobId: string) { - const job = this.db.jobs.find(j => j.id === jobId)! + const job = this.db.jobs.find((j) => j.id === jobId)! job.status = 'COMPLETED' - const siblings = this.db.jobs.filter(j => j.outboxEventId === job.outboxEventId) - if (siblings.every(j => j.status === 'COMPLETED')) { - this.db.events.find(e => e.id === job.outboxEventId)!.status = 'PUBLISHED' + const siblings = this.db.jobs.filter( + (j) => j.outboxEventId === job.outboxEventId, + ) + if (siblings.every((j) => j.status === 'COMPLETED')) { + this.db.events.find((e) => e.id === job.outboxEventId)!.status = + 'PUBLISHED' } } async failJob(jobId: string, _token: string, error: Error | string) { - const job = this.db.jobs.find(j => j.id === jobId)! + const job = this.db.jobs.find((j) => j.id === jobId)! job.attempt += 1 job.lastError = error instanceof Error ? error.message : String(error) if (job.attempt >= job.maxAttempts) { job.status = 'DEAD_LETTER' - this.db.events.find(e => e.id === job.outboxEventId)!.status = 'DEAD_LETTER' + this.db.events.find((e) => e.id === job.outboxEventId)!.status = + 'DEAD_LETTER' } else { job.status = 'PENDING' job.availableAt = Date.now() + 60_000 @@ -155,7 +175,7 @@ class FakeLeaseService { } async resetJobForRetry(jobId: string) { - const job = this.db.jobs.find(j => j.id === jobId)! + const job = this.db.jobs.find((j) => j.id === jobId)! job.status = 'PENDING' job.attempt = 0 job.lastError = null @@ -169,8 +189,16 @@ class FakeLeaseService { function buildRelay(db: FakeDb, handlers: OutboxEventHandler[]) { const schemas = new EventSchemaRegistry() - schemas.register({ eventType: 'UserCreated', version: 1, validate: () => undefined }) - schemas.register({ eventType: 'OrderPlaced', version: 1, validate: () => undefined }) + schemas.register({ + eventType: 'UserCreated', + version: 1, + validate: () => undefined, + }) + schemas.register({ + eventType: 'OrderPlaced', + version: 1, + validate: () => undefined, + }) const registry = new OutboxHandlerRegistry(schemas) for (const handler of handlers) registry.register(handler) @@ -204,7 +232,7 @@ describe('OutboxRelay', () => { name: 'h1', eventType: 'UserCreated', eventVersion: 1, - handle: async ctx => { + handle: async (ctx) => { seen.push(ctx.payload) }, }, @@ -228,7 +256,7 @@ describe('OutboxRelay', () => { name: 'user-handler', eventType: 'UserCreated', eventVersion: 1, - handle: async ctx => { + handle: async (ctx) => { userSeen.push(ctx.eventId) }, }, @@ -236,7 +264,7 @@ describe('OutboxRelay', () => { name: 'order-handler', eventType: 'OrderPlaced', eventVersion: 1, - handle: async ctx => { + handle: async (ctx) => { orderSeen.push(ctx.eventId) }, }, @@ -253,7 +281,12 @@ describe('OutboxRelay', () => { let failNext = true const { relay, leases } = buildRelay(db, [ - { name: 'ok', eventType: 'UserCreated', eventVersion: 1, handle: async () => undefined }, + { + name: 'ok', + eventType: 'UserCreated', + eventVersion: 1, + handle: async () => undefined, + }, { name: 'flaky', eventType: 'UserCreated', @@ -279,14 +312,21 @@ describe('OutboxRelay', () => { db.addEvent({ eventType: 'OrderPlaced', payload: { orderId: 'o1' } }) const { relay } = buildRelay(db, [ - { name: 'user-handler', eventType: 'UserCreated', eventVersion: 1, handle: async () => undefined }, + { + name: 'user-handler', + eventType: 'UserCreated', + eventVersion: 1, + handle: async () => undefined, + }, ]) const summary = await relay.runOnce() expect(summary.unhandled).toBe(1) expect(db.events[0].status).toBe('DEAD_LETTER') - expect(silentLog.error).toHaveBeenCalledWith(expect.stringContaining('no handler registered')) + expect(silentLog.error).toHaveBeenCalledWith( + expect.stringContaining('no handler registered'), + ) }) it('dead-letters after max attempts without blocking other event types', async () => { @@ -303,7 +343,12 @@ describe('OutboxRelay', () => { throw new Error('provider down') }, }, - { name: 'healthy', eventType: 'OrderPlaced', eventVersion: 1, handle: async () => undefined }, + { + name: 'healthy', + eventType: 'OrderPlaced', + eventVersion: 1, + handle: async () => undefined, + }, ]) await relay.runOnce() @@ -313,7 +358,9 @@ describe('OutboxRelay', () => { const [userEvent, orderEvent] = db.events expect(userEvent.status).toBe('DEAD_LETTER') expect(orderEvent.status).toBe('PUBLISHED') - expect(db.jobs.find(j => j.jobType === 'always-fails')?.lastError).toContain('provider down') + expect( + db.jobs.find((j) => j.jobType === 'always-fails')?.lastError, + ).toContain('provider down') }) it('replays a dead-lettered event without duplicating side effects', async () => { @@ -327,7 +374,7 @@ describe('OutboxRelay', () => { eventType: 'UserCreated', eventVersion: 1, maxAttempts: 1, - handle: async ctx => { + handle: async (ctx) => { if (!healthy) throw new Error('downstream down') effects.push(ctx.eventId) }, @@ -353,7 +400,12 @@ describe('OutboxRelay', () => { db.addEvent({ eventType: 'UserCreated', payload: { userId: 'u1' } }) const { relay } = buildRelay(db, [ - { name: 'h1', eventType: 'UserCreated', eventVersion: 1, handle: async () => undefined }, + { + name: 'h1', + eventType: 'UserCreated', + eventVersion: 1, + handle: async () => undefined, + }, ]) await relay.runOnce() diff --git a/tests/workers/scheduled-job-runner.test.ts b/tests/workers/scheduled-job-runner.test.ts index 2a2e6d0a..f812fb01 100644 --- a/tests/workers/scheduled-job-runner.test.ts +++ b/tests/workers/scheduled-job-runner.test.ts @@ -53,7 +53,11 @@ class FakeLeaseStore implements QueueLeaseApi { private seq = 0 acquireQueueLease = vi.fn( - async (options: { queueName: string; leaseMs?: number; owner?: string }) => { + async (options: { + queueName: string + leaseMs?: number + owner?: string + }) => { const now = Date.now() const leaseMs = options.leaseMs ?? 60_000 const current = this.rows.get(options.queueName) @@ -63,23 +67,28 @@ class FakeLeaseStore implements QueueLeaseApi { } const leaseToken = `${options.owner ?? 'anon'}-${++this.seq}` - this.rows.set(options.queueName, { token: leaseToken, until: now + leaseMs }) + this.rows.set(options.queueName, { + token: leaseToken, + until: now + leaseMs, + }) return { queueName: options.queueName, leaseToken, leasedUntil: new Date(now + leaseMs), } - } + }, ) - renewQueueLease = vi.fn(async (queueName: string, leaseToken: string, leaseMs = 60_000) => { - const current = this.rows.get(queueName) - if (!current || current.token !== leaseToken) return false - current.until = Date.now() + leaseMs + renewQueueLease = vi.fn( + async (queueName: string, leaseToken: string, leaseMs = 60_000) => { + const current = this.rows.get(queueName) + if (!current || current.token !== leaseToken) return false + current.until = Date.now() + leaseMs - return true - }) + return true + }, + ) releaseQueueLease = vi.fn(async (queueName: string, leaseToken: string) => { const current = this.rows.get(queueName) @@ -90,17 +99,23 @@ class FakeLeaseStore implements QueueLeaseApi { }) hold(queueName: string, forMs: number): void { - this.rows.set(queueName, { token: 'foreign-holder', until: Date.now() + forMs }) + this.rows.set(queueName, { + token: 'foreign-holder', + until: Date.now() + forMs, + }) } } -async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { +async function waitFor( + predicate: () => boolean, + timeoutMs = 2_000, +): Promise { const deadline = Date.now() + timeoutMs while (!predicate()) { if (Date.now() > deadline) { throw new Error('waitFor timed out') } - await new Promise(resolve => setTimeout(resolve, 5)) + await new Promise((resolve) => setTimeout(resolve, 5)) } } @@ -113,7 +128,7 @@ function fakeQueue( name: string, rows: FakeRow[], processed: string[], - drainImpl?: () => Promise + drainImpl?: () => Promise, ): ScheduledQueue { return { name, @@ -121,7 +136,7 @@ function fakeQueue( drainImpl ?? (async () => { const now = Date.now() - const due = rows.filter(row => row.nextAttemptAt.getTime() <= now) + const due = rows.filter((row) => row.nextAttemptAt.getTime() <= now) for (const row of due) { rows.splice(rows.indexOf(row), 1) processed.push(row.id) @@ -129,9 +144,9 @@ function fakeQueue( }), inspect: async () => { const now = Date.now() - const due = rows.filter(row => row.nextAttemptAt.getTime() <= now) + const due = rows.filter((row) => row.nextAttemptAt.getTime() <= now) const oldest = [...due].sort( - (a, b) => a.nextAttemptAt.getTime() - b.nextAttemptAt.getTime() + (a, b) => a.nextAttemptAt.getTime() - b.nextAttemptAt.getTime(), )[0] return { @@ -155,7 +170,9 @@ describe('ScheduledJobRunner', () => { it('drains due rows on a timer with no inbound traffic', async () => { const processed: string[] = [] - const rows: FakeRow[] = [{ id: 'row-1', nextAttemptAt: new Date(Date.now() - 1) }] + const rows: FakeRow[] = [ + { id: 'row-1', nextAttemptAt: new Date(Date.now() - 1) }, + ] const runner = new ScheduledJobRunner({ queues: [fakeQueue('email', rows, processed)], @@ -174,7 +191,9 @@ describe('ScheduledJobRunner', () => { it('picks up a row whose nextAttemptAt falls due within one interval', async () => { const processed: string[] = [] - const rows: FakeRow[] = [{ id: 'retry-1', nextAttemptAt: new Date(Date.now() + 40) }] + const rows: FakeRow[] = [ + { id: 'retry-1', nextAttemptAt: new Date(Date.now() + 40) }, + ] const runner = new ScheduledJobRunner({ queues: [fakeQueue('email', rows, processed)], @@ -186,7 +205,7 @@ describe('ScheduledJobRunner', () => { runner.start() - await new Promise(resolve => setTimeout(resolve, 15)) + await new Promise((resolve) => setTimeout(resolve, 15)) expect(processed).toEqual([]) await waitFor(() => processed.length === 1) @@ -197,7 +216,9 @@ describe('ScheduledJobRunner', () => { it('skips a tick when another holder owns the queue lease', async () => { const processed: string[] = [] - const rows: FakeRow[] = [{ id: 'row-1', nextAttemptAt: new Date(Date.now() - 1) }] + const rows: FakeRow[] = [ + { id: 'row-1', nextAttemptAt: new Date(Date.now() - 1) }, + ] leases.hold('email', 60_000) const runner = new ScheduledJobRunner({ @@ -225,12 +246,12 @@ describe('ScheduledJobRunner', () => { const slowDrain = async () => { const now = Date.now() - const due = rows.filter(row => row.nextAttemptAt.getTime() <= now) + const due = rows.filter((row) => row.nextAttemptAt.getTime() <= now) for (const row of due) { const index = rows.indexOf(row) if (index === -1) continue rows.splice(index, 1) - await new Promise(resolve => setTimeout(resolve, 1)) + await new Promise((resolve) => setTimeout(resolve, 1)) processed.push(row.id) } } @@ -263,7 +284,11 @@ describe('ScheduledJobRunner', () => { drain: async () => { throw new Error('provider down') }, - inspect: async () => ({ depth: 3, due: 3, oldestDueAt: new Date(Date.now() - 5_000) }), + inspect: async () => ({ + depth: 3, + due: 3, + oldestDueAt: new Date(Date.now() - 5_000), + }), } const runner = new ScheduledJobRunner({ @@ -322,7 +347,7 @@ describe('ScheduledJobRunner', () => { name: 'data-export', drain: async () => { started = true - await new Promise(resolve => setTimeout(resolve, 60)) + await new Promise((resolve) => setTimeout(resolve, 60)) }, inspect: async () => ({ depth: 1, due: 1, oldestDueAt: null }), } @@ -341,10 +366,12 @@ describe('ScheduledJobRunner', () => { await runner.stop() expect(leases.releaseQueueLease).toHaveBeenCalledOnce() - expect(await leases.acquireQueueLease({ queueName: 'data-export' })).not.toBeNull() + expect( + await leases.acquireQueueLease({ queueName: 'data-export' }), + ).not.toBeNull() const ticksAtStop = metrics.snapshot()[0].attempts - await new Promise(resolve => setTimeout(resolve, 40)) + await new Promise((resolve) => setTimeout(resolve, 40)) expect(metrics.snapshot()[0].attempts).toBe(ticksAtStop) }) diff --git a/tsconfig.json b/tsconfig.json index b0bc6007..532f5958 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,24 +1,24 @@ -{ - "compilerOptions": { - "target": "es2023", - "module": "esnext", - "lib": ["ES2023"], - "rootDir": "./src", - "outDir": "./dist", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - // "ignoreDeprecations": "6.0" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests"] -} +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023"], + "rootDir": "./src", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + // "ignoreDeprecations": "6.0" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/vitest.config.js b/vitest.config.js index b657b916..883c43b4 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -13,6 +13,12 @@ export default defineConfig({ outputDir: 'coverage', include: ['src/**/*.ts'], exclude: ['src/**/*.d.ts'], + thresholds: { + branches: 15, + functions: 20, + lines: 20, + statements: 20, + }, }, }, }) From b0313b6e1d64ba64944654cd6ed450f333fa3b11 Mon Sep 17 00:00:00 2001 From: Copstud3 Date: Sun, 30 Aug 2026 20:31:00 +0100 Subject: [PATCH 2/4] fix(tests): standardize string quotes in test descriptions --- integrations/stellar.service.test.ts | 2 +- src/middleware/rate-limit.middleware.ts | 4 ---- tests/account.controller.test.ts | 2 +- tests/avatar.service.test.ts | 2 +- tests/stellar.service.test.ts | 2 +- tests/unit/wallet-transitions.test.ts | 2 +- tests/wallet-status.service.test.ts | 4 ++-- 7 files changed, 7 insertions(+), 11 deletions(-) diff --git a/integrations/stellar.service.test.ts b/integrations/stellar.service.test.ts index a0fcb8c6..6eb786b6 100644 --- a/integrations/stellar.service.test.ts +++ b/integrations/stellar.service.test.ts @@ -306,7 +306,7 @@ describe('StellarService', () => { expect(await service.getNativeBalance('GPUBKEY...')).toBe('42.0000000') }) - it("returns '0' when no native balance exists", async () => { + it('returns \'0\' when no native balance exists', async () => { mockGetAccount.mockResolvedValue({ balances: [] }) expect(await service.getNativeBalance('GPUBKEY...')).toBe('0') }) diff --git a/src/middleware/rate-limit.middleware.ts b/src/middleware/rate-limit.middleware.ts index e9246d18..1b919398 100644 --- a/src/middleware/rate-limit.middleware.ts +++ b/src/middleware/rate-limit.middleware.ts @@ -6,8 +6,6 @@ interface RateLimitOptions { windowMs: number max: number message?: string - skipSuccessfulRequests?: boolean - skipFailedRequests?: boolean } interface RateLimitData { @@ -38,8 +36,6 @@ function createRateLimiter( windowMs, max, message = 'Too many requests, please try again later.', - skipSuccessfulRequests = false, - skipFailedRequests = false, } = options const isTest = process.env.NODE_ENV === 'test' diff --git a/tests/account.controller.test.ts b/tests/account.controller.test.ts index 03f421ea..80f054c2 100644 --- a/tests/account.controller.test.ts +++ b/tests/account.controller.test.ts @@ -182,7 +182,7 @@ describe('AccountController', () => { }) describe('getExportStatus', () => { - it("scopes the lookup to the requesting user and 404s on other users' requests", async () => { + it('scopes the lookup to the requesting user and 404s on other users\' requests', async () => { req.params = { id: '123e4567-e89b-42d3-a456-426614174000' } vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue(null) diff --git a/tests/avatar.service.test.ts b/tests/avatar.service.test.ts index cdef551e..744746b2 100644 --- a/tests/avatar.service.test.ts +++ b/tests/avatar.service.test.ts @@ -375,7 +375,7 @@ describe('AvatarService', () => { ) }) - it("prevents deleting another user's avatar", async () => { + it('prevents deleting another user\'s avatar', async () => { // The query is scoped to userId, so a different user simply gets no result mockFindFirst.mockResolvedValue(null) diff --git a/tests/stellar.service.test.ts b/tests/stellar.service.test.ts index a0fcb8c6..6eb786b6 100644 --- a/tests/stellar.service.test.ts +++ b/tests/stellar.service.test.ts @@ -306,7 +306,7 @@ describe('StellarService', () => { expect(await service.getNativeBalance('GPUBKEY...')).toBe('42.0000000') }) - it("returns '0' when no native balance exists", async () => { + it('returns \'0\' when no native balance exists', async () => { mockGetAccount.mockResolvedValue({ balances: [] }) expect(await service.getNativeBalance('GPUBKEY...')).toBe('0') }) diff --git a/tests/unit/wallet-transitions.test.ts b/tests/unit/wallet-transitions.test.ts index 0232232d..a9b9fe1b 100644 --- a/tests/unit/wallet-transitions.test.ts +++ b/tests/unit/wallet-transitions.test.ts @@ -65,7 +65,7 @@ describe('wallet status transitions and lifecycle guards', () => { InvalidWalletTransitionError, ) expect(() => assertValidWalletTransition('DISABLED', 'ACTIVE')).toThrow( - "Cannot transition wallet status from 'DISABLED' to 'ACTIVE'", + 'Cannot transition wallet status from \'DISABLED\' to \'ACTIVE\'', ) expect(() => assertValidWalletTransition('ACTIVE', 'EXPORTING'), diff --git a/tests/wallet-status.service.test.ts b/tests/wallet-status.service.test.ts index ddf18370..38ce1670 100644 --- a/tests/wallet-status.service.test.ts +++ b/tests/wallet-status.service.test.ts @@ -90,7 +90,7 @@ describe('WalletStatusService', () => { expect(status.status).toBe('UNAVAILABLE') }) - it("never exposes another user's wallet", async () => { + it('never exposes another user\'s wallet', async () => { const service = new WalletStatusService( repositoryFor(wallet()), {} as WalletStatusStellarProvider, @@ -230,7 +230,7 @@ describe('WalletStatusService', () => { memoType: null, } - it("marks records as incoming or outgoing relative to the owner's address", async () => { + it('marks records as incoming or outgoing relative to the owner\'s address', async () => { const stellar: WalletStatusStellarProvider = { getAccountSnapshot: async () => ({ found: true, From dfb7b1c954c027bfde7536d99e656ca7b7b48e12 Mon Sep 17 00:00:00 2001 From: Copstud3 Date: Sun, 30 Aug 2026 20:34:42 +0100 Subject: [PATCH 3/4] fix(tests): update string quotes in test cases for consistency --- eslint.config.ts | 4 +++- integrations/stellar.service.test.ts | 2 +- tests/account.controller.test.ts | 2 +- tests/avatar.service.test.ts | 2 +- tests/stellar.service.test.ts | 2 +- tests/unit/wallet-transitions.test.ts | 2 +- tests/wallet-status.service.test.ts | 4 ++-- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index c43b1bad..848469d9 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -47,7 +47,9 @@ export default defineConfig( // 'no-ternary': 'error', 'newline-before-return': 'error', semi: ['error', 'never'], - quotes: ['error', 'single'], + // Match Prettier: prefer single quotes, but avoid needless escaping in + // strings that contain apostrophes. + quotes: ['error', 'single', { avoidEscape: true }], 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': [ 'warn', diff --git a/integrations/stellar.service.test.ts b/integrations/stellar.service.test.ts index 6eb786b6..a0fcb8c6 100644 --- a/integrations/stellar.service.test.ts +++ b/integrations/stellar.service.test.ts @@ -306,7 +306,7 @@ describe('StellarService', () => { expect(await service.getNativeBalance('GPUBKEY...')).toBe('42.0000000') }) - it('returns \'0\' when no native balance exists', async () => { + it("returns '0' when no native balance exists", async () => { mockGetAccount.mockResolvedValue({ balances: [] }) expect(await service.getNativeBalance('GPUBKEY...')).toBe('0') }) diff --git a/tests/account.controller.test.ts b/tests/account.controller.test.ts index 80f054c2..03f421ea 100644 --- a/tests/account.controller.test.ts +++ b/tests/account.controller.test.ts @@ -182,7 +182,7 @@ describe('AccountController', () => { }) describe('getExportStatus', () => { - it('scopes the lookup to the requesting user and 404s on other users\' requests', async () => { + it("scopes the lookup to the requesting user and 404s on other users' requests", async () => { req.params = { id: '123e4567-e89b-42d3-a456-426614174000' } vi.mocked(prisma.dataExportRequest.findFirst).mockResolvedValue(null) diff --git a/tests/avatar.service.test.ts b/tests/avatar.service.test.ts index 744746b2..cdef551e 100644 --- a/tests/avatar.service.test.ts +++ b/tests/avatar.service.test.ts @@ -375,7 +375,7 @@ describe('AvatarService', () => { ) }) - it('prevents deleting another user\'s avatar', async () => { + it("prevents deleting another user's avatar", async () => { // The query is scoped to userId, so a different user simply gets no result mockFindFirst.mockResolvedValue(null) diff --git a/tests/stellar.service.test.ts b/tests/stellar.service.test.ts index 6eb786b6..a0fcb8c6 100644 --- a/tests/stellar.service.test.ts +++ b/tests/stellar.service.test.ts @@ -306,7 +306,7 @@ describe('StellarService', () => { expect(await service.getNativeBalance('GPUBKEY...')).toBe('42.0000000') }) - it('returns \'0\' when no native balance exists', async () => { + it("returns '0' when no native balance exists", async () => { mockGetAccount.mockResolvedValue({ balances: [] }) expect(await service.getNativeBalance('GPUBKEY...')).toBe('0') }) diff --git a/tests/unit/wallet-transitions.test.ts b/tests/unit/wallet-transitions.test.ts index a9b9fe1b..0232232d 100644 --- a/tests/unit/wallet-transitions.test.ts +++ b/tests/unit/wallet-transitions.test.ts @@ -65,7 +65,7 @@ describe('wallet status transitions and lifecycle guards', () => { InvalidWalletTransitionError, ) expect(() => assertValidWalletTransition('DISABLED', 'ACTIVE')).toThrow( - 'Cannot transition wallet status from \'DISABLED\' to \'ACTIVE\'', + "Cannot transition wallet status from 'DISABLED' to 'ACTIVE'", ) expect(() => assertValidWalletTransition('ACTIVE', 'EXPORTING'), diff --git a/tests/wallet-status.service.test.ts b/tests/wallet-status.service.test.ts index 38ce1670..ddf18370 100644 --- a/tests/wallet-status.service.test.ts +++ b/tests/wallet-status.service.test.ts @@ -90,7 +90,7 @@ describe('WalletStatusService', () => { expect(status.status).toBe('UNAVAILABLE') }) - it('never exposes another user\'s wallet', async () => { + it("never exposes another user's wallet", async () => { const service = new WalletStatusService( repositoryFor(wallet()), {} as WalletStatusStellarProvider, @@ -230,7 +230,7 @@ describe('WalletStatusService', () => { memoType: null, } - it('marks records as incoming or outgoing relative to the owner\'s address', async () => { + it("marks records as incoming or outgoing relative to the owner's address", async () => { const stellar: WalletStatusStellarProvider = { getAccountSnapshot: async () => ({ found: true, From 960d1a9e679a7aab2f311f60b1c11d4725aeb904 Mon Sep 17 00:00:00 2001 From: Copstud3 Date: Sun, 30 Aug 2026 20:39:56 +0100 Subject: [PATCH 4/4] refactor: update reward handling to use stroops instead of XLM --- src/controllers/credential.controller.ts | 4 ++-- src/controllers/module.controller.ts | 13 ++++++++----- src/controllers/referral.controller.ts | 19 +++++++++++-------- src/services/data-export.service.ts | 4 ++-- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/controllers/credential.controller.ts b/src/controllers/credential.controller.ts index 8894c5c8..7329903b 100644 --- a/src/controllers/credential.controller.ts +++ b/src/controllers/credential.controller.ts @@ -215,7 +215,7 @@ export class CredentialController { description: true, category: true, difficulty: true, - reward: true, + rewardStroops: true, }, }, }, @@ -245,7 +245,7 @@ export class CredentialController { issuedAt: credential.issuedAt.toISOString(), shareableLink: `/api/v1/credentials/verify/${credential.onChainId || credential.id}`, metadata: { - reward: credential.module.reward, + reward: credential.module.rewardStroops.toString(), verificationUrl: `/api/v1/credentials/verify/${credential.onChainId || credential.id}`, }, }, diff --git a/src/controllers/module.controller.ts b/src/controllers/module.controller.ts index ea7e55d5..1d80e47b 100644 --- a/src/controllers/module.controller.ts +++ b/src/controllers/module.controller.ts @@ -5,6 +5,7 @@ const COMPLETION_IN_PROGRESS_SCORE = -1 import { z } from 'zod' import { prisma } from '../config/database' import { NotificationService } from '../services/notification.service' +import { stroopsToXlmString } from '../utils/money' const notificationService = new NotificationService() @@ -151,7 +152,7 @@ export const listModules = async (req: Request, res: Response) => { description: module.description, category: module.category, difficulty: module.difficulty, - reward: module.reward, + reward: stroopsToXlmString(module.rewardStroops), createdAt: module.createdAt, updatedAt: module.updatedAt, completionCount: module._count.completions, @@ -251,7 +252,7 @@ export const getModuleById = async (req: Request, res: Response) => { description: module.description, category: module.category, difficulty: module.difficulty, - reward: module.reward, + reward: stroopsToXlmString(module.rewardStroops), createdAt: module.createdAt, updatedAt: module.updatedAt, completionCount: module._count.completions, @@ -497,7 +498,7 @@ export const completeModule = async (req: Request, res: Response) => { rewardTransaction = await prisma.transaction.create({ data: { userId: req.user.id, - amount: module.reward, + amountStroops: module.rewardStroops, type: 'reward', status: 'pending', }, @@ -511,7 +512,7 @@ export const completeModule = async (req: Request, res: Response) => { 'quizPassFail', isEligibleForReward ? 'Quiz Passed!' : 'Quiz Completed', isEligibleForReward - ? `Great job! You scored ${score}% on "${module.title}" and earned ${module.reward} XLM.` + ? `Great job! You scored ${score}% on "${module.title}" and earned ${stroopsToXlmString(module.rewardStroops)} XLM.` : `You scored ${score}% on "${module.title}". Keep practicing to earn rewards!`, ) .catch((err) => @@ -522,7 +523,9 @@ export const completeModule = async (req: Request, res: Response) => { message: 'Module completed successfully', score, isEligibleForReward, - reward: isEligibleForReward ? module.reward : 0, + reward: isEligibleForReward + ? stroopsToXlmString(module.rewardStroops) + : '0.0000000', rewardTransaction: rewardTransaction?.id, completedAt: updatedCompletion.completedAt, }) diff --git a/src/controllers/referral.controller.ts b/src/controllers/referral.controller.ts index 39ce6a9c..76aafd94 100644 --- a/src/controllers/referral.controller.ts +++ b/src/controllers/referral.controller.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express' import { randomBytes } from 'crypto' +import { stroopsToXlmString, xlmToStroops } from '../utils/money' import prisma from '../config/database' import { asyncHandler } from '../middleware/error.middleware' import { @@ -9,7 +10,7 @@ import { UnauthorizedError, } from '../utils/errors' -const REFERRAL_BONUS_AMOUNT = 5.0 +const REFERRAL_BONUS_STROOPS = xlmToStroops(5n) const CODE_BYTES = 4 export class ReferralController { @@ -209,8 +210,8 @@ export class ReferralController { ).length const paidBonuses = referrals.filter((r: ReferralRow) => r.bonusPaid) const earnedBonuses = paidBonuses.reduce( - (sum: number, r: ReferralRow) => sum + (r.bonusAmount ?? 0), - 0, + (sum: bigint, r: ReferralRow) => sum + (r.bonusAmountStroops ?? 0n), + 0n, ) res.status(200).json({ @@ -218,9 +219,11 @@ export class ReferralController { data: { totalReferrals, activeReferrals, - earnedBonuses, - pendingBonuses: - (totalReferrals - paidBonuses.length) * REFERRAL_BONUS_AMOUNT, + earnedBonuses: stroopsToXlmString(earnedBonuses), + pendingBonuses: stroopsToXlmString( + BigInt(totalReferrals - paidBonuses.length) * + REFERRAL_BONUS_STROOPS, + ), }, }) }, @@ -245,7 +248,7 @@ export class ReferralController { where: { id: referral.id }, data: { bonusPaid: true, - bonusAmount: REFERRAL_BONUS_AMOUNT, + bonusAmountStroops: REFERRAL_BONUS_STROOPS, bonusPaidAt: new Date(), }, }) @@ -253,7 +256,7 @@ export class ReferralController { await prisma.transaction.create({ data: { userId: referral.referrerId, - amount: REFERRAL_BONUS_AMOUNT, + amountStroops: REFERRAL_BONUS_STROOPS, type: 'referral_reward', status: 'completed', }, diff --git a/src/services/data-export.service.ts b/src/services/data-export.service.ts index b23b1669..c8087be2 100644 --- a/src/services/data-export.service.ts +++ b/src/services/data-export.service.ts @@ -174,7 +174,7 @@ export class DataExportService { select: { referreeId: true, bonusPaid: true, - bonusAmount: true, + bonusAmountStroops: true, createdAt: true, }, }), @@ -243,7 +243,7 @@ export class DataExportService { })), transactions: transactions.map((t: any) => ({ id: t.id, - amount: t.amount, + amountStroops: t.amountStroops.toString(), type: t.type, status: t.status, createdAt: t.createdAt,