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,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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user