AI Implementation feature(869): Admin Area Blog and Blog Page (#58)
This commit was merged in pull request #58.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
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<typeof request.agent>;
|
||||
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<typeof request.agent>; 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<typeof request.agent>;
|
||||
|
||||
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<typeof request.agent>;
|
||||
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<any>(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']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import '@angular/compiler';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import {
|
||||
PostFormGroup,
|
||||
buildPostBody,
|
||||
statusBadgeClass,
|
||||
statusLabel,
|
||||
syncPostForm,
|
||||
validatePostForm,
|
||||
} from '../../frontend/src/app/features/admin/blog/blog-form.pure';
|
||||
import { AdminBlogPost } from '../../frontend/src/app/core/services/blog.service';
|
||||
|
||||
function buildForm(): PostFormGroup {
|
||||
const fb = new FormBuilder();
|
||||
return fb.nonNullable.group({
|
||||
title: fb.nonNullable.control(''),
|
||||
bodyMd: fb.nonNullable.control(''),
|
||||
});
|
||||
}
|
||||
|
||||
function makePost(overrides: Partial<AdminBlogPost> = {}): AdminBlogPost {
|
||||
return {
|
||||
id: 'p-1',
|
||||
title: 'Existing Title',
|
||||
bodyMd: 'existing body',
|
||||
status: 'draft',
|
||||
createdAt: '2026-07-21T10:00:00.000Z',
|
||||
updatedAt: '2026-07-22T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('validatePostForm', () => {
|
||||
it('rejects empty title', () => {
|
||||
const r = validatePostForm({ title: '', bodyMd: 'x' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.title).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects whitespace-only title', () => {
|
||||
const r = validatePostForm({ title: ' \t ', bodyMd: 'x' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.title).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects title longer than 200 characters', () => {
|
||||
const r = validatePostForm({ title: 'a'.repeat(201), bodyMd: '' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.title).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects body longer than 200000 characters', () => {
|
||||
const r = validatePostForm({ title: 'ok', bodyMd: 'x'.repeat(200_001) });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.bodyMd).toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts a valid title and empty body', () => {
|
||||
const r = validatePostForm({ title: ' Real Title ', bodyMd: '' });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.errors.title).toBeUndefined();
|
||||
expect(r.errors.bodyMd).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncPostForm', () => {
|
||||
it('prefills the form for edit mode with a post', () => {
|
||||
const form = buildForm();
|
||||
syncPostForm(form, 'edit', makePost());
|
||||
expect(form.controls.title.value).toBe('Existing Title');
|
||||
expect(form.controls.bodyMd.value).toBe('existing body');
|
||||
expect(form.controls.title.pristine).toBe(true);
|
||||
expect(form.controls.title.touched).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the form for create mode with null post', () => {
|
||||
const form = buildForm();
|
||||
syncPostForm(form, 'edit', makePost());
|
||||
expect(form.controls.title.value).toBe('Existing Title');
|
||||
syncPostForm(form, 'create', null);
|
||||
expect(form.controls.title.value).toBe('');
|
||||
expect(form.controls.bodyMd.value).toBe('');
|
||||
});
|
||||
|
||||
it('re-populates when invoked a second time with a different post', () => {
|
||||
const form = buildForm();
|
||||
syncPostForm(form, 'edit', makePost({ title: 'Alpha' }));
|
||||
expect(form.controls.title.value).toBe('Alpha');
|
||||
syncPostForm(form, 'edit', makePost({ title: 'Bravo', bodyMd: 'b body' }));
|
||||
expect(form.controls.title.value).toBe('Bravo');
|
||||
expect(form.controls.bodyMd.value).toBe('b body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPostBody', () => {
|
||||
it('trims the title and passes body through', () => {
|
||||
expect(buildPostBody({ title: ' hello ', bodyMd: 'md' })).toEqual({
|
||||
title: 'hello',
|
||||
bodyMd: 'md',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusBadgeClass / statusLabel', () => {
|
||||
it('maps draft → badge-draft and "Draft"', () => {
|
||||
expect(statusBadgeClass('draft')).toBe('badge-draft');
|
||||
expect(statusLabel('draft')).toBe('Draft');
|
||||
});
|
||||
it('maps published → badge-published and "Published"', () => {
|
||||
expect(statusBadgeClass('published')).toBe('badge-published');
|
||||
expect(statusLabel('published')).toBe('Published');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { deriveBlogListState } from '../../frontend/src/app/features/blog/blog.pure';
|
||||
import { renderMarkdownToHtml } from '../../frontend/src/app/core/services/markdown.pure';
|
||||
|
||||
describe('deriveBlogListState', () => {
|
||||
it('returns loading when loading is true (regardless of error/posts)', () => {
|
||||
expect(deriveBlogListState({ loading: true, error: null, count: 0 }).kind).toBe('loading');
|
||||
expect(deriveBlogListState({ loading: true, error: 'x', count: 3 }).kind).toBe('loading');
|
||||
});
|
||||
|
||||
it('returns error when not loading and error message set', () => {
|
||||
const s = deriveBlogListState({ loading: false, error: 'boom', count: 0 });
|
||||
expect(s.kind).toBe('error');
|
||||
expect(s.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('returns empty when not loading and no posts', () => {
|
||||
expect(deriveBlogListState({ loading: false, error: null, count: 0 }).kind).toBe('empty');
|
||||
});
|
||||
|
||||
it('returns list when posts exist', () => {
|
||||
expect(deriveBlogListState({ loading: false, error: null, count: 3 }).kind).toBe('list');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared Markdown presentation', () => {
|
||||
it('produces the same HTML for the same Markdown body (sanitized, no script)', () => {
|
||||
const md = '# Title\n\nSome **bold** text and <script>alert(1)</script>';
|
||||
const a = renderMarkdownToHtml(md);
|
||||
const b = renderMarkdownToHtml(md);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toContain('<h1>Title</h1>');
|
||||
expect(a).toContain('<strong>bold</strong>');
|
||||
expect(a).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('strips dangerous javascript: hrefs in both contexts', () => {
|
||||
const md = '[click](javascript:alert(1))';
|
||||
const a = renderMarkdownToHtml(md);
|
||||
expect(a).not.toMatch(/href="javascript:/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user