import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { DataSource } from 'typeorm'; import { AppModule } from './../src/app.module'; import { OidcProfile, OidcService } from '../src/auth/oidc.service'; interface AuthResponseBody { accessToken?: string; refreshToken?: string; user: { id?: string; email: string; }; } interface ListTemplateResponseBody { id: string; name: string; kind: string; items: { id: string; title: string; checked?: boolean; }[]; } describe('AppController (e2e)', () => { let app: INestApplication; let oidcService: { createAuthorizationUrl: jest.Mock, []>; exchangeCallback: jest.Mock< Promise, [string | undefined, string | undefined] >; }; beforeEach(async () => { oidcService = { createAuthorizationUrl: jest.fn( async () => 'https://sso.example.test/authorize', ), exchangeCallback: jest.fn(async () => ({ subject: 'oidc-default', email: 'default@example.com', name: 'Default User', })), }; const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }) .overrideProvider(OidcService) .useValue(oidcService) .compile(); app = moduleFixture.createNestApplication(); await app.init(); await ensureSsoSchema(app.get(DataSource)); }); it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') .expect(200) .expect('Hello World!'); }); it('/auth sso callback and refresh', async () => { const email = uniqueEmail('auth-user'); const loginBody = await loginWithSso(email); expect(loginBody.accessToken).toBeDefined(); expect(loginBody.refreshToken).toBeDefined(); expect(loginBody.user.email).toBe(email); const refreshResponse = await request(app.getHttpServer()) .post('/auth/refresh') .send({ refreshToken: loginBody.refreshToken, }) .expect(201); const refreshBody = refreshResponse.body as unknown as AuthResponseBody; expect(refreshBody.accessToken).toBeDefined(); expect(refreshBody.refreshToken).toBeDefined(); expect(refreshBody.refreshToken).not.toBe(loginBody.refreshToken); await request(app.getHttpServer()) .post('/auth/refresh') .send({ refreshToken: loginBody.refreshToken, }) .expect(401); }); it('/list-templates creates, updates and uses a template', async () => { const accessToken = await loginWithSsoAndGetAccessToken( uniqueEmail('template-user'), ); const initialTemplatesResponse = await request(app.getHttpServer()) .get('/list-templates') .set('Authorization', `Bearer ${accessToken}`) .expect(200); const initialTemplates = initialTemplatesResponse.body as unknown as ListTemplateResponseBody[]; expect(initialTemplates).toHaveLength(3); expect(initialTemplates.map((template) => template.name)).toContain( 'Urlaub', ); const createTemplateResponse = await request(app.getHttpServer()) .post('/list-templates') .set('Authorization', `Bearer ${accessToken}`) .send({ name: 'Urlaub', kind: 'packing', items: [{ title: 'Pass' }, { title: 'Tickets' }], }) .expect(201); const createdTemplate = createTemplateResponse.body as unknown as ListTemplateResponseBody; expect(createdTemplate.name).toBe('Urlaub'); expect(createdTemplate.items).toHaveLength(2); const updateTemplateResponse = await request(app.getHttpServer()) .patch(`/list-templates/${createdTemplate.id}`) .set('Authorization', `Bearer ${accessToken}`) .send({ name: 'Sommerurlaub', }) .expect(200); const updatedTemplate = updateTemplateResponse.body as unknown as ListTemplateResponseBody; expect(updatedTemplate.name).toBe('Sommerurlaub'); const createListResponse = await request(app.getHttpServer()) .post(`/list-templates/${createdTemplate.id}/lists`) .set('Authorization', `Bearer ${accessToken}`) .send({ name: 'Sommerurlaub 2026', }) .expect(201); const createdList = createListResponse.body as unknown as ListTemplateResponseBody; expect(createdList.name).toBe('Sommerurlaub 2026'); expect(createdList.items[0].title).toBe('Pass'); expect(createdList.items[0].checked).toBe(false); }); it('/lists creates, updates and reads a concrete list', async () => { const accessToken = await loginWithSsoAndGetAccessToken( uniqueEmail('list-user'), ); const createListResponse = await request(app.getHttpServer()) .post('/lists') .set('Authorization', `Bearer ${accessToken}`) .send({ name: 'Wocheneinkauf', kind: 'shopping', }) .expect(201); const createdList = createListResponse.body as unknown as ListTemplateResponseBody; expect(createdList.name).toBe('Wocheneinkauf'); expect(createdList.items).toHaveLength(0); const addItemResponse = await request(app.getHttpServer()) .post(`/lists/${createdList.id}/items`) .set('Authorization', `Bearer ${accessToken}`) .send({ title: 'Milch', quantity: 2, }) .expect(201); const listWithItem = addItemResponse.body as unknown as ListTemplateResponseBody; expect(listWithItem.items).toHaveLength(1); expect(listWithItem.items[0].title).toBe('Milch'); await request(app.getHttpServer()) .patch(`/lists/${createdList.id}/items/${listWithItem.items[0].id}`) .set('Authorization', `Bearer ${accessToken}`) .send({ checked: true, }) .expect(200); const getListResponse = await request(app.getHttpServer()) .get(`/lists/${createdList.id}`) .set('Authorization', `Bearer ${accessToken}`) .expect(200); const fetchedList = getListResponse.body as unknown as ListTemplateResponseBody; expect(fetchedList.items[0].checked).toBe(true); }); async function loginWithSsoAndGetAccessToken( email: string, ): Promise { const loginBody = await loginWithSso(email); expect(loginBody.accessToken).toBeDefined(); return loginBody.accessToken ?? ''; } async function loginWithSso(email: string): Promise { oidcService.exchangeCallback.mockResolvedValueOnce({ subject: `sub-${email}`, email, name: 'Test User', }); const exchangeResponse = await request(app.getHttpServer()) .post('/auth/sso/exchange') .send({ code: 'code', state: 'state' }) .expect(200); return exchangeResponse.body as unknown as AuthResponseBody; } function uniqueEmail(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`; } async function ensureSsoSchema(dataSource: DataSource): Promise { const usersTables = (await dataSource.query( "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'", )) as unknown[]; if (!usersTables.length) { return; } const oidcSubjectColumns = (await dataSource.query( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'oidcSubject'", )) as unknown[]; if (!oidcSubjectColumns.length) { await dataSource.query( 'ALTER TABLE `users` ADD `oidcSubject` varchar(255) NULL', ); await dataSource.query( 'CREATE UNIQUE INDEX `IDX_users_oidc_subject` ON `users` (`oidcSubject`)', ); } await dropColumnIfExists(dataSource, 'passwordHash'); await dropColumnIfExists(dataSource, 'verificationToken'); await dropColumnIfExists(dataSource, 'verified'); } async function dropColumnIfExists( dataSource: DataSource, columnName: string, ): Promise { const columns = (await dataSource.query( 'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', ['users', columnName], )) as unknown[]; if (columns.length) { await dataSource.query(`ALTER TABLE \`users\` DROP COLUMN \`${columnName}\``); } } afterEach(async () => { await app.close(); }); });