diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 521810d..8619084 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,13 +33,13 @@ jobs: run: npm ci --no-audit --no-fund - name: Lint - run: npm run lint || true + run: npm run lint - name: Type-check run: npm run typecheck - name: Test (with coverage) - run: npm run test:coverage -- --runInBand + run: npm run test:coverage -- --runInBand --ci - name: Upload coverage if: always() @@ -74,8 +74,9 @@ jobs: - name: Install run: npm ci --no-audit --no-fund - - name: Lint (non-blocking) - run: npm run lint || true + - name: Lint + run: npm run lint + continue-on-error: true - name: Build run: npm run build @@ -93,20 +94,21 @@ jobs: cache: npm cache-dependency-path: api-gateway/package-lock.json - - name: npm audit (production deps, high+ only) + - name: npm audit (production deps, critical only) working-directory: api-gateway run: | - npm audit --omit=dev --audit-level=high || \ - (echo "::warning::high or critical vulnerability detected in api-gateway production deps" && true) + npm audit --omit=dev --audit-level=critical || \ + echo "::warning::Critical vulnerability detected in api-gateway production deps" - name: Trivy filesystem scan - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@0.30.0 with: scan-type: fs scan-ref: . - severity: CRITICAL,HIGH - exit-code: '0' + severity: CRITICAL + exit-code: '1' ignore-unfixed: true + format: table docker: name: Docker — build images diff --git a/api-gateway/.eslintrc.json b/api-gateway/.eslintrc.json index 4c73227..0d4e0a2 100644 --- a/api-gateway/.eslintrc.json +++ b/api-gateway/.eslintrc.json @@ -13,8 +13,13 @@ "env": { "node": true, "es2021": true, "jest": true }, "ignorePatterns": ["dist", "coverage", "node_modules"], "rules": { - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], - "no-console": ["warn", { "allow": ["warn", "error"] }] + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-var-requires": "warn", + "no-console": ["error", { "allow": ["warn", "error"] }], + "eqeqeq": ["error", "always"], + "no-return-await": "error", + "prefer-const": "error" } } diff --git a/api-gateway/package.json b/api-gateway/package.json index d4024f5..9ca34ed 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -9,9 +9,9 @@ "dev": "ts-node-dev --respawn --transpile-only src/server.ts", "lint": "eslint 'src/**/*.ts'", "lint:fix": "eslint 'src/**/*.ts' --fix", - "test": "jest --runInBand --detectOpenHandles", + "test": "jest --runInBand --forceExit", "test:watch": "jest --watch", - "test:coverage": "jest --coverage", + "test:coverage": "jest --coverage --forceExit", "typecheck": "tsc --noEmit", "migrate": "node dist/db/migrate.js", "migrate:dev": "ts-node src/db/migrate.ts" @@ -66,6 +66,14 @@ ], "coverageDirectory": "coverage", "coverageReporters": ["text", "lcov", "html"], + "coverageThreshold": { + "global": { + "lines": 60, + "functions": 60, + "branches": 28, + "statements": 60 + } + }, "setupFilesAfterEnv": ["/tests/setup.ts"] } } diff --git a/api-gateway/src/routes/auth.ts b/api-gateway/src/routes/auth.ts index 4d54410..67b820a 100644 --- a/api-gateway/src/routes/auth.ts +++ b/api-gateway/src/routes/auth.ts @@ -4,7 +4,7 @@ import { validate } from '../middleware/validate'; import { authLimiter } from '../middleware/rateLimit'; import { asyncHandler } from '../utils/asyncHandler'; import { BadRequestError, ConflictError, UnauthorizedError } from '../utils/errors'; -import { createUser, findUserByEmail, verifyCredentials } from '../services/userRepo'; +import { createUser, findUserByEmail, findUserById, verifyCredentials } from '../services/userRepo'; import { signAccessToken, signRefreshToken } from '../services/tokens'; import { decodeRefreshToken, requireAuth } from '../middleware/auth'; import { isStrongPassword } from '../services/passwords'; @@ -92,10 +92,12 @@ authRouter.post( } catch { throw new UnauthorizedError('Invalid refresh token'); } + const user = await findUserById(payload.userId); + if (!user) throw new UnauthorizedError('User not found'); const accessToken = signAccessToken({ - userId: payload.userId, - email: '', - role: 'student', + userId: user.id, + email: user.email, + role: user.role, }); res.json({ accessToken }); }), diff --git a/api-gateway/src/routes/courses.ts b/api-gateway/src/routes/courses.ts index 14ee82c..465fc5a 100644 --- a/api-gateway/src/routes/courses.ts +++ b/api-gateway/src/routes/courses.ts @@ -26,7 +26,7 @@ coursesRouter.get( if (!course) throw new NotFoundError(`Course '${id}' not found`); const lesson = course.sections?.flatMap((section) => section.lessons || []).find((lesson) => lesson.id === lessonId); if (!lesson) throw new NotFoundError(`Lesson '${lessonId}' not found for course '${id}'`); - res.json({ id: lesson.id, content: (lesson as any).content ?? '', title: lesson.title, type: lesson.type, duration: lesson.duration }); + res.json({ id: lesson.id, content: lesson.content ?? '', title: lesson.title, type: lesson.type, duration: lesson.duration }); }), ); diff --git a/api-gateway/src/routes/health.ts b/api-gateway/src/routes/health.ts index c001629..040087d 100644 --- a/api-gateway/src/routes/health.ts +++ b/api-gateway/src/routes/health.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express'; import { env } from '../config/env'; import { getPool, isDatabaseHealthy, verifyDatabaseConnection } from '../db/pool'; +import pkg from '../../package.json'; export const healthRouter = Router(); @@ -29,7 +30,7 @@ healthRouter.get('/', async (_req: Request, res: Response) => { res.json({ status: 'ok', service: 'krai-api-gateway', - version: '2.1.0', + version: pkg.version, env: env.NODE_ENV, database: dbStatus, demo: env.DEMO_MODE, diff --git a/api-gateway/src/services/userRepo.ts b/api-gateway/src/services/userRepo.ts index a1a5ad3..8302e9f 100644 --- a/api-gateway/src/services/userRepo.ts +++ b/api-gateway/src/services/userRepo.ts @@ -29,6 +29,35 @@ function toPublic(u: InternalUser): UserRecord { }; } +export async function findUserById(id: string): Promise { + const pool = getPool(); + if (pool) { + try { + const { rows } = await pool.query( + 'SELECT id,email,first_name,last_name,role FROM users WHERE id=$1', + [id], + ); + if (!rows.length) return null; + const r = rows[0]; + return { + id: r.id, + email: r.email, + firstName: r.first_name ?? '', + lastName: r.last_name ?? '', + role: r.role ?? 'student', + }; + } catch (err) { + logger.warn('findUserById DB error; falling back to memory', { + error: (err as Error).message, + }); + } + } + for (const u of memUsers.values()) { + if (u.id === id) return toPublic(u); + } + return null; +} + export async function findUserByEmail(email: string): Promise { const normalized = email.trim().toLowerCase(); const pool = getPool(); diff --git a/api-gateway/tests/integration/auth.test.ts b/api-gateway/tests/integration/auth.test.ts index 7b1822c..d8a07d9 100644 --- a/api-gateway/tests/integration/auth.test.ts +++ b/api-gateway/tests/integration/auth.test.ts @@ -63,7 +63,7 @@ describe('auth routes', () => { expect(res.status).toBe(401); }); - it('refreshes the access token', async () => { + it('refreshes the access token with correct user data', async () => { const reg = await request(app).post('/api/v1/auth/register').send(validUser); const refreshToken = reg.body.refreshToken; const res = await request(app) @@ -71,5 +71,28 @@ describe('auth routes', () => { .send({ refreshToken }); expect(res.status).toBe(200); expect(res.body.accessToken).toBeDefined(); + + // The refreshed access token must carry the real email — not an empty string. + const me = await request(app) + .get('/api/v1/auth/me') + .set('Authorization', `Bearer ${res.body.accessToken}`); + expect(me.status).toBe(200); + expect(me.body.email).toBe(validUser.email); + }); + + it('rejects refresh with invalid token', async () => { + const res = await request(app) + .post('/api/v1/auth/refresh') + .send({ refreshToken: 'not.a.valid.jwt.token' }); + expect(res.status).toBe(401); + }); + + it('logout returns success', async () => { + const reg = await request(app).post('/api/v1/auth/register').send(validUser); + const res = await request(app) + .post('/api/v1/auth/logout') + .set('Authorization', `Bearer ${reg.body.accessToken}`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); }); }); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a33e27b..ecf93ab 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -192,7 +192,7 @@ services: grafana: image: grafana/grafana:latest ports: - - "3001:3000" + - "3030:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin123 volumes: diff --git a/ngrok.zip b/ngrok.zip deleted file mode 100644 index ae2b9ad..0000000 --- a/ngrok.zip +++ /dev/null @@ -1,2 +0,0 @@ - -NoSuchKeyThe specified key does not exist.ngrok-v3-stable-linux-x86_64.zipJQ13SXEKMMFQ8ARDVpMTjYIwhZZHRwev6H0ab262quSI4iNDOrg4d3YkMWb/7UYj6G8FLderzyiXuuompLySmjo49gQ= \ No newline at end of file