Integrate Oudelaa backend features, security, tests, and deployment updates

This commit is contained in:
boutmoun123
2026-07-26 16:58:53 +03:00
parent 1b24ca4294
commit 2fd5322ef7
178 changed files with 19068 additions and 2592 deletions
+17
View File
@@ -0,0 +1,17 @@
.git
.github
.env
.env.*
!.env.example
node_modules
dist
coverage
uploads
*.log
npm-debug.log*
.agents
.codex
.vscode
.idea
postman
docs
+36
View File
@@ -6,16 +6,44 @@ HOST=0.0.0.0
PUBLIC_BASE_URL=http://localhost:4000
PUBLIC_APP_URL=https://oudelaa.com
RESPONSE_ENVELOPE_ENABLED=false
SWAGGER_ENABLED=true
GLOBAL_PREFIX=api/v1
# Add every frontend origin used by web/mobile debug tools.
CORS_ORIGINS=http://localhost:3000,http://192.168.1.14:3000,http://192.168.1.14:5173
MONGODB_URI=mongodb://127.0.0.1:27017/oudelaa
MONGODB_AUTO_INDEX=true
MONGODB_MIN_POOL_SIZE=5
MONGODB_MAX_POOL_SIZE=100
MONGODB_MAX_IDLE_TIME_MS=60000
MONGODB_SERVER_SELECTION_TIMEOUT_MS=10000
MONGODB_SOCKET_TIMEOUT_MS=45000
# Search keeps the same API contract. "auto" uses Atlas Search when available
# and falls back to MongoDB regex search on local/community deployments.
SEARCH_ENGINE=auto
SEARCH_ATLAS_USER_INDEX=users_search
SEARCH_ATLAS_POST_INDEX=posts_search
SEARCH_FALLBACK_ENABLED=true
SEARCH_ATLAS_RETRY_SECONDS=300
JWT_ACCESS_SECRET=change_me_access_secret
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_SECRET=change_me_refresh_secret
JWT_REFRESH_EXPIRES_IN=30d
BCRYPT_SALT_ROUNDS=12
REFRESH_TOKEN_HASH_SECRET=
HTTP_BODY_LIMIT=1mb
# Controller execution deadline. Override individual long-running routes with @RequestTimeout().
HTTP_REQUEST_TIMEOUT_MS=30000
# Low-level slow-client protection and connection reuse limits.
HTTP_SERVER_REQUEST_TIMEOUT_MS=120000
HTTP_HEADERS_TIMEOUT_MS=15000
HTTP_KEEP_ALIVE_TIMEOUT_MS=5000
HTTP_MAX_REQUESTS_PER_SOCKET=1000
# Time allowed for in-flight controller requests after SIGTERM/SIGINT.
SHUTDOWN_GRACE_PERIOD_MS=30000
METRICS_EVENT_LOOP_RESOLUTION_MS=20
METRICS_EVENT_LOOP_LAG_WARN_MS=100
METRICS_MAX_ROUTES=500
MODERATION_BLOCKED_TERMS=
PASSWORD_RESET_CODE_EXPIRES_MINUTES=10
PASSWORD_RESET_MAX_ATTEMPTS=5
PASSWORD_RESET_TOKEN_SECRET=
@@ -31,6 +59,10 @@ REQUEST_LOGGING_ENABLED=true
FEED_CACHE_ENABLED=true
FEED_CACHE_USER_TTL_SECONDS=15
FEED_CACHE_TRENDING_TTL_SECONDS=30
FEED_CACHE_RANKING_PROFILE_TTL_SECONDS=30
FEED_TIMING_LOGS_ENABLED=false
HTTP_COMPRESSION_ENABLED=true
HTTP_COMPRESSION_THRESHOLD_BYTES=1024
REDIS_ENABLED=false
REDIS_URL=
@@ -50,6 +82,7 @@ QUEUE_REMOVE_ON_COMPLETE=true
QUEUE_WORKER_CONCURRENCY=5
STORAGE_PROVIDER=local
MEDIA_ACCESS_MODE=direct
STORAGE_BASE_PATH=uploads
# In Docker/production with local storage, mount a persistent volume to /app/uploads
# or to the runtime path resolved from STORAGE_BASE_PATH.
@@ -113,7 +146,10 @@ AI_MUSIC_MODEL=lyria-002
SUPERADMIN_EMAIL=admin@oudelaa.com
SUPERADMIN_PASSWORD=SuperAdminStrongPass123!
# Required in production. Generate with bcrypt and leave SUPERADMIN_PASSWORD empty there.
SUPERADMIN_PASSWORD_HASH=
SUPERADMIN_ACCESS_SECRET=change_me_superadmin_access_secret
SUPERADMIN_ACCESS_EXPIRES_IN=15m
SUPERADMIN_REFRESH_SECRET=change_me_superadmin_refresh_secret
SUPERADMIN_REFRESH_EXPIRES_IN=30d
SUPERADMIN_TOTP_SECRET=
+69 -1
View File
@@ -1,13 +1,81 @@
name: Deploy To Ghaymah
permissions:
contents: read
concurrency:
group: oudelaa-production-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
deploy:
quality:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:7
ports:
- 27017:27017
options: >-
--health-cmd "mongosh --quiet --eval 'db.runCommand({ ping: 1 })'"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install locked dependencies
run: npm ci
- name: Static quality gates
run: |
npm run lint
npm run build
- name: Unit, integration, and coverage gates
run: npm run test:coverage
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
if-no-files-found: error
retention-days: 14
- name: Performance tooling tests
run: npm run test:perf
- name: End-to-end tests
run: npm run test:e2e
env:
E2E_MONGODB_URI: mongodb://127.0.0.1:27017/oudelaa-e2e
- name: Dependency security audit
run: npm audit --audit-level=high
- name: Verify production image
run: docker build --tag oudelaa-api:${{ github.sha }} .
deploy:
needs: quality
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
+1
View File
@@ -1,6 +1,7 @@
# Build
node_modules
dist
coverage
uploads
stream_*/
+1
View File
@@ -0,0 +1 @@
20
+13 -3
View File
@@ -14,16 +14,26 @@ WORKDIR /app
ENV NODE_ENV=production
ENV VIDEO_PROCESSING_FFMPEG_PATH=/usr/bin/ffmpeg
ENV NODE_OPTIONS=--enable-source-maps
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.edge.kernel.org/g' /etc/apk/repositories \
&& apk update \
&& apk add --no-cache ffmpeg
&& apk add --no-cache ffmpeg tini
COPY package*.json ./
RUN npm ci --omit=dev
RUN npm ci --omit=dev \
&& npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder --chown=node:node /app/dist ./dist
RUN mkdir -p /app/uploads \
&& chown -R node:node /app/uploads
USER node
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:4000/api/v1/health || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/main.js"]
+26
View File
@@ -55,10 +55,36 @@ Supported options:
- `--concurrency`
- `--timeout`
- `--warmup`
- `--min-success-rate` (percentage from `0` to `100`)
- `--min-rps` (minimum requests per second)
- `--max-p95` (maximum p95 latency in milliseconds)
- `--max-p99` (maximum p99 latency in milliseconds)
- `--header "Key: Value"`
- `--body`
- `--body-file`
### Automated performance gates
Add one or more gates to turn a benchmark into a CI regression check:
```powershell
node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/health --duration 15 --concurrency 20 --min-success-rate 99.9 --min-rps 1000 --max-p95 100 --max-p99 150
```
Or run the provided health baseline:
```powershell
npm run perf:health:gate
```
All configured gates must pass. The JSON summary contains the individual gate results, followed by a readable `PASS`/`FAIL` report. A performance regression exits with code `2`; invalid arguments or runtime errors exit with code `1`. Calibrate thresholds on production-like staging hardware rather than copying local numbers into deployment policy.
The gate parser, evaluator, and process exit behavior have a standalone test suite:
```powershell
npm run test:perf
```
## 5. What to watch
- `requestsPerSecond`: throughput
+13
View File
@@ -1,5 +1,18 @@
# Oudelaa Backend
Advanced search deployment and compatibility notes are documented in
[`docs/SEARCH_ARCHITECTURE.md`](docs/SEARCH_ARCHITECTURE.md).
Home feed candidate generation, personalization, safety, diversity, and cursor behavior are
documented in [`docs/FEED_RANKING.md`](docs/FEED_RANKING.md).
Production-oriented NestJS backend for a social media platform.
Frontend handoff notes and WebSocket event docs live in [docs/FRONTEND_INTEGRATION.md](docs/FRONTEND_INTEGRATION.md).
Capacity baselines and production limitations are tracked in
[`docs/SYSTEM_STRENGTH_REPORT.md`](docs/SYSTEM_STRENGTH_REPORT.md), with repeatable CI performance
gates documented in [`PERFORMANCE_TESTING.md`](PERFORMANCE_TESTING.md).
For S3 deployments, authenticated clients can request a short-lived, user-scoped direct upload
with `POST /api/v1/media/uploads/presign`. Existing multipart endpoints remain supported.
+71
View File
@@ -0,0 +1,71 @@
# Home feed ranking
The home feed contract remains `GET /api/v1/feed/me`; no query parameter or response field was removed or renamed.
## Candidate generation
Each request merges two bounded candidate pools:
- Recent eligible posts (65%).
- High-quality eligible posts ranked by saves, shares, comments, and likes (35%).
The merged pool is deduplicated and capped between 400 and 1,000 candidates depending on the requested page size. The default feed remains focused on followed accounts and falls back to public discovery only when the default following feed is empty.
## Hard safety filters
The ranking layer cannot override these rules:
- Deleted and archived posts are excluded.
- Posts with `moderationStatus=hidden` are excluded.
- Blocked or blocking authors are excluded.
- Disabled and missing authors are excluded after population.
- Posts reported by the current viewer are excluded unless the report was rejected.
- Public, followers-only, and own-post visibility rules remain enforced.
## Personalized signals
A short-lived ranking profile is computed from the viewer's last 90 days of:
- Likes (weight 1.5).
- Comments (weight 3).
- Saves (weight 4).
- Shares (weight 5).
Those events produce capped affinities for authors, post types, and hashtags. Existing profile preferences, follows, location, and requested post type remain signals. The profile is cached for 30 seconds by default.
## Scoring
Raw counters use `log1p` normalization so viral totals cannot grow without bound and erase personalization. Ranking combines:
- Log-normalized engagement quality.
- Exponential freshness decay with a small boost for posts younger than six hours.
- Follow, author affinity, post-type affinity, and hashtag affinity.
- Profile interests and a bounded geographic boost.
- Small verification and follower-count priors.
- A penalty for posts the viewer already interacted with, reducing repetition without hiding them.
## Diversity
After relevance scoring, a deterministic greedy reranker applies penalties for:
- Consecutive posts from the same author.
- Repeated appearances by the same author.
- Overrepresented post types.
- Repeated hashtags.
This preserves strong content while avoiding a page dominated by one creator or one format.
## Cursor stability
New cursors contain an opaque version, offset, and ranking timestamp. Every page in the cursor chain applies the same `createdAt <= rankedAt` window and computes freshness against the same timestamp, so newly created posts do not shift already paginated results. Legacy numeric offset cursors remain supported.
## Configuration
```dotenv
FEED_CACHE_ENABLED=true
FEED_CACHE_USER_TTL_SECONDS=15
FEED_CACHE_TRENDING_TTL_SECONDS=30
FEED_CACHE_RANKING_PROFILE_TTL_SECONDS=30
```
Viewer-level watch duration and explicit "Not interested" events do not exist in the current data model. They should be added as future ranking signals when the mobile client starts emitting those events; global `viewCount` and `playCount` are used only as weak quality priors.
+80
View File
@@ -0,0 +1,80 @@
# Search architecture
The public API contract is unchanged:
- `GET /api/v1/search`
- `GET /api/v1/search/users`
- `GET /api/v1/search/posts`
- `GET /api/v1/search/hashtags`
- `GET /api/v1/search/suggestions`
The existing `q`, `type`, `page`, `limit`, and `sortOrder` query parameters and all response and pagination shapes remain compatible with existing clients.
## Search engines
`SEARCH_ENGINE` accepts:
- `auto` (default): use Atlas Search and automatically use compatibility search if Atlas Search or its indexes are unavailable.
- `atlas`: prefer Atlas Search. With `SEARCH_FALLBACK_ENABLED=true`, service remains available during an Atlas Search failure.
- `regex`: use the original MongoDB regex implementation only.
When Atlas Search fails, the service logs one warning and waits `SEARCH_ATLAS_RETRY_SECONDS` before retrying it. Requests continue through the compatibility implementation during that interval.
## Ranking
User ranking combines:
1. Exact username relevance.
2. Username, stage name, and display name autocomplete relevance.
3. One-character typo tolerance for queries of at least three characters.
4. Whether the viewer follows the result.
5. Whether the viewer recently liked or saved posts from the result.
6. Verification and a logarithmic follower-count boost.
Post ranking combines:
1. Content, hashtag, style, maqam, and rhythm relevance.
2. Typo tolerance for queries of at least four characters.
3. Follow and recent interaction affinity with the author.
4. A logarithmic engagement boost.
5. A seven-day recency decay.
Blocked users, disabled authors, deleted or archived posts, moderation state, and visibility are hard filters. They never become ranking signals and cannot be bypassed by a high search score.
## Atlas Search index deployment
The index definitions are stored in:
- `ops/atlas-search/users_search.json`
- `ops/atlas-search/posts_search.json`
Set `MONGODB_URI` to the target Atlas database and run:
```bash
npm run search:sync-indexes
```
The script creates missing indexes and updates existing indexes using `SEARCH_ATLAS_USER_INDEX` and `SEARCH_ATLAS_POST_INDEX`. Atlas builds indexes asynchronously; `SEARCH_ENGINE=auto` continues using compatibility search until they become queryable.
Recommended production configuration:
```dotenv
SEARCH_ENGINE=auto
SEARCH_ATLAS_USER_INDEX=users_search
SEARCH_ATLAS_POST_INDEX=posts_search
SEARCH_FALLBACK_ENABLED=true
SEARCH_ATLAS_RETRY_SECONDS=300
```
For local MongoDB Community development, keep `auto` for production parity or use `regex` to suppress the initial Atlas capability check.
## Verification
Run:
```bash
npm run build
npm test -- --runInBand src/modules/search/search.service.spec.ts
```
The tests verify response compatibility, Atlas result ordering, privacy clauses, and fallback retry throttling.
+105
View File
@@ -0,0 +1,105 @@
# System strength report
Date: 2026-07-22
## Result
The backend is functionally stable and performs well in the tested local environment. It is not yet possible to claim production-scale capacity because the test used a single Node.js process, local MongoDB, a small dataset, and disabled Redis/S3/Atlas dependencies.
## Verification summary
| Area | Result |
| --- | --- |
| TypeScript build | Passed |
| ESLint | Passed |
| Unit/integration suites | 85/85 passed |
| Unit/integration tests | 947/947 passed |
| End-to-end scenarios | 14/14 passed |
| Dependency audit | 0 cached advisory findings; live audit runs in CI |
| Statement coverage | 93.08% |
| Branch coverage | 67.97% |
| Function coverage | 92.93% |
| Line coverage | 93.26% |
The executable service layer is also protected by an aggregate coverage gate: 92.81% statements,
93.02% lines, 93.67% functions, and 74.96% branches across 43 service files. `npm run
test:coverage` enforces both the project-wide gates and these service-layer gates, and the same
command is mandatory in CI.
## Performance results
Tests ran on one local process and are useful for regression comparison, not public capacity promises.
| Workload | Concurrency | Throughput | Average | p95 | p99 | Success |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Liveness health | 20 | 2357 req/s | 8.47 ms | 12.64 ms | 15.92 ms | 100% |
| Cached personalized feed | 20 | 567 req/s | 35.20 ms | 40.78 ms | 45.25 ms | 100% |
| Liveness burst | 100 | 2448 req/s | 40.71 ms | 48.89 ms | 72.62 ms | 100% |
| Uncached personalized feed | 20 | 65 req/s | 304.34 ms | 392.81 ms | 445.92 ms | 100% |
| Uncached compatibility search | 10 | 109 req/s | 91.38 ms | 100.38 ms | 106.79 ms | 100% |
| Hardened health gate | 50 | 2041 req/s | 24.40 ms | 39.85 ms | 48.57 ms | 100% |
| Cold-cache/coalesced feed gate | 30 | 336 req/s | 88.79 ms | 116.14 ms | 168.69 ms | 100% |
Cold startup was approximately 4.8 seconds.
## Security checks
- Forged JWT was rejected with 401.
- NoSQL-shaped login input was rejected with 400.
- Unknown privileged fields were rejected with 400.
- Malformed JSON was rejected with 400.
- A request above the configured body limit was rejected with 413.
- Login throttling returned 429 after the allowed attempts were consumed.
- Production CORS returned the configured origin and did not return an allow-origin header for an untrusted origin.
- HSTS, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy were present in production mode.
- The npm audit reported zero known vulnerabilities.
## Concurrency and realtime checks
- Twenty simultaneous duplicate like requests produced one like and a correct counter.
- Authenticated Socket.IO clients connected to both `chat` and `notifications` namespaces.
- An anonymous Socket.IO client was disconnected.
- Refresh-token rotation, notification delivery, feed exclusion, comments, uploads, and superadmin sessions passed end-to-end.
## Improvements made during testing
- Added `/api/v1/health/ready`, which checks MongoDB, Redis when enabled, and storage, and returns 503 when degraded.
- Added readiness unit and end-to-end coverage.
- Added real Socket.IO end-to-end tests.
- Added concurrent idempotency coverage for likes.
- Improved startup benchmark diagnostics to include the child exit code and recent logs.
- Corrected local storage configuration to use direct media access.
- Added local single-flight and ownership-safe Redis leases to prevent cache stampedes.
- Added configurable MongoDB connection pools and disabled automatic production index builds by default.
- Added response compression and bounded Node HTTP transport settings.
- Added request deadlines, graceful request draining, and drain-aware readiness.
- Added event-loop, in-flight request, timeout, and bounded-cardinality route metrics.
- Added direct, user-scoped S3 PUT uploads so large media can bypass Node memory.
- Added CI performance gates and made unit, E2E, audit, and Docker build checks mandatory before deployment.
- Added behavioral, security, failure-path, controller-delegation, repository-persistence, and
bootstrap tests, increasing the regression suite to 947 tests.
- Added enforced project-wide and service-layer coverage gates so the achieved baseline cannot
silently regress.
- Hardened the production container with a non-root user, `tini`, a healthcheck, and secret-safe Docker context exclusions.
## Remaining production validation
Before a high-traffic launch, run the same tests in staging with production-sized data and production topology:
1. Multiple application replicas behind the real load balancer.
2. Redis enabled for cache, throttling, queues, and Socket.IO fan-out.
3. S3-compatible storage, signed media URLs, and CDN behavior.
4. Atlas Search indexes and representative Arabic/English datasets.
5. Long soak tests (2-8 hours), failover tests, and MongoDB/Redis latency injection.
6. Continue raising branch coverage, prioritizing rare infrastructure and dependency-failure combinations.
7. Run an external DAST/SAST and penetration test before handling sensitive production data.
## Current assessment
- Functional correctness: strong.
- Local performance: strong with cache; acceptable but database-bound without cache.
- Security controls: strong baseline.
- Operational readiness: good after adding dependency readiness checks.
- Automated test maturity: strong baseline, with more than 90% statement, line, and function coverage
plus separate runtime-service gates.
- Production-scale confidence: pending staging tests with real infrastructure and data volume.
+29
View File
@@ -0,0 +1,29 @@
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist/**', 'node_modules/**', 'coverage/**'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
},
rules: {
'no-undef': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
eslintConfigPrettier,
);
+16
View File
@@ -2,10 +2,26 @@ module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testEnvironment: 'node',
setupFiles: ['<rootDir>/test/setup-env.ts'],
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['src/**/*.(t|j)s'],
coverageDirectory: './coverage',
coverageReporters: ['text', 'json-summary', 'lcov'],
coverageThreshold: {
global: {
statements: 90,
lines: 90,
functions: 90,
branches: 40,
},
'./src/**/*.service.ts': {
statements: 50,
lines: 50,
functions: 50,
branches: 30,
},
},
};
+44
View File
@@ -0,0 +1,44 @@
{
"mappings": {
"dynamic": false,
"fields": {
"content": {
"type": "string",
"analyzer": "lucene.standard"
},
"contentTop": {
"type": "string",
"analyzer": "lucene.standard"
},
"contentBottom": {
"type": "string",
"analyzer": "lucene.standard"
},
"hashtags": [
{
"type": "autocomplete",
"tokenization": "edgeGram",
"minGrams": 2,
"maxGrams": 15,
"foldDiacritics": true
},
{
"type": "string",
"analyzer": "lucene.keyword"
}
],
"style": {
"type": "string",
"analyzer": "lucene.standard"
},
"maqam": {
"type": "string",
"analyzer": "lucene.standard"
},
"rhythmSignature": {
"type": "string",
"analyzer": "lucene.standard"
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"mappings": {
"dynamic": false,
"fields": {
"username": [
{
"type": "autocomplete",
"tokenization": "edgeGram",
"minGrams": 2,
"maxGrams": 15,
"foldDiacritics": true
},
{
"type": "string",
"analyzer": "lucene.keyword"
}
],
"name": [
{
"type": "autocomplete",
"tokenization": "edgeGram",
"minGrams": 2,
"maxGrams": 15,
"foldDiacritics": true
},
{
"type": "string",
"analyzer": "lucene.standard"
}
],
"stageName": [
{
"type": "autocomplete",
"tokenization": "edgeGram",
"minGrams": 2,
"maxGrams": 15,
"foldDiacritics": true
},
{
"type": "string",
"analyzer": "lucene.standard"
}
]
}
}
}
+1913 -2166
View File
File diff suppressed because it is too large Load Diff
+33 -18
View File
@@ -4,44 +4,54 @@
"private": true,
"description": "Production-ready social media backend with NestJS and MongoDB",
"license": "UNLICENSED",
"engines": {
"node": ">=20 <25"
},
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"lint": "eslint \"src/**/*.ts\" --fix",
"lint": "eslint \"src/**/*.ts\"",
"lint:fix": "eslint \"src/**/*.ts\" --fix",
"test": "jest",
"test:coverage": "jest --runInBand --coverage && node scripts/check-coverage.js",
"test:watch": "jest --watch",
"test:e2e": "jest --runInBand --config ./test/jest-e2e.json",
"test:perf": "node --test scripts/load-test.test.js",
"perf:load": "node scripts/load-test.js",
"perf:health": "node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/health --duration 15 --concurrency 20",
"perf:startup": "node scripts/startup-benchmark.js"
"perf:health:gate": "node scripts/load-test.js --url http://127.0.0.1:4000/api/v1/health --duration 15 --concurrency 20 --min-success-rate 99.9 --min-rps 1000 --max-p95 100 --max-p99 150",
"perf:startup": "node scripts/startup-benchmark.js",
"search:sync-indexes": "node scripts/sync-atlas-search-indexes.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1041.0",
"@aws-sdk/lib-storage": "^3.1041.0",
"@aws-sdk/s3-request-presigner": "^3.1041.0",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mongoose": "^10.1.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/platform-socket.io": "^10.4.0",
"@nestjs/swagger": "^8.1.0",
"@nestjs/websockets": "^10.4.0",
"@nestjs/common": "^11.1.28",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.28",
"@nestjs/jwt": "^11.0.2",
"@nestjs/mongoose": "^11.0.4",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.28",
"@nestjs/platform-socket.io": "^11.1.28",
"@nestjs/swagger": "^11.4.6",
"@nestjs/websockets": "^11.1.28",
"@socket.io/redis-adapter": "^8.3.0",
"@types/passport-google-oauth20": "^2.0.17",
"bcrypt": "^5.1.1",
"bcrypt": "^6.0.0",
"bullmq": "^5.76.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"compression": "^1.8.1",
"dotenv": "^16.4.5",
"google-auth-library": "^10.6.2",
"ioredis": "^5.10.1",
"joi": "^17.13.3",
"mongoose": "^8.6.0",
"nodemailer": "^8.0.5",
"nodemailer": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
@@ -50,10 +60,12 @@
"socket.io": "^4.8.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.0",
"@eslint/js": "^9.39.4",
"@nestjs/cli": "^11.0.24",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.28",
"@types/bcrypt": "^5.0.2",
"@types/compression": "^1.8.1",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.16.5",
@@ -63,14 +75,17 @@
"eslint": "^9.11.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"globals": "^16.5.0",
"jest": "^29.7.0",
"prettier": "^3.3.3",
"socket.io-client": "^4.8.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.2"
"typescript": "^5.6.2",
"typescript-eslint": "^8.64.0"
}
}
@@ -7155,7 +7155,185 @@
"pm.expect(json.audioUrl).to.exist;",
"pm.expect(json.mimeType).to.exist;",
"pm.expect(json.sizeBytes).to.be.a('number');",
"pm.environment.set('generatedMusicUrl', json.audioUrl);"
"pm.environment.set('generatedMusicUrl', json.audioUrl);",
"if (json.archiveItemId) { pm.environment.set('aiArchiveItemId', json.archiveItemId); }"
]
}
}
]
},
{
"name": "Get My AI Music Archive",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive?page={{aiArchivePage}}&limit={{aiArchiveLimit}}"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json.items).to.be.an('array');",
"pm.expect(json.pagination).to.have.property('hasMore');",
"if (json.items.length > 0) { pm.environment.set('aiArchiveItemId', json.items[0].id); }"
]
}
}
]
},
{
"name": "Get AI Music Archive Item",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json.id).to.eql(pm.environment.get('aiArchiveItemId'));",
"pm.expect(json.audioUrl).to.exist;"
]
}
}
]
},
{
"name": "Rename AI Music Archive Item",
"request": {
"method": "PATCH",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}",
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"{{aiArchiveNewTitle}}\"\n}"
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json.title).to.eql(pm.environment.get('aiArchiveNewTitle'));"
]
}
}
]
},
{
"name": "Share AI Music Archive Item",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}/share"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 201 or 200', function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });",
"const json = pm.response.json();",
"pm.expect(json.shareUrl).to.exist;",
"pm.expect(json.audioUrl).to.exist;"
]
}
}
]
},
{
"name": "Share AI Music Archive Item To Feed",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}/share-to-feed",
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"{{aiArchiveShareContent}}\"\n}"
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 201 or 200', function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });",
"const json = pm.response.json();",
"pm.expect(json.audioUrl).to.exist;",
"pm.environment.set('ownPostId', json.id || json._id);"
]
}
}
]
},
{
"name": "Delete AI Music Archive Item",
"request": {
"method": "DELETE",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json.message).to.eql('Archive item deleted');"
]
}
}
@@ -10818,6 +10996,26 @@
{
"key": "socketAccessToken",
"value": "{{accessToken}}"
},
{
"key": "aiArchivePage",
"value": "1"
},
{
"key": "aiArchiveLimit",
"value": "20"
},
{
"key": "aiArchiveItemId",
"value": ""
},
{
"key": "aiArchiveNewTitle",
"value": "Renamed AI Music"
},
{
"key": "aiArchiveShareContent",
"value": "Shared from my AI music archive"
}
]
}
+199 -1
View File
@@ -6710,7 +6710,185 @@
"pm.expect(json.audioUrl).to.exist;",
"pm.expect(json.mimeType).to.exist;",
"pm.expect(json.sizeBytes).to.be.a('number');",
"pm.environment.set('generatedMusicUrl', json.audioUrl);"
"pm.environment.set('generatedMusicUrl', json.audioUrl);",
"if (json.archiveItemId) { pm.environment.set('aiArchiveItemId', json.archiveItemId); }"
]
}
}
]
},
{
"name": "Get My AI Music Archive",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive?page={{aiArchivePage}}&limit={{aiArchiveLimit}}"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json.items).to.be.an('array');",
"pm.expect(json.pagination).to.have.property('hasMore');",
"if (json.items.length > 0) { pm.environment.set('aiArchiveItemId', json.items[0].id); }"
]
}
}
]
},
{
"name": "Get AI Music Archive Item",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json.id).to.eql(pm.environment.get('aiArchiveItemId'));",
"pm.expect(json.audioUrl).to.exist;"
]
}
}
]
},
{
"name": "Rename AI Music Archive Item",
"request": {
"method": "PATCH",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}",
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"{{aiArchiveNewTitle}}\"\n}"
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json.title).to.eql(pm.environment.get('aiArchiveNewTitle'));"
]
}
}
]
},
{
"name": "Share AI Music Archive Item",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}/share"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 201 or 200', function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });",
"const json = pm.response.json();",
"pm.expect(json.shareUrl).to.exist;",
"pm.expect(json.audioUrl).to.exist;"
]
}
}
]
},
{
"name": "Share AI Music Archive Item To Feed",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}/share-to-feed",
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"{{aiArchiveShareContent}}\"\n}"
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 201 or 200', function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });",
"const json = pm.response.json();",
"pm.expect(json.audioUrl).to.exist;",
"pm.environment.set('ownPostId', json.id || json._id);"
]
}
}
]
},
{
"name": "Delete AI Music Archive Item",
"request": {
"method": "DELETE",
"header": [
{
"key": "Authorization",
"value": "Bearer {{accessToken}}"
}
],
"url": "{{baseUrl}}/media/ai/music/archive/{{aiArchiveItemId}}"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status is 200', function () { pm.response.to.have.status(200); });",
"const json = pm.response.json();",
"pm.expect(json.message).to.eql('Archive item deleted');"
]
}
}
@@ -8192,6 +8370,26 @@
{
"key": "socketAccessToken",
"value": "{{accessToken}}"
},
{
"key": "aiArchivePage",
"value": "1"
},
{
"key": "aiArchiveLimit",
"value": "20"
},
{
"key": "aiArchiveItemId",
"value": ""
},
{
"key": "aiArchiveNewTitle",
"value": "Renamed AI Music"
},
{
"key": "aiArchiveShareContent",
"value": "Shared from my AI music archive"
}
]
}
+53
View File
@@ -0,0 +1,53 @@
const fs = require('node:fs');
const path = require('node:path');
const summaryPath = path.resolve(process.cwd(), 'coverage', 'coverage-summary.json');
const thresholds = {
statements: 90,
lines: 90,
functions: 90,
branches: 70,
};
if (!fs.existsSync(summaryPath)) {
console.error(`Coverage summary was not found at ${summaryPath}`);
process.exit(1);
}
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
const serviceEntries = Object.entries(summary).filter(([file]) =>
file.replace(/\\/g, '/').endsWith('.service.ts'),
);
if (!serviceEntries.length) {
console.error('Coverage summary did not contain any service files');
process.exit(1);
}
let failed = false;
console.log(`\nRuntime service coverage (${serviceEntries.length} files):`);
for (const [metric, minimum] of Object.entries(thresholds)) {
const totals = serviceEntries.reduce(
(result, [, coverage]) => ({
covered: result.covered + coverage[metric].covered,
total: result.total + coverage[metric].total,
}),
{ covered: 0, total: 0 },
);
const percentage = totals.total === 0 ? 100 : (totals.covered / totals.total) * 100;
const formatted = percentage.toFixed(2);
const status = percentage >= minimum ? 'PASS' : 'FAIL';
console.log(
` ${metric.padEnd(10)} ${formatted.padStart(6)}% ` +
`(${totals.covered}/${totals.total}, required ${minimum}%) ${status}`,
);
failed ||= percentage < minimum;
}
if (failed) {
console.error('\nRuntime service coverage gate failed.');
process.exit(1);
}
console.log('\nRuntime service coverage gate passed.');
+165 -4
View File
@@ -11,6 +11,12 @@ function parseArgs(argv) {
warmup: 5,
headers: {},
body: undefined,
gates: {
minSuccessRate: undefined,
minRequestsPerSecond: undefined,
maxP95Ms: undefined,
maxP99Ms: undefined,
},
};
for (let index = 0; index < argv.length; index += 1) {
@@ -53,6 +59,34 @@ function parseArgs(argv) {
continue;
}
if (arg === '--min-success-rate') {
assertOptionValue(arg, next);
options.gates.minSuccessRate = Number(next);
index += 1;
continue;
}
if (arg === '--min-rps') {
assertOptionValue(arg, next);
options.gates.minRequestsPerSecond = Number(next);
index += 1;
continue;
}
if (arg === '--max-p95') {
assertOptionValue(arg, next);
options.gates.maxP95Ms = Number(next);
index += 1;
continue;
}
if (arg === '--max-p99') {
assertOptionValue(arg, next);
options.gates.maxP99Ms = Number(next);
index += 1;
continue;
}
if (arg === '--header' && next) {
const separatorIndex = next.indexOf(':');
if (separatorIndex === -1) {
@@ -95,6 +129,14 @@ function parseArgs(argv) {
throw new Error('warmup must be zero or a positive integer');
}
validateGate(options.gates.minSuccessRate, 'min-success-rate', {
min: 0,
max: 100,
});
validateGate(options.gates.minRequestsPerSecond, 'min-rps', { min: 0 });
validateGate(options.gates.maxP95Ms, 'max-p95', { min: 0 });
validateGate(options.gates.maxP99Ms, 'max-p99', { min: 0 });
if (options.body && !options.headers['Content-Type']) {
options.headers['Content-Type'] = 'application/json';
}
@@ -102,6 +144,27 @@ function parseArgs(argv) {
return options;
}
function assertOptionValue(option, value) {
if (value === undefined) {
throw new Error(`${option} requires a value`);
}
}
function validateGate(value, name, limits) {
if (value === undefined) {
return;
}
const aboveMaximum = limits.max !== undefined && value > limits.max;
if (!Number.isFinite(value) || value < limits.min || aboveMaximum) {
const range =
limits.max === undefined
? `at least ${limits.min}`
: `between ${limits.min} and ${limits.max}`;
throw new Error(`${name} must be a finite number ${range}`);
}
}
function percentile(sortedValues, p) {
if (!sortedValues.length) {
return 0;
@@ -124,6 +187,9 @@ function average(values) {
function printUsage() {
console.log('Usage: node scripts/load-test.js --url <url> [--duration 15] [--concurrency 20]');
console.log('Optional: --method POST --header "Authorization: Bearer <token>" --body "{\"key\":\"value\"}"');
console.log(
'Performance gates: --min-success-rate 99.9 --min-rps 100 --max-p95 250 --max-p99 500',
);
}
async function warmup(options) {
@@ -218,6 +284,86 @@ function buildSummary(options, results, totalDurationMs) {
};
}
function evaluatePerformanceGates(summary, gates) {
const definitions = [
{
configuredValue: gates.minSuccessRate,
metric: 'successRate',
label: 'Success rate',
actual: summary.successRate,
operator: '>=',
unit: '%',
passes: (actual, threshold) => actual >= threshold,
},
{
configuredValue: gates.minRequestsPerSecond,
metric: 'requestsPerSecond',
label: 'Requests/second',
actual: summary.requestsPerSecond,
operator: '>=',
unit: ' req/s',
passes: (actual, threshold) => actual >= threshold,
},
{
configuredValue: gates.maxP95Ms,
metric: 'latencyMs.p95',
label: 'Latency p95',
actual: summary.latencyMs.p95,
operator: '<=',
unit: ' ms',
passes: (actual, threshold) => actual <= threshold,
},
{
configuredValue: gates.maxP99Ms,
metric: 'latencyMs.p99',
label: 'Latency p99',
actual: summary.latencyMs.p99,
operator: '<=',
unit: ' ms',
passes: (actual, threshold) => actual <= threshold,
},
];
const checks = definitions
.filter((definition) => definition.configuredValue !== undefined)
.map((definition) => ({
metric: definition.metric,
label: definition.label,
actual: definition.actual,
operator: definition.operator,
threshold: definition.configuredValue,
unit: definition.unit,
passed: definition.passes(definition.actual, definition.configuredValue),
}));
return {
configured: checks.length > 0,
passed: checks.every((check) => check.passed),
checks,
};
}
function printGateReport(gateResult) {
console.log('');
if (!gateResult.configured) {
console.log('Performance gates: not configured');
return;
}
const passedChecks = gateResult.checks.filter((check) => check.passed).length;
console.log(
`Performance gates: ${gateResult.passed ? 'PASSED' : 'FAILED'} (${passedChecks}/${gateResult.checks.length})`,
);
for (const check of gateResult.checks) {
const status = check.passed ? 'PASS' : 'FAIL';
console.log(
`[${status}] ${check.label}: ${check.actual}${check.unit} ${check.operator} ${check.threshold}${check.unit}`,
);
}
}
async function main() {
if (process.argv.includes('--help')) {
printUsage();
@@ -249,12 +395,27 @@ async function main() {
const totalDurationMs = performance.now() - startedAt;
const summary = buildSummary(options, results, totalDurationMs);
const performanceGates = evaluatePerformanceGates(summary, options.gates);
summary.performanceGates = performanceGates;
console.log('');
console.log(JSON.stringify(summary, null, 2));
printGateReport(performanceGates);
if (!performanceGates.passed) {
process.exitCode = 2;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
module.exports = {
buildSummary,
evaluatePerformanceGates,
parseArgs,
};
+123
View File
@@ -0,0 +1,123 @@
const assert = require('node:assert/strict');
const { execFile } = require('node:child_process');
const { createServer } = require('node:http');
const path = require('node:path');
const { test } = require('node:test');
const { evaluatePerformanceGates, parseArgs } = require('./load-test');
const passingSummary = {
requestsPerSecond: 250.25,
successRate: 99.95,
latencyMs: { p95: 120.5, p99: 180.75 },
};
test('parseArgs reads all optional performance gates', () => {
const options = parseArgs([
'--min-success-rate',
'99.9',
'--min-rps',
'200',
'--max-p95',
'150',
'--max-p99',
'250',
]);
assert.deepEqual(options.gates, {
minSuccessRate: 99.9,
minRequestsPerSecond: 200,
maxP95Ms: 150,
maxP99Ms: 250,
});
});
test('parseArgs rejects invalid gate values', () => {
assert.throws(
() => parseArgs(['--min-success-rate', '100.1']),
/min-success-rate must be a finite number between 0 and 100/,
);
assert.throws(() => parseArgs(['--min-rps', '-1']), /min-rps must be a finite number/);
assert.throws(() => parseArgs(['--max-p95']), /--max-p95 requires a value/);
});
test('evaluatePerformanceGates passes when every threshold is satisfied', () => {
const result = evaluatePerformanceGates(passingSummary, {
minSuccessRate: 99.9,
minRequestsPerSecond: 200,
maxP95Ms: 150,
maxP99Ms: 200,
});
assert.equal(result.configured, true);
assert.equal(result.passed, true);
assert.equal(result.checks.length, 4);
assert.ok(result.checks.every((check) => check.passed));
});
test('evaluatePerformanceGates reports every failed threshold', () => {
const result = evaluatePerformanceGates(passingSummary, {
minSuccessRate: 100,
minRequestsPerSecond: 300,
maxP95Ms: 100,
maxP99Ms: 150,
});
assert.equal(result.passed, false);
assert.deepEqual(
result.checks.filter((check) => !check.passed).map((check) => check.metric),
['successRate', 'requestsPerSecond', 'latencyMs.p95', 'latencyMs.p99'],
);
});
test('load-test process exits with code 2 and a clear report when a gate fails', async () => {
const server = createServer((_request, response) => {
response.statusCode = 503;
response.end('unavailable');
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
try {
const address = server.address();
assert.ok(address && typeof address === 'object');
const result = await runLoadTest([
'--url',
`http://127.0.0.1:${address.port}`,
'--duration',
'0.05',
'--concurrency',
'1',
'--warmup',
'0',
'--min-success-rate',
'100',
]);
assert.equal(result.exitCode, 2);
assert.match(result.stdout, /Performance gates: FAILED \(0\/1\)/);
assert.match(result.stdout, /\[FAIL\] Success rate:/);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
function runLoadTest(args) {
const scriptPath = path.join(__dirname, 'load-test.js');
return new Promise((resolve) => {
execFile(process.execPath, [scriptPath, ...args], (error, stdout, stderr) => {
resolve({
exitCode: error && typeof error.code === 'number' ? error.code : 0,
stdout,
stderr,
});
});
});
}
+10
View File
@@ -139,6 +139,16 @@ async function main() {
2,
),
);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
[
reason,
`Child exit code: ${child.exitCode ?? 'still-running'}`,
`Recent stdout: ${stdoutLines.join(' | ') || 'none'}`,
`Recent stderr: ${stderrLines.join(' | ') || 'none'}`,
].join('\n'),
);
} finally {
await terminate(child);
}
+58
View File
@@ -0,0 +1,58 @@
const fs = require('node:fs');
const path = require('node:path');
const mongoose = require('mongoose');
const projectRoot = path.resolve(__dirname, '..');
require('dotenv').config({ path: path.join(projectRoot, '.env') });
const indexSpecs = [
{
collection: 'users',
name: process.env.SEARCH_ATLAS_USER_INDEX || 'users_search',
definitionPath: path.join(projectRoot, 'ops', 'atlas-search', 'users_search.json'),
},
{
collection: 'posts',
name: process.env.SEARCH_ATLAS_POST_INDEX || 'posts_search',
definitionPath: path.join(projectRoot, 'ops', 'atlas-search', 'posts_search.json'),
},
];
async function syncIndex(database, spec) {
const collection = database.collection(spec.collection);
const definition = JSON.parse(fs.readFileSync(spec.definitionPath, 'utf8'));
const existing = await collection.listSearchIndexes(spec.name).toArray();
if (existing.length) {
await collection.updateSearchIndex(spec.name, definition);
process.stdout.write(`Updated Atlas Search index ${spec.name} on ${spec.collection}\n`);
return;
}
await collection.createSearchIndex({ name: spec.name, definition });
process.stdout.write(`Created Atlas Search index ${spec.name} on ${spec.collection}\n`);
}
async function main() {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error('MONGODB_URI is required');
}
await mongoose.connect(uri, { serverSelectionTimeoutMS: 15_000 });
try {
if (!mongoose.connection.db) {
throw new Error('MongoDB connection has no selected database');
}
for (const spec of indexSpecs) {
await syncIndex(mongoose.connection.db, spec);
}
} finally {
await mongoose.disconnect();
}
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
+372
View File
@@ -0,0 +1,372 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import * as ts from 'typescript';
const sourceRoot = resolve(__dirname);
const validObjectId = '507f1f77bcf86cd799439011';
type Constructor = new (...args: any[]) => any;
type Operation = { dependency: number; method: string; args: unknown[] };
function collectAdapterFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
return collectAdapterFiles(path);
}
return entry.name.endsWith('.controller.ts') || entry.name.endsWith('.repository.ts') ? [path] : [];
});
}
function publicMethodParameters(file: string, className: string): Map<string, string[]> {
const source = ts.createSourceFile(
file,
readFileSync(file, 'utf8'),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const methods = new Map<string, string[]>();
source.forEachChild((node) => {
if (!ts.isClassDeclaration(node) || node.name?.text !== className) return;
for (const member of node.members) {
if (!ts.isMethodDeclaration(member) || !member.name) continue;
const isPrivate = member.modifiers?.some(
(modifier) =>
modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword,
);
if (isPrivate) continue;
methods.set(
member.name.getText(source).replace(/^['"]|['"]$/g, ''),
member.parameters.map((parameter) => parameter.name.getText(source)),
);
}
});
return methods;
}
function createTransportValue(): any {
const target = function transportValue() {
return value;
};
Object.assign(target, {
status: 'ready',
success: true,
checks: {},
items: [],
data: [],
user: {
sub: validObjectId,
userId: validObjectId,
id: validObjectId,
email: 'member@example.com',
permissions: [],
},
});
const value: any = new Proxy(target, {
apply: () => value,
get: (object, property) => {
if (property === 'then') return undefined;
if (property === Symbol.iterator) return function* emptyIterator() {};
if (property === Symbol.toPrimitive) return () => validObjectId;
if (property === 'toString' || property === 'valueOf') return () => validObjectId;
if (property === 'map' || property === 'filter' || property === 'slice') return () => [];
if (Reflect.has(object, property)) return Reflect.get(object, property);
return value;
},
});
return value;
}
function representativePayload() {
return {
id: validObjectId,
_id: validObjectId,
sub: validObjectId,
userId: validObjectId,
authorId: validObjectId,
ownerId: validObjectId,
ownerAdminId: validObjectId,
postId: validObjectId,
commentId: validObjectId,
targetId: validObjectId,
conversationId: validObjectId,
participantId: validObjectId,
recipientId: validObjectId,
parentCommentId: validObjectId,
email: 'member@example.com',
username: 'member',
name: 'Member',
content: 'contract probe',
title: 'Contract probe',
reason: 'contract probe',
type: 'post',
targetType: 'post',
resourceType: 'post',
status: 'active',
action: 'activate',
isActive: true,
isDisabled: false,
targetIds: [validObjectId],
participantIds: [validObjectId],
userIds: [validObjectId],
deviceId: 'device-contract',
fcmToken: 'token-contract',
imageUrls: [],
tags: [],
page: 1,
limit: 2,
};
}
function argumentFor(parameterName: string): unknown {
const name = parameterName.toLowerCase();
const upload = {
originalname: 'image.jpg',
mimetype: 'image/jpeg',
size: 3,
buffer: Buffer.from([0xff, 0xd8, 0xff]),
};
if (name === 'req' || name === 'request' || name.endsWith('request')) {
return {
user: representativePayload(),
headers: { 'x-request-id': 'contract-request' },
method: 'GET',
originalUrl: '/contract',
};
}
if (name === 'res' || name.includes('response')) {
const response: any = new Proxy(
{},
{
get: (target, property) => {
if (!Reflect.has(target, property)) {
Reflect.set(target, property, jest.fn(() => response));
}
return Reflect.get(target, property);
},
},
);
return response;
}
if (name === 'user' || name.includes('currentuser')) return representativePayload();
if (name.includes('files')) {
return Object.assign([upload], {
imageFiles: [upload],
shopImageFiles: [upload],
avatarFile: [upload],
audioFile: [upload],
videoFile: [upload],
});
}
if (name.includes('file')) return upload;
if (/(^|_)(ids|idlist)$/.test(name) || name.endsWith('ids')) return [validObjectId];
if (name.endsWith('id') || name === 'jti') return validObjectId;
if (name.includes('limit')) return 2;
if (name.includes('skip') || name.includes('offset')) return 0;
if (name.includes('page')) return 1;
if (name.includes('direction')) return -1;
if (name.includes('session')) return undefined;
if (name.includes('email')) return 'member@example.com';
if (name.endsWith('names') || name === 'usernames') return ['member'];
if (name.includes('text') || name.includes('content') || name.includes('title') || name.includes('reason')) {
return 'contract probe';
}
if (name === 'q' || name.includes('search')) return 'contract';
if (name.includes('sort')) return { createdAt: -1 };
if (name.includes('filter')) return {};
return representativePayload();
}
function createControllerDependency(index: number, operations: Operation[]) {
const methods = new Map<PropertyKey, jest.Mock>();
return new Proxy(
{},
{
get: (_target, property) => {
if (property === 'then') return undefined;
if (!methods.has(property)) {
methods.set(
property,
jest.fn((...args: unknown[]) => {
operations.push({ dependency: index, method: String(property), args });
return createTransportValue();
}),
);
}
return methods.get(property);
},
},
);
}
function createRepositoryModel(index: number, operations: Operation[]) {
const document: any = {
id: validObjectId,
_id: validObjectId,
participantIds: [validObjectId],
read: false,
modifiedCount: 1,
deletedCount: 1,
populate: jest.fn(async () => document),
save: jest.fn(async (...args: unknown[]): Promise<unknown> => {
operations.push({ dependency: index, method: 'document.save', args });
return document;
}),
};
const createQuery = () => {
const queryResult: any[] = [];
Object.assign(queryResult, document);
const query: any = new Proxy(
{},
{
get: (_target, property) => {
if (property === 'then') return undefined;
if (property === 'exec') {
return jest.fn(async () => {
operations.push({ dependency: index, method: 'query.exec', args: [] });
return queryResult;
});
}
return jest.fn((...args: unknown[]) => {
operations.push({ dependency: index, method: `query.${String(property)}`, args });
return query;
});
},
},
);
return query;
};
const methods = new Map<PropertyKey, jest.Mock>();
const modelTarget = function modelConstructor() {
return document;
};
return new Proxy(modelTarget, {
construct: (_target, args) => {
operations.push({ dependency: index, method: 'new', args });
return document;
},
get: (_target, property) => {
if (property === 'collection') return { name: `collection_${index}` };
if (!methods.has(property)) {
methods.set(
property,
jest.fn((...args: unknown[]) => {
operations.push({ dependency: index, method: String(property), args });
return property === 'create'
? Promise.resolve(Array.isArray(args[0]) ? [document] : document)
: createQuery();
}),
);
}
return methods.get(property);
},
});
}
describe('controller and repository contracts', () => {
const files = collectAdapterFiles(sourceRoot);
it.each(files.filter((file) => file.endsWith('.controller.ts')))(
'%s delegates every public handler or enforces its documented local rejection',
async (file) => {
const moduleExports = require(file) as Record<string, Constructor>;
const controllers = Object.values(moduleExports).filter(
(exported): exported is Constructor =>
typeof exported === 'function' && exported.name.endsWith('Controller'),
);
expect(controllers).not.toHaveLength(0);
for (const Controller of controllers) {
const operations: Operation[] = [];
const dependencies = Array.from({ length: Controller.length }, (_, index) =>
createControllerDependency(index, operations),
);
const instance = Reflect.construct(Controller, dependencies);
const methods = publicMethodParameters(file, Controller.name);
expect(methods.size).toBeGreaterThan(0);
for (const [method, parameters] of methods) {
const key = `${Controller.name}.${method}`;
const args = parameters.map(argumentFor);
const before = operations.length;
if (key === 'ChatController.messagesMissingConversationId') {
await expect(Promise.resolve().then(() => instance[method](...args))).rejects.toThrow(
'conversationId is required',
);
expect(operations).toHaveLength(before);
continue;
}
if (key === 'AuthController.googleAuth') {
await expect(Promise.resolve(instance[method](...args))).resolves.toBeUndefined();
expect(operations).toHaveLength(before);
continue;
}
try {
await instance[method](...args);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${key} failed its delegation contract: ${detail}`);
}
const delegated = operations.slice(before);
if (!delegated.length) {
throw new Error(`${key} did not delegate to an injected dependency`);
}
expect(delegated[0]).toEqual(
expect.objectContaining({ dependency: expect.any(Number), method: expect.any(String) }),
);
}
}
},
);
it.each(files.filter((file) => file.endsWith('.repository.ts')))(
'%s routes every public operation through its persistence model',
async (file) => {
const moduleExports = require(file) as Record<string, Constructor>;
const repositories = Object.values(moduleExports).filter(
(exported): exported is Constructor =>
typeof exported === 'function' && exported.name.endsWith('Repository'),
);
expect(repositories).not.toHaveLength(0);
for (const Repository of repositories) {
const operations: Operation[] = [];
const dependencies = Array.from({ length: Repository.length }, (_, index) =>
createRepositoryModel(index, operations),
);
const instance = Reflect.construct(Repository, dependencies);
const methods = publicMethodParameters(file, Repository.name);
expect(methods.size).toBeGreaterThan(0);
for (const [method, parameters] of methods) {
const before = operations.length;
const args = parameters.map(argumentFor);
if (`${Repository.name}.${method}` === 'FollowsRepository.isDuplicateKeyError') {
expect(instance[method]({ code: 11000 })).toBe(true);
expect(instance[method]({ code: 1 })).toBe(false);
expect(operations).toHaveLength(before);
continue;
}
try {
await instance[method](...args);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${Repository.name}.${method} failed its persistence contract: ${detail}`);
}
const persistenceCalls = operations.slice(before);
if (!persistenceCalls.length) {
throw new Error(`${Repository.name}.${method} did not reach an injected persistence model`);
}
expect(persistenceCalls.some((operation) => operation.method !== 'toString')).toBe(true);
}
}
},
);
});
+8 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
import { AppService } from './app.service';
@Controller('health')
@@ -9,4 +9,11 @@ export class AppController {
getHealth(): { status: string; service: string } {
return this.appService.getHealth();
}
@Get('ready')
async getReadiness() {
const readiness = await this.appService.getReadiness();
if (readiness.status !== 'ready') throw new ServiceUnavailableException(readiness);
return readiness;
}
}
+8
View File
@@ -8,6 +8,9 @@ import { validationSchema } from './config/validation.schema';
import { DatabaseModule } from './database/database.module';
import { CacheModule } from './infrastructure/cache/cache.module';
import { LoggingModule } from './infrastructure/logging/logging.module';
import { MetricsModule } from './infrastructure/metrics/metrics.module';
import { MaintenanceModule } from './infrastructure/maintenance/maintenance.module';
import { ReliabilityModule } from './infrastructure/reliability/reliability.module';
import { QueueModule } from './infrastructure/queue/queue.module';
import { RedisModule } from './infrastructure/redis/redis.module';
import { StorageModule } from './infrastructure/storage/storage.module';
@@ -19,6 +22,7 @@ import { CommentsModule } from './modules/comments/comments.module';
import { CollaborationRequestsModule } from './modules/collaboration-requests/collaboration-requests.module';
import { DevicesModule } from './modules/devices/devices.module';
import { FeedModule } from './modules/feed/feed.module';
import { EngagementModule } from './modules/engagement/engagement.module';
import { FollowsModule } from './modules/follows/follows.module';
import { LikesModule } from './modules/likes/likes.module';
import { MediaModule } from './modules/media/media.module';
@@ -46,6 +50,9 @@ import { MediaUrlInterceptor } from './common/interceptors/media-url.interceptor
validationSchema,
}),
LoggingModule,
MetricsModule,
ReliabilityModule,
MaintenanceModule,
RedisModule,
CacheModule,
StorageModule,
@@ -62,6 +69,7 @@ import { MediaUrlInterceptor } from './common/interceptors/media-url.interceptor
LikesModule,
FollowsModule,
FeedModule,
EngagementModule,
NotificationsModule,
OutboxModule,
ChatModule,
+42
View File
@@ -0,0 +1,42 @@
import { AppService } from './app.service';
describe('AppService readiness', () => {
const createService = (options: { mongoFails?: boolean; storageWritable?: boolean } = {}) => {
const connection = { db: { admin: () => ({
ping: options.mongoFails ? jest.fn().mockRejectedValue(new Error('mongo down')) : jest.fn().mockResolvedValue({ ok: 1 }),
}) } };
const redis = { isEnabled: jest.fn().mockReturnValue(false), getClient: jest.fn() };
const storage = { getHealth: jest.fn().mockResolvedValue({
provider: 'local', local: { readable: true, writable: options.storageWritable ?? true },
}) };
const shutdown = {
isDraining: jest.fn().mockReturnValue(false),
getActiveRequests: jest.fn().mockReturnValue(0),
};
return {
service: new AppService(connection as any, redis as any, storage as any, shutdown as any),
shutdown,
};
};
it('reports ready when required dependencies are healthy', async () => {
await expect(createService().service.getReadiness()).resolves.toEqual(expect.objectContaining({ status: 'ready' }));
});
it('reports degraded and identifies failed dependencies', async () => {
const result = await createService({ mongoFails: true, storageWritable: false }).service.getReadiness();
expect(result).toEqual(expect.objectContaining({ status: 'degraded' }));
expect((result.checks as any).mongodb.status).toBe('down');
expect((result.checks as any).storage.status).toBe('down');
});
it('reports draining immediately during graceful shutdown', async () => {
const { service, shutdown } = createService();
shutdown.isDraining.mockReturnValue(true);
shutdown.getActiveRequests.mockReturnValue(2);
await expect(service.getReadiness()).resolves.toEqual(
expect.objectContaining({ status: 'draining', activeRequests: 2 }),
);
});
});
+79
View File
@@ -1,8 +1,87 @@
import { Injectable } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';
import { ManagedStorageService } from './infrastructure/storage/managed-storage.service';
import { RedisService } from './infrastructure/redis/redis.service';
import { ShutdownCoordinatorService } from './infrastructure/reliability/shutdown-coordinator.service';
@Injectable()
export class AppService {
constructor(
@InjectConnection() private readonly connection: Connection,
private readonly redis: RedisService,
private readonly storage: ManagedStorageService,
private readonly shutdownCoordinator: ShutdownCoordinatorService,
) {}
getHealth(): { status: string; service: string } {
return { status: 'ok', service: 'oudelaa-backend' };
}
async getReadiness(): Promise<Record<string, unknown>> {
const checks: Record<string, { status: 'up' | 'down'; error?: string }> = {};
if (this.shutdownCoordinator.isDraining()) {
checks.lifecycle = { status: 'down', error: 'Application is draining for shutdown' };
return {
status: 'draining',
service: 'oudelaa-backend',
checks,
activeRequests: this.shutdownCoordinator.getActiveRequests(),
};
}
checks.lifecycle = { status: 'up' };
try {
if (!this.connection.db) throw new Error('MongoDB connection is unavailable');
await this.withTimeout(this.connection.db.admin().ping(), 3000, 'MongoDB ping timeout');
checks.mongodb = { status: 'up' };
} catch (error) {
checks.mongodb = { status: 'down', error: this.errorMessage(error) };
}
if (this.redis.isEnabled()) {
try {
const client = this.redis.getClient();
if (!client) throw new Error('Redis client is unavailable');
await this.withTimeout(client.ping(), 2000, 'Redis ping timeout');
checks.redis = { status: 'up' };
} catch (error) {
checks.redis = { status: 'down', error: this.errorMessage(error) };
}
} else {
checks.redis = { status: 'up' };
}
try {
const health = await this.withTimeout(this.storage.getHealth(), 5000, 'Storage check timeout');
const local = health.local as { readable?: boolean; writable?: boolean } | undefined;
const s3 = health.s3 as { reachable?: boolean; configured?: boolean } | undefined;
const storageUp = health.provider === 'local'
? local?.readable === true && local?.writable === true
: s3?.configured === true && s3?.reachable === true;
checks.storage = storageUp
? { status: 'up' }
: { status: 'down', error: 'Storage is not readable/writable or reachable' };
} catch (error) {
checks.storage = { status: 'down', error: this.errorMessage(error) };
}
const ready = Object.values(checks).every((check) => check.status === 'up');
return { status: ready ? 'ready' : 'degraded', service: 'oudelaa-backend', checks };
}
private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); }),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown dependency error';
}
}
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const REQUEST_TIMEOUT_METADATA = 'request_timeout_ms';
/** Override the global request timeout for a controller or a single route. */
export const RequestTimeout = (timeoutMs: number) =>
SetMetadata(REQUEST_TIMEOUT_METADATA, timeoutMs);
/** Disable the application-level timeout for a route that intentionally streams indefinitely. */
export const DisableRequestTimeout = () => SetMetadata(REQUEST_TIMEOUT_METADATA, 0);
@@ -2,6 +2,7 @@ export enum NotificationType {
LIKE = 'like',
COMMENT = 'comment',
FOLLOW = 'follow',
FOLLOW_REQUEST_APPROVED = 'follow_request_approved',
MESSAGE = 'message',
SAVE = 'save',
SHARE = 'share',
@@ -0,0 +1,137 @@
import {
ExecutionContext,
ForbiddenException,
HttpException,
UnsupportedMediaTypeException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { MultipartFormDataGuard } from './multipart-form-data.guard';
import { RolesGuard } from './roles.guard';
import { SuperAdminPermissionsGuard } from './superadmin-permissions.guard';
import { ThrottleGuard } from './throttle.guard';
const contextFor = (request: Record<string, any>): ExecutionContext =>
({
getHandler: () => function handler() {},
getClass: () => class Controller {},
switchToHttp: () => ({ getRequest: () => request }),
}) as unknown as ExecutionContext;
describe('authorization guards', () => {
describe('RolesGuard', () => {
it('allows routes without role metadata', () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(undefined) };
const guard = new RolesGuard(reflector as unknown as Reflector);
expect(guard.canActivate(contextFor({}))).toBe(true);
});
it.each([
[{ role: 'admin' }, true],
[{ roles: ['support', 'admin'] }, true],
[{ role: 'user' }, false],
[{}, false],
])('evaluates normalized payload roles %#', (user, expected) => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(['admin']) };
const guard = new RolesGuard(reflector as unknown as Reflector);
expect(guard.canActivate(contextFor({ user }))).toBe(expected);
});
});
describe('SuperAdminPermissionsGuard', () => {
it('allows routes without permission requirements', () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue([]) };
const guard = new SuperAdminPermissionsGuard(reflector as unknown as Reflector);
expect(guard.canActivate(contextFor({}))).toBe(true);
});
it('requires every declared permission', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(['users.read', 'users.manage']),
};
const guard = new SuperAdminPermissionsGuard(reflector as unknown as Reflector);
expect(
guard.canActivate(
contextFor({ user: { permissions: ['users.read', 'users.manage', 'audit.read'] } }),
),
).toBe(true);
expect(() =>
guard.canActivate(contextFor({ user: { permissions: ['users.read'] } })),
).toThrow(ForbiddenException);
expect(() => guard.canActivate(contextFor({}))).toThrow(
'Missing superadmin permission',
);
});
});
describe('ThrottleGuard', () => {
it('does not touch cache for an unthrottled route', async () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(undefined) };
const cache = { incr: jest.fn() };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(guard.canActivate(contextFor({}))).resolves.toBe(true);
expect(cache.incr).not.toHaveBeenCalled();
});
it('uses authenticated subject and a minimum one-second window', async () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue({ limit: 2, windowMs: 10 }),
};
const cache = { incr: jest.fn().mockResolvedValue(2) };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(
guard.canActivate(
contextFor({
user: { sub: 'user-1' },
ip: '127.0.0.1',
baseUrl: '/api/v1/auth',
route: { path: '/login' },
}),
),
).resolves.toBe(true);
expect(cache.incr).toHaveBeenCalledWith(
'rate-limit:/api/v1/auth/login:user-1',
1,
);
});
it('falls back to IP/original URL and rejects requests above the limit', async () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue({ limit: 1, windowMs: 1_500 }),
};
const cache = { incr: jest.fn().mockResolvedValue(2) };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(
guard.canActivate(contextFor({ ip: '10.0.0.2', originalUrl: '/fallback' })),
).rejects.toBeInstanceOf(HttpException);
expect(cache.incr).toHaveBeenCalledWith('rate-limit:/fallback:10.0.0.2', 2);
});
it('uses stable unknown placeholders if request identity and route are absent', async () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue({ limit: 1, windowMs: 1_000 }),
};
const cache = { incr: jest.fn().mockResolvedValue(1) };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(guard.canActivate(contextFor({}))).resolves.toBe(true);
expect(cache.incr).toHaveBeenCalledWith('rate-limit:unknown-route:unknown', 1);
});
});
describe('MultipartFormDataGuard', () => {
it('accepts multipart requests and rejects other media types', () => {
const guard = new MultipartFormDataGuard();
expect(guard.canActivate(contextFor({ is: jest.fn().mockReturnValue(true) }))).toBe(true);
expect(() =>
guard.canActivate(contextFor({ is: jest.fn().mockReturnValue(false) })),
).toThrow(UnsupportedMediaTypeException);
});
});
});
@@ -0,0 +1,29 @@
import { CallHandler, ExecutionContext } from '@nestjs/common';
import { firstValueFrom, of } from 'rxjs';
import { ResponseEnvelopeInterceptor } from './response-envelope.interceptor';
describe('ResponseEnvelopeInterceptor', () => {
it('wraps data with response status and timestamp', async () => {
const context = {
switchToHttp: () => ({ getResponse: () => ({ statusCode: 201 }) }),
} as unknown as ExecutionContext;
const next = { handle: () => of({ id: 'one' }) } as CallHandler;
const value = await firstValueFrom(new ResponseEnvelopeInterceptor().intercept(context, next));
expect(value).toEqual({
data: { id: 'one' },
meta: { statusCode: 201, timestamp: expect.any(String) },
});
});
it('uses HTTP 200 when an adapter does not expose a status', async () => {
const context = {
switchToHttp: () => ({ getResponse: () => ({}) }),
} as unknown as ExecutionContext;
const value = await firstValueFrom(
new ResponseEnvelopeInterceptor().intercept(context, { handle: () => of(null) }),
);
expect(value).toEqual(
expect.objectContaining({ meta: expect.objectContaining({ statusCode: 200 }) }),
);
});
});
+54 -8
View File
@@ -7,6 +7,7 @@ export type MediaUploadFile = {
originalname?: string;
mimetype?: string;
size?: number;
buffer?: Buffer;
};
export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const;
@@ -168,17 +169,62 @@ function resolveMediaExtensionOrThrow(file: MediaUploadFile, type: AllowedMediaT
function resolveMediaExtension(file: MediaUploadFile, type: AllowedMediaType): string | null {
const extension = getFileExtension(file.originalname);
if (EXTENSIONS_BY_TYPE[type].includes(extension)) {
return extension;
}
const mimetype = file.mimetype?.toLowerCase();
if (mimetype && EXTENSION_BY_MIMETYPE[type][mimetype]) {
return EXTENSION_BY_MIMETYPE[type][mimetype];
const mimeExtension = mimetype ? EXTENSION_BY_MIMETYPE[type][mimetype] : undefined;
const extensionAllowed = EXTENSIONS_BY_TYPE[type].includes(extension);
if (extension && !extensionAllowed) {
return null;
}
if (mimetype && !mimeExtension) {
return null;
}
if (extensionAllowed && mimeExtension && !extensionsAreCompatible(extension, mimeExtension)) {
return null;
}
// TODO: Add magic-byte sniffing before tightening this contract to require both extension and mimetype.
return null;
const resolved = extensionAllowed ? extension : mimeExtension;
if (!resolved) {
return null;
}
if (file.buffer && file.buffer.length >= 12 && !hasExpectedFileSignature(file.buffer, resolved)) {
return null;
}
return resolved;
}
function extensionsAreCompatible(left: string, right: string): boolean {
const normalize = (value: string) => (value === '.jpeg' ? '.jpg' : value);
return normalize(left) === normalize(right);
}
function hasExpectedFileSignature(buffer: Buffer, extension: string): boolean {
const ascii = buffer.subarray(0, 16).toString('ascii');
switch (extension) {
case '.jpg':
case '.jpeg':
return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
case '.png':
return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
case '.webp':
return ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WEBP';
case '.wav':
return ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WAVE';
case '.ogg':
return ascii.startsWith('OggS');
case '.webm':
return buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3;
case '.mp3':
return ascii.startsWith('ID3') || (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0);
case '.aac':
return buffer[0] === 0xff && (buffer[1] & 0xf6) === 0xf0;
case '.m4a':
case '.mp4':
case '.mov':
return ascii.slice(4, 8) === 'ftyp';
default:
return false;
}
}
function defaultInvalidMessage(type: AllowedMediaType): string {
+93
View File
@@ -0,0 +1,93 @@
import {
AllowedMediaType,
AUDIO_MIMETYPES,
IMAGE_MIMETYPES,
MEDIA_MAX_SIZE_BYTES,
VIDEO_MIMETYPES,
} from './allowed-media';
export const DIRECT_UPLOAD_FOLDERS = [
'posts/images',
'posts/video',
'posts/audio',
'users/avatar',
'users/cover',
'chat/media',
'marketplace/images',
'support/images',
'collaboration/audio',
] as const;
export type DirectUploadFolder = (typeof DIRECT_UPLOAD_FOLDERS)[number];
export const DIRECT_UPLOAD_MIME_TYPES = [
...IMAGE_MIMETYPES,
...AUDIO_MIMETYPES,
...VIDEO_MIMETYPES,
] as const;
export type DirectUploadMimeType = (typeof DIRECT_UPLOAD_MIME_TYPES)[number];
type DirectUploadMediaDefinition = {
extension: string;
mediaType: AllowedMediaType;
};
type DirectUploadFolderPolicy = Partial<Record<AllowedMediaType, number>>;
const MEDIA_BY_MIME_TYPE: Record<DirectUploadMimeType, DirectUploadMediaDefinition> = {
'image/jpeg': { extension: '.jpg', mediaType: 'image' },
'image/png': { extension: '.png', mediaType: 'image' },
'image/webp': { extension: '.webp', mediaType: 'image' },
'audio/mpeg': { extension: '.mp3', mediaType: 'audio' },
'audio/mp3': { extension: '.mp3', mediaType: 'audio' },
'audio/mp4': { extension: '.m4a', mediaType: 'audio' },
'audio/x-m4a': { extension: '.m4a', mediaType: 'audio' },
'audio/m4a': { extension: '.m4a', mediaType: 'audio' },
'audio/wav': { extension: '.wav', mediaType: 'audio' },
'audio/x-wav': { extension: '.wav', mediaType: 'audio' },
'audio/wave': { extension: '.wav', mediaType: 'audio' },
'audio/aac': { extension: '.aac', mediaType: 'audio' },
'audio/ogg': { extension: '.ogg', mediaType: 'audio' },
'audio/webm': { extension: '.webm', mediaType: 'audio' },
'video/mp4': { extension: '.mp4', mediaType: 'video' },
'video/quicktime': { extension: '.mov', mediaType: 'video' },
'video/webm': { extension: '.webm', mediaType: 'video' },
};
const FOLDER_POLICIES: Record<DirectUploadFolder, DirectUploadFolderPolicy> = {
'posts/images': { image: MEDIA_MAX_SIZE_BYTES.postsImage },
'posts/video': { video: MEDIA_MAX_SIZE_BYTES.postsVideo },
'posts/audio': { audio: MEDIA_MAX_SIZE_BYTES.postsAudio },
'users/avatar': { image: MEDIA_MAX_SIZE_BYTES.userImage },
'users/cover': { image: MEDIA_MAX_SIZE_BYTES.userImage },
'chat/media': {
image: MEDIA_MAX_SIZE_BYTES.postsImage,
audio: MEDIA_MAX_SIZE_BYTES.postsAudio,
video: MEDIA_MAX_SIZE_BYTES.postsVideo,
},
'marketplace/images': { image: MEDIA_MAX_SIZE_BYTES.marketplaceImage },
'support/images': { image: MEDIA_MAX_SIZE_BYTES.supportImage },
'collaboration/audio': { audio: MEDIA_MAX_SIZE_BYTES.collaborationAudio },
};
export type ResolvedDirectUploadPolicy = DirectUploadMediaDefinition & {
maxSizeBytes: number;
};
export function resolveDirectUploadPolicy(
folder: string,
mimeType: string,
): ResolvedDirectUploadPolicy | null {
if (!DIRECT_UPLOAD_FOLDERS.includes(folder as DirectUploadFolder)) {
return null;
}
const media = MEDIA_BY_MIME_TYPE[mimeType as DirectUploadMimeType];
if (!media) {
return null;
}
const maxSizeBytes = FOLDER_POLICIES[folder as DirectUploadFolder][media.mediaType];
return maxSizeBytes ? { ...media, maxSizeBytes } : null;
}
@@ -6,6 +6,7 @@ import { MediaStorageService } from './media-storage.service';
describe('HlsPlaylistService', () => {
const storageService = {
shouldSignResponseUrls: jest.fn(() => true),
isValidHlsPlaylistAccess: jest.fn(() => true),
getBucketName: jest.fn(() => 'oudelaa'),
isManagedKey: jest.fn((key: string) => key.startsWith('uploads/')),
getObjectText: jest.fn(),
@@ -23,6 +24,16 @@ describe('HlsPlaylistService', () => {
beforeEach(() => {
jest.clearAllMocks();
(storageService.isValidHlsPlaylistAccess as jest.Mock).mockReturnValue(true);
});
it('rejects an invalid or expired playlist access signature', async () => {
(storageService.isValidHlsPlaylistAccess as jest.Mock).mockReturnValue(false);
await expect(
service.rewritePlaylist('uploads/posts/hls/stream-1/master.m3u8'),
).rejects.toThrow(BadRequestException);
expect(storageService.getObjectText).not.toHaveBeenCalled();
});
it('rewrites master playlist variants through the backend endpoint', async () => {
+7 -1
View File
@@ -14,12 +14,18 @@ export class HlsPlaylistService {
private readonly appLogger: AppLoggerService,
) {}
async rewritePlaylist(requestedPath: string): Promise<string> {
async rewritePlaylist(
requestedPath: string,
access?: { expires?: string; signature?: string },
): Promise<string> {
if (!this.mediaStorageService.shouldSignResponseUrls()) {
throw new BadRequestException('Private HLS rewrite is disabled');
}
const key = this.normalizePlaylistKey(requestedPath);
if (!this.mediaStorageService.isValidHlsPlaylistAccess(key, access?.expires, access?.signature)) {
throw new BadRequestException('Invalid or expired HLS access signature');
}
const bucket = this.mediaStorageService.getBucketName();
const startedAt = Date.now();
this.appLogger.log(
@@ -0,0 +1,155 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import { MediaStorageService } from './media-storage.service';
jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: jest.fn() }));
type StorageInternals = {
getS3Client(): { send(command: unknown): Promise<any> };
getSignedUrlExpiresSeconds(): number;
cacheSignedUrl(key: string, url: string, seconds: number): void;
normalizeFolder(folder: string): string;
normalizeUserSegment(user: string): string;
normalizeKey(key: string): string;
};
const internals = (service: MediaStorageService): StorageInternals =>
service as unknown as StorageInternals;
describe('MediaStorageService behavior', () => {
const values: Record<string, unknown> = {
'storage.provider': 's3', 'storage.basePath': '/uploads/',
'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test/',
'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
'storage.s3.forcePathStyle': true, 'storage.publicBaseUrl': 'https://cdn.test/',
'storage.mediaAccessMode': 'public', publicBaseUrl: 'https://api.test/', globalPrefix: '/api/v2/',
'security.refreshTokenHashSecret': 'signing-secret',
};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const logger = { error: jest.fn() } as unknown as AppLoggerService;
let send: jest.Mock;
let service: MediaStorageService;
beforeEach(() => {
jest.clearAllMocks();
Object.assign(values, {
'storage.provider': 's3', 'storage.basePath': '/uploads/',
'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test/',
'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
'storage.s3.forcePathStyle': true, 'storage.publicBaseUrl': 'https://cdn.test/',
'storage.mediaAccessMode': 'public', publicBaseUrl: 'https://api.test/', globalPrefix: '/api/v2/',
'storage.signedUrlExpiresSeconds': 60, 'security.refreshTokenHashSecret': 'signing-secret',
});
send = jest.fn().mockResolvedValue({});
service = new MediaStorageService(config, logger);
jest.spyOn(internals(service), 'getS3Client').mockReturnValue({ send });
});
it('uploads generated and exact keys with content metadata', async () => {
const result = await service.uploadFile({ buffer: Buffer.from('abc'), originalname: 'photo.JPG', mimetype: 'image/jpeg' }, '/posts\\images/');
expect(result).toEqual(expect.objectContaining({ key: expect.stringMatching(/^uploads\/posts\/images\/media-[0-9a-f-]+\.jpg$/i), url: expect.stringContaining('https://cdn.test/uploads/'), size: 3, originalName: 'photo.JPG' }));
expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand);
await expect(service.uploadFileToKey({ buffer: Buffer.from('x') }, '/uploads/hls/master.m3u8')).resolves.toEqual(expect.objectContaining({ key: 'uploads/hls/master.m3u8', mimeType: 'application/octet-stream' }));
const command = send.mock.calls[1][0] as PutObjectCommand;
expect(command.input).toEqual(expect.objectContaining({ CacheControl: 'public, max-age=300', Key: 'uploads/hls/master.m3u8' }));
await expect(service.uploadFileToKey({ buffer: Buffer.alloc(0) }, '../bad')).rejects.toBeInstanceOf(BadRequestException);
});
it('deletes valid keys and ignores invalid keys', async () => {
await service.deleteFile('../bad');
expect(send).not.toHaveBeenCalled();
await service.deleteFile('/uploads/file.jpg');
expect(send.mock.calls[0][0]).toBeInstanceOf(DeleteObjectCommand);
});
it('builds public, path-style, and virtual endpoint URLs', () => {
expect(service.getPublicUrl('/uploads/file.jpg')).toBe('https://cdn.test/uploads/file.jpg');
values['storage.publicBaseUrl'] = '';
expect(service.getPublicUrl('uploads/file.jpg')).toBe('https://s3.test/bucket/uploads/file.jpg');
values['storage.s3.forcePathStyle'] = false;
expect(service.getPublicUrl('uploads/file.jpg')).toBe('https://s3.test/uploads/file.jpg');
expect(() => service.getPublicUrl('../bad')).toThrow(BadRequestException);
});
it('resolves managed keys from relative, CDN, endpoint, and application URLs', () => {
expect(service.resolveKeyFromUrl('/uploads/posts/a.jpg?token=1')).toBe('uploads/posts/a.jpg');
expect(service.resolveKeyFromUrl('https://cdn.test/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
values['storage.publicBaseUrl'] = '';
expect(service.resolveKeyFromUrl('https://s3.test/bucket/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
expect(service.resolveKeyFromUrl('https://api.test/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
expect(service.resolveKeyFromUrl('https://external.test/a.jpg')).toBeNull();
expect(service.resolveKeyFromUrl('not a url')).toBeNull();
});
it('loads object text and validates missing bodies and unsafe keys', async () => {
send.mockResolvedValueOnce({ Body: { transformToString: jest.fn().mockResolvedValue('#EXTM3U') } });
await expect(service.getObjectText('uploads/master.m3u8')).resolves.toBe('#EXTM3U');
expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
send.mockResolvedValueOnce({});
await expect(service.getObjectText('uploads/empty.txt')).rejects.toThrow('object body is empty');
await expect(service.getObjectText('../bad')).rejects.toBeInstanceOf(BadRequestException);
});
it('builds unsigned and signed HLS endpoints and validates signatures', () => {
const unsigned = service.getHlsPlaylistUrl('uploads/folder name/master.m3u8');
expect(unsigned).toBe('https://api.test/api/v2/media/hls/uploads/folder%20name/master.m3u8');
values['storage.mediaAccessMode'] = 'signed';
const signed = service.getHlsPlaylistUrl('uploads/master.m3u8');
const url = new URL(signed);
const expires = url.searchParams.get('expires') ?? '';
const signature = url.searchParams.get('signature') ?? '';
expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', expires, signature)).toBe(true);
expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', '1', signature)).toBe(false);
expect(service.isValidHlsPlaylistAccess('../bad', expires, signature)).toBe(false);
expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', expires, 'short')).toBe(false);
expect(() => service.getHlsPlaylistUrl('uploads/video.mp4')).toThrow(BadRequestException);
});
it('caches signed object URLs until their refresh boundary', async () => {
values['storage.mediaAccessMode'] = 'signed';
jest.mocked(getSignedUrl).mockResolvedValue('https://signed.test/one');
await expect(service.getSignedObjectUrl('uploads/a.jpg')).resolves.toBe('https://signed.test/one');
await expect(service.getSignedObjectUrl('uploads/a.jpg')).resolves.toBe('https://signed.test/one');
expect(getSignedUrl).toHaveBeenCalledTimes(1);
await expect(service.getSignedObjectUrl('../bad')).resolves.toBe('');
service.onModuleDestroy();
await service.getSignedObjectUrl('uploads/a.jpg');
expect(getSignedUrl).toHaveBeenCalledTimes(2);
});
it('checks managed prefixes and clamps signing expiration', () => {
expect(service.isManagedKey('/uploads/posts/a.jpg')).toBe(true);
expect(service.isManagedKey('private/a.jpg')).toBe(false);
values['storage.signedUrlExpiresSeconds'] = 0;
expect(internals(service).getSignedUrlExpiresSeconds()).toBe(1);
values['storage.signedUrlExpiresSeconds'] = 9999999;
expect(internals(service).getSignedUrlExpiresSeconds()).toBe(604800);
});
it('validates configuration and normalization helpers', () => {
expect(internals(service).normalizeFolder('posts\\images')).toBe('posts/images');
expect(() => internals(service).normalizeFolder('../bad')).toThrow(BadRequestException);
expect(internals(service).normalizeUserSegment(' user_1-2 ')).toBe('user_1-2');
expect(() => internals(service).normalizeUserSegment('bad/user')).toThrow(BadRequestException);
expect(internals(service).normalizeKey('a\\b')).toBe('a/b');
expect(internals(service).normalizeKey('a//b')).toBe('');
values['storage.s3.bucket'] = '';
expect(() => service.getBucketName()).toThrow(BadRequestException);
values['storage.s3.bucket'] = 'bucket';
const unconfigured = new MediaStorageService({ get: jest.fn(() => '') } as unknown as ConfigService, logger);
expect(() => internals(unconfigured).getS3Client()).toThrow(BadRequestException);
});
it('evicts the oldest signing cache entry at its safety limit', () => {
const target = internals(service);
for (let index = 0; index < 10001; index += 1) {
target.cacheSignedUrl(`uploads/${index}`, `url-${index}`, 10);
}
expect((service as unknown as { signedUrlCache: Map<string, unknown> }).signedUrlCache.size).toBe(10000);
expect((service as unknown as { signedUrlCache: Map<string, unknown> }).signedUrlCache.has('uploads/0')).toBe(false);
});
});
@@ -0,0 +1,165 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import { MediaStorageService } from './media-storage.service';
jest.mock('@aws-sdk/s3-request-presigner', () => ({
getSignedUrl: jest.fn(),
}));
const mockedGetSignedUrl = jest.mocked(getSignedUrl);
describe('MediaStorageService direct uploads', () => {
const config: Record<string, unknown> = {
'storage.provider': 's3',
'storage.mediaAccessMode': 'signed',
'storage.basePath': 'uploads',
'storage.publicBaseUrl': 'https://cdn.oudelaa.test',
'storage.s3.bucket': 'oudelaa-media',
'storage.s3.region': 'us-east-1',
'storage.s3.endpoint': 'https://s3.oudelaa.test',
'storage.s3.accessKeyId': 'test-access-key',
'storage.s3.secretAccessKey': 'test-secret-key',
'storage.s3.forcePathStyle': true,
};
const configService = {
get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue),
} as unknown as ConfigService;
const appLogger = { error: jest.fn() } as unknown as AppLoggerService;
beforeEach(() => {
mockedGetSignedUrl.mockReset();
mockedGetSignedUrl.mockResolvedValue('https://s3.oudelaa.test/presigned-put');
config['storage.provider'] = 's3';
});
it('creates a user-scoped PUT URL with signed MIME and exact content length', async () => {
const service = new MediaStorageService(configService, appLogger);
const result = await service.createPresignedUpload({
userId: '507f1f77bcf86cd799439011',
folder: 'posts/video',
mimeType: 'video/mp4',
size: 5_242_880,
});
expect(result).toEqual(
expect.objectContaining({
method: 'PUT',
uploadUrl: 'https://s3.oudelaa.test/presigned-put',
mediaUrl: expect.stringMatching(
/^https:\/\/cdn\.oudelaa\.test\/uploads\/posts\/video\/507f1f77bcf86cd799439011\/media-[0-9a-f-]+\.mp4$/,
),
storageKey: expect.stringMatching(
/^uploads\/posts\/video\/507f1f77bcf86cd799439011\/media-[0-9a-f-]+\.mp4$/,
),
mimeType: 'video/mp4',
size: 5_242_880,
requiredHeaders: { 'Content-Type': 'video/mp4' },
expiresIn: 900,
expiresAt: expect.any(String),
}),
);
expect(mockedGetSignedUrl).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
Bucket: 'oudelaa-media',
Key: result.storageKey,
ContentLength: 5_242_880,
ContentType: 'video/mp4',
CacheControl: 'public, max-age=31536000, immutable',
Metadata: {
owner: '507f1f77bcf86cd799439011',
'upload-source': 'direct',
},
}),
}),
{
expiresIn: 900,
signableHeaders: new Set(['content-type']),
},
);
});
it('is disabled for local storage without attempting to sign', async () => {
config['storage.provider'] = 'local';
const service = new MediaStorageService(configService, appLogger);
await expect(
service.createPresignedUpload({
userId: 'user-1',
folder: 'posts/images',
mimeType: 'image/jpeg',
size: 1024,
}),
).rejects.toBeInstanceOf(ServiceUnavailableException);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
it('does not rewrite an existing presigned PUT URL as a signed GET URL', async () => {
const service = new MediaStorageService(configService, appLogger);
const uploadUrl =
'https://s3.oudelaa.test/oudelaa-media/uploads/posts/video/file.mp4' +
'?X-Amz-Algorithm=AWS4-HMAC-SHA256' +
'&X-Amz-Credential=test' +
'&X-Amz-Signature=presigned-put';
await expect(service.resolveResponseUrl(uploadUrl)).resolves.toBe(uploadUrl);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
it.each([
{
name: 'a MIME type that does not match the folder',
input: {
userId: 'user-1',
folder: 'users/avatar' as const,
mimeType: 'video/mp4' as const,
size: 1024,
},
},
{
name: 'an oversized image',
input: {
userId: 'user-1',
folder: 'users/avatar' as const,
mimeType: 'image/jpeg' as const,
size: 5 * 1024 * 1024 + 1,
},
},
{
name: 'a zero-byte body',
input: {
userId: 'user-1',
folder: 'posts/audio' as const,
mimeType: 'audio/mpeg' as const,
size: 0,
},
},
{
name: 'an unsafe user segment',
input: {
userId: '../another-user',
folder: 'posts/images' as const,
mimeType: 'image/jpeg' as const,
size: 1024,
},
},
{
name: 'an unsafe folder',
input: {
userId: 'user-1',
folder: '../posts/images' as unknown as 'posts/images',
mimeType: 'image/jpeg' as const,
size: 1024,
},
},
])('rejects $name', async ({ input }) => {
const service = new MediaStorageService(configService, appLogger);
await expect(service.createPresignedUpload(input)).rejects.toBeInstanceOf(BadRequestException);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
});
@@ -24,6 +24,7 @@ describe('MediaStorageService response URLs', () => {
'storage.s3.forcePathStyle': true,
publicBaseUrl: 'https://api.oudelaa.test',
globalPrefix: 'api/v1',
'security.refreshTokenHashSecret': 'test-hls-signing-secret',
};
const configService = {
get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue),
@@ -64,8 +65,8 @@ describe('MediaStorageService response URLs', () => {
await expect(
service.resolveResponseUrl('https://s3.cumin.dev/uploads/posts/hls/stream-1/master.m3u8'),
).resolves.toBe(
'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream-1/master.m3u8',
).resolves.toMatch(
/^https:\/\/api\.oudelaa\.test\/api\/v1\/media\/hls\/uploads\/posts\/hls\/stream-1\/master\.m3u8\?expires=\d+&signature=/,
);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
+142 -4
View File
@@ -1,4 +1,9 @@
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
import {
BadRequestException,
Injectable,
OnModuleDestroy,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DeleteObjectCommand,
@@ -7,9 +12,14 @@ import {
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
import { createHmac, randomUUID, timingSafeEqual } from 'crypto';
import { posix } from 'path';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import {
DirectUploadFolder,
DirectUploadMimeType,
resolveDirectUploadPolicy,
} from './direct-upload.policy';
import { getFileExtension } from './allowed-media';
export type MediaStorageUploadFile = {
@@ -27,6 +37,27 @@ export type MediaStorageUploadResult = {
originalName: string;
};
export type CreatePresignedMediaUploadInput = {
userId: string;
folder: DirectUploadFolder;
mimeType: DirectUploadMimeType;
size: number;
};
export type PresignedMediaUploadResult = {
method: 'PUT';
uploadUrl: string;
storageKey: string;
mediaUrl: string;
mimeType: DirectUploadMimeType;
size: number;
requiredHeaders: { 'Content-Type': DirectUploadMimeType };
expiresIn: number;
expiresAt: string;
};
const DIRECT_UPLOAD_EXPIRES_SECONDS = 15 * 60;
@Injectable()
export class MediaStorageService implements OnModuleDestroy {
private s3Client: S3Client | null = null;
@@ -78,6 +109,64 @@ export class MediaStorageService implements OnModuleDestroy {
};
}
async createPresignedUpload(
input: CreatePresignedMediaUploadInput,
): Promise<PresignedMediaUploadResult> {
if (this.getProvider() !== 's3') {
throw new ServiceUnavailableException(
'Direct media uploads are available only when S3 storage is enabled',
);
}
const policy = resolveDirectUploadPolicy(input.folder, input.mimeType);
if (!policy) {
throw new BadRequestException('Media type is not allowed for this upload folder');
}
if (!Number.isSafeInteger(input.size) || input.size < 1) {
throw new BadRequestException('Media size must be a positive integer');
}
if (input.size > policy.maxSizeBytes) {
throw new BadRequestException(
`Media size exceeds the ${policy.maxSizeBytes}-byte limit for this upload folder`,
);
}
const userSegment = this.normalizeUserSegment(input.userId);
const fileName = `media-${randomUUID()}${policy.extension}`;
const key = posix.join(this.getBasePath(), input.folder, userSegment, fileName);
const uploadUrl = await getSignedUrl(
this.getS3Client(),
new PutObjectCommand({
Bucket: this.getBucket(),
Key: key,
ContentLength: input.size,
ContentType: input.mimeType,
CacheControl: this.resolveCacheControl(key),
Metadata: {
owner: userSegment,
'upload-source': 'direct',
},
}),
{
expiresIn: DIRECT_UPLOAD_EXPIRES_SECONDS,
signableHeaders: new Set(['content-type']),
},
);
const expiresAt = new Date(Date.now() + DIRECT_UPLOAD_EXPIRES_SECONDS * 1000).toISOString();
return {
method: 'PUT',
uploadUrl,
storageKey: key,
mediaUrl: this.getPublicUrl(key),
mimeType: input.mimeType,
size: input.size,
requiredHeaders: { 'Content-Type': input.mimeType },
expiresIn: DIRECT_UPLOAD_EXPIRES_SECONDS,
expiresAt,
};
}
async deleteFile(key: string): Promise<void> {
const normalizedKey = this.normalizeKey(key);
if (!normalizedKey) {
@@ -139,7 +228,11 @@ export class MediaStorageService implements OnModuleDestroy {
}
async resolveResponseUrl(fileUrl: string): Promise<string> {
if (!this.shouldSignResponseUrls() || !fileUrl.trim()) {
if (
!this.shouldSignResponseUrls() ||
!fileUrl.trim() ||
this.isAlreadyPresignedS3Url(fileUrl)
) {
return fileUrl;
}
@@ -236,7 +329,22 @@ export class MediaStorageService implements OnModuleDestroy {
.join('/');
const path = `/${globalPrefix}/media/hls/${encodedKey}`;
return publicBaseUrl ? `${publicBaseUrl}${path}` : path;
const baseUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : path;
if (!this.shouldSignResponseUrls()) return baseUrl;
const expires = Math.floor(Date.now() / 1000) + this.getSignedUrlExpiresSeconds();
const signature = this.signHlsPlaylistKey(normalizedKey, expires);
return `${baseUrl}?expires=${expires}&signature=${encodeURIComponent(signature)}`;
}
isValidHlsPlaylistAccess(key: string, expiresValue?: string, signature?: string): boolean {
const normalizedKey = this.normalizeKey(key);
const expires = Number(expiresValue);
if (!normalizedKey || !Number.isInteger(expires) || expires <= Math.floor(Date.now() / 1000) || !signature) {
return false;
}
const expected = Buffer.from(this.signHlsPlaylistKey(normalizedKey, expires));
const supplied = Buffer.from(signature);
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
}
getBucketName(): string {
@@ -253,6 +361,14 @@ export class MediaStorageService implements OnModuleDestroy {
this.signedUrlCache.clear();
}
private signHlsPlaylistKey(key: string, expires: number): string {
const secret =
this.configService.get<string>('security.refreshTokenHashSecret', { infer: true }) ||
this.configService.get<string>('jwt.accessSecret', { infer: true }) ||
'';
return createHmac('sha256', secret).update(`${key}:${expires}`).digest('base64url');
}
private getS3Client(): S3Client {
if (this.s3Client) {
return this.s3Client;
@@ -272,6 +388,7 @@ export class MediaStorageService implements OnModuleDestroy {
region: this.configService.get<string>('storage.s3.region', { infer: true }) ?? 'auto',
endpoint,
forcePathStyle: this.getForcePathStyle(),
requestChecksumCalculation: 'WHEN_REQUIRED',
credentials: {
accessKeyId,
secretAccessKey,
@@ -355,6 +472,19 @@ export class MediaStorageService implements OnModuleDestroy {
);
}
private isAlreadyPresignedS3Url(fileUrl: string): boolean {
try {
const searchParams = new URL(fileUrl).searchParams;
return (
searchParams.has('X-Amz-Algorithm') &&
searchParams.has('X-Amz-Credential') &&
searchParams.has('X-Amz-Signature')
);
} catch {
return false;
}
}
private cacheSignedUrl(key: string, url: string, refreshAfterSeconds: number): void {
if (this.signedUrlCache.size >= 10000) {
const oldestKey = this.signedUrlCache.keys().next().value as string | undefined;
@@ -380,6 +510,14 @@ export class MediaStorageService implements OnModuleDestroy {
return normalized;
}
private normalizeUserSegment(userId: string): string {
const normalized = userId.trim();
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(normalized)) {
throw new BadRequestException('Invalid media owner id');
}
return normalized;
}
private normalizeKey(key: string): string {
const normalized = key.replace(/\\/g, '/').replace(/^\/+/, '');
if (
+7 -3
View File
@@ -9,10 +9,14 @@ export const decodeOffsetCursor = (cursor?: string): number | null => {
try {
const raw = Buffer.from(cursor, 'base64url').toString('utf8');
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 0) {
return null;
if (Number.isInteger(parsed) && parsed >= 0) {
return parsed;
}
return parsed;
const payload = JSON.parse(raw) as { offset?: unknown };
return Number.isInteger(payload.offset) && Number(payload.offset) >= 0
? Number(payload.offset)
: null;
} catch {
return null;
}
+22
View File
@@ -0,0 +1,22 @@
import * as bcrypt from 'bcrypt';
import { compareHash, compareStoredHighEntropyValue, hashHighEntropyValue, hashValue } from './hash.util';
describe('hash utilities', () => {
it('hashes and verifies passwords with bcrypt', async () => {
const hash = await hashValue('secret', 4);
await expect(compareHash('secret', hash)).resolves.toBe(true);
await expect(compareHash('wrong', hash)).resolves.toBe(false);
});
it('uses deterministic secret-keyed hashes for high entropy tokens', async () => {
const stored = hashHighEntropyValue('token', 'pepper');
expect(stored).toMatch(/^sha256:[0-9a-f]{64}$/);
await expect(compareStoredHighEntropyValue('token', stored, 'pepper')).resolves.toBe(true);
await expect(compareStoredHighEntropyValue('other', stored, 'pepper')).resolves.toBe(false);
});
it('supports legacy bcrypt token hashes', async () => {
const stored = await bcrypt.hash('legacy-token', 4);
await expect(compareStoredHighEntropyValue('legacy-token', stored, 'unused')).resolves.toBe(true);
});
});
+1
View File
@@ -0,0 +1 @@
export const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+33
View File
@@ -0,0 +1,33 @@
import { ConfigService } from '@nestjs/config';
import { buildAiMusicShareUrl, buildPostShareUrl, buildProfileShareUrl, normalizeShareBaseUrl, resolveShareBaseUrl } from './share-url.util';
describe('share URL utilities', () => {
const originalShare = process.env.SHARE_BASE_URL;
const originalPublicWeb = process.env.PUBLIC_WEB_URL;
afterEach(() => {
if (originalShare === undefined) delete process.env.SHARE_BASE_URL;
else process.env.SHARE_BASE_URL = originalShare;
if (originalPublicWeb === undefined) delete process.env.PUBLIC_WEB_URL;
else process.env.PUBLIC_WEB_URL = originalPublicWeb;
});
it('normalizes defaults and trailing slashes', () => {
expect(normalizeShareBaseUrl(undefined)).toBe('https://oudelaa.com');
expect(normalizeShareBaseUrl(' https://web.test/// ')).toBe('https://web.test');
});
it('resolves environment before configuration', () => {
process.env.SHARE_BASE_URL = 'https://env.test/';
const config = { get: jest.fn(() => 'https://config.test') } as unknown as ConfigService;
expect(resolveShareBaseUrl(config)).toBe('https://env.test');
expect(config.get).not.toHaveBeenCalled();
});
it('builds encoded profile, post, and AI music links', () => {
expect(buildProfileShareUrl('https://web', { username: 'a/b' })).toBe('https://web/u/a%2Fb');
expect(buildProfileShareUrl('https://web', { _id: { toString: () => 'id one' } })).toBe('https://web/profile/id%20one');
expect(buildPostShareUrl('https://web', 'post/1')).toBe('https://web/posts/post%2F1');
expect(buildAiMusicShareUrl('https://web', 'track 1')).toBe('https://web/ai/music/track%201');
});
});
+9
View File
@@ -0,0 +1,9 @@
import { verifyTotp } from './totp.util';
describe('verifyTotp', () => {
it('accepts a known code and rejects invalid values', () => {
expect(verifyTotp('JBSWY3DPEHPK3PXP', '282760', 0)).toBe(true);
expect(verifyTotp('JBSWY3DPEHPK3PXP', '000000', 0)).toBe(false);
expect(verifyTotp('not-base32!', '282760', 0)).toBe(false);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { createHmac, timingSafeEqual } from 'crypto';
const decodeBase32 = (value: string): Buffer => {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const normalized = value.toUpperCase().replace(/=|\s|-/g, '');
let bits = '';
for (const character of normalized) {
const index = alphabet.indexOf(character);
if (index < 0) return Buffer.alloc(0);
bits += index.toString(2).padStart(5, '0');
}
const bytes: number[] = [];
for (let offset = 0; offset + 8 <= bits.length; offset += 8) {
bytes.push(Number.parseInt(bits.slice(offset, offset + 8), 2));
}
return Buffer.from(bytes);
};
const generateTotp = (secret: Buffer, counter: number): string => {
const message = Buffer.alloc(8);
message.writeBigUInt64BE(BigInt(counter));
const digest = createHmac('sha1', secret).update(message).digest();
const offset = digest[digest.length - 1] & 0x0f;
return ((digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000)
.toString()
.padStart(6, '0');
};
export const verifyTotp = (base32Secret: string, suppliedCode: string, nowMs = Date.now()): boolean => {
if (!/^\d{6}$/.test(suppliedCode)) return false;
const secret = decodeBase32(base32Secret);
if (!secret.length) return false;
const counter = Math.floor(nowMs / 30_000);
return [-1, 0, 1].filter((drift) => counter + drift >= 0).some((drift) => {
const expected = Buffer.from(generateTotp(secret, counter + drift));
const supplied = Buffer.from(suppliedCode);
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
});
};
+52 -6
View File
@@ -13,6 +13,16 @@ export default () => ({
responseEnvelopeEnabled:
(process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true',
globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1',
swagger: {
enabled:
typeof process.env.SWAGGER_ENABLED === 'string'
? process.env.SWAGGER_ENABLED.toLowerCase() === 'true'
: (process.env.NODE_ENV ?? 'development') !== 'production',
title: process.env.SWAGGER_TITLE ?? 'Oudelaa API',
description: process.env.SWAGGER_DESCRIPTION ?? 'Social media backend API documentation',
version: process.env.SWAGGER_VERSION ?? '1.0.0',
path: process.env.SWAGGER_PATH ?? 'docs',
},
cors: {
origins: (process.env.CORS_ORIGINS ?? '')
.split(',')
@@ -21,6 +31,22 @@ export default () => ({
},
mongodb: {
uri: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/oudelaa',
autoIndex:
typeof process.env.MONGODB_AUTO_INDEX === 'string'
? process.env.MONGODB_AUTO_INDEX.toLowerCase() === 'true'
: (process.env.NODE_ENV ?? 'development') !== 'production',
minPoolSize: Number(process.env.MONGODB_MIN_POOL_SIZE ?? 5),
maxPoolSize: Number(process.env.MONGODB_MAX_POOL_SIZE ?? 100),
maxIdleTimeMs: Number(process.env.MONGODB_MAX_IDLE_TIME_MS ?? 60_000),
serverSelectionTimeoutMs: Number(process.env.MONGODB_SERVER_SELECTION_TIMEOUT_MS ?? 10_000),
socketTimeoutMs: Number(process.env.MONGODB_SOCKET_TIMEOUT_MS ?? 45_000),
},
search: {
engine: (process.env.SEARCH_ENGINE ?? 'auto').toLowerCase(),
atlasUserIndex: process.env.SEARCH_ATLAS_USER_INDEX ?? 'users_search',
atlasPostIndex: process.env.SEARCH_ATLAS_POST_INDEX ?? 'posts_search',
fallbackEnabled: (process.env.SEARCH_FALLBACK_ENABLED ?? 'true').toLowerCase() === 'true',
retrySeconds: Number(process.env.SEARCH_ATLAS_RETRY_SECONDS ?? 300),
},
jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET ?? '',
@@ -31,10 +57,12 @@ export default () => ({
superAdmin: {
email: (process.env.SUPERADMIN_EMAIL ?? '').toLowerCase(),
password: process.env.SUPERADMIN_PASSWORD ?? '',
passwordHash: process.env.SUPERADMIN_PASSWORD_HASH ?? '',
accessSecret: process.env.SUPERADMIN_ACCESS_SECRET ?? '',
accessExpiresIn: process.env.SUPERADMIN_ACCESS_EXPIRES_IN ?? '15m',
refreshSecret: process.env.SUPERADMIN_REFRESH_SECRET ?? '',
refreshExpiresIn: process.env.SUPERADMIN_REFRESH_EXPIRES_IN ?? '30d',
totpSecret: process.env.SUPERADMIN_TOTP_SECRET ?? '',
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
@@ -65,6 +93,20 @@ export default () => ({
bcryptSaltRounds: Number(process.env.BCRYPT_SALT_ROUNDS ?? 12),
refreshTokenHashSecret:
process.env.REFRESH_TOKEN_HASH_SECRET ?? process.env.JWT_REFRESH_SECRET ?? '',
bodyLimit: process.env.HTTP_BODY_LIMIT ?? '1mb',
},
reliability: {
requestTimeoutMs: Number(process.env.HTTP_REQUEST_TIMEOUT_MS ?? 30_000),
serverRequestTimeoutMs: Number(process.env.HTTP_SERVER_REQUEST_TIMEOUT_MS ?? 120_000),
headersTimeoutMs: Number(process.env.HTTP_HEADERS_TIMEOUT_MS ?? 15_000),
keepAliveTimeoutMs: Number(process.env.HTTP_KEEP_ALIVE_TIMEOUT_MS ?? 5_000),
maxRequestsPerSocket: Number(process.env.HTTP_MAX_REQUESTS_PER_SOCKET ?? 1_000),
shutdownGracePeriodMs: Number(process.env.SHUTDOWN_GRACE_PERIOD_MS ?? 30_000),
},
metrics: {
eventLoopResolutionMs: Number(process.env.METRICS_EVENT_LOOP_RESOLUTION_MS ?? 20),
eventLoopLagWarnMs: Number(process.env.METRICS_EVENT_LOOP_LAG_WARN_MS ?? 100),
maxRoutes: Number(process.env.METRICS_MAX_ROUTES ?? 500),
},
redis: {
enabled: (process.env.REDIS_ENABLED ?? 'false').toLowerCase() === 'true',
@@ -147,10 +189,20 @@ export default () => ({
enabled: (process.env.FEED_CACHE_ENABLED ?? 'true').toLowerCase() === 'true',
userFeedTtlSeconds: Number(process.env.FEED_CACHE_USER_TTL_SECONDS ?? 15),
trendingTtlSeconds: Number(process.env.FEED_CACHE_TRENDING_TTL_SECONDS ?? 30),
rankingProfileTtlSeconds: Number(process.env.FEED_CACHE_RANKING_PROFILE_TTL_SECONDS ?? 30),
},
performance: {
feedTimingLogsEnabled:
(process.env.FEED_TIMING_LOGS_ENABLED ?? 'false').toLowerCase() === 'true',
compressionEnabled:
(process.env.HTTP_COMPRESSION_ENABLED ?? 'true').toLowerCase() === 'true',
compressionThresholdBytes: Number(process.env.HTTP_COMPRESSION_THRESHOLD_BYTES ?? 1024),
},
moderation: {
blockedTerms: (process.env.MODERATION_BLOCKED_TERMS ?? '')
.split(',')
.map((term) => term.trim().toLowerCase())
.filter(Boolean),
},
passwordReset: {
codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10),
@@ -162,10 +214,4 @@ export default () => ({
codeExpiresMinutes: Number(process.env.EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES ?? 10),
maxAttempts: Number(process.env.EMAIL_VERIFICATION_MAX_ATTEMPTS ?? 5),
},
swagger: {
title: process.env.SWAGGER_TITLE ?? 'Oudelaa API',
description: process.env.SWAGGER_DESCRIPTION ?? 'Social media backend API documentation',
version: process.env.SWAGGER_VERSION ?? '1.0.0',
path: process.env.SWAGGER_PATH ?? 'docs',
},
});
+3
View File
@@ -3,6 +3,9 @@ import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
export const setupSwagger = (app: INestApplication, configService: ConfigService): void => {
if (!(configService.get<boolean>('swagger.enabled', { infer: true }) ?? false)) {
return;
}
const config = new DocumentBuilder()
.setTitle(configService.get<string>('swagger.title', 'Oudelaa API'))
.setDescription(
+53 -2
View File
@@ -11,16 +11,33 @@ export const validationSchema = Joi.object({
GLOBAL_PREFIX: Joi.string().default('api/v1'),
CORS_ORIGINS: Joi.string().allow('').optional(),
MONGODB_URI: Joi.string().required(),
MONGODB_AUTO_INDEX: Joi.boolean().truthy('true').falsy('false').optional(),
MONGODB_MIN_POOL_SIZE: Joi.number().integer().min(0).max(500).default(5),
MONGODB_MAX_POOL_SIZE: Joi.number().integer().min(1).max(1000).default(100),
MONGODB_MAX_IDLE_TIME_MS: Joi.number().integer().min(1000).max(600000).default(60000),
MONGODB_SERVER_SELECTION_TIMEOUT_MS: Joi.number().integer().min(1000).max(120000).default(10000),
MONGODB_SOCKET_TIMEOUT_MS: Joi.number().integer().min(1000).max(600000).default(45000),
SEARCH_ENGINE: Joi.string().valid('auto', 'atlas', 'regex').default('auto'),
SEARCH_ATLAS_USER_INDEX: Joi.string().default('users_search'),
SEARCH_ATLAS_POST_INDEX: Joi.string().default('posts_search'),
SEARCH_FALLBACK_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
SEARCH_ATLAS_RETRY_SECONDS: Joi.number().integer().min(30).max(3600).default(300),
JWT_ACCESS_SECRET: Joi.string().min(16).required(),
JWT_ACCESS_EXPIRES_IN: Joi.string().default('15m'),
JWT_REFRESH_SECRET: Joi.string().min(16).required(),
JWT_REFRESH_EXPIRES_IN: Joi.string().default('30d'),
SUPERADMIN_EMAIL: Joi.string().email().required(),
SUPERADMIN_PASSWORD: Joi.string().min(8).required(),
SUPERADMIN_PASSWORD: Joi.string().min(8).allow('').optional(),
SUPERADMIN_PASSWORD_HASH: Joi.when('NODE_ENV', {
is: 'production',
then: Joi.string().pattern(/^\$2[aby]\$\d{2}\$.{53}$/).required(),
otherwise: Joi.string().allow('').optional(),
}),
SUPERADMIN_ACCESS_SECRET: Joi.string().min(16).required(),
SUPERADMIN_ACCESS_EXPIRES_IN: Joi.string().default('15m'),
SUPERADMIN_REFRESH_SECRET: Joi.string().min(16).required(),
SUPERADMIN_REFRESH_EXPIRES_IN: Joi.string().default('30d'),
SUPERADMIN_TOTP_SECRET: Joi.string().pattern(/^[A-Z2-7\s=-]{16,}$/i).allow('').optional(),
GOOGLE_CLIENT_ID: Joi.string().allow('').optional(),
GOOGLE_CLIENT_SECRET: Joi.string().allow('').optional(),
GOOGLE_CALLBACK_URL: Joi.string().uri().optional(),
@@ -40,6 +57,20 @@ export const validationSchema = Joi.object({
AI_MUSIC_MODEL: Joi.string().default('lyria-002'),
BCRYPT_SALT_ROUNDS: Joi.number().min(8).max(15).default(12),
REFRESH_TOKEN_HASH_SECRET: Joi.string().allow('').optional(),
HTTP_BODY_LIMIT: Joi.string().pattern(/^\d+(kb|mb)$/i).default('1mb'),
HTTP_REQUEST_TIMEOUT_MS: Joi.number().integer().min(1000).max(300000).default(30000),
HTTP_SERVER_REQUEST_TIMEOUT_MS: Joi.number()
.integer()
.min(1000)
.max(900000)
.default(120000),
HTTP_HEADERS_TIMEOUT_MS: Joi.number().integer().min(1000).max(120000).default(15000),
HTTP_KEEP_ALIVE_TIMEOUT_MS: Joi.number().integer().min(1000).max(60000).default(5000),
HTTP_MAX_REQUESTS_PER_SOCKET: Joi.number().integer().min(1).max(100000).default(1000),
SHUTDOWN_GRACE_PERIOD_MS: Joi.number().integer().min(1000).max(120000).default(30000),
METRICS_EVENT_LOOP_RESOLUTION_MS: Joi.number().integer().min(10).max(1000).default(20),
METRICS_EVENT_LOOP_LAG_WARN_MS: Joi.number().integer().min(10).max(10000).default(100),
METRICS_MAX_ROUTES: Joi.number().integer().min(10).max(10000).default(500),
REDIS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
REDIS_URL: Joi.string().allow('').optional(),
REDIS_HOST: Joi.string().default('127.0.0.1'),
@@ -95,7 +126,11 @@ export const validationSchema = Joi.object({
FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
FEED_CACHE_USER_TTL_SECONDS: Joi.number().min(1).max(3600).default(15),
FEED_CACHE_TRENDING_TTL_SECONDS: Joi.number().min(1).max(3600).default(30),
FEED_CACHE_RANKING_PROFILE_TTL_SECONDS: Joi.number().min(1).max(3600).default(30),
FEED_TIMING_LOGS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
HTTP_COMPRESSION_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
HTTP_COMPRESSION_THRESHOLD_BYTES: Joi.number().integer().min(0).max(1048576).default(1024),
MODERATION_BLOCKED_TERMS: Joi.string().allow('').optional(),
PASSWORD_RESET_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
PASSWORD_RESET_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(),
@@ -103,7 +138,23 @@ export const validationSchema = Joi.object({
EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
EMAIL_VERIFICATION_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
SWAGGER_TITLE: Joi.string().default('Oudelaa API'),
SWAGGER_ENABLED: Joi.boolean().truthy('true').falsy('false').optional(),
SWAGGER_DESCRIPTION: Joi.string().default('Social media backend API documentation'),
SWAGGER_VERSION: Joi.string().default('1.0.0'),
SWAGGER_PATH: Joi.string().default('docs'),
});
}).custom((value: Record<string, unknown>, helpers: Joi.CustomHelpers) => {
if (value.STORAGE_PROVIDER === 'local' && value.MEDIA_ACCESS_MODE === 'signed') {
return helpers.message({ custom: 'MEDIA_ACCESS_MODE=signed requires STORAGE_PROVIDER=s3' });
}
if (Number(value.HTTP_HEADERS_TIMEOUT_MS) <= Number(value.HTTP_KEEP_ALIVE_TIMEOUT_MS)) {
return helpers.message({
custom: 'HTTP_HEADERS_TIMEOUT_MS must be greater than HTTP_KEEP_ALIVE_TIMEOUT_MS',
});
}
if (Number(value.HTTP_SERVER_REQUEST_TIMEOUT_MS) < Number(value.HTTP_HEADERS_TIMEOUT_MS)) {
return helpers.message({
custom: 'HTTP_SERVER_REQUEST_TIMEOUT_MS must be at least HTTP_HEADERS_TIMEOUT_MS',
});
}
return value;
}, 'storage access mode validation');
@@ -0,0 +1,27 @@
import { createMongooseOptions } from './mongoose-options.factory';
describe('createMongooseOptions', () => {
const config = (values: Record<string, unknown>) => ({
get: jest.fn((key: string) => values[key]),
}) as any;
it('uses a bounded production pool and disables startup index builds', () => {
const options = createMongooseOptions(config({
nodeEnv: 'production', 'mongodb.uri': 'mongodb://database/app',
}));
expect(options).toEqual(expect.objectContaining({
autoIndex: false, minPoolSize: 5, maxPoolSize: 100,
serverSelectionTimeoutMS: 10_000, socketTimeoutMS: 45_000,
}));
});
it('accepts explicit pool and index configuration', () => {
const options = createMongooseOptions(config({
nodeEnv: 'production', 'mongodb.uri': 'mongodb://database/app',
'mongodb.autoIndex': true, 'mongodb.minPoolSize': 10, 'mongodb.maxPoolSize': 200,
}));
expect(options.autoIndex).toBe(true);
expect(options.minPoolSize).toBe(10);
expect(options.maxPoolSize).toBe(200);
});
});
+14 -4
View File
@@ -1,7 +1,17 @@
import { ConfigService } from '@nestjs/config';
import { MongooseModuleOptions } from '@nestjs/mongoose';
export const createMongooseOptions = (configService: ConfigService): MongooseModuleOptions => ({
uri: configService.get<string>('mongodb.uri', { infer: true }),
autoIndex: true,
});
export const createMongooseOptions = (configService: ConfigService): MongooseModuleOptions => {
const nodeEnv = configService.get<string>('nodeEnv', { infer: true }) ?? 'development';
return {
uri: configService.get<string>('mongodb.uri', { infer: true }),
autoIndex:
configService.get<boolean>('mongodb.autoIndex', { infer: true }) ?? nodeEnv !== 'production',
minPoolSize: configService.get<number>('mongodb.minPoolSize', { infer: true }) ?? 5,
maxPoolSize: configService.get<number>('mongodb.maxPoolSize', { infer: true }) ?? 100,
maxIdleTimeMS: configService.get<number>('mongodb.maxIdleTimeMs', { infer: true }) ?? 60_000,
serverSelectionTimeoutMS:
configService.get<number>('mongodb.serverSelectionTimeoutMs', { infer: true }) ?? 10_000,
socketTimeoutMS: configService.get<number>('mongodb.socketTimeoutMs', { infer: true }) ?? 45_000,
};
};
+150
View File
@@ -0,0 +1,150 @@
import { RedisService } from '../redis/redis.service';
import { AppCacheService, CacheFillLock } from './app-cache.service';
type RedisDouble = {
get: jest.Mock;
set: jest.Mock;
del: jest.Mock;
incr: jest.Mock;
expire: jest.Mock;
eval: jest.Mock;
exists: jest.Mock;
};
const redisDouble = (): RedisDouble => ({
get: jest.fn(), set: jest.fn(), del: jest.fn(), incr: jest.fn(), expire: jest.fn(),
eval: jest.fn(), exists: jest.fn(),
});
const redisService = (client: RedisDouble | null): RedisService =>
({ getClient: jest.fn(() => client), getKeyPrefix: jest.fn(() => 'spec') }) as unknown as RedisService;
type CacheInternals = {
normalizeLockTtlMilliseconds(value: number): number;
normalizeDurationMilliseconds(value: number, minimum: number): number;
withJitter(value: number): number;
errorMessage(error: unknown): string;
};
describe('AppCacheService behavior', () => {
it('supports memory get, set, expiration, deletion, and absent writes', async () => {
const service = new AppCacheService(redisService(null));
await service.set('plain', { id: 1 });
await expect(service.get('plain')).resolves.toEqual({ id: 1 });
await expect(service.setIfAbsent('plain', 2, 10)).resolves.toBe(false);
await service.del('plain');
await expect(service.get('plain')).resolves.toBeNull();
await expect(service.setIfAbsent('plain', 2, 0)).resolves.toBe(true);
await expect(service.get('plain')).resolves.toBe(2);
const now = jest.spyOn(Date, 'now');
now.mockReturnValueOnce(1000);
await service.set('short', 'value', 1);
now.mockReturnValue(2001);
await expect(service.get('short')).resolves.toBeNull();
now.mockRestore();
});
it('uses Redis for CRUD, NX, and JSON serialization', async () => {
const redis = redisDouble();
const service = new AppCacheService(redisService(redis));
redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce('{"id":4}');
await expect(service.get('missing')).resolves.toBeNull();
await expect(service.get('item')).resolves.toEqual({ id: 4 });
await service.set('ttl', { yes: true }, 4);
expect(redis.set).toHaveBeenCalledWith('spec:ttl', '{"yes":true}', 'EX', 4);
await service.set('forever', 1, 0);
expect(redis.set).toHaveBeenCalledWith('spec:forever', '1');
redis.set.mockResolvedValueOnce('OK').mockResolvedValueOnce(null);
await expect(service.setIfAbsent('one', 1, 0)).resolves.toBe(true);
await expect(service.setIfAbsent('one', 1, 10)).resolves.toBe(false);
expect(redis.set).toHaveBeenCalledWith('spec:one', '1', 'EX', 1, 'NX');
await service.del('one');
expect(redis.del).toHaveBeenCalledWith('spec:one');
});
it('increments Redis values and expires only a newly created counter', async () => {
const redis = redisDouble();
const service = new AppCacheService(redisService(redis));
redis.incr.mockResolvedValueOnce(1).mockResolvedValueOnce(2);
await expect(service.incr('counter', 5)).resolves.toBe(1);
await expect(service.incr('counter', 5)).resolves.toBe(2);
expect(redis.expire).toHaveBeenCalledTimes(1);
expect(redis.expire).toHaveBeenCalledWith('spec:counter', 5);
});
it('increments memory counters and resets expired values', async () => {
const service = new AppCacheService(redisService(null));
await expect(service.incr('counter')).resolves.toBe(1);
await expect(service.incr('counter')).resolves.toBe(2);
const now = jest.spyOn(Date, 'now');
now.mockReturnValueOnce(1000);
await service.set('expiring-counter', 9, 1);
now.mockReturnValue(2001);
await expect(service.incr('expiring-counter', 5)).resolves.toBe(1);
now.mockRestore();
});
it('enforces memory lock ownership, extension, expiration, and one-time release', async () => {
const service = new AppCacheService(redisService(null));
const lock = await service.acquireFillLock('fill', 10);
expect(lock.acquired).toBe(true);
expect((await service.acquireFillLock('fill', 10)).acquired).toBe(false);
await expect(lock.extend(20)).resolves.toBe(true);
await expect(lock.release()).resolves.toBe(true);
await expect(lock.extend()).resolves.toBe(false);
await expect(lock.release()).resolves.toBe(false);
const now = jest.spyOn(Date, 'now');
now.mockReturnValueOnce(1000);
await service.acquireFillLock('expired', 1);
now.mockReturnValue(3000);
expect((await service.acquireFillLock('expired', 1)).acquired).toBe(true);
now.mockRestore();
});
it('handles Redis lock contention, extension, and release', async () => {
const redis = redisDouble();
const service = new AppCacheService(redisService(redis));
redis.set.mockResolvedValueOnce(null);
const unavailable = await service.acquireFillLock('fill', Number.NaN);
expect(unavailable.acquired).toBe(false);
await expect(unavailable.extend()).resolves.toBe(false);
await expect(unavailable.release()).resolves.toBe(false);
redis.set.mockResolvedValueOnce('OK');
redis.eval.mockResolvedValueOnce(1).mockResolvedValueOnce(1);
const lock = await service.acquireFillLock('fill', 0);
expect(lock.acquired).toBe(true);
await expect(lock.extend(2)).resolves.toBe(true);
await expect(lock.release()).resolves.toBe(true);
await expect(lock.release()).resolves.toBe(false);
expect(redis.set).toHaveBeenLastCalledWith(expect.stringContaining('__fill_lock'), expect.any(String), 'PX', 1000, 'NX');
});
it('returns immediately when waiting has no time budget', async () => {
const service = new AppCacheService(redisService(null));
await expect(service.waitForValue('never', 0, Number.NaN)).resolves.toBeNull();
});
it('releases a remember lease even when factories reject and tolerates release failures', async () => {
const service = new AppCacheService(redisService(null));
const release = jest.fn().mockRejectedValue(new Error('Redis unavailable'));
const lock: CacheFillLock = { acquired: true, extend: jest.fn().mockResolvedValue(true), release };
jest.spyOn(service, 'acquireFillLock').mockResolvedValue(lock);
await expect(service.remember('broken', 10, async () => { throw new Error('factory failed'); })).rejects.toThrow('factory failed');
expect(release).toHaveBeenCalled();
});
it('normalizes unsafe durations and formats unknown errors', () => {
const service = new AppCacheService(redisService(null));
const target = service as unknown as CacheInternals;
expect(target.normalizeLockTtlMilliseconds(-1)).toBe(1000);
expect(target.normalizeLockTtlMilliseconds(0.0001)).toBe(1);
expect(target.normalizeDurationMilliseconds(Number.NaN, 3)).toBe(3);
expect(target.normalizeDurationMilliseconds(2.9, 3)).toBe(3);
expect(target.withJitter(0)).toBeGreaterThanOrEqual(1);
expect(target.errorMessage(new Error('boom'))).toBe('boom');
expect(target.errorMessage('boom')).toBe('boom');
});
});
+142
View File
@@ -0,0 +1,142 @@
import { RedisService } from '../redis/redis.service';
import { AppCacheService } from './app-cache.service';
class FakeRedis {
readonly values = new Map<string, string>();
async get(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async set(
key: string,
value: string,
...options: Array<string | number>
): Promise<'OK' | null> {
if (options.includes('NX') && this.values.has(key)) {
return null;
}
this.values.set(key, value);
return 'OK';
}
async exists(key: string): Promise<number> {
return this.values.has(key) ? 1 : 0;
}
async eval(
script: string,
_numberOfKeys: number,
key: string,
ownerToken: string,
_ttlMs?: number,
): Promise<number> {
if (this.values.get(key) !== ownerToken) {
return 0;
}
if (script.includes("redis.call('pexpire'")) {
return 1;
}
this.values.delete(key);
return 1;
}
}
const createRedisService = (client: FakeRedis | null): RedisService =>
({
getClient: jest.fn(() => client),
getKeyPrefix: jest.fn(() => 'test'),
}) as unknown as RedisService;
const nextTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
describe('AppCacheService stampede protection', () => {
it('coalesces concurrent local cache misses into one factory call', async () => {
const service = new AppCacheService(createRedisService(null));
let resolveFactory!: (value: { id: number }) => void;
const factory = jest.fn(
() =>
new Promise<{ id: number }>((resolve) => {
resolveFactory = resolve;
}),
);
const requests = Array.from({ length: 25 }, () => service.remember('feed:1', 60, factory));
await nextTurn();
expect(factory).toHaveBeenCalledTimes(1);
resolveFactory({ id: 42 });
await expect(Promise.all(requests)).resolves.toEqual(
Array.from({ length: 25 }, () => ({ id: 42 })),
);
await expect(service.remember('feed:1', 60, factory)).resolves.toEqual({ id: 42 });
expect(factory).toHaveBeenCalledTimes(1);
});
it('clears a failed local single-flight so a later request can retry', async () => {
const service = new AppCacheService(createRedisService(null));
const failedFactory = jest.fn().mockRejectedValue(new Error('database unavailable'));
await expect(service.remember('feed:failure', 60, failedFactory)).rejects.toThrow(
'database unavailable',
);
await expect(
service.remember('feed:failure', 60, async () => ({ recovered: true })),
).resolves.toEqual({ recovered: true });
expect(failedFactory).toHaveBeenCalledTimes(1);
});
it('uses a shared Redis lease to coalesce fills across service instances', async () => {
const redis = new FakeRedis();
const firstService = new AppCacheService(createRedisService(redis));
const secondService = new AppCacheService(createRedisService(redis));
let resolveFirst!: (value: string) => void;
const firstFactory = jest.fn(
() =>
new Promise<string>((resolve) => {
resolveFirst = resolve;
}),
);
const secondFactory = jest.fn(async () => 'duplicate');
const first = firstService.remember('feed:shared', 60, firstFactory);
await nextTurn();
expect(firstFactory).toHaveBeenCalledTimes(1);
const second = secondService.remember('feed:shared', 60, secondFactory);
await nextTurn();
resolveFirst('canonical');
await expect(Promise.all([first, second])).resolves.toEqual(['canonical', 'canonical']);
expect(secondFactory).not.toHaveBeenCalled();
});
it('never releases or extends a Redis lock now owned by another request', async () => {
const redis = new FakeRedis();
const service = new AppCacheService(createRedisService(redis));
const lease = await service.acquireFillLock('feed:ownership', 10);
const lockKey = 'test:feed:ownership:__fill_lock';
expect(lease.acquired).toBe(true);
redis.values.set(lockKey, 'replacement-owner');
await expect(lease.extend(20)).resolves.toBe(false);
await expect(lease.release()).resolves.toBe(false);
expect(redis.values.get(lockKey)).toBe('replacement-owner');
});
it('releases its own Redis lock atomically and only once', async () => {
const redis = new FakeRedis();
const service = new AppCacheService(createRedisService(redis));
const lease = await service.acquireFillLock('feed:release', 10);
await expect(lease.release()).resolves.toBe(true);
await expect(lease.release()).resolves.toBe(false);
expect(redis.values.has('test:feed:release:__fill_lock')).toBe(false);
});
it('waits for a value populated by another request', async () => {
const service = new AppCacheService(createRedisService(null));
const waiting = service.waitForValue<{ ready: boolean }>('feed:waiting', 200, 5);
setTimeout(() => void service.set('feed:waiting', { ready: true }, 60), 10);
await expect(waiting).resolves.toEqual({ ready: true });
});
});
+354 -7
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
type MemoryEntry = {
@@ -6,9 +7,41 @@ type MemoryEntry = {
expiresAt: number | null;
};
type MemoryLock = {
ownerToken: string;
expiresAt: number;
};
export type CacheFillLock = {
readonly acquired: boolean;
extend: (ttlSeconds?: number) => Promise<boolean>;
release: () => Promise<boolean>;
};
const RELEASE_LOCK_SCRIPT = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
`;
const EXTEND_LOCK_SCRIPT = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
end
return 0
`;
@Injectable()
export class AppCacheService {
private static readonly DEFAULT_FILL_LOCK_TTL_SECONDS = 30;
private static readonly DEFAULT_FILL_WAIT_TIMEOUT_MS = 30_000;
private static readonly DEFAULT_FILL_POLL_MS = 50;
private readonly logger = new Logger(AppCacheService.name);
private readonly memory = new Map<string, MemoryEntry>();
private readonly memoryLocks = new Map<string, MemoryLock>();
private readonly inFlight = new Map<string, Promise<unknown>>();
constructor(private readonly redisService: RedisService) {}
@@ -56,6 +89,28 @@ export class AppCacheService {
this.memory.set(fullKey, { value: serialized, expiresAt });
}
async setIfAbsent<T>(key: string, value: T, ttlSeconds: number): Promise<boolean> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
const serialized = JSON.stringify(value);
if (redis) {
const result = await redis.set(fullKey, serialized, 'EX', Math.max(1, ttlSeconds), 'NX');
return result === 'OK';
}
const existing = this.memory.get(fullKey);
if (existing && (!existing.expiresAt || existing.expiresAt > Date.now())) {
return false;
}
this.memory.set(fullKey, {
value: serialized,
expiresAt: Date.now() + Math.max(1, ttlSeconds) * 1000,
});
return true;
}
async del(key: string): Promise<void> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
@@ -73,9 +128,132 @@ export class AppCacheService {
return cached;
}
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
const fullKey = this.buildKey(key);
const existingFill = this.inFlight.get(fullKey) as Promise<T> | undefined;
if (existingFill) {
return existingFill;
}
const fill = this.fillRememberedValue(key, ttlSeconds, factory);
this.inFlight.set(fullKey, fill);
try {
return await fill;
} finally {
if (this.inFlight.get(fullKey) === fill) {
this.inFlight.delete(fullKey);
}
}
}
/**
* Acquires a short-lived cache-fill lease. The returned release/extend
* functions are ownership checked, so an expired lock can never be removed
* or extended by its previous owner.
*/
async acquireFillLock(key: string, ttlSeconds: number): Promise<CacheFillLock> {
const redis = this.redisService.getClient();
const lockKey = this.buildLockKey(key);
const ownerToken = randomUUID();
const ttlMs = this.normalizeLockTtlMilliseconds(ttlSeconds);
if (redis) {
const result = await redis.set(lockKey, ownerToken, 'PX', ttlMs, 'NX');
if (result !== 'OK') {
return this.createUnavailableLock();
}
let released = false;
return {
acquired: true,
extend: async (nextTtlSeconds = ttlSeconds): Promise<boolean> => {
if (released) {
return false;
}
const extended = await redis.eval(
EXTEND_LOCK_SCRIPT,
1,
lockKey,
ownerToken,
this.normalizeLockTtlMilliseconds(nextTtlSeconds),
);
return Number(extended) === 1;
},
release: async (): Promise<boolean> => {
if (released) {
return false;
}
const deleted = await redis.eval(RELEASE_LOCK_SCRIPT, 1, lockKey, ownerToken);
if (Number(deleted) === 1) {
released = true;
return true;
}
return false;
},
};
}
const now = Date.now();
const current = this.memoryLocks.get(lockKey);
if (current && current.expiresAt > now) {
return this.createUnavailableLock();
}
this.memoryLocks.set(lockKey, { ownerToken, expiresAt: now + ttlMs });
let released = false;
return {
acquired: true,
extend: async (nextTtlSeconds = ttlSeconds): Promise<boolean> => {
if (released) {
return false;
}
const lock = this.memoryLocks.get(lockKey);
if (!lock || lock.ownerToken !== ownerToken || lock.expiresAt <= Date.now()) {
return false;
}
lock.expiresAt = Date.now() + this.normalizeLockTtlMilliseconds(nextTtlSeconds);
return true;
},
release: async (): Promise<boolean> => {
if (released) {
return false;
}
const lock = this.memoryLocks.get(lockKey);
if (!lock || lock.ownerToken !== ownerToken) {
return false;
}
this.memoryLocks.delete(lockKey);
released = true;
return true;
},
};
}
async waitForValue<T>(key: string, timeoutMs: number, pollMs = 50): Promise<T | null> {
const timeout = this.normalizeDurationMilliseconds(timeoutMs, 0);
const pollInterval = this.normalizeDurationMilliseconds(pollMs, 1);
const deadline = Date.now() + timeout;
while (true) {
const cached = await this.get<T>(key);
if (cached !== null) {
return cached;
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
return null;
}
await this.delay(Math.min(this.withJitter(pollInterval), remaining));
}
}
async incr(key: string, ttlSeconds?: number): Promise<number> {
@@ -90,13 +268,182 @@ export class AppCacheService {
return nextValue;
}
const existing = await this.get<number>(key);
const nextValue = (existing ?? 0) + 1;
await this.set(key, nextValue, ttlSeconds);
const existing = this.memory.get(fullKey);
const currentValue =
existing && (!existing.expiresAt || existing.expiresAt > Date.now())
? (JSON.parse(existing.value) as number)
: 0;
const nextValue = currentValue + 1;
this.memory.set(fullKey, {
value: JSON.stringify(nextValue),
expiresAt: ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null,
});
return nextValue;
}
private buildKey(key: string): string {
return `${this.redisService.getKeyPrefix()}:${key}`;
}
private buildLockKey(key: string): string {
return `${this.buildKey(key)}:__fill_lock`;
}
private async fillRememberedValue<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>,
): Promise<T> {
const cachedAfterJoining = await this.get<T>(key);
if (cachedAfterJoining !== null) {
return cachedAfterJoining;
}
const lockTtlSeconds = AppCacheService.DEFAULT_FILL_LOCK_TTL_SECONDS;
const deadline = Date.now() + AppCacheService.DEFAULT_FILL_WAIT_TIMEOUT_MS;
while (Date.now() < deadline) {
const lock = await this.acquireFillLock(key, lockTtlSeconds);
if (lock.acquired) {
return this.fillAsLockOwner(key, ttlSeconds, factory, lock, lockTtlSeconds);
}
const value = await this.waitForValueWhileLocked<T>(
key,
Math.max(0, deadline - Date.now()),
AppCacheService.DEFAULT_FILL_POLL_MS,
);
if (value !== null) {
return value;
}
}
// Keep request latency bounded if a remote owner is unhealthy or its
// factory never completes. This rare fallback may duplicate work, but it
// prevents an orphaned cache lease from taking down the request path.
const lastCachedValue = await this.get<T>(key);
if (lastCachedValue !== null) {
return lastCachedValue;
}
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
}
private async fillAsLockOwner<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>,
lock: CacheFillLock,
lockTtlSeconds: number,
): Promise<T> {
const heartbeat = setInterval(() => {
void lock.extend(lockTtlSeconds).catch((error: unknown) => {
this.logger.warn(`Could not extend cache fill lock for ${key}: ${this.errorMessage(error)}`);
});
}, Math.max(1_000, Math.floor((lockTtlSeconds * 1000) / 3)));
heartbeat.unref?.();
try {
// A different owner may have filled the value immediately before this
// process acquired the lease.
const cached = await this.get<T>(key);
if (cached !== null) {
return cached;
}
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
} finally {
clearInterval(heartbeat);
try {
await lock.release();
} catch (error: unknown) {
// A successful cache fill must not be turned into a failed request by
// a transient Redis error during best-effort lease cleanup.
this.logger.warn(`Could not release cache fill lock for ${key}: ${this.errorMessage(error)}`);
}
}
}
private async waitForValueWhileLocked<T>(
key: string,
timeoutMs: number,
pollMs: number,
): Promise<T | null> {
const deadline = Date.now() + timeoutMs;
while (true) {
const cached = await this.get<T>(key);
if (cached !== null) {
return cached;
}
if (!(await this.isFillLockHeld(key))) {
return null;
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
return null;
}
await this.delay(Math.min(this.withJitter(pollMs), remaining));
}
}
private async isFillLockHeld(key: string): Promise<boolean> {
const redis = this.redisService.getClient();
const lockKey = this.buildLockKey(key);
if (redis) {
return (await redis.exists(lockKey)) === 1;
}
const lock = this.memoryLocks.get(lockKey);
if (!lock) {
return false;
}
if (lock.expiresAt <= Date.now()) {
this.memoryLocks.delete(lockKey);
return false;
}
return true;
}
private createUnavailableLock(): CacheFillLock {
return {
acquired: false,
extend: async () => false,
release: async () => false,
};
}
private normalizeLockTtlMilliseconds(ttlSeconds: number): number {
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
return 1_000;
}
return Math.max(1, Math.ceil(ttlSeconds * 1000));
}
private normalizeDurationMilliseconds(value: number, minimum: number): number {
if (!Number.isFinite(value)) {
return minimum;
}
return Math.max(minimum, Math.floor(value));
}
private withJitter(durationMs: number): number {
return Math.max(1, Math.round(durationMs * (0.8 + Math.random() * 0.4)));
}
private delay(durationMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, durationMs));
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { AppCacheService } from './app-cache.service';
import { FeedVersionService } from './feed-version.service';
describe('FeedVersionService', () => {
const cache = { get: jest.fn(), set: jest.fn(), incr: jest.fn() } as unknown as AppCacheService;
const service = new FeedVersionService(cache);
beforeEach(() => jest.clearAllMocks());
it('returns existing positive global and user versions', async () => {
(cache.get as jest.Mock).mockResolvedValueOnce(7).mockResolvedValueOnce(3);
await expect(service.getGlobalVersion()).resolves.toBe(7);
await expect(service.getUserVersion('u1')).resolves.toBe(3);
expect(cache.set).not.toHaveBeenCalled();
});
it.each([null, 0, -1, '1'])('initializes invalid global version %p', async (stored) => {
(cache.get as jest.Mock).mockResolvedValue(stored);
await expect(service.getGlobalVersion()).resolves.toBe(1);
expect(cache.set).toHaveBeenCalledWith('feed:global:version', 1);
});
it('initializes an absent user version and bumps both scopes', async () => {
(cache.get as jest.Mock).mockResolvedValue(null);
(cache.incr as jest.Mock).mockResolvedValueOnce(4).mockResolvedValueOnce(9);
await expect(service.getUserVersion('u2')).resolves.toBe(1);
expect(cache.set).toHaveBeenCalledWith('feed:user:u2:version', 1);
await expect(service.bumpGlobalVersion()).resolves.toBe(4);
await expect(service.bumpUserVersion('u2')).resolves.toBe(9);
expect(cache.incr).toHaveBeenNthCalledWith(1, 'feed:global:version');
expect(cache.incr).toHaveBeenNthCalledWith(2, 'feed:user:u2:version');
});
});
+12
View File
@@ -20,4 +20,16 @@ export class FeedVersionService {
async bumpGlobalVersion(): Promise<number> {
return this.cacheService.incr(FeedVersionService.GLOBAL_VERSION_KEY);
}
async getUserVersion(userId: string): Promise<number> {
const key = `feed:user:${userId}:version`;
const current = await this.cacheService.get<number>(key);
if (typeof current === 'number' && current > 0) return current;
await this.cacheService.set(key, 1);
return 1;
}
async bumpUserVersion(userId: string): Promise<number> {
return this.cacheService.incr(`feed:user:${userId}:version`);
}
}
@@ -0,0 +1,55 @@
import { ConfigService } from '@nestjs/config';
import { AppLoggerService } from './app-logger.service';
describe('AppLoggerService', () => {
let level = 'verbose';
const config = { get: jest.fn(() => level) } as unknown as ConfigService;
let stdout: jest.SpyInstance;
let stderr: jest.SpyInstance;
beforeEach(() => {
level = 'verbose';
stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
});
afterEach(() => {
stdout.mockRestore();
stderr.mockRestore();
});
it('writes structured messages, errors, traces, and HTTP metadata', () => {
const logger = new AppLoggerService(config);
logger.log('hello', 'Test');
logger.warn({ id: 1 });
logger.debug(new Error('debug failed'));
logger.verbose('details');
logger.logHttp({ method: 'GET', path: '/health' });
logger.error('broken', 'trace-text', 'Test');
expect(stdout).toHaveBeenCalledTimes(5);
const logEntry = JSON.parse(String(stdout.mock.calls[0][0]));
expect(logEntry).toEqual(expect.objectContaining({ level: 'log', context: 'Test', message: 'hello' }));
const structured = JSON.parse(String(stdout.mock.calls[1][0]));
expect(structured).toEqual(expect.objectContaining({ message: 'structured_log', payload: { id: 1 } }));
const errorObject = JSON.parse(String(stdout.mock.calls[2][0]));
expect(errorObject).toEqual(expect.objectContaining({ errorName: 'Error', message: 'debug failed' }));
const http = JSON.parse(String(stdout.mock.calls[4][0]));
expect(http).toEqual(expect.objectContaining({ context: 'HttpLogger', method: 'GET', path: '/health' }));
const error = JSON.parse(String(stderr.mock.calls[0][0]));
expect(error).toEqual(expect.objectContaining({ level: 'error', trace: 'trace-text' }));
});
it('filters messages below the configured level and defaults to log', () => {
level = 'warn';
const logger = new AppLoggerService(config);
logger.log('hidden');
logger.debug('hidden');
logger.warn('visible');
expect(stdout).toHaveBeenCalledTimes(1);
(config.get as jest.Mock).mockReturnValueOnce(undefined);
logger.verbose('default-hidden');
expect(stdout).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,80 @@
import { Types } from 'mongoose';
import { AppQueueService } from '../queue/app-queue.service';
import { CounterReconciliationService } from './counter-reconciliation.service';
import { MaintenanceController } from './maintenance.controller';
describe('CounterReconciliationService', () => {
const postOne = new Types.ObjectId();
const postTwo = new Types.ObjectId();
let rows: Record<string, Array<{ _id: Types.ObjectId; count: number }>>;
let postsBulkWrite: jest.Mock;
let collection: jest.Mock;
const queue = { registerProcessor: jest.fn(), enqueue: jest.fn() } as unknown as AppQueueService;
beforeEach(() => {
jest.clearAllMocks();
rows = {
likes: [{ _id: postOne, count: 3 }],
comments: [{ _id: postOne, count: 2 }],
saves: [{ _id: postTwo, count: 5 }],
postshares: [{ _id: postOne, count: 1 }],
};
postsBulkWrite = jest.fn();
collection = jest.fn((name: string) =>
name === 'posts'
? {
find: jest.fn(() => ({ toArray: jest.fn().mockResolvedValue([{ _id: postOne }, { _id: postTwo }]) })),
bulkWrite: postsBulkWrite,
}
: { aggregate: jest.fn(() => ({ toArray: jest.fn().mockResolvedValue(rows[name]) })) },
);
});
it('registers and enqueues reconciliation jobs', async () => {
const service = new CounterReconciliationService({ collection } as never, queue);
service.onModuleInit();
expect(queue.registerProcessor).toHaveBeenCalledWith(
'maintenance.reconcile-post-counters',
expect.any(Function),
);
const callback = (queue.registerProcessor as jest.Mock).mock.calls[0][1] as () => Promise<void>;
const reconcile = jest.spyOn(service, 'reconcile').mockResolvedValue({ dryRun: false, scannedPosts: 0, updates: 0 });
await callback();
expect(reconcile).toHaveBeenCalledWith(false);
await expect(service.enqueue()).resolves.toEqual({ queued: true });
expect(queue.enqueue).toHaveBeenCalledWith('maintenance.reconcile-post-counters', {});
});
it('computes every counter without writes during a dry run', async () => {
const service = new CounterReconciliationService({ collection } as never, queue);
await expect(service.reconcile()).resolves.toEqual({ dryRun: true, scannedPosts: 2, updates: 2 });
expect(postsBulkWrite).not.toHaveBeenCalled();
const likesAggregate = collection.mock.results.find((result) => result.value.aggregate)?.value.aggregate;
expect(likesAggregate).toHaveBeenCalledWith([
{ $match: { targetType: 'post' } },
{ $group: { _id: '$targetId', count: { $sum: 1 } } },
{ $match: { _id: { $type: 'objectId' } } },
]);
});
it('writes reconciled values including zeros', async () => {
const service = new CounterReconciliationService({ collection } as never, queue);
await service.reconcile(false);
expect(postsBulkWrite).toHaveBeenCalledWith(
[
expect.objectContaining({ updateOne: expect.objectContaining({ update: { $set: { likesCount: 3, commentsCount: 2, savesCount: 0, shareCount: 1 } } }) }),
expect.objectContaining({ updateOne: expect.objectContaining({ update: { $set: { likesCount: 0, commentsCount: 0, savesCount: 5, shareCount: 0 } } }) }),
],
{ ordered: false },
);
});
it('controller executes only on an explicit true flag', async () => {
const counters = { enqueue: jest.fn().mockResolvedValue({ queued: true }), reconcile: jest.fn().mockResolvedValue({ dryRun: true }) };
const controller = new MaintenanceController(counters as never);
await controller.reconcile('true');
await controller.reconcile('TRUE');
expect(counters.enqueue).toHaveBeenCalledTimes(1);
expect(counters.reconcile).toHaveBeenCalledWith(true);
});
});
@@ -0,0 +1,64 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection, Types } from 'mongoose';
import { AppQueueService } from '../queue/app-queue.service';
const JOB_NAME = 'maintenance.reconcile-post-counters';
@Injectable()
export class CounterReconciliationService implements OnModuleInit {
constructor(
@InjectConnection() private readonly connection: Connection,
private readonly queue: AppQueueService,
) {}
onModuleInit(): void {
this.queue.registerProcessor(JOB_NAME, async () => { await this.reconcile(false); });
}
async enqueue(): Promise<{ queued: true }> {
await this.queue.enqueue(JOB_NAME, {});
return { queued: true };
}
async reconcile(dryRun = true): Promise<{ dryRun: boolean; scannedPosts: number; updates: number }> {
const posts = await this.connection.collection('posts').find(
{ isDeleted: { $ne: true } },
{ projection: { _id: 1 } },
).toArray();
const [likes, comments, saves, shares] = await Promise.all([
this.countByPost('likes', { targetType: 'post' }, '$targetId'),
this.countByPost('comments', { isDeleted: { $ne: true } }, '$postId'),
this.countByPost('saves', {}, '$postId'),
this.countByPost('postshares', { isDeleted: { $ne: true } }, '$postId'),
]);
const maps = [likes, comments, saves, shares].map((rows) =>
new Map(rows.map((row) => [row._id.toString(), row.count])),
);
const operations = posts.map((post) => ({
updateOne: {
filter: { _id: post._id },
update: { $set: {
likesCount: maps[0].get(post._id.toString()) ?? 0,
commentsCount: maps[1].get(post._id.toString()) ?? 0,
savesCount: maps[2].get(post._id.toString()) ?? 0,
shareCount: maps[3].get(post._id.toString()) ?? 0,
} },
},
}));
if (!dryRun) {
for (let offset = 0; offset < operations.length; offset += 500) {
await this.connection.collection('posts').bulkWrite(operations.slice(offset, offset + 500), { ordered: false });
}
}
return { dryRun, scannedPosts: posts.length, updates: operations.length };
}
private countByPost(collection: string, match: Record<string, unknown>, id: string) {
return this.connection.collection(collection).aggregate<{ _id: Types.ObjectId; count: number }>([
{ $match: match },
{ $group: { _id: id, count: { $sum: 1 } } },
{ $match: { _id: { $type: 'objectId' } } },
]).toArray();
}
}
@@ -0,0 +1,17 @@
import { Controller, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { CounterReconciliationService } from './counter-reconciliation.service';
@ApiTags('Operations')
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Controller('internal/maintenance')
export class MaintenanceController {
constructor(private readonly counters: CounterReconciliationService) {}
@Post('reconcile-post-counters')
reconcile(@Query('execute') execute?: string) {
return execute === 'true' ? this.counters.enqueue() : this.counters.reconcile(true);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { QueueModule } from '../queue/queue.module';
import { CounterReconciliationService } from './counter-reconciliation.service';
import { MaintenanceController } from './maintenance.controller';
@Module({
imports: [QueueModule],
controllers: [MaintenanceController],
providers: [CounterReconciliationService],
})
export class MaintenanceModule {}
@@ -0,0 +1,9 @@
import { MetricsController } from './metrics.controller';
describe('MetricsController', () => {
it('returns a current metrics snapshot', () => {
const metrics = { snapshot: jest.fn(() => ({ requests: 4 })) };
expect(new MetricsController(metrics as never).getMetrics()).toEqual({ requests: 4 });
expect(metrics.snapshot).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,17 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { MetricsService } from './metrics.service';
@ApiTags('Operations')
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Controller('internal/metrics')
export class MetricsController {
constructor(private readonly metrics: MetricsService) {}
@Get()
getMetrics() {
return this.metrics.snapshot();
}
}
@@ -0,0 +1,60 @@
import { CallHandler, ExecutionContext, InternalServerErrorException } from '@nestjs/common';
import { firstValueFrom, of, throwError } from 'rxjs';
import { MetricsInterceptor } from './metrics.interceptor';
import { MetricsService } from './metrics.service';
describe('MetricsInterceptor', () => {
const createContext = (statusCode = 200) =>
({
getType: () => 'http',
switchToHttp: () => ({
getRequest: () => ({
method: 'GET',
baseUrl: '/api/v1/posts',
route: { path: '/:id' },
}),
getResponse: () => ({ statusCode }),
}),
}) as unknown as ExecutionContext;
it('uses a route template instead of a high-cardinality raw URL', async () => {
const metrics = {
requestStarted: jest.fn(),
requestFinished: jest.fn(),
} as unknown as MetricsService;
const interceptor = new MetricsInterceptor(metrics);
await firstValueFrom(
interceptor.intercept(createContext(), { handle: () => of({ ok: true }) } as CallHandler),
);
expect(metrics.requestStarted).toHaveBeenCalledTimes(1);
expect(metrics.requestFinished).toHaveBeenCalledWith(
'GET',
'/api/v1/posts/:id',
200,
expect.any(Number),
);
});
it('records the actual exception status', async () => {
const metrics = {
requestStarted: jest.fn(),
requestFinished: jest.fn(),
} as unknown as MetricsService;
const interceptor = new MetricsInterceptor(metrics);
const handler = {
handle: () => throwError(() => new InternalServerErrorException()),
} as CallHandler;
await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).rejects.toBeInstanceOf(
InternalServerErrorException,
);
expect(metrics.requestFinished).toHaveBeenCalledWith(
'GET',
'/api/v1/posts/:id',
500,
expect.any(Number),
);
});
});
@@ -0,0 +1,56 @@
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, catchError, finalize, throwError } from 'rxjs';
import { MetricsService } from './metrics.service';
type MetricRequest = {
method?: string;
baseUrl?: string;
route?: { path?: unknown };
};
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
constructor(private readonly metrics: MetricsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
if (context.getType() !== 'http') {
return next.handle();
}
const request = context.switchToHttp().getRequest<MetricRequest>();
const response = context.switchToHttp().getResponse<{ statusCode: number }>();
const startedAt = Date.now();
const route = this.normalizedRoute(request);
let errorStatusCode: number | undefined;
this.metrics.requestStarted();
return next.handle().pipe(
catchError((error: unknown) => {
errorStatusCode = error instanceof HttpException ? error.getStatus() : 500;
return throwError(() => error);
}),
finalize(() => {
this.metrics.requestFinished(
request.method ?? 'UNKNOWN',
route,
errorStatusCode ?? response.statusCode,
Date.now() - startedAt,
);
}),
);
}
private normalizedRoute(request: MetricRequest): string {
const routePath = request.route?.path;
if (typeof routePath !== 'string') {
return '__unmatched__';
}
const joined = `${request.baseUrl ?? ''}/${routePath}`.replace(/\/{2,}/g, '/');
return joined.length > 1 ? joined.replace(/\/$/, '') : joined;
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { MetricsController } from './metrics.controller';
import { MetricsInterceptor } from './metrics.interceptor';
import { MetricsService } from './metrics.service';
@Module({
controllers: [MetricsController],
providers: [MetricsService, { provide: APP_INTERCEPTOR, useClass: MetricsInterceptor }],
exports: [MetricsService],
})
export class MetricsModule {}
@@ -0,0 +1,65 @@
import { ConfigService } from '@nestjs/config';
import { MetricsService } from './metrics.service';
describe('MetricsService', () => {
const createService = (values: Record<string, number> = {}) => {
const config = {
get: jest.fn((key: string) => values[key]),
} as unknown as ConfigService;
return new MetricsService(config);
};
it('aggregates latency, errors, timeouts, and an approximate p95 by route', () => {
const service = createService();
service.observe('GET', '/feed/me', 200, 20);
service.observe('GET', '/feed/me', 408, 40);
service.observe('GET', '/feed/me', 500, 260);
expect(service.snapshot().routes[0]).toEqual(
expect.objectContaining({
route: 'GET /feed/me',
requests: 3,
clientErrors: 1,
errors: 1,
timeouts: 1,
errorRate: 1 / 3,
averageDurationMs: 106.67,
p95DurationMs: 500,
maxDurationMs: 260,
}),
);
});
it('tracks current and peak in-flight work', () => {
const service = createService();
service.requestStarted();
service.requestStarted();
service.requestFinished('GET', '/health', 200, 2);
expect(service.snapshot().operational).toEqual({
totalRequests: 2,
inFlightRequests: 1,
peakInFlightRequests: 2,
});
});
it('caps route cardinality and exposes finite event-loop metrics', () => {
const service = createService({ 'metrics.maxRoutes': 1 });
service.onModuleInit();
service.observe('GET', '/first', 200, 1);
service.observe('GET', '/second', 200, 1);
const snapshot = service.snapshot();
service.onModuleDestroy();
expect(snapshot.routes.map((route) => route.route)).toEqual(
expect.arrayContaining(['GET /first', '__other_routes__']),
);
expect(snapshot.eventLoop).toEqual(
expect.objectContaining({
healthy: expect.any(Boolean),
utilization: expect.any(Number),
lagP99Ms: expect.any(Number),
}),
);
});
});
@@ -0,0 +1,165 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IntervalHistogram, monitorEventLoopDelay, performance } from 'perf_hooks';
const LATENCY_BUCKETS_MS = [10, 25, 50, 100, 250, 500, 1_000, 2_500, 5_000];
const OVERFLOW_ROUTE = '__other_routes__';
type RouteMetric = {
requests: number;
clientErrors: number;
errors: number;
timeouts: number;
totalDurationMs: number;
maxDurationMs: number;
latencyBuckets: number[];
};
@Injectable()
export class MetricsService implements OnModuleInit, OnModuleDestroy {
private readonly startedAt = Date.now();
private readonly routes = new Map<string, RouteMetric>();
private readonly eventLoopBaseline = performance.eventLoopUtilization();
private eventLoopDelay?: IntervalHistogram;
private totalRequests = 0;
private inFlightRequests = 0;
private peakInFlightRequests = 0;
constructor(private readonly configService: ConfigService) {}
onModuleInit(): void {
const resolution =
this.configService.get<number>('metrics.eventLoopResolutionMs', { infer: true }) ?? 20;
this.eventLoopDelay = monitorEventLoopDelay({ resolution });
this.eventLoopDelay.enable();
}
onModuleDestroy(): void {
this.eventLoopDelay?.disable();
}
requestStarted(): void {
this.totalRequests += 1;
this.inFlightRequests += 1;
this.peakInFlightRequests = Math.max(this.peakInFlightRequests, this.inFlightRequests);
}
requestFinished(method: string, route: string, statusCode: number, durationMs: number): void {
this.inFlightRequests = Math.max(0, this.inFlightRequests - 1);
this.observe(method, route, statusCode, durationMs);
}
observe(method: string, route: string, statusCode: number, durationMs: number): void {
const key = this.resolveRouteKey(`${method.toUpperCase()} ${route}`);
const current = this.routes.get(key) ?? this.createRouteMetric();
current.requests += 1;
current.clientErrors += statusCode >= 400 && statusCode < 500 ? 1 : 0;
current.errors += statusCode >= 500 ? 1 : 0;
current.timeouts += statusCode === 408 ? 1 : 0;
current.totalDurationMs += durationMs;
current.maxDurationMs = Math.max(current.maxDurationMs, durationMs);
const bucketIndex = LATENCY_BUCKETS_MS.findIndex((boundary) => durationMs <= boundary);
current.latencyBuckets[bucketIndex === -1 ? LATENCY_BUCKETS_MS.length : bucketIndex] += 1;
this.routes.set(key, current);
}
snapshot() {
const routes = Array.from(this.routes.entries()).map(([route, metric]) => ({
route,
requests: metric.requests,
clientErrors: metric.clientErrors,
errors: metric.errors,
timeouts: metric.timeouts,
errorRate: metric.requests ? metric.errors / metric.requests : 0,
averageDurationMs: metric.requests
? Math.round((metric.totalDurationMs / metric.requests) * 100) / 100
: 0,
p95DurationMs: this.estimatePercentile(metric, 0.95),
maxDurationMs: metric.maxDurationMs,
}));
const eventLoopUtilization = performance.eventLoopUtilization(this.eventLoopBaseline);
const eventLoop = this.eventLoopSnapshot(eventLoopUtilization.utilization);
return {
uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
process: {
pid: process.pid,
nodeVersion: process.version,
platform: process.platform,
},
memory: process.memoryUsage(),
operational: {
totalRequests: this.totalRequests,
inFlightRequests: this.inFlightRequests,
peakInFlightRequests: this.peakInFlightRequests,
},
eventLoop,
routes: routes.sort((left, right) => right.requests - left.requests),
};
}
private createRouteMetric(): RouteMetric {
return {
requests: 0,
clientErrors: 0,
errors: 0,
timeouts: 0,
totalDurationMs: 0,
maxDurationMs: 0,
latencyBuckets: Array.from({ length: LATENCY_BUCKETS_MS.length + 1 }, () => 0),
};
}
private resolveRouteKey(candidate: string): string {
if (this.routes.has(candidate)) {
return candidate;
}
const maxRoutes = this.configService.get<number>('metrics.maxRoutes', { infer: true }) ?? 500;
return this.routes.size < maxRoutes ? candidate : OVERFLOW_ROUTE;
}
private estimatePercentile(metric: RouteMetric, percentile: number): number {
if (!metric.requests) {
return 0;
}
const target = Math.ceil(metric.requests * percentile);
let cumulative = 0;
for (let index = 0; index < metric.latencyBuckets.length; index += 1) {
cumulative += metric.latencyBuckets[index];
if (cumulative >= target) {
return index < LATENCY_BUCKETS_MS.length
? LATENCY_BUCKETS_MS[index]
: Math.ceil(metric.maxDurationMs);
}
}
return Math.ceil(metric.maxDurationMs);
}
private eventLoopSnapshot(utilization: number) {
const warnThresholdMs =
this.configService.get<number>('metrics.eventLoopLagWarnMs', { infer: true }) ?? 100;
const meanMs = this.nanosecondsToMilliseconds(this.eventLoopDelay?.mean);
const p50Ms = this.nanosecondsToMilliseconds(this.eventLoopDelay?.percentile(50));
const p95Ms = this.nanosecondsToMilliseconds(this.eventLoopDelay?.percentile(95));
const p99Ms = this.nanosecondsToMilliseconds(this.eventLoopDelay?.percentile(99));
const maxMs = this.nanosecondsToMilliseconds(this.eventLoopDelay?.max);
return {
healthy: p99Ms <= warnThresholdMs,
warnThresholdMs,
utilization: Math.round(utilization * 10_000) / 10_000,
lagMeanMs: meanMs,
lagP50Ms: p50Ms,
lagP95Ms: p95Ms,
lagP99Ms: p99Ms,
lagMaxMs: maxMs,
};
}
private nanosecondsToMilliseconds(value: number | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
return 0;
}
return Math.round((value / 1_000_000) * 100) / 100;
}
}
@@ -0,0 +1,109 @@
import { ConfigService } from '@nestjs/config';
import { Queue, Worker } from 'bullmq';
import { AppLoggerService } from '../logging/app-logger.service';
import { RedisService } from '../redis/redis.service';
import { AppQueueService } from './app-queue.service';
jest.mock('bullmq', () => ({ Queue: jest.fn(), Worker: jest.fn() }));
describe('AppQueueService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const redis = { isEnabled: jest.fn(), createQueueClient: jest.fn() } as unknown as RedisService;
const logger = { error: jest.fn() } as unknown as AppLoggerService;
let queueInstance: { add: jest.Mock; close: jest.Mock };
let workerInstance: { on: jest.Mock; close: jest.Mock };
let workerProcessor: (job: { name: string; data: unknown }) => Promise<void>;
beforeEach(() => {
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
queueInstance = { add: jest.fn(), close: jest.fn() };
workerInstance = { on: jest.fn(), close: jest.fn() };
jest.mocked(Queue).mockImplementation(() => queueInstance as never);
jest.mocked(Worker).mockImplementation((_name, processor) => {
workerProcessor = processor as typeof workerProcessor;
return workerInstance as never;
});
});
it('does not connect when queues or Redis are disabled', () => {
(redis.isEnabled as jest.Mock).mockReturnValue(true);
const service = new AppQueueService(config, redis, logger);
service.onModuleInit();
service.onApplicationBootstrap();
expect(Queue).not.toHaveBeenCalled();
values['queue.enabled'] = true;
(redis.isEnabled as jest.Mock).mockReturnValue(false);
service.onApplicationBootstrap();
expect(redis.createQueueClient).not.toHaveBeenCalled();
});
it('starts BullMQ with configured defaults and dispatches registered processors', async () => {
Object.assign(values, {
'queue.enabled': true,
'queue.name': 'important-jobs',
'queue.workerConcurrency': 8,
'queue.defaultJobAttempts': 5,
'queue.defaultJobBackoffMs': 250,
'queue.removeOnComplete': false,
});
(redis.isEnabled as jest.Mock).mockReturnValue(true);
(redis.createQueueClient as jest.Mock).mockReturnValueOnce({ id: 'queue' }).mockReturnValueOnce({ id: 'worker' });
const service = new AppQueueService(config, redis, logger);
const processor = jest.fn().mockResolvedValue(undefined);
service.registerProcessor('send', processor);
service.onApplicationBootstrap();
expect(Queue).toHaveBeenCalledWith('important-jobs', expect.objectContaining({ defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 250 }, removeOnComplete: false } }));
expect(Worker).toHaveBeenCalledWith('important-jobs', expect.any(Function), expect.objectContaining({ concurrency: 8 }));
await workerProcessor({ name: 'send', data: { id: 1 } });
expect(processor).toHaveBeenCalledWith({ id: 1 });
await expect(workerProcessor({ name: 'missing', data: {} })).rejects.toThrow('No processor registered');
const failed = workerInstance.on.mock.calls.find(([event]) => event === 'failed')?.[1] as (
job: { name: string; id: string },
error: Error,
) => void;
failed({ name: 'send', id: 'j1' }, new Error('boom'));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ queue: 'important-jobs', jobId: 'j1', error: 'boom' }), undefined, 'AppQueueService');
});
it('enqueues remotely with per-job overrides and closes resources', async () => {
values['queue.enabled'] = true;
(redis.isEnabled as jest.Mock).mockReturnValue(true);
(redis.createQueueClient as jest.Mock).mockReturnValue({});
const service = new AppQueueService(config, redis, logger);
service.onApplicationBootstrap();
await service.enqueue('job', { id: 1 }, { attempts: 9 });
expect(queueInstance.add).toHaveBeenCalledWith('job', { id: 1 }, expect.objectContaining({ attempts: 9, removeOnComplete: true }));
await service.onModuleDestroy();
expect(workerInstance.close).toHaveBeenCalled();
expect(queueInstance.close).toHaveBeenCalled();
});
it('falls back to a microtask and logs processor failures', async () => {
const service = new AppQueueService(config, redis, logger);
const success = jest.fn().mockResolvedValue(undefined);
service.registerProcessor('success', success);
await service.enqueue('success', { ok: true });
await new Promise((resolve) => setImmediate(resolve));
expect(success).toHaveBeenCalledWith({ ok: true });
const failed = jest.fn().mockRejectedValue(new Error('offline'));
service.registerProcessor('failed', failed);
await service.enqueue('failed', { id: 4 });
await new Promise((resolve) => setImmediate(resolve));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ jobName: 'failed', error: 'offline' }), expect.any(String), 'AppQueueService');
await expect(service.enqueue('unknown', {})).resolves.toBeUndefined();
});
it('aborts startup when either Redis connection cannot be created', () => {
values['queue.enabled'] = true;
(redis.isEnabled as jest.Mock).mockReturnValue(true);
(redis.createQueueClient as jest.Mock).mockReturnValueOnce({}).mockReturnValueOnce(null);
new AppQueueService(config, redis, logger).onApplicationBootstrap();
expect(Queue).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,86 @@
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { RedisService } from './redis.service';
jest.mock('ioredis', () => ({ __esModule: true, default: jest.fn() }));
describe('RedisService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const clients: Array<{ duplicate: jest.Mock; quit: jest.Mock; disconnect: jest.Mock }> = [];
beforeEach(() => {
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
clients.length = 0;
jest.mocked(Redis).mockImplementation(() => {
const duplicateClient = { quit: jest.fn(), disconnect: jest.fn() };
const client = {
duplicate: jest.fn(() => duplicateClient),
quit: jest.fn().mockResolvedValue('OK'),
disconnect: jest.fn(),
};
clients.push(client);
return client as never;
});
});
it('stays inert when disabled and exposes default prefix', () => {
const service = new RedisService(config);
expect(service.isEnabled()).toBe(false);
expect(service.getKeyPrefix()).toBe('oudelaa');
expect(service.getClient()).toBeNull();
expect(service.createPubSubClients()).toBeNull();
expect(service.createQueueClient()).toBeNull();
expect(Redis).not.toHaveBeenCalled();
});
it('creates one shared host-based client and independent queue/pubsub clients', () => {
Object.assign(values, {
'redis.enabled': true,
'redis.keyPrefix': 'test',
'redis.host': 'redis.internal',
'redis.port': 6380,
'redis.username': 'app',
'redis.password': 'secret',
'redis.db': 2,
});
const service = new RedisService(config);
expect(service.getKeyPrefix()).toBe('test');
expect(service.getClient()).toBe(service.getClient());
expect(Redis).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
host: 'redis.internal', port: 6380, username: 'app', password: 'secret', db: 2,
maxRetriesPerRequest: null, lazyConnect: false, enableReadyCheck: true,
}),
);
const pair = service.createPubSubClients();
expect(pair?.pubClient).toBeDefined();
expect(pair?.subClient).toBeDefined();
expect(clients[1].duplicate).toHaveBeenCalled();
expect(service.createQueueClient()).toBeDefined();
});
it('prefers a URL and omits host credentials', () => {
Object.assign(values, { 'redis.enabled': true, 'redis.url': 'rediss://redis.example/1' });
const service = new RedisService(config);
service.getClient();
expect(Redis).toHaveBeenCalledWith('rediss://redis.example/1', {
maxRetriesPerRequest: null,
lazyConnect: false,
enableReadyCheck: true,
});
});
it('quits the shared client and falls back to disconnect on failure', async () => {
values['redis.enabled'] = true;
const service = new RedisService(config);
service.getClient();
clients[0].quit.mockRejectedValue(new Error('closed'));
service.onModuleDestroy();
await new Promise((resolve) => setImmediate(resolve));
expect(clients[0].disconnect).toHaveBeenCalled();
expect(service.getClient()).not.toBe(clients[0]);
});
});
+2 -1
View File
@@ -48,7 +48,8 @@ export class RedisService implements OnModuleDestroy {
onModuleDestroy(): void {
if (this.client) {
void this.client.quit().catch(() => this.client?.disconnect());
const client = this.client;
void client.quit().catch(() => client.disconnect());
this.client = null;
}
}
@@ -0,0 +1,31 @@
import { ConfigService } from '@nestjs/config';
import { ConfigurableHttpServer, configureHttpServer } from './http-server.config';
describe('configureHttpServer', () => {
it('applies transport settings from application configuration', () => {
const values: Record<string, number> = {
'reliability.serverRequestTimeoutMs': 90_000,
'reliability.headersTimeoutMs': 12_000,
'reliability.keepAliveTimeoutMs': 4_000,
'reliability.maxRequestsPerSocket': 500,
};
const config = {
get: jest.fn((key: string) => values[key]),
} as unknown as ConfigService;
const server: ConfigurableHttpServer = {
requestTimeout: 0,
headersTimeout: 0,
keepAliveTimeout: 0,
maxRequestsPerSocket: 0,
};
configureHttpServer(server, config);
expect(server).toEqual({
requestTimeout: 90_000,
headersTimeout: 12_000,
keepAliveTimeout: 4_000,
maxRequestsPerSocket: 500,
});
});
});
@@ -0,0 +1,22 @@
import { ConfigService } from '@nestjs/config';
export type ConfigurableHttpServer = {
requestTimeout: number;
headersTimeout: number;
keepAliveTimeout: number;
maxRequestsPerSocket: number;
};
export const configureHttpServer = (
server: ConfigurableHttpServer,
configService: ConfigService,
): void => {
server.requestTimeout =
configService.get<number>('reliability.serverRequestTimeoutMs', { infer: true }) ?? 120_000;
server.headersTimeout =
configService.get<number>('reliability.headersTimeoutMs', { infer: true }) ?? 15_000;
server.keepAliveTimeout =
configService.get<number>('reliability.keepAliveTimeoutMs', { infer: true }) ?? 5_000;
server.maxRequestsPerSocket =
configService.get<number>('reliability.maxRequestsPerSocket', { infer: true }) ?? 1_000;
};
@@ -0,0 +1,43 @@
import { validationSchema } from '../../config/validation.schema';
describe('reliability environment validation', () => {
const validEnvironment = {
NODE_ENV: 'test',
MONGODB_URI: 'mongodb://127.0.0.1:27017/test',
JWT_ACCESS_SECRET: 'access-secret-at-least-16',
JWT_REFRESH_SECRET: 'refresh-secret-at-least-16',
SUPERADMIN_EMAIL: 'admin@example.com',
SUPERADMIN_PASSWORD: 'StrongPassword123!',
SUPERADMIN_ACCESS_SECRET: 'admin-access-secret-at-least-16',
SUPERADMIN_REFRESH_SECRET: 'admin-refresh-secret-at-least-16',
STORAGE_PROVIDER: 'local',
MEDIA_ACCESS_MODE: 'direct',
};
it('applies safe reliability defaults', () => {
const { error, value } = validationSchema.validate(validEnvironment);
expect(error).toBeUndefined();
expect(value).toEqual(
expect.objectContaining({
HTTP_REQUEST_TIMEOUT_MS: 30_000,
HTTP_SERVER_REQUEST_TIMEOUT_MS: 120_000,
HTTP_HEADERS_TIMEOUT_MS: 15_000,
HTTP_KEEP_ALIVE_TIMEOUT_MS: 5_000,
SHUTDOWN_GRACE_PERIOD_MS: 30_000,
}),
);
});
it('rejects unsafe or contradictory timeout settings', () => {
expect(
validationSchema.validate({ ...validEnvironment, HTTP_REQUEST_TIMEOUT_MS: 500 }).error,
).toBeDefined();
expect(
validationSchema.validate({
...validEnvironment,
HTTP_HEADERS_TIMEOUT_MS: 4_000,
HTTP_KEEP_ALIVE_TIMEOUT_MS: 5_000,
}).error,
).toBeDefined();
});
});
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { RequestTimeoutInterceptor } from './request-timeout.interceptor';
import { ShutdownCoordinatorService } from './shutdown-coordinator.service';
@Global()
@Module({
providers: [
ShutdownCoordinatorService,
{ provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor },
],
exports: [ShutdownCoordinatorService],
})
export class ReliabilityModule {}
@@ -0,0 +1,75 @@
import { CallHandler, ExecutionContext, RequestTimeoutException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { firstValueFrom, of, timer } from 'rxjs';
import { RequestTimeoutInterceptor } from './request-timeout.interceptor';
describe('RequestTimeoutInterceptor', () => {
const createContext = (url = '/api/v1/feed/me') =>
({
getType: () => 'http',
getHandler: () => createContext,
getClass: () => RequestTimeoutInterceptor,
switchToHttp: () => ({ getRequest: () => ({ originalUrl: url }) }),
}) as unknown as ExecutionContext;
const createCoordinator = (draining = false) => ({
isDraining: jest.fn().mockReturnValue(draining),
beginRequest: jest.fn().mockReturnValue(!draining),
endRequest: jest.fn(),
});
const createInterceptor = (options: { timeoutMs?: number; override?: number; draining?: boolean }) => {
const config = {
get: jest.fn().mockReturnValue(options.timeoutMs ?? 10),
} as unknown as ConfigService;
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(options.override),
} as unknown as Reflector;
const coordinator = createCoordinator(options.draining);
return {
coordinator,
interceptor: new RequestTimeoutInterceptor(config, reflector, coordinator as any),
};
};
it('returns the response and releases the in-flight request', async () => {
const { interceptor, coordinator } = createInterceptor({ timeoutMs: 50 });
const handler: CallHandler = { handle: () => of({ ok: true }) };
await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).resolves.toEqual({
ok: true,
});
expect(coordinator.beginRequest).toHaveBeenCalledTimes(1);
expect(coordinator.endRequest).toHaveBeenCalledTimes(1);
});
it('turns a slow handler into HTTP 408 and releases the request', async () => {
const { interceptor, coordinator } = createInterceptor({ timeoutMs: 5 });
const handler: CallHandler = { handle: () => timer(30) };
await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).rejects.toBeInstanceOf(
RequestTimeoutException,
);
expect(coordinator.endRequest).toHaveBeenCalledTimes(1);
});
it('allows a route-level zero override to disable the timeout', async () => {
const { interceptor } = createInterceptor({ timeoutMs: 5, override: 0 });
const handler: CallHandler = { handle: () => timer(15) };
await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).resolves.toBe(0);
});
it('rejects new work while draining but still allows readiness checks', async () => {
const { interceptor } = createInterceptor({ draining: true });
const handler: CallHandler = { handle: () => of({ status: 'draining' }) };
await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).rejects.toMatchObject({
status: 503,
});
await expect(
firstValueFrom(interceptor.intercept(createContext('/api/v1/health/ready'), handler)),
).resolves.toEqual({ status: 'draining' });
});
});
@@ -0,0 +1,91 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
RequestTimeoutException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { Observable, TimeoutError, catchError, finalize, throwError, timeout } from 'rxjs';
import { REQUEST_TIMEOUT_METADATA } from '../../common/decorators/request-timeout.decorator';
import { ShutdownCoordinatorService } from './shutdown-coordinator.service';
type HttpRequest = {
originalUrl?: string;
url?: string;
};
@Injectable()
export class RequestTimeoutInterceptor implements NestInterceptor {
constructor(
private readonly configService: ConfigService,
private readonly reflector: Reflector,
private readonly shutdownCoordinator: ShutdownCoordinatorService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
if (context.getType() !== 'http') {
return next.handle();
}
const request = context.switchToHttp().getRequest<HttpRequest>();
const healthRequest = this.isHealthRequest(request.originalUrl ?? request.url ?? '');
if (this.shutdownCoordinator.isDraining()) {
if (healthRequest) {
return next.handle();
}
return throwError(
() =>
new ServiceUnavailableException({
statusCode: 503,
error: 'Service Unavailable',
message: 'Server is shutting down; retry the request on another instance',
}),
);
}
if (!this.shutdownCoordinator.beginRequest()) {
return throwError(() => new ServiceUnavailableException('Server is shutting down'));
}
const timeoutOverride = this.reflector.getAllAndOverride<number>(REQUEST_TIMEOUT_METADATA, [
context.getHandler(),
context.getClass(),
]);
const timeoutMs =
timeoutOverride ??
this.configService.get<number>('reliability.requestTimeoutMs', { infer: true }) ??
30_000;
const response$ = next.handle();
if (timeoutMs <= 0) {
return response$.pipe(finalize(() => this.shutdownCoordinator.endRequest()));
}
return response$.pipe(
timeout({ first: timeoutMs }),
catchError((error: unknown) => {
if (error instanceof TimeoutError) {
return throwError(
() =>
new RequestTimeoutException({
statusCode: 408,
error: 'Request Timeout',
message: `Request did not complete within ${timeoutMs}ms`,
}),
);
}
return throwError(() => error);
}),
finalize(() => this.shutdownCoordinator.endRequest()),
);
}
private isHealthRequest(url: string): boolean {
const path = url.split('?', 1)[0].replace(/\/+$/, '');
return /\/health(?:\/ready)?$/.test(path);
}
}
@@ -0,0 +1,35 @@
import { ConfigService } from '@nestjs/config';
import { AppLoggerService } from '../logging/app-logger.service';
import { ShutdownCoordinatorService } from './shutdown-coordinator.service';
describe('ShutdownCoordinatorService', () => {
const createService = () => {
const config = { get: jest.fn().mockReturnValue(25) } as unknown as ConfigService;
const logger = { warn: jest.fn(), log: jest.fn() } as unknown as AppLoggerService;
return { logger, service: new ShutdownCoordinatorService(config, logger) };
};
it('tracks active requests without allowing the counter below zero', () => {
const { service } = createService();
expect(service.beginRequest()).toBe(true);
expect(service.getActiveRequests()).toBe(1);
service.endRequest();
service.endRequest();
expect(service.getActiveRequests()).toBe(0);
});
it('enters drain mode before shutdown and refuses new requests', async () => {
const { logger, service } = createService();
await service.beforeApplicationShutdown('SIGTERM');
expect(service.getState()).toBe('draining');
expect(service.beginRequest()).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('SIGTERM'),
'ShutdownCoordinator',
);
service.onApplicationShutdown('SIGTERM');
expect(service.getState()).toBe('stopped');
});
});
@@ -0,0 +1,79 @@
import {
BeforeApplicationShutdown,
Injectable,
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { setTimeout as delay } from 'timers/promises';
import { AppLoggerService } from '../logging/app-logger.service';
export type ApplicationLifecycleState = 'running' | 'draining' | 'stopped';
@Injectable()
export class ShutdownCoordinatorService
implements BeforeApplicationShutdown, OnApplicationShutdown
{
private state: ApplicationLifecycleState = 'running';
private activeRequests = 0;
constructor(
private readonly configService: ConfigService,
private readonly logger: AppLoggerService,
) {}
getState(): ApplicationLifecycleState {
return this.state;
}
isDraining(): boolean {
return this.state !== 'running';
}
getActiveRequests(): number {
return this.activeRequests;
}
beginRequest(): boolean {
if (this.state !== 'running') {
return false;
}
this.activeRequests += 1;
return true;
}
endRequest(): void {
this.activeRequests = Math.max(0, this.activeRequests - 1);
}
async beforeApplicationShutdown(signal?: string): Promise<void> {
this.state = 'draining';
const gracePeriodMs =
this.configService.get<number>('reliability.shutdownGracePeriodMs', { infer: true }) ??
30_000;
const deadline = Date.now() + gracePeriodMs;
this.logger.warn(
`Shutdown initiated${signal ? ` by ${signal}` : ''}; draining ${this.activeRequests} active request(s) for up to ${gracePeriodMs}ms`,
'ShutdownCoordinator',
);
while (this.activeRequests > 0 && Date.now() < deadline) {
await delay(Math.min(50, Math.max(1, deadline - Date.now())));
}
if (this.activeRequests > 0) {
this.logger.warn(
`Grace period elapsed with ${this.activeRequests} active request(s) still running`,
'ShutdownCoordinator',
);
}
}
onApplicationShutdown(signal?: string): void {
this.state = 'stopped';
this.logger.log(
`Shutdown complete${signal ? ` (${signal})` : ''}`,
'ShutdownCoordinator',
);
}
}
@@ -0,0 +1,43 @@
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { RedisService } from '../redis/redis.service';
import { RedisIoAdapter } from './redis-io.adapter';
jest.mock('@socket.io/redis-adapter', () => ({ createAdapter: jest.fn(() => 'redis-adapter') }));
describe('RedisIoAdapter', () => {
const redis = { createPubSubClients: jest.fn() } as unknown as RedisService;
let createServer: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
createServer = jest.spyOn(IoAdapter.prototype, 'createIOServer').mockReturnValue({ adapter: jest.fn() });
});
afterEach(() => createServer.mockRestore());
it('uses the default Socket.IO adapter without Redis clients', async () => {
(redis.createPubSubClients as jest.Mock).mockReturnValue(null);
const adapter = new RedisIoAdapter({} as never, redis);
await adapter.connectToRedis();
const server = adapter.createIOServer(4000, { path: '/socket' } as never);
expect(createAdapter).not.toHaveBeenCalled();
expect(server.adapter).not.toHaveBeenCalled();
});
it('attaches Redis adapter and closes both clients safely', async () => {
const pubClient = { quit: jest.fn().mockResolvedValue('OK'), disconnect: jest.fn() };
const subClient = { quit: jest.fn().mockRejectedValue(new Error('closed')), disconnect: jest.fn() };
(redis.createPubSubClients as jest.Mock).mockReturnValue({ pubClient, subClient });
const adapter = new RedisIoAdapter({} as never, redis);
await adapter.connectToRedis();
const server = adapter.createIOServer(4000);
expect(createAdapter).toHaveBeenCalledWith(pubClient, subClient);
expect(server.adapter).toHaveBeenCalledWith('redis-adapter');
await adapter.close();
expect(pubClient.quit).toHaveBeenCalled();
expect(subClient.disconnect).toHaveBeenCalled();
await adapter.close();
expect(pubClient.quit).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,88 @@
import { ConfigService } from '@nestjs/config';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { ImageProcessingService } from './image-processing.service';
jest.mock('fs/promises', () => ({ mkdtemp: jest.fn(), readFile: jest.fn(), rm: jest.fn(), writeFile: jest.fn() }));
type ImageInternals = {
ensureFfmpegAvailable(): Promise<boolean>;
runFfmpeg(args: string[]): Promise<void>;
};
const internals = (service: ImageProcessingService): ImageInternals =>
service as unknown as ImageInternals;
describe('ImageProcessingService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
beforeEach(() => {
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
(mkdtemp as jest.Mock).mockResolvedValue('/tmp/image-work');
(writeFile as jest.Mock).mockResolvedValue(undefined);
(readFile as jest.Mock).mockImplementation(async (path: string) => Buffer.from(path));
(rm as jest.Mock).mockResolvedValue(undefined);
});
it.each([
[{ originalname: 'photo.PNG', mimetype: '', size: 1, buffer: Buffer.from('x') }, '.png', 'image/png'],
[{ mimetype: 'image/webp', size: 1, buffer: Buffer.from('x') }, '.webp', 'image/webp'],
[{ mimetype: 'image/gif', size: 1, buffer: Buffer.from('x') }, '.gif', 'image/gif'],
[{ mimetype: 'unknown', size: 1, buffer: Buffer.from('x') }, '.jpg', 'unknown'],
])('returns original when disabled %#', async (file, extension, contentType) => {
const result = await new ImageProcessingService(config).processForResponsiveDelivery(file);
expect(result).toEqual({ primaryVariantName: 'original', variants: [expect.objectContaining({ relativePath: `original${extension}`, contentType })] });
});
it('falls back when ffmpeg is unavailable', async () => {
values['imageProcessing.enabled'] = true;
const service = new ImageProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(false);
const result = await service.processForResponsiveDelivery({ originalname: 'x.jpg', mimetype: 'image/jpeg', size: 1, buffer: Buffer.from('x') });
expect(result.primaryVariantName).toBe('original');
expect(mkdtemp).not.toHaveBeenCalled();
});
it('creates low, medium, and high WebP variants', async () => {
Object.assign(values, {
'imageProcessing.enabled': true, 'imageProcessing.lowWidth': 320,
'imageProcessing.mediumWidth': 640, 'imageProcessing.highWidth': 1024,
'imageProcessing.quality': 70,
});
const service = new ImageProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
const ffmpeg = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
const result = await service.processForResponsiveDelivery({ originalname: 'x.jpg', mimetype: 'image/jpeg', size: 1, buffer: Buffer.from('x') });
expect(result.primaryVariantName).toBe('medium');
expect(result.variants.map((item) => item.name)).toEqual(['original', 'low', 'medium', 'high']);
expect(ffmpeg).toHaveBeenCalledTimes(3);
expect(ffmpeg).toHaveBeenCalledWith(expect.arrayContaining([expect.stringContaining('min(320,iw)'), '70']));
expect(rm).toHaveBeenCalledWith('/tmp/image-work', { recursive: true, force: true });
});
it('returns the original and cleans up after conversion errors', async () => {
values['videoProcessing.enabled'] = true;
const service = new ImageProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
(writeFile as jest.Mock).mockRejectedValue(new Error('disk full'));
const result = await service.processForResponsiveDelivery({ size: 1, buffer: Buffer.from('x') });
expect(result.primaryVariantName).toBe('original');
expect(rm).toHaveBeenCalled();
});
it('probes ffmpeg once and caches both success and failure', async () => {
values['imageProcessing.ffmpegPath'] = ' custom-ffmpeg ';
const success = new ImageProcessingService(config);
const successRun = jest.spyOn(internals(success), 'runFfmpeg').mockResolvedValue(undefined);
await expect((success as never as { ensureFfmpegAvailable(): Promise<boolean> }).ensureFfmpegAvailable()).resolves.toBe(true);
await (success as never as { ensureFfmpegAvailable(): Promise<boolean> }).ensureFfmpegAvailable();
expect(successRun).toHaveBeenCalledTimes(1);
const failure = new ImageProcessingService(config);
const failRun = jest.spyOn(internals(failure), 'runFfmpeg').mockRejectedValue('missing');
await expect((failure as never as { ensureFfmpegAvailable(): Promise<boolean> }).ensureFfmpegAvailable()).resolves.toBe(false);
await (failure as never as { ensureFfmpegAvailable(): Promise<boolean> }).ensureFfmpegAvailable();
expect(failRun).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,190 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises';
import { MediaStorageService } from '../../common/media/media-storage.service';
import { ManagedStorageService } from './managed-storage.service';
jest.mock('fs/promises', () => ({
access: jest.fn(), mkdir: jest.fn(), rm: jest.fn(), stat: jest.fn(), unlink: jest.fn(), writeFile: jest.fn(),
}));
type ManagedInternals = {
deleteS3Prefix(prefix: string): Promise<void>;
getS3Bucket(): string;
getS3Client(): { send(command: unknown): Promise<unknown> };
resolveCacheControl(key: string): string;
};
const internals = (service: ManagedStorageService): ManagedInternals =>
service as unknown as ManagedInternals;
describe('ManagedStorageService', () => {
const values: Record<string, unknown> = {
'storage.provider': 'local', 'storage.basePath': 'uploads', publicBaseUrl: 'https://api.test',
};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const media = {
uploadFile: jest.fn(), uploadFileToKey: jest.fn(), deleteFile: jest.fn(),
} as unknown as MediaStorageService;
beforeEach(() => {
jest.clearAllMocks();
Object.assign(values, {
'storage.provider': 'local', 'storage.basePath': 'uploads', publicBaseUrl: 'https://api.test',
'storage.publicBaseUrl': '', 'storage.s3.bucket': '', 'storage.s3.endpoint': '',
'storage.s3.accessKeyId': '', 'storage.s3.secretAccessKey': '',
'storage.s3.forcePathStyle': false, 'storage.s3.healthWriteTestEnabled': false,
});
(mkdir as jest.Mock).mockResolvedValue(undefined);
(writeFile as jest.Mock).mockResolvedValue(undefined);
(unlink as jest.Mock).mockResolvedValue(undefined);
(rm as jest.Mock).mockResolvedValue(undefined);
(stat as jest.Mock).mockResolvedValue({});
(access as jest.Mock).mockResolvedValue(undefined);
});
it('saves one or many files locally with normalized managed paths', async () => {
const service = new ManagedStorageService(config, media);
const one = await service.saveFile({
folderSegments: ['/posts\\images/', 'user-1'], extension: '.jpg', buffer: Buffer.from('jpg'),
contentType: 'image/jpeg', fileNamePrefix: 'photo',
});
expect(one).toMatch(/^\/uploads\/posts\/images\/user-1\/photo-[0-9a-f-]+\.jpg$/);
expect(writeFile).toHaveBeenCalledWith(expect.stringContaining('photo-'), Buffer.from('jpg'));
await expect(service.saveFiles({ folderSegments: [], files: [] })).resolves.toEqual({});
await expect(service.saveFiles({
folderSegments: ['hls'],
files: [
{ relativePath: 'master.m3u8', buffer: Buffer.from('playlist') },
{ relativePath: 'stream_0\\segment.m4s', buffer: Buffer.from('segment') },
],
})).resolves.toEqual({
'master.m3u8': '/uploads/hls/master.m3u8',
'stream_0/segment.m4s': '/uploads/hls/stream_0/segment.m4s',
});
expect(mkdir).toHaveBeenCalled();
});
it.each(['', '.', '../secret', 'a//b'])('rejects unsafe relative file path %p', async (relativePath) => {
const service = new ManagedStorageService(config, media);
await expect(service.saveFiles({ folderSegments: [], files: [{ relativePath, buffer: Buffer.alloc(1) }] }))
.rejects.toBeInstanceOf(BadRequestException);
});
it('delegates S3 writes and returns uploaded URLs', async () => {
values['storage.provider'] = 's3';
(media.uploadFile as jest.Mock).mockResolvedValue({ url: 'https://cdn/one.jpg' });
(media.uploadFileToKey as jest.Mock).mockResolvedValue({ url: 'https://cdn/master.m3u8' });
const service = new ManagedStorageService(config, media);
await expect(service.saveFile({ folderSegments: ['posts'], extension: '.jpg', buffer: Buffer.from('x') }))
.resolves.toBe('https://cdn/one.jpg');
expect(media.uploadFile).toHaveBeenCalledWith(expect.objectContaining({ size: 1 }), 'posts');
await expect(service.saveFiles({ folderSegments: ['hls'], files: [{ relativePath: 'master.m3u8', buffer: Buffer.from('x') }] }))
.resolves.toEqual({ 'master.m3u8': 'https://cdn/master.m3u8' });
expect(media.uploadFileToKey).toHaveBeenCalledWith(expect.anything(), 'uploads/hls/master.m3u8');
});
it('deletes and resolves only safe local managed paths', async () => {
const service = new ManagedStorageService(config, media);
await service.deleteFile();
await service.deleteFile('https://external/file.jpg');
await service.deleteFile('/uploads/../secret');
expect(unlink).not.toHaveBeenCalled();
await service.deleteFile('/uploads/posts/file.jpg?token=one');
expect(unlink).toHaveBeenCalledWith(expect.stringMatching(/uploads[\\/]posts[\\/]file\.jpg$/));
(unlink as jest.Mock).mockRejectedValueOnce(new Error('missing'));
await expect(service.deleteFile('/uploads/posts/missing.jpg')).resolves.toBeUndefined();
expect(service.resolveLocalFilePath('/uploads/posts/file.jpg')).toMatch(/uploads[\\/]posts[\\/]file\.jpg$/);
expect(service.resolveLocalFilePath('/other/file.jpg')).toBeNull();
values['storage.provider'] = 's3';
expect(service.resolveLocalFilePath('/uploads/posts/file.jpg')).toBeNull();
});
it('deletes safe containing directories locally without deleting the root', async () => {
const service = new ManagedStorageService(config, media);
await service.deleteContainingDirectory();
await service.deleteContainingDirectory('/uploads/file.jpg');
await service.deleteContainingDirectory('/uploads/../secret/file.jpg');
expect(rm).not.toHaveBeenCalled();
await service.deleteContainingDirectory('/uploads/posts/hls/master.m3u8');
expect(rm).toHaveBeenCalledWith(expect.stringMatching(/uploads[\\/]posts[\\/]hls$/), { recursive: true, force: true });
});
it('deletes S3 objects and containing prefixes resolved from public and endpoint URLs', async () => {
Object.assign(values, {
'storage.provider': 's3', 'storage.publicBaseUrl': 'https://cdn.test/',
'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test',
'storage.s3.forcePathStyle': true,
});
const service = new ManagedStorageService(config, media);
await service.deleteFile('https://cdn.test/uploads/posts/file.jpg?x=1');
expect(media.deleteFile).toHaveBeenCalledWith('uploads/posts/file.jpg');
const deletePrefix = jest.spyOn(internals(service), 'deleteS3Prefix').mockResolvedValue(undefined);
await service.deleteContainingDirectory('https://cdn.test/uploads/posts/hls/master.m3u8');
expect(deletePrefix).toHaveBeenCalledWith('uploads/posts/hls/');
await service.deleteContainingDirectory('https://cdn.test/uploads/file.jpg');
expect(deletePrefix).toHaveBeenCalledTimes(1);
values['storage.publicBaseUrl'] = '';
await service.deleteFile('https://s3.test/bucket/uploads/posts/two.jpg');
expect(media.deleteFile).toHaveBeenCalledWith('uploads/posts/two.jpg');
});
it('reports healthy and failed local probes', async () => {
const service = new ManagedStorageService(config, media);
await expect(service.getHealth()).resolves.toEqual(expect.objectContaining({
provider: 'local', isLocalStorage: true, uploadPathExists: true,
uploadPathReadable: true, uploadPathWritable: true,
}));
(writeFile as jest.Mock).mockRejectedValueOnce(new Error('read only'));
(unlink as jest.Mock).mockRejectedValueOnce(new Error('not created'));
(stat as jest.Mock).mockRejectedValueOnce(new Error('missing'));
(access as jest.Mock).mockRejectedValueOnce(new Error('denied'));
const failed = await service.getHealth();
expect(failed).toEqual(expect.objectContaining({ uploadPathExists: false, uploadPathReadable: false, uploadPathWritable: false }));
expect(failed.local).toEqual(expect.objectContaining({ error: 'read only' }));
});
it('reports incomplete and reachable S3 health including write probes', async () => {
values['storage.provider'] = 's3';
const service = new ManagedStorageService(config, media);
expect(await service.getHealth()).toEqual(expect.objectContaining({ s3: expect.objectContaining({ configured: false, reachable: false }) }));
Object.assign(values, {
'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test',
'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
'storage.s3.healthWriteTestEnabled': true,
});
const client = { send: jest.fn().mockResolvedValue({}) };
jest.spyOn(internals(service), 'getS3Client').mockReturnValue(client);
const healthy = await service.getHealth();
expect(healthy.s3).toEqual(expect.objectContaining({ configured: true, reachable: true, writable: true }));
expect(client.send).toHaveBeenCalledTimes(3);
client.send.mockRejectedValueOnce(new Error('unreachable'));
const unhealthy = await service.getHealth();
expect(unhealthy.s3).toEqual(expect.objectContaining({ reachable: false, writable: false, error: 'unreachable' }));
service.onModuleDestroy();
});
it('validates S3 setup and paginates prefix deletion', async () => {
values['storage.provider'] = 's3';
const service = new ManagedStorageService(config, media);
expect(() => internals(service).getS3Bucket()).toThrow(BadRequestException);
expect(() => internals(service).getS3Client()).toThrow(BadRequestException);
Object.assign(values, { 'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test', 'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret' });
const client = { send: jest.fn()
.mockResolvedValueOnce({ Contents: [{ Key: 'uploads/a' }, {}], IsTruncated: true, NextContinuationToken: 'next' })
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ Contents: [{ Key: 'uploads/b' }], IsTruncated: false })
.mockResolvedValueOnce({}) };
jest.spyOn(internals(service), 'getS3Client').mockReturnValue(client);
await internals(service).deleteS3Prefix('uploads/posts/');
expect(client.send.mock.calls.filter(([command]) => command instanceof ListObjectsV2Command)).toHaveLength(2);
expect(client.send.mock.calls.filter(([command]) => command instanceof DeleteObjectsCommand)).toHaveLength(2);
expect(internals(service).resolveCacheControl('master.m3u8')).toBe('public, max-age=300');
expect(internals(service).resolveCacheControl('file.jpg')).toContain('immutable');
});
});
@@ -239,14 +239,11 @@ export class ManagedStorageService implements OnModuleDestroy {
const exists = await this.pathExists(uploadDir);
const readable = await this.canAccess(uploadDir, constants.R_OK);
health.local = {
runtimePath: uploadDir,
absolutePath: uploadDir,
exists,
writable,
readable,
error,
};
health.absolutePath = uploadDir;
health.uploadPathExists = exists;
health.uploadPathReadable = readable;
health.uploadPathWritable = writable;
@@ -0,0 +1,142 @@
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { MediaProbeService } from './media-probe.service';
jest.mock('child_process', () => ({ spawn: jest.fn() }));
jest.mock('fs/promises', () => ({ mkdtemp: jest.fn(), rm: jest.fn(), writeFile: jest.fn() }));
type FakeChild = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: jest.Mock;
};
const fakeChild = (): FakeChild => {
const child = new EventEmitter() as FakeChild;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = jest.fn();
return child;
};
type ProbeInternals = {
runCommand(command: string, args: string[], timeout: number): Promise<string>;
};
const internals = (service: MediaProbeService): ProbeInternals =>
service as unknown as ProbeInternals;
describe('MediaProbeService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
beforeEach(() => {
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
(mkdtemp as jest.Mock).mockResolvedValue('/tmp/media-probe');
(writeFile as jest.Mock).mockResolvedValue(undefined);
(rm as jest.Mock).mockResolvedValue(undefined);
});
it('derives ffprobe beside configured ffmpeg on Windows and Unix', () => {
values['videoProcessing.ffmpegPath'] = ' C:\\ffmpeg\\ffmpeg.exe ';
expect(new MediaProbeService(config).getFfprobePath()).toBe('C:\\ffmpeg\\ffprobe.exe');
values['videoProcessing.ffmpegPath'] = '/usr/local/bin/ffmpeg';
expect(new MediaProbeService(config).getFfprobePath()).toBe('/usr/local/bin/ffprobe');
values['videoProcessing.ffprobePath'] = ' custom-probe ';
expect(new MediaProbeService(config).getFfprobePath()).toBe('custom-probe');
values['videoProcessing.ffprobePath'] = '';
values['videoProcessing.ffmpegPath'] = 'wrapper';
expect(new MediaProbeService(config).getFfprobePath()).toBe('ffprobe');
});
it('caches successful and failed command probes', async () => {
const success = new MediaProbeService(config);
const run = jest.spyOn(internals(success), 'runCommand').mockResolvedValue('ffmpeg version 7\nother');
await expect(success.checkFfmpeg()).resolves.toEqual({ path: 'ffmpeg', available: true, version: 'ffmpeg version 7' });
await success.checkFfmpeg();
expect(run).toHaveBeenCalledTimes(1);
const failure = new MediaProbeService(config);
const fail = jest.spyOn(internals(failure), 'runCommand').mockRejectedValue('not installed');
await expect(failure.checkFfprobe()).resolves.toEqual({ path: 'ffprobe', available: false, version: '', error: 'unknown command error' });
await failure.checkFfprobe();
expect(fail).toHaveBeenCalledTimes(1);
});
it('extracts rounded positive durations and rejects unusable output', async () => {
const service = new MediaProbeService(config);
jest.spyOn(service, 'checkFfprobe').mockResolvedValue({ path: 'probe', available: true, version: '1' });
const run = jest.spyOn(internals(service), 'runCommand');
run.mockResolvedValueOnce('12.6\n').mockResolvedValueOnce('0').mockResolvedValueOnce('NaN').mockRejectedValueOnce(new Error('bad media'));
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBe(13);
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
jest.spyOn(service, 'checkFfprobe').mockResolvedValue({ path: 'probe', available: false, version: '' });
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
});
it.each([
['clip.mp4', undefined, '.mp4'], [undefined, 'video/quicktime', '.mov'],
[undefined, 'video/webm', '.webm'], [undefined, 'audio/mpeg', '.mp3'],
[undefined, 'audio/x-m4a', '.m4a'], [undefined, 'audio/x-wav', '.wav'],
[undefined, 'audio/aac', '.aac'], [undefined, 'audio/ogg', '.ogg'],
[undefined, 'other', '.media'],
])('uses safe buffer extension for %p/%p', async (originalname, mimetype, expected) => {
const service = new MediaProbeService(config);
jest.spyOn(service, 'extractDurationSeconds').mockResolvedValue(8);
await expect(service.extractDurationSecondsFromBuffer(Buffer.from('content'), { originalname, mimetype })).resolves.toBe(8);
expect(writeFile).toHaveBeenCalledWith(expect.stringContaining(expected), Buffer.from('content'));
expect(rm).toHaveBeenCalledWith('/tmp/media-probe', { recursive: true, force: true });
});
it('handles empty buffers and temporary file failures', async () => {
const service = new MediaProbeService(config);
await expect(service.extractDurationSecondsFromBuffer(Buffer.alloc(0))).resolves.toBeNull();
expect(mkdtemp).not.toHaveBeenCalled();
(writeFile as jest.Mock).mockRejectedValue(new Error('disk full'));
await expect(service.extractDurationSecondsFromBuffer(Buffer.from('x'), { originalname: 'bad.mp4' })).resolves.toBeNull();
expect(rm).toHaveBeenCalled();
});
it('runs commands and handles output, process errors, and nonzero exits', async () => {
const service = new MediaProbeService(config);
const invoke = internals(service).runCommand.bind(service);
const success = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(success);
const successPromise = invoke('probe', ['-version'], 100);
success.stdout.emit('data', Buffer.from('version'));
success.stderr.emit('data', Buffer.from('warning'));
success.emit('close', 0);
await expect(successPromise).resolves.toBe('version');
const failed = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(failed);
const failedPromise = invoke('probe', [], 100);
failed.stderr.emit('data', Buffer.from('invalid'));
failed.emit('close', 2);
await expect(failedPromise).rejects.toThrow('invalid');
const errored = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(errored);
const errorPromise = invoke('probe', [], 100);
errored.emit('error', new Error('spawn error'));
await expect(errorPromise).rejects.toThrow('spawn error');
});
it('kills commands that exceed their timeout', async () => {
jest.useFakeTimers();
const service = new MediaProbeService(config);
const child = fakeChild();
(spawn as jest.Mock).mockReturnValue(child);
const promise = internals(service).runCommand('slow', [], 10);
jest.advanceTimersByTime(11);
await expect(promise).rejects.toThrow('timed out after 10ms');
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
jest.useRealTimers();
});
});
@@ -0,0 +1,271 @@
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises';
import { VideoProcessingService } from './video-processing.service';
jest.mock('child_process', () => ({ spawn: jest.fn() }));
jest.mock('fs/promises', () => ({
mkdir: jest.fn(), mkdtemp: jest.fn(), readFile: jest.fn(), readdir: jest.fn(), rm: jest.fn(), writeFile: jest.fn(),
}));
type Internals = {
ensureFfmpegAvailable(): Promise<boolean>;
runFfmpeg(args: string[]): Promise<void>;
runCommand(command: string, args: string[]): Promise<string>;
generateHlsPackage(input: string, output: string): Promise<any>;
generateSingleRenditionHlsPackage(input: string, output: string): Promise<any>;
buildAdaptiveHlsFilterComplex(renditions: Array<{ width: number }>): string;
buildHlsRenditions(width: number): Array<Record<string, number>>;
resolveHlsRendition(width: number): Record<string, number>;
probeVideo(path: string): Promise<{ hasAudio: boolean; width: number }>;
readFilesRecursively(base: string, current?: string): Promise<any[]>;
resolveStreamingContentType(name: string): string;
resolveInputExtension(file: any): string;
buildOptimizedFileName(name?: string): string;
buildVideoFilter(): string;
buildThumbnailFilter(): string;
getFfprobePath(): string;
};
const internals = (service: VideoProcessingService): Internals => service as unknown as Internals;
type FakeChild = EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
const fakeChild = (): FakeChild => {
const child = new EventEmitter() as FakeChild;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
return child;
};
describe('VideoProcessingService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const file = { originalname: 'concert.mov', mimetype: 'video/quicktime', size: 5, buffer: Buffer.from('video') };
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
(mkdtemp as jest.Mock).mockResolvedValue('/tmp/video-work');
(mkdir as jest.Mock).mockResolvedValue(undefined);
(writeFile as jest.Mock).mockResolvedValue(undefined);
(readFile as jest.Mock).mockImplementation(async (path: string) => Buffer.from(path.includes('thumbnail') ? 'thumb' : 'optimized'));
(rm as jest.Mock).mockResolvedValue(undefined);
});
it('returns uploads untouched when processing is disabled or ffmpeg unavailable', async () => {
const disabled = new VideoProcessingService(config);
await expect(disabled.optimizeForPlayback(file)).resolves.toEqual({ file });
expect(mkdtemp).not.toHaveBeenCalled();
values['videoProcessing.enabled'] = true;
const unavailable = new VideoProcessingService(config);
jest.spyOn(internals(unavailable), 'ensureFfmpegAvailable').mockResolvedValue(false);
await expect(unavailable.optimizeForPlayback(file)).resolves.toEqual({ file });
});
it('optimizes video and returns optional thumbnail and HLS assets', async () => {
Object.assign(values, {
'videoProcessing.enabled': true, 'videoProcessing.generateThumbnails': true,
'videoProcessing.generateHls': true, 'videoProcessing.maxWidth': 1080,
'videoProcessing.thumbnailWidth': 600, 'videoProcessing.maxFps': 24,
'videoProcessing.crf': 25, 'videoProcessing.preset': ' fast ',
'videoProcessing.audioBitrateKbps': 96,
});
const service = new VideoProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
const ffmpeg = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
const hls = { playlistRelativePath: 'master.m3u8', files: [{ relativePath: 'master.m3u8', buffer: Buffer.from('hls'), contentType: 'application/vnd.apple.mpegurl' }] };
jest.spyOn(internals(service), 'generateHlsPackage').mockResolvedValue(hls);
const result = await service.optimizeForPlayback(file);
expect(result.file).toEqual(expect.objectContaining({ mimetype: 'video/mp4', originalname: 'concert-optimized.mp4', size: 9 }));
expect(result.generatedThumbnail).toEqual({ buffer: Buffer.from('thumb'), extension: '.jpg', contentType: 'image/jpeg' });
expect(result.generatedHls).toEqual(hls);
expect(ffmpeg).toHaveBeenCalledTimes(2);
expect(ffmpeg.mock.calls[0][0]).toEqual(expect.arrayContaining(['-crf', '25', '-preset', 'fast', '-b:a', '96k']));
expect(ffmpeg.mock.calls[0][0]).toEqual(expect.arrayContaining([expect.stringContaining('min(1080,iw)')]));
expect(ffmpeg.mock.calls[1][0]).toEqual(expect.arrayContaining([expect.stringContaining('min(600,iw)')]));
expect(rm).toHaveBeenCalledWith('/tmp/video-work', { recursive: true, force: true });
});
it('keeps optimized output when thumbnail and HLS generation fail', async () => {
Object.assign(values, { 'videoProcessing.enabled': true, 'videoProcessing.generateThumbnails': true, 'videoProcessing.generateHls': true });
const service = new VideoProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValueOnce(undefined).mockRejectedValueOnce('thumbnail failed');
jest.spyOn(internals(service), 'generateHlsPackage').mockRejectedValue('HLS failed');
const result = await service.optimizeForPlayback(file);
expect(result.file.mimetype).toBe('video/mp4');
expect(result.generatedThumbnail).toBeUndefined();
expect(result.generatedHls).toBeUndefined();
});
it('falls back to original and cleans up after optimization errors', async () => {
values['videoProcessing.enabled'] = true;
const service = new VideoProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
jest.spyOn(internals(service), 'runFfmpeg').mockRejectedValue(new Error('codec failed'));
await expect(service.optimizeForPlayback(file)).resolves.toEqual({ file });
expect(rm).toHaveBeenCalled();
});
it('supports configured and inferred executable paths and upload extensions', () => {
const service = new VideoProcessingService(config);
values['videoProcessing.ffmpegPath'] = 'C:\\bin\\ffmpeg.exe';
expect(internals(service).getFfprobePath()).toBe('C:\\bin\\ffprobe.exe');
values['videoProcessing.ffprobePath'] = ' custom-ffprobe ';
expect(internals(service).getFfprobePath()).toBe('custom-ffprobe');
values['videoProcessing.ffprobePath'] = '';
values['videoProcessing.ffmpegPath'] = 'wrapper';
expect(internals(service).getFfprobePath()).toBe('ffprobe');
expect(internals(service).buildOptimizedFileName()).toBe('video-optimized.mp4');
expect(internals(service).buildOptimizedFileName('a.b.webm')).toBe('a.b-optimized.mp4');
expect(internals(service).resolveInputExtension({ originalname: 'A.MOV' })).toBe('.mov');
expect(internals(service).resolveInputExtension({ mimetype: 'video/webm' })).toBe('.webm');
expect(internals(service).resolveInputExtension({ mimetype: 'video/x-matroska' })).toBe('.mkv');
expect(internals(service).resolveInputExtension({ mimetype: 'video/x-msvideo' })).toBe('.avi');
expect(internals(service).resolveInputExtension({ mimetype: 'unknown' })).toBe('.mp4');
});
it('builds ordered adaptive rendition profiles and filters', () => {
values['videoProcessing.maxWidth'] = 1280;
values['videoProcessing.maxFps'] = 30;
const service = new VideoProcessingService(config);
expect(internals(service).buildHlsRenditions(200).map((item) => item.width)).toEqual([240]);
expect(internals(service).buildHlsRenditions(720).map((item) => item.width)).toEqual([480, 720]);
expect(internals(service).buildHlsRenditions(1920).map((item) => item.width)).toEqual([480, 720, 1280]);
expect(internals(service).resolveHlsRendition(480)).toEqual(expect.objectContaining({ videoBitrateKbps: 800, audioBitrateKbps: 96 }));
expect(internals(service).resolveHlsRendition(720)).toEqual(expect.objectContaining({ videoBitrateKbps: 1600 }));
expect(internals(service).resolveHlsRendition(1080)).toEqual(expect.objectContaining({ videoBitrateKbps: 2800 }));
expect(internals(service).buildAdaptiveHlsFilterComplex([{ width: 480 }])).toContain('[v0out]');
const adaptive = internals(service).buildAdaptiveHlsFilterComplex([{ width: 480 }, { width: 720 }]);
expect(adaptive).toContain('split=2[v0][v1]');
expect(adaptive).toContain('fps=30');
expect(internals(service).buildVideoFilter()).toContain('format=yuv420p');
expect(internals(service).buildThumbnailFilter()).toContain('force_original_aspect_ratio=decrease');
});
it('generates adaptive HLS with audio and gathers generated files', async () => {
Object.assign(values, { 'videoProcessing.maxWidth': 1280, 'videoProcessing.maxFps': 25, 'videoProcessing.hlsSegmentDurationSeconds': 6 });
const service = new VideoProcessingService(config);
jest.spyOn(internals(service), 'probeVideo').mockResolvedValue({ width: 1280, hasAudio: true });
const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
const generated = [{ relativePath: 'master.m3u8', buffer: Buffer.from('x'), contentType: 'application/vnd.apple.mpegurl' }];
jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue(generated);
await expect(internals(service).generateHlsPackage('/in.mp4', '/hls')).resolves.toEqual({ playlistRelativePath: 'master.m3u8', files: generated });
const args = run.mock.calls[0][0];
expect(args).toEqual(expect.arrayContaining(['-filter_complex', expect.stringContaining('split=3'), '-var_stream_map', 'v:0,a:0 v:1,a:1 v:2,a:2']));
expect(args.filter((arg) => arg === '0:a:0')).toHaveLength(3);
});
it('generates adaptive HLS without audio mappings', async () => {
const service = new VideoProcessingService(config);
jest.spyOn(internals(service), 'probeVideo').mockResolvedValue({ width: 720, hasAudio: false });
const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue([]);
await internals(service).generateHlsPackage('/in.mp4', '/hls');
expect(run.mock.calls[0][0]).toEqual(expect.arrayContaining(['-var_stream_map', 'v:0 v:1']));
expect(run.mock.calls[0][0]).not.toContain('0:a:0');
});
it('falls back to a single HLS stream when probing fails or only one rendition fits', async () => {
const failedProbe = new VideoProcessingService(config);
jest.spyOn(internals(failedProbe), 'probeVideo').mockRejectedValue(new Error('probe failed'));
const single = jest.spyOn(internals(failedProbe), 'generateSingleRenditionHlsPackage').mockResolvedValue({ playlistRelativePath: 'playlist.m3u8', files: [] });
await internals(failedProbe).generateHlsPackage('/in.mp4', '/hls');
expect(single).toHaveBeenCalled();
const narrow = new VideoProcessingService(config);
jest.spyOn(internals(narrow), 'probeVideo').mockResolvedValue({ width: 200, hasAudio: false });
const narrowSingle = jest.spyOn(internals(narrow), 'generateSingleRenditionHlsPackage').mockResolvedValue({ playlistRelativePath: 'playlist.m3u8', files: [] });
await internals(narrow).generateHlsPackage('/in.mp4', '/hls');
expect(narrowSingle).toHaveBeenCalled();
});
it('generates a single fMP4 rendition with default settings', async () => {
const service = new VideoProcessingService(config);
const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue([]);
await expect(internals(service).generateSingleRenditionHlsPackage('/in.mp4', '/hls')).resolves.toEqual({ playlistRelativePath: 'playlist.m3u8', files: [] });
expect(run).toHaveBeenCalledWith(expect.arrayContaining(['-hls_time', '4', '-hls_segment_type', 'fmp4']));
});
it('parses video probe output and rejects missing widths', async () => {
const service = new VideoProcessingService(config);
const run = jest.spyOn(internals(service), 'runCommand');
run.mockResolvedValueOnce(JSON.stringify({ streams: [{ codec_type: 'video', width: 1920 }, { codec_type: 'audio' }] }));
await expect(internals(service).probeVideo('/in.mp4')).resolves.toEqual({ width: 1920, hasAudio: true });
run.mockResolvedValueOnce('{}');
await expect(internals(service).probeVideo('/bad.mp4')).rejects.toThrow('video width');
});
it('reads nested HLS output in stable order and assigns content types', async () => {
const service = new VideoProcessingService(config);
(readdir as jest.Mock).mockImplementation(async (path: string) =>
path === '/hls'
? [
{ name: 'stream_0', isDirectory: () => true },
{ name: 'master.m3u8', isDirectory: () => false },
]
: [
{ name: 'segment.ts', isDirectory: () => false },
{ name: 'init.mp4', isDirectory: () => false },
{ name: 'segment.m4s', isDirectory: () => false },
{ name: 'unknown.bin', isDirectory: () => false },
],
);
const files = await internals(service).readFilesRecursively('/hls');
expect(files.map((item) => [item.relativePath, item.contentType])).toEqual([
['master.m3u8', 'application/vnd.apple.mpegurl'],
['stream_0/init.mp4', 'video/mp4'],
['stream_0/segment.m4s', 'video/iso.segment'],
['stream_0/segment.ts', 'video/mp2t'],
['stream_0/unknown.bin', 'application/octet-stream'],
]);
});
it('probes ffmpeg only once and caches success or failure', async () => {
const success = new VideoProcessingService(config);
const run = jest.spyOn(internals(success), 'runFfmpeg').mockResolvedValue(undefined);
await expect(internals(success).ensureFfmpegAvailable()).resolves.toBe(true);
await internals(success).ensureFfmpegAvailable();
expect(run).toHaveBeenCalledTimes(1);
const failure = new VideoProcessingService(config);
const fail = jest.spyOn(internals(failure), 'runFfmpeg').mockRejectedValue('missing');
await expect(internals(failure).ensureFfmpegAvailable()).resolves.toBe(false);
await internals(failure).ensureFfmpegAvailable();
expect(fail).toHaveBeenCalledTimes(1);
});
it('runs child commands and propagates stderr, generic exit, and spawn errors', async () => {
const service = new VideoProcessingService(config);
const ok = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(ok);
const okPromise = internals(service).runCommand('ffmpeg', ['-version']);
ok.stdout.emit('data', Buffer.from('version'));
ok.emit('close', 0);
await expect(okPromise).resolves.toBe('version');
const failed = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(failed);
const failedPromise = internals(service).runCommand('ffmpeg', []);
failed.stderr.emit('data', Buffer.from('invalid codec'));
failed.emit('close', 1);
await expect(failedPromise).rejects.toThrow('invalid codec');
const generic = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(generic);
const genericPromise = internals(service).runCommand('ffmpeg', []);
generic.emit('close', null);
await expect(genericPromise).rejects.toThrow('exited with code unknown');
const errored = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(errored);
const errorPromise = internals(service).runCommand('ffmpeg', []);
errored.emit('error', new Error('spawn failed'));
await expect(errorPromise).rejects.toThrow('spawn failed');
});
});
+230
View File
@@ -0,0 +1,230 @@
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger';
import * as express from 'express';
import { existsSync, mkdirSync } from 'fs';
import { networkInterfaces } from 'os';
import { AppLoggerService } from './infrastructure/logging/app-logger.service';
import { RedisService } from './infrastructure/redis/redis.service';
import { configureHttpServer } from './infrastructure/reliability/http-server.config';
import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter';
import { bootstrap, getLocalIpv4Addresses, getStaticMediaHeaders, isPrivateIpv4Host } from './main';
jest.mock('./app.module', () => ({ AppModule: class AppModule {} }));
jest.mock('@nestjs/core', () => ({ NestFactory: { create: jest.fn() } }));
jest.mock('@nestjs/swagger', () => {
class DocumentBuilder {
setTitle() { return this; }
setDescription() { return this; }
setVersion() { return this; }
addBearerAuth() { return this; }
build() { return { openapi: '3.0.0' }; }
}
return {
DocumentBuilder,
SwaggerModule: { createDocument: jest.fn(() => ({ paths: {} })), setup: jest.fn() },
};
});
jest.mock('compression', () => jest.fn(() => 'compression-middleware'));
jest.mock('express', () => ({
json: jest.fn(() => 'json-middleware'),
urlencoded: jest.fn(() => 'urlencoded-middleware'),
static: jest.fn(() => 'static-middleware'),
}));
jest.mock('fs', () => ({
...jest.requireActual<typeof import('fs')>('fs'),
existsSync: jest.fn(),
mkdirSync: jest.fn(),
}));
jest.mock('os', () => ({
...jest.requireActual<typeof import('os')>('os'),
networkInterfaces: jest.fn(),
}));
jest.mock('./infrastructure/reliability/http-server.config', () => ({ configureHttpServer: jest.fn() }));
jest.mock('./infrastructure/socket/redis-io.adapter', () => ({
RedisIoAdapter: jest.fn().mockImplementation(() => ({
connectToRedis: jest.fn().mockResolvedValue(undefined),
})),
}));
describe('main bootstrap', () => {
const build = (overrides: Record<string, unknown> = {}) => {
const values: Record<string, unknown> = {
'cors.origins': ['https://app.example.com'],
nodeEnv: 'production',
'security.bodyLimit': '2mb',
'performance.compressionEnabled': true,
'performance.compressionThresholdBytes': 2048,
'storage.provider': 'local',
'storage.basePath': '/uploads/',
publicBaseUrl: 'http://localhost:4000/',
'email.enabled': true,
'email.smtpHost': 'smtp.example.com',
'email.smtpUser': 'smtp-user',
'email.fromEmail': 'noreply@example.com',
globalPrefix: 'api/v1',
responseEnvelopeEnabled: true,
'redis.enabled': true,
'redis.socketAdapterEnabled': true,
'swagger.enabled': true,
'swagger.path': 'docs',
'swagger.title': 'Oudelaa API',
'swagger.description': 'API',
'swagger.version': '1.0.0',
port: 4000,
host: '0.0.0.0',
...overrides,
};
const config = {
get: jest.fn((key: string, defaultValue?: unknown) =>
Object.prototype.hasOwnProperty.call(values, key) ? values[key] : defaultValue,
),
};
const logger = {
log: jest.fn(),
warn: jest.fn(),
logHttp: jest.fn(),
};
const redis = {};
const httpServer = { keepAliveTimeout: 0, headersTimeout: 0, requestTimeout: 0 };
const app = {
get: jest.fn((token: unknown) => {
if (token === ConfigService) return config;
if (token === AppLoggerService) return logger;
if (token === RedisService) return redis;
return undefined;
}),
useLogger: jest.fn(),
enableShutdownHooks: jest.fn(),
use: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
useGlobalPipes: jest.fn(),
useGlobalInterceptors: jest.fn(),
useWebSocketAdapter: jest.fn(),
listen: jest.fn().mockResolvedValue(undefined),
getHttpServer: jest.fn(() => httpServer),
};
(NestFactory.create as jest.Mock).mockResolvedValue(app);
return { app, config, logger, redis, httpServer };
};
beforeEach(() => {
jest.clearAllMocks();
(existsSync as jest.Mock).mockReturnValue(false);
(networkInterfaces as jest.Mock).mockReturnValue({
Ethernet: [
{ family: 'IPv4', internal: false, address: '192.168.1.20' },
{ family: 'IPv6', internal: false, address: '::1' },
null,
],
Loopback: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
});
});
it('classifies local addresses and static-media response headers', () => {
expect(getLocalIpv4Addresses()).toEqual(['192.168.1.20']);
expect(isPrivateIpv4Host('10.0.0.1')).toBe(true);
expect(isPrivateIpv4Host('192.168.1.1')).toBe(true);
expect(isPrivateIpv4Host('172.16.0.1')).toBe(true);
expect(isPrivateIpv4Host('172.31.0.1')).toBe(true);
expect(isPrivateIpv4Host('172.15.0.1')).toBe(false);
expect(getStaticMediaHeaders('.mp3', '/audio/song.mp3')).toEqual(
expect.objectContaining({ contentType: 'audio/mpeg', acceptRanges: true }),
);
expect(getStaticMediaHeaders('.gif')).toEqual(expect.objectContaining({ contentType: 'image/gif' }));
expect(getStaticMediaHeaders('.m3u8')).toEqual(
expect.objectContaining({ contentType: 'application/vnd.apple.mpegurl', acceptRanges: true }),
);
expect(getStaticMediaHeaders('.m4s')).toEqual(expect.objectContaining({ contentType: 'video/iso.segment' }));
expect(getStaticMediaHeaders('.ts')).toEqual(expect.objectContaining({ contentType: 'video/mp2t' }));
expect(getStaticMediaHeaders('.unknown')).toEqual({});
});
it('boots the production stack with security, local media, Redis sockets, and Swagger', async () => {
const { app, logger, httpServer } = build();
await bootstrap();
expect(NestFactory.create).toHaveBeenCalledWith(expect.any(Function), {
bufferLogs: true,
bodyParser: false,
});
expect(mkdirSync).toHaveBeenCalledWith(expect.stringMatching(/uploads$/), { recursive: true });
expect(app.enableCors).toHaveBeenCalledWith(expect.objectContaining({ credentials: true }));
expect(app.setGlobalPrefix).toHaveBeenCalledWith('api/v1');
expect(app.useGlobalPipes).toHaveBeenCalled();
expect(app.useGlobalInterceptors).toHaveBeenCalled();
expect(app.listen).toHaveBeenCalledWith(4000, '0.0.0.0');
expect(configureHttpServer).toHaveBeenCalledWith(httpServer, expect.anything());
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Mobile devices on the LAN'), 'Bootstrap');
expect(RedisIoAdapter).toHaveBeenCalled();
const adapter = (RedisIoAdapter as unknown as jest.Mock).mock.results[0].value;
expect(adapter.connectToRedis).toHaveBeenCalled();
expect(app.useWebSocketAdapter).toHaveBeenCalledWith(adapter);
expect(SwaggerModule.createDocument).toHaveBeenCalled();
expect(SwaggerModule.setup).toHaveBeenCalledWith('docs', app, expect.anything());
const middleware = app.use.mock.calls
.filter((call) => typeof call[0] === 'function')
.map((call) => call[0]);
expect(middleware).toHaveLength(2);
const setHeader = jest.fn();
const next = jest.fn();
middleware[0]({}, { setHeader } as any, next);
expect(setHeader).toHaveBeenCalledWith('Strict-Transport-Security', expect.any(String));
let finish: (() => void) | undefined;
const request = { headers: { 'x-request-id': 'invalid id' }, method: 'GET', originalUrl: '/health' } as any;
const response = {
statusCode: 200,
setHeader,
on: jest.fn((_event: string, listener: () => void) => { finish = listener; }),
} as any;
middleware[1](request, response, next);
finish?.();
expect(request.headers['x-request-id']).toEqual(expect.any(String));
expect(logger.logHttp).toHaveBeenCalledWith(expect.objectContaining({ method: 'GET', statusCode: 200 }));
const suppliedIdRequest = {
headers: { 'x-request-id': 'mobile-client:request-1' },
method: 'GET',
originalUrl: '/health',
} as any;
middleware[1](suppliedIdRequest, response, next);
expect(suppliedIdRequest.headers['x-request-id']).toBe('mobile-client:request-1');
const staticOptions = (express.static as unknown as jest.Mock).mock.calls[0][1];
const staticHeader = jest.fn();
staticOptions.setHeaders({ setHeader: staticHeader }, '/uploads/stream.m3u8');
expect(staticHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.apple.mpegurl');
expect(staticHeader).toHaveBeenCalledWith('Accept-Ranges', 'bytes');
});
it('boots without optional integrations and warns about a mismatched private public URL', async () => {
const { app, logger } = build({
'cors.origins': [],
nodeEnv: 'development',
'performance.compressionEnabled': false,
'storage.provider': 's3',
publicBaseUrl: 'http://192.168.1.99:4000',
responseEnvelopeEnabled: false,
'redis.enabled': false,
'redis.socketAdapterEnabled': false,
'swagger.enabled': false,
'email.enabled': false,
'email.smtpHost': '',
'email.smtpUser': '',
'email.fromEmail': '',
});
await bootstrap();
expect(mkdirSync).not.toHaveBeenCalled();
expect(app.enableCors).toHaveBeenCalledWith(expect.objectContaining({ origin: true, credentials: true }));
expect(app.useGlobalInterceptors).not.toHaveBeenCalled();
expect(app.useWebSocketAdapter).not.toHaveBeenCalled();
expect(SwaggerModule.setup).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('not assigned to the current machine'), 'Bootstrap');
});
});
+59 -17
View File
@@ -1,8 +1,9 @@
import { ValidationPipe } from '@nestjs/common';
import { ShutdownSignal, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as express from 'express';
import compression = require('compression');
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
import { existsSync, mkdirSync } from 'fs';
@@ -13,22 +14,26 @@ import { ResponseEnvelopeInterceptor } from './common/interceptors/response-enve
import { getFileExtension, getStaticMediaContentType } from './common/media/allowed-media';
import { AppLoggerService } from './infrastructure/logging/app-logger.service';
import { RedisService } from './infrastructure/redis/redis.service';
import {
ConfigurableHttpServer,
configureHttpServer,
} from './infrastructure/reliability/http-server.config';
import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter';
const getLocalIpv4Addresses = (): string[] =>
export const getLocalIpv4Addresses = (): string[] =>
Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.filter((entry): entry is NetworkInterfaceInfo => !!entry)
.filter((entry) => entry.family === 'IPv4' && !entry.internal)
.map((entry) => entry.address);
const isPrivateIpv4Host = (host: string): boolean =>
export const isPrivateIpv4Host = (host: string): boolean =>
/^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(host);
const IMMUTABLE_MEDIA_CACHE_CONTROL = 'public, max-age=31536000, immutable';
const SHORT_MANIFEST_CACHE_CONTROL = 'public, max-age=300, stale-while-revalidate=60';
const getStaticMediaHeaders = (
export const getStaticMediaHeaders = (
extension: string,
filePath = '',
): {
@@ -74,12 +79,19 @@ const getStaticMediaHeaders = (
}
};
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
export async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { bufferLogs: true, bodyParser: false });
const configService = app.get(ConfigService);
const appLogger = app.get(AppLoggerService);
app.useLogger(appLogger);
app.enableShutdownHooks([ShutdownSignal.SIGINT, ShutdownSignal.SIGTERM]);
const corsOrigins = configService.get<string[]>('cors.origins', []);
const nodeEnv = configService.get<string>('nodeEnv', 'development');
const bodyLimit = configService.get<string>('security.bodyLimit', { infer: true }) ?? '1mb';
const compressionEnabled =
configService.get<boolean>('performance.compressionEnabled', { infer: true }) ?? true;
const compressionThresholdBytes =
configService.get<number>('performance.compressionThresholdBytes', { infer: true }) ?? 1024;
const storageProvider = configService.get<string>('storage.provider', { infer: true }) ?? 'local';
const storageBasePath =
(configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads').replace(
@@ -94,13 +106,31 @@ async function bootstrap(): Promise<void> {
const emailFromEmail = configService.get<string>('email.fromEmail', { infer: true }) ?? '';
const uploadsDir = join(process.cwd(), storageBasePath);
if (compressionEnabled) {
app.use(compression({ threshold: compressionThresholdBytes }));
}
app.use(express.json({ limit: bodyLimit }));
app.use(express.urlencoded({ extended: true, limit: bodyLimit }));
app.use((_req: Request, res: Response, next: NextFunction) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
if (nodeEnv === 'production') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
if (storageProvider === 'local' && !existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true });
}
app.enableCors({
origin: corsOrigins.length ? corsOrigins : true,
credentials: true,
origin: corsOrigins.length ? corsOrigins : nodeEnv === 'production' ? false : true,
credentials: corsOrigins.length > 0 || nodeEnv !== 'production',
exposedHeaders: [
'Accept-Ranges',
'Content-Length',
@@ -125,7 +155,11 @@ async function bootstrap(): Promise<void> {
app.use((req: Request, res: Response, next: NextFunction) => {
const startedAt = Date.now();
const requestId = (req.headers['x-request-id'] as string | undefined) ?? randomUUID();
const suppliedRequestId = req.headers['x-request-id'];
const requestId =
typeof suppliedRequestId === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(suppliedRequestId)
? suppliedRequestId
: randomUUID();
req.headers['x-request-id'] = requestId;
res.setHeader('x-request-id', requestId);
@@ -147,9 +181,10 @@ async function bootstrap(): Promise<void> {
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
}
app.use(
`/${storageBasePath}`,
express.static(uploadsDir, {
if (storageProvider === 'local') {
app.use(
`/${storageBasePath}`,
express.static(uploadsDir, {
acceptRanges: true,
setHeaders: (res, filePath) => {
const extension = getFileExtension(filePath);
@@ -167,8 +202,9 @@ async function bootstrap(): Promise<void> {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}),
);
}),
);
}
const redisEnabled = configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
const socketAdapterEnabled =
@@ -188,8 +224,11 @@ async function bootstrap(): Promise<void> {
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup(configService.get<string>('swagger.path', 'docs'), app, document);
const swaggerEnabled = configService.get<boolean>('swagger.enabled', { infer: true }) ?? false;
if (swaggerEnabled) {
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup(configService.get<string>('swagger.path', 'docs'), app, document);
}
const port = configService.get<number>('port', 4000);
const host = configService.get<string>('host', '0.0.0.0');
@@ -221,6 +260,7 @@ async function bootstrap(): Promise<void> {
}
await app.listen(port, host);
configureHttpServer(app.getHttpServer() as ConfigurableHttpServer, configService);
appLogger.log(`Server listening on http://${host}:${port}`, 'Bootstrap');
appLogger.log(`Resolved PUBLIC_BASE_URL=${publicBaseUrl || `http://localhost:${port}`}`, 'Bootstrap');
appLogger.log(
@@ -229,4 +269,6 @@ async function bootstrap(): Promise<void> {
);
}
void bootstrap();
if (require.main === module) {
void bootstrap();
}
+119
View File
@@ -0,0 +1,119 @@
import { SortOrder } from '../../common/enums/sort-order.enum';
import { AuditService } from './audit.service';
describe('AuditService', () => {
const createService = () => {
const auditRepository = {
create: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
};
return {
service: new AuditService(auditRepository as any),
auditRepository,
};
};
it('writes a complete super-admin audit record', async () => {
const { service, auditRepository } = createService();
auditRepository.create.mockResolvedValue(undefined);
await expect(
service.logSuperAdminAction('root@example.com', 'suspend', 'user', 'user-1', {
reason: 'abuse',
}),
).resolves.toBeUndefined();
expect(auditRepository.create).toHaveBeenCalledWith({
actorType: 'superadmin',
actorIdentifier: 'root@example.com',
action: 'suspend',
targetType: 'user',
targetId: 'user-1',
metadata: { reason: 'abuse' },
});
});
it('uses default pagination, filters, and descending order', async () => {
const { service, auditRepository } = createService();
const items = [{ id: 'log-1' }, { id: 'log-2' }];
auditRepository.findMany.mockResolvedValue(items);
auditRepository.count.mockResolvedValue(25);
const result = await service.listSuperAdminLogs({});
expect(auditRepository.findMany).toHaveBeenCalledWith({}, 0, 20, { createdAt: -1 });
expect(auditRepository.count).toHaveBeenCalledWith({});
expect(result).toMatchObject({
items,
count: 2,
page: 1,
limit: 20,
total: 25,
totalPages: 2,
pagination: {
hasNextPage: true,
hasPreviousPage: false,
nextPage: 2,
previousPage: null,
},
});
});
it('builds escaped search filters and applies explicit ascending pagination', async () => {
const { service, auditRepository } = createService();
auditRepository.findMany.mockResolvedValue([{ id: 'log-3' }]);
auditRepository.count.mockResolvedValue(21);
const result = await service.listSuperAdminLogs({
q: ' user.* ',
actorType: 'system',
targetType: ' post ',
page: 3,
limit: 10,
sortOrder: SortOrder.ASC,
});
const filter = {
$or: [
{ action: { $regex: 'user\\.\\*', $options: 'i' } },
{ targetType: { $regex: 'user\\.\\*', $options: 'i' } },
{ targetId: { $regex: 'user\\.\\*', $options: 'i' } },
{ actorIdentifier: { $regex: 'user\\.\\*', $options: 'i' } },
],
actorType: 'system',
targetType: 'post',
};
expect(auditRepository.findMany).toHaveBeenCalledWith(filter, 20, 10, { createdAt: 1 });
expect(auditRepository.count).toHaveBeenCalledWith(filter);
expect(result.pagination).toMatchObject({
hasNextPage: false,
hasPreviousPage: true,
nextPage: null,
previousPage: 2,
});
});
it('ignores whitespace-only optional filters', async () => {
const { service, auditRepository } = createService();
auditRepository.findMany.mockResolvedValue([]);
auditRepository.count.mockResolvedValue(0);
await service.listSuperAdminLogs({ q: ' ', targetType: ' ' });
expect(auditRepository.findMany).toHaveBeenCalledWith({}, 0, 20, { createdAt: -1 });
});
it('propagates write and read failures', async () => {
const { service, auditRepository } = createService();
auditRepository.create.mockRejectedValue(new Error('write failed'));
await expect(
service.logSuperAdminAction('root', 'action', 'target'),
).rejects.toThrow('write failed');
auditRepository.findMany.mockRejectedValue(new Error('read failed'));
auditRepository.count.mockResolvedValue(0);
await expect(service.listSuperAdminLogs({})).rejects.toThrow('read failed');
});
});
+5 -4
View File
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { AuditRepository } from './audit.repository';
import { escapeRegex } from '../../common/utils/regex.util';
import { AuditQueryDto } from './dto/audit-query.dto';
@Injectable()
@@ -33,10 +34,10 @@ export class AuditService {
if (query.q?.trim()) {
filter.$or = [
{ action: { $regex: query.q.trim(), $options: 'i' } },
{ targetType: { $regex: query.q.trim(), $options: 'i' } },
{ targetId: { $regex: query.q.trim(), $options: 'i' } },
{ actorIdentifier: { $regex: query.q.trim(), $options: 'i' } },
{ action: { $regex: escapeRegex(query.q.trim()), $options: 'i' } },
{ targetType: { $regex: escapeRegex(query.q.trim()), $options: 'i' } },
{ targetId: { $regex: escapeRegex(query.q.trim()), $options: 'i' } },
{ actorIdentifier: { $regex: escapeRegex(query.q.trim()), $options: 'i' } },
];
}
+6 -1
View File
@@ -12,6 +12,7 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { Throttle } from '../../common/decorators/throttle.decorator';
@@ -73,7 +74,11 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@Post('signup/complete')
@Throttle(10, 60_000)
@UseInterceptors(FileFieldsInterceptor([{ name: 'avatarFile', maxCount: 1 }]))
@UseInterceptors(
FileFieldsInterceptor([{ name: 'avatarFile', maxCount: 1 }], {
limits: { fileSize: MEDIA_MAX_SIZE_BYTES.userImage, files: 1 },
}),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
+2 -2
View File
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { JwtModule, JwtSignOptions } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose';
import { EmailModule } from '../email/email.module';
import { UsersModule } from '../users/users.module';
@@ -34,7 +34,7 @@ import { SuperAdminJwtStrategy } from './strategies/super-admin-jwt.strategy';
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('jwt.accessSecret', { infer: true }),
signOptions: {
expiresIn: configService.get<string>('jwt.accessExpiresIn', { infer: true }),
expiresIn: configService.get<string>('jwt.accessExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'],
},
}),
}),
+49
View File
@@ -59,6 +59,17 @@ export class AuthRepository {
.exec();
}
async consumeUserTokenByJti(userId: string, jti: string): Promise<boolean> {
const consumed = await this.refreshTokenModel
.findOneAndUpdate(
{ userId: new Types.ObjectId(userId), jti, revoked: false, expiresAt: { $gt: new Date() } },
{ revoked: true },
{ new: false },
)
.exec();
return !!consumed;
}
async findActiveUserTokenByJti(userId: string, jti: string): Promise<RefreshTokenDocument | null> {
return this.refreshTokenModel
.findOne({ userId: new Types.ObjectId(userId), jti, revoked: false })
@@ -135,6 +146,22 @@ export class AuthRepository {
.exec();
}
async consumeSuperAdminTokenByJti(adminEmail: string, jti: string): Promise<boolean> {
const consumed = await this.superAdminRefreshTokenModel
.findOneAndUpdate(
{
adminEmail: adminEmail.toLowerCase(),
jti,
revoked: false,
expiresAt: { $gt: new Date() },
},
{ revoked: true },
{ new: false },
)
.exec();
return !!consumed;
}
async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> {
await this.superAdminRefreshTokenModel
.deleteMany({
@@ -284,6 +311,28 @@ export class AuthRepository {
.exec();
}
async consumeValidVerifiedPasswordResetCode(
codeId: string,
userId: string,
): Promise<PasswordResetCodeDocument | null> {
if (!Types.ObjectId.isValid(codeId) || !Types.ObjectId.isValid(userId)) {
return null;
}
return this.passwordResetCodeModel
.findOneAndUpdate(
{
_id: new Types.ObjectId(codeId),
userId: new Types.ObjectId(userId),
used: false,
verified: true,
expiresAt: { $gt: new Date() },
},
{ used: true },
{ new: false },
)
.exec();
}
async upsertSignupDraft(params: {
email: string;
passwordHash: string;
@@ -0,0 +1,844 @@
import {
BadRequestException,
ForbiddenException,
GoneException,
HttpException,
UnauthorizedException,
} from '@nestjs/common';
import { hashHighEntropyValue, hashValue } from '../../common/utils/hash.util';
import { AuthService } from './auth.service';
type MockMap = Record<string, jest.Mock>;
const future = () => new Date(Date.now() + 60_000);
const createHarness = (options: {
config?: Record<string, unknown>;
users?: Partial<MockMap>;
repository?: Partial<MockMap>;
jwt?: Partial<MockMap>;
email?: Partial<MockMap>;
} = {}) => {
const user = {
id: 'user-1',
email: 'user@example.com',
username: 'artist',
role: 'user',
password: '',
isDisabled: false,
isEmailVerified: false,
googleId: 'google-1',
toObject: () => ({ id: 'user-1', email: 'user@example.com', username: 'artist' }),
};
const users: MockMap = {
create: jest.fn().mockResolvedValue(user),
createWithOptionalAvatarFile: jest.fn().mockResolvedValue(user),
rollbackSignupCreatedUser: jest.fn().mockResolvedValue(undefined),
findByEmailWithPassword: jest.fn(),
findByIdWithPassword: jest.fn(),
findByIdOrFail: jest.fn().mockResolvedValue(user),
findByEmail: jest.fn().mockResolvedValue(null),
findByUsername: jest.fn().mockResolvedValue(null),
findByGoogleId: jest.fn().mockResolvedValue(null),
linkGoogleAccount: jest.fn().mockResolvedValue(user),
markEmailVerified: jest.fn().mockResolvedValue(undefined),
updatePassword: jest.fn().mockResolvedValue(undefined),
...options.users,
};
const repository: MockMap = {
invalidateActiveEmailVerificationCodes: jest.fn().mockResolvedValue(undefined),
createEmailVerificationCode: jest.fn().mockResolvedValue(undefined),
findLatestActiveEmailVerificationCode: jest.fn(),
incrementEmailVerificationAttempts: jest.fn().mockResolvedValue(undefined),
markEmailVerificationCodeUsed: jest.fn().mockResolvedValue(undefined),
markAllEmailVerificationCodesUsedByUser: jest.fn().mockResolvedValue(undefined),
createRefreshToken: jest.fn().mockResolvedValue(undefined),
findActiveUserTokenByJti: jest.fn(),
consumeUserTokenByJti: jest.fn().mockResolvedValue(true),
markCompromisedAndRevokeAll: jest.fn().mockResolvedValue(undefined),
revokeAllUserTokens: jest.fn().mockResolvedValue(undefined),
revokeUserTokenByJti: jest.fn().mockResolvedValue(undefined),
removeExpiredAndRevoked: jest.fn().mockResolvedValue(undefined),
createSuperAdminRefreshToken: jest.fn().mockResolvedValue(undefined),
findActiveSuperAdminTokenByJti: jest.fn(),
consumeSuperAdminTokenByJti: jest.fn().mockResolvedValue(true),
revokeAllSuperAdminTokens: jest.fn().mockResolvedValue(undefined),
revokeSuperAdminTokenByJti: jest.fn().mockResolvedValue(undefined),
removeExpiredAndRevokedSuperAdmin: jest.fn().mockResolvedValue(undefined),
listUserSessions: jest.fn().mockResolvedValue([]),
listSuperAdminSessions: jest.fn().mockResolvedValue([]),
revokeSuperAdminSessionById: jest.fn().mockResolvedValue(true),
invalidateActivePasswordResetCodes: jest.fn().mockResolvedValue(undefined),
createPasswordResetCode: jest.fn().mockResolvedValue(undefined),
findLatestActivePasswordResetCode: jest.fn(),
incrementPasswordResetAttempts: jest.fn().mockResolvedValue(undefined),
markPasswordResetCodeVerified: jest.fn().mockResolvedValue(undefined),
markPasswordResetCodeUsed: jest.fn().mockResolvedValue(undefined),
markPasswordResetCodeUsedByUser: jest.fn().mockResolvedValue(undefined),
consumeValidVerifiedPasswordResetCode: jest.fn(),
...options.repository,
};
const jwt: MockMap = {
signAsync: jest.fn((payload: { tokenType?: string }) =>
Promise.resolve(payload.tokenType?.includes('refresh') ? 'new-refresh-token' : 'new-access-token'),
),
verify: jest.fn(),
...options.jwt,
};
const email: MockMap = {
sendVerificationCode: jest.fn().mockResolvedValue(undefined),
sendPasswordResetCode: jest.fn().mockResolvedValue(undefined),
...options.email,
};
const config: Record<string, unknown> = {
nodeEnv: 'test',
'security.bcryptSaltRounds': 4,
'security.refreshTokenHashSecret': 'hash-secret',
'jwt.accessSecret': 'access-secret',
'jwt.accessExpiresIn': '15m',
'jwt.refreshSecret': 'refresh-secret',
'jwt.refreshExpiresIn': '30d',
'superAdmin.email': 'root@example.com',
'superAdmin.password': 'RootPass123!',
'superAdmin.accessSecret': 'admin-access-secret',
'superAdmin.accessExpiresIn': '15m',
'superAdmin.refreshSecret': 'admin-refresh-secret',
'superAdmin.refreshExpiresIn': '30d',
'emailVerification.codeExpiresMinutes': 20,
'emailVerification.maxAttempts': 3,
'passwordReset.codeExpiresMinutes': 10,
'passwordReset.maxAttempts': 3,
'passwordReset.tokenSecret': 'password-reset-secret',
'passwordReset.tokenExpiresIn': '15m',
'google.clientId': 'google-client-id',
...options.config,
};
const configService = {
get: jest.fn((key: string) => config[key]),
};
const service = new AuthService(
users as any,
repository as any,
jwt as any,
configService as any,
email as any,
);
return { service, users, repository, jwt, email, configService, user };
};
describe('AuthService security-sensitive account flows', () => {
describe('registration and login', () => {
it('rejects registration when password confirmation differs', async () => {
const { service, users } = createHarness();
await expect(
service.register({
email: 'user@example.com',
username: 'artist',
password: 'StrongPass123!',
confirmPassword: 'DifferentPass123!',
} as any),
).rejects.toBeInstanceOf(BadRequestException);
expect(users.create).not.toHaveBeenCalled();
});
it('normalizes a generated unique username and never stores the confirmation', async () => {
const { service, users, repository, email } = createHarness({
users: {
findByUsername: jest
.fn()
.mockResolvedValueOnce({ id: 'collision' })
.mockResolvedValueOnce(null),
},
});
const result = await service.register({
email: 'A+rtist@example.com',
stageName: 'Stage Artist',
password: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
} as any);
expect(users.create).toHaveBeenCalledWith(
expect.objectContaining({ username: 'artist_1', name: 'Stage Artist' }),
);
expect(users.create.mock.calls[0][0]).not.toHaveProperty('confirmPassword');
expect(repository.invalidateActiveEmailVerificationCodes).toHaveBeenCalledWith('user-1');
expect(email.sendVerificationCode).toHaveBeenCalled();
expect(result.debugCode).toMatch(/^\d{6}$/);
});
it('omits verification codes in production registration responses', async () => {
const { service } = createHarness({ config: { nodeEnv: 'production' } });
const result = await service.registerBasic({
email: 'new@example.com',
password: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
});
expect(result).toEqual({
message: 'Registration successful. Verification code sent to email.',
email: 'user@example.com',
});
});
it('rejects a missing account, disabled account, and incorrect password', async () => {
const missing = createHarness();
await expect(
missing.service.login({ email: 'none@example.com', password: 'StrongPass123!' }),
).rejects.toBeInstanceOf(UnauthorizedException);
const disabledHash = await hashValue('StrongPass123!', 4);
const disabled = createHarness({
users: {
findByEmailWithPassword: jest.fn().mockResolvedValue({
...missing.user,
password: disabledHash,
isDisabled: true,
}),
},
});
await expect(
disabled.service.login({ email: 'user@example.com', password: 'StrongPass123!' }),
).rejects.toBeInstanceOf(ForbiddenException);
const wrong = createHarness({
users: {
findByEmailWithPassword: jest.fn().mockResolvedValue({
...missing.user,
password: disabledHash,
}),
},
});
await expect(
wrong.service.login({ email: 'user@example.com', password: 'WrongPass123!' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns a safe user and persists a hashed refresh token on login', async () => {
const password = await hashValue('StrongPass123!', 4);
const { service, users, repository } = createHarness({
users: {
findByEmailWithPassword: jest.fn().mockResolvedValue({
id: 'user-1',
username: 'artist',
role: undefined,
password,
isDisabled: false,
}),
},
});
const result = await service.login({
email: 'user@example.com',
password: 'StrongPass123!',
});
expect(result).toMatchObject({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' });
expect(users.findByIdOrFail).toHaveBeenCalledWith('user-1');
expect(repository.createRefreshToken).toHaveBeenCalledWith(
'user-1',
expect.any(String),
hashHighEntropyValue('new-refresh-token', 'hash-secret'),
expect.any(Date),
);
});
});
describe('email verification', () => {
it('does not reveal whether an email is absent or disabled', async () => {
const absent = createHarness();
const disabled = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ isDisabled: true }) },
});
await expect(absent.service.sendEmailVerification({ email: 'NONE@EXAMPLE.COM' })).resolves.toEqual(
{ message: 'If this email exists, a verification code was sent' },
);
await expect(disabled.service.sendEmailVerification({ email: 'user@example.com' })).resolves.toEqual(
{ message: 'If this email exists, a verification code was sent' },
);
expect(absent.repository.createEmailVerificationCode).not.toHaveBeenCalled();
});
it('short-circuits an already verified account', async () => {
const { service, repository } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ isEmailVerified: true }) },
});
await expect(service.sendEmailVerification({ email: 'user@example.com' })).resolves.toEqual({
message: 'Email is already verified',
});
expect(repository.createEmailVerificationCode).not.toHaveBeenCalled();
});
it('invalidates a code at the attempt limit', async () => {
const { service, repository } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
repository: {
findLatestActiveEmailVerificationCode: jest.fn().mockResolvedValue({
id: 'code-1',
attempts: 3,
}),
},
});
await expect(
service.verifyEmail({ email: 'user@example.com', code: '123456' }),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(repository.markEmailVerificationCodeUsed).toHaveBeenCalledWith('code-1');
});
it('increments a wrong-code attempt and consumes the final attempt', async () => {
const codeHash = await hashValue('123456', 4);
const { service, repository } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
repository: {
findLatestActiveEmailVerificationCode: jest.fn().mockResolvedValue({
id: 'code-1',
codeHash,
attempts: 2,
}),
},
});
await expect(
service.verifyEmail({ email: 'user@example.com', code: '654321' }),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(repository.incrementEmailVerificationAttempts).toHaveBeenCalledWith('code-1');
expect(repository.markEmailVerificationCodeUsed).toHaveBeenCalledWith('code-1');
});
it('atomically verifies the user and invalidates every outstanding code', async () => {
const codeHash = await hashValue('123456', 4);
const { service, users, repository } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
repository: {
findLatestActiveEmailVerificationCode: jest.fn().mockResolvedValue({
id: 'code-1',
codeHash,
attempts: 0,
}),
},
});
await expect(
service.verifyEmail({ email: 'USER@EXAMPLE.COM', code: '123456' }),
).resolves.toEqual({ message: 'Email verified successfully' });
expect(users.markEmailVerified).toHaveBeenCalledWith('user-1');
expect(repository.markAllEmailVerificationCodesUsedByUser).toHaveBeenCalledWith('user-1');
});
});
describe('refresh-token rotation', () => {
it('rejects access tokens passed as refresh tokens before repository lookup', async () => {
const { service, repository } = createHarness({
jwt: { verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'access' }) },
});
await expect(service.refresh({ refreshToken: 'token' })).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(repository.findActiveUserTokenByJti).not.toHaveBeenCalled();
});
it.each([
['missing record', null, true],
['hash mismatch', { tokenHash: hashHighEntropyValue('other-token', 'hash-secret') }, true],
])('revokes all sessions on %s', async (_name, tokenRecord, consume) => {
const { service, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }),
},
repository: {
findActiveUserTokenByJti: jest.fn().mockResolvedValue(tokenRecord),
consumeUserTokenByJti: jest.fn().mockResolvedValue(consume),
},
});
await expect(service.refresh({ refreshToken: 'refresh-token' })).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(repository.markCompromisedAndRevokeAll).toHaveBeenCalledWith('user-1');
});
it('detects a concurrent double-use when token consumption loses the race', async () => {
const raw = 'refresh-token';
const { service, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }),
},
repository: {
findActiveUserTokenByJti: jest.fn().mockResolvedValue({
tokenHash: hashHighEntropyValue(raw, 'hash-secret'),
}),
consumeUserTokenByJti: jest.fn().mockResolvedValue(false),
},
});
await expect(service.refresh({ refreshToken: raw })).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(repository.markCompromisedAndRevokeAll).toHaveBeenCalled();
});
it('blocks rotation for a disabled user', async () => {
const raw = 'refresh-token';
const { service, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }),
},
users: { findByIdOrFail: jest.fn().mockResolvedValue({ isDisabled: true }) },
repository: {
findActiveUserTokenByJti: jest.fn().mockResolvedValue({
tokenHash: hashHighEntropyValue(raw, 'hash-secret'),
}),
},
});
await expect(service.refresh({ refreshToken: raw })).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(repository.consumeUserTokenByJti).not.toHaveBeenCalled();
});
it('consumes the old token before issuing a new pair', async () => {
const raw = 'refresh-token';
const { service, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }),
},
repository: {
findActiveUserTokenByJti: jest.fn().mockResolvedValue({
tokenHash: hashHighEntropyValue(raw, 'hash-secret'),
}),
},
});
await expect(service.refresh({ refreshToken: raw })).resolves.toMatchObject({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});
expect(repository.consumeUserTokenByJti).toHaveBeenCalledWith('user-1', 'jti-1');
});
it('revokes and cleans sessions at logout but maps invalid tokens to 400', async () => {
const valid = createHarness({ jwt: { verify: jest.fn().mockReturnValue({ sub: 'user-1' }) } });
await expect(valid.service.logout({ refreshToken: 'valid' })).resolves.toBeUndefined();
expect(valid.repository.revokeAllUserTokens).toHaveBeenCalledWith('user-1');
const invalid = createHarness({ jwt: { verify: jest.fn(() => { throw new Error('bad'); }) } });
await expect(invalid.service.logout({ refreshToken: 'bad' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
describe('Google and superadmin authentication', () => {
it('creates and links a new Google user while defaulting a missing avatar', async () => {
const { service, users } = createHarness({
users: {
create: jest.fn().mockResolvedValue({
id: 'user-1',
username: 'new',
role: 'user',
isDisabled: false,
}),
},
});
const result = await service.loginWithGoogle({
googleId: 'google-new',
email: 'new@example.com',
name: 'New User',
});
expect(users.create).toHaveBeenCalledWith(
expect.objectContaining({
email: 'new@example.com',
avatar: '',
isEmailVerified: true,
}),
);
expect(users.linkGoogleAccount).toHaveBeenCalled();
expect(result.accessToken).toBe('new-access-token');
});
it('rejects disabled Google-linked accounts', async () => {
const { service } = createHarness({
users: {
findByGoogleId: jest.fn().mockResolvedValue({
id: 'user-1',
googleId: 'google-1',
isDisabled: true,
}),
},
});
await expect(
service.loginWithGoogle({
googleId: 'google-1',
email: 'user@example.com',
name: 'User',
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects Google ID login when the client or verified claims are missing', async () => {
const missingClient = createHarness({ config: { 'google.clientId': ' ' } });
await expect(
missingClient.service.loginWithGoogleIdToken({ idToken: 'token' }),
).rejects.toBeInstanceOf(BadRequestException);
const invalidClaims = createHarness();
(invalidClaims.service as any).googleOAuthClient = {
verifyIdToken: jest.fn().mockResolvedValue({
getPayload: () => ({ sub: 'google-1', email: 'user@example.com', email_verified: false }),
}),
};
await expect(
invalidClaims.service.loginWithGoogleIdToken({ idToken: 'token' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects wrong superadmin credentials and requires OTP when configured', async () => {
const wrong = createHarness();
await expect(
wrong.service.superAdminLogin({ email: 'root@example.com', password: 'wrong' }),
).rejects.toBeInstanceOf(UnauthorizedException);
const otpRequired = createHarness({ config: { 'superAdmin.totpSecret': 'JBSWY3DPEHPK3PXP' } });
await expect(
otpRequired.service.superAdminLogin({
email: 'root@example.com',
password: 'RootPass123!',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('issues dedicated superadmin tokens and persists only the refresh hash', async () => {
const { service, repository, jwt } = createHarness();
const result = await service.superAdminLogin({
email: 'ROOT@example.com',
password: 'RootPass123!',
});
expect(result.superAdmin.email).toBe('root@example.com');
expect(jwt.signAsync).toHaveBeenCalledWith(
expect.objectContaining({ tokenType: 'superadmin_access', permissions: expect.any(Array) }),
expect.any(Object),
);
expect(repository.createSuperAdminRefreshToken).toHaveBeenCalledWith(
'root@example.com',
expect.any(String),
hashHighEntropyValue('new-refresh-token', 'hash-secret'),
expect.any(Date),
);
});
it('detects missing, mismatched, and concurrently consumed superadmin refresh tokens', async () => {
const cases = [
{ record: null, consumed: true },
{ record: { tokenHash: hashHighEntropyValue('other', 'hash-secret') }, consumed: true },
{ record: { tokenHash: hashHighEntropyValue('admin-refresh', 'hash-secret') }, consumed: false },
];
for (const testCase of cases) {
const { service, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({
email: 'root@example.com',
tokenType: 'superadmin_refresh',
jti: 'admin-jti',
}),
},
repository: {
findActiveSuperAdminTokenByJti: jest.fn().mockResolvedValue(testCase.record),
consumeSuperAdminTokenByJti: jest.fn().mockResolvedValue(testCase.consumed),
},
});
await expect(
service.superAdminRefresh({ refreshToken: 'admin-refresh' }),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(repository.revokeAllSuperAdminTokens).toHaveBeenCalledWith('root@example.com');
}
});
it('rotates a valid superadmin token and supports targeted/global logout', async () => {
const raw = 'admin-refresh';
const rotating = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({
email: 'root@example.com',
tokenType: 'superadmin_refresh',
jti: 'admin-jti',
}),
},
repository: {
findActiveSuperAdminTokenByJti: jest.fn().mockResolvedValue({
tokenHash: hashHighEntropyValue(raw, 'hash-secret'),
}),
},
});
await expect(rotating.service.superAdminRefresh({ refreshToken: raw })).resolves.toMatchObject({
superAdmin: { email: 'root@example.com' },
});
await rotating.service.superAdminLogout({ refreshToken: raw });
expect(rotating.repository.revokeSuperAdminTokenByJti).toHaveBeenCalledWith(
'root@example.com',
'admin-jti',
);
const global = createHarness({
jwt: { verify: jest.fn().mockReturnValue({ email: 'root@example.com' }) },
});
await global.service.superAdminLogout({ refreshToken: raw });
expect(global.repository.revokeAllSuperAdminTokens).toHaveBeenCalledWith('root@example.com');
});
});
describe('password reset and session management', () => {
it('keeps forgot-password responses indistinguishable and omits production codes', async () => {
const missing = createHarness();
await expect(missing.service.forgotPassword({ email: 'NONE@EXAMPLE.COM' })).resolves.toEqual({
message: 'If this email exists, a reset code was sent',
});
const production = createHarness({
config: { nodeEnv: 'production' },
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
});
const result = await production.service.forgotPassword({ email: 'USER@EXAMPLE.COM' });
expect(result.debugCode).toBeUndefined();
expect(production.repository.invalidateActivePasswordResetCodes).toHaveBeenCalledWith('user-1');
expect(production.email.sendPasswordResetCode).toHaveBeenCalledWith(
'user@example.com',
expect.stringMatching(/^\d{6}$/),
10,
);
});
it('locks a reset code that already reached or reaches the attempt limit', async () => {
for (const attempts of [3, 2]) {
const codeHash = await hashValue('123456', 4);
const { service, repository } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
repository: {
findLatestActivePasswordResetCode: jest.fn().mockResolvedValue({
id: 'reset-1',
attempts,
codeHash,
}),
},
});
await expect(
service.verifyResetCode({ email: 'user@example.com', code: '654321' }),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(repository.markPasswordResetCodeUsed).toHaveBeenCalledWith('reset-1');
}
});
it('marks the code verified and issues a short-lived purpose-bound reset token', async () => {
const codeHash = await hashValue('123456', 4);
const { service, repository, jwt } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
repository: {
findLatestActivePasswordResetCode: jest.fn().mockResolvedValue({
id: 'reset-1',
attempts: 0,
codeHash,
}),
},
});
await expect(
service.verifyResetCode({ email: 'USER@EXAMPLE.COM', code: '123456' }),
).resolves.toEqual({ resetToken: 'new-access-token', expiresIn: '15m' });
expect(repository.markPasswordResetCodeVerified).toHaveBeenCalledWith('reset-1');
expect(jwt.signAsync).toHaveBeenCalledWith(
{ sub: 'user-1', tokenType: 'password_reset', prcId: 'reset-1' },
expect.objectContaining({ secret: 'password-reset-secret' }),
);
});
it('rejects reset confirmation mismatch, invalid JWTs, wrong purpose and consumed codes', async () => {
const mismatch = createHarness();
await expect(
mismatch.service.resetPassword({
resetToken: 'token',
newPassword: 'StrongPass123!',
confirmPassword: 'DifferentPass123!',
}),
).rejects.toBeInstanceOf(BadRequestException);
const invalid = createHarness({ jwt: { verify: jest.fn(() => { throw new Error('expired'); }) } });
await expect(
invalid.service.resetPassword({
resetToken: 'token',
newPassword: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
const wrongPurpose = createHarness({
jwt: { verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'access' }) },
});
await expect(
wrongPurpose.service.resetPassword({
resetToken: 'token',
newPassword: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
const consumed = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({
sub: 'user-1',
tokenType: 'password_reset',
prcId: 'reset-1',
}),
},
});
await expect(
consumed.service.resetPassword({
resetToken: 'token',
newPassword: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('consumes a reset code exactly once then revokes all existing sessions', async () => {
const { service, users, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({
sub: 'user-1',
tokenType: 'password_reset',
prcId: 'reset-1',
}),
},
repository: {
consumeValidVerifiedPasswordResetCode: jest.fn().mockResolvedValue({ id: 'reset-1' }),
},
});
await expect(
service.resetPassword({
resetToken: 'token',
newPassword: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
}),
).resolves.toEqual({ message: 'Password reset successfully' });
expect(users.updatePassword).toHaveBeenCalledWith('user-1', expect.any(String));
expect(repository.revokeAllUserTokens).toHaveBeenCalledWith('user-1');
});
it('lists and revokes sessions without exposing token hashes', async () => {
const createdAt = new Date();
const expiresAt = future();
const { service, repository } = createHarness({
repository: {
listUserSessions: jest.fn().mockResolvedValue([
{ jti: 'jti-1', tokenHash: 'secret', createdAt, expiresAt },
]),
listSuperAdminSessions: jest.fn().mockResolvedValue([
{ id: 'session-1', jti: 'jti-2', tokenHash: 'secret', createdAt, expiresAt },
]),
},
});
expect(await service.listUserSessions('user-1')).toEqual({
items: [{ jti: 'jti-1', createdAt, expiresAt }],
});
expect(await service.listSuperAdminSessions('root@example.com')).toEqual({
items: [{ id: 'session-1', jti: 'jti-2', createdAt, expiresAt }],
});
await service.revokeUserSession('user-1', 'jti-1');
expect(repository.revokeUserTokenByJti).toHaveBeenCalledWith('user-1', 'jti-1');
});
it('fails closed when a requested superadmin session no longer exists', async () => {
const { service } = createHarness({
repository: { revokeSuperAdminSessionById: jest.fn().mockResolvedValue(false) },
});
await expect(
service.revokeSuperAdminSession('root@example.com', 'missing'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('signup draft hardening', () => {
it('rejects consumed, mismatched and expired drafts', async () => {
const invalidDrafts = [
{ consumed: true, email: 'user@example.com', expiresAt: future(), codeExpiresAt: future() },
{ consumed: false, email: 'other@example.com', expiresAt: future(), codeExpiresAt: future() },
];
for (const draft of invalidDrafts) {
const { service } = createHarness({
repository: { findSignupDraftWithSecrets: jest.fn().mockResolvedValue(draft) },
});
await expect(
service.signupVerify({ signupId: 'draft-1', email: 'user@example.com', code: '123456' }),
).rejects.toBeInstanceOf(BadRequestException);
}
const expired = createHarness({
repository: {
findSignupDraftWithSecrets: jest.fn().mockResolvedValue({
consumed: false,
email: 'user@example.com',
expiresAt: new Date(Date.now() - 1),
codeExpiresAt: future(),
}),
},
});
await expect(
expired.service.signupVerify({
signupId: 'draft-1',
email: 'user@example.com',
code: '123456',
}),
).rejects.toBeInstanceOf(GoneException);
});
it('returns 429 before checking a draft code at the attempt limit', async () => {
const { service } = createHarness({
repository: {
findSignupDraftWithSecrets: jest.fn().mockResolvedValue({
id: 'draft-1',
consumed: false,
email: 'user@example.com',
expiresAt: future(),
codeExpiresAt: future(),
attempts: 3,
}),
},
});
await expect(
service.signupVerify({ signupId: 'draft-1', email: 'user@example.com', code: '123456' }),
).rejects.toBeInstanceOf(HttpException);
});
it('uses bounded expiration parsing and token masking helpers', () => {
const { service } = createHarness();
expect((service as any).parseExpiresInToMs('10s')).toBe(10_000);
expect((service as any).parseExpiresInToMs('5m')).toBe(300_000);
expect((service as any).parseExpiresInToMs('2h')).toBe(7_200_000);
expect((service as any).parseExpiresInToMs('1d')).toBe(86_400_000);
expect((service as any).parseExpiresInToMs('invalid')).toBe(2_592_000_000);
expect((service as any).clampMinutes(Number.NaN, 5, 30)).toBe(5);
expect((service as any).clampMinutes(99, 5, 30)).toBe(30);
expect((service as any).maskToken('short')).toBe('[redacted]');
expect((service as any).maskToken('1234567890abcdefghijkl')).toBe('12345678...efghijkl');
});
});
});
+29 -17
View File
@@ -10,7 +10,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import { randomBytes, randomInt, randomUUID } from 'crypto';
import { OAuth2Client } from 'google-auth-library';
import {
@@ -39,6 +39,7 @@ import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
import { AuthRepository } from './auth.repository';
import { AuthResult, TokenPair } from './types/token-pair.type';
import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
import { verifyTotp } from '../../common/utils/totp.util';
type UploadedImageFile = {
mimetype?: string;
@@ -335,7 +336,11 @@ export class AuthService {
throw new ForbiddenException('Account is disabled');
}
await this.authRepository.revokeUserTokenByJti(decoded.sub, decoded.jti);
const consumed = await this.authRepository.consumeUserTokenByJti(decoded.sub, decoded.jti);
if (!consumed) {
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
throw new UnauthorizedException('Refresh token reuse detected');
}
const authTokens = await this.generateAndStoreTokenPair(
decoded.sub,
safeUser.username,
@@ -440,11 +445,7 @@ export class AuthService {
});
payload = ticket.getPayload();
} catch (error) {
this.logger.warn(
`Google id token verification failed: ${this.getErrorMessage(error)}; token=${this.maskToken(
dto.idToken,
)}`,
);
this.logger.warn(`Google id token verification failed: ${this.getErrorMessage(error)}`);
throw new UnauthorizedException('Invalid Google id token');
}
@@ -467,16 +468,24 @@ export class AuthService {
}> {
const configuredEmail = this.configService.get<string>('superAdmin.email', { infer: true });
const configuredPassword = this.configService.get<string>('superAdmin.password', { infer: true });
const configuredPasswordHash = this.configService.get<string>('superAdmin.passwordHash', { infer: true });
const totpSecret = this.configService.get<string>('superAdmin.totpSecret', { infer: true }) ?? '';
const passwordMatches = configuredPasswordHash
? await compareHash(dto.password, configuredPasswordHash)
: !!configuredPassword && dto.password === configuredPassword;
if (
!configuredEmail ||
!configuredPassword ||
dto.email.toLowerCase() !== configuredEmail.toLowerCase() ||
dto.password !== configuredPassword
!passwordMatches
) {
throw new UnauthorizedException('Invalid superadmin credentials');
}
if (totpSecret && (!dto.otpCode || !verifyTotp(totpSecret, dto.otpCode))) {
throw new UnauthorizedException('Invalid superadmin verification code');
}
const tokens = await this.generateAndStoreSuperAdminTokenPair(configuredEmail);
return { ...tokens, superAdmin: { email: configuredEmail } };
}
@@ -516,7 +525,11 @@ export class AuthService {
throw new UnauthorizedException('Superadmin refresh token reuse detected');
}
await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti);
const consumed = await this.authRepository.consumeSuperAdminTokenByJti(decoded.email, decoded.jti);
if (!consumed) {
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
throw new UnauthorizedException('Superadmin refresh token reuse detected');
}
const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email);
return { ...tokens, superAdmin: { email: decoded.email } };
}
@@ -643,7 +656,7 @@ export class AuthService {
{ sub: user.id, tokenType: 'password_reset', prcId: codeRecord.id },
{
secret: this.configService.get<string>('passwordReset.tokenSecret', { infer: true }),
expiresIn: resetTokenExpiresIn,
expiresIn: resetTokenExpiresIn as JwtSignOptions['expiresIn'],
},
);
@@ -671,7 +684,7 @@ export class AuthService {
throw new UnauthorizedException('Invalid or expired reset token');
}
const codeRecord = await this.authRepository.findValidVerifiedPasswordResetCode(
const codeRecord = await this.authRepository.consumeValidVerifiedPasswordResetCode(
decoded.prcId,
decoded.sub,
);
@@ -688,7 +701,6 @@ export class AuthService {
const passwordHash = await hashValue(dto.newPassword, saltRounds);
await this.usersService.updatePassword(decoded.sub, passwordHash);
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
await this.authRepository.markPasswordResetCodeUsedByUser(decoded.sub);
await this.authRepository.revokeAllUserTokens(decoded.sub);
await this.authRepository.removeExpiredAndRevoked(decoded.sub);
@@ -707,14 +719,14 @@ export class AuthService {
{ sub: userId, username, role, tokenType: 'access' },
{
secret: this.configService.get<string>('jwt.accessSecret', { infer: true }),
expiresIn: this.configService.get<string>('jwt.accessExpiresIn', { infer: true }),
expiresIn: this.configService.get<string>('jwt.accessExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'],
},
),
this.jwtService.signAsync(
{ sub: userId, username, role, tokenType: 'refresh', jti: refreshJti },
{
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
expiresIn: this.configService.get<string>('jwt.refreshExpiresIn', { infer: true }),
expiresIn: this.configService.get<string>('jwt.refreshExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'],
},
),
]);
@@ -746,7 +758,7 @@ export class AuthService {
},
{
secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }),
expiresIn: this.configService.get<string>('superAdmin.accessExpiresIn', { infer: true }),
expiresIn: this.configService.get<string>('superAdmin.accessExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'],
},
),
this.jwtService.signAsync(
@@ -760,7 +772,7 @@ export class AuthService {
},
{
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
expiresIn: this.configService.get<string>('superAdmin.refreshExpiresIn', { infer: true }),
expiresIn: this.configService.get<string>('superAdmin.refreshExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'],
},
),
]);
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, Length } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString, Length, Matches } from 'class-validator';
export class SuperAdminLoginDto {
@ApiProperty({ example: 'admin@oudelaa.com' })
@@ -10,4 +10,10 @@ export class SuperAdminLoginDto {
@IsString()
@Length(8, 128)
password!: string;
@ApiPropertyOptional({ description: 'TOTP code; required when SUPERADMIN_TOTP_SECRET is set' })
@IsOptional()
@IsString()
@Matches(/^\d{6}$/)
otpCode?: string;
}
@@ -28,5 +28,5 @@ export class RefreshToken {
export const RefreshTokenSchema = SchemaFactory.createForClass(RefreshToken);
RefreshTokenSchema.index({ userId: 1, revoked: 1 });
RefreshTokenSchema.index({ userId: 1, jti: 1 });
RefreshTokenSchema.index({ userId: 1, jti: 1 }, { unique: true });
RefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
@@ -0,0 +1,120 @@
import { UnauthorizedException } from '@nestjs/common';
import { GoogleStrategy } from './google.strategy';
import { JwtRefreshStrategy } from './jwt-refresh.strategy';
import { JwtStrategy } from './jwt.strategy';
import { SuperAdminJwtStrategy } from './super-admin-jwt.strategy';
const config = (values: Record<string, string> = {}) => ({
get: jest.fn((key: string) => values[key] ?? 'test-secret'),
});
describe('authentication strategies', () => {
describe('JwtStrategy', () => {
it('accepts access tokens only for active users', async () => {
const repository = { findById: jest.fn().mockResolvedValue({ isDisabled: false }) };
const strategy = new JwtStrategy(
config({ 'jwt.accessSecret': 'access-secret' }) as any,
repository as any,
);
const payload = { sub: 'user-1', tokenType: 'access' } as any;
await expect(strategy.validate(payload)).resolves.toBe(payload);
expect(repository.findById).toHaveBeenCalledWith('user-1');
});
it('rejects wrong token purpose before database access', async () => {
const repository = { findById: jest.fn() };
const strategy = new JwtStrategy(config() as any, repository as any);
await expect(
strategy.validate({ sub: 'user-1', tokenType: 'refresh' } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(repository.findById).not.toHaveBeenCalled();
});
it.each([null, { isDisabled: true }])('rejects unavailable account %#', async (user) => {
const strategy = new JwtStrategy(
config() as any,
{ findById: jest.fn().mockResolvedValue(user) } as any,
);
await expect(
strategy.validate({ sub: 'user-1', tokenType: 'access' } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
describe('purpose-bound stateless strategies', () => {
it('accepts only user refresh tokens in JwtRefreshStrategy', () => {
const strategy = new JwtRefreshStrategy(config() as any);
const payload = { sub: 'user-1', tokenType: 'refresh' } as any;
expect(strategy.validate(payload)).toBe(payload);
expect(() => strategy.validate({ tokenType: 'access' } as any)).toThrow(
UnauthorizedException,
);
});
it('accepts only superadmin access tokens', () => {
const strategy = new SuperAdminJwtStrategy(config() as any);
const payload = { sub: 'superadmin', tokenType: 'superadmin_access' } as any;
expect(strategy.validate(payload)).toBe(payload);
expect(() => strategy.validate({ tokenType: 'access' } as any)).toThrow(
UnauthorizedException,
);
});
});
describe('GoogleStrategy', () => {
it('normalizes profile data before authentication', () => {
const strategy = new GoogleStrategy(config() as any);
const done = jest.fn();
strategy.validate(
'access',
'refresh',
{
id: 'google-1',
displayName: 'Google Artist',
emails: [{ value: 'ARTIST@EXAMPLE.COM', verified: true }],
photos: [{ value: 'https://example.com/avatar.jpg' }],
} as any,
done,
);
expect(done).toHaveBeenCalledWith(null, {
googleId: 'google-1',
email: 'artist@example.com',
name: 'Google Artist',
avatar: 'https://example.com/avatar.jpg',
});
});
it('rejects profiles without an email', () => {
const strategy = new GoogleStrategy(config() as any);
const done = jest.fn();
strategy.validate('access', 'refresh', { id: 'google-1' } as any, done);
expect(done).toHaveBeenCalledWith(expect.any(UnauthorizedException));
});
it('provides a safe fallback display name', () => {
const strategy = new GoogleStrategy(config() as any);
const done = jest.fn();
strategy.validate(
'access',
'refresh',
{ id: 'google-1', emails: [{ value: 'artist@example.com' }] } as any,
done,
);
expect(done).toHaveBeenCalledWith(
null,
expect.objectContaining({ name: 'Google User', avatar: undefined }),
);
});
});
});
@@ -1,7 +1,6 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
@@ -11,12 +10,11 @@ export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh'
super({
jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.refreshSecret', { infer: true }),
passReqToCallback: true,
secretOrKey: configService.get<string>('jwt.refreshSecret', { infer: true }) ?? '',
});
}
validate(_: Request, payload: JwtPayload): JwtPayload {
validate(payload: JwtPayload): JwtPayload {
if (payload.tokenType !== 'refresh') {
throw new UnauthorizedException('Invalid token type');
}
+8 -3
View File
@@ -3,22 +3,27 @@ import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
import { UsersRepository } from '../../users/users.repository';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(configService: ConfigService) {
constructor(configService: ConfigService, private readonly usersRepository: UsersRepository) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.accessSecret', { infer: true }),
secretOrKey: configService.get<string>('jwt.accessSecret', { infer: true }) ?? '',
});
}
validate(payload: JwtPayload): JwtPayload {
async validate(payload: JwtPayload): Promise<JwtPayload> {
if (payload.tokenType !== 'access') {
throw new UnauthorizedException('Invalid token type');
}
const user = await this.usersRepository.findById(payload.sub);
if (!user || user.isDisabled) {
throw new UnauthorizedException('Account is unavailable');
}
return payload;
}
}

Some files were not shown because too many files have changed in this diff Show More