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/`
|
- One feature folder under `src/modules/`
|
||||||
- Each feature has `*.module.ts`, `*.controller.ts`, `*.service.ts`, `dto/`
|
- Each feature has `*.module.ts`, `*.controller.ts`, `*.service.ts`, `dto/`
|
||||||
- Share cross-cutting code via `src/common/` (filters, guards, pipes, interceptors)
|
- Share cross-cutting code via `src/common/` (filters, guards, pipes, interceptors)
|
||||||
|
- Document HTTP endpoints per `.cursor/rules/nestjs-swagger.mdc`
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,24 @@
|
|||||||
# Stored values remain UTC unix milliseconds. Fixed offsets only (no IANA names).
|
# Stored values remain UTC unix milliseconds. Fixed offsets only (no IANA names).
|
||||||
# Examples: GMT+7, GMT+07:00, UTC+7, +07:00, +7, +0700
|
# Examples: GMT+7, GMT+07:00, UTC+7, +07:00, +7, +0700
|
||||||
DEFAULT_TIMEZONE=GMT+7
|
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",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"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:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@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",
|
"libphonenumber-js": "^1.13.11",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -33,10 +54,13 @@
|
|||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
@@ -49,7 +73,7 @@
|
|||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.20.0"
|
"typescript-eslint": "^8.20.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
@@ -64,8 +88,19 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": [
|
"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",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"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 { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Public } from './common/decorators/public.decorator';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@ApiTags('app')
|
||||||
|
@Public()
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Health/hello probe' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'Plain text greeting',
|
||||||
|
schema: { type: 'string', example: 'Hello World!' },
|
||||||
|
})
|
||||||
getHello(): string {
|
getHello(): string {
|
||||||
return this.appService.getHello();
|
return this.appService.getHello();
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -1,9 +1,22 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
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({
|
@Module({
|
||||||
imports: [],
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [loadEnv],
|
||||||
|
}),
|
||||||
|
DatabaseModule,
|
||||||
|
UsersModule,
|
||||||
|
AuthModule,
|
||||||
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
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 { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { configureApp } from './common/configure-app';
|
||||||
|
import { loadEnv } from './config/env';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
const env = loadEnv();
|
||||||
const app = await NestFactory.create(AppModule);
|
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 request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
import { AppModule } from './../src/app.module';
|
import { AppModule } from './../src/app.module';
|
||||||
|
import { configureApp } from '../src/common/configure-app';
|
||||||
|
|
||||||
describe('AppController (e2e)', () => {
|
describe('AppController (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
@@ -13,6 +14,10 @@ describe('AppController (e2e)', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
|
configureApp(app, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
});
|
||||||
await app.init();
|
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": {
|
"compilerOptions": {
|
||||||
"module": "nodenext",
|
"module": "commonjs",
|
||||||
"moduleResolution": "nodenext",
|
"moduleResolution": "bundler",
|
||||||
"resolvePackageJsonExports": true,
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"declaration": true,
|
"declaration": false,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
@@ -13,6 +12,7 @@
|
|||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
"types": ["node", "jest"],
|
"types": ["node", "jest"],
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -22,6 +22,6 @@
|
|||||||
"strictBindCallApply": false,
|
"strictBindCallApply": false,
|
||||||
"noFallthroughCasesInSwitch": false
|
"noFallthroughCasesInSwitch": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "test/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user