feat(backend): Auth/Users/Audit-Modul (Modul 1)
AuthModule: Login/Refresh/Logout mit JWT-Access-Token + rotierendem, gehashtem Refresh-Token (RefreshToken-Tabelle). JwtAuthGuard + RolesGuard global via APP_GUARD registriert (gilt fuer jeden kuenftigen Endpunkt), @Public()-Decorator als Ausnahme (Auth-Endpunkte, /health). UsersModule: CRUD + Deactivate, nur ADMIN-Rolle, Passwoerter mit bcrypt gehasht, nie im Response enthalten. AuditModule: AuditLogService (Grundstruktur), wird von UsersService bei create/update/deactivate injiziert und geschrieben. Geteilte App-Konfiguration (Prefix/Versioning/Pipes/Helmet) aus main.ts in src/app.config.ts extrahiert; e2e-Tests nutzten das vorher nicht und bekamen dadurch 404 auf jede /api/v1/... Route - jetzt ueber test/utils/create-test-app.ts geteilt, betrifft alle kuenftigen e2e-Tests gleichermassen. prisma/seed.ts legt einen Admin-User fuer lokale Entwicklung an. 26 Unit-Tests + 11 e2e-Tests (gegen echte Postgres-Testdatenbank) gruen, Lint/Typecheck/Build gruen, Login/Refresh/Logout/Rollenpruefung manuell gegen laufenden Server verifiziert.
This commit is contained in:
1 parent
eae3fea957
commit
ae8f6d4435
46 files changed
+1258
-98
No files matched your search
@@ -1,29 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { AuthTokensBody, login } from './utils/api';
|
||||
|
||||
describe('Auth (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const email = `auth-e2e-${Date.now()}@example.com`;
|
||||
const password = 'SuperSecret123!';
|
||||
let userId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.user.create({
|
||||
data: { email, passwordHash, name: 'Auth E2E User', role: 'ADMIN' },
|
||||
});
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.refreshToken.deleteMany({ where: { userId } });
|
||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects login with wrong password', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email, password: 'wrong-password' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('logs in and returns access + refresh tokens', async () => {
|
||||
const tokens = await login(app, email, password);
|
||||
|
||||
expect(tokens.accessToken).toEqual(expect.any(String));
|
||||
expect(tokens.refreshToken).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('rejects a protected route without a token', () => {
|
||||
return request(app.getHttpServer()).get('/api/v1/users').expect(401);
|
||||
});
|
||||
|
||||
it('accepts a protected route with a valid access token', async () => {
|
||||
const tokens = await login(app, email, password);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/users')
|
||||
.set('Authorization', `Bearer ${tokens.accessToken}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('rotates the refresh token and rejects reuse of the old one', async () => {
|
||||
const tokens = await login(app, email, password);
|
||||
|
||||
const refreshResponse = await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/refresh')
|
||||
.send({ refreshToken: tokens.refreshToken })
|
||||
.expect(200);
|
||||
const refreshed = refreshResponse.body as AuthTokensBody;
|
||||
|
||||
expect(refreshed.refreshToken).not.toEqual(tokens.refreshToken);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/refresh')
|
||||
.send({ refreshToken: tokens.refreshToken })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('logs out and invalidates the refresh token', async () => {
|
||||
const tokens = await login(app, email, password);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/logout')
|
||||
.send({ refreshToken: tokens.refreshToken })
|
||||
.expect(204);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/refresh')
|
||||
.send({ refreshToken: tokens.refreshToken })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
|
||||
describe('Health (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /health returns ok without auth', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/health')
|
||||
.expect(200)
|
||||
.expect({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('GET /health/ready returns ok when the database is reachable', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/health/ready')
|
||||
.expect(200)
|
||||
.expect({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"setupFiles": ["<rootDir>/setup-env.ts"],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { config } from 'dotenv';
|
||||
import path from 'node:path';
|
||||
|
||||
config({ path: path.resolve(__dirname, '../.env.test') });
|
||||
@@ -0,0 +1,102 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { UserBody, login } from './utils/api';
|
||||
|
||||
describe('Users (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const adminEmail = `users-e2e-admin-${Date.now()}@example.com`;
|
||||
const staffEmail = `users-e2e-staff-${Date.now()}@example.com`;
|
||||
const password = 'SuperSecret123!';
|
||||
let adminId: string;
|
||||
let staffId: string;
|
||||
let adminAccessToken: string;
|
||||
let staffAccessToken: string;
|
||||
let createdUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
email: adminEmail,
|
||||
passwordHash,
|
||||
name: 'Users E2E Admin',
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
adminId = admin.id;
|
||||
const staff = await prisma.user.create({
|
||||
data: {
|
||||
email: staffEmail,
|
||||
passwordHash,
|
||||
name: 'Users E2E Staff',
|
||||
role: 'STAFF',
|
||||
},
|
||||
});
|
||||
staffId = staff.id;
|
||||
|
||||
adminAccessToken = (await login(app, adminEmail, password)).accessToken;
|
||||
staffAccessToken = (await login(app, staffEmail, password)).accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const userIds = [adminId, staffId, createdUserId].filter(Boolean);
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||
if (createdUserId) {
|
||||
await prisma.user.delete({ where: { id: createdUserId } });
|
||||
}
|
||||
await prisma.user.deleteMany({ where: { id: { in: [adminId, staffId] } } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects non-admin roles from listing users', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/users')
|
||||
.set('Authorization', `Bearer ${staffAccessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('lets an admin create a user and writes an audit log entry', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/users')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({
|
||||
email: `users-e2e-created-${Date.now()}@example.com`,
|
||||
password: 'AnotherSecret123!',
|
||||
name: 'Created via e2e',
|
||||
role: 'BEEKEEPER',
|
||||
})
|
||||
.expect(201);
|
||||
const created = response.body as UserBody & { passwordHash?: string };
|
||||
|
||||
expect(created.id).toEqual(expect.any(String));
|
||||
expect(created.passwordHash).toBeUndefined();
|
||||
createdUserId = created.id;
|
||||
|
||||
const auditEntry = await prisma.auditLog.findFirst({
|
||||
where: { entityType: 'User', entityId: createdUserId, action: 'CREATE' },
|
||||
});
|
||||
expect(auditEntry).not.toBeNull();
|
||||
});
|
||||
|
||||
it('lets an admin deactivate a user', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v1/users/${createdUserId}/deactivate`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.expect(201);
|
||||
const deactivated = response.body as UserBody;
|
||||
|
||||
expect(deactivated.isActive).toBe(false);
|
||||
expect(deactivated.deactivatedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
|
||||
export interface AuthTokensBody {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface UserBody {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
deactivatedAt: string | null;
|
||||
}
|
||||
|
||||
export async function login(
|
||||
app: INestApplication<App>,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AuthTokensBody> {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email, password })
|
||||
.expect(200);
|
||||
return response.body as AuthTokensBody;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from '../../src/app.module';
|
||||
import { configureApp } from '../../src/app.config';
|
||||
|
||||
export async function createTestApp(): Promise<INestApplication<App>> {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<INestApplication<App>>();
|
||||
configureApp(app);
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
Reference in new issue
Block a user