Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 8 additions & 3 deletions api-gateway/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
12 changes: 10 additions & 2 deletions api-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -66,6 +66,14 @@
],
"coverageDirectory": "coverage",
"coverageReporters": ["text", "lcov", "html"],
"coverageThreshold": {
"global": {
"lines": 60,
"functions": 60,
"branches": 28,
"statements": 60
}
},
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"]
}
}
10 changes: 6 additions & 4 deletions api-gateway/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
}),
Expand Down
2 changes: 1 addition & 1 deletion api-gateway/src/routes/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}),
);

Expand Down
3 changes: 2 additions & 1 deletion api-gateway/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions api-gateway/src/services/userRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,35 @@ function toPublic(u: InternalUser): UserRecord {
};
}

export async function findUserById(id: string): Promise<UserRecord | null> {
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<UserRecord | null> {
const normalized = email.trim().toLowerCase();
const pool = getPool();
Expand Down
25 changes: 24 additions & 1 deletion api-gateway/tests/integration/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,36 @@ 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)
.post('/api/v1/auth/refresh')
.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);
});
});
2 changes: 1 addition & 1 deletion docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ services:
grafana:
image: grafana/grafana:latest
ports:
- "3001:3000"
- "3030:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin123
volumes:
Expand Down
2 changes: 0 additions & 2 deletions ngrok.zip

This file was deleted.

Loading