Add Logbuch: project update blog with admin, media and API

Public archive with sidebar navigation, project pages, month archive,
search and entry pages with image blocks. Admin area for brands,
projects, post types, users, api clients, media and the entry editor
with drag and drop images, preview per audience, scheduling and
publish checks. Read and write API with bearer tokens, audience
scoping, idempotent creation, OpenAPI document and editorial guide.
Magic link login with configurable allowed domains, whole app behind
the session gate. 456 tests including design rule checks.
This commit is contained in:
Matthias Giesselmann
2026-07-31 21:33:42 +02:00
commit b90ff252d1
291 changed files with 43671 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { listBrands } from '~/data/repositories/projects'
import { performSaveBrand } from '~/lib/admin-brands'
import { adminViewer, auditEntries, form, makeBrand, moderatorViewer, resetAccounts } from '../support/admin'
beforeEach(resetAccounts)
describe('performSaveBrand', () => {
it('legt eine Marke an und protokolliert das', async () => {
const state = await performSaveBrand(
adminViewer(),
form({ name: 'Pocket Rocket', slug: '', color: '#C43A22', sort: '2' }),
)
expect(state.status).toBe('ok')
expect(state.created).toBe(true)
const brands = await listBrands()
expect(brands).toHaveLength(1)
expect(brands[0]).toMatchObject({ slug: 'pocket-rocket', name: 'Pocket Rocket', color: '#c43a22', sort: 2 })
const entries = await auditEntries()
expect(entries[0]).toMatchObject({ action: 'brand.create', entity: 'brand', entityId: brands[0]!.id })
})
it('lehnt einen vergebenen Slug ab', async () => {
await makeBrand('nyo', 'NYO')
const state = await performSaveBrand(adminViewer(), form({ name: 'NYO zwei', slug: 'nyo', color: '#191c20' }))
expect(state.message).toBe('slugTaken')
expect(state.fields?.slug).toBe('slugTaken')
expect(await listBrands()).toHaveLength(1)
})
it('speichert Änderungen an einer bestehenden Marke', async () => {
const brand = await makeBrand('nyo', 'NYO')
const state = await performSaveBrand(
adminViewer(),
form({ id: brand.id, name: 'NYO GmbH', slug: 'nyo', color: '#2b4a9b', sort: '1' }),
)
expect(state.status).toBe('ok')
expect(state.created).toBe(false)
const brands = await listBrands()
expect(brands[0]).toMatchObject({ name: 'NYO GmbH', color: '#2b4a9b' })
expect((await auditEntries())[0]?.action).toBe('brand.update')
})
it('lässt einen Moderator keine Marke anlegen', async () => {
const state = await performSaveBrand(moderatorViewer(), form({ name: 'Fremd', color: '#191c20' }))
expect(state.message).toBe('forbidden')
expect(await listBrands()).toHaveLength(0)
expect(await auditEntries()).toHaveLength(0)
})
it('verlangt einen Namen', async () => {
const state = await performSaveBrand(adminViewer(), form({ name: '', color: '#191c20' }))
expect(state.fields?.name).toBe('nameRequired')
})
})
+159
View File
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { findActiveClientByTokenHash, listClients } from '~/data/repositories/clients'
import { performCreateClient, performRevokeClient, tokenPrefix } from '~/lib/admin-clients'
import { hashToken } from '~/lib/api-auth'
import {
adminViewer,
auditEntries,
form,
makeBrand,
makeProject,
moderatorViewer,
resetAccounts,
} from '../support/admin'
beforeEach(resetAccounts)
describe('performCreateClient', () => {
it('legt einen Zugang an, zeigt das Token einmal und speichert nur den Hash', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const state = await performCreateClient(
adminViewer(),
form({ name: 'Trakk Web', mode: 'read', scope: 'customer', projectId: project.id }),
)
expect(state.status).toBe('ok')
expect(state.token?.startsWith(tokenPrefix)).toBe(true)
const clients = await listClients()
expect(clients).toHaveLength(1)
expect(clients[0]).toMatchObject({
name: 'Trakk Web',
mode: 'read',
scope: 'customer',
projectId: project.id,
canPublish: false,
revokedAt: null,
})
expect(clients[0]?.tokenHash).not.toBe(state.token)
expect(clients[0]?.tokenHash).toBe(hashToken(state.token!))
const found = await findActiveClientByTokenHash(hashToken(state.token!))
expect(found?.id).toBe(clients[0]?.id)
})
it('protokolliert das Anlegen ohne das Token', async () => {
await performCreateClient(adminViewer(), form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }))
const entries = await auditEntries()
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({ action: 'client.create', entity: 'api_client' })
expect(JSON.stringify(entries[0]?.data)).not.toContain(tokenPrefix)
})
it('lässt die Projektbindung leer, wenn kein Projekt gewählt ist', async () => {
const state = await performCreateClient(
adminViewer(),
form({ name: 'Alle Projekte', mode: 'read', scope: 'internal', projectId: '' }),
)
expect(state.status).toBe('ok')
expect((await listClients())[0]?.projectId).toBeNull()
})
it('lehnt ein unbekanntes Projekt ab', async () => {
const state = await performCreateClient(
adminViewer(),
form({
name: 'Fremd',
mode: 'read',
scope: 'customer',
projectId: '11111111-1111-4111-8111-111111111111',
}),
)
expect(state.fields?.projectId).toBe('projectUnknown')
expect(await listClients()).toHaveLength(0)
})
it('lehnt einen unbekannten Modus ab', async () => {
const state = await performCreateClient(
adminViewer(),
form({ name: 'Fremd', mode: 'alles', scope: 'customer' }),
)
expect(state.fields?.mode).toBe('modeInvalid')
})
it('lässt einen Moderator keinen Zugang anlegen', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const state = await performCreateClient(
moderatorViewer([project.id]),
form({ name: 'Eigener Zugang', mode: 'read', scope: 'customer', projectId: project.id }),
)
expect(state.message).toBe('forbidden')
expect(await listClients()).toHaveLength(0)
expect(await auditEntries()).toHaveLength(0)
})
})
describe('performRevokeClient', () => {
it('widerruft einen Zugang und protokolliert das', async () => {
const created = await performCreateClient(
adminViewer(),
form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }),
)
const state = await performRevokeClient(adminViewer(), form({ id: created.id! }))
expect(state.status).toBe('ok')
const clients = await listClients()
expect(clients[0]?.revokedAt).toBeInstanceOf(Date)
expect((await auditEntries())[0]?.action).toBe('client.revoke')
})
it('lässt ein widerrufenes Token nicht mehr durch', async () => {
const created = await performCreateClient(
adminViewer(),
form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }),
)
await performRevokeClient(adminViewer(), form({ id: created.id! }))
expect(await findActiveClientByTokenHash(hashToken(created.token!))).toBeUndefined()
})
it('meldet einen zweiten Widerruf als unbekannt', async () => {
const created = await performCreateClient(
adminViewer(),
form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }),
)
await performRevokeClient(adminViewer(), form({ id: created.id! }))
const state = await performRevokeClient(adminViewer(), form({ id: created.id! }))
expect(state.message).toBe('notFound')
})
it('lässt einen Moderator keinen Zugang widerrufen', async () => {
const created = await performCreateClient(
adminViewer(),
form({ name: 'Trakk Web', mode: 'read', scope: 'customer' }),
)
const state = await performRevokeClient(moderatorViewer(), form({ id: created.id! }))
expect(state.message).toBe('forbidden')
expect((await listClients())[0]?.revokedAt).toBeNull()
})
})
+236
View File
@@ -0,0 +1,236 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { listAllProjects } from '~/data/repositories/projects'
import { performSaveProject } from '~/lib/admin-projects'
import {
adminViewer,
auditEntries,
form,
makeBrand,
makeProject,
moderatorViewer,
resetAccounts,
} from '../support/admin'
beforeEach(resetAccounts)
describe('performSaveProject legt an', () => {
it('speichert ein Projekt und schreibt einen Eintrag ins audit_log', async () => {
const brand = await makeBrand()
const state = await performSaveProject(
adminViewer(),
form({
name: 'MTA Telematik',
slug: '',
code: 'tel',
description: 'Telemetrie für die Flotte',
color: '#B4761A',
brandId: brand.id,
sort: '3',
isActive: 'on',
}),
)
expect(state.status).toBe('ok')
expect(state.created).toBe(true)
const projects = await listAllProjects()
expect(projects).toHaveLength(1)
expect(projects[0]).toMatchObject({
slug: 'mta-telematik',
name: 'MTA Telematik',
code: 'TEL',
color: '#b4761a',
sort: 3,
isActive: true,
brandId: brand.id,
})
const entries = await auditEntries()
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({
action: 'project.create',
entity: 'project',
entityId: projects[0]!.id,
actorId: 'admin-1',
})
})
it('legt ein stillgelegtes Projekt an, wenn der Haken fehlt', async () => {
const brand = await makeBrand()
await performSaveProject(adminViewer(), form({ name: 'Altprojekt', brandId: brand.id, color: '#191c20' }))
const projects = await listAllProjects()
expect(projects[0]?.isActive).toBe(false)
})
})
describe('performSaveProject prüft den Slug', () => {
it('lehnt einen vergebenen Slug ab', async () => {
const brand = await makeBrand()
await makeProject(brand.id, 'trakk', 'Trakk')
const state = await performSaveProject(
adminViewer(),
form({ name: 'Trakk Zwei', slug: 'trakk', brandId: brand.id, color: '#191c20', isActive: 'on' }),
)
expect(state.status).toBe('error')
expect(state.message).toBe('slugTaken')
expect(state.fields?.slug).toBe('slugTaken')
expect(await listAllProjects()).toHaveLength(1)
})
it('erlaubt beim Bearbeiten den eigenen Slug', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id, 'trakk', 'Trakk')
const state = await performSaveProject(
adminViewer(),
form({
id: project.id,
name: 'Trakk',
slug: 'trakk',
brandId: brand.id,
color: '#2e7d5b',
sort: '1',
isActive: 'on',
}),
)
expect(state.status).toBe('ok')
expect(state.created).toBe(false)
const entries = await auditEntries()
expect(entries[0]?.action).toBe('project.update')
})
it('schreibt einen eingetippten Slug klein', async () => {
const brand = await makeBrand()
const state = await performSaveProject(
adminViewer(),
form({ name: 'Trakk', slug: 'Trakk', brandId: brand.id, color: '#191c20' }),
)
expect(state.status).toBe('ok')
const projects = await listAllProjects()
expect(projects[0]?.slug).toBe('trakk')
})
it('lehnt einen Slug mit Leerzeichen ab', async () => {
const brand = await makeBrand()
const state = await performSaveProject(
adminViewer(),
form({ name: 'Trakk', slug: 'trakk web', brandId: brand.id, color: '#191c20' }),
)
expect(state.status).toBe('error')
expect(state.fields?.slug).toBe('slugInvalid')
})
})
describe('performSaveProject prüft die übrigen Felder', () => {
it('verlangt einen Namen', async () => {
const brand = await makeBrand()
const state = await performSaveProject(adminViewer(), form({ name: '', brandId: brand.id, color: '#191c20' }))
expect(state.fields?.name).toBe('nameRequired')
})
it('lehnt eine unbekannte Marke ab', async () => {
await makeBrand()
const state = await performSaveProject(
adminViewer(),
form({
name: 'Trakk',
brandId: '11111111-1111-4111-8111-111111111111',
color: '#191c20',
}),
)
expect(state.fields?.brandId).toBe('brandUnknown')
expect(await listAllProjects()).toHaveLength(0)
})
it('lehnt eine Farbe ab, die kein Hexwert ist', async () => {
const brand = await makeBrand()
const state = await performSaveProject(
adminViewer(),
form({ name: 'Trakk', brandId: brand.id, color: 'rot' }),
)
expect(state.fields?.color).toBe('colorInvalid')
})
it('lehnt eine Sortierung ab, die keine Zahl ist', async () => {
const brand = await makeBrand()
const state = await performSaveProject(
adminViewer(),
form({ name: 'Trakk', brandId: brand.id, color: '#191c20', sort: 'oben' }),
)
expect(state.fields?.sort).toBe('sortInvalid')
})
})
describe('performSaveProject prüft die Rechte', () => {
it('lässt einen Moderator kein Projekt anlegen', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const state = await performSaveProject(
moderatorViewer([project.id]),
form({ name: 'Neues Projekt', brandId: brand.id, color: '#191c20', isActive: 'on' }),
)
expect(state.status).toBe('error')
expect(state.message).toBe('forbidden')
expect(await listAllProjects()).toHaveLength(1)
expect(await auditEntries()).toHaveLength(0)
})
it('lässt einen Moderator sein eigenes Projekt nicht bearbeiten', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const state = await performSaveProject(
moderatorViewer([project.id]),
form({ id: project.id, name: 'Umbenannt', brandId: brand.id, color: '#191c20', isActive: 'on' }),
)
expect(state.message).toBe('forbidden')
const projects = await listAllProjects()
expect(projects[0]?.name).toBe('Trakk')
})
it('meldet einen unbekannten Datensatz beim Bearbeiten', async () => {
const brand = await makeBrand()
const state = await performSaveProject(
adminViewer(),
form({
id: '11111111-1111-4111-8111-111111111111',
name: 'Trakk',
brandId: brand.id,
color: '#191c20',
}),
)
expect(state.message).toBe('notFound')
})
})
+133
View File
@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { findUserById } from '~/data/repositories/users'
import { listAssignmentsForUser } from '~/data/repositories/user-roles'
import { performGrantRole, performRevokeRole, performSetUserAdmin } from '~/lib/admin-users'
import {
adminViewer,
auditEntries,
form,
makeBrand,
makeProject,
makeUser,
moderatorViewer,
resetAccounts,
} from '../support/admin'
beforeEach(resetAccounts)
describe('performSetUserAdmin', () => {
it('gibt einem Konto das Verwaltungsrecht und protokolliert das', async () => {
const user = await makeUser()
const state = await performSetUserAdmin(adminViewer(), form({ userId: user.id, isAdmin: 'on' }))
expect(state.status).toBe('ok')
expect((await findUserById(user.id))?.isAdmin).toBe(true)
expect((await auditEntries())[0]).toMatchObject({ action: 'user.admin.grant', entity: 'user', entityId: user.id })
})
it('entzieht das Verwaltungsrecht wieder', async () => {
const user = await makeUser()
await performSetUserAdmin(adminViewer(), form({ userId: user.id, isAdmin: 'on' }))
await performSetUserAdmin(adminViewer(), form({ userId: user.id, isAdmin: '' }))
expect((await findUserById(user.id))?.isAdmin).toBe(false)
expect((await auditEntries())[0]?.action).toBe('user.admin.revoke')
})
it('lässt niemanden das eigene Verwaltungsrecht ändern', async () => {
const user = await makeUser()
const state = await performSetUserAdmin(adminViewer({ id: user.id }), form({ userId: user.id, isAdmin: '' }))
expect(state.message).toBe('selfAdmin')
expect(await auditEntries()).toHaveLength(0)
})
it('lässt einen Moderator keine Rechte vergeben', async () => {
const user = await makeUser()
const state = await performSetUserAdmin(moderatorViewer(), form({ userId: user.id, isAdmin: 'on' }))
expect(state.message).toBe('forbidden')
expect((await findUserById(user.id))?.isAdmin).toBe(false)
})
it('meldet ein unbekanntes Konto', async () => {
const state = await performSetUserAdmin(adminViewer(), form({ userId: 'gibtsnicht', isAdmin: 'on' }))
expect(state.message).toBe('notFound')
})
})
describe('performGrantRole und performRevokeRole', () => {
it('schaltet ein Konto für ein Projekt frei und protokolliert das', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const user = await makeUser()
const state = await performGrantRole(adminViewer(), form({ userId: user.id, projectId: project.id }))
expect(state.status).toBe('ok')
expect(await listAssignmentsForUser(user.id)).toEqual([{ projectId: project.id, role: 'moderator' }])
expect((await auditEntries())[0]).toMatchObject({ action: 'role.grant', entityId: user.id })
})
it('ist beim zweiten Freischalten nicht doppelt', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const user = await makeUser()
await performGrantRole(adminViewer(), form({ userId: user.id, projectId: project.id }))
await performGrantRole(adminViewer(), form({ userId: user.id, projectId: project.id }))
expect(await listAssignmentsForUser(user.id)).toHaveLength(1)
})
it('entzieht die Freischaltung wieder', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const user = await makeUser()
await performGrantRole(adminViewer(), form({ userId: user.id, projectId: project.id }))
const state = await performRevokeRole(adminViewer(), form({ userId: user.id, projectId: project.id }))
expect(state.status).toBe('ok')
expect(await listAssignmentsForUser(user.id)).toHaveLength(0)
expect((await auditEntries())[0]?.action).toBe('role.revoke')
})
it('lässt einen Moderator keine Freischaltung vergeben', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const user = await makeUser()
const state = await performGrantRole(
moderatorViewer([project.id]),
form({ userId: user.id, projectId: project.id }),
)
expect(state.message).toBe('forbidden')
expect(await listAssignmentsForUser(user.id)).toHaveLength(0)
})
it('meldet ein unbekanntes Projekt', async () => {
const user = await makeUser()
const state = await performGrantRole(
adminViewer(),
form({ userId: user.id, projectId: '11111111-1111-4111-8111-111111111111' }),
)
expect(state.message).toBe('notFound')
})
it('meldet eine Projektkennung, die keine Kennung ist', async () => {
const user = await makeUser()
const state = await performGrantRole(adminViewer(), form({ userId: user.id, projectId: 'trakk' }))
expect(state.message).toBe('notFound')
})
})
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { db } from '~/data/db'
import { apiClients } from '~/data/schema'
import { hashToken, resolveClient } from '~/lib/api-auth'
async function seedClient(token: string, revoked = false) {
const [client] = await db.insert(apiClients).values({
name: 'Trakk Leseclient',
tokenHash: hashToken(token),
mode: 'read',
scope: 'internal',
revokedAt: revoked ? new Date() : null,
}).returning()
return client!
}
describe('resolveClient', () => {
it('findet den Client zu einem gültigen Bearer-Token', async () => {
const client = await seedClient('lb_live_abc')
const request = new Request('http://localhost/api/v1/posts', {
headers: { authorization: 'Bearer lb_live_abc' },
})
await expect(resolveClient(request)).resolves.toMatchObject({ id: client.id })
})
it('liefert nichts ohne Kopfzeile', async () => {
await seedClient('lb_live_abc')
const request = new Request('http://localhost/api/v1/posts')
await expect(resolveClient(request)).resolves.toBeUndefined()
})
it('liefert nichts für ein widerrufenes Token', async () => {
await seedClient('lb_live_abc', true)
const request = new Request('http://localhost/api/v1/posts', {
headers: { authorization: 'Bearer lb_live_abc' },
})
await expect(resolveClient(request)).resolves.toBeUndefined()
})
it('speichert das Token niemals im Klartext', async () => {
await seedClient('lb_live_abc')
const rows = await db.select().from(apiClients)
expect(rows[0]!.tokenHash).not.toContain('lb_live_abc')
})
})
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import {
canAccessProject,
canManageInstance,
canOpenAdmin,
isAdmin,
isModeratorOf,
moderatedProjectIds,
visibleProjects,
type Viewer,
} from '~/lib/auth-access'
const trakk = '11111111-1111-4111-8111-111111111111'
const mta360 = '22222222-2222-4222-8222-222222222222'
function viewer(overrides: Partial<Viewer> = {}): Viewer {
return {
id: 'user-1',
name: 'Paul',
email: 'paul@example.test',
isAdmin: false,
assignments: [],
...overrides,
}
}
describe('isAdmin', () => {
it('erkennt das Verwaltungsrecht', () => {
expect(isAdmin(viewer({ isAdmin: true }))).toBe(true)
})
it('gilt nicht für ein Konto ohne Verwaltungsrecht', () => {
expect(isAdmin(viewer())).toBe(false)
})
it('gilt nicht ohne Sitzung', () => {
expect(isAdmin(null)).toBe(false)
})
})
describe('moderatedProjectIds', () => {
it('liefert die Projekte der Moderatorenrolle', () => {
const person = viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] })
expect(moderatedProjectIds(person)).toEqual([trakk])
})
it('liefert jedes Projekt nur einmal', () => {
const person = viewer({
assignments: [
{ projectId: trakk, role: 'moderator' },
{ projectId: trakk, role: 'moderator' },
],
})
expect(moderatedProjectIds(person)).toEqual([trakk])
})
it('liefert nichts ohne Sitzung', () => {
expect(moderatedProjectIds(null)).toEqual([])
})
it('zählt Verwaltungsrecht nicht als Zuweisung', () => {
expect(moderatedProjectIds(viewer({ isAdmin: true }))).toEqual([])
})
})
describe('isModeratorOf', () => {
it('trifft auf das zugewiesene Projekt zu', () => {
const person = viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] })
expect(isModeratorOf(person, trakk)).toBe(true)
})
it('trifft auf ein fremdes Projekt nicht zu', () => {
const person = viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] })
expect(isModeratorOf(person, mta360)).toBe(false)
})
it('trifft auf einen Admin ohne Zuweisung nicht zu', () => {
expect(isModeratorOf(viewer({ isAdmin: true }), trakk)).toBe(false)
})
})
describe('canOpenAdmin', () => {
it('lässt einen Admin herein', () => {
expect(canOpenAdmin(viewer({ isAdmin: true }))).toBe(true)
})
it('lässt einen Moderator herein', () => {
expect(canOpenAdmin(viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] }))).toBe(true)
})
it('sperrt ein angemeldetes Konto ohne Rolle aus', () => {
expect(canOpenAdmin(viewer())).toBe(false)
})
it('sperrt ohne Sitzung aus', () => {
expect(canOpenAdmin(null)).toBe(false)
})
})
describe('canAccessProject', () => {
it('gibt einem Admin jedes Projekt frei', () => {
expect(canAccessProject(viewer({ isAdmin: true }), mta360)).toBe(true)
})
it('gibt einem Moderator genau sein Projekt frei', () => {
const person = viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] })
expect(canAccessProject(person, trakk)).toBe(true)
expect(canAccessProject(person, mta360)).toBe(false)
})
it('gibt ohne Sitzung nichts frei', () => {
expect(canAccessProject(null, trakk)).toBe(false)
})
})
describe('canManageInstance', () => {
it('bleibt Admins vorbehalten', () => {
expect(canManageInstance(viewer({ isAdmin: true }))).toBe(true)
expect(canManageInstance(viewer({ assignments: [{ projectId: trakk, role: 'moderator' }] }))).toBe(false)
})
})
describe('visibleProjects', () => {
const projects = [{ id: trakk }, { id: mta360 }]
it('zeigt einem Admin alle Projekte', () => {
expect(visibleProjects(viewer({ isAdmin: true }), projects)).toEqual(projects)
})
it('zeigt einem Moderator nur seine Projekte', () => {
const person = viewer({ assignments: [{ projectId: mta360, role: 'moderator' }] })
expect(visibleProjects(person, projects)).toEqual([{ id: mta360 }])
})
it('zeigt ohne Sitzung nichts', () => {
expect(visibleProjects(null, projects)).toEqual([])
})
})
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { internalTarget, loginPath, overviewTarget } from '~/lib/auth-routes'
describe('internalTarget', () => {
it('behält ein Ziel innerhalb der Verwaltung', () => {
expect(internalTarget('/admin/posts/neu', overviewTarget)).toBe('/admin/posts/neu')
})
it('fällt auf die Verwaltungsübersicht zurück', () => {
expect(internalTarget(undefined, overviewTarget)).toBe('/')
expect(internalTarget('', overviewTarget)).toBe('/')
expect(internalTarget('/search', overviewTarget)).toBe('/search')
})
it('lässt keine fremde Adresse durch', () => {
expect(internalTarget('//example.test/admin', overviewTarget)).toBe('/')
expect(internalTarget('https://example.test/admin', overviewTarget)).toBe('/')
expect(internalTarget('/admin\\@example.test', overviewTarget)).toBe('/')
})
})
describe('loginPath', () => {
it('bleibt ohne Ziel schlicht', () => {
expect(loginPath()).toBe('/login')
expect(loginPath('/')).toBe('/login')
})
it('hängt ein erlaubtes Ziel an', () => {
expect(loginPath('/admin/posts/neu')).toBe('/login?next=%2Fadmin%2Fposts%2Fneu')
})
it('verwirft ein fremdes Ziel', () => {
expect(loginPath('https://example.test')).toBe('/login')
})
})
+367
View File
@@ -0,0 +1,367 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { findEntry, listEntryBlocks } from '~/data/repositories/entries'
import {
performArchiveEntry,
performPublishEntry,
performResumeEntry,
performSaveEntry,
performScheduleEntry,
performUnscheduleEntry,
} from '~/lib/entries'
import { adminViewer, auditEntries, makeBrand, makeProject, moderatorViewer, resetAccounts } from '../support/admin'
import { entryInput, makeMedia } from '../support/entries'
beforeEach(resetAccounts)
async function ready(audience: 'internal' | 'customer' | 'public' = 'internal') {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const state = await performSaveEntry(adminViewer(), entryInput(project.id, { audience }))
if (!state.ok) {
throw new Error(`Anlegen fehlgeschlagen: ${state.error}`)
}
return { project, entry: state.entry }
}
describe('performSaveEntry legt an', () => {
it('speichert Beitrag, Blöcke und Autor', async () => {
const { entry } = await ready()
expect(entry.number).toBe(1)
expect(entry.slug).toBe('regel-engine')
expect(entry.status).toBe('draft')
const saved = await findEntry(entry.id)
const blocks = await listEntryBlocks(entry.id)
expect(saved?.title).toBe('Regel-Engine')
expect(saved?.authorName).toBe('Admin')
expect(blocks).toHaveLength(1)
expect(blocks[0]?.type).toBe('text')
const entries = await auditEntries()
expect(entries[0]).toMatchObject({ action: 'post.create', entity: 'post', entityId: entry.id })
})
it('verlangt einen Titel', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const state = await performSaveEntry(adminViewer(), entryInput(project.id, { title: ' ' }))
expect(state).toMatchObject({ ok: false, error: 'titleMissing' })
})
it('lehnt einen unbekannten Blocktyp ab', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const state = await performSaveEntry(
adminViewer(),
entryInput(project.id, { blocks: [{ type: 'unbekannt', data: {} }] as never }),
)
expect(state).toMatchObject({ ok: false, error: 'blocksInvalid' })
})
it('lässt einen Moderator nur in seinem Projekt schreiben', async () => {
const brand = await makeBrand()
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
const allowed = await performSaveEntry(moderatorViewer([trakk.id]), entryInput(trakk.id))
const denied = await performSaveEntry(moderatorViewer([trakk.id]), entryInput(orbit.id))
expect(allowed.ok).toBe(true)
expect(denied).toMatchObject({ ok: false, error: 'forbidden' })
})
})
describe('performSaveEntry ändert', () => {
it('behält Nummer und Slug, wenn sich der Titel ändert', async () => {
const { project, entry } = await ready()
const state = await performSaveEntry(
adminViewer(),
entryInput(project.id, { id: entry.id, title: 'Regel-Engine, zweiter Anlauf' }),
)
expect(state).toMatchObject({ ok: true })
expect(state.ok && state.entry.slug).toBe('regel-engine')
expect(state.ok && state.entry.number).toBe(1)
})
it('schreibt Blöcke in neuer Reihenfolge', async () => {
const { project, entry } = await ready()
await performSaveEntry(adminViewer(), entryInput(project.id, {
id: entry.id,
blocks: [
{ type: 'quote', data: { text: 'Erst das Zitat', source: '' } },
{ type: 'text', data: { text: 'Dann der Text' } },
],
}))
const blocks = await listEntryBlocks(entry.id)
expect(blocks.map(block => block.type)).toEqual(['quote', 'text'])
})
it('lehnt einen vergebenen Slug ab', async () => {
const { project, entry } = await ready()
await performSaveEntry(adminViewer(), entryInput(project.id, { title: 'SLA-Uhr', slug: 'sla-uhr' }))
const state = await performSaveEntry(adminViewer(), entryInput(project.id, { id: entry.id, slug: 'sla-uhr' }))
expect(state).toMatchObject({ ok: false, error: 'slugTaken' })
})
it('lässt das Projekt wechseln und nummeriert im Zielprojekt neu', async () => {
const brand = await makeBrand()
const trakk = await makeProject(brand.id, 'trakk', 'Trakk')
const orbit = await makeProject(brand.id, 'orbit', 'Orbit')
const created = await performSaveEntry(adminViewer(), entryInput(trakk.id))
const state = await performSaveEntry(
adminViewer(),
entryInput(orbit.id, { id: created.ok ? created.entry.id : '' }),
)
expect(state.ok).toBe(true)
const saved = await findEntry(created.ok ? created.entry.id : '')
expect(saved?.projectId).toBe(orbit.id)
expect(saved?.number).toBe(1)
})
})
describe('Statusübergänge', () => {
it('veröffentlicht einen Entwurf und setzt den Zeitpunkt', async () => {
const { entry } = await ready()
const state = await performPublishEntry(adminViewer(), { id: entry.id })
expect(state).toMatchObject({ ok: true })
expect(state.ok && state.entry.status).toBe('published')
expect(state.ok && state.entry.publishAt).not.toBeNull()
const entries = await auditEntries()
expect(entries[0]).toMatchObject({ action: 'post.publish', entityId: entry.id })
})
it('zieht einen veröffentlichten Beitrag zurück und holt ihn wieder', async () => {
const { entry } = await ready()
await performPublishEntry(adminViewer(), { id: entry.id })
const archived = await performArchiveEntry(adminViewer(), { id: entry.id })
const resumed = await performResumeEntry(adminViewer(), { id: entry.id })
expect(archived.ok && archived.entry.status).toBe('archived')
expect(resumed.ok && resumed.entry.status).toBe('draft')
})
it('setzt einen Termin und nimmt ihn zurück', async () => {
const { entry } = await ready()
const later = new Date(Date.now() + 60 * 60 * 1000).toISOString()
const scheduled = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: later })
const dropped = await performUnscheduleEntry(adminViewer(), { id: entry.id })
expect(scheduled.ok && scheduled.entry.status).toBe('scheduled')
expect(scheduled.ok && scheduled.entry.publishAt).toBe(new Date(later).toISOString())
expect(dropped.ok && dropped.entry.status).toBe('draft')
expect(dropped.ok && dropped.entry.publishAt).toBeNull()
})
it('lehnt einen Termin in der Vergangenheit ab', async () => {
const { entry } = await ready()
const earlier = new Date(Date.now() - 60 * 1000).toISOString()
const state = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: earlier })
expect(state).toMatchObject({ ok: false, error: 'schedulePast' })
})
it('lehnt einen Übergang ab, den der Status nicht hergibt', async () => {
const { entry } = await ready()
const later = new Date(Date.now() + 60 * 60 * 1000).toISOString()
await performPublishEntry(adminViewer(), { id: entry.id })
const state = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: later })
expect(state).toMatchObject({ ok: false, error: 'statusInvalid' })
})
it('lässt einen fremden Moderator nichts veröffentlichen', async () => {
const { entry } = await ready()
const state = await performPublishEntry(moderatorViewer([]), { id: entry.id })
expect(state).toMatchObject({ ok: false, error: 'notFound' })
const saved = await findEntry(entry.id)
expect(saved?.status).toBe('draft')
})
})
describe('Veröffentlichungsprüfungen', () => {
it('sperrt einen öffentlichen Beitrag mit Bild ohne Alt-Text', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const item = await makeMedia(project.id, null)
const created = await performSaveEntry(adminViewer(), entryInput(project.id, {
audience: 'public',
blocks: [
{ type: 'text', data: { text: 'Dazu ein Bild.' } },
{ type: 'image', data: { mediaId: item.id } },
],
}))
const state = await performPublishEntry(adminViewer(), { id: created.ok ? created.entry.id : '' })
expect(state).toMatchObject({ ok: false, error: 'blocked' })
expect(!state.ok && state.blockers).toContain('alt_text_missing')
const saved = await findEntry(created.ok ? created.entry.id : '')
expect(saved?.status).toBe('draft')
})
it('lässt denselben Beitrag intern durch', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const item = await makeMedia(project.id, null)
const created = await performSaveEntry(adminViewer(), entryInput(project.id, {
audience: 'internal',
blocks: [{ type: 'image', data: { mediaId: item.id } }],
}))
const state = await performPublishEntry(adminViewer(), { id: created.ok ? created.entry.id : '' })
expect(state).toMatchObject({ ok: true })
})
it('sperrt einen Beitrag ohne Inhalt', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const created = await performSaveEntry(adminViewer(), entryInput(project.id, {
blocks: [{ type: 'text', data: { text: ' ' } }],
}))
const state = await performPublishEntry(adminViewer(), { id: created.ok ? created.entry.id : '' })
expect(state).toMatchObject({ ok: false, error: 'blocked' })
expect(!state.ok && state.blockers).toContain('content_empty')
})
it('sperrt einen Termin, solange ein Sperrgrund besteht', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const created = await performSaveEntry(adminViewer(), entryInput(project.id, { blocks: [] }))
const later = new Date(Date.now() + 60 * 60 * 1000).toISOString()
const state = await performScheduleEntry(adminViewer(), {
id: created.ok ? created.entry.id : '',
publishAt: later,
})
expect(state).toMatchObject({ ok: false, error: 'blocked' })
})
})
describe('performSaveEntry wechselt das Projekt', () => {
it('vergibt im Zielprojekt die nächste freie Nummer', async () => {
const brand = await makeBrand()
const from = await makeProject(brand.id, 'quelle', 'Quelle')
const to = await makeProject(brand.id, 'ziel', 'Ziel')
const first = await performSaveEntry(adminViewer(), entryInput(to.id, { slug: 'schon-da' }))
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
if (!first.ok || !created.ok) {
throw new Error('Anlegen fehlgeschlagen')
}
const moved = await performSaveEntry(adminViewer(), {
...entryInput(to.id),
id: created.entry.id,
})
if (!moved.ok) {
throw new Error(`Wechsel fehlgeschlagen: ${moved.error}`)
}
const saved = await findEntry(created.entry.id)
expect(saved?.projectId).toBe(to.id)
expect(moved.entry.number).toBe(2)
})
it('macht den Slug im Zielprojekt eindeutig', async () => {
const brand = await makeBrand()
const from = await makeProject(brand.id, 'quelle', 'Quelle')
const to = await makeProject(brand.id, 'ziel', 'Ziel')
const blocking = await performSaveEntry(adminViewer(), entryInput(to.id))
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
if (!blocking.ok || !created.ok) {
throw new Error('Anlegen fehlgeschlagen')
}
const moved = await performSaveEntry(adminViewer(), {
...entryInput(to.id),
id: created.entry.id,
})
if (!moved.ok) {
throw new Error(`Wechsel fehlgeschlagen: ${moved.error}`)
}
expect(moved.entry.slug).not.toBe(blocking.entry.slug)
})
it('schreibt den Wechsel ins Protokoll', async () => {
const brand = await makeBrand()
const from = await makeProject(brand.id, 'quelle', 'Quelle')
const to = await makeProject(brand.id, 'ziel', 'Ziel')
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
if (!created.ok) {
throw new Error('Anlegen fehlgeschlagen')
}
await performSaveEntry(adminViewer(), { ...entryInput(to.id), id: created.entry.id })
const log = await auditEntries()
expect(log.some(row => row.action === 'post.move')).toBe(true)
})
it('lässt einen Moderator ohne Recht am Zielprojekt nicht wechseln', async () => {
const brand = await makeBrand()
const from = await makeProject(brand.id, 'quelle', 'Quelle')
const to = await makeProject(brand.id, 'ziel', 'Ziel')
const created = await performSaveEntry(adminViewer(), entryInput(from.id))
if (!created.ok) {
throw new Error('Anlegen fehlgeschlagen')
}
const result = await performSaveEntry(moderatorViewer([from.id]), {
...entryInput(to.id),
id: created.entry.id,
})
expect(result.ok).toBe(false)
})
})
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { loginDomains } from '~/lib/login-domains'
const fallback = ['nyo.de', 'pocketrocket.de', 'pocket-rocket.io']
describe('loginDomains', () => {
it('nimmt die Liste aus der Umgebung', () => {
expect(loginDomains('example.org, Beispiel.de')).toEqual(['example.org', 'beispiel.de'])
})
it('fällt ohne Angabe auf die drei Hausdomains zurück', () => {
expect(loginDomains(undefined)).toEqual(fallback)
expect(loginDomains('')).toEqual(fallback)
expect(loginDomains(' , ')).toEqual(fallback)
})
})
+38
View File
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { magicLinkWindowSeconds } from '~/domain/magic-link'
import { forgetMagicLinkAttempts, registerMagicLinkAttempt } from '~/lib/magic-link-attempts'
const window = magicLinkWindowSeconds * 1000
describe('registerMagicLinkAttempt', () => {
beforeEach(() => {
forgetMagicLinkAttempts()
})
it('zählt die Anforderungen einer Adresse hoch', () => {
const now = 1_700_000_000_000
expect(registerMagicLinkAttempt('paul@nyo.de', now)).toBe(1)
expect(registerMagicLinkAttempt('paul@nyo.de', now + 1000)).toBe(2)
expect(registerMagicLinkAttempt('PAUL@nyo.de', now + 2000)).toBe(3)
})
it('zählt jede Adresse für sich', () => {
const now = 1_700_000_000_000
registerMagicLinkAttempt('paul@nyo.de', now)
registerMagicLinkAttempt('paul@nyo.de', now)
expect(registerMagicLinkAttempt('mia@nyo.de', now)).toBe(1)
})
it('vergisst Anforderungen außerhalb des Zeitfensters', () => {
const now = 1_700_000_000_000
registerMagicLinkAttempt('paul@nyo.de', now)
registerMagicLinkAttempt('paul@nyo.de', now + 1000)
expect(registerMagicLinkAttempt('paul@nyo.de', now + window + 1)).toBe(2)
expect(registerMagicLinkAttempt('paul@nyo.de', now + 2 * window + 2)).toBe(1)
})
})
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import sharp from 'sharp'
import {
extensionForFormat,
isSupportedFormat,
mimeForFormat,
plannedWidths,
probeImage,
renderVariants,
supportedMimes,
variantWidths,
} from '~/lib/media-images'
async function png(width: number, height: number): Promise<Buffer> {
return sharp({ create: { width, height, channels: 3, background: '#2b4a9b' } }).png().toBuffer()
}
describe('plannedWidths', () => {
it('erzeugt für ein grosses Bild alle vereinbarten Breiten', () => {
expect(plannedWidths(2000)).toEqual([480, 960, 1600])
})
it('vergrössert ein Bild nicht über seine eigene Breite hinaus', () => {
expect(plannedWidths(1000)).toEqual([480, 960])
expect(plannedWidths(600)).toEqual([480])
})
it('behält bei sehr kleinen Bildern die Originalbreite', () => {
expect(plannedWidths(320)).toEqual([320])
})
it('nimmt die Grenzbreiten selbst mit', () => {
expect(plannedWidths(480)).toEqual([480])
expect(plannedWidths(1600)).toEqual([...variantWidths])
})
})
describe('probeImage', () => {
it('liest Format und Masse aus einem Bild', async () => {
const probed = await probeImage(await png(800, 400))
expect(probed).toEqual({ format: 'png', width: 800, height: 400 })
})
it('meldet undefined für alles, was kein Bild ist', async () => {
expect(await probeImage(Buffer.from('das ist kein bild, sondern text'))).toBeUndefined()
})
})
describe('renderVariants', () => {
it('erzeugt WebP-Fassungen in den erwarteten Breiten', async () => {
const source = await png(2000, 1125)
const rendered = await renderVariants(source, plannedWidths(2000))
expect(rendered.map(variant => variant.width)).toEqual([480, 960, 1600])
expect(rendered.every(variant => variant.format === 'webp')).toBe(true)
for (const variant of rendered) {
const meta = await sharp(variant.body).metadata()
expect(meta.format).toBe('webp')
expect(meta.width).toBe(variant.width)
}
})
})
describe('Formatliste', () => {
it('kennt die angenommenen Formate', () => {
expect(isSupportedFormat('png')).toBe(true)
expect(isSupportedFormat('svg')).toBe(false)
expect(mimeForFormat('jpeg')).toBe('image/jpeg')
expect(extensionForFormat('jpeg')).toBe('jpg')
})
it('nennt jeden Mime-Typ nur einmal', () => {
const mimes = supportedMimes()
expect(new Set(mimes).size).toBe(mimes.length)
expect(mimes).toContain('image/webp')
})
})
+59
View File
@@ -0,0 +1,59 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import sharp from 'sharp'
import { db } from '~/data/db'
import { brands, media, projects } from '~/data/schema'
import { uploadMedia } from '~/lib/media-upload'
import { createLocalStorage, type Storage } from '~/lib/storage'
vi.mock('~/lib/media-images', async importOriginal => {
const original = await importOriginal<typeof import('~/lib/media-images')>()
return {
...original,
renderVariant: vi.fn(async () => {
throw new Error('libvips ist ausgefallen')
}),
}
})
let root: string
let storage: Storage
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'logbuch-upload-'))
storage = createLocalStorage(root)
})
afterAll(async () => {
await rm(root, { recursive: true, force: true })
})
async function project(): Promise<string> {
const [brand] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO' }).returning()
const [row] = await db.insert(projects).values({ brandId: brand!.id, slug: 'pulsar', name: 'Pulsar' }).returning()
return row!.id
}
describe('uploadMedia mit gescheiterter Umwandlung', () => {
it('behält das Original und schreibt den Fehler an das Medium', async () => {
const projectId = await project()
const body = await sharp({ create: { width: 1200, height: 800, channels: 3, background: '#a33b6a' } }).png().toBuffer()
const result = await uploadMedia({ projectId, filename: 'ausfall.png', body, storage })
expect(result.ok).toBe(true)
const rows = await db.select().from(media)
expect(rows).toHaveLength(1)
expect(rows[0]!.variants).toEqual([])
expect(rows[0]!.variantError).toBe('libvips ist ausgefallen')
expect(rows[0]!.width).toBe(1200)
expect(rows[0]!.height).toBe(800)
expect(await storage.exists(rows[0]!.path)).toBe(true)
})
})
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { contentBlocks, readingMinutes } from '~/lib/post-content'
import type { Block } from '~/data/schema'
function block(id: string, type: string, data: Record<string, unknown>): Block {
return { id, postId: 'post', sort: 0, type, data } as Block
}
describe('contentBlocks', () => {
it('lässt Blöcke unangetastet, wenn nichts doppelt ist', () => {
const blocks = [block('a', 'text', { text: 'Ein eigener Absatz.' })]
expect(contentBlocks(blocks, 'Anderer Anreißer', null)).toHaveLength(1)
})
it('überspringt den ersten Textblock, wenn er den Anreißer wiederholt', () => {
const blocks = [
block('a', 'text', { text: ' Tickets reagieren jetzt selbst. ' }),
block('b', 'text', { text: 'Und noch etwas.' }),
]
const result = contentBlocks(blocks, 'Tickets reagieren jetzt selbst.', null)
expect(result.map(entry => entry.id)).toEqual(['b'])
})
it('überspringt einen Bildblock, der das Aufmacherbild wiederholt', () => {
const blocks = [
block('a', 'image', { mediaId: 'cover-1' }),
block('b', 'image', { mediaId: 'andere-2' }),
]
const result = contentBlocks(blocks, null, 'cover-1')
expect(result.map(entry => entry.id)).toEqual(['b'])
})
it('entfernt beide Wiederholungen zusammen', () => {
const blocks = [
block('a', 'image', { mediaId: 'cover-1' }),
block('b', 'text', { text: 'Der Anreißer.' }),
block('c', 'text', { text: 'Der eigentliche Inhalt.' }),
]
const result = contentBlocks(blocks, 'Der Anreißer.', 'cover-1')
expect(result.map(entry => entry.id)).toEqual(['c'])
})
it('behält den Bildblock, wenn es kein Aufmacherbild gibt', () => {
const blocks = [block('a', 'image', { mediaId: 'irgendwas' })]
expect(contentBlocks(blocks, null, null)).toHaveLength(1)
})
})
describe('readingMinutes', () => {
it('meldet mindestens eine Minute', () => {
expect(readingMinutes([], null)).toBe(1)
})
it('rechnet Anreißer und Blöcke zusammen', () => {
const words = Array.from({ length: 400 }, () => 'Wort').join(' ')
expect(readingMinutes([block('a', 'text', { text: words })], null)).toBe(2)
})
})
+58
View File
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { findEntry } from '~/data/repositories/entries'
import { publishDueEntries } from '~/lib/publish-due'
import { auditEntries, makeBrand, makeProject, resetAccounts } from '../support/admin'
import { makePost } from '../support/entries'
beforeEach(resetAccounts)
const past = new Date('2026-07-30T06:00:00Z')
const future = new Date('2026-08-30T06:00:00Z')
const now = new Date('2026-07-30T08:00:00Z')
describe('publishDueEntries', () => {
it('veröffentlicht nur fällige terminierte Beiträge', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const due = await makePost(project.id, { number: 1, slug: 'faellig', status: 'scheduled', publishAt: past })
const later = await makePost(project.id, { number: 2, slug: 'spaeter', status: 'scheduled', publishAt: future })
const draft = await makePost(project.id, { number: 3, slug: 'entwurf', status: 'draft', publishAt: past })
const result = await publishDueEntries(now)
expect(result.published.map(entry => entry.slug)).toEqual(['faellig'])
expect((await findEntry(due.id))?.status).toBe('published')
expect((await findEntry(later.id))?.status).toBe('scheduled')
expect((await findEntry(draft.id))?.status).toBe('draft')
})
it('behält den geplanten Zeitpunkt und schreibt ins Protokoll', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
const due = await makePost(project.id, { number: 1, slug: 'faellig', status: 'scheduled', publishAt: past })
await publishDueEntries(now)
const saved = await findEntry(due.id)
const entries = await auditEntries()
expect(saved?.publishAt?.toISOString()).toBe(past.toISOString())
expect(entries[0]).toMatchObject({ action: 'post.publish.due', entityId: due.id, actorType: 'client' })
})
it('veröffentlicht bei einem zweiten Lauf nichts noch einmal', async () => {
const brand = await makeBrand()
const project = await makeProject(brand.id)
await makePost(project.id, { number: 1, slug: 'faellig', status: 'scheduled', publishAt: past })
const first = await publishDueEntries(now)
const second = await publishDueEntries(now)
expect(first.published).toHaveLength(1)
expect(second.published).toHaveLength(0)
})
})
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { pageCount, readMonth, readPage, readText, readYear } from '~/lib/query'
describe('readText', () => {
it('nimmt den ersten Wert und wirft Leerraum weg', () => {
expect(readText([' Spalten ', 'zweite'])).toBe('Spalten')
expect(readText(' ')).toBeUndefined()
expect(readText(undefined)).toBeUndefined()
})
})
describe('readPage', () => {
it('fällt auf Seite eins zurück', () => {
expect(readPage('3')).toBe(3)
expect(readPage('0')).toBe(1)
expect(readPage('-2')).toBe(1)
expect(readPage('abc')).toBe(1)
expect(readPage(undefined)).toBe(1)
})
})
describe('readYear', () => {
it('nimmt nur vierstellige Jahre', () => {
expect(readYear('2026')).toBe(2026)
expect(readYear('26')).toBeUndefined()
expect(readYear('20261')).toBeUndefined()
expect(readYear('zweitausend')).toBeUndefined()
})
})
describe('readMonth', () => {
it('nimmt nur Monate von eins bis zwölf', () => {
expect(readMonth('07')).toBe(7)
expect(readMonth('7')).toBe(7)
expect(readMonth('12')).toBe(12)
expect(readMonth('0')).toBeUndefined()
expect(readMonth('13')).toBeUndefined()
expect(readMonth('007')).toBeUndefined()
})
})
describe('pageCount', () => {
it('zählt mindestens eine Seite', () => {
expect(pageCount(0, 10)).toBe(1)
expect(pageCount(10, 10)).toBe(1)
expect(pageCount(11, 10)).toBe(2)
expect(pageCount(7, 3)).toBe(3)
})
})
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { monthPath, overviewPath, postPath, projectPath, searchPath, yearPath } from '~/lib/routes'
describe('routes', () => {
it('lässt Seite eins aus der Adresse weg', () => {
expect(overviewPath()).toBe('/')
expect(overviewPath({ page: 1 })).toBe('/')
expect(overviewPath({ page: 2 })).toBe('/?page=2')
expect(projectPath('trakk', { page: 3 })).toBe('/trakk?page=3')
})
it('schreibt Monate zweistellig', () => {
expect(overviewPath({ year: 2026, month: 7 })).toBe('/?year=2026&month=07')
expect(monthPath('trakk', 2026, 7)).toBe('/trakk/2026/07')
expect(yearPath('trakk', 2026)).toBe('/trakk/2026')
})
it('maskiert Slugs und Suchbegriffe', () => {
expect(postPath('mta 360', 'a/b')).toBe('/mta%20360/a%2Fb')
expect(searchPath({ query: 'spalten menü' })).toBe('/search?q=spalten+men%C3%BC')
expect(searchPath()).toBe('/search')
})
})
+126
View File
@@ -0,0 +1,126 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
contentTypeForPath,
createLocalStorage,
isSafeObjectPath,
resolveObjectPath,
StoragePathError,
type Storage,
} from '~/lib/storage'
let root: string
let storage: Storage
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'logbuch-storage-'))
storage = createLocalStorage(root)
})
afterAll(async () => {
await rm(root, { recursive: true, force: true })
})
const escapes = [
'../secret.txt',
'..',
'a/../../secret.txt',
'/etc/passwd',
'./a.webp',
'a//b.webp',
'a/./b.webp',
'..\\secret.txt',
'a\\b.webp',
'',
'C:/windows/win.ini',
]
describe('isSafeObjectPath', () => {
it('nimmt gewöhnliche Objektpfade an', () => {
expect(isSafeObjectPath('project/media/w960.webp')).toBe(true)
expect(isSafeObjectPath('a.webp')).toBe(true)
})
it('weist jeden Ausbruch aus dem Speicher ab', () => {
for (const path of escapes) {
expect(isSafeObjectPath(path), path).toBe(false)
}
})
it('weist Nullbytes und Steuerzeichen ab', () => {
expect(isSafeObjectPath('a\u0000b.webp')).toBe(false)
expect(isSafeObjectPath('a\nb.webp')).toBe(false)
})
})
describe('resolveObjectPath', () => {
it('bleibt innerhalb des Speicherverzeichnisses', () => {
expect(resolveObjectPath(root, 'a/b.webp')).toBe(resolve(root, 'a/b.webp'))
})
it('wirft bei jedem Ausbruchsversuch', () => {
for (const path of escapes) {
expect(() => resolveObjectPath(root, path), path).toThrow(StoragePathError)
}
})
})
describe('lokaler Speicher', () => {
it('legt ab, liest zurück und meldet den Inhaltstyp', async () => {
await storage.put('projekt/bild/w480.webp', Buffer.from('inhalt'), 'image/webp')
const object = await storage.read('projekt/bild/w480.webp')
expect(object?.body.toString()).toBe('inhalt')
expect(object?.contentType).toBe('image/webp')
expect(object?.byteSize).toBe(6)
})
it('meldet fehlende Objekte als undefined', async () => {
expect(await storage.read('projekt/bild/gibtsnicht.webp')).toBeUndefined()
})
it('legt keine Datei ausserhalb des Verzeichnisses an', async () => {
await expect(storage.put('../ausbruch.webp', Buffer.from('nein'), 'image/webp')).rejects.toThrow(StoragePathError)
})
it('liest keine Datei ausserhalb des Verzeichnisses', async () => {
const outside = resolve(root, '..', 'logbuch-outside.txt')
await writeFile(outside, 'geheim')
try {
await expect(storage.read('../logbuch-outside.txt')).rejects.toThrow(StoragePathError)
expect(await readFile(outside, 'utf8')).toBe('geheim')
} finally {
await rm(outside, { force: true })
}
})
it('entfernt Objekte und meldet ihre Abwesenheit', async () => {
await storage.put('projekt/bild/weg.webp', Buffer.from('x'), 'image/webp')
expect(await storage.exists('projekt/bild/weg.webp')).toBe(true)
await storage.remove('projekt/bild/weg.webp')
expect(await storage.exists('projekt/bild/weg.webp')).toBe(false)
})
it('baut eine Auslieferungs-URL unter dem Medienpfad', () => {
expect(storage.url('projekt/bild/w960.webp')).toBe('/api/v1/media/file/projekt/bild/w960.webp')
})
})
describe('contentTypeForPath', () => {
it('kennt die Bildformate', () => {
expect(contentTypeForPath('a/b.webp')).toBe('image/webp')
expect(contentTypeForPath('a/b.PNG')).toBe('image/png')
expect(contentTypeForPath('a/b.jpg')).toBe('image/jpeg')
})
it('fällt bei Unbekanntem auf Bytes zurück', () => {
expect(contentTypeForPath('a/b.bin')).toBe('application/octet-stream')
expect(contentTypeForPath('ohnepunkt')).toBe('application/octet-stream')
})
})