Set up PostgreSQL database configuration and enhance application structure
- Added .env.example with database connection details and JWT configuration. - Introduced docker-compose.yml for PostgreSQL service setup with health checks. - Created drizzle.config.ts for database schema and migration management. - Updated nest-cli.json to include Swagger plugin configuration for API documentation. - Enhanced package.json with new database-related scripts and dependencies. - Implemented initial database migrations for user and token management. - Configured application bootstrap process to load environment variables and set up Swagger. - Added shared application configuration in configure-app.ts for consistent setup. - Included unit tests for application configuration and Swagger setup.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: NestJS OpenAPI/Swagger conventions for controllers and DTOs
|
||||
globs: "src/**/*.controller.ts,src/**/dto/**/*.ts,src/common/swagger/**/*.ts,src/main.ts,nest-cli.json"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# NestJS Swagger Best Practices
|
||||
|
||||
## Controllers
|
||||
|
||||
Document every HTTP handler:
|
||||
|
||||
```typescript
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Log in' })
|
||||
@ApiOkResponse({ type: TokenPairDto })
|
||||
login(@Body() dto: LoginDto) { /* ... */ }
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOkResponse({ type: MeResponseDto })
|
||||
me(@CurrentUser() user: AuthUser) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
- Use `@ApiTags`, `@ApiOperation`, and typed `@ApiOkResponse` / `@ApiCreatedResponse` / `@ApiNoContentResponse`
|
||||
- Response types must be DTO **classes**, not interfaces or domain entities
|
||||
- Add `@ApiBearerAuth('access-token')` only on JWT-protected routes
|
||||
- Public (`@Public()`) routes must omit bearer auth
|
||||
- Document expected errors (`@ApiBadRequestResponse`, `@ApiUnauthorizedResponse`, etc.) when meaningful
|
||||
|
||||
## DTOs
|
||||
|
||||
- Request and response DTOs live under `dto/`
|
||||
- Never expose domain entities or sensitive fields (`passwordHash`, secrets) in OpenAPI
|
||||
- Nest CLI Swagger plugin is enabled — use `@ApiProperty` for examples, `format: 'password'`, `writeOnly`, not to restate every class-validator rule
|
||||
- Examples must be fake; never real tokens or secrets
|
||||
|
||||
```typescript
|
||||
// BAD — documents entity / real secret
|
||||
@ApiOkResponse({ type: User })
|
||||
@ApiProperty({ example: process.env.JWT_ACCESS_SECRET })
|
||||
|
||||
// GOOD — response DTO + fake example
|
||||
@ApiOkResponse({ type: MeResponseDto })
|
||||
@ApiProperty({ example: 'alice', format: 'password', writeOnly: true })
|
||||
```
|
||||
|
||||
## Bootstrap
|
||||
|
||||
- Mount UI at `/docs` and JSON at `/docs-json` only (via `setupSwagger` / `configureApp`)
|
||||
- Swagger is off when `NODE_ENV=production` unless `SWAGGER_ENABLED=true`
|
||||
- Keep the `@nestjs/swagger` plugin in `nest-cli.json` (`classValidatorShim`, `introspectComments`, `dtoFileNameSuffix: [".dto.ts"]`)
|
||||
@@ -13,6 +13,7 @@ Stack: NestJS + PostgreSQL + Drizzle ORM. Follow `.agents/skills/nestjs-best-pra
|
||||
- One feature folder under `src/modules/`
|
||||
- Each feature has `*.module.ts`, `*.controller.ts`, `*.service.ts`, `dto/`
|
||||
- Share cross-cutting code via `src/common/` (filters, guards, pipes, interceptors)
|
||||
- Document HTTP endpoints per `.cursor/rules/nestjs-swagger.mdc`
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -2,3 +2,24 @@
|
||||
# Stored values remain UTC unix milliseconds. Fixed offsets only (no IANA names).
|
||||
# Examples: GMT+7, GMT+07:00, UTC+7, +07:00, +7, +0700
|
||||
DEFAULT_TIMEZONE=GMT+7
|
||||
|
||||
PORT=3000
|
||||
|
||||
# PostgreSQL connection string (docker-compose default shown)
|
||||
DATABASE_URL=postgresql://tracking:tracking@localhost:5432/tracking
|
||||
|
||||
# Required. Generate with: openssl rand -base64 48
|
||||
JWT_ACCESS_SECRET=
|
||||
|
||||
# Access token lifetime (<number><s|m|h|d>)
|
||||
JWT_ACCESS_EXPIRES_IN=15m
|
||||
|
||||
# Refresh token lifetime in milliseconds (default 7 days)
|
||||
REFRESH_TOKEN_EXPIRES_IN_MS=604800000
|
||||
|
||||
# bcrypt cost factor
|
||||
BCRYPT_SALT_ROUNDS=10
|
||||
|
||||
# OpenAPI UI at /docs (default: on unless NODE_ENV=production)
|
||||
# SWAGGER_ENABLED=true
|
||||
# SWAGGER_ENABLED=false
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
bcrypt
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
POSTGRES_USER: tracking
|
||||
POSTGRES_PASSWORD: tracking
|
||||
POSTGRES_DB: tracking
|
||||
volumes:
|
||||
- tracking_pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U tracking -d tracking']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
tracking_pg_data:
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/database/schema.ts',
|
||||
out: './drizzle/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE "refresh_tokens" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"access_jti" text NOT NULL,
|
||||
"expires_at" bigint NOT NULL,
|
||||
"revoked_at" bigint,
|
||||
"replaced_by" uuid,
|
||||
"created_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "revoked_access_tokens" (
|
||||
"jti" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"created_at" bigint NOT NULL,
|
||||
"updated_at" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "refresh_tokens_token_hash_unique" ON "refresh_tokens" USING btree ("token_hash");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_username_unique" ON "users" USING btree ("username");
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");
|
||||
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"id": "e3f29ef3-7c4b-42df-9f2a-e751897061e8",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.refresh_tokens": {
|
||||
"name": "refresh_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_jti": {
|
||||
"name": "access_jti",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"replaced_by": {
|
||||
"name": "replaced_by",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"refresh_tokens_token_hash_unique": {
|
||||
"name": "refresh_tokens_token_hash_unique",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "token_hash",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"refresh_tokens_user_id_users_id_fk": {
|
||||
"name": "refresh_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "refresh_tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.revoked_access_tokens": {
|
||||
"name": "revoked_access_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"jti": {
|
||||
"name": "jti",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "username",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"id": "55ce9d09-18dc-4cf8-8fa0-6b96b229820f",
|
||||
"prevId": "e3f29ef3-7c4b-42df-9f2a-e751897061e8",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.refresh_tokens": {
|
||||
"name": "refresh_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_jti": {
|
||||
"name": "access_jti",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"replaced_by": {
|
||||
"name": "replaced_by",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"refresh_tokens_token_hash_unique": {
|
||||
"name": "refresh_tokens_token_hash_unique",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "token_hash",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"refresh_tokens_user_id_idx": {
|
||||
"name": "refresh_tokens_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"refresh_tokens_user_id_users_id_fk": {
|
||||
"name": "refresh_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "refresh_tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.revoked_access_tokens": {
|
||||
"name": "revoked_access_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"jti": {
|
||||
"name": "jti",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "username",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1787284452106,
|
||||
"tag": "0000_jazzy_tomorrow_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1787285129382,
|
||||
"tag": "0001_amusing_maximus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+11
-1
@@ -3,6 +3,16 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "@nestjs/swagger",
|
||||
"options": {
|
||||
"classValidatorShim": true,
|
||||
"introspectComments": true,
|
||||
"dtoFileNameSuffix": [".dto.ts"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+38
-3
@@ -17,13 +17,34 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"bcrypt",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.4.7",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"libphonenumber-js": "^1.13.11",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"postgres": "^3.4.9",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -33,10 +54,13 @@
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
@@ -49,7 +73,7 @@
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
@@ -64,8 +88,19 @@
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
"**/*.(t|j)s",
|
||||
"!**/*.module.ts",
|
||||
"!**/main.ts",
|
||||
"!**/*.dto.ts"
|
||||
],
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"branches": 70,
|
||||
"functions": 70,
|
||||
"lines": 80,
|
||||
"statements": 80
|
||||
}
|
||||
},
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
|
||||
Generated
+1418
-75
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,20 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from './common/decorators/public.decorator';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@ApiTags('app')
|
||||
@Public()
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Health/hello probe' })
|
||||
@ApiOkResponse({
|
||||
description: 'Plain text greeting',
|
||||
schema: { type: 'string', example: 'Hello World!' },
|
||||
})
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
+14
-1
@@ -1,9 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import loadEnv from './config/env';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [loadEnv],
|
||||
}),
|
||||
DatabaseModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type AuthUser = {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
readonly jti: string;
|
||||
};
|
||||
|
||||
export type JwtAccessPayload = {
|
||||
readonly sub: string;
|
||||
readonly username: string;
|
||||
readonly jti: string;
|
||||
readonly typ: 'access';
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from '../app.controller';
|
||||
import { AppService } from '../app.service';
|
||||
import { configureApp } from './configure-app';
|
||||
import * as setupSwaggerModule from './swagger/setup-swagger';
|
||||
|
||||
describe('configureApp', () => {
|
||||
let app: INestApplication;
|
||||
let useGlobalPipesSpy: jest.SpyInstance;
|
||||
let setupSwaggerSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
useGlobalPipesSpy = jest.spyOn(app, 'useGlobalPipes');
|
||||
setupSwaggerSpy = jest
|
||||
.spyOn(setupSwaggerModule, 'setupSwagger')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useGlobalPipesSpy.mockClear();
|
||||
setupSwaggerSpy.mockClear();
|
||||
});
|
||||
|
||||
it('registers ValidationPipe and setupSwagger', () => {
|
||||
const env = { NODE_ENV: 'test' } as NodeJS.ProcessEnv;
|
||||
configureApp(app, env);
|
||||
|
||||
expect(useGlobalPipesSpy).toHaveBeenCalledWith(expect.any(ValidationPipe));
|
||||
expect(setupSwaggerSpy).toHaveBeenCalledWith(app, env);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { setupSwagger } from './swagger/setup-swagger';
|
||||
|
||||
/** Shared Nest app configuration for bootstrap and E2E. */
|
||||
export function configureApp(
|
||||
app: INestApplication,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
setupSwagger(app, env);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth-user';
|
||||
|
||||
/**
|
||||
* Mirrors CurrentUser createParamDecorator factory for unit testing.
|
||||
*/
|
||||
function extractCurrentUser(
|
||||
data: keyof AuthUser | undefined,
|
||||
ctx: ExecutionContext,
|
||||
): AuthUser | AuthUser[keyof AuthUser] {
|
||||
const request = ctx.switchToHttp().getRequest<{ user?: AuthUser }>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return data ? user[data] : user;
|
||||
}
|
||||
|
||||
describe('CurrentUser decorator', () => {
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'jti-1',
|
||||
};
|
||||
|
||||
const createCtx = (u?: AuthUser): ExecutionContext =>
|
||||
({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user: u }),
|
||||
}),
|
||||
}) as unknown as ExecutionContext;
|
||||
|
||||
it('returns the full AuthUser when no property is specified', () => {
|
||||
expect(extractCurrentUser(undefined, createCtx(user))).toEqual(user);
|
||||
});
|
||||
|
||||
it('returns a single property when a key is specified', () => {
|
||||
expect(extractCurrentUser('username', createCtx(user))).toBe('alice');
|
||||
expect(extractCurrentUser('id', createCtx(user))).toBe('user-1');
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when user is missing', () => {
|
||||
expect(() => extractCurrentUser(undefined, createCtx())).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthUser } from '../auth/auth-user';
|
||||
|
||||
/**
|
||||
* Injects the authenticated user from the request (set by JwtAuthGuard).
|
||||
* Pass a property name to pick a single field, e.g. `@CurrentUser('id')`.
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(
|
||||
data: keyof AuthUser | undefined,
|
||||
ctx: ExecutionContext,
|
||||
): AuthUser | AuthUser[keyof AuthUser] => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user?: AuthUser }>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return data ? user[data] : user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
/** Marks a controller class or route handler as publicly accessible (no JWT). */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
const createContext = (
|
||||
handlerMeta?: boolean,
|
||||
classMeta?: boolean,
|
||||
): ExecutionContext => {
|
||||
const handler = () => undefined;
|
||||
const controller = class TestController {};
|
||||
if (handlerMeta !== undefined) {
|
||||
Reflect.defineMetadata(IS_PUBLIC_KEY, handlerMeta, handler);
|
||||
}
|
||||
if (classMeta !== undefined) {
|
||||
Reflect.defineMetadata(IS_PUBLIC_KEY, classMeta, controller);
|
||||
}
|
||||
|
||||
return {
|
||||
getHandler: () => handler,
|
||||
getClass: () => controller,
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
};
|
||||
|
||||
it('allows public methods without JWT', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
const result = guard.canActivate(createContext(true));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('allows public controller classes without JWT', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
const result = guard.canActivate(createContext(undefined, true));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('delegates to AuthGuard when route is protected', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
const spy = jest
|
||||
.spyOn(Object.getPrototypeOf(JwtAuthGuard.prototype), 'canActivate')
|
||||
.mockReturnValue(true);
|
||||
|
||||
const result = guard.canActivate(createContext());
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppController } from '../../app.controller';
|
||||
import { AppService } from '../../app.service';
|
||||
import { AuthController } from '../../modules/auth/auth.controller';
|
||||
import { AuthService } from '../../modules/auth/auth.service';
|
||||
import {
|
||||
createOpenApiDocument,
|
||||
isSwaggerEnabled,
|
||||
setupSwagger,
|
||||
} from './setup-swagger';
|
||||
|
||||
describe('isSwaggerEnabled', () => {
|
||||
it('is disabled in production by default', () => {
|
||||
expect(isSwaggerEnabled({ NODE_ENV: 'production' })).toBe(false);
|
||||
});
|
||||
|
||||
it('is enabled in production when SWAGGER_ENABLED=true', () => {
|
||||
expect(
|
||||
isSwaggerEnabled({
|
||||
NODE_ENV: 'production',
|
||||
SWAGGER_ENABLED: 'true',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is enabled when not production', () => {
|
||||
expect(isSwaggerEnabled({ NODE_ENV: 'development' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is disabled when SWAGGER_ENABLED=false', () => {
|
||||
expect(
|
||||
isSwaggerEnabled({
|
||||
NODE_ENV: 'development',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOpenApiDocument', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController, AuthController],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: {
|
||||
register: jest.fn(),
|
||||
login: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
revoke: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('includes auth and app paths with bearer scheme', () => {
|
||||
const document = createOpenApiDocument(app);
|
||||
|
||||
expect(document.info.title).toBe('Tracking API');
|
||||
expect(document.paths['/auth/login']).toBeDefined();
|
||||
expect(document.paths['/auth/me']).toBeDefined();
|
||||
expect(document.paths['/']).toBeDefined();
|
||||
expect(document.components?.securitySchemes?.['access-token']).toEqual(
|
||||
expect.objectContaining({ type: 'http', scheme: 'bearer' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('requires bearer on /auth/me but not on public auth posts', () => {
|
||||
const document = createOpenApiDocument(app);
|
||||
|
||||
expect(document.paths['/auth/me']?.get?.security).toEqual([
|
||||
{ 'access-token': [] },
|
||||
]);
|
||||
expect(document.paths['/auth/login']?.post?.security).toBeUndefined();
|
||||
expect(document.paths['/auth/register']?.post?.security).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupSwagger', () => {
|
||||
let app: INestApplication;
|
||||
let setupSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setupSpy = jest
|
||||
.spyOn(SwaggerModule, 'setup')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setupSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('skips mounting when swagger is disabled', () => {
|
||||
setupSwagger(app, {
|
||||
NODE_ENV: 'production',
|
||||
});
|
||||
|
||||
expect(setupSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mounts at /docs when swagger is enabled', () => {
|
||||
setupSwagger(app, {
|
||||
NODE_ENV: 'test',
|
||||
});
|
||||
|
||||
expect(setupSpy).toHaveBeenCalledWith(
|
||||
'docs',
|
||||
app,
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ jsonDocumentUrl: 'docs-json' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import type { OpenAPIObject } from '@nestjs/swagger';
|
||||
|
||||
export const SWAGGER_PATH = 'docs';
|
||||
export const SWAGGER_JSON_PATH = 'docs-json';
|
||||
export const BEARER_AUTH_NAME = 'access-token';
|
||||
|
||||
export function isSwaggerEnabled(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
if (env.SWAGGER_ENABLED === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (env.SWAGGER_ENABLED === 'false') {
|
||||
return false;
|
||||
}
|
||||
return env.NODE_ENV !== 'production';
|
||||
}
|
||||
|
||||
export function createOpenApiDocument(app: INestApplication): OpenAPIObject {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Tracking API')
|
||||
.setDescription('HTTP API for the Tracking service')
|
||||
.setVersion('0.0.1')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'Access token from login or register',
|
||||
},
|
||||
BEARER_AUTH_NAME,
|
||||
)
|
||||
.build();
|
||||
|
||||
return SwaggerModule.createDocument(app, config);
|
||||
}
|
||||
|
||||
export function setupSwagger(
|
||||
app: INestApplication,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
if (!isSwaggerEnabled(env)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = createOpenApiDocument(app);
|
||||
SwaggerModule.setup(SWAGGER_PATH, app, document, {
|
||||
jsonDocumentUrl: SWAGGER_JSON_PATH,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { loadEnv } from './env';
|
||||
|
||||
describe('loadEnv', () => {
|
||||
const valid = {
|
||||
DATABASE_URL: 'postgresql://user:pass@localhost:5432/tracking',
|
||||
JWT_ACCESS_SECRET: 'test-secret-at-least-32-chars-long!!',
|
||||
};
|
||||
|
||||
it('loads required values with defaults', () => {
|
||||
const env = loadEnv(valid);
|
||||
|
||||
expect(env.DATABASE_URL).toBe(valid.DATABASE_URL);
|
||||
expect(env.JWT_ACCESS_SECRET).toBe(valid.JWT_ACCESS_SECRET);
|
||||
expect(env.PORT).toBe(3000);
|
||||
expect(env.JWT_ACCESS_EXPIRES_IN).toBe('15m');
|
||||
expect(env.JWT_ACCESS_EXPIRES_IN_MS).toBe(15 * 60 * 1000);
|
||||
expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
expect(env.BCRYPT_SALT_ROUNDS).toBe(10);
|
||||
expect(env.DEFAULT_TIMEZONE).toBe('GMT+7');
|
||||
});
|
||||
|
||||
it('throws when DATABASE_URL is missing', () => {
|
||||
expect(() =>
|
||||
loadEnv({ JWT_ACCESS_SECRET: valid.JWT_ACCESS_SECRET }),
|
||||
).toThrow('DATABASE_URL is not configured');
|
||||
});
|
||||
|
||||
it('throws when JWT_ACCESS_SECRET is missing', () => {
|
||||
expect(() => loadEnv({ DATABASE_URL: valid.DATABASE_URL })).toThrow(
|
||||
'JWT_ACCESS_SECRET is not configured',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when JWT_ACCESS_SECRET is a placeholder', () => {
|
||||
expect(() =>
|
||||
loadEnv({
|
||||
DATABASE_URL: valid.DATABASE_URL,
|
||||
JWT_ACCESS_SECRET: 'change-me-to-a-long-random-secret',
|
||||
}),
|
||||
).toThrow('known placeholder');
|
||||
});
|
||||
|
||||
it('throws when JWT_ACCESS_SECRET is too short', () => {
|
||||
expect(() =>
|
||||
loadEnv({
|
||||
DATABASE_URL: valid.DATABASE_URL,
|
||||
JWT_ACCESS_SECRET: 'short-secret',
|
||||
}),
|
||||
).toThrow('at least 32 characters');
|
||||
});
|
||||
|
||||
it('parses optional numeric overrides', () => {
|
||||
const env = loadEnv({
|
||||
...valid,
|
||||
PORT: '4000',
|
||||
BCRYPT_SALT_ROUNDS: '12',
|
||||
REFRESH_TOKEN_EXPIRES_IN_MS: '3600000',
|
||||
JWT_ACCESS_EXPIRES_IN: '5m',
|
||||
DEFAULT_TIMEZONE: 'GMT+0',
|
||||
});
|
||||
|
||||
expect(env.PORT).toBe(4000);
|
||||
expect(env.BCRYPT_SALT_ROUNDS).toBe(12);
|
||||
expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(3_600_000);
|
||||
expect(env.JWT_ACCESS_EXPIRES_IN).toBe('5m');
|
||||
expect(env.JWT_ACCESS_EXPIRES_IN_MS).toBe(5 * 60 * 1000);
|
||||
expect(env.DEFAULT_TIMEZONE).toBe('GMT+0');
|
||||
});
|
||||
|
||||
it('rejects invalid JWT_ACCESS_EXPIRES_IN format', () => {
|
||||
expect(() =>
|
||||
loadEnv({
|
||||
...valid,
|
||||
JWT_ACCESS_EXPIRES_IN: '900',
|
||||
}),
|
||||
).toThrow('must match');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
export type AppEnv = {
|
||||
PORT: number;
|
||||
DATABASE_URL: string;
|
||||
JWT_ACCESS_SECRET: string;
|
||||
JWT_ACCESS_EXPIRES_IN: string;
|
||||
JWT_ACCESS_EXPIRES_IN_MS: number;
|
||||
REFRESH_TOKEN_EXPIRES_IN_MS: number;
|
||||
BCRYPT_SALT_ROUNDS: number;
|
||||
DEFAULT_TIMEZONE: string;
|
||||
};
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const FIFTEEN_MINUTES_MS = 15 * 60 * 1000;
|
||||
const MIN_SECRET_LENGTH = 32;
|
||||
|
||||
const PLACEHOLDER_SECRETS = new Set([
|
||||
'change-me-to-a-long-random-secret',
|
||||
'changeme',
|
||||
'secret',
|
||||
]);
|
||||
|
||||
function requireString(name: string, value: string | undefined): string {
|
||||
if (value === undefined || value.trim() === '') {
|
||||
throw new Error(`${name} is not configured`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requireSecret(name: string, value: string | undefined): string {
|
||||
const secret = requireString(name, value);
|
||||
if (secret.length < MIN_SECRET_LENGTH) {
|
||||
throw new Error(`${name} must be at least ${MIN_SECRET_LENGTH} characters`);
|
||||
}
|
||||
if (PLACEHOLDER_SECRETS.has(secret.toLowerCase())) {
|
||||
throw new Error(`${name} is set to a known placeholder value`);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function parsePositiveInt(
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (value === undefined || value.trim() === '') {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Parses forms like 15m, 900s, 1h, 7d into milliseconds. */
|
||||
export function parseDurationMs(
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
fallbackMs: number,
|
||||
): { raw: string; ms: number } {
|
||||
if (value === undefined || value.trim() === '') {
|
||||
return { raw: '15m', ms: fallbackMs };
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
const match = /^(\d+)([smhd])$/.exec(trimmed);
|
||||
if (!match) {
|
||||
throw new Error(`${name} must match <number><s|m|h|d> (e.g. 15m, 7d)`);
|
||||
}
|
||||
const amount = Number.parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
const multipliers: Record<string, number> = {
|
||||
s: 1000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
};
|
||||
return { raw: trimmed, ms: amount * multipliers[unit] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes process environment for application boot.
|
||||
* Throws if required secrets or connection strings are missing.
|
||||
*/
|
||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
||||
const accessExpiry = parseDurationMs(
|
||||
'JWT_ACCESS_EXPIRES_IN',
|
||||
source.JWT_ACCESS_EXPIRES_IN,
|
||||
FIFTEEN_MINUTES_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
PORT: parsePositiveInt('PORT', source.PORT, 3000),
|
||||
DATABASE_URL: requireString('DATABASE_URL', source.DATABASE_URL),
|
||||
JWT_ACCESS_SECRET: requireSecret(
|
||||
'JWT_ACCESS_SECRET',
|
||||
source.JWT_ACCESS_SECRET,
|
||||
),
|
||||
JWT_ACCESS_EXPIRES_IN: accessExpiry.raw,
|
||||
JWT_ACCESS_EXPIRES_IN_MS: accessExpiry.ms,
|
||||
REFRESH_TOKEN_EXPIRES_IN_MS: parsePositiveInt(
|
||||
'REFRESH_TOKEN_EXPIRES_IN_MS',
|
||||
source.REFRESH_TOKEN_EXPIRES_IN_MS,
|
||||
SEVEN_DAYS_MS,
|
||||
),
|
||||
BCRYPT_SALT_ROUNDS: parsePositiveInt(
|
||||
'BCRYPT_SALT_ROUNDS',
|
||||
source.BCRYPT_SALT_ROUNDS,
|
||||
10,
|
||||
),
|
||||
DEFAULT_TIMEZONE: source.DEFAULT_TIMEZONE?.trim() || 'GMT+7',
|
||||
};
|
||||
}
|
||||
|
||||
/** Nest ConfigModule factory — returns a plain object for ConfigService. */
|
||||
export default () => loadEnv();
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Global,
|
||||
Inject,
|
||||
Injectable,
|
||||
Module,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import postgres, { type Sql } from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
export const DRIZZLE = Symbol('DRIZZLE');
|
||||
export const POSTGRES_CLIENT = Symbol('POSTGRES_CLIENT');
|
||||
|
||||
export type DrizzleDB = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
class PostgresShutdown implements OnModuleDestroy {
|
||||
constructor(@Inject(POSTGRES_CLIENT) private readonly client: Sql) {}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.client.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: POSTGRES_CLIENT,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): Sql => {
|
||||
const url = config.getOrThrow<string>('DATABASE_URL');
|
||||
return postgres(url, { max: 10 });
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DRIZZLE,
|
||||
inject: [POSTGRES_CLIENT],
|
||||
useFactory: (client: Sql): DrizzleDB => drizzle(client, { schema }),
|
||||
},
|
||||
PostgresShutdown,
|
||||
],
|
||||
exports: [DRIZZLE],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
bigint,
|
||||
index,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* Application users. Timestamps are UTC unix milliseconds.
|
||||
*/
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
username: text('username').notNull(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(t) => [uniqueIndex('users_username_unique').on(t.username)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Refresh sessions. token_hash is SHA-256 of the opaque refresh token.
|
||||
*/
|
||||
export const refreshTokens = pgTable(
|
||||
'refresh_tokens',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
accessJti: text('access_jti').notNull(),
|
||||
expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
|
||||
revokedAt: bigint('revoked_at', { mode: 'number' }),
|
||||
replacedBy: uuid('replaced_by'),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('refresh_tokens_token_hash_unique').on(t.tokenHash),
|
||||
index('refresh_tokens_user_id_idx').on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Denylist for access JWT jti values after revoke or refresh rotation.
|
||||
*/
|
||||
export const revokedAccessTokens = pgTable('revoked_access_tokens', {
|
||||
jti: text('jti').notNull().primaryKey(),
|
||||
expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export type UserRow = typeof users.$inferSelect;
|
||||
export type NewUserRow = typeof users.$inferInsert;
|
||||
export type RefreshTokenRow = typeof refreshTokens.$inferSelect;
|
||||
export type NewRefreshTokenRow = typeof refreshTokens.$inferInsert;
|
||||
+6
-2
@@ -1,8 +1,12 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { configureApp } from './common/configure-app';
|
||||
import { loadEnv } from './config/env';
|
||||
|
||||
async function bootstrap() {
|
||||
const env = loadEnv();
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
configureApp(app);
|
||||
await app.listen(env.PORT);
|
||||
}
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
let authService: jest.Mocked<
|
||||
Pick<AuthService, 'register' | 'login' | 'refresh' | 'revoke'>
|
||||
>;
|
||||
|
||||
beforeEach(async () => {
|
||||
authService = {
|
||||
register: jest.fn().mockResolvedValue({
|
||||
accessToken: 'a',
|
||||
refreshToken: 'b'.repeat(64),
|
||||
}),
|
||||
login: jest.fn().mockResolvedValue({
|
||||
accessToken: 'a',
|
||||
refreshToken: 'b'.repeat(64),
|
||||
}),
|
||||
refresh: jest.fn().mockResolvedValue({
|
||||
accessToken: 'c',
|
||||
refreshToken: 'd'.repeat(64),
|
||||
}),
|
||||
revoke: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [{ provide: AuthService, useValue: authService }],
|
||||
}).compile();
|
||||
|
||||
controller = moduleRef.get(AuthController);
|
||||
});
|
||||
|
||||
it('register delegates to AuthService', async () => {
|
||||
await controller.register({ username: 'alice', password: 'password123' });
|
||||
expect(authService.register).toHaveBeenCalledWith('alice', 'password123');
|
||||
});
|
||||
|
||||
it('login delegates to AuthService', async () => {
|
||||
await controller.login({ username: 'alice', password: 'password123' });
|
||||
expect(authService.login).toHaveBeenCalledWith('alice', 'password123');
|
||||
});
|
||||
|
||||
it('refresh delegates to AuthService', async () => {
|
||||
const token = 'e'.repeat(64);
|
||||
await controller.refresh({ refreshToken: token });
|
||||
expect(authService.refresh).toHaveBeenCalledWith(token);
|
||||
});
|
||||
|
||||
it('revoke delegates to AuthService', async () => {
|
||||
const token = 'f'.repeat(64);
|
||||
await controller.revoke({ refreshToken: token });
|
||||
expect(authService.revoke).toHaveBeenCalledWith(token);
|
||||
});
|
||||
|
||||
it('me returns id and username', () => {
|
||||
expect(
|
||||
controller.me({ id: 'user-1', username: 'alice', jti: 'jti-1' }),
|
||||
).toEqual({ id: 'user-1', username: 'alice' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiBearerAuth,
|
||||
ApiConflictResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiTooManyRequestsResponse,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { AuthUser } from '../../common/auth/auth-user';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
LoginDto,
|
||||
MeResponseDto,
|
||||
RefreshTokenDto,
|
||||
RegisterDto,
|
||||
TokenPairDto,
|
||||
} from './dto/auth.dto';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@ApiCreatedResponse({ type: TokenPairDto })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
@ApiConflictResponse({ description: 'Username already registered' })
|
||||
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
|
||||
register(@Body() dto: RegisterDto): Promise<TokenPairDto> {
|
||||
return this.authService.register(dto.username, dto.password);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Log in with username and password' })
|
||||
@ApiOkResponse({ type: TokenPairDto })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
|
||||
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
|
||||
login(@Body() dto: LoginDto): Promise<TokenPairDto> {
|
||||
return this.authService.login(dto.username, dto.password);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: 'Rotate refresh token and issue a new access token',
|
||||
})
|
||||
@ApiOkResponse({ type: TokenPairDto })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid refresh token' })
|
||||
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
|
||||
refresh(@Body() dto: RefreshTokenDto): Promise<TokenPairDto> {
|
||||
return this.authService.refresh(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@Post('revoke')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Revoke a refresh token session' })
|
||||
@ApiNoContentResponse({ description: 'Session revoked (or already invalid)' })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
|
||||
async revoke(@Body() dto: RefreshTokenDto): Promise<void> {
|
||||
await this.authService.revoke(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@ApiOperation({ summary: 'Get the current authenticated user' })
|
||||
@ApiOkResponse({ type: MeResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: 'Missing or invalid access token' })
|
||||
me(@CurrentUser() user: AuthUser): MeResponseDto {
|
||||
return { id: user.id, username: user.username };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RefreshTokensRepository } from './refresh-tokens.repository';
|
||||
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: config.getOrThrow<string>(
|
||||
'JWT_ACCESS_EXPIRES_IN',
|
||||
) as `${number}${'s' | 'm' | 'h' | 'd'}`,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
RefreshTokensRepository,
|
||||
RevokedAccessTokensRepository,
|
||||
JwtStrategy,
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import type { User } from '../users/user';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
RefreshTokensRepository,
|
||||
type RefreshSession,
|
||||
} from './refresh-tokens.repository';
|
||||
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let usersService: jest.Mocked<
|
||||
Pick<UsersService, 'create' | 'findByUsername' | 'findById'>
|
||||
>;
|
||||
let jwtService: jest.Mocked<Pick<JwtService, 'signAsync'>>;
|
||||
let config: { getOrThrow: jest.Mock };
|
||||
let refreshTokensRepository: jest.Mocked<
|
||||
Pick<
|
||||
RefreshTokensRepository,
|
||||
| 'create'
|
||||
| 'findByTokenHash'
|
||||
| 'claimForRotation'
|
||||
| 'setReplacedBy'
|
||||
| 'markRevoked'
|
||||
| 'revokeAllForUser'
|
||||
>
|
||||
>;
|
||||
let revokedAccessTokensRepository: jest.Mocked<
|
||||
Pick<RevokedAccessTokensRepository, 'add' | 'exists'>
|
||||
>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
let user: User;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: await bcrypt.hash('password123', 4),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
usersService = {
|
||||
create: jest.fn(),
|
||||
findByUsername: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
};
|
||||
jwtService = {
|
||||
signAsync: jest.fn().mockResolvedValue('access.jwt.token'),
|
||||
};
|
||||
config = {
|
||||
getOrThrow: jest.fn((key: string) => {
|
||||
const values: Record<string, string | number> = {
|
||||
BCRYPT_SALT_ROUNDS: 4,
|
||||
JWT_ACCESS_EXPIRES_IN: '15m',
|
||||
JWT_ACCESS_EXPIRES_IN_MS: 15 * 60 * 1000,
|
||||
REFRESH_TOKEN_EXPIRES_IN_MS: 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
return values[key];
|
||||
}),
|
||||
};
|
||||
refreshTokensRepository = {
|
||||
create: jest.fn().mockImplementation(async (input) => ({
|
||||
id: 'session-new',
|
||||
userId: input.userId,
|
||||
tokenHash: input.tokenHash,
|
||||
accessJti: input.accessJti,
|
||||
expiresAt: input.expiresAt,
|
||||
revokedAt: null,
|
||||
replacedBy: null,
|
||||
createdAt: now,
|
||||
})),
|
||||
findByTokenHash: jest.fn(),
|
||||
claimForRotation: jest.fn(),
|
||||
setReplacedBy: jest.fn(),
|
||||
markRevoked: jest.fn(),
|
||||
revokeAllForUser: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
revokedAccessTokensRepository = {
|
||||
add: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
{ provide: JwtService, useValue: jwtService },
|
||||
{ provide: ConfigService, useValue: config },
|
||||
{ provide: RefreshTokensRepository, useValue: refreshTokensRepository },
|
||||
{
|
||||
provide: RevokedAccessTokensRepository,
|
||||
useValue: revokedAccessTokensRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(AuthService);
|
||||
});
|
||||
|
||||
it('register creates user and returns token pair', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
usersService.create.mockResolvedValue(user);
|
||||
|
||||
const pair = await service.register('Alice', 'password123');
|
||||
|
||||
expect(usersService.create).toHaveBeenCalled();
|
||||
expect(pair.accessToken).toBe('access.jwt.token');
|
||||
expect(pair.refreshToken).toHaveLength(64);
|
||||
expect(Object.keys(pair).sort()).toEqual(['accessToken', 'refreshToken']);
|
||||
expect(refreshTokensRepository.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('register throws ConflictException when username exists', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(user);
|
||||
|
||||
await expect(
|
||||
service.register('alice', 'password123'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('login returns tokens for valid credentials', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(user);
|
||||
|
||||
const pair = await service.login('alice', 'password123');
|
||||
|
||||
expect(pair.accessToken).toBe('access.jwt.token');
|
||||
expect(pair.refreshToken).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('login throws UnauthorizedException for unknown user', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
|
||||
await expect(service.login('alice', 'password123')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('login throws UnauthorizedException for wrong password', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(user);
|
||||
|
||||
await expect(service.login('alice', 'wrong-pass')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('refresh rotates tokens and denylists old access jti', async () => {
|
||||
const rawRefresh = 'a'.repeat(64);
|
||||
const session: RefreshSession = {
|
||||
id: 'session-old',
|
||||
userId: user.id,
|
||||
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
|
||||
accessJti: 'jti-old',
|
||||
expiresAt: DateTime.fromUnixMs(Date.now() + 60_000),
|
||||
revokedAt: DateTime.fromUnixMs(Date.now()),
|
||||
replacedBy: null,
|
||||
createdAt: now,
|
||||
};
|
||||
refreshTokensRepository.claimForRotation.mockResolvedValue(session);
|
||||
usersService.findById.mockResolvedValue(user);
|
||||
|
||||
const pair = await service.refresh(rawRefresh);
|
||||
|
||||
expect(revokedAccessTokensRepository.add).toHaveBeenCalledWith(
|
||||
'jti-old',
|
||||
expect.any(DateTime),
|
||||
);
|
||||
expect(refreshTokensRepository.setReplacedBy).toHaveBeenCalledWith(
|
||||
'session-old',
|
||||
'session-new',
|
||||
);
|
||||
expect(pair.accessToken).toBe('access.jwt.token');
|
||||
});
|
||||
|
||||
it('refresh reuse revokes all user sessions and access jtis', async () => {
|
||||
const rawRefresh = 'b'.repeat(64);
|
||||
const session: RefreshSession = {
|
||||
id: 'session-old',
|
||||
userId: user.id,
|
||||
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
|
||||
accessJti: 'jti-old',
|
||||
expiresAt: DateTime.fromUnixMs(Date.now() + 60_000),
|
||||
revokedAt: DateTime.fromUnixMs(Date.now() - 1000),
|
||||
replacedBy: 'session-new',
|
||||
createdAt: now,
|
||||
};
|
||||
refreshTokensRepository.claimForRotation.mockResolvedValue(null);
|
||||
refreshTokensRepository.findByTokenHash.mockResolvedValue(session);
|
||||
refreshTokensRepository.revokeAllForUser.mockResolvedValue([
|
||||
{
|
||||
...session,
|
||||
id: 'other-session',
|
||||
accessJti: 'jti-other',
|
||||
revokedAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.refresh(rawRefresh)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(refreshTokensRepository.revokeAllForUser).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
);
|
||||
expect(revokedAccessTokensRepository.add).toHaveBeenCalledWith(
|
||||
'jti-other',
|
||||
expect.any(DateTime),
|
||||
);
|
||||
});
|
||||
|
||||
it('refresh rejects expired tokens', async () => {
|
||||
const rawRefresh = 'c'.repeat(64);
|
||||
const session: RefreshSession = {
|
||||
id: 'session-old',
|
||||
userId: user.id,
|
||||
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
|
||||
accessJti: 'jti-old',
|
||||
expiresAt: DateTime.fromUnixMs(Date.now() - 1000),
|
||||
revokedAt: DateTime.fromUnixMs(Date.now()),
|
||||
replacedBy: null,
|
||||
createdAt: now,
|
||||
};
|
||||
refreshTokensRepository.claimForRotation.mockResolvedValue(session);
|
||||
|
||||
await expect(service.refresh(rawRefresh)).rejects.toThrow(
|
||||
'Invalid refresh token',
|
||||
);
|
||||
});
|
||||
|
||||
it('revoke marks session revoked and denylists access jti', async () => {
|
||||
const rawRefresh = 'd'.repeat(64);
|
||||
const session: RefreshSession = {
|
||||
id: 'session-1',
|
||||
userId: user.id,
|
||||
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
|
||||
accessJti: 'jti-1',
|
||||
expiresAt: DateTime.fromUnixMs(Date.now() + 60_000),
|
||||
revokedAt: null,
|
||||
replacedBy: null,
|
||||
createdAt: now,
|
||||
};
|
||||
refreshTokensRepository.findByTokenHash.mockResolvedValue(session);
|
||||
refreshTokensRepository.markRevoked.mockResolvedValue(true);
|
||||
|
||||
await service.revoke(rawRefresh);
|
||||
|
||||
expect(refreshTokensRepository.markRevoked).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
);
|
||||
expect(revokedAccessTokensRepository.add).toHaveBeenCalledWith(
|
||||
'jti-1',
|
||||
expect.any(DateTime),
|
||||
);
|
||||
});
|
||||
|
||||
it('revoke is a no-op for unknown refresh tokens', async () => {
|
||||
refreshTokensRepository.findByTokenHash.mockResolvedValue(null);
|
||||
|
||||
await expect(service.revoke('unknown')).resolves.toBeUndefined();
|
||||
expect(refreshTokensRepository.markRevoked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { JwtAccessPayload } from '../../common/auth/auth-user';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import type { User } from '../users/user';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { RefreshTokensRepository } from './refresh-tokens.repository';
|
||||
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
|
||||
|
||||
export type TokenPair = {
|
||||
readonly accessToken: string;
|
||||
readonly refreshToken: string;
|
||||
};
|
||||
|
||||
/** Precomputed bcrypt hash used only to equalize login timing on unknown users. */
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
'$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly refreshTokensRepository: RefreshTokensRepository,
|
||||
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository,
|
||||
) {}
|
||||
|
||||
async register(username: string, password: string): Promise<TokenPair> {
|
||||
const existing = await this.usersService.findByUsername(username);
|
||||
if (existing) {
|
||||
throw new ConflictException('Username already registered');
|
||||
}
|
||||
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
const user = await this.usersService.create(username, passwordHash);
|
||||
const { tokens } = await this.issueTokenPair(user);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<TokenPair> {
|
||||
const user = await this.usersService.findByUsername(username);
|
||||
const passwordHash = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
|
||||
const match = await bcrypt.compare(password, passwordHash);
|
||||
if (!user || !match) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const { tokens } = await this.issueTokenPair(user);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string): Promise<TokenPair> {
|
||||
const tokenHash = this.hashRefreshToken(refreshToken);
|
||||
const claimed =
|
||||
await this.refreshTokensRepository.claimForRotation(tokenHash);
|
||||
|
||||
if (!claimed) {
|
||||
const existing =
|
||||
await this.refreshTokensRepository.findByTokenHash(tokenHash);
|
||||
if (existing) {
|
||||
await this.revokeAllSessionsForUser(existing.userId);
|
||||
}
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
if (claimed.expiresAt.value <= Date.now()) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findById(claimed.userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
await this.denylistAccessJti(claimed.accessJti);
|
||||
const issued = await this.issueTokenPair(user);
|
||||
await this.refreshTokensRepository.setReplacedBy(
|
||||
claimed.id,
|
||||
issued.sessionId,
|
||||
);
|
||||
|
||||
return issued.tokens;
|
||||
}
|
||||
|
||||
async revoke(refreshToken: string): Promise<void> {
|
||||
const tokenHash = this.hashRefreshToken(refreshToken);
|
||||
const session =
|
||||
await this.refreshTokensRepository.findByTokenHash(tokenHash);
|
||||
|
||||
if (!session || session.revokedAt !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const claimed = await this.refreshTokensRepository.markRevoked(session.id);
|
||||
if (claimed) {
|
||||
await this.denylistAccessJti(session.accessJti);
|
||||
}
|
||||
}
|
||||
|
||||
async isAccessJtiRevoked(jti: string): Promise<boolean> {
|
||||
return this.revokedAccessTokensRepository.exists(jti);
|
||||
}
|
||||
|
||||
private async revokeAllSessionsForUser(userId: string): Promise<void> {
|
||||
const revoked = await this.refreshTokensRepository.revokeAllForUser(userId);
|
||||
await Promise.all(
|
||||
revoked.map((session) => this.denylistAccessJti(session.accessJti)),
|
||||
);
|
||||
}
|
||||
|
||||
private async issueTokenPair(
|
||||
user: User,
|
||||
): Promise<{ tokens: TokenPair; sessionId: string }> {
|
||||
const jti = randomUUID();
|
||||
const payload: JwtAccessPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
jti,
|
||||
typ: 'access',
|
||||
};
|
||||
|
||||
const expiresIn = this.config.getOrThrow<string>('JWT_ACCESS_EXPIRES_IN');
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ ...payload },
|
||||
{ expiresIn: expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}` },
|
||||
);
|
||||
|
||||
const refreshToken = randomBytes(32).toString('hex');
|
||||
const refreshMs = this.config.getOrThrow<number>(
|
||||
'REFRESH_TOKEN_EXPIRES_IN_MS',
|
||||
);
|
||||
const expiresAt = DateTime.fromUnixMs(Date.now() + refreshMs);
|
||||
|
||||
const session = await this.refreshTokensRepository.create({
|
||||
userId: user.id,
|
||||
tokenHash: this.hashRefreshToken(refreshToken),
|
||||
accessJti: jti,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
tokens: { accessToken, refreshToken },
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
private hashRefreshToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
private async denylistAccessJti(jti: string): Promise<void> {
|
||||
const ttlMs = this.config.getOrThrow<number>('JWT_ACCESS_EXPIRES_IN_MS');
|
||||
const expiresAt = DateTime.fromUnixMs(Date.now() + ttlMs);
|
||||
await this.revokedAccessTokensRepository.add(jti, expiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
import type { TokenPair } from '../auth.service';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({
|
||||
example: 'alice',
|
||||
description: 'Letters, numbers, and underscores only',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(32)
|
||||
@Matches(/^[a-zA-Z0-9_]+$/, {
|
||||
message: 'username must contain only letters, numbers, and underscores',
|
||||
})
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'password123',
|
||||
format: 'password',
|
||||
writeOnly: true,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'alice' })
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(32)
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'password123',
|
||||
format: 'password',
|
||||
writeOnly: true,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({
|
||||
example: 'a'.repeat(64),
|
||||
description: 'Opaque refresh token from login or register',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(32)
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
export class TokenPairDto implements TokenPair {
|
||||
@ApiProperty({
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example',
|
||||
description: 'JWT access token',
|
||||
})
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'a'.repeat(64),
|
||||
description: 'Opaque refresh token',
|
||||
})
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
export class MeResponseDto {
|
||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: 'alice' })
|
||||
username!: string;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { refreshTokens, type RefreshTokenRow } from '../../database/schema';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
|
||||
|
||||
export type RefreshSession = {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly tokenHash: string;
|
||||
readonly accessJti: string;
|
||||
readonly expiresAt: DateTime;
|
||||
readonly revokedAt: DateTime | null;
|
||||
readonly replacedBy: string | null;
|
||||
readonly createdAt: DateTime;
|
||||
};
|
||||
|
||||
export type CreateRefreshSessionInput = {
|
||||
readonly userId: string;
|
||||
readonly tokenHash: string;
|
||||
readonly accessJti: string;
|
||||
readonly expiresAt: DateTime;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTokensRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async create(input: CreateRefreshSessionInput): Promise<RefreshSession> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const [row] = await this.db
|
||||
.insert(refreshTokens)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
tokenHash: input.tokenHash,
|
||||
accessJti: input.accessJti,
|
||||
expiresAt: input.expiresAt.value,
|
||||
createdAt: now.value,
|
||||
})
|
||||
.returning();
|
||||
return this.toDomain(row);
|
||||
}
|
||||
|
||||
async findByTokenHash(tokenHash: string): Promise<RefreshSession | null> {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(refreshTokens)
|
||||
.where(eq(refreshTokens.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claims a session for rotation. Returns null if already revoked
|
||||
* or missing (loser of a concurrent refresh race).
|
||||
*/
|
||||
async claimForRotation(tokenHash: string): Promise<RefreshSession | null> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const [row] = await this.db
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: now.value })
|
||||
.where(
|
||||
and(
|
||||
eq(refreshTokens.tokenHash, tokenHash),
|
||||
isNull(refreshTokens.revokedAt),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async setReplacedBy(id: string, replacedBy: string): Promise<void> {
|
||||
await this.db
|
||||
.update(refreshTokens)
|
||||
.set({ replacedBy })
|
||||
.where(eq(refreshTokens.id, id));
|
||||
}
|
||||
|
||||
async markRevoked(id: string): Promise<boolean> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: now.value })
|
||||
.where(and(eq(refreshTokens.id, id), isNull(refreshTokens.revokedAt)))
|
||||
.returning({ id: refreshTokens.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async revokeAllForUser(userId: string): Promise<RefreshSession[]> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: now.value })
|
||||
.where(
|
||||
and(eq(refreshTokens.userId, userId), isNull(refreshTokens.revokedAt)),
|
||||
)
|
||||
.returning();
|
||||
return rows.map((row) => this.toDomain(row));
|
||||
}
|
||||
|
||||
private toDomain(row: RefreshTokenRow): RefreshSession {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
tokenHash: row.tokenHash,
|
||||
accessJti: row.accessJti,
|
||||
expiresAt: DateTime.fromUnixMs(row.expiresAt),
|
||||
revokedAt:
|
||||
row.revokedAt === null || row.revokedAt === undefined
|
||||
? null
|
||||
: DateTime.fromUnixMs(row.revokedAt),
|
||||
replacedBy: row.replacedBy ?? null,
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { revokedAccessTokens } from '../../database/schema';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
|
||||
|
||||
@Injectable()
|
||||
export class RevokedAccessTokensRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async add(jti: string, expiresAt: DateTime): Promise<void> {
|
||||
await this.db
|
||||
.insert(revokedAccessTokens)
|
||||
.values({ jti, expiresAt: expiresAt.value })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async exists(jti: string): Promise<boolean> {
|
||||
const [row] = await this.db
|
||||
.select({ jti: revokedAccessTokens.jti })
|
||||
.from(revokedAccessTokens)
|
||||
.where(eq(revokedAccessTokens.jti, jti))
|
||||
.limit(1);
|
||||
return row !== undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import type { User } from '../../users/user';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
let strategy: JwtStrategy;
|
||||
let usersService: jest.Mocked<Pick<UsersService, 'findById'>>;
|
||||
let revoked: jest.Mocked<Pick<RevokedAccessTokensRepository, 'exists'>>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const user: User = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
usersService = { findById: jest.fn() };
|
||||
revoked = { exists: jest.fn() };
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
getOrThrow: () => 'test-secret-at-least-32-characters-long!!',
|
||||
},
|
||||
},
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
{ provide: RevokedAccessTokensRepository, useValue: revoked },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
strategy = moduleRef.get(JwtStrategy);
|
||||
});
|
||||
|
||||
it('returns AuthUser for a valid access payload', async () => {
|
||||
revoked.exists.mockResolvedValue(false);
|
||||
usersService.findById.mockResolvedValue(user);
|
||||
|
||||
await expect(
|
||||
strategy.validate({
|
||||
sub: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'jti-1',
|
||||
typ: 'access',
|
||||
}),
|
||||
).resolves.toEqual({ id: 'user-1', username: 'alice', jti: 'jti-1' });
|
||||
});
|
||||
|
||||
it('rejects revoked access tokens', async () => {
|
||||
revoked.exists.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
strategy.validate({
|
||||
sub: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'jti-1',
|
||||
typ: 'access',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects non-access token types', async () => {
|
||||
await expect(
|
||||
strategy.validate({
|
||||
sub: 'user-1',
|
||||
username: 'alice',
|
||||
jti: 'jti-1',
|
||||
typ: 'refresh' as 'access',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type {
|
||||
AuthUser,
|
||||
JwtAccessPayload,
|
||||
} from '../../../common/auth/auth-user';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtAccessPayload): Promise<AuthUser> {
|
||||
if (payload.typ !== 'access' || !payload.sub || !payload.jti) {
|
||||
throw new UnauthorizedException('Invalid access token');
|
||||
}
|
||||
|
||||
const revoked = await this.revokedAccessTokensRepository.exists(
|
||||
payload.jti,
|
||||
);
|
||||
if (revoked) {
|
||||
throw new UnauthorizedException('Access token revoked');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
jti: payload.jti,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DRIZZLE } from '../../database/database.module';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import { RefreshTokensRepository } from './refresh-tokens.repository';
|
||||
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
|
||||
|
||||
describe('RefreshTokensRepository', () => {
|
||||
let repository: RefreshTokensRepository;
|
||||
const returning = jest.fn();
|
||||
const where = jest.fn(() => ({ returning, limit: jest.fn() }));
|
||||
const set = jest.fn(() => ({ where }));
|
||||
const values = jest.fn(() => ({ returning }));
|
||||
const insert = jest.fn(() => ({ values }));
|
||||
const update = jest.fn(() => ({ set }));
|
||||
const from = jest.fn(() => ({ where }));
|
||||
const select = jest.fn(() => ({ from }));
|
||||
const db = { insert, update, select };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [RefreshTokensRepository, { provide: DRIZZLE, useValue: db }],
|
||||
}).compile();
|
||||
repository = moduleRef.get(RefreshTokensRepository);
|
||||
});
|
||||
|
||||
it('create returns a domain session', async () => {
|
||||
returning.mockResolvedValue([
|
||||
{
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
tokenHash: 'hash',
|
||||
accessJti: 'jti-1',
|
||||
expiresAt: 1_700_000_100_000,
|
||||
revokedAt: null,
|
||||
replacedBy: null,
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const session = await repository.create({
|
||||
userId: 'user-1',
|
||||
tokenHash: 'hash',
|
||||
accessJti: 'jti-1',
|
||||
expiresAt: DateTime.fromUnixMs(1_700_000_100_000),
|
||||
});
|
||||
|
||||
expect(session.id).toBe('session-1');
|
||||
expect(session.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('claimForRotation returns null when no row claimed', async () => {
|
||||
returning.mockResolvedValue([]);
|
||||
await expect(repository.claimForRotation('hash')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('findByTokenHash returns a session', async () => {
|
||||
const limitFn = jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
tokenHash: 'hash',
|
||||
accessJti: 'jti-1',
|
||||
expiresAt: 1_700_000_100_000,
|
||||
revokedAt: null,
|
||||
replacedBy: null,
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
where.mockReturnValueOnce({ limit: limitFn, returning });
|
||||
const session = await repository.findByTokenHash('hash');
|
||||
expect(session?.id).toBe('session-1');
|
||||
});
|
||||
|
||||
it('setReplacedBy updates the row', async () => {
|
||||
where.mockReturnValueOnce({ returning, limit: jest.fn() });
|
||||
await repository.setReplacedBy('session-1', 'session-2');
|
||||
expect(set).toHaveBeenCalledWith({ replacedBy: 'session-2' });
|
||||
});
|
||||
|
||||
it('markRevoked returns true when a row is updated', async () => {
|
||||
returning.mockResolvedValue([{ id: 'session-1' }]);
|
||||
await expect(repository.markRevoked('session-1')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('revokeAllForUser returns revoked sessions', async () => {
|
||||
returning.mockResolvedValue([
|
||||
{
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
tokenHash: 'hash',
|
||||
accessJti: 'jti-1',
|
||||
expiresAt: 1_700_000_100_000,
|
||||
revokedAt: 1_700_000_050_000,
|
||||
replacedBy: null,
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const sessions = await repository.revokeAllForUser('user-1');
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].accessJti).toBe('jti-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RevokedAccessTokensRepository', () => {
|
||||
let repository: RevokedAccessTokensRepository;
|
||||
const limit = jest.fn();
|
||||
const where = jest.fn(() => ({ limit }));
|
||||
const from = jest.fn(() => ({ where }));
|
||||
const select = jest.fn(() => ({ from }));
|
||||
const onConflictDoNothing = jest.fn();
|
||||
const values = jest.fn(() => ({ onConflictDoNothing }));
|
||||
const insert = jest.fn(() => ({ values }));
|
||||
const db = { insert, select };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RevokedAccessTokensRepository,
|
||||
{ provide: DRIZZLE, useValue: db },
|
||||
],
|
||||
}).compile();
|
||||
repository = moduleRef.get(RevokedAccessTokensRepository);
|
||||
});
|
||||
|
||||
it('add inserts a denylist row', async () => {
|
||||
onConflictDoNothing.mockResolvedValue(undefined);
|
||||
await repository.add('jti-1', DateTime.fromUnixMs(1_700_000_100_000));
|
||||
expect(values).toHaveBeenCalledWith({
|
||||
jti: 'jti-1',
|
||||
expiresAt: 1_700_000_100_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('exists returns true when jti is present', async () => {
|
||||
limit.mockResolvedValue([{ jti: 'jti-1' }]);
|
||||
await expect(repository.exists('jti-1')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('exists returns false when jti is absent', async () => {
|
||||
limit.mockResolvedValue([]);
|
||||
await expect(repository.exists('missing')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
|
||||
export type User = {
|
||||
readonly id: string;
|
||||
readonly username: string;
|
||||
readonly passwordHash: string;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
};
|
||||
|
||||
export type CreateUserInput = {
|
||||
readonly username: string;
|
||||
readonly passwordHash: string;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersRepository } from './users.repository';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
providers: [UsersRepository, UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DRIZZLE } from '../../database/database.module';
|
||||
import { UsersRepository } from './users.repository';
|
||||
|
||||
describe('UsersRepository', () => {
|
||||
let repository: UsersRepository;
|
||||
const limit = jest.fn();
|
||||
const where = jest.fn(() => ({ limit }));
|
||||
const from = jest.fn(() => ({ where }));
|
||||
const select = jest.fn(() => ({ from }));
|
||||
const returning = jest.fn();
|
||||
const values = jest.fn(() => ({ returning }));
|
||||
const insert = jest.fn(() => ({ values }));
|
||||
|
||||
const db = { select, insert };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [UsersRepository, { provide: DRIZZLE, useValue: db }],
|
||||
}).compile();
|
||||
repository = moduleRef.get(UsersRepository);
|
||||
});
|
||||
|
||||
it('findById maps a row to domain User', async () => {
|
||||
limit.mockResolvedValue([
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const user = await repository.findById('user-1');
|
||||
expect(user).toMatchObject({ id: 'user-1', username: 'alice' });
|
||||
expect(user?.createdAt.value).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('findById returns null when missing', async () => {
|
||||
limit.mockResolvedValue([]);
|
||||
await expect(repository.findById('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('create inserts lowercase username', async () => {
|
||||
returning.mockResolvedValue([
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const user = await repository.create({
|
||||
username: 'Alice',
|
||||
passwordHash: 'hash',
|
||||
});
|
||||
expect(user.username).toBe('alice');
|
||||
expect(values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ username: 'alice', passwordHash: 'hash' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('findByUsername maps a row', async () => {
|
||||
limit.mockResolvedValue([
|
||||
{
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
},
|
||||
]);
|
||||
const user = await repository.findByUsername('Alice');
|
||||
expect(user?.username).toBe('alice');
|
||||
});
|
||||
|
||||
it('create maps unique violations to ConflictException', async () => {
|
||||
returning.mockRejectedValue({ code: '23505' });
|
||||
await expect(
|
||||
repository.create({ username: 'alice', passwordHash: 'hash' }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('create rethrows unknown errors', async () => {
|
||||
returning.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
repository.create({ username: 'alice', passwordHash: 'hash' }),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ConflictException, Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { users, type UserRow } from '../../database/schema';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
|
||||
import type { CreateUserInput, User } from './user';
|
||||
|
||||
@Injectable()
|
||||
export class UsersRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
const normalized = username.toLowerCase();
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, normalized))
|
||||
.limit(1);
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async create(input: CreateUserInput): Promise<User> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
const [row] = await this.db
|
||||
.insert(users)
|
||||
.values({
|
||||
username: input.username.toLowerCase(),
|
||||
passwordHash: input.passwordHash,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
})
|
||||
.returning();
|
||||
return this.toDomain(row);
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
throw new ConflictException('Username already registered');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private toDomain(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.passwordHash,
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||
import type { User } from './user';
|
||||
import { UsersRepository } from './users.repository';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
let repository: jest.Mocked<
|
||||
Pick<UsersRepository, 'findById' | 'findByUsername' | 'create'>
|
||||
>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sampleUser: User = {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
passwordHash: 'hashed',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = {
|
||||
findById: jest.fn(),
|
||||
findByUsername: jest.fn(),
|
||||
create: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UsersService,
|
||||
{ provide: UsersRepository, useValue: repository },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(UsersService);
|
||||
});
|
||||
|
||||
it('findById delegates to repository', async () => {
|
||||
repository.findById.mockResolvedValue(sampleUser);
|
||||
await expect(service.findById('user-1')).resolves.toEqual(sampleUser);
|
||||
});
|
||||
|
||||
it('create stores lowercase username', async () => {
|
||||
repository.findByUsername.mockResolvedValue(null);
|
||||
repository.create.mockResolvedValue(sampleUser);
|
||||
|
||||
await service.create('Alice', 'hashed');
|
||||
|
||||
expect(repository.findByUsername).toHaveBeenCalledWith('alice');
|
||||
expect(repository.create).toHaveBeenCalledWith({
|
||||
username: 'alice',
|
||||
passwordHash: 'hashed',
|
||||
});
|
||||
});
|
||||
|
||||
it('create throws ConflictException when username exists', async () => {
|
||||
repository.findByUsername.mockResolvedValue(sampleUser);
|
||||
|
||||
await expect(service.create('alice', 'hashed')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('findByUsername delegates to repository', async () => {
|
||||
repository.findByUsername.mockResolvedValue(sampleUser);
|
||||
await expect(service.findByUsername('Alice')).resolves.toEqual(sampleUser);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { UsersRepository } from './users.repository';
|
||||
import type { User } from './user';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly usersRepository: UsersRepository) {}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.usersRepository.findById(id);
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
return this.usersRepository.findByUsername(username);
|
||||
}
|
||||
|
||||
async create(username: string, passwordHash: string): Promise<User> {
|
||||
const normalized = username.toLowerCase();
|
||||
const existing = await this.usersRepository.findByUsername(normalized);
|
||||
if (existing) {
|
||||
throw new ConflictException('Username already registered');
|
||||
}
|
||||
return this.usersRepository.create({
|
||||
username: normalized,
|
||||
passwordHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
import { configureApp } from '../src/common/configure-app';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
@@ -13,6 +14,10 @@ describe('AppController (e2e)', () => {
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
configureApp(app, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
});
|
||||
await app.init();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { configureApp } from '../src/common/configure-app';
|
||||
|
||||
describe('Auth (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
const username = `user_${Date.now()}`;
|
||||
const password = 'password123';
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
configureApp(app, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
});
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET / remains public', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
|
||||
it('GET /auth/me without token returns 401', () => {
|
||||
return request(app.getHttpServer()).get('/auth/me').expect(401);
|
||||
});
|
||||
|
||||
it('register → me → refresh → revoke → me 401', async () => {
|
||||
const register = await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({ username, password })
|
||||
.expect(201);
|
||||
|
||||
const { accessToken, refreshToken } = register.body as {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toHaveLength(64);
|
||||
expect(Object.keys(register.body).sort()).toEqual([
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
]);
|
||||
|
||||
const me = await request(app.getHttpServer())
|
||||
.get('/auth/me')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(me.body).toMatchObject({ username: username.toLowerCase() });
|
||||
|
||||
const refreshed = await request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.send({ refreshToken })
|
||||
.expect(200);
|
||||
|
||||
const { accessToken: nextAccess, refreshToken: nextRefresh } =
|
||||
refreshed.body as {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/auth/me')
|
||||
.set('Authorization', `Bearer ${nextAccess}`)
|
||||
.expect(200);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/revoke')
|
||||
.send({ refreshToken: nextRefresh })
|
||||
.expect(204);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/auth/me')
|
||||
.set('Authorization', `Bearer ${nextAccess}`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('login rejects invalid credentials', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ username, password: 'wrongpass' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('duplicate register returns 409', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({ username, password })
|
||||
.expect(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { configureApp } from '../src/common/configure-app';
|
||||
|
||||
describe('Swagger (e2e)', () => {
|
||||
describe('when enabled', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
configureApp(app, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'true',
|
||||
});
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /docs returns HTML UI', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/docs').expect(200);
|
||||
expect(res.text).toContain('Swagger UI');
|
||||
});
|
||||
|
||||
it('GET /docs-json includes auth paths', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/docs-json')
|
||||
.expect(200);
|
||||
|
||||
const body = res.body as {
|
||||
paths: Record<string, unknown>;
|
||||
components: { securitySchemes: Record<string, unknown> };
|
||||
};
|
||||
|
||||
expect(body.paths['/auth/login']).toBeDefined();
|
||||
expect(body.paths['/auth/me']).toBeDefined();
|
||||
expect(body.paths['/auth/register']).toBeDefined();
|
||||
expect(body.components.securitySchemes['access-token']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when production and not explicitly enabled', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
configureApp(app, {
|
||||
NODE_ENV: 'production',
|
||||
});
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /docs returns 404', () => {
|
||||
return request(app.getHttpServer()).get('/docs').expect(404);
|
||||
});
|
||||
|
||||
it('GET /docs-json returns 404', () => {
|
||||
return request(app.getHttpServer()).get('/docs-json').expect(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
+5
-5
@@ -1,11 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"declaration": false,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
@@ -13,6 +12,7 @@
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node", "jest"],
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -22,6 +22,6 @@
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user