diff --git a/.env.example b/.env.example index bebf437..168090a 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,19 @@ -# Server — production cutover uses PORT=3000 (replaces legacy Fastify) +# Server — production trial uses PORT=3000 PORT=3000 HOST=0.0.0.0 NODE_ENV=development -# CORS (Vue dev server) -CORS_ORIGIN=http://localhost:5173 +# CORS — exact browser origin(s); comma-separated for multiple trial hosts +CORS_ORIGIN=http://localhost:5174 -# SQLite — path inside container or on host (DB_PATH or DATABASE_PATH) +# SQLite — persistent path (container: /app/data/app.db) # DB_PATH=/app/data/app.db # DATABASE_PATH=/app/data/app.db -# JWT — must match legacy Fastify when migrating existing tokens +# JWT — required in production (min 32 chars, not a placeholder below) JWT_SECRET=gold-land-dev-secret-change-in-production + +# Only when behind a known reverse proxy (optional in development) +# TRUST_PROXY=false +# Required in production when TRUST_PROXY=true — set your proxy/Docker network CIDR(s), not empty: +# TRUST_PROXY_CIDRS=172.20.0.0/16 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8221b1b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,39 @@ +name: Backend CI + +on: + push: + pull_request: + +env: + NODE_VERSION: '22.18.0' + +jobs: + checks: + name: type-check, build, tests + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + JWT_SECRET: ci-test-jwt-not-for-production + NODE_ENV: test + steps: + - name: Checkout backend + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies (npm ci) + run: npm ci + + - name: Type check + run: npm run type-check + + - name: Build + run: npm run build + + - name: Tests (temporary SQLite only) + run: npm run test diff --git a/.gitignore b/.gitignore index 554c6b4..de79b84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.idea/ data/ .env .env.docker diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..91d5f6f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.18.0 diff --git a/Dockerfile b/Dockerfile index bd5580e..31bfd03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM node:22.12.0-bookworm-slim AS builder +FROM node:22.18.0-bookworm-slim AS builder WORKDIR /app @@ -17,7 +17,7 @@ COPY src ./src RUN npm run build -FROM node:22.12.0-bookworm-slim AS production +FROM node:22.18.0-bookworm-slim AS production WORKDIR /app @@ -48,6 +48,6 @@ USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:3000/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + CMD node -e "fetch('http://127.0.0.1:3000/health/ready').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" CMD ["node", "dist/main.js"] diff --git a/README.md b/README.md index 8247d31..85a7b6e 100644 --- a/README.md +++ b/README.md @@ -6,23 +6,111 @@ Legacy Fastify `backend/` was removed in G4 (2026-08-01). ## Requirements -- Node.js **22+** (`nvm use 22.12.0`) +- **Node.js:** `^22.18.0` or `>=24.12.0` (`package.json` → `engines`) +- **Pinned for local dev / CI:** `22.18.0` (see `.nvmrc`) + +```powershell +nvm install 22.18.0 +nvm use 22.18.0 +``` + +After switching Node on a new machine, ensure **better-sqlite3** matches the Node ABI: + +```powershell +npm ci +# if load fails: npm rebuild better-sqlite3 +``` + +On Linux CI/runners, native build needs `build-essential` and `python3`. ## Configuration -Copy `.env.example` to `.env` or use the checked-in local `.env` (gitignored): +Copy `.env.example` to `.env` for a new machine. Do not commit `.env`. -| Variable | Production cutover | +| Variable | Local development | |----------|-------------------| | `PORT` | `3000` | -| `DB_PATH` | `backend-nest/data/app.db` | -| `JWT_SECRET` | Same as legacy Fastify | -| `CORS_ORIGIN` | `http://localhost:5173` | +| `CORS_ORIGIN` | `http://localhost:5174` (Vite dev server) | +| `DB_PATH` | optional; defaults to `backend-nest/data/app.db` | +| `JWT_SECRET` | dev value in `.env.example`; required in production | -## Development +**CI/tests** use temporary SQLite paths or env overrides — never commit or upload `data/app.db`. + +## Development (this repo) ```powershell -npm run dev +npm ci +npm run build # first time or after src changes if not using dev watch +npm run dev # nest start --watch +# or run compiled: +npm run start +``` + +- Health: http://127.0.0.1:3000/health +- API prefix: `/api/...` + +## Frontend (sibling repo) + +Vue app: `gold_land_user` on Gitea — dev http://localhost:5174, proxies `/api` to this backend. + +## CI (Gitea Actions) + +Workflow: [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) (runs on **push** / **pull_request** when **act_runner** is available — **not verified** on `get.feep.ir` from this repo alone). + +### Job **checks** + +```powershell +npm ci +npm run type-check +npm run build +npm run test +``` + +- **Node:** `22.18.0` +- **Env in job:** `NODE_ENV=test`, `JWT_SECRET=ci-test-jwt-not-for-production` (not production secrets) +- **Timeout:** 25 minutes +- **Cache:** npm optional via `actions/setup-node` + +### Local equivalent + +Same commands as the CI job above. Use a **clean** tree: + +```powershell +Remove-Item -Recurse -Force node_modules -ErrorAction SilentlyContinue +npm ci +npm run type-check +npm run build +npm run test +``` + +### E2E (frontend repo) + +Browser E2E lives in **frontend** (`.gitea/workflows/ci.yml` job **e2e**). It clones this repo at a **pinned ref** (`frontend/e2e/backend.contract.json` + optional variable `E2E_BACKEND_REF`). Backend CI does not run Playwright. + +### Runner prerequisites + +- Node **22.18.0** +- Toolchain for **better-sqlite3** native module on Linux + +### Secrets + +This workflow needs **no** repository secrets. Frontend E2E may use `E2E_BACKEND_CHECKOUT_TOKEN` to clone this repo if it is private. + +### Not yet verified on a real runner + +- Workflow execution on `get.feep.ir` with registered **act_runner** +- `npm ci` + full test suite on a clean Linux runner (validated locally in a temp checkout — see stage 7 report) + +## Optional: start backend + frontend (Windows) + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\dev-local-stack.ps1 +``` + +Test ports + temp DB (does not touch `data/app.db`): + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\dev-local-stack.ps1 -UseTestPorts -SmokeTest ``` ## Production @@ -32,10 +120,43 @@ npm run build npm run start ``` +Set `NODE_ENV=production` and a strong `JWT_SECRET` via environment or `.env` on the server. + ## Tests +Uses **temporary SQLite files only** — not `data/app.db`. + ```powershell -npm run type-check -npm run test:progression -npm run test:integration +npm run test +``` + +Includes: progression, integration, concurrency, gold-precision, lesson-rewards, dual-connection. + +Removed historical `test:cutover:*` scripts — see [docs/cutover-scripts-removed.md](docs/cutover-scripts-removed.md). + +## Docker + +`docker-compose.yml` in this repo runs backend only. Full stack compose lives in the **frontend** repo. + +Trial deploy guide (6 lessons, placeholders, backup, rollback): [../frontend/docs/DEPLOY-TRIAL.md](../frontend/docs/DEPLOY-TRIAL.md) when repos are siblings, or `frontend/docs/DEPLOY-TRIAL.md` in the user repo. + +## Backup / restore (SQLite) + +WAL-safe backup (not raw copy): + +```powershell +$env:DB_PATH = ".\data\app.db" +$env:BACKUP_DIR = ".\backups" +$env:BACKUP_RETENTION_DAYS = "14" +npm run backup:sqlite +npm run restore:sqlite -- backups\app-....db .\restored-trial.db +``` + +**Live database path:** the script cannot restore directly onto configured `DB_PATH`. Stop the service, restore to a sibling file, swap files, remove stale `-wal`/`-shm`. See [docs/restore-production-db.md](docs/restore-production-db.md). `RESTORE_ACK_STOPPED=YES` is operator acknowledgment only — not a service stop detector. + +## Production smoke (local, temp DB) + +```powershell +npm run build +npm run smoke:trial-production ``` diff --git a/docs/cutover-scripts-removed.md b/docs/cutover-scripts-removed.md new file mode 100644 index 0000000..b68e899 --- /dev/null +++ b/docs/cutover-scripts-removed.md @@ -0,0 +1,17 @@ +# Cutover npm scripts (removed) + +Historical migration scripts lived at `scripts/cutover/` (workspace parent of `backend-nest`). That folder was not recovered after the 2026-08 incident. + +The following **npm scripts were removed** from `package.json` because they pointed at missing files: + +- `test:cutover:g1a` +- `test:cutover:g1b` +- `test:cutover:g1b2b` +- `test:cutover:g1b:dry-run` + +**Use instead:** + +- `npm run test:progression` — Gold Land progression, grader, `reward_ledger` +- `npm run test:integration` — auth, economy, API contracts (temporary SQLite only) + +To recover old cutover tooling, restore from Git history or the backend remote archive; do not re-add broken script paths without the files. diff --git a/docs/restore-production-db.md b/docs/restore-production-db.md new file mode 100644 index 0000000..9c77154 --- /dev/null +++ b/docs/restore-production-db.md @@ -0,0 +1,73 @@ +# Restore SQLite onto the live database path + +The restore script **refuses to write directly** to the path in `DB_PATH` while that variable still points at the live file. That avoids accidental overwrite with the service running. + +`RESTORE_ACK_STOPPED=YES` is an **operator acknowledgment** only — the script does **not** detect whether the backend process is stopped. You must stop the service yourself before swapping files. + +## Prerequisites + +- Backend **stopped** (no open handles on `app.db`, `-wal`, `-shm`). +- A validated backup from `npm run backup:sqlite` (WAL-safe, schema-checked). +- Shell access to the data directory (host path or mounted volume). + +## Recommended procedure (same logical database) + +Replace `/app/data/app.db` with your paths. + +1. **Stop backend** + + ```powershell + docker compose stop backend + # or: stop the Node process / systemd unit + ``` + +2. **Optional safety backup of current file** + + ```powershell + $env:DB_PATH = "/app/data/app.db" + $env:BACKUP_DIR = "./backups" + npm run backup:sqlite + ``` + +3. **Restore to a sibling file** (not the live `DB_PATH` target) + + ```powershell + npm run restore:sqlite -- ./backups/app-2026-01-01T12-00-00.db /app/data/app-restored.db + ``` + + Use `RESTORE_ACK_STOPPED=YES` if `/app/data/app-restored.db` already exists from a prior attempt. + +4. **Verify** (optional second check) + + ```powershell + node -e "import('./scripts/sqlite-app-schema.mjs').then(m=>console.log(m.validateAppSqliteFile('/app/data/app-restored.db')))" + ``` + +5. **Swap files** (preserve previous DB on disk) + + ```powershell + mv /app/data/app.db /app/data/app.db.pre-restore- + rm -f /app/data/app.db-wal /app/data/app.db-shm + mv /app/data/app-restored.db /app/data/app.db + ``` + +6. **Start backend** and check readiness + + ```powershell + docker compose start backend + curl http://127.0.0.1:/health/ready + ``` + +## What the script enforces + +| Rule | Meaning | +|------|---------| +| No restore when `target === DB_PATH` | Use a sibling path, then rename after stop | +| `RESTORE_ACK_STOPPED=YES` if target exists | Confirms you intend to overwrite an existing file | +| Schema + `integrity_check` | Backup must match app migrations | +| Failed validation | Staging removed; live file unchanged | +| Existing target | Moved to `*.pre-restore-.bak` before replace | + +## Development + +Restoring to a **new temp path** does not require stopping production. See `npm run restore:sqlite -- backup.db ./tmp/restored.db`. diff --git a/package.json b/package.json index 66135d9..2391a39 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,23 @@ "start": "node dist/main.js", "start:prod": "node dist/main.js", "type-check": "tsc --noEmit -p tsconfig.json", - "test:integration": "tsx test/integration.test.ts", - "test:progression": "tsx test/progression.test.ts", - "test:cutover:g1a": "tsx ../scripts/cutover/run-g1a.ts", - "test:cutover:g1b": "tsx ../scripts/cutover/run-g1b.ts", - "test:cutover:g1b2b": "tsx ../scripts/cutover/run-g1b2b.ts", - "test:cutover:g1b:dry-run": "tsx ../scripts/cutover/g1b-dry-run.ts" + "test:integration": "tsx --tsconfig tsconfig.json test/integration.test.ts", + "test:progression": "tsx --tsconfig tsconfig.json test/progression.test.ts", + "test:concurrency": "tsx --tsconfig tsconfig.json test/concurrency.test.ts", + "test:gold-precision": "tsx --tsconfig tsconfig.json test/gold-precision.test.ts", + "test:lesson-rewards": "tsx --tsconfig tsconfig.json test/lesson-rewards-api.test.ts", + "test:dual-connection": "tsx --tsconfig tsconfig.json test/dual-connection.test.ts", + "test:production-config": "tsx --tsconfig tsconfig.json test/production-config.test.ts", + "test:auth-rate-limit": "tsx --tsconfig tsconfig.json test/auth-rate-limit.test.ts", + "test:client-ip": "tsx --tsconfig tsconfig.json test/client-ip.test.ts", + "test:auth-rate-limit-xff": "tsx --tsconfig tsconfig.json test/auth-rate-limit-xff.test.ts", + "test:restore-safety": "tsx --tsconfig tsconfig.json test/restore-safety.test.ts", + "test:production-error": "tsx --tsconfig tsconfig.json test/production-error.test.ts", + "backup:sqlite": "node scripts/sqlite-backup.mjs", + "restore:sqlite": "node scripts/sqlite-restore.mjs", + "smoke:trial-production": "node scripts/trial-production-smoke.mjs", + "test:backup-restore": "tsx --tsconfig tsconfig.json test/backup-restore.test.ts", + "test": "npm run test:progression && npm run test:integration && npm run test:concurrency && npm run test:gold-precision && npm run test:lesson-rewards && npm run test:dual-connection && npm run test:production-config && npm run test:production-error && npm run test:client-ip && npm run test:auth-rate-limit && npm run test:auth-rate-limit-xff && npm run test:backup-restore && npm run test:restore-safety" }, "dependencies": { "@nestjs/common": "^11.0.12", diff --git a/scripts/dev-local-stack.ps1 b/scripts/dev-local-stack.ps1 new file mode 100644 index 0000000..b9a49a7 --- /dev/null +++ b/scripts/dev-local-stack.ps1 @@ -0,0 +1,280 @@ +# Starts Child Gold Land backend + frontend for local dev. +# Manages only processes started by this script. No npm install / rebuild on each run. +# +# Usage: +# pwsh -File .\scripts\dev-local-stack.ps1 +# pwsh -File .\scripts\dev-local-stack.ps1 -UseTestPorts -SmokeTest # alt ports + health/login probe then exit +# +# Default ports: backend 3000, frontend 5174. Test ports: 3010 / 5184 (when 3000/5174 are busy). + +param( + [switch]$UseTestPorts, + [switch]$SmokeTest +) + +$ErrorActionPreference = 'Stop' + +$BackendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$WorkspaceRoot = (Resolve-Path (Join-Path $BackendRoot '..')).Path +$FrontendRoot = Join-Path $WorkspaceRoot 'frontend' + +if ($UseTestPorts) { + $BackendPort = 3010 + $FrontendPort = 5190 +} else { + $BackendPort = 3000 + $FrontendPort = 5174 +} + +$HealthUrl = "http://127.0.0.1:${BackendPort}/health" +$FrontendUrl = "http://localhost:${FrontendPort}/" +$LoginProxyUrl = "http://localhost:${FrontendPort}/api/auth/login" + +$script:ManagedProcesses = @() +$script:TempDbDir = $null + +function Write-Info([string]$Message) { Write-Host $Message -ForegroundColor Cyan } +function Write-Warn([string]$Message) { Write-Host $Message -ForegroundColor Yellow } +function Write-Err([string]$Message) { Write-Host $Message -ForegroundColor Red } + +function Test-NodeEngines { + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + throw 'Node.js not found in PATH. Install Node ^22.18.0 or >=24.12.0 (see .nvmrc).' + } + $raw = (node -v).TrimStart('v') + $ver = [version]$raw + $ok = ($ver.Major -eq 22 -and $ver -ge [version]'22.18.0') -or ($ver.Major -ge 24 -and $ver -ge [version]'24.12.0') + if (-not $ok) { + throw "Node v$raw is not supported. Required: ^22.18.0 or >=24.12.0. Run: nvm install 22.18.0 && nvm use 22.18.0" + } + Write-Info "Node $(node -v) at $((Get-Command node).Source)" +} + +function Test-RepoLayout { + if (-not (Test-Path (Join-Path $BackendRoot 'package.json'))) { + throw "Backend package.json not found at $BackendRoot" + } + if (-not (Test-Path (Join-Path $FrontendRoot 'package.json'))) { + throw "Frontend not found at `"$FrontendRoot`" (expected sibling of backend-nest)." + } +} + +function Test-DependenciesPresent { + if (-not (Test-Path (Join-Path $BackendRoot 'node_modules'))) { + throw "Backend node_modules missing. Run once: cd `"$BackendRoot`"; npm install" + } + if (-not (Test-Path (Join-Path $FrontendRoot 'node_modules'))) { + throw "Frontend node_modules missing. Run once: cd `"$FrontendRoot`"; npm install" + } +} + +function Get-ListenProcessId([int]$Port) { + $conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($conn) { return [int]$conn.OwningProcess } + return $null +} + +function Assert-PortFree([int]$Port, [string]$Label) { + $ownerPid = Get-ListenProcessId $Port + if ($null -ne $ownerPid) { + $name = (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue).ProcessName + throw "${Label} port $Port is in use (PID $ownerPid, $name). Use -UseTestPorts or free the port." + } +} + +function New-BackendProcessEnvironment { + $envMap = @{} + foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) { + $envMap[$entry.Key] = [string]$entry.Value + } + $envMap['PORT'] = [string]$BackendPort + $envMap['HOST'] = '0.0.0.0' + $envMap['NODE_ENV'] = 'development' + $envMap['CORS_ORIGIN'] = "http://localhost:${FrontendPort}" + if ($UseTestPorts) { + $script:TempDbDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cgl-devstack-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $script:TempDbDir -Force | Out-Null + $envMap['DB_PATH'] = Join-Path $script:TempDbDir 'app.db' + Write-Info "Test backend DB: $($envMap['DB_PATH'])" + } + return $envMap +} + +function Start-ProcessWithEnv { + param( + [string]$FilePath, + [string[]]$ArgumentList, + [string]$WorkingDirectory, + [hashtable]$Environment + ) + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $FilePath + $psi.WorkingDirectory = $WorkingDirectory + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + if ($ArgumentList -and $ArgumentList.Count -gt 0) { + $quoted = $ArgumentList | ForEach-Object { + if ($_ -match '\s') { "`"$_`"" } else { $_ } + } + $psi.Arguments = $quoted -join ' ' + } + foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) { + $psi.EnvironmentVariables[[string]$entry.Key] = [string]$entry.Value + } + foreach ($key in $Environment.Keys) { + $psi.EnvironmentVariables[$key] = [string]$Environment[$key] + } + $proc = [System.Diagnostics.Process]::Start($psi) + if (-not $proc) { + throw "Failed to start process: $FilePath" + } + return $proc +} + +function Start-Backend { + $mainJs = Join-Path $BackendRoot 'dist\main.js' + if (-not (Test-Path $mainJs)) { + throw "Missing dist/main.js. Run once: cd `"$BackendRoot`"; npm run build" + } + $node = (Get-Command node).Source + $envMap = New-BackendProcessEnvironment + Write-Info "Starting backend on port $BackendPort ..." + $p = Start-ProcessWithEnv -FilePath $node -ArgumentList @($mainJs) -WorkingDirectory $BackendRoot -Environment $envMap + $script:ManagedProcesses += $p + Start-Sleep -Milliseconds 500 + if ($p.HasExited) { + throw "Backend exited immediately (exit $($p.ExitCode)). Build with current Node and check dist/main.js." + } + return $p +} + +function Start-Frontend { + $npm = (Get-Command npm.cmd -ErrorAction SilentlyContinue).Source + if (-not $npm) { + $npm = (Get-Command npm -ErrorAction SilentlyContinue).Source + } + if (-not $npm) { + throw 'npm not found in PATH.' + } + $envMap = @{} + foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) { + $envMap[$entry.Key] = [string]$entry.Value + } + $envMap['VITE_DEV_PORT'] = [string]$FrontendPort + $envMap['VITE_DEV_API_TARGET'] = "http://127.0.0.1:${BackendPort}" + Write-Info "Starting frontend on port $FrontendPort (proxy -> $($envMap['VITE_DEV_API_TARGET'])) ..." + $p = Start-ProcessWithEnv -FilePath $npm -ArgumentList @('run', 'dev', '--', '--port', [string]$FrontendPort) ` + -WorkingDirectory $FrontendRoot -Environment $envMap + $script:ManagedProcesses += $p + return $p +} + +function Wait-BackendHealth { + param([int]$TimeoutSeconds = 90) + Write-Info "Waiting for backend health (max ${TimeoutSeconds}s) ..." + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + foreach ($p in $script:ManagedProcesses) { + if ($p.HasExited) { + throw "Managed process exited early (PID $($p.Id), exit $($p.ExitCode))." + } + } + try { + $r = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 3 + if ($r.StatusCode -eq 200) { + Write-Info "Backend health OK: $HealthUrl" + return + } + } catch { + Start-Sleep -Seconds 2 + } + } + throw "Backend health timed out: $HealthUrl" +} + +function Wait-FrontendHttp { + param([int]$TimeoutSeconds = 120) + Write-Info "Waiting for frontend (max ${TimeoutSeconds}s) ..." + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + try { + $r = Invoke-WebRequest -Uri $FrontendUrl -UseBasicParsing -TimeoutSec 3 + if ($r.StatusCode -eq 200) { + Write-Info "Frontend OK: $FrontendUrl" + return + } + } catch { + Start-Sleep -Seconds 2 + } + } + throw "Frontend HTTP timed out: $FrontendUrl" +} + +function Test-ProxyLogin { + $body = '{"username":"demo","password":"123456"}' + $r = Invoke-WebRequest -Uri $LoginProxyUrl -Method POST -Body $body -ContentType 'application/json' -UseBasicParsing + if ($r.StatusCode -ne 200) { + throw "Proxy login failed: HTTP $($r.StatusCode)" + } + Write-Info "Proxy login OK: $LoginProxyUrl" +} + +function Stop-ManagedProcesses { + foreach ($p in $script:ManagedProcesses) { + if ($p -and -not $p.HasExited) { + Write-Info "Stopping PID $($p.Id) ..." + try { + & taskkill.exe /PID $p.Id /T /F 2>$null | Out-Null + } catch { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + } + } + $script:ManagedProcesses = @() + if ($script:TempDbDir -and (Test-Path $script:TempDbDir)) { + Remove-Item -Recurse -Force $script:TempDbDir -ErrorAction SilentlyContinue + $script:TempDbDir = $null + } +} + +try { + Write-Info 'Child Gold Land — local dev stack' + if ($UseTestPorts) { Write-Warn 'Using test ports 3010 / 5190 (temp SQLite when backend starts fresh DB).' } + Test-NodeEngines + Test-RepoLayout + Test-DependenciesPresent + Assert-PortFree -Port $BackendPort -Label 'Backend' + Assert-PortFree -Port $FrontendPort -Label 'Frontend' + + Start-Backend | Out-Null + Start-Frontend | Out-Null + Wait-BackendHealth + if ($SmokeTest) { + Wait-FrontendHttp + Test-ProxyLogin + Write-Info 'SmokeTest passed.' + exit 0 + } + + Write-Host '' + Write-Info "Frontend: $FrontendUrl" + Write-Info "Backend health: $HealthUrl" + Write-Info 'Demo login: demo / 123456' + Write-Warn 'Press Ctrl+C to stop only processes started by this script.' + Write-Host '' + + while ($true) { + Start-Sleep -Seconds 2 + foreach ($p in $script:ManagedProcesses) { + if ($p.HasExited) { + Write-Err "Process PID $($p.Id) exited unexpectedly (code $($p.ExitCode))." + exit 1 + } + } + } +} catch { + Write-Err $_.Exception.Message + exit 1 +} finally { + Stop-ManagedProcesses +} diff --git a/scripts/sqlite-app-schema.mjs b/scripts/sqlite-app-schema.mjs new file mode 100644 index 0000000..bd3dcda --- /dev/null +++ b/scripts/sqlite-app-schema.mjs @@ -0,0 +1,65 @@ +/** + * Validates that a SQLite file matches Child Gold Land backend schema expectations. + */ +import Database from 'better-sqlite3' + +export const EXPECTED_TABLES = [ + 'users', + 'user_level_progress', + 'reward_ledger', + 'auth_accounts', +] + +/** Keep in sync with src/database/migrations/index.ts */ +export const EXPECTED_MIGRATION_IDS = ['001_initial_schema'] + +/** + * @param {string} dbPath + * @returns {{ ok: true } | { ok: false; reason: string }} + */ +export function validateAppSqliteFile(dbPath) { + let db + try { + db = new Database(dbPath, { readonly: true }) + } catch (error) { + return { ok: false, reason: `cannot open database: ${error instanceof Error ? error.message : error}` } + } + + try { + const integrity = db.pragma('integrity_check', { simple: true }) + if (integrity !== 'ok') { + return { ok: false, reason: `integrity_check failed: ${integrity}` } + } + + const tables = db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'", + ) + .all() + .map((r) => r.name) + + for (const name of EXPECTED_TABLES) { + if (!tables.includes(name)) { + return { ok: false, reason: `missing table: ${name}` } + } + } + + const migTable = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'") + .get() + if (!migTable) { + return { ok: false, reason: 'missing schema_migrations table' } + } + + const applied = db.prepare('SELECT id FROM schema_migrations ORDER BY id').all().map((r) => r.id) + for (const id of EXPECTED_MIGRATION_IDS) { + if (!applied.includes(id)) { + return { ok: false, reason: `missing migration: ${id}` } + } + } + + return { ok: true } + } finally { + db.close() + } +} diff --git a/scripts/sqlite-backup.mjs b/scripts/sqlite-backup.mjs new file mode 100644 index 0000000..df80972 --- /dev/null +++ b/scripts/sqlite-backup.mjs @@ -0,0 +1,53 @@ +/** + * WAL-safe SQLite backup via better-sqlite3 backup API (not a raw file copy). + * Usage: DB_PATH=./data/app.db BACKUP_DIR=./backups node scripts/sqlite-backup.mjs + */ +import Database from 'better-sqlite3' +import { mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +const srcPath = process.env.DB_PATH ?? join(process.cwd(), 'data', 'app.db') +const backupDir = process.env.BACKUP_DIR ?? join(process.cwd(), 'backups') +const retentionDays = Number(process.env.BACKUP_RETENTION_DAYS ?? '0') + +if (!existsSync(srcPath)) { + console.error(`Source database not found: ${srcPath}`) + process.exit(1) +} + +mkdirSync(backupDir, { recursive: true }) + +const stamp = new Date().toISOString().replace(/[:.]/g, '-') +const destPath = join(backupDir, `app-${stamp}.db`) + +const src = new Database(srcPath, { readonly: true }) +try { + await src.backup(destPath) +} finally { + src.close() +} + +const verify = new Database(destPath, { readonly: true }) +try { + const integrity = verify.pragma('integrity_check', { simple: true }) + if (integrity !== 'ok') { + console.error(`Backup integrity check failed: ${integrity}`) + process.exit(1) + } +} finally { + verify.close() +} + +console.log(`Backup written: ${destPath}`) + +if (retentionDays > 0) { + const cutoff = Date.now() - retentionDays * 86_400_000 + for (const name of readdirSync(backupDir)) { + if (!name.startsWith('app-') || !name.endsWith('.db')) continue + const full = join(backupDir, name) + if (statSync(full).mtimeMs < cutoff) { + unlinkSync(full) + console.log(`Removed old backup: ${name}`) + } + } +} diff --git a/scripts/sqlite-restore.mjs b/scripts/sqlite-restore.mjs new file mode 100644 index 0000000..9578d6a --- /dev/null +++ b/scripts/sqlite-restore.mjs @@ -0,0 +1,109 @@ +/** + * Restore backup to a target path (service must be stopped). + * Usage: node scripts/sqlite-restore.mjs + */ +import Database from 'better-sqlite3' +import { + copyFileSync, + existsSync, + mkdirSync, + renameSync, + rmSync, + statSync, +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { validateAppSqliteFile } from './sqlite-app-schema.mjs' + +const backupPath = resolve(process.argv[2] ?? '') +const targetPath = resolve(process.argv[3] ?? '') + +if (!backupPath || !targetPath) { + console.error('Usage: node scripts/sqlite-restore.mjs ') + process.exit(1) +} + +if (!existsSync(backupPath)) { + console.error(`Backup not found: ${backupPath}`) + process.exit(1) +} + +const activeDb = resolve(process.env.DB_PATH ?? join(process.cwd(), 'data', 'app.db')) +const targetExists = existsSync(targetPath) + +if (targetPath === activeDb) { + console.error('Restore onto active DB_PATH is not allowed while it is configured as the live database.') + console.error('Stop the backend service, point DB_PATH elsewhere, or restore to a temp file first.') + process.exit(1) +} + +if (targetExists && process.env.RESTORE_ACK_STOPPED !== 'YES') { + console.error( + 'Target database file already exists. Stop the backend service and set RESTORE_ACK_STOPPED=YES to continue.', + ) + process.exit(1) +} + +const schema = validateAppSqliteFile(backupPath) +if (!schema.ok) { + console.error(`Backup rejected: ${schema.reason}`) + process.exit(1) +} + +const staging = `${targetPath}.restore-staging` +const stagingWal = `${staging}-wal` +const stagingShm = `${staging}-shm` + +function cleanupStaging() { + for (const p of [staging, stagingWal, stagingShm]) { + try { + if (existsSync(p)) rmSync(p) + } catch { + // ignore + } + } +} + +cleanupStaging() + +try { + copyFileSync(backupPath, staging) + const stagedCheck = validateAppSqliteFile(staging) + if (!stagedCheck.ok) { + console.error(`Staging validation failed: ${stagedCheck.reason}`) + cleanupStaging() + process.exit(1) + } + + const verify = new Database(staging, { readonly: true }) + try { + const integrity = verify.pragma('integrity_check', { simple: true }) + if (integrity !== 'ok') { + console.error(`Staging integrity check failed: ${integrity}`) + cleanupStaging() + process.exit(1) + } + } finally { + verify.close() + } + + for (const sidecar of [`${targetPath}-wal`, `${targetPath}-shm`]) { + try { + if (existsSync(sidecar)) rmSync(sidecar) + } catch { + // ignore + } + } + + if (targetExists) { + const backupOfTarget = `${targetPath}.pre-restore-${Date.now()}.bak` + renameSync(targetPath, backupOfTarget) + console.log(`Existing target moved to ${backupOfTarget}`) + } + + renameSync(staging, targetPath) + console.log(`Restored ${backupPath} -> ${targetPath}`) +} catch (error) { + cleanupStaging() + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} diff --git a/scripts/trial-production-smoke.mjs b/scripts/trial-production-smoke.mjs new file mode 100644 index 0000000..606271f --- /dev/null +++ b/scripts/trial-production-smoke.mjs @@ -0,0 +1,102 @@ +/** + * Smoke-test production env on a temp DB (no deploy). + * Requires: npm run build first. + */ +import { spawn, spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +if (!existsSync(join(backendRoot, 'dist', 'main.js'))) { + console.error('Run npm run build before trial-production-smoke.mjs') + process.exit(1) +} + +const tempDir = mkdtempSync(join(tmpdir(), 'cgl-prod-smoke-')) +const dbPath = join(tempDir, 'app.db') +const port = String(3020 + Math.floor(Math.random() * 100)) +const secret = `trial-smoke-${'x'.repeat(40)}` + +function fail(msg) { + console.error(msg) + rmSync(tempDir, { recursive: true, force: true }) + process.exit(1) +} + +const bad = spawnSync(process.execPath, ['dist/main.js'], { + cwd: backendRoot, + env: { + ...process.env, + NODE_ENV: 'production', + PORT: port, + DB_PATH: dbPath, + JWT_SECRET: 'gold-land-dev-secret-change-in-production', + CORS_ORIGIN: 'http://127.0.0.1:8080', + }, + stdio: 'pipe', + encoding: 'utf8', + timeout: 8000, +}) + +if (bad.status === 0) { + fail('Expected startup failure with dev JWT placeholder in production') +} + +async function waitForReady() { + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['dist/main.js'], { + cwd: backendRoot, + env: { + ...process.env, + NODE_ENV: 'production', + PORT: port, + DB_PATH: dbPath, + JWT_SECRET: secret, + CORS_ORIGIN: 'http://127.0.0.1:8080', + }, + stdio: 'pipe', + }) + + const timer = setTimeout(() => { + child.kill() + reject(new Error('startup timeout')) + }, 20_000) + + const poll = async () => { + try { + const live = await fetch(`http://127.0.0.1:${port}/health`) + const ready = await fetch(`http://127.0.0.1:${port}/health/ready`) + if (live.ok && ready.ok) { + clearTimeout(timer) + child.kill() + resolve(undefined) + return + } + } catch { + // retry + } + setTimeout(poll, 300) + } + setTimeout(poll, 500) + }) +} + +await waitForReady().catch((e) => fail(String(e))) + +const demoCheck = spawnSync( + process.execPath, + [ + '-e', + `const D=require('better-sqlite3');const d=new D(${JSON.stringify(dbPath)});const row=d.prepare("SELECT COUNT(*) as c FROM auth_accounts WHERE username='demo'").get();console.log(row.c);d.close();`, + ], + { cwd: backendRoot, encoding: 'utf8' }, +) +const demoCount = Number(demoCheck.stdout.trim()) +if (demoCount !== 0) { + fail(`Expected no demo account in production DB, found ${demoCount}`) +} + +rmSync(tempDir, { recursive: true, force: true }) +console.log('trial-production-smoke passed') diff --git a/src/auth/auth-seed.service.ts b/src/auth/auth-seed.service.ts index 463a91e..b182457 100644 --- a/src/auth/auth-seed.service.ts +++ b/src/auth/auth-seed.service.ts @@ -8,6 +8,10 @@ export class AuthSeedService implements OnModuleInit { constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {} onModuleInit(): void { + if (process.env.NODE_ENV === 'production') { + this.logger.log('Auth seed skipped in production (no demo account)') + return + } this.auth.seedDemoAccount() this.logger.log('Auth seed completed (demo account if missing)') } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index bb7273e..7930f0b 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,5 +1,8 @@ -import { Body, Controller, HttpCode, Inject, Post, forwardRef } from '@nestjs/common' +import { Body, Controller, HttpCode, Inject, Post, Req, forwardRef } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' import { Public } from '../common/decorators/public.decorator' +import { getClientIp } from '../common/http/client-ip' +import { assertAuthRateLimit } from '../common/rate-limit/auth-rate-limit' import { strictBodyPipe } from '../common/pipes/strict-body.pipe' import { AuthService } from './auth.service' import { LoginDto } from './dto/login.dto' @@ -12,14 +15,16 @@ export class AuthController { @Public() @HttpCode(200) @Post('register') - register(@Body(strictBodyPipe(RegisterDto)) dto: RegisterDto) { + register(@Req() req: FastifyRequest, @Body(strictBodyPipe(RegisterDto)) dto: RegisterDto) { + assertAuthRateLimit(getClientIp(req)) return this.auth.register(dto.username, dto.password, dto.name) } @Public() @HttpCode(200) @Post('login') - login(@Body(strictBodyPipe(LoginDto)) dto: LoginDto) { + login(@Req() req: FastifyRequest, @Body(strictBodyPipe(LoginDto)) dto: LoginDto) { + assertAuthRateLimit(getClientIp(req)) return this.auth.login(dto.username, dto.password) } } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 8ceb5a7..bbb6ced 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -99,6 +99,10 @@ export class AuthService { } seedDemoAccount(): void { + if (process.env.NODE_ENV === 'production') { + return + } + const demoPasswordHash = bcrypt.hashSync('123456', 10) const demo = this.findAccountByUsername('demo') diff --git a/src/common/filters/api-exception.filter.ts b/src/common/filters/api-exception.filter.ts index a90e373..22cd752 100644 --- a/src/common/filters/api-exception.filter.ts +++ b/src/common/filters/api-exception.filter.ts @@ -7,6 +7,7 @@ import { Logger, } from '@nestjs/common' import type { FastifyReply } from 'fastify' +import { safeErrorMessage } from '../http/safe-log' interface ApiErrorBody { error: string @@ -19,43 +20,53 @@ export class ApiExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp() const reply = ctx.getResponse() + const isProduction = process.env.NODE_ENV === 'production' if (exception instanceof HttpException) { const status = exception.getStatus() - const message = this.extractMessage(exception) + const message = this.extractMessage(exception, isProduction) reply.status(status).send({ error: message } satisfies ApiErrorBody) return } - this.logger.error(exception) + this.logger.error(`Unhandled error: ${safeErrorMessage(exception)}`) reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({ error: 'خطای سرور', } satisfies ApiErrorBody) } - private extractMessage(exception: HttpException): string { + private extractMessage(exception: HttpException, isProduction: boolean): string { const response = exception.getResponse() if (typeof response === 'string') { - return response + return isProduction ? this.sanitizeClientMessage(response) : response } if (typeof response === 'object' && response !== null) { const body = response as Record if (typeof body.message === 'string') { - return body.message + return isProduction ? this.sanitizeClientMessage(body.message) : body.message } if (Array.isArray(body.message) && body.message.length > 0) { - return body.message.map(String).join('؛ ') + const joined = body.message.map(String).join('؛ ') + return isProduction ? this.sanitizeClientMessage(joined) : joined } if (typeof body.error === 'string') { - return body.error + return isProduction ? this.sanitizeClientMessage(body.error) : body.error } } return 'درخواست نامعتبر است' } + + /** Avoid leaking SQL paths, stack fragments, or driver names to clients in production. */ + private sanitizeClientMessage(message: string): string { + if (/sqlite|SQLITE|better-sqlite|ENOENT|EACCES|stack|at\s+\S+\.(ts|js)/i.test(message)) { + return 'درخواست نامعتبر است' + } + return message + } } diff --git a/src/common/http/client-ip.ts b/src/common/http/client-ip.ts new file mode 100644 index 0000000..2e5b95e --- /dev/null +++ b/src/common/http/client-ip.ts @@ -0,0 +1,90 @@ +import type { FastifyRequest } from 'fastify' + +/** Private ranges for Docker / RFC1918 — peer must match before X-Forwarded-For is used. */ +const DEFAULT_TRUSTED_PROXY_CIDRS = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.0/8'] + +function parseCidrList(raw: string | undefined): string[] { + if (!raw?.trim()) { + if (process.env.NODE_ENV === 'production') return [] + return DEFAULT_TRUSTED_PROXY_CIDRS + } + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +} + +function ipv4ToInt(ip: string): number | null { + const parts = ip.split('.') + if (parts.length !== 4) return null + let n = 0 + for (const p of parts) { + const oct = Number(p) + if (!Number.isInteger(oct) || oct < 0 || oct > 255) return null + n = (n << 8) + oct + } + return n >>> 0 +} + +function normalizePeerIp(raw: string | undefined): string { + if (!raw) return '' + if (raw.startsWith('::ffff:')) return raw.slice('::ffff:'.length) + return raw +} + +function ipInCidr(ip: string, cidr: string): boolean { + const [net, bitsStr] = cidr.split('/') + const bits = Number(bitsStr) + if (!net || !Number.isFinite(bits) || bits < 0 || bits > 32) return false + const ipInt = ipv4ToInt(ip) + const netInt = ipv4ToInt(net) + if (ipInt === null || netInt === null) return false + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0 + return (ipInt & mask) === (netInt & mask) +} + +function isTrustedProxyPeer(peerIp: string, cidrs: string[]): boolean { + const ip = normalizePeerIp(peerIp) + if (!ip) return false + return cidrs.some((cidr) => ipInCidr(ip, cidr)) +} + +function trustProxyEnabled(): boolean { + return process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1' +} + +/** + * Client IP for rate limiting. + * X-Forwarded-For is honored only when the immediate TCP peer is a trusted proxy (nginx on Docker network). + * Direct/untrusted connections ignore client-supplied XFF (mitigates header spoofing). + */ +export function getClientIp(req: FastifyRequest): string { + const peer = normalizePeerIp(req.socket.remoteAddress) || 'unknown' + + if (!trustProxyEnabled()) { + return peer + } + + const cidrs = parseCidrList(process.env.TRUST_PROXY_CIDRS) + if (!isTrustedProxyPeer(peer, cidrs)) { + return peer + } + + const xff = req.headers['x-forwarded-for'] + if (typeof xff === 'string' && xff.trim()) { + const client = xff.split(',')[0]?.trim() + if (client) return client + } + + const realIp = req.headers['x-real-ip'] + if (typeof realIp === 'string' && realIp.trim()) { + return realIp.trim() + } + + return peer +} + +export function resetClientIpConfigForTests(): void { + delete process.env.TRUST_PROXY + delete process.env.TRUST_PROXY_CIDRS +} diff --git a/src/common/http/safe-log.ts b/src/common/http/safe-log.ts new file mode 100644 index 0000000..9431d68 --- /dev/null +++ b/src/common/http/safe-log.ts @@ -0,0 +1,27 @@ +const SENSITIVE_KEY = /password|authorization|token|secret|jwt|cookie/i + +export function sanitizeForLog(value: unknown): unknown { + if (value === null || value === undefined) return value + if (typeof value === 'string') { + if (value.length > 200 && /^Bearer\s+/i.test(value)) return '[Bearer redacted]' + return value + } + if (Array.isArray(value)) return value.map(sanitizeForLog) + if (typeof value === 'object') { + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + if (SENSITIVE_KEY.test(k)) { + out[k] = '[redacted]' + } else { + out[k] = sanitizeForLog(v) + } + } + return out + } + return value +} + +export function safeErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} diff --git a/src/common/rate-limit/auth-rate-limit.ts b/src/common/rate-limit/auth-rate-limit.ts new file mode 100644 index 0000000..79dc8f3 --- /dev/null +++ b/src/common/rate-limit/auth-rate-limit.ts @@ -0,0 +1,29 @@ +import { HttpException, HttpStatus } from '@nestjs/common' + +type Bucket = { count: number; resetAt: number } + +const buckets = new Map() + +export function resetAuthRateLimitForTests(): void { + buckets.clear() +} + +const WINDOW_MS = 60_000 +const MAX_ATTEMPTS = 30 + +/** In-memory limit for login/register per client key (IP). Suitable for single-instance trial deploy. */ +export function assertAuthRateLimit(clientKey: string): void { + const key = clientKey.trim() || 'unknown' + const now = Date.now() + let bucket = buckets.get(key) + + if (!bucket || now >= bucket.resetAt) { + bucket = { count: 0, resetAt: now + WINDOW_MS } + buckets.set(key, bucket) + } + + bucket.count += 1 + if (bucket.count > MAX_ATTEMPTS) { + throw new HttpException('تعداد درخواست زیاد است؛ کمی بعد دوباره تلاش کن.', HttpStatus.TOO_MANY_REQUESTS) + } +} diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 860eb7d..2f94e34 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,6 +1,15 @@ import { plainToInstance } from 'class-transformer' import { IsIn, IsInt, IsOptional, IsString, Min, validateSync } from 'class-validator' +const DEV_JWT_FALLBACK = 'gold-land-dev-secret-change-in-production' + +const FORBIDDEN_PRODUCTION_JWT_SECRETS = new Set([ + DEV_JWT_FALLBACK, + 'e2e-local-jwt-not-for-production', + 'e2e-ci-jwt-not-for-production', + 'ci-test-jwt-not-for-production', +]) + export class EnvironmentVariables { @IsIn(['development', 'production', 'test']) NODE_ENV!: 'development' | 'production' | 'test' @@ -27,14 +36,42 @@ export class EnvironmentVariables { @IsOptional() @IsString() JWT_SECRET?: string + + /** When `true`, Fastify trusts X-Forwarded-* (only enable behind a known reverse proxy). */ + @IsOptional() + @IsString() + TRUST_PROXY?: string +} + +function assertProductionJwtSecret(secret: string): void { + const trimmed = secret.trim() + if (trimmed.length < 32) { + throw new Error('JWT_SECRET must be at least 32 characters when NODE_ENV=production') + } + if (FORBIDDEN_PRODUCTION_JWT_SECRETS.has(trimmed)) { + throw new Error('JWT_SECRET is a known development placeholder — set a unique production secret') + } } export function validateEnv(config: Record): EnvironmentVariables { const nodeEnv = (config.NODE_ENV as string | undefined) ?? 'development' const jwtSecret = config.JWT_SECRET as string | undefined - if (nodeEnv === 'production' && (!jwtSecret || jwtSecret.trim().length === 0)) { - throw new Error('JWT_SECRET is required when NODE_ENV=production') + const trustProxyEnabled = + config.TRUST_PROXY === 'true' || + config.TRUST_PROXY === '1' || + config.TRUST_PROXY === true + + if (nodeEnv === 'production') { + if (!jwtSecret || jwtSecret.trim().length === 0) { + throw new Error('JWT_SECRET is required when NODE_ENV=production') + } + assertProductionJwtSecret(jwtSecret) + if (trustProxyEnabled && !(config.TRUST_PROXY_CIDRS as string | undefined)?.trim()) { + throw new Error( + 'TRUST_PROXY_CIDRS is required when TRUST_PROXY is enabled in production (set the reverse-proxy network CIDR, not broad defaults)', + ) + } } if (nodeEnv !== 'production' && !jwtSecret) { @@ -45,11 +82,12 @@ export function validateEnv(config: Record): EnvironmentVariabl const normalized = { NODE_ENV: nodeEnv, - PORT: Number(config.PORT ?? 3001), + PORT: Number(config.PORT ?? 3000), HOST: (config.HOST as string | undefined) ?? '0.0.0.0', - CORS_ORIGIN: (config.CORS_ORIGIN as string | undefined) ?? 'http://localhost:5173', + CORS_ORIGIN: (config.CORS_ORIGIN as string | undefined) ?? 'http://localhost:5174', DB_PATH: (config.DB_PATH ?? config.DATABASE_PATH) as string | undefined, - JWT_SECRET: jwtSecret ?? 'gold-land-dev-secret-change-in-production', + JWT_SECRET: jwtSecret ?? DEV_JWT_FALLBACK, + TRUST_PROXY: (config.TRUST_PROXY as string | undefined) ?? 'false', } const validated = plainToInstance(EnvironmentVariables, normalized, { diff --git a/src/database/database.service.ts b/src/database/database.service.ts index 5306be8..b905f4b 100644 --- a/src/database/database.service.ts +++ b/src/database/database.service.ts @@ -23,7 +23,16 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy { mkdirSync(dirname(this.dbPath), { recursive: true }) this.db = new Database(this.dbPath) this.db.pragma('journal_mode = WAL') - const applied = runSchemaMigrations(this.db) + this.db.pragma('busy_timeout = 5000') + let applied: string[] + try { + applied = runSchemaMigrations(this.db) + } catch (error) { + this.logger.error('SQLite migration failed', error instanceof Error ? error.stack : error) + throw new Error( + `SQLite migration failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } if (applied.length > 0) { this.logger.log(`Applied migrations: ${applied.join(', ')}`) } @@ -49,8 +58,9 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy { return this.db } + /** IMMEDIATE acquires the write lock before reads — avoids lost updates on state_json RMW. */ transaction(fn: () => T): T { - return this.getDb().transaction(fn)() + return this.getDb().transaction(fn).immediate() } verifyMigrationsApplied(): { ok: boolean; tables: string[]; missing: string[] } { diff --git a/src/gold-land/services/progression.service.ts b/src/gold-land/services/progression.service.ts index 7143a13..bdb6e37 100644 --- a/src/gold-land/services/progression.service.ts +++ b/src/gold-land/services/progression.service.ts @@ -173,10 +173,12 @@ export class GoldLandProgressionService { } syncUserProgressionState(userId: string): UserState { - const state = this.users.getUserState(userId) - const items = this.buildLevelItems(userId) - this.syncLegacyMapLevels(state, items) - return this.users.saveUserState(state) + return this.database.transaction(() => { + const state = this.users.getUserState(userId) + const items = this.buildLevelItems(userId) + this.syncLegacyMapLevels(state, items) + return this.users.saveUserState(state) + }) } getGoldLandProgress(userId: string) { @@ -238,38 +240,40 @@ export class GoldLandProgressionService { } startLevel(userId: string, levelId: number) { - this.assertLevelPlayable(userId, levelId) + return this.database.transaction(() => { + this.assertLevelPlayable(userId, levelId) - const definition = getLevelDefinition(levelId) - if (!definition?.isActive) { - throw new ProgressionError(403, 'این مرحله به‌زودی باز می‌شود') - } + const definition = getLevelDefinition(levelId) + if (!definition?.isActive) { + throw new ProgressionError(403, 'این مرحله به‌زودی باز می‌شود') + } - const now = new Date().toISOString() - const existing = this.getProgressRow(userId, levelId) - const db = this.database.getDb() + const now = new Date().toISOString() + const existing = this.getProgressRow(userId, levelId) + const db = this.database.getDb() - if (existing?.completed_at) { - db.prepare(` - UPDATE user_level_progress - SET attempts_count = attempts_count + 1, started_at = ?, updated_at = ? - WHERE user_id = ? AND level_id = ? - `).run(now, now, userId, levelId) - } else if (existing) { - db.prepare(` - UPDATE user_level_progress - SET started_at = COALESCE(started_at, ?), updated_at = ? - WHERE user_id = ? AND level_id = ? - `).run(now, now, userId, levelId) - } else { - db.prepare(` - INSERT INTO user_level_progress ( - user_id, level_id, best_score, stars, attempts_count, started_at, updated_at - ) VALUES (?, ?, 0, 0, 1, ?, ?) - `).run(userId, levelId, now, now) - } + if (existing?.completed_at) { + db.prepare(` + UPDATE user_level_progress + SET attempts_count = attempts_count + 1, started_at = ?, updated_at = ? + WHERE user_id = ? AND level_id = ? + `).run(now, now, userId, levelId) + } else if (existing) { + db.prepare(` + UPDATE user_level_progress + SET started_at = COALESCE(started_at, ?), updated_at = ? + WHERE user_id = ? AND level_id = ? + `).run(now, now, userId, levelId) + } else { + db.prepare(` + INSERT INTO user_level_progress ( + user_id, level_id, best_score, stars, attempts_count, started_at, updated_at + ) VALUES (?, ?, 0, 0, 1, ?, ?) + `).run(userId, levelId, now, now) + } - return { levelId, status: 'in_progress' as const } + return { levelId, status: 'in_progress' as const } + }) } completeLevel( @@ -290,16 +294,15 @@ export class GoldLandProgressionService { throw new ProgressionError(400, 'امتیاز کافی برای تکمیل مرحله نبود. دوباره تلاش کن!') } - const existing = this.getProgressRow(userId, levelId) - const previousStars = (existing?.stars ?? 0) as 0 | 1 | 2 | 3 - const previousBestScore = existing?.best_score ?? 0 - const bestScore = Math.max(previousBestScore, score) - const bestScoreImproved = score > previousBestScore - const finalStars = Math.max(previousStars, newStars) as 0 | 1 | 2 | 3 - const isFirstCompletion = !existing?.completed_at - const now = new Date().toISOString() - return this.database.transaction(() => { + const existing = this.getProgressRow(userId, levelId) + const previousStars = (existing?.stars ?? 0) as 0 | 1 | 2 | 3 + const previousBestScore = existing?.best_score ?? 0 + const bestScore = Math.max(previousBestScore, score) + const bestScoreImproved = score > previousBestScore + const finalStars = Math.max(previousStars, newStars) as 0 | 1 | 2 | 3 + const isFirstCompletion = !existing?.completed_at + const now = new Date().toISOString() const db = this.database.getDb() if (existing) { diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 828f3a5..37375f4 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,9 +1,13 @@ -import { Controller, Get } from '@nestjs/common' +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common' import { Public } from '../common/decorators/public.decorator' +import { DatabaseService } from '../database/database.service' @Public() @Controller() export class HealthController { + constructor(private readonly database: DatabaseService) {} + + /** Process is up (liveness). Does not verify database. */ @Get('health') getHealth() { return { @@ -12,4 +16,20 @@ export class HealthController { runtime: 'nest', } } + + /** DB migrations applied (readiness for traffic / compose depends_on). */ + @Get('health/ready') + getReady() { + const check = this.database.verifyMigrationsApplied() + if (!check.ok) { + throw new ServiceUnavailableException({ + status: 'not_ready', + missingTables: check.missing, + }) + } + return { + status: 'ready', + service: 'child-gold-land-backend', + } + } } diff --git a/src/health/health.module.ts b/src/health/health.module.ts index c40b3b7..ee84f36 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common' +import { DatabaseModule } from '../database/database.module' import { HealthController } from './health.controller' @Module({ + imports: [DatabaseModule], controllers: [HealthController], }) export class HealthModule {} diff --git a/src/main.ts b/src/main.ts index d51af9b..4e34b3f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,10 +6,21 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify import { AppModule } from './app.module' import { ApiExceptionFilter } from './common/filters/api-exception.filter' +function parseCorsOrigins(raw: string): string | string[] { + const parts = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + if (parts.length === 0) return 'http://localhost:5174' + if (parts.length === 1) return parts[0]! + return parts +} + async function bootstrap() { + // Rate-limit IP: getClientIp() + TRUST_PROXY_CIDRS — not global Fastify trustProxy. const app = await NestFactory.create( AppModule, - new FastifyAdapter(), + new FastifyAdapter({ trustProxy: false }), ) app.enableShutdownHooks() @@ -17,7 +28,7 @@ async function bootstrap() { const config = app.get(ConfigService) app.enableCors({ - origin: config.get('CORS_ORIGIN'), + origin: parseCorsOrigins(config.get('CORS_ORIGIN') ?? 'http://localhost:5174'), credentials: true, }) diff --git a/src/progression/progression-sync.service.ts b/src/progression/progression-sync.service.ts index b2d9ed9..18015fb 100644 --- a/src/progression/progression-sync.service.ts +++ b/src/progression/progression-sync.service.ts @@ -46,10 +46,12 @@ export class ProgressionSyncService { ) {} syncUserProgressionState(userId: string): UserState { - const state = this.users.getUserState(userId) - const items = this.buildLevelItems(userId) - this.syncLegacyMapLevels(state, items) - return this.users.saveUserState(state) + return this.database.transaction(() => { + const state = this.users.getUserState(userId) + const items = this.buildLevelItems(userId) + this.syncLegacyMapLevels(state, items) + return this.users.saveUserState(state) + }) } private getProgressRow(userId: string, levelId: number): LevelProgressRow | undefined { diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 19723d3..ec8d753 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -90,10 +90,12 @@ export class UsersService { } updateProfile(userId: string, name: string): MeResponse { - const state = this.getUserState(userId) - state.name = name.trim() - this.saveUserState(state) - return this.toMeResponse(state) + return this.database.transaction(() => { + const state = this.getUserState(userId) + state.name = name.trim() + this.saveUserState(state) + return this.toMeResponse(state) + }) } private parseStateJson(raw: string, userId: string): UserState { diff --git a/src/wallet/dto/buy-gold.dto.ts b/src/wallet/dto/buy-gold.dto.ts index 6719f68..f97bd6c 100644 --- a/src/wallet/dto/buy-gold.dto.ts +++ b/src/wallet/dto/buy-gold.dto.ts @@ -1,6 +1,9 @@ -import { IsNumber } from 'class-validator' +import { IsNumber, Max, Min } from 'class-validator' +import { MAX_GOLD_TRADE_GRAMS, MIN_GOLD_TRADE_GRAMS } from '../gold-trade.helpers' export class BuyGoldDto { @IsNumber({ allowNaN: false, allowInfinity: false }) + @Min(MIN_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' }) + @Max(MAX_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' }) grams!: number } diff --git a/src/wallet/dto/sell-gold.dto.ts b/src/wallet/dto/sell-gold.dto.ts index 169fef5..dc28362 100644 --- a/src/wallet/dto/sell-gold.dto.ts +++ b/src/wallet/dto/sell-gold.dto.ts @@ -1,6 +1,9 @@ -import { IsNumber } from 'class-validator' +import { IsNumber, Max, Min } from 'class-validator' +import { MAX_GOLD_TRADE_GRAMS, MIN_GOLD_TRADE_GRAMS } from '../gold-trade.helpers' export class SellGoldDto { @IsNumber({ allowNaN: false, allowInfinity: false }) + @Min(MIN_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' }) + @Max(MAX_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' }) grams!: number } diff --git a/src/wallet/gold-trade.helpers.ts b/src/wallet/gold-trade.helpers.ts new file mode 100644 index 0000000..f7956cc --- /dev/null +++ b/src/wallet/gold-trade.helpers.ts @@ -0,0 +1,113 @@ +import { BadRequestException } from '@nestjs/common' +import { GOLD_PRICE } from '../shared/catalog/market.constants' + +/** + * Gold trade policy (Child Gold Land): + * - cash: integer tomans in user state + * - goldGrams: grams stored with at most 6 decimal places (1 µg = 10⁻⁶ g) + * - buy notional: ceil(grams × GOLD_PRICE) tomans (no rounding arbitrage vs sell) + * - sell notional: floor(grams × GOLD_PRICE) tomans + * - tomans computed via integer microGrams × GOLD_PRICE (BigInt) when product exceeds 2⁵³ + * - maximum grams per request: 10_000 + */ +export const GOLD_GRAMS_DECIMALS = 6 +export const MICROGRAMS_PER_GRAM = 1_000_000 +export const MAX_GOLD_TRADE_GRAMS = 10_000 +export const GOLD_PRICE_TOMAN_PER_GRAM = GOLD_PRICE + +/** Smallest trade size allowed by DTO (6 decimal grams). Min tomans checked separately for buy/sell. */ +export const MIN_GOLD_TRADE_GRAMS = 0.000_001 + +const MAX_MICROGRAMS = MAX_GOLD_TRADE_GRAMS * MICROGRAMS_PER_GRAM + +export function normalizeGoldGrams(grams: number): number { + const factor = 10 ** GOLD_GRAMS_DECIMALS + return Math.round(grams * factor) / factor +} + +export function gramsToMicroGrams(normalizedGrams: number): number { + return Math.round(normalizedGrams * MICROGRAMS_PER_GRAM) +} + +/** Integer-safe tomans numerator: microGrams × GOLD_PRICE (may exceed Number.MAX_SAFE_INTEGER). */ +function tomansNumeratorMicro(microGrams: number): bigint { + return BigInt(microGrams) * BigInt(GOLD_PRICE_TOMAN_PER_GRAM) +} + +export function tomansForGoldPurchase(grams: number): number { + const normalized = normalizeGoldGrams(grams) + const micro = gramsToMicroGrams(normalized) + const num = tomansNumeratorMicro(micro) + return Number((num + BigInt(MICROGRAMS_PER_GRAM - 1)) / BigInt(MICROGRAMS_PER_GRAM)) +} + +export function tomansForGoldSale(grams: number): number { + const micro = gramsToMicroGrams(normalizeGoldGrams(grams)) + const num = tomansNumeratorMicro(micro) + return Number(num / BigInt(MICROGRAMS_PER_GRAM)) +} + +/** @deprecated Symmetric round — only for documenting pre-fix regression; do not use in trades. */ +export function tomansForGoldGramsRound(grams: number): number { + return Math.round(normalizeGoldGrams(grams) * GOLD_PRICE_TOMAN_PER_GRAM) +} + +function validateTradeGramsInput(grams: number): number { + if (typeof grams !== 'number' || !Number.isFinite(grams)) { + throw new BadRequestException('مقدار نامعتبر است') + } + if (grams <= 0) { + throw new BadRequestException('مقدار نامعتبر است') + } + if (grams > MAX_GOLD_TRADE_GRAMS) { + throw new BadRequestException('مقدار نامعتبر است') + } + + const normalized = normalizeGoldGrams(grams) + if (Math.abs(grams - normalized) > 1 / 10 ** (GOLD_GRAMS_DECIMALS + 2)) { + throw new BadRequestException('دقت مقدار طلا بیش از حد مجاز است') + } + + const micro = gramsToMicroGrams(normalized) + if (micro <= 0 || micro > MAX_MICROGRAMS) { + throw new BadRequestException('مقدار نامعتبر است') + } + + return normalized +} + +function assertMinPurchaseTomans(normalized: number): void { + if (tomansForGoldPurchase(normalized) < 1) { + throw new BadRequestException('مبلغ معامله کمتر از یک تومان است') + } +} + +function assertMinSaleTomans(normalized: number): void { + if (tomansForGoldSale(normalized) < 1) { + throw new BadRequestException('مبلغ فروش کمتر از یک تومان است') + } +} + +export function applyGoldPurchase(state: { cash: number; goldGrams: number }, grams: number): number { + const normalized = validateTradeGramsInput(grams) + assertMinPurchaseTomans(normalized) + const cost = tomansForGoldPurchase(normalized) + if (state.cash < cost) { + throw new BadRequestException('موجودی نقد کافی نیست') + } + state.cash -= cost + state.goldGrams = normalizeGoldGrams(state.goldGrams + normalized) + return cost +} + +export function applyGoldSale(state: { cash: number; goldGrams: number }, grams: number): number { + const normalized = validateTradeGramsInput(grams) + assertMinSaleTomans(normalized) + if (normalizeGoldGrams(state.goldGrams) < normalized) { + throw new BadRequestException('طلای کافی نداری') + } + const value = tomansForGoldSale(normalized) + state.goldGrams = normalizeGoldGrams(state.goldGrams - normalized) + state.cash += value + return value +} diff --git a/src/wallet/wallet.service.ts b/src/wallet/wallet.service.ts index a0742b6..dcc9903 100644 --- a/src/wallet/wallet.service.ts +++ b/src/wallet/wallet.service.ts @@ -1,9 +1,9 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { Inject, Injectable } from '@nestjs/common' import { DatabaseService } from '../database/database.service' -import { GOLD_PRICE } from '../shared/catalog/market.constants' import type { MeResponse, Transaction } from '../shared/types/user.types' import { UsersService } from '../users/users.service' import { addTransaction, incrementMission, unlockAchievement } from '../shared/economy/user-economy.helpers' +import { applyGoldPurchase, applyGoldSale } from './gold-trade.helpers' @Injectable() export class WalletService { @@ -20,18 +20,7 @@ export class WalletService { buyGold(userId: string, grams: number): MeResponse { return this.database.transaction(() => { const state = this.users.getUserState(userId) - const pricePerGram = GOLD_PRICE - const cost = grams * pricePerGram - - if (grams <= 0) { - throw new BadRequestException('مقدار نامعتبر است') - } - if (state.cash < cost) { - throw new BadRequestException('موجودی نقد کافی نیست') - } - - state.cash -= cost - state.goldGrams += grams + const cost = applyGoldPurchase(state, grams) addTransaction(state, { type: 'expense', title: 'خرید طلا', @@ -48,18 +37,7 @@ export class WalletService { sellGold(userId: string, grams: number): MeResponse { return this.database.transaction(() => { const state = this.users.getUserState(userId) - const pricePerGram = GOLD_PRICE - const value = grams * pricePerGram - - if (grams <= 0) { - throw new BadRequestException('مقدار نامعتبر است') - } - if (state.goldGrams < grams) { - throw new BadRequestException('طلای کافی نداری') - } - - state.goldGrams -= grams - state.cash += value + const value = applyGoldSale(state, grams) addTransaction(state, { type: 'income', title: 'فروش طلا', diff --git a/test/auth-rate-limit-xff.test.ts b/test/auth-rate-limit-xff.test.ts new file mode 100644 index 0000000..f6cdb76 --- /dev/null +++ b/test/auth-rate-limit-xff.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import type { FastifyRequest } from 'fastify' +import { getClientIp } from '../src/common/http/client-ip' +import { + assertAuthRateLimit, + resetAuthRateLimitForTests, +} from '../src/common/rate-limit/auth-rate-limit' + +resetAuthRateLimitForTests() +process.env.TRUST_PROXY = 'true' + +const attackerPeer = '203.0.113.99' +const req = { + socket: { remoteAddress: attackerPeer }, + headers: { 'x-forwarded-for': 'fresh-spoofed-ip-1' }, +} as FastifyRequest + +const ip = getClientIp(req) +assert.equal(ip, attackerPeer, 'forged XFF must not become rate-limit key') + +for (let i = 0; i < 30; i++) { + assertAuthRateLimit(ip) +} +let blocked = false +try { + assertAuthRateLimit(ip) + blocked = true +} catch (error) { + assert.equal((error as { status?: number }).status, 429) +} +assert.equal(blocked, false, 'rate limit must apply to real peer IP') + +resetAuthRateLimitForTests() +delete process.env.TRUST_PROXY + +console.log('auth-rate-limit-xff tests passed') diff --git a/test/auth-rate-limit.test.ts b/test/auth-rate-limit.test.ts new file mode 100644 index 0000000..510c465 --- /dev/null +++ b/test/auth-rate-limit.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import { assertAuthRateLimit } from '../src/common/rate-limit/auth-rate-limit' + +let threw = false +for (let i = 0; i < 35; i++) { + try { + assertAuthRateLimit('test-client-429') + } catch (error) { + threw = true + assert.equal((error as { status?: number }).status, 429) + break + } +} +assert.equal(threw, true, 'expected rate limit after repeated auth attempts') + +console.log('auth-rate-limit tests passed') diff --git a/test/backup-restore.test.ts b/test/backup-restore.test.ts new file mode 100644 index 0000000..2ce7ce8 --- /dev/null +++ b/test/backup-restore.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import Database from 'better-sqlite3' +import { up, migrationId } from '../src/database/migrations/001-initial-schema' + +const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const root = mkdtempSync(join(tmpdir(), 'cgl-backup-')) +const srcDb = join(root, 'source.db') +const backupDir = join(root, 'backups') +const restoreTarget = join(root, 'restored.db') + +try { + const db = new Database(srcDb) + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL + ); + `) + db.exec(up) + db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run( + migrationId, + new Date().toISOString(), + ) + db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run( + 'u1', + '{}', + new Date().toISOString(), + ) + db.close() + + let r = spawnSync('node', ['scripts/sqlite-backup.mjs'], { + cwd: backendRoot, + env: { ...process.env, DB_PATH: srcDb, BACKUP_DIR: backupDir }, + encoding: 'utf8', + }) + assert.equal(r.status, 0, r.stderr || r.stdout) + + const backupFile = readdirSync(backupDir).find((f) => f.endsWith('.db')) + assert.ok(backupFile, 'backup file created') + + r = spawnSync( + 'node', + ['scripts/sqlite-restore.mjs', join(backupDir, backupFile!), restoreTarget], + { + cwd: backendRoot, + env: { ...process.env, RESTORE_ACK_STOPPED: 'YES' }, + encoding: 'utf8', + }, + ) + assert.equal(r.status, 0, r.stderr || r.stdout) + assert.ok(existsSync(restoreTarget)) + + const restored = new Database(restoreTarget, { readonly: true }) + const row = restored.prepare('SELECT id FROM users WHERE id = ?').get('u1') as { id: string } + restored.close() + assert.equal(row.id, 'u1') +} finally { + try { + rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 200 }) + } catch { + // Windows may briefly lock SQLite files after close. + } +} + +console.log('backup-restore tests passed') diff --git a/test/client-ip.test.ts b/test/client-ip.test.ts new file mode 100644 index 0000000..f7d42d7 --- /dev/null +++ b/test/client-ip.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import type { FastifyRequest } from 'fastify' +import { getClientIp, resetClientIpConfigForTests } from '../src/common/http/client-ip' + +function mockReq(peer: string, headers: Record = {}): FastifyRequest { + return { + socket: { remoteAddress: peer }, + headers, + } as FastifyRequest +} + +resetClientIpConfigForTests() +process.env.TRUST_PROXY = 'true' + +const untrustedPeer = '203.0.113.50' +const trustedPeer = '172.20.0.5' + +assert.equal( + getClientIp(mockReq(untrustedPeer, { 'x-forwarded-for': '1.2.3.4' })), + untrustedPeer, + 'untrusted peer must ignore forged XFF', +) + +assert.equal( + getClientIp(mockReq(trustedPeer, { 'x-forwarded-for': '198.51.100.10' })), + '198.51.100.10', + 'trusted proxy peer may use first XFF hop', +) + +delete process.env.TRUST_PROXY +assert.equal( + getClientIp(mockReq(trustedPeer, { 'x-forwarded-for': '198.51.100.10' })), + trustedPeer, + 'TRUST_PROXY off ignores XFF even from docker peer', +) + +console.log('client-ip tests passed') diff --git a/test/concurrency.test.ts b/test/concurrency.test.ts new file mode 100644 index 0000000..c84e7b6 --- /dev/null +++ b/test/concurrency.test.ts @@ -0,0 +1,294 @@ +/** + * Concurrency / RMW safety — temp SQLite only. + * Run: npm run test:concurrency + */ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ValidationPipe } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify' +import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter' +import { DatabaseService } from '../src/database/database.service' +import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader' +import { loadLessonForLevel } from '../src/gold-land/lessons/lesson.loader' +import { createDefaultUserState } from '../src/shared/catalog/default-user-state' + +const testDir = mkdtempSync(join(tmpdir(), 'gold-land-concurrency-')) +process.env.DB_PATH = join(testDir, 'test.db') +process.env.NODE_ENV = 'test' +process.env.PORT = '3997' +process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production' + +function parseJson(payload: string): T { + return JSON.parse(payload) as T +} + +function authHeader(token: string) { + return { authorization: `Bearer ${token}` } +} + +async function createApp(): Promise { + const { AppModule } = await import('../src/app.module') + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() + const app = moduleRef.createNestApplication(new FastifyAdapter()) + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), + ) + app.useGlobalFilters(new ApiExceptionFilter()) + await app.init() + await app.getHttpAdapter().getInstance().ready() + return app +} + +async function registerUser(app: NestFastifyApplication, username: string): Promise { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username, password: '123456', name: username }, + }) + assert.ok(res.statusCode === 200 || res.statusCode === 201, `register status ${res.statusCode}`) + return parseJson<{ token: string }>(res.payload).token +} + +function setUserCoins(db: DatabaseService, userId: string, coins: number) { + const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as + | { state_json: string } + | undefined + assert.ok(row) + const state = JSON.parse(row.state_json) as ReturnType + state.coins = coins + db.getDb() + .prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?') + .run(JSON.stringify(state), new Date().toISOString(), userId) +} + +function setUserCash(db: DatabaseService, userId: string, cash: number, goldGrams = 0) { + const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as + | { state_json: string } + | undefined + assert.ok(row) + const state = JSON.parse(row.state_json) as ReturnType + state.cash = cash + state.goldGrams = goldGrams + db.getDb() + .prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?') + .run(JSON.stringify(state), new Date().toISOString(), userId) +} + +function readCoins(db: DatabaseService, userId: string): number { + const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as { + state_json: string + } + return (JSON.parse(row.state_json) as { coins: number }).coins +} + +function countLedger(db: DatabaseService, userId: string, rewardKey: string): number { + const row = db + .getDb() + .prepare('SELECT COUNT(*) AS c FROM reward_ledger WHERE user_id = ? AND reward_key = ?') + .get(userId, rewardKey) as { c: number } + return row.c +} + +async function runTests() { + const app = await createApp() + const db = app.get(DatabaseService) + + try { + // --- parallel store purchases: combined cost exceeds balance --- + { + const token = await registerUser(app, `store-race-${Date.now()}`) + const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) }) + const userId = parseJson<{ user: { id: string } }>(me.payload).user.id + setUserCoins(db, userId, 6000) + + const [castle, crown] = await Promise.all([ + app.inject({ + method: 'POST', + url: '/api/store/purchases', + headers: authHeader(token), + payload: { productId: 'castle' }, + }), + app.inject({ + method: 'POST', + url: '/api/store/purchases', + headers: authHeader(token), + payload: { productId: 'crown' }, + }), + ]) + + const okCount = [castle, crown].filter((r) => r.statusCode === 200).length + const failCount = [castle, crown].filter((r) => r.statusCode >= 400).length + assert.equal(okCount, 1) + assert.equal(failCount, 1) + const finalCoins = readCoins(db, userId) + assert.ok(finalCoins >= 0) + assert.ok(finalCoins < 6000) + } + + // --- parallel lesson completes: first-completion ledger once --- + { + const token = await registerUser(app, `lesson-race-${Date.now()}`) + const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) }) + const userId = parseJson<{ user: { id: string } }>(me.payload).user.id + + await app.inject({ + method: 'POST', + url: '/api/gold-land/levels/1/start', + headers: authHeader(token), + }) + + const lesson = loadLessonForLevel('what-is-money') + assert.ok(lesson) + const answers = getGradableQuestions(lesson).map(({ questionId, step }) => { + if (step.type === 'sort') { + return { questionId, orderedItemIds: [...step.correctOrder] } + } + return { questionId, answerId: step.correctAnswerId } + }) + const payload = JSON.stringify({ answers, durationMs: 3000 }) + + const [a, b] = await Promise.all([ + app.inject({ + method: 'POST', + url: '/api/gold-land/levels/1/complete', + headers: { ...authHeader(token), 'content-type': 'application/json' }, + payload, + }), + app.inject({ + method: 'POST', + url: '/api/gold-land/levels/1/complete', + headers: { ...authHeader(token), 'content-type': 'application/json' }, + payload, + }), + ]) + + assert.ok(a.statusCode === 200 || b.statusCode === 200) + assert.equal(countLedger(db, userId, 'level:1:first-completion'), 1) + } + + // --- parallel daily challenge completes --- + { + const token = await registerUser(app, `challenge-race-${Date.now()}`) + const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) }) + const userId = parseJson<{ user: { id: string } }>(me.payload).user.id + const coinsBefore = readCoins(db, userId) + + const [a, b] = await Promise.all([ + app.inject({ + method: 'POST', + url: '/api/challenges/daily/complete', + headers: authHeader(token), + payload: { optionId: 'gold' }, + }), + app.inject({ + method: 'POST', + url: '/api/challenges/daily/complete', + headers: authHeader(token), + payload: { optionId: 'gold' }, + }), + ]) + + assert.equal(a.statusCode, 200) + assert.equal(b.statusCode, 200) + const coinsAfter = readCoins(db, userId) + assert.equal(coinsAfter, coinsBefore + 50) + } + + // --- parallel gold buys: only one should succeed when cash fits one --- + { + const token = await registerUser(app, `wallet-race-${Date.now()}`) + const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) }) + const userId = parseJson<{ user: { id: string } }>(me.payload).user.id + const { GOLD_PRICE } = await import('../src/shared/catalog/market.constants') + setUserCash(db, userId, GOLD_PRICE * 2, 0) + + const [buy1, buy2] = await Promise.all([ + app.inject({ + method: 'POST', + url: '/api/wallet/gold/buy', + headers: authHeader(token), + payload: { grams: 2 }, + }), + app.inject({ + method: 'POST', + url: '/api/wallet/gold/buy', + headers: authHeader(token), + payload: { grams: 2 }, + }), + ]) + + const results = [buy1, buy2] + const ok = results.filter((r) => r.statusCode === 200).length + const bad = results.filter((r) => r.statusCode >= 400).length + assert.equal(ok, 1) + assert.equal(bad, 1) + const state = JSON.parse( + (db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as { + state_json: string + }).state_json, + ) as { cash: number; goldGrams: number } + assert.ok(state.cash >= 0) + assert.equal(state.goldGrams, 2) + assert.ok(state.cash < GOLD_PRICE * 2) + } + + // --- parallel game complete: first-completion reward once --- + { + const token = await registerUser(app, `game-race-${Date.now()}`) + const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) }) + const userId = parseJson<{ user: { id: string; coins: number; xp: number } }>(me.payload).user + const coinsBefore = userId.coins + const xpBefore = userId.xp + + const [g1, g2] = await Promise.all([ + app.inject({ + method: 'POST', + url: '/api/games/gold-guess/complete', + headers: authHeader(token), + payload: {}, + }), + app.inject({ + method: 'POST', + url: '/api/games/gold-guess/complete', + headers: authHeader(token), + payload: {}, + }), + ]) + + assert.equal(g1.statusCode, 200) + assert.equal(g2.statusCode, 200) + const after = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) }) + const body = parseJson<{ user: { coins: number; xp: number } }>(after.payload) + assert.equal(body.user.coins, coinsBefore + 20) + assert.equal(body.user.xp, xpBefore + 50) + } + + // --- coming_soon level cannot start --- + { + const token = await registerUser(app, `locked7-${Date.now()}`) + const res = await app.inject({ + method: 'POST', + url: '/api/gold-land/levels/7/start', + headers: authHeader(token), + }) + assert.equal(res.statusCode, 403) + } + + console.log('concurrency tests passed') + } finally { + await app.close() + try { + rmSync(testDir, { recursive: true, force: true }) + } catch { + // ignore + } + } +} + +runTests().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/test/dual-connection.test.ts b/test/dual-connection.test.ts new file mode 100644 index 0000000..d810bd3 --- /dev/null +++ b/test/dual-connection.test.ts @@ -0,0 +1,97 @@ +/** + * Two independent SQLite connections on one file — one writer wins, no corrupt cash. + * Run: npm run test:dual-connection + */ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import Database from 'better-sqlite3' +import { runSchemaMigrations } from '../src/database/migration.service' +import { createDefaultUserState } from '../src/shared/catalog/default-user-state' + +const testDir = mkdtempSync(join(tmpdir(), 'gold-land-dual-sqlite-')) +const dbPath = join(testDir, 'dual.db') +const userId = 'dual-user' + +function seedUser(db: Database.Database) { + const state = createDefaultUserState(userId) + state.cash = 1000 + state.goldGrams = 0 + db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run( + userId, + JSON.stringify(state), + new Date().toISOString(), + ) +} + +function readCash(): number { + const db = new Database(dbPath, { readonly: true }) + const row = db.prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as { + state_json: string + } + db.close() + return (JSON.parse(row.state_json) as { cash: number }).cash +} + +function spendInWorker(amount: number): Promise<'ok' | 'fail'> { + return new Promise((resolve, reject) => { + const worker = new Worker( + ` + const Database = require('better-sqlite3'); + const { parentPort, workerData } = require('worker_threads'); + const db = new Database(workerData.dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); + const spend = db.transaction(() => { + const row = db.prepare('SELECT state_json FROM users WHERE id = ?').get(workerData.userId); + const state = JSON.parse(row.state_json); + if (state.cash < workerData.amount) return false; + state.cash -= workerData.amount; + db.prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?').run( + JSON.stringify(state), + new Date().toISOString(), + workerData.userId, + ); + return true; + }).immediate; + parentPort.postMessage(spend() ? 'ok' : 'fail'); + db.close(); + `, + { eval: true, workerData: { dbPath, userId, amount } }, + ) + worker.on('message', (msg) => resolve(msg as 'ok' | 'fail')) + worker.on('error', reject) + }) +} + +async function runTests() { + const bootstrap = new Database(dbPath) + bootstrap.pragma('journal_mode = WAL') + runSchemaMigrations(bootstrap) + seedUser(bootstrap) + bootstrap.close() + + const [a, b] = await Promise.all([spendInWorker(700), spendInWorker(700)]) + + const outcomes = [a, b] + assert.equal(outcomes.filter((x) => x === 'ok').length, 1) + assert.equal(outcomes.filter((x) => x === 'fail').length, 1) + + const finalCash = readCash() + assert.equal(finalCash, 300) + assert.ok(finalCash >= 0) + + try { + rmSync(testDir, { recursive: true, force: true }) + } catch { + // ignore + } + console.log('dual-connection tests passed') +} + +runTests().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/test/gold-precision.test.ts b/test/gold-precision.test.ts new file mode 100644 index 0000000..976b95c --- /dev/null +++ b/test/gold-precision.test.ts @@ -0,0 +1,276 @@ +/** + * Gold trade precision + no rounding-arbitrage — temp SQLite only. + * Run: npm run test:gold-precision + */ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ValidationPipe, BadRequestException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify' +import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter' +import { + applyGoldPurchase, + applyGoldSale, + gramsToMicroGrams, + normalizeGoldGrams, + tomansForGoldGramsRound, + tomansForGoldPurchase, + tomansForGoldSale, + GOLD_PRICE_TOMAN_PER_GRAM, + MAX_GOLD_TRADE_GRAMS, +} from '../src/wallet/gold-trade.helpers' +import { createDefaultUserState } from '../src/shared/catalog/default-user-state' +import { GOLD_PRICE } from '../src/shared/catalog/market.constants' +import { DatabaseService } from '../src/database/database.service' + +const testDir = mkdtempSync(join(tmpdir(), 'gold-land-gold-precision-')) +process.env.DB_PATH = join(testDir, 'test.db') +process.env.NODE_ENV = 'test' +process.env.PORT = '3996' +process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production' + +function parseJson(payload: string): T { + return JSON.parse(payload) as T +} + +function authHeader(token: string) { + return { authorization: `Bearer ${token}` } +} + +async function createApp(): Promise { + const { AppModule } = await import('../src/app.module') + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() + const app = moduleRef.createNestApplication(new FastifyAdapter()) + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), + ) + app.useGlobalFilters(new ApiExceptionFilter()) + await app.init() + await app.getHttpAdapter().getInstance().ready() + return app +} + +async function registerUser(app: NestFastifyApplication, username: string): Promise { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username, password: '123456', name: username }, + }) + assert.ok(res.statusCode === 200 || res.statusCode === 201) + return parseJson<{ token: string }>(res.payload).token +} + +function assertNoCashArbitrage(initialCash: number, finalCash: number) { + assert.ok( + finalCash <= initialCash, + `cash must not increase from rounding (initial=${initialCash}, final=${finalCash})`, + ) +} + +function unitTests() { + assert.equal(GOLD_PRICE_TOMAN_PER_GRAM, GOLD_PRICE) + + // Pre-fix symmetric round produced +1 toman on this split (5 buy, 6 sell). + assert.equal(tomansForGoldGramsRound(0.000_003), 5) + assert.equal(tomansForGoldGramsRound(0.000_001), 2) + assert.equal(tomansForGoldPurchase(0.000_003), 6) + assert.equal(tomansForGoldSale(0.000_001), 1) + assert.equal(tomansForGoldPurchase(0.000_001), 2) + assert.equal(tomansForGoldPurchase(1), GOLD_PRICE) + assert.equal(tomansForGoldSale(1), GOLD_PRICE) + assert.equal(tomansForGoldPurchase(0.05), Math.ceil(0.05 * GOLD_PRICE)) + assert.equal(tomansForGoldSale(0.05), Math.floor(0.05 * GOLD_PRICE)) + + assert.throws(() => applyGoldPurchase({ cash: 1e12, goldGrams: 0 }, Number.POSITIVE_INFINITY), BadRequestException) + assert.throws(() => applyGoldPurchase({ cash: 1e12, goldGrams: 0 }, 0), BadRequestException) + assert.throws(() => applyGoldPurchase({ cash: 1e12, goldGrams: 0 }, 0.000_000_4), BadRequestException) + + const maxMicro = gramsToMicroGrams(MAX_GOLD_TRADE_GRAMS) + assert.ok(maxMicro * GOLD_PRICE > Number.MAX_SAFE_INTEGER) + assert.equal(tomansForGoldPurchase(MAX_GOLD_TRADE_GRAMS), MAX_GOLD_TRADE_GRAMS * GOLD_PRICE) + + const state = { cash: 10_000_000, goldGrams: 42.5 } + const cost = applyGoldPurchase(state, 0.05) + assert.equal(cost, Math.ceil(0.05 * GOLD_PRICE)) + assert.equal(state.goldGrams, normalizeGoldGrams(42.5 + 0.05)) + + applyGoldSale(state, 0.001) + assert.equal(state.goldGrams, normalizeGoldGrams(42.5 + 0.05 - 0.001)) + + // 3 µg buy then three 1 µg sells — no cash gain. + const arb = createDefaultUserState('arb') + arb.cash = 1000 + arb.goldGrams = 0 + applyGoldPurchase(arb, 0.000_003) + applyGoldSale(arb, 0.000_001) + applyGoldSale(arb, 0.000_001) + applyGoldSale(arb, 0.000_001) + assert.equal(arb.goldGrams, 0) + assertNoCashArbitrage(1000, arb.cash) + + // Aggregated sell vs split sells — same gold out, cash never above start. + const agg = createDefaultUserState('agg') + agg.cash = 500_000 + agg.goldGrams = 0 + const aggStart = agg.cash + applyGoldPurchase(agg, 0.000_003) + applyGoldSale(agg, 0.000_003) + assert.equal(agg.goldGrams, 0) + assertNoCashArbitrage(aggStart, agg.cash) + + // Many micro cycles at fixed rate. + const repeat = createDefaultUserState('u') + const startCash = GOLD_PRICE * 100 + repeat.cash = startCash + repeat.goldGrams = 0 + for (let i = 0; i < 50; i++) { + applyGoldPurchase(repeat, 0.000_001) + applyGoldSale(repeat, 0.000_001) + } + assert.equal(repeat.goldGrams, 0) + assertNoCashArbitrage(startCash, repeat.cash) + + // Buy small chunks, sell in one lump. + const lump = createDefaultUserState('lump') + lump.cash = 1_000_000 + lump.goldGrams = 0 + const lumpStart = lump.cash + for (let i = 0; i < 10; i++) { + applyGoldPurchase(lump, 0.000_001) + } + applyGoldSale(lump, 0.000_01) + assert.equal(lump.goldGrams, 0) + assertNoCashArbitrage(lumpStart, lump.cash) +} + +async function seedWallet( + app: NestFastifyApplication, + token: string, + cash: number, + goldGrams: number, +): Promise { + const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) }) + const userId = parseJson<{ user: { id: string } }>(me.payload).user.id + const db = app.get(DatabaseService) + const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as { + state_json: string + } + const state = JSON.parse(row.state_json) as ReturnType + state.cash = cash + state.goldGrams = goldGrams + db.getDb() + .prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?') + .run(JSON.stringify(state), new Date().toISOString(), userId) + return userId +} + +async function apiRegressionScenario(app: NestFastifyApplication) { + const token = await registerUser(app, `gold-round-${Date.now()}`) + const initialCash = 1000 + await seedWallet(app, token, initialCash, 0) + + const buy = await app.inject({ + method: 'POST', + url: '/api/wallet/gold/buy', + headers: { ...authHeader(token), 'content-type': 'application/json' }, + payload: JSON.stringify({ grams: 0.000_003 }), + }) + assert.equal(buy.statusCode, 200) + const afterBuy = parseJson<{ wallet: { cash: number; goldGrams: number } }>(buy.payload) + assert.equal(afterBuy.wallet.goldGrams, 0.000_003) + assert.equal(afterBuy.wallet.cash, initialCash - tomansForGoldPurchase(0.000_003)) + + let cash = afterBuy.wallet.cash + let gold = afterBuy.wallet.goldGrams + for (let i = 0; i < 3; i++) { + const sell = await app.inject({ + method: 'POST', + url: '/api/wallet/gold/sell', + headers: { ...authHeader(token), 'content-type': 'application/json' }, + payload: JSON.stringify({ grams: 0.000_001 }), + }) + assert.equal(sell.statusCode, 200) + const body = parseJson<{ wallet: { cash: number; goldGrams: number } }>(sell.payload) + cash = body.wallet.cash + gold = body.wallet.goldGrams + } + assert.equal(gold, 0) + assertNoCashArbitrage(initialCash, cash) + assert.notEqual(cash, initialCash + 1, 'old symmetric round would have created +1 toman') +} + +async function apiTests(app: NestFastifyApplication) { + await apiRegressionScenario(app) + + const token = await registerUser(app, `gold-precision-${Date.now()}`) + await seedWallet(app, token, 500_000, 0) + + const subToman = await app.inject({ + method: 'POST', + url: '/api/wallet/gold/buy', + headers: { ...authHeader(token), 'content-type': 'application/json' }, + payload: JSON.stringify({ grams: 0.000_000_5 }), + }) + assert.equal(subToman.statusCode, 400) + + const highPrecision = await app.inject({ + method: 'POST', + url: '/api/wallet/gold/buy', + headers: { ...authHeader(token), 'content-type': 'application/json' }, + payload: JSON.stringify({ grams: 0.000_001_5 }), + }) + assert.equal(highPrecision.statusCode, 400) + assert.equal(parseJson<{ error: string }>(highPrecision.payload).error, 'دقت مقدار طلا بیش از حد مجاز است') + + const userId = await seedWallet(app, token, 500_000, 0) + const db = app.get(DatabaseService) + const stateBeforeFail = ( + db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as { state_json: string } + ).state_json + + const insufficient = await app.inject({ + method: 'POST', + url: '/api/wallet/gold/buy', + headers: authHeader(token), + payload: { grams: 1000 }, + }) + assert.equal(insufficient.statusCode, 400) + const stateAfterFail = ( + db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as { state_json: string } + ).state_json + assert.equal(stateAfterFail, stateBeforeFail) + + const buy = await app.inject({ + method: 'POST', + url: '/api/wallet/gold/buy', + headers: { ...authHeader(token), 'content-type': 'application/json' }, + payload: JSON.stringify({ grams: 0.000_001 }), + }) + assert.equal(buy.statusCode, 200) + const buyBody = parseJson<{ wallet: { cash: number; goldGrams: number } }>(buy.payload) + assert.equal(buyBody.wallet.goldGrams, 0.000_001) + assert.equal(buyBody.wallet.cash, 500_000 - tomansForGoldPurchase(0.000_001)) +} + +async function runTests() { + unitTests() + const app = await createApp() + try { + await apiTests(app) + console.log('gold-precision tests passed') + } finally { + await app.close() + try { + rmSync(testDir, { recursive: true, force: true }) + } catch { + // ignore + } + } +} + +runTests().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/test/integration.test.ts b/test/integration.test.ts index 9a2cc73..03763f6 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1825,7 +1825,7 @@ async function runTests() { catalogSnapshot, ) - const { createDefaultUserState: createDefaultUserStateF0 } = await import( + const { createDefaultUserState } = await import( '../src/shared/catalog/default-user-state' ) const defaultState1 = createDefaultUserState('f4-default-1') diff --git a/test/lesson-rewards-api.test.ts b/test/lesson-rewards-api.test.ts new file mode 100644 index 0000000..0ca7390 --- /dev/null +++ b/test/lesson-rewards-api.test.ts @@ -0,0 +1,211 @@ +/** + * Lesson reward rules via HTTP — four replay scenarios on level 6 (inflation). + * Run: npm run test:lesson-rewards + */ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ValidationPipe } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify' +import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter' +import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader' +import type { LessonDefinition, LessonSubmissionAnswer } from '../src/gold-land/lessons/lesson.types' +import { inflationLesson } from '../src/gold-land/lessons/inflation.lesson' +import { DatabaseService } from '../src/database/database.service' + +const testDir = mkdtempSync(join(tmpdir(), 'gold-land-lesson-rewards-')) +process.env.DB_PATH = join(testDir, 'test.db') +process.env.NODE_ENV = 'test' +process.env.PORT = '3995' +process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production' + +function parseJson(payload: string): T { + return JSON.parse(payload) as T +} + +function authHeader(token: string) { + return { authorization: `Bearer ${token}` } +} + +function buildPartialCorrect(lesson: LessonDefinition, correctCount: number): LessonSubmissionAnswer[] { + const gradable = getGradableQuestions(lesson) + return gradable.map(({ questionId, step }, index) => { + const isCorrect = index < correctCount + if (step.type === 'sort') { + const order = isCorrect ? [...step.correctOrder] : [...step.correctOrder].reverse() + return { questionId, orderedItemIds: order } + } + if (!isCorrect) { + const wrong = + step.type === 'true_false' + ? step.correctAnswerId === 'true' + ? 'false' + : 'true' + : step.options.find((o) => o.id !== step.correctAnswerId)?.id ?? 'wrong' + return { questionId, answerId: wrong } + } + return { questionId, answerId: step.correctAnswerId } + }) +} + +function buildAllCorrect(lesson: LessonDefinition): LessonSubmissionAnswer[] { + return getGradableQuestions(lesson).map(({ questionId, step }) => { + if (step.type === 'sort') { + return { questionId, orderedItemIds: [...step.correctOrder] } + } + return { questionId, answerId: step.correctAnswerId } + }) +} + +async function createApp(): Promise { + const { AppModule } = await import('../src/app.module') + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() + const app = moduleRef.createNestApplication(new FastifyAdapter()) + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), + ) + app.useGlobalFilters(new ApiExceptionFilter()) + await app.init() + await app.getHttpAdapter().getInstance().ready() + return app +} + +async function registerUser(app: NestFastifyApplication): Promise<{ token: string; userId: string }> { + const username = `lesson-rewards-${Date.now()}` + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username, password: '123456', name: username }, + }) + assert.ok(res.statusCode === 200 || res.statusCode === 201) + const token = parseJson<{ token: string }>(res.payload).token + const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) }) + const userId = parseJson<{ user: { id: string } }>(me.payload).user.id + return { token, userId } +} + +function unlockThroughLevel5(db: DatabaseService, userId: string) { + const now = new Date().toISOString() + for (let levelId = 1; levelId <= 5; levelId++) { + db.getDb().prepare(` + INSERT INTO user_level_progress ( + user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at + ) VALUES (?, ?, 65, 1, 1, ?, ?, ?) + `).run(userId, levelId, now, now, now) + } +} + +function countLedger(db: DatabaseService, userId: string, rewardKey: string): number { + const row = db + .getDb() + .prepare('SELECT COUNT(*) AS c FROM reward_ledger WHERE user_id = ? AND reward_key = ?') + .get(userId, rewardKey) as { c: number } + return row.c +} + +type CompletePayload = { + levelId: number + score: number + stars: number + previousStars: number + isFirstCompletion: boolean + bestScoreImproved: boolean + reward: { xp: number; coins: number } + me: { user: { xp: number; coins: number }; progress: { totalStars: number } } +} + +async function completeLevel( + app: NestFastifyApplication, + token: string, + answers: LessonSubmissionAnswer[], +): Promise { + const res = await app.inject({ + method: 'POST', + url: '/api/gold-land/levels/6/complete', + headers: { ...authHeader(token), 'content-type': 'application/json' }, + payload: JSON.stringify({ answers, durationMs: 4000 }), + }) + assert.equal(res.statusCode, 200, res.payload) + return parseJson(res.payload) +} + +async function runTests() { + const app = await createApp() + const db = app.get(DatabaseService) + + try { + const { token, userId } = await registerUser(app) + unlockThroughLevel5(db, userId) + await app.inject({ + method: 'POST', + url: '/api/gold-land/levels/6/start', + headers: authHeader(token), + }) + + const THREE = buildPartialCorrect(inflationLesson, 3) + const ALL = buildAllCorrect(inflationLesson) + const FOUR = buildPartialCorrect(inflationLesson, 4) + + const me0 = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) }) + const base = parseJson<{ user: { xp: number; coins: number } }>(me0.payload) + + // 1) First completion with 1★ (score 60) + const first = await completeLevel(app, token, THREE) + assert.equal(first.score, 60) + assert.equal(first.stars, 1) + assert.equal(first.previousStars, 0) + assert.equal(first.isFirstCompletion, true) + assert.equal(first.bestScoreImproved, true) + assert.equal(first.reward.xp, 40 + 5) + assert.equal(first.reward.coins, 15 + 3) + assert.equal(countLedger(db, userId, 'level:6:first-completion'), 1) + assert.equal(countLedger(db, userId, 'level:6:star:1'), 1) + assert.equal(first.me.progress.mapLevels.find((l) => l.id === 6)?.stars, 1) + + // 2) Repeat same score — no extra reward + const repeat = await completeLevel(app, token, THREE) + assert.equal(repeat.stars, 1) + assert.equal(repeat.isFirstCompletion, false) + assert.equal(repeat.bestScoreImproved, false) + assert.equal(repeat.reward.xp, 0) + assert.equal(repeat.reward.coins, 0) + assert.equal(repeat.me.user.xp, base.user.xp + first.reward.xp) + assert.equal(repeat.me.user.coins, base.user.coins + first.reward.coins) + + // 3) Improve 1★ → 3★ + const improved = await completeLevel(app, token, ALL) + assert.equal(improved.score, 100) + assert.equal(improved.stars, 3) + assert.equal(improved.previousStars, 1) + assert.equal(improved.bestScoreImproved, true) + assert.equal(improved.reward.xp, 5 + 5) + assert.equal(improved.reward.coins, 3 + 3) + assert.equal(countLedger(db, userId, 'level:6:star:2'), 1) + assert.equal(countLedger(db, userId, 'level:6:star:3'), 1) + + // 4) Lower run after best — stars stay 3, no reward + const lower = await completeLevel(app, token, FOUR) + assert.equal(lower.score, 80) + assert.equal(lower.stars, 3) + assert.equal(lower.previousStars, 3) + assert.equal(lower.bestScoreImproved, false) + assert.equal(lower.reward.xp, 0) + assert.equal(lower.reward.coins, 0) + + console.log('lesson-rewards-api tests passed') + } finally { + await app.close() + try { + rmSync(testDir, { recursive: true, force: true }) + } catch { + // ignore + } + } +} + +runTests().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/test/production-config.test.ts b/test/production-config.test.ts new file mode 100644 index 0000000..35bbfab --- /dev/null +++ b/test/production-config.test.ts @@ -0,0 +1,67 @@ +import 'reflect-metadata' +import assert from 'node:assert/strict' +import { validateEnv } from '../src/config/env.validation' + +const VALID_PROD_SECRET = 'a'.repeat(48) + +assert.throws( + () => + validateEnv({ + NODE_ENV: 'production', + PORT: 3000, + HOST: '0.0.0.0', + CORS_ORIGIN: 'https://app.example.com', + JWT_SECRET: '', + }), + /JWT_SECRET is required/, +) + +assert.throws( + () => + validateEnv({ + NODE_ENV: 'production', + PORT: 3000, + HOST: '0.0.0.0', + CORS_ORIGIN: 'https://app.example.com', + JWT_SECRET: 'gold-land-dev-secret-change-in-production', + }), + /placeholder/, +) + +assert.throws( + () => + validateEnv({ + NODE_ENV: 'production', + PORT: 3000, + HOST: '0.0.0.0', + CORS_ORIGIN: 'https://app.example.com', + JWT_SECRET: 'short', + }), + /32 characters/, +) + +assert.throws( + () => + validateEnv({ + NODE_ENV: 'production', + PORT: 3000, + HOST: '0.0.0.0', + CORS_ORIGIN: 'https://app.example.com', + JWT_SECRET: VALID_PROD_SECRET, + TRUST_PROXY: 'true', + }), + /TRUST_PROXY_CIDRS is required/, +) + +const ok = validateEnv({ + NODE_ENV: 'production', + PORT: 3000, + HOST: '0.0.0.0', + CORS_ORIGIN: 'https://app.example.com', + JWT_SECRET: VALID_PROD_SECRET, + TRUST_PROXY: 'true', + TRUST_PROXY_CIDRS: '172.20.0.0/16', +}) +assert.equal(ok.NODE_ENV, 'production') + +console.log('production-config tests passed') diff --git a/test/production-error.test.ts b/test/production-error.test.ts new file mode 100644 index 0000000..095ae59 --- /dev/null +++ b/test/production-error.test.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata' +import assert from 'node:assert/strict' +import { HttpException, HttpStatus } from '@nestjs/common' +import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter' + +const filter = new ApiExceptionFilter() + +function runFilter(exception: unknown): { status: number; body: { error: string } } { + let status = 0 + let body: { error: string } = { error: '' } + const host = { + switchToHttp: () => ({ + getResponse: () => ({ + status(code: number) { + status = code + return { + send(payload: { error: string }) { + body = payload + }, + } + }, + }), + }), + } + filter.catch(exception, host as never) + return { status, body } +} + +const prev = process.env.NODE_ENV +process.env.NODE_ENV = 'production' + +const internal = runFilter( + new Error('SqliteError: constraint at /secret/path/app.db\n at foo.ts:1:1'), +) +assert.equal(internal.status, HttpStatus.INTERNAL_SERVER_ERROR) +assert.equal(internal.body.error, 'خطای سرور') +assert.ok(!JSON.stringify(internal.body).includes('app.db')) + +const httpErr = runFilter( + new HttpException('SQLITE_BUSY: database is locked', HttpStatus.BAD_REQUEST), +) +assert.equal(httpErr.body.error, 'درخواست نامعتبر است') + +process.env.NODE_ENV = prev + +console.log('production-error tests passed') diff --git a/test/restore-safety.test.ts b/test/restore-safety.test.ts new file mode 100644 index 0000000..67abea0 --- /dev/null +++ b/test/restore-safety.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import Database from 'better-sqlite3' +import { up, migrationId } from '../src/database/migrations/001-initial-schema' + +const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const root = mkdtempSync(join(tmpdir(), 'cgl-restore-safety-')) + +function runRestore(backup: string, target: string, env: Record = {}) { + return spawnSync('node', ['scripts/sqlite-restore.mjs', backup, target], { + cwd: backendRoot, + env: { ...process.env, ...env }, + encoding: 'utf8', + }) +} + +function seedAppDb(path: string) { + const db = new Database(path) + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL + ); + `) + db.exec(up) + db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run( + migrationId, + new Date().toISOString(), + ) + db.close() +} + +try { + const goodBackup = join(root, 'good.db') + seedAppDb(goodBackup) + + const wrongSchema = join(root, 'wrong.db') + const w = new Database(wrongSchema) + w.exec('CREATE TABLE users (id TEXT PRIMARY KEY);') + w.close() + + writeFileSync(join(root, 'corrupt.db'), 'not-a-sqlite-file') + + let r = runRestore(join(root, 'corrupt.db'), join(root, 't1.db')) + assert.notEqual(r.status, 0) + + r = runRestore(wrongSchema, join(root, 't2.db')) + assert.notEqual(r.status, 0) + + const existing = join(root, 'existing.db') + seedAppDb(existing) + const before = readFileSync(existing) + r = runRestore(goodBackup, existing, {}) + assert.notEqual(r.status, 0) + assert.deepEqual(readFileSync(existing), before, 'failed restore must not change target') + + const freshTarget = join(root, 'fresh.db') + r = runRestore(goodBackup, freshTarget, { RESTORE_ACK_STOPPED: 'YES' }) + assert.equal(r.status, 0, r.stderr || r.stdout) + assert.ok(existsSync(freshTarget)) + + const liveConfigured = join(root, 'live-configured.db') + seedAppDb(liveConfigured) + r = runRestore(goodBackup, liveConfigured, { + DB_PATH: liveConfigured, + RESTORE_ACK_STOPPED: 'YES', + }) + assert.notEqual(r.status, 0, 'must refuse restore onto configured live DB_PATH') +} finally { + try { + rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 200 }) + } catch { + // Windows may briefly lock SQLite files after close. + } +} + +console.log('restore-safety tests passed') diff --git a/tsconfig.build.json b/tsconfig.build.json index 2fe1df2..0a5e581 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,7 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false + }, "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] }