Release 0.3.0
This commit is contained in:
1 parent
08e8875cb5
commit
7590be66a5
31 files changed
+3270
-151
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createPiece,
|
||||
createTaxonomy,
|
||||
deletePieceImage,
|
||||
deleteTaxonomy,
|
||||
duplicatePiece,
|
||||
getMediaFile,
|
||||
getPiece,
|
||||
getTaxonomy,
|
||||
listPieces,
|
||||
savePiece,
|
||||
setPieceArchived,
|
||||
uploadPieceImage,
|
||||
type PieceInput
|
||||
} from './content.js';
|
||||
import { closeDatabase } from './database.js';
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-content-test-'));
|
||||
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
|
||||
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
|
||||
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
delete process.env.SENDEWERK_DB_PATH;
|
||||
delete process.env.SENDEWERK_UPLOAD_DIR;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function completeInput(overrides: Partial<PieceInput> = {}): PieceInput {
|
||||
return {
|
||||
title: 'Getestetes Piece',
|
||||
summary: 'Eine persistente Kurzbeschreibung.',
|
||||
formatId: 'format-talk',
|
||||
status: 'ready',
|
||||
durationSeconds: 420,
|
||||
intro: '**Starker Einstieg**',
|
||||
researchText: 'Private Recherche',
|
||||
moderationNotes: '- Punkt eins',
|
||||
transitionText: 'Weiter geht es.',
|
||||
presentationText: '## Sichtbare Aussage',
|
||||
topicIds: ['topic-gaming', 'topic-internet'],
|
||||
categoryIds: ['cat-jc', 'cat-reaction'],
|
||||
sources: [
|
||||
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('content management', () => {
|
||||
it('persists complete pieces and rejects stale revisions atomically', () => {
|
||||
const id = createPiece('talk');
|
||||
const initial = getPiece(id)!;
|
||||
const saved = savePiece(id, initial.revision, completeInput());
|
||||
const piece = getPiece(id)!;
|
||||
|
||||
expect(saved.revision).toBe(2);
|
||||
expect(piece).toMatchObject({
|
||||
title: 'Getestetes Piece',
|
||||
status: 'ready',
|
||||
researchText: 'Private Recherche',
|
||||
topicIds: ['topic-gaming', 'topic-internet'],
|
||||
categoryIds: ['cat-jc', 'cat-reaction']
|
||||
});
|
||||
expect(piece.sources).toEqual([
|
||||
{ label: 'Dokumentation', url: 'https://example.com/dokumentation', timecodeSeconds: 75 }
|
||||
]);
|
||||
expect(() =>
|
||||
savePiece(id, initial.revision, completeInput({ title: 'Veralteter Titel', topicIds: [] }))
|
||||
).toThrow('CONFLICT');
|
||||
expect(getPiece(id)).toMatchObject({
|
||||
title: 'Getestetes Piece',
|
||||
topicIds: ['topic-gaming', 'topic-internet']
|
||||
});
|
||||
});
|
||||
|
||||
it('searches independent taxonomies and archives, restores and duplicates pieces', async () => {
|
||||
createTaxonomy('topic', 'Technikgeschichte');
|
||||
const topic = getTaxonomy().topics.find((entry) => entry.name === 'Technikgeschichte')!;
|
||||
const sourceId = createPiece('find');
|
||||
const source = getPiece(sourceId)!;
|
||||
savePiece(
|
||||
sourceId,
|
||||
source.revision,
|
||||
completeInput({
|
||||
title: 'Seltene Hardware',
|
||||
formatId: 'format-find',
|
||||
topicIds: [topic.id],
|
||||
categoryIds: ['cat-reaction']
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
listPieces({ statuses: ['ready'], query: 'dokumentation', topicId: topic.id })
|
||||
).toHaveLength(1);
|
||||
expect(listPieces({ statuses: ['ready'], query: '%' })).toHaveLength(0);
|
||||
expect(() => deleteTaxonomy('topic', topic.id)).toThrow('TAXONOMY_IN_USE');
|
||||
createTaxonomy('topic', 'Unbenutzt');
|
||||
const unused = getTaxonomy().topics.find((entry) => entry.name === 'Unbenutzt')!;
|
||||
deleteTaxonomy('topic', unused.id);
|
||||
expect(getTaxonomy().topics.some((entry) => entry.id === unused.id)).toBe(false);
|
||||
expect(() => deleteTaxonomy('format', 'format-find')).toThrow('TAXONOMY_IN_USE');
|
||||
const duplicateId = await duplicatePiece(sourceId);
|
||||
expect(getPiece(duplicateId)).toMatchObject({
|
||||
title: 'Kopie von Seltene Hardware',
|
||||
status: 'preparing',
|
||||
topicIds: [topic.id]
|
||||
});
|
||||
const current = getPiece(sourceId)!;
|
||||
setPieceArchived(sourceId, current.revision, true);
|
||||
expect(listPieces({ statuses: ['ready'] }).some((piece) => piece.id === sourceId)).toBe(false);
|
||||
const archived = listPieces({ statuses: ['archived'] }).find((piece) => piece.id === sourceId)!;
|
||||
setPieceArchived(sourceId, archived.revision, false);
|
||||
expect(getPiece(sourceId)?.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('sanitizes dangerous markdown while preserving useful formatting', () => {
|
||||
const html = renderMarkdown(
|
||||
'# Verbotene Ebene\n\n**sicher** <script>globalThis.compromised = true</script> [Link](javascript:alert(1))'
|
||||
);
|
||||
expect(html).toContain('<strong>sicher</strong>');
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).not.toContain('javascript:');
|
||||
expect(html).not.toContain('<h1');
|
||||
});
|
||||
|
||||
it('accepts signed raster images, rejects disguised files and keeps storage private', async () => {
|
||||
const id = createPiece();
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
await uploadPieceImage(
|
||||
id,
|
||||
1,
|
||||
new File([png], '../bild.png', { type: 'text/html' }),
|
||||
'Ein heller Pixel'
|
||||
);
|
||||
const piece = getPiece(id)!;
|
||||
expect(piece.media).toHaveLength(1);
|
||||
expect(piece.media[0]).toMatchObject({ mimeType: 'image/png', altText: 'Ein heller Pixel' });
|
||||
const media = await getMediaFile(piece.media[0].id);
|
||||
expect(media?.data.equals(png)).toBe(true);
|
||||
expect(existsSync(path.join(directory, 'uploads', '../bild.png'))).toBe(false);
|
||||
|
||||
await expect(
|
||||
uploadPieceImage(
|
||||
id,
|
||||
piece.revision,
|
||||
new File(['<script>alert(1)</script>'], 'angriff.png', { type: 'image/png' }),
|
||||
''
|
||||
)
|
||||
).rejects.toThrow('INVALID_IMAGE_TYPE');
|
||||
await deletePieceImage(id, piece.media[0].id, piece.revision);
|
||||
expect(getPiece(id)?.media).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user