process.env.DATABASE_PATH = ':memory:'; process.env.THEMES_DIR = './themes'; process.env.FRONTEND_DIST = './frontend/dist'; import { Test } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { HttpAdapterHost } from '@nestjs/core'; import cookieParser from 'cookie-parser'; import * as express from 'express'; import request from 'supertest'; import { CookieAccessInfo } from 'cookiejar'; import { AppModule } from '../../backend/src/app.module'; import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter'; import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware'; import { ConfigService } from '@nestjs/config'; import { initDb } from './db-helper'; import { BlogPostEntity } from '../../backend/src/database/entities/blog-post.entity'; import { getRepositoryToken } from '@nestjs/typeorm'; async function loginAs(app: INestApplication, username: string, password: string): Promise<{ agent: ReturnType; accessToken: string; csrf: string; }> { const agent = request.agent(app.getHttpServer()); await agent.get('/api/v1/auth/csrf'); const cookies: any = agent.jar.getCookies(CookieAccessInfo.All); const csrf = cookies.find((c: any) => c.name === 'csrf')?.value; const res = await agent .post('/api/v1/auth/login') .set('X-CSRF-Token', csrf) .send({ username, password }) .expect(201); return { agent, accessToken: res.body.accessToken as string, csrf }; } async function createPlayer( app: INestApplication, adminToken: string, adminCsrf: string, username: string, password: string, ): Promise<{ agent: ReturnType; accessToken: string; csrf: string }> { const adminAgent = request.agent(app.getHttpServer()); await adminAgent.get('/api/v1/auth/csrf'); const adminAgentCookies: any = adminAgent.jar.getCookies(CookieAccessInfo.All); const adminAgentCsrf = adminAgentCookies.find((c: any) => c.name === 'csrf')?.value; await adminAgent .post('/api/v1/admin/users') .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminAgentCsrf) .send({ username, password, role: 'player' }) .expect(201); return loginAs(app, username, password); } describe('Admin blog posts API (Job 869)', () => { let app: INestApplication; let adminToken: string; let adminCsrf: string; let adminAgent: ReturnType; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleRef.createNestApplication(); app.use(cookieParser()); app.use(express.json({ limit: '1mb' })); const csrfMw = new CsrfMiddleware(app.get(ConfigService)); app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); const httpAdapterHost = app.get(HttpAdapterHost); app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); await app.init(); await initDb(app); const server = app.getHttpServer(); await request(server).post('/api/v1/auth/register-first-admin') .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) .expect(201); const logged = await loginAs(app, 'admin', 'Sup3rSecret!Pass'); adminToken = logged.accessToken; adminCsrf = logged.csrf; adminAgent = logged.agent; }); afterAll(async () => { await app.close(); }); it('GET /api/v1/admin/blog/posts without a token returns 401', async () => { await request(app.getHttpServer()).get('/api/v1/admin/blog/posts').expect(401); }); it('GET /api/v1/admin/blog/posts with a player JWT returns 403', async () => { const player = await createPlayer(app, adminToken, adminCsrf, 'player1', 'Sup3rSecret!Pass'); await request(app.getHttpServer()) .get('/api/v1/admin/blog/posts') .set('Authorization', `Bearer ${player.accessToken}`) .expect(403); }); it('POST with status=draft creates a draft with publishedAt=null and POST with status=published stamps publishedAt', async () => { const draft = await adminAgent .post('/api/v1/admin/blog/posts') .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ title: 'Draft Post', bodyMd: 'still cooking', status: 'draft' }) .expect(201); expect(draft.body.status).toBe('draft'); expect(draft.body.publishedAt).toBeNull(); expect(draft.body.title).toBe('Draft Post'); expect(draft.body.id).toBeDefined(); expect(draft.body.createdAt).toBeDefined(); const published = await adminAgent .post('/api/v1/admin/blog/posts') .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ title: 'Live Post', bodyMd: '# Hello world', status: 'published' }) .expect(201); expect(published.body.status).toBe('published'); expect(published.body.publishedAt).toBeTruthy(); expect(typeof published.body.publishedAt).toBe('string'); }); it('POST with empty title returns 400 VALIDATION_FAILED', async () => { const res = await adminAgent .post('/api/v1/admin/blog/posts') .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ title: ' ', bodyMd: 'x' }) .expect(400); expect(res.body.code).toBe('VALIDATION_FAILED'); }); it('GET /api/v1/admin/blog/posts returns drafts AND published (admin-only view)', async () => { const res = await adminAgent .get('/api/v1/admin/blog/posts') .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .expect(200); expect(Array.isArray(res.body)).toBe(true); // Both 'Draft Post' and 'Live Post' should appear since we just created them. const titles = res.body.map((p: any) => p.title); expect(titles).toEqual(expect.arrayContaining(['Draft Post', 'Live Post'])); // Every row includes admin-only fields. for (const p of res.body) { expect(p.status).toBeDefined(); expect(p.createdAt).toBeDefined(); expect(p.updatedAt).toBeDefined(); } }); it('PUT preserves draft state (publishedAt=null) on a draft edit; PUT to published stamps publishedAt once and preserves it on subsequent edits', async () => { // 1. Create a draft. const draft = await adminAgent .post('/api/v1/admin/blog/posts') .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ title: 'Edit Me Draft', bodyMd: 'v1', status: 'draft' }) .expect(201); const draftId = draft.body.id; // 2. Edit title/body without changing status -> stays draft, publishedAt stays null. const edited = await adminAgent .put(`/api/v1/admin/blog/posts/${draftId}`) .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ title: 'Edit Me Draft (updated)', bodyMd: 'v2' }) .expect(200); expect(edited.body.status).toBe('draft'); expect(edited.body.publishedAt).toBeNull(); expect(edited.body.bodyMd).toBe('v2'); // 3. Publish -> stamps publishedAt. const firstPublish = await adminAgent .put(`/api/v1/admin/blog/posts/${draftId}`) .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ status: 'published' }) .expect(200); expect(firstPublish.body.status).toBe('published'); const originalPublishedAt = firstPublish.body.publishedAt; expect(originalPublishedAt).toBeTruthy(); // 4. Wait a few ms and update body again; publishedAt must not change. await new Promise((r) => setTimeout(r, 5)); const secondEdit = await adminAgent .put(`/api/v1/admin/blog/posts/${draftId}`) .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ bodyMd: 'v3 — content edit after publish' }) .expect(200); expect(secondEdit.body.status).toBe('published'); expect(secondEdit.body.publishedAt).toBe(originalPublishedAt); expect(secondEdit.body.bodyMd).toBe('v3 — content edit after publish'); // 5. Demote to draft; publishedAt must be preserved so a later re-publish does not change it. const demoted = await adminAgent .put(`/api/v1/admin/blog/posts/${draftId}`) .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ status: 'draft' }) .expect(200); expect(demoted.body.status).toBe('draft'); expect(demoted.body.publishedAt).toBe(originalPublishedAt); // 6. Re-publish; publishedAt must STILL be the original timestamp. const republished = await adminAgent .put(`/api/v1/admin/blog/posts/${draftId}`) .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ status: 'published' }) .expect(200); expect(republished.body.status).toBe('published'); expect(republished.body.publishedAt).toBe(originalPublishedAt); }); it('DELETE removes the post and a subsequent DELETE returns 404', async () => { const created = await adminAgent .post('/api/v1/admin/blog/posts') .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ title: 'To Delete', bodyMd: 'goodbye', status: 'draft' }) .expect(201); const id = created.body.id; await adminAgent .delete(`/api/v1/admin/blog/posts/${id}`) .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .expect(204); await adminAgent .delete(`/api/v1/admin/blog/posts/${id}`) .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .expect(404); }); it('public GET /api/v1/blog/posts hides drafts after admin operations', async () => { // Make sure a draft and a published exist (regression for public list). await adminAgent .post('/api/v1/admin/blog/posts') .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ title: 'Hidden Draft', bodyMd: 'should not leak', status: 'draft' }) .expect(201); const res = await request(app.getHttpServer()).get('/api/v1/blog/posts').expect(200); const titles = res.body.posts.map((p: any) => p.title); expect(titles).not.toContain('Hidden Draft'); // No row in the public list should ever include admin-only fields. for (const p of res.body.posts) { expect(p.status).toBeUndefined(); } }); it('PUT with empty title returns 400 VALIDATION_FAILED', async () => { const created = await adminAgent .post('/api/v1/admin/blog/posts') .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ title: 'Edit Title Test', bodyMd: '', status: 'draft' }) .expect(201); const id = created.body.id; const res = await adminAgent .put(`/api/v1/admin/blog/posts/${id}`) .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .send({ title: '' }) .expect(400); expect(res.body.code).toBe('VALIDATION_FAILED'); }); }); describe('Admin blog posts raw access', () => { let app: INestApplication; let adminAgent: ReturnType; let adminToken: string; let adminCsrf: string; beforeAll(async () => { process.env.DATABASE_PATH = ':memory:'; process.env.THEMES_DIR = './themes'; process.env.FRONTEND_DIST = './frontend/dist'; const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleRef.createNestApplication(); app.use(cookieParser()); app.use(express.json({ limit: '1mb' })); const csrfMw = new CsrfMiddleware(app.get(ConfigService)); app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); const httpAdapterHost = app.get(HttpAdapterHost); app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); await app.init(); await initDb(app); const server = app.getHttpServer(); await request(server).post('/api/v1/auth/register-first-admin') .send({ username: 'rawadmin', password: 'Sup3rSecret!Pass' }) .expect(201); const logged = await loginAs(app, 'rawadmin', 'Sup3rSecret!Pass'); adminToken = logged.accessToken; adminCsrf = logged.csrf; adminAgent = logged.agent; // Seed one published + one draft directly via the entity manager. const repo = app.get(getRepositoryToken(BlogPostEntity)); await repo.insert([ { id: 'seed-pub', title: 'Seeded Published', bodyMd: '# Published', status: 'published', publishedAt: '2026-07-21T10:00:00.000Z', createdAt: '2026-07-20T10:00:00.000Z', updatedAt: '2026-07-21T10:00:00.000Z', }, { id: 'seed-drf', title: 'Seeded Draft', bodyMd: 'wip', status: 'draft', publishedAt: null, createdAt: '2026-07-22T10:00:00.000Z', updatedAt: '2026-07-22T10:00:00.000Z', }, ]); }); afterAll(async () => { await app.close(); }); it('returns both seeded rows including drafts for the admin', async () => { const res = await adminAgent .get('/api/v1/admin/blog/posts') .set('Authorization', `Bearer ${adminToken}`) .set('X-CSRF-Token', adminCsrf) .expect(200); const ids = res.body.map((p: any) => p.id); expect(ids).toEqual(expect.arrayContaining(['seed-pub', 'seed-drf'])); }); });