From f8888c7b308a2ecf9ee77691f746a3a7cbf021c3 Mon Sep 17 00:00:00 2001 From: Dhemy Date: Wed, 31 Dec 2025 20:33:57 +0100 Subject: [PATCH 1/4] feat(test): allow TestAgent to act as specific user via middleware --- src/Http/Scope/types.ts | 4 +++- src/Security/index.ts | 2 ++ src/Security/types.ts | 18 ++++++++++++++++++ src/Testing/TestAgentFactory.ts | 22 ++++++++++++++++++++-- 4 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 src/Security/index.ts create mode 100644 src/Security/types.ts diff --git a/src/Http/Scope/types.ts b/src/Http/Scope/types.ts index d16c432..a7f461a 100644 --- a/src/Http/Scope/types.ts +++ b/src/Http/Scope/types.ts @@ -1,10 +1,12 @@ import type { Context, DefaultState, Next, Request } from 'koa'; import { type HttpRequest } from '../Request'; import { type HttpResponse } from '../Response'; +import { type User } from '@/Security/types'; -export interface HttpScope extends Context { +export interface HttpScope extends Context { request: TRequest; response: HttpResponse; + user?: TUser; } export type NextMiddleware = Next; diff --git a/src/Security/index.ts b/src/Security/index.ts new file mode 100644 index 0000000..144552e --- /dev/null +++ b/src/Security/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './firewall-middleware'; diff --git a/src/Security/types.ts b/src/Security/types.ts new file mode 100644 index 0000000..2a2b6e3 --- /dev/null +++ b/src/Security/types.ts @@ -0,0 +1,18 @@ +import { type HttpRequest } from '@/Http'; + +export interface User { + identifier: string; + roles?: string[]; +} + +export type UserProvider = (request: HttpRequest) => Promise; + +export interface Firewall { + pattern: string; + security: boolean; + provider?: UserProvider; +} + +export interface SecurityConfig { + firewalls: Firewall[]; +} diff --git a/src/Testing/TestAgentFactory.ts b/src/Testing/TestAgentFactory.ts index 3e708b6..d7bac54 100644 --- a/src/Testing/TestAgentFactory.ts +++ b/src/Testing/TestAgentFactory.ts @@ -2,7 +2,25 @@ import supertest from 'supertest'; import { type TestAgent } from './types'; import { create } from '@/Application'; import { type KoalaConfig } from '@/Config'; +import { type HttpMiddleware, type HttpScope, type NextMiddleware } from '@/Http'; +import { type User } from '@/Security/types'; -export function createTestAgent(config: KoalaConfig): TestAgent { - return supertest(create(config).callback()); +export function createTestAgent(config: KoalaConfig, agentConfig?: { actAs?: User }): TestAgent { + const globalMiddleware = config.globalMiddleware ?? []; + const testConfig = { ...config }; + + const { actAs } = agentConfig ?? {}; + + if (undefined !== actAs) { + testConfig.globalMiddleware = [actAsUser(actAs), ...globalMiddleware]; + } + + return supertest(create(testConfig).callback()); +} + +function actAsUser(user: User): HttpMiddleware { + return async function actAsUserMiddleware(scope: HttpScope, next: NextMiddleware): Promise { + scope.user = user; + await next(); + }; } From d47020faf6912d5bb537eb1abfadd027cacb8e93 Mon Sep 17 00:00:00 2001 From: Dhemy Date: Wed, 31 Dec 2025 20:34:40 +0100 Subject: [PATCH 2/4] feat(security): add security firewall - Authentication --- src/Security/firewall-middleware.test.ts | 95 ++++++++++++++++++++ src/Security/firewall-middleware.ts | 28 ++++++ tests/security-authentication.e2e.test.ts | 101 ++++++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 src/Security/firewall-middleware.test.ts create mode 100644 src/Security/firewall-middleware.ts create mode 100644 tests/security-authentication.e2e.test.ts diff --git a/src/Security/firewall-middleware.test.ts b/src/Security/firewall-middleware.test.ts new file mode 100644 index 0000000..3e76f36 --- /dev/null +++ b/src/Security/firewall-middleware.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test, vi } from 'vitest'; +import { type HttpScope } from '@/Http'; +import { firewall } from '@/Security/firewall-middleware'; + +describe('Firewall Middleware', () => { + test('it should skip unmatched firewalls', async () => { + const userProvider = vi.fn(); + const config = { + firewalls: [ + { + pattern: '^/admin', + security: true, + provider: userProvider, + }, + ], + }; + const middleware = firewall(config); + const next = vi.fn(); + const request = { path: '/public' }; + + await middleware({ request } as HttpScope, next); + + expect(userProvider).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('it should skip unsecured firewall', async () => { + const userProvider = vi.fn(); + const config = { + firewalls: [ + { + pattern: '^/public', + security: false, + provider: userProvider, + }, + ], + }; + const middleware = firewall(config); + const next = vi.fn(); + const request = { path: '/public' }; + + await middleware({ request } as HttpScope, next); + + expect(userProvider).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('it should pass if user is already authenticated', async () => { + const userProvider = vi.fn(); + const config = { + firewalls: [ + { + pattern: '^/admin', + security: true, + provider: userProvider, + }, + ], + }; + const middleware = firewall(config); + const next = vi.fn(); + const request = { path: '/admin' }; + const scope = { request, user: { identifier: 'user-1' } } as unknown as HttpScope; + + await middleware(scope, next); + + expect(userProvider).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('it should stop execution if authentication fails', async () => { + const userProvider = vi.fn().mockResolvedValue(undefined); + const config = { + firewalls: [ + { + pattern: '^/admin', + security: true, + provider: userProvider, + }, + ], + }; + const middleware = firewall(config); + const next = vi.fn(); + const request = { path: '/admin' }; + const internalThrow = vi.fn(); + const scope = { + request, + throw: internalThrow, + } as unknown as HttpScope; + + await middleware(scope, next); + + expect(userProvider).toHaveBeenCalled(); + expect(internalThrow).toHaveBeenCalledWith(401, 'Authentication required'); + }); +}); diff --git a/src/Security/firewall-middleware.ts b/src/Security/firewall-middleware.ts new file mode 100644 index 0000000..16dcc0f --- /dev/null +++ b/src/Security/firewall-middleware.ts @@ -0,0 +1,28 @@ +import { type HttpMiddleware, type HttpScope, type NextMiddleware } from '@/Http'; +import { type Firewall, type SecurityConfig } from '@/Security/types'; + +export function firewall(config: SecurityConfig): HttpMiddleware { + return async function firewallMiddleware(scope: HttpScope, next: NextMiddleware): Promise { + const firewall = config.firewalls.find(fw => { + const regex = new RegExp(fw.pattern); + return regex.test(scope.request.path); + }); + + if (undefined !== firewall) { + await runFirewall(firewall, scope); + } + + await next(); + }; +} + +async function runFirewall(firewall: Firewall, scope: HttpScope): Promise { + if (!firewall.security) return; + if (undefined !== scope.user) return; + + const user = await firewall.provider?.(scope.request); + + if (undefined === user) scope.throw(401, 'Authentication required'); + + scope.user = user; +} diff --git a/tests/security-authentication.e2e.test.ts b/tests/security-authentication.e2e.test.ts new file mode 100644 index 0000000..3c3c8f7 --- /dev/null +++ b/tests/security-authentication.e2e.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test, vi } from 'vitest'; +import { createTestAgent, type HttpScope, Route } from '../src'; +import { firewall } from '../src/Security/firewall-middleware'; + +class MyController { + @Route({ path: '/public', method: 'get' }) + publicResource(scope: HttpScope): void { + scope.response.body = 'Hello, World!'; + } + + @Route({ path: '/api/v1/protected', method: 'get' }) + protectedResource(scope: HttpScope): void { + scope.response.body = 'Protected Resource'; + } +} + +const userProvider = vi.fn(); +const securityConfig = { + firewalls: [ + { + pattern: '^/public', + security: false, + }, + { + pattern: '^/api', + security: true, + }, + { + pattern: '^/protected/with/provider', + security: true, + provider: userProvider, + }, + ], +}; + +describe('Security Authentication E2E Tests', () => { + test('Firewall should block access to protected route', async () => { + const config = { + controllers: [MyController], + globalMiddleware: [firewall(securityConfig)], + }; + const agent = createTestAgent(config); + + const response = await agent.get('/api/v1/protected'); + + expect(response.status).toBe(401); + expect(response.text).toBe('Authentication required'); + }); + + test('Firewall should allow only authenticated users to access protected route', async () => { + const config = { + controllers: [MyController], + globalMiddleware: [firewall(securityConfig)], + }; + const MyUser = { identifier: 'user-1' }; + const agent = createTestAgent(config, { actAs: MyUser }); + + const response = await agent.get('/api/v1/protected'); + + expect(response.status).toBe(200); + expect(response.text).toBe('Protected Resource'); + }); + + test('Firewall should allow access to unprotected route', async () => { + const config = { + controllers: [MyController], + globalMiddleware: [firewall(securityConfig)], + }; + const agent = createTestAgent(config); + + const response = await agent.get('/public'); + + expect(response.status).toBe(200); + expect(response.text).toBe('Hello, World!'); + }); + + test('Firewall should depend on specified user provider for authentication', async () => { + userProvider.mockResolvedValue({ identifier: 'provided-user' }); + const config = { + controllers: [MyController], + globalMiddleware: [firewall(securityConfig)], + }; + const agent = createTestAgent(config); + + await agent.get('/protected/with/provider'); + + expect(userProvider).toHaveBeenCalled(); + }); + + test('Firewall should bypass routes without firewall', async () => { + const config = { + controllers: [MyController], + globalMiddleware: [firewall(securityConfig)], + }; + const agent = createTestAgent(config); + + const response = await agent.get('/no/firewall/here'); + + expect(response.status).toBe(404); + }); +}); From 9d2cbf8868fd9d50c54e34254a1ad87bdbc22471 Mon Sep 17 00:00:00 2001 From: Dhemy Date: Wed, 31 Dec 2025 20:39:16 +0100 Subject: [PATCH 3/4] chore(security): remove identifier property from User interface --- src/Security/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Security/types.ts b/src/Security/types.ts index 2a2b6e3..fe527cd 100644 --- a/src/Security/types.ts +++ b/src/Security/types.ts @@ -1,7 +1,6 @@ import { type HttpRequest } from '@/Http'; export interface User { - identifier: string; roles?: string[]; } From 2475649ccec0008001fe0b8c7f8f7ba9020a8068 Mon Sep 17 00:00:00 2001 From: Dhemy Date: Wed, 31 Dec 2025 20:40:20 +0100 Subject: [PATCH 4/4] chore(security): move firewall-middleware to firewall subdirectory and update imports --- src/Security/{ => firewall}/firewall-middleware.test.ts | 2 +- src/Security/{ => firewall}/firewall-middleware.ts | 0 src/Security/index.ts | 2 +- tests/security-authentication.e2e.test.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename src/Security/{ => firewall}/firewall-middleware.test.ts (97%) rename src/Security/{ => firewall}/firewall-middleware.ts (100%) diff --git a/src/Security/firewall-middleware.test.ts b/src/Security/firewall/firewall-middleware.test.ts similarity index 97% rename from src/Security/firewall-middleware.test.ts rename to src/Security/firewall/firewall-middleware.test.ts index 3e76f36..acf7378 100644 --- a/src/Security/firewall-middleware.test.ts +++ b/src/Security/firewall/firewall-middleware.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from 'vitest'; import { type HttpScope } from '@/Http'; -import { firewall } from '@/Security/firewall-middleware'; +import { firewall } from '@/Security/firewall/firewall-middleware'; describe('Firewall Middleware', () => { test('it should skip unmatched firewalls', async () => { diff --git a/src/Security/firewall-middleware.ts b/src/Security/firewall/firewall-middleware.ts similarity index 100% rename from src/Security/firewall-middleware.ts rename to src/Security/firewall/firewall-middleware.ts diff --git a/src/Security/index.ts b/src/Security/index.ts index 144552e..01560c2 100644 --- a/src/Security/index.ts +++ b/src/Security/index.ts @@ -1,2 +1,2 @@ export * from './types'; -export * from './firewall-middleware'; +export * from './firewall/firewall-middleware'; diff --git a/tests/security-authentication.e2e.test.ts b/tests/security-authentication.e2e.test.ts index 3c3c8f7..0c0d7c0 100644 --- a/tests/security-authentication.e2e.test.ts +++ b/tests/security-authentication.e2e.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from 'vitest'; import { createTestAgent, type HttpScope, Route } from '../src'; -import { firewall } from '../src/Security/firewall-middleware'; +import { firewall } from '../src/Security/firewall/firewall-middleware'; class MyController { @Route({ path: '/public', method: 'get' })