seed data
This commit is contained in:
26
adapters/http/RequestContext.ts
Normal file
26
adapters/http/RequestContext.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
export type HttpRequestContext = {
|
||||||
|
req: Request;
|
||||||
|
res: Response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestContextStorage = new AsyncLocalStorage<HttpRequestContext>();
|
||||||
|
|
||||||
|
export function tryGetHttpRequestContext(): HttpRequestContext | null {
|
||||||
|
return requestContextStorage.getStore() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHttpRequestContext(): HttpRequestContext {
|
||||||
|
const ctx = tryGetHttpRequestContext();
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('HttpRequestContext is not available (missing requestContextMiddleware)');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestContextMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
requestContextStorage.run({ req, res }, next);
|
||||||
|
}
|
||||||
@@ -1,46 +1,145 @@
|
|||||||
/**
|
import { randomUUID } from 'node:crypto';
|
||||||
* Adapter: CookieIdentitySessionAdapter
|
|
||||||
*
|
|
||||||
* Manages user session using cookies. This is a placeholder implementation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AuthenticatedUser } from '@core/identity/application/ports/IdentityProviderPort';
|
import type { AuthenticatedUser } from '@core/identity/application/ports/IdentityProviderPort';
|
||||||
import type { AuthSession, IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
|
import type { AuthSession, IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
|
import { tryGetHttpRequestContext } from '@adapters/http/RequestContext';
|
||||||
|
|
||||||
|
const COOKIE_NAME = 'gp_session';
|
||||||
|
const SESSION_TTL_MS = 3600 * 1000;
|
||||||
|
|
||||||
|
type StoredSession = AuthSession;
|
||||||
|
|
||||||
|
function readCookieHeader(headerValue: string | undefined): Record<string, string> {
|
||||||
|
if (!headerValue) return {};
|
||||||
|
const pairs = headerValue.split(';');
|
||||||
|
const record: Record<string, string> = {};
|
||||||
|
for (const pair of pairs) {
|
||||||
|
const trimmed = pair.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const eq = trimmed.indexOf('=');
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const name = trimmed.slice(0, eq).trim();
|
||||||
|
const value = trimmed.slice(eq + 1).trim();
|
||||||
|
if (!name) continue;
|
||||||
|
record[name] = decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSetCookieHeader(options: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
maxAgeSeconds: number;
|
||||||
|
httpOnly: boolean;
|
||||||
|
sameSite: 'Lax' | 'Strict' | 'None';
|
||||||
|
secure: boolean;
|
||||||
|
path?: string;
|
||||||
|
}): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`${options.name}=${encodeURIComponent(options.value)}`);
|
||||||
|
parts.push(`Max-Age=${options.maxAgeSeconds}`);
|
||||||
|
parts.push(`Path=${options.path ?? '/'}`);
|
||||||
|
if (options.httpOnly) parts.push('HttpOnly');
|
||||||
|
parts.push(`SameSite=${options.sameSite}`);
|
||||||
|
if (options.secure) parts.push('Secure');
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSetCookieHeader(existing: string | string[] | undefined, next: string): string[] {
|
||||||
|
if (!existing) return [next];
|
||||||
|
if (Array.isArray(existing)) return [...existing, next];
|
||||||
|
return [existing, next];
|
||||||
|
}
|
||||||
|
|
||||||
export class CookieIdentitySessionAdapter implements IdentitySessionPort {
|
export class CookieIdentitySessionAdapter implements IdentitySessionPort {
|
||||||
private currentSession: AuthSession | null = null;
|
private readonly sessionsByToken = new Map<string, StoredSession>();
|
||||||
|
|
||||||
constructor(private readonly logger: Logger) {
|
constructor(private readonly logger: Logger) {
|
||||||
this.logger.info('CookieIdentitySessionAdapter initialized.');
|
this.logger.info('[CookieIdentitySessionAdapter] initialized (in-memory cookie sessions).');
|
||||||
// In a real application, you would load the session from a cookie here
|
|
||||||
// For demo, we'll start with no session.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentSession(): Promise<AuthSession | null> {
|
async getCurrentSession(): Promise<AuthSession | null> {
|
||||||
this.logger.debug('[CookieIdentitySessionAdapter] Getting current session.');
|
const ctx = tryGetHttpRequestContext();
|
||||||
return Promise.resolve(this.currentSession);
|
if (!ctx) {
|
||||||
|
// Called outside HTTP request (e.g. some unit tests). Behave as no session.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = readCookieHeader(ctx.req.headers.cookie);
|
||||||
|
const token = cookies[COOKIE_NAME];
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const session = this.sessionsByToken.get(token) ?? null;
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (session.expiresAt <= now) {
|
||||||
|
this.sessionsByToken.delete(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(user: AuthenticatedUser): Promise<AuthSession> {
|
async createSession(user: AuthenticatedUser): Promise<AuthSession> {
|
||||||
this.logger.debug(`[CookieIdentitySessionAdapter] Creating session for user: ${user.id}`);
|
const issuedAt = Date.now();
|
||||||
const newSession: AuthSession = {
|
const expiresAt = issuedAt + SESSION_TTL_MS;
|
||||||
user: user,
|
|
||||||
issuedAt: Date.now(),
|
const token = `gp_${randomUUID()}`;
|
||||||
expiresAt: Date.now() + 3600 * 1000, // 1 hour expiration
|
|
||||||
token: `mock-token-${user.id}-${Date.now()}`,
|
const session: AuthSession = {
|
||||||
|
user,
|
||||||
|
issuedAt,
|
||||||
|
expiresAt,
|
||||||
|
token,
|
||||||
};
|
};
|
||||||
this.currentSession = newSession;
|
|
||||||
// In a real app, you'd set a secure, HTTP-only cookie here.
|
this.sessionsByToken.set(token, session);
|
||||||
this.logger.info(`[CookieIdentitySessionAdapter] Session created for user ${user.id}.`);
|
|
||||||
return Promise.resolve(newSession);
|
const ctx = tryGetHttpRequestContext();
|
||||||
|
if (ctx) {
|
||||||
|
const setCookie = buildSetCookieHeader({
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
value: token,
|
||||||
|
maxAgeSeconds: Math.floor(SESSION_TTL_MS / 1000),
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
secure: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = ctx.res.getHeader('Set-Cookie');
|
||||||
|
ctx.res.setHeader('Set-Cookie', appendSetCookieHeader(existing as any, setCookie));
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearSession(): Promise<void> {
|
async clearSession(): Promise<void> {
|
||||||
this.logger.debug('[CookieIdentitySessionAdapter] Clearing session.');
|
const ctx = tryGetHttpRequestContext();
|
||||||
this.currentSession = null;
|
|
||||||
// In a real app, you'd clear the session cookie here.
|
if (ctx) {
|
||||||
this.logger.info('[CookieIdentitySessionAdapter] Session cleared.');
|
const cookies = readCookieHeader(ctx.req.headers.cookie);
|
||||||
return Promise.resolve();
|
const token = cookies[COOKIE_NAME];
|
||||||
|
if (token) {
|
||||||
|
this.sessionsByToken.delete(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCookie = buildSetCookieHeader({
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
value: '',
|
||||||
|
maxAgeSeconds: 0,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
secure: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = ctx.res.getHeader('Set-Cookie');
|
||||||
|
ctx.res.setHeader('Set-Cookie', appendSetCookieHeader(existing as any, setCookie));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No request context: nothing to clear from cookie; just clear all in-memory sessions.
|
||||||
|
this.sessionsByToken.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, type NestModule } from '@nestjs/common';
|
||||||
import { AnalyticsModule } from './domain/analytics/AnalyticsModule';
|
import { AnalyticsModule } from './domain/analytics/AnalyticsModule';
|
||||||
import { AuthModule } from './domain/auth/AuthModule';
|
import { AuthModule } from './domain/auth/AuthModule';
|
||||||
import { BootstrapModule } from './domain/bootstrap/BootstrapModule';
|
import { BootstrapModule } from './domain/bootstrap/BootstrapModule';
|
||||||
@@ -19,6 +19,7 @@ import { SponsorModule } from './domain/sponsor/SponsorModule';
|
|||||||
import { TeamModule } from './domain/team/TeamModule';
|
import { TeamModule } from './domain/team/TeamModule';
|
||||||
|
|
||||||
import { getApiPersistence, getEnableBootstrap } from './env';
|
import { getApiPersistence, getEnableBootstrap } from './env';
|
||||||
|
import { RequestContextMiddleware } from './shared/http/RequestContext';
|
||||||
|
|
||||||
const API_PERSISTENCE = getApiPersistence();
|
const API_PERSISTENCE = getApiPersistence();
|
||||||
const USE_DATABASE = API_PERSISTENCE === 'postgres';
|
const USE_DATABASE = API_PERSISTENCE === 'postgres';
|
||||||
@@ -47,4 +48,8 @@ const ENABLE_BOOTSTRAP = getEnableBootstrap();
|
|||||||
PolicyModule,
|
PolicyModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
consumer.apply(RequestContextMiddleware).forRoutes('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe('AuthController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getSession', () => {
|
describe('getSession', () => {
|
||||||
it('should call service.getCurrentSession and return session DTO', async () => {
|
it('should call service.getCurrentSession and write JSON response', async () => {
|
||||||
const session: AuthSessionDTO = {
|
const session: AuthSessionDTO = {
|
||||||
token: 'token123',
|
token: 'token123',
|
||||||
user: {
|
user: {
|
||||||
@@ -91,18 +91,23 @@ describe('AuthController', () => {
|
|||||||
};
|
};
|
||||||
(service.getCurrentSession as Mock).mockResolvedValue(session);
|
(service.getCurrentSession as Mock).mockResolvedValue(session);
|
||||||
|
|
||||||
const result = await controller.getSession();
|
const res = { json: vi.fn() } as any;
|
||||||
|
|
||||||
|
await controller.getSession(res);
|
||||||
|
|
||||||
expect(service.getCurrentSession).toHaveBeenCalled();
|
expect(service.getCurrentSession).toHaveBeenCalled();
|
||||||
expect(result).toEqual(session);
|
expect(res.json).toHaveBeenCalledWith(session);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if no session', async () => {
|
it('should write JSON null when no session', async () => {
|
||||||
(service.getCurrentSession as Mock).mockResolvedValue(null);
|
(service.getCurrentSession as Mock).mockResolvedValue(null);
|
||||||
|
|
||||||
const result = await controller.getSession();
|
const res = { json: vi.fn() } as any;
|
||||||
|
|
||||||
expect(result).toBeNull();
|
await controller.getSession(res);
|
||||||
|
|
||||||
|
expect(service.getCurrentSession).toHaveBeenCalled();
|
||||||
|
expect(res.json).toHaveBeenCalledWith(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Controller, Get, Post, Body, Query, Inject } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Query, Inject, Res } from '@nestjs/common';
|
||||||
import { Public } from './Public';
|
import { Public } from './Public';
|
||||||
import { AuthService } from './AuthService';
|
import { AuthService } from './AuthService';
|
||||||
import { LoginParamsDTO, SignupParamsDTO, AuthSessionDTO } from './dtos/AuthDto';
|
import { LoginParamsDTO, SignupParamsDTO, AuthSessionDTO } from './dtos/AuthDto';
|
||||||
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
|
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -20,8 +21,11 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('session')
|
@Get('session')
|
||||||
async getSession(): Promise<AuthSessionDTO | null> {
|
async getSession(@Res() res: Response): Promise<void> {
|
||||||
return this.authService.getCurrentSession();
|
const session = await this.authService.getCurrentSession();
|
||||||
|
// Nest treats `null`/`undefined` as "no body" for Express responses; we need JSON `null`
|
||||||
|
// so browser clients can safely call `response.json()`.
|
||||||
|
res.json(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
|
|||||||
@@ -37,8 +37,13 @@ export const COMMAND_RESULT_OUTPUT_PORT_TOKEN = 'CommandResultOutputPort';
|
|||||||
export const AuthProviders: Provider[] = [
|
export const AuthProviders: Provider[] = [
|
||||||
{
|
{
|
||||||
provide: AUTH_REPOSITORY_TOKEN,
|
provide: AUTH_REPOSITORY_TOKEN,
|
||||||
useFactory: (passwordHashingService: IPasswordHashingService, logger: Logger) => {
|
useFactory: (userRepository: InMemoryUserRepository, passwordHashingService: IPasswordHashingService, logger: Logger) =>
|
||||||
// Seed initial users for InMemoryUserRepository
|
new InMemoryAuthRepository(userRepository, passwordHashingService, logger),
|
||||||
|
inject: [USER_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: USER_REPOSITORY_TOKEN,
|
||||||
|
useFactory: (logger: Logger) => {
|
||||||
const initialUsers: StoredUser[] = [
|
const initialUsers: StoredUser[] = [
|
||||||
{
|
{
|
||||||
// Match seeded racing driver id so dashboard works in inmemory mode.
|
// Match seeded racing driver id so dashboard works in inmemory mode.
|
||||||
@@ -50,14 +55,8 @@ export const AuthProviders: Provider[] = [
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const inMemoryUserRepository = new InMemoryUserRepository(logger, initialUsers);
|
return new InMemoryUserRepository(logger, initialUsers);
|
||||||
return new InMemoryAuthRepository(inMemoryUserRepository, passwordHashingService, logger);
|
|
||||||
},
|
},
|
||||||
inject: [PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: USER_REPOSITORY_TOKEN,
|
|
||||||
useFactory: (logger: Logger) => new InMemoryUserRepository(logger), // Factory for InMemoryUserRepository
|
|
||||||
inject: [LOGGER_TOKEN],
|
inject: [LOGGER_TOKEN],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
92
apps/api/src/domain/auth/AuthSession.http.test.ts
Normal file
92
apps/api/src/domain/auth/AuthSession.http.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { requestContextMiddleware } from '@adapters/http/RequestContext';
|
||||||
|
import { AuthModule } from './AuthModule';
|
||||||
|
|
||||||
|
describe('Auth session (HTTP, inmemory)', () => {
|
||||||
|
let app: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
imports: [AuthModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
|
||||||
|
app.use(requestContextMiddleware);
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signup sets gp_session cookie and session persists across requests', async () => {
|
||||||
|
const agent = request.agent(app.getHttpServer());
|
||||||
|
|
||||||
|
const signupRes = await agent
|
||||||
|
.post('/auth/signup')
|
||||||
|
.send({ email: 'u1@gridpilot.local', password: 'pw1', displayName: 'User 1' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const setCookie = signupRes.headers['set-cookie'] as string[] | undefined;
|
||||||
|
expect(setCookie?.some((v) => v.startsWith('gp_session='))).toBe(true);
|
||||||
|
|
||||||
|
const sessionRes = await agent.get('/auth/session').expect(200);
|
||||||
|
|
||||||
|
expect(sessionRes.body).toMatchObject({
|
||||||
|
token: expect.stringMatching(/^gp_/),
|
||||||
|
user: {
|
||||||
|
email: 'u1@gridpilot.local',
|
||||||
|
displayName: 'User 1',
|
||||||
|
userId: expect.any(String),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login sets gp_session cookie for seeded admin and logout clears it', async () => {
|
||||||
|
const agent = request.agent(app.getHttpServer());
|
||||||
|
|
||||||
|
const loginRes = await agent
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ email: 'admin@gridpilot.local', password: 'admin123' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const setCookie = loginRes.headers['set-cookie'] as string[] | undefined;
|
||||||
|
expect(setCookie?.some((v) => v.startsWith('gp_session='))).toBe(true);
|
||||||
|
|
||||||
|
const sessionRes = await agent.get('/auth/session').expect(200);
|
||||||
|
expect(sessionRes.body).toMatchObject({
|
||||||
|
token: expect.any(String),
|
||||||
|
user: {
|
||||||
|
userId: 'driver-1',
|
||||||
|
email: 'admin@gridpilot.local',
|
||||||
|
displayName: 'Admin',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoutRes = await agent.post('/auth/logout').expect(201);
|
||||||
|
expect(logoutRes.body).toEqual({ success: true });
|
||||||
|
|
||||||
|
const logoutCookies = logoutRes.headers['set-cookie'] as string[] | undefined;
|
||||||
|
expect(logoutCookies?.some((v) => v.includes('gp_session=') && v.includes('Max-Age=0'))).toBe(true);
|
||||||
|
|
||||||
|
await agent.get('/auth/session').expect(200).expect((res) => {
|
||||||
|
expect(res.body).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
11
apps/api/src/shared/http/RequestContext.ts
Normal file
11
apps/api/src/shared/http/RequestContext.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { requestContextMiddleware } from '@adapters/http/RequestContext';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RequestContextMiddleware implements NestMiddleware {
|
||||||
|
use(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
requestContextMiddleware(req, res, next as NextFunction);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export default defineConfig({
|
|||||||
include: ['apps/api/src/**/*.{test,spec}.ts'],
|
include: ['apps/api/src/**/*.{test,spec}.ts'],
|
||||||
exclude: ['node_modules/**', 'apps/api/dist/**', 'dist/**'],
|
exclude: ['node_modules/**', 'apps/api/dist/**', 'dist/**'],
|
||||||
coverage: {
|
coverage: {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reportsDirectory: 'coverage/api',
|
reportsDirectory: 'coverage/api',
|
||||||
reporter: ['text', 'html', 'lcov'],
|
reporter: ['text', 'html', 'lcov'],
|
||||||
|
|||||||
Reference in New Issue
Block a user