Files
InsanityLab-web/.superpowers/sdd/review-f3fb2fe..f0ad162.diff
Adriano b913a2bf90 registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:36:26 +02:00

531 lines
22 KiB
Diff

# Review package: f3fb2fe..HEAD
## Commits
f0ad162 feat: area admin con editor tiptap, api crud e upload immagini
## Files changed
src/components/admin/PostForm.astro | 136 ++++++++++++++++++++++++++++++++++++
src/layouts/Admin.astro | 36 ++++++++++
src/lib/post-body.ts | 21 ++++++
src/lib/sanitize.ts | 11 +++
src/pages/admin/edit/[id].astro | 14 ++++
src/pages/admin/index.astro | 37 ++++++++++
src/pages/admin/login.astro | 34 +++++++++
src/pages/admin/logout.ts | 11 +++
src/pages/admin/new.astro | 10 +++
src/pages/api/admin/posts/[id].ts | 29 ++++++++
src/pages/api/admin/posts/index.ts | 22 ++++++
src/pages/api/admin/upload.ts | 26 +++++++
src/pages/uploads/[...path].ts | 20 ++++++
tests/sanitize.test.ts | 16 +++++
14 files changed, 423 insertions(+)
## Diff
diff --git a/src/components/admin/PostForm.astro b/src/components/admin/PostForm.astro
new file mode 100644
index 0000000..4ffdf2a
--- /dev/null
+++ b/src/components/admin/PostForm.astro
@@ -0,0 +1,136 @@
+---
+import { CATEGORIES } from '../../lib/blog-const';
+import type { Post } from '../../lib/posts';
+interface Props { post?: Post }
+const { post } = Astro.props;
+---
+<form id="post-form" data-id={post?.id ?? ''}>
+ <input class="afield" name="title" placeholder="Titolo" required value={post?.title ?? ''} />
+ <input class="afield" name="slug" placeholder="Slug (vuoto = generato dal titolo)" value={post?.slug ?? ''} />
+ <select class="afield" name="category">
+ {CATEGORIES.map((c) => <option value={c} selected={post?.category === c}>{c}</option>)}
+ </select>
+ <textarea class="afield" name="excerpt" placeholder="Estratto (anteprima in lista blog)" maxlength="300">{post?.excerpt ?? ''}</textarea>
+
+ <label>Copertina: <input type="file" id="cover-file" accept="image/*" /></label>
+ <input type="hidden" name="cover" value={post?.cover ?? ''} />
+ <p id="cover-preview">{post?.cover && <img src={post.cover} style="max-width:200px" />}</p>
+
+ <div class="ed-toolbar" id="ed-toolbar">
+ <button type="button" data-cmd="h2">H2</button>
+ <button type="button" data-cmd="h3">H3</button>
+ <button type="button" data-cmd="bold"><b>B</b></button>
+ <button type="button" data-cmd="italic"><i>I</i></button>
+ <button type="button" data-cmd="bullet">• Elenco</button>
+ <button type="button" data-cmd="ordered">1. Elenco</button>
+ <button type="button" data-cmd="quote">" Citazione</button>
+ <button type="button" data-cmd="link">Link</button>
+ <button type="button" data-cmd="image">Immagine</button>
+ </div>
+ <div id="editor" class="ed-body"></div>
+
+ <label><input type="checkbox" name="draft" checked={post ? post.draft === 1 : true} /> Bozza (non visibile sul sito)</label>
+ <p><button class="abtn" type="submit">Salva</button> <span id="save-msg"></span></p>
+</form>
+
+<style>
+ .ed-toolbar { display: flex; gap: 6px; flex-wrap: wrap; background: #fff; border: 1px solid #ccc; border-bottom: 0; padding: 8px; }
+ .ed-toolbar button { background: #eee; border: 1px solid #ccc; padding: 4px 10px; cursor: pointer; }
+ .ed-toolbar button.is-active { background: #b5a48b; color: #fff; }
+ .ed-body { background: #fff; border: 1px solid #ccc; min-height: 340px; padding: 12px 16px; margin-bottom: 14px; }
+ .ed-body :global(.ProseMirror) { outline: none; min-height: 320px; }
+</style>
+
+<script>
+ import { Editor } from '@tiptap/core';
+ import StarterKit from '@tiptap/starter-kit';
+ import Image from '@tiptap/extension-image';
+ import Link from '@tiptap/extension-link';
+
+ const form = document.getElementById('post-form') as HTMLFormElement;
+ const initial = (document.getElementById('initial-body') as HTMLScriptElement | null)?.textContent ?? '';
+
+ const editor = new Editor({
+ element: document.getElementById('editor')!,
+ extensions: [
+ StarterKit.configure({ heading: { levels: [2, 3] } }),
+ Image,
+ Link.configure({ openOnClick: false }),
+ ],
+ content: initial,
+ });
+
+ async function uploadFile(file: File): Promise<string | null> {
+ const fd = new FormData();
+ fd.append('file', file);
+ const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
+ if (!res.ok) { alert('Upload non riuscito.'); return null; }
+ return (await res.json()).url;
+ }
+
+ document.getElementById('ed-toolbar')!.addEventListener('click', async (e) => {
+ const btn = (e.target as HTMLElement).closest('button');
+ if (!btn) return;
+ const c = editor.chain().focus();
+ switch (btn.dataset.cmd) {
+ case 'h2': c.toggleHeading({ level: 2 }).run(); break;
+ case 'h3': c.toggleHeading({ level: 3 }).run(); break;
+ case 'bold': c.toggleBold().run(); break;
+ case 'italic': c.toggleItalic().run(); break;
+ case 'bullet': c.toggleBulletList().run(); break;
+ case 'ordered': c.toggleOrderedList().run(); break;
+ case 'quote': c.toggleBlockquote().run(); break;
+ case 'link': {
+ const url = prompt('URL del link:');
+ if (url) c.setLink({ href: url }).run();
+ break;
+ }
+ case 'image': {
+ const input = document.createElement('input');
+ input.type = 'file'; input.accept = 'image/*';
+ input.onchange = async () => {
+ const url = input.files?.[0] && await uploadFile(input.files[0]);
+ if (url) editor.chain().focus().setImage({ src: url }).run();
+ };
+ input.click();
+ break;
+ }
+ }
+ });
+
+ document.getElementById('cover-file')!.addEventListener('change', async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+ const url = await uploadFile(file);
+ if (url) {
+ (form.elements.namedItem('cover') as HTMLInputElement).value = url;
+ document.getElementById('cover-preview')!.innerHTML = `<img src="${url}" style="max-width:200px">`;
+ }
+ });
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const id = form.dataset.id;
+ const body = {
+ title: (form.elements.namedItem('title') as HTMLInputElement).value,
+ slug: (form.elements.namedItem('slug') as HTMLInputElement).value,
+ category: (form.elements.namedItem('category') as HTMLSelectElement).value,
+ excerpt: (form.elements.namedItem('excerpt') as HTMLTextAreaElement).value,
+ cover: (form.elements.namedItem('cover') as HTMLInputElement).value || null,
+ draft: (form.elements.namedItem('draft') as HTMLInputElement).checked,
+ body_html: editor.getHTML(),
+ };
+ const res = await fetch(id ? `/api/admin/posts/${id}` : '/api/admin/posts', {
+ method: id ? 'PUT' : 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const msg = document.getElementById('save-msg')!;
+ if (res.ok) {
+ msg.textContent = 'Salvato ✓';
+ if (!id) location.href = '/admin';
+ } else {
+ msg.textContent = (await res.json()).error ?? 'Errore di salvataggio';
+ }
+ });
+</script>
diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro
new file mode 100644
index 0000000..0cb4bd0
--- /dev/null
+++ b/src/layouts/Admin.astro
@@ -0,0 +1,36 @@
+---
+interface Props { title: string }
+const { title } = Astro.props;
+const user = Astro.locals.user;
+---
+<!doctype html>
+<html lang="it">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>{title} — Admin InsanityLab</title>
+ <meta name="robots" content="noindex" />
+ <style is:global>
+ body { font-family: system-ui, sans-serif; margin: 0; background: #f4f2ef; color: #333; }
+ .abar { background: #3a2d26; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
+ .abar a { color: #d9cfc4; text-decoration: none; margin-left: 16px; }
+ .awrap { max-width: 960px; margin: 30px auto; padding: 0 20px; }
+ .abtn { background: #b5a48b; color: #fff; border: 0; padding: 10px 18px; cursor: pointer; font-size: .9rem; text-decoration: none; display: inline-block; }
+ .abtn--danger { background: #a33; }
+ .afield { width: 100%; padding: 10px; margin-bottom: 14px; border: 1px solid #ccc; box-sizing: border-box; font: inherit; }
+ table { width: 100%; border-collapse: collapse; background: #fff; }
+ th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
+ .pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
+ .pill--draft { background: #f3d9a4; }
+ </style>
+ </head>
+ <body>
+ <div class="abar">
+ <strong>InsanityLab · Admin</strong>
+ <span>
+ {user && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>}
+ </span>
+ </div>
+ <div class="awrap"><slot /></div>
+ </body>
+</html>
diff --git a/src/lib/post-body.ts b/src/lib/post-body.ts
new file mode 100644
index 0000000..7d34ee4
--- /dev/null
+++ b/src/lib/post-body.ts
@@ -0,0 +1,21 @@
+import { slugify } from './slug';
+import { sanitizeHtml } from './sanitize';
+import { CATEGORIES } from './blog-const';
+
+export function parsePostBody(data: Record<string, unknown>) {
+ const title = String(data.title ?? '').trim();
+ if (!title) return { error: 'Il titolo è obbligatorio.' } as const;
+ const category = String(data.category ?? '');
+ if (!(CATEGORIES as readonly string[]).includes(category)) return { error: 'Categoria non valida.' } as const;
+ const slug = slugify(String(data.slug ?? '') || title);
+ if (!slug) return { error: 'Slug non valido.' } as const;
+ return {
+ value: {
+ title, slug, category,
+ excerpt: String(data.excerpt ?? '').trim().slice(0, 300),
+ body_html: sanitizeHtml(String(data.body_html ?? '')),
+ cover: data.cover ? String(data.cover) : null,
+ draft: Boolean(data.draft),
+ },
+ } as const;
+}
diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts
new file mode 100644
index 0000000..b72e440
--- /dev/null
+++ b/src/lib/sanitize.ts
@@ -0,0 +1,11 @@
+import sanitize from 'sanitize-html';
+
+export function sanitizeHtml(html: string): string {
+ return sanitize(html, {
+ allowedTags: ['h2', 'h3', 'p', 'strong', 'em', 'u', 's', 'ul', 'ol', 'li', 'a', 'img', 'blockquote', 'br', 'hr'],
+ allowedAttributes: { a: ['href', 'rel', 'target'], img: ['src', 'alt'] },
+ allowedSchemes: ['https', 'http', 'mailto'],
+ allowedSchemesAppliedToAttributes: ['href', 'src'],
+ allowProtocolRelative: false,
+ });
+}
diff --git a/src/pages/admin/edit/[id].astro b/src/pages/admin/edit/[id].astro
new file mode 100644
index 0000000..7789e84
--- /dev/null
+++ b/src/pages/admin/edit/[id].astro
@@ -0,0 +1,14 @@
+---
+import Admin from '../../../layouts/Admin.astro';
+import PostForm from '../../../components/admin/PostForm.astro';
+import { getDb } from '../../../lib/db';
+import { getPostById } from '../../../lib/posts';
+export const prerender = false;
+const post = getPostById(getDb(), Number(Astro.params.id));
+if (!post) return Astro.redirect('/admin');
+---
+<Admin title={`Modifica: ${post.title}`}>
+ <h1>Modifica articolo</h1>
+ <script id="initial-body" type="text/plain" set:html={post.body_html}></script>
+ <PostForm post={post} />
+</Admin>
diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro
new file mode 100644
index 0000000..a8e0cd3
--- /dev/null
+++ b/src/pages/admin/index.astro
@@ -0,0 +1,37 @@
+---
+import Admin from '../../layouts/Admin.astro';
+import { getDb } from '../../lib/db';
+import { listAllPosts } from '../../lib/posts';
+export const prerender = false;
+const posts = listAllPosts(getDb());
+const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
+---
+<Admin title="Articoli">
+ <h1>Articoli</h1>
+ <p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
+ <table>
+ <thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
+ <tbody>
+ {posts.map((p) => (
+ <tr>
+ <td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
+ <td>{p.category}</td>
+ <td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
+ <td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
+ <td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ {posts.length === 0 && <p>Nessun articolo: creane uno nuovo.</p>}
+</Admin>
+
+<script>
+ document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
+ b.addEventListener('click', async () => {
+ if (!confirm('Eliminare definitivamente questo articolo?')) return;
+ const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
+ if (res.ok) location.reload();
+ else alert('Eliminazione non riuscita.');
+ }));
+</script>
diff --git a/src/pages/admin/login.astro b/src/pages/admin/login.astro
new file mode 100644
index 0000000..2f39dd8
--- /dev/null
+++ b/src/pages/admin/login.astro
@@ -0,0 +1,34 @@
+---
+import Admin from '../../layouts/Admin.astro';
+import { getDb } from '../../lib/db';
+import { login, SESSION_COOKIE } from '../../lib/auth';
+import { rateLimit } from '../../lib/rate-limit';
+export const prerender = false;
+
+let error = '';
+if (Astro.request.method === 'POST') {
+ if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
+ error = 'Troppi tentativi: riprova tra 15 minuti.';
+ } else {
+ const form = await Astro.request.formData();
+ const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
+ if (token) {
+ Astro.cookies.set(SESSION_COOKIE, token, {
+ httpOnly: true, sameSite: 'lax', path: '/',
+ secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
+ });
+ return Astro.redirect('/admin');
+ }
+ error = 'Credenziali non valide.';
+ }
+}
+---
+<Admin title="Login">
+ <h1>Accesso</h1>
+ {error && <p style="color:#a33">{error}</p>}
+ <form method="post" style="max-width:360px">
+ <input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
+ <input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
+ <button class="abtn" type="submit">Entra</button>
+ </form>
+</Admin>
diff --git a/src/pages/admin/logout.ts b/src/pages/admin/logout.ts
new file mode 100644
index 0000000..883170b
--- /dev/null
+++ b/src/pages/admin/logout.ts
@@ -0,0 +1,11 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../lib/db';
+import { logout, SESSION_COOKIE } from '../../lib/auth';
+export const prerender = false;
+
+export const GET: APIRoute = ({ cookies, redirect }) => {
+ const token = cookies.get(SESSION_COOKIE)?.value;
+ if (token) logout(getDb(), token);
+ cookies.delete(SESSION_COOKIE, { path: '/' });
+ return redirect('/admin/login');
+};
diff --git a/src/pages/admin/new.astro b/src/pages/admin/new.astro
new file mode 100644
index 0000000..08163ea
--- /dev/null
+++ b/src/pages/admin/new.astro
@@ -0,0 +1,10 @@
+---
+import Admin from '../../layouts/Admin.astro';
+import PostForm from '../../components/admin/PostForm.astro';
+export const prerender = false;
+---
+<Admin title="Nuovo articolo">
+ <h1>Nuovo articolo</h1>
+ <script id="initial-body" type="text/plain"></script>
+ <PostForm />
+</Admin>
diff --git a/src/pages/api/admin/posts/[id].ts b/src/pages/api/admin/posts/[id].ts
new file mode 100644
index 0000000..c7e8a33
--- /dev/null
+++ b/src/pages/api/admin/posts/[id].ts
@@ -0,0 +1,29 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../lib/db';
+import { getPostById, updatePost, deletePost } from '../../../../lib/posts';
+import { parsePostBody } from '../../../../lib/post-body';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const PUT: APIRoute = async ({ params, request }) => {
+ const id = Number(params.id);
+ if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' });
+ let data: Record<string, unknown>;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ const parsed = parsePostBody(data);
+ if ('error' in parsed) return json(400, { error: parsed.error });
+ try {
+ updatePost(getDb(), id, parsed.value);
+ return json(200, { ok: true });
+ } catch (err: any) {
+ if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
+ throw err;
+ }
+};
+
+export const DELETE: APIRoute = ({ params }) => {
+ deletePost(getDb(), Number(params.id));
+ return json(200, { ok: true });
+};
diff --git a/src/pages/api/admin/posts/index.ts b/src/pages/api/admin/posts/index.ts
new file mode 100644
index 0000000..16305d7
--- /dev/null
+++ b/src/pages/api/admin/posts/index.ts
@@ -0,0 +1,22 @@
+import type { APIRoute } from 'astro';
+import { getDb } from '../../../../lib/db';
+import { createPost } from '../../../../lib/posts';
+import { parsePostBody } from '../../../../lib/post-body';
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request }) => {
+ let data: Record<string, unknown>;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ const parsed = parsePostBody(data);
+ if ('error' in parsed) return json(400, { error: parsed.error });
+ try {
+ const id = createPost(getDb(), parsed.value);
+ return json(201, { id, slug: parsed.value.slug });
+ } catch (err: any) {
+ if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
+ throw err;
+ }
+};
diff --git a/src/pages/api/admin/upload.ts b/src/pages/api/admin/upload.ts
new file mode 100644
index 0000000..e14487d
--- /dev/null
+++ b/src/pages/api/admin/upload.ts
@@ -0,0 +1,26 @@
+import type { APIRoute } from 'astro';
+import { writeFile, mkdir } from 'node:fs/promises';
+import { randomBytes } from 'node:crypto';
+import { join, extname } from 'node:path';
+import { getEnv } from '../../../lib/env';
+export const prerender = false;
+
+const ALLOWED: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
+const MAX_BYTES = 5 * 1024 * 1024;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request }) => {
+ const form = await request.formData().catch(() => null);
+ const file = form?.get('file');
+ if (!(file instanceof File)) return json(400, { error: 'Nessun file.' });
+ const ext = extname(file.name).toLowerCase();
+ if (!ALLOWED[ext]) return json(400, { error: 'Formato non consentito (jpg, png, webp, gif).' });
+ if (file.size > MAX_BYTES) return json(400, { error: 'File troppo grande (max 5 MB).' });
+ const dir = getEnv('UPLOADS_DIR', 'uploads');
+ await mkdir(dir, { recursive: true });
+ const name = `${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
+ await writeFile(join(dir, name), Buffer.from(await file.arrayBuffer()));
+ return json(200, { url: `/uploads/${name}` });
+};
diff --git a/src/pages/uploads/[...path].ts b/src/pages/uploads/[...path].ts
new file mode 100644
index 0000000..1444dfa
--- /dev/null
+++ b/src/pages/uploads/[...path].ts
@@ -0,0 +1,20 @@
+import type { APIRoute } from 'astro';
+import { readFile } from 'node:fs/promises';
+import { join, normalize, extname } from 'node:path';
+import { getEnv } from '../../lib/env';
+export const prerender = false;
+
+const MIME: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
+
+export const GET: APIRoute = async ({ params }) => {
+ const rel = normalize(params.path ?? '').replace(/^(\.\.[/\\])+/, '');
+ if (!rel || rel.includes('..')) return new Response('Not found', { status: 404 });
+ const mime = MIME[extname(rel).toLowerCase()];
+ if (!mime) return new Response('Not found', { status: 404 });
+ try {
+ const buf = await readFile(join(getEnv('UPLOADS_DIR', 'uploads'), rel));
+ return new Response(buf, { headers: { 'Content-Type': mime, 'Cache-Control': 'public, max-age=31536000, immutable' } });
+ } catch {
+ return new Response('Not found', { status: 404 });
+ }
+};
diff --git a/tests/sanitize.test.ts b/tests/sanitize.test.ts
new file mode 100644
index 0000000..0830958
--- /dev/null
+++ b/tests/sanitize.test.ts
@@ -0,0 +1,16 @@
+import { describe, it, expect } from 'vitest';
+import { sanitizeHtml } from '../src/lib/sanitize';
+
+describe('sanitizeHtml', () => {
+ it('mantiene la formattazione consentita', () => {
+ const html = '<h2>Titolo</h2><p>Testo <strong>forte</strong> ed <em>enfasi</em></p><ul><li>voce</li></ul><blockquote>citazione</blockquote>';
+ expect(sanitizeHtml(html)).toBe(html);
+ });
+ it('rimuove script e handler', () => {
+ expect(sanitizeHtml('<p onclick="x()">ciao</p><script>alert(1)</script>')).toBe('<p>ciao</p>');
+ });
+ it('consente immagini locali e https, blocca javascript:', () => {
+ expect(sanitizeHtml('<img src="/uploads/a.jpg">')).toContain('/uploads/a.jpg');
+ expect(sanitizeHtml('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>');
+ });
+});