TASKS-1823 WebManager module
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { test } from '@japa/runner'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { FileNotFoundException } from '#exceptions/file_manager_exceptions'
|
||||
import { PageRouteConflictException } from '#exceptions/web_manager_exceptions'
|
||||
|
||||
function captureContext() {
|
||||
const captured: { status?: number; body?: unknown } = {}
|
||||
const response = {
|
||||
status(code: number) {
|
||||
captured.status = code
|
||||
return response
|
||||
},
|
||||
send(body: unknown) {
|
||||
captured.body = body
|
||||
},
|
||||
}
|
||||
return { captured, ctx: { response } as unknown as HttpContext }
|
||||
}
|
||||
|
||||
test.group('API exceptions', () => {
|
||||
test('page manager exceptions self-render as message and code', async ({ assert }) => {
|
||||
const { captured, ctx } = captureContext()
|
||||
const error = new PageRouteConflictException('en', 'about')
|
||||
await error.handle(error, ctx)
|
||||
|
||||
assert.equal(captured.status, 409)
|
||||
assert.deepEqual(captured.body, {
|
||||
message: 'A page already uses /en/about',
|
||||
code: 'E_PAGE_ROUTE_CONFLICT',
|
||||
})
|
||||
})
|
||||
|
||||
test('file manager exceptions self-render as message and code', async ({ assert }) => {
|
||||
const { captured, ctx } = captureContext()
|
||||
const error = new FileNotFoundException()
|
||||
await error.handle(error, ctx)
|
||||
|
||||
assert.equal(captured.status, 404)
|
||||
assert.deepEqual(captured.body, { message: 'File not found', code: 'E_FILE_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from '@japa/runner'
|
||||
import {
|
||||
ancestorPaths,
|
||||
ancestorsAlreadyLoaded,
|
||||
BREADCRUMB_PARENT_GROUP_KEYS,
|
||||
} from '#base_modules/WebManager/services/breadcrumb_paths'
|
||||
import { BREADCRUMB_PAGE_TYPE_PARENTS } from '#base_modules/WebManager/config/general'
|
||||
|
||||
/**
|
||||
* These decide which rows the breadcrumb query asks for. Miss a path here and the trail
|
||||
* silently loses a level, because the walk can only choose from what was loaded.
|
||||
*/
|
||||
test.group('Web Manager breadcrumb candidate paths', () => {
|
||||
test('climbs every shorter prefix, longest first, then home', ({ assert }) => {
|
||||
assert.deepEqual(ancestorPaths('blog/post/hello'), ['blog/post', 'blog', ''])
|
||||
})
|
||||
|
||||
test('never offers the page itself as its own parent', ({ assert }) => {
|
||||
assert.notInclude(ancestorPaths('about'), 'about')
|
||||
assert.notInclude(ancestorPaths('blog/post'), 'blog/post')
|
||||
})
|
||||
|
||||
test('a top-level page and the home page both fall back to home', ({ assert }) => {
|
||||
assert.deepEqual(ancestorPaths('about'), [''])
|
||||
assert.deepEqual(ancestorPaths(''), [''])
|
||||
})
|
||||
|
||||
test('tolerates stray slashes in a stored path', ({ assert }) => {
|
||||
assert.deepEqual(ancestorPaths('/blog/post/'), ancestorPaths('blog/post'))
|
||||
assert.deepEqual(ancestorPaths('/blog/post/'), ['blog', ''])
|
||||
})
|
||||
|
||||
test('covers the group keys a page type can be re-parented onto', ({ assert }) => {
|
||||
assert.includeMembers(BREADCRUMB_PARENT_GROUP_KEYS, Object.values(BREADCRUMB_PAGE_TYPE_PARENTS))
|
||||
assert.equal(
|
||||
BREADCRUMB_PARENT_GROUP_KEYS.length,
|
||||
new Set(BREADCRUMB_PARENT_GROUP_KEYS).size,
|
||||
'duplicate group keys would widen the query for nothing'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('Web Manager breadcrumb candidate reuse', () => {
|
||||
test('walking up a prefix reuses the rows already loaded', ({ assert }) => {
|
||||
assert.isTrue(ancestorsAlreadyLoaded('blog/post', ['blog/post/hello']))
|
||||
assert.isTrue(ancestorsAlreadyLoaded('blog', ['blog/post/hello']))
|
||||
assert.isTrue(ancestorsAlreadyLoaded('', ['blog/post/hello']))
|
||||
assert.isTrue(ancestorsAlreadyLoaded('blog/post/hello', ['blog/post/hello']))
|
||||
})
|
||||
|
||||
test('a jump to an unrelated branch needs its own query', ({ assert }) => {
|
||||
assert.isFalse(ancestorsAlreadyLoaded('company/blog', ['news/hello']))
|
||||
assert.isFalse(ancestorsAlreadyLoaded('blog', []))
|
||||
})
|
||||
|
||||
test('does not mistake a shared name prefix for an ancestor', ({ assert }) => {
|
||||
assert.isFalse(ancestorsAlreadyLoaded('blogging', ['blog/post']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from '@japa/runner'
|
||||
import {
|
||||
sanitizePayload,
|
||||
sanitizeRichText,
|
||||
sanitizeUrl,
|
||||
} from '#base_modules/WebManager/services/content_sanitizer'
|
||||
|
||||
test.group('Web Manager content sanitizer', () => {
|
||||
test('rich text keeps allowed markup and drops event handlers', ({ assert }) => {
|
||||
assert.equal(
|
||||
sanitizeRichText('<p class="lead" onclick="alert(1)">Hi <em>there</em></p>'),
|
||||
'<p class="lead">Hi <em>there</em></p>'
|
||||
)
|
||||
})
|
||||
|
||||
test('rich text drops dangerous subtrees entirely', ({ assert }) => {
|
||||
assert.equal(
|
||||
sanitizeRichText('before<script>alert(1)</script><svg><a onload="alert(1)"></a></svg>after'),
|
||||
'beforeafter'
|
||||
)
|
||||
})
|
||||
|
||||
test('rich text rejects unsafe link schemes and normalizes rel', ({ assert }) => {
|
||||
assert.equal(
|
||||
sanitizeRichText('<a href="javascript:alert(1)" target="_blank" rel="me nofollow">x</a>'),
|
||||
'<a target="_blank" rel="noopener noreferrer">x</a>'
|
||||
)
|
||||
assert.equal(
|
||||
sanitizeRichText('<a href="https://example.com" target="_top" rel="me nofollow">x</a>'),
|
||||
'<a href="https://example.com" rel="nofollow">x</a>'
|
||||
)
|
||||
})
|
||||
|
||||
test('url fields allow relative and https urls, reject script schemes', ({ assert }) => {
|
||||
assert.equal(sanitizeUrl('/en/about'), '/en/about')
|
||||
assert.equal(sanitizeUrl('https://example.com/?a=1&b=2'), 'https://example.com/?a=1&b=2')
|
||||
assert.equal(sanitizeUrl('mailto:hi@example.com'), 'mailto:hi@example.com')
|
||||
assert.equal(sanitizeUrl('javascript:alert(1)'), '')
|
||||
assert.equal(sanitizeUrl(' jav\tascript:alert(1)'), '')
|
||||
})
|
||||
|
||||
test('payload pass applies the url policy to known url keys only', ({ assert }) => {
|
||||
assert.deepEqual(
|
||||
sanitizePayload({
|
||||
title: '<b>Hello</b>',
|
||||
externalUrl: 'javascript:alert(1)',
|
||||
items: [{ url: 'https://example.com/?a=1&b=2' }],
|
||||
}),
|
||||
{
|
||||
title: '<b>Hello</b>',
|
||||
externalUrl: '',
|
||||
items: [{ url: 'https://example.com/?a=1&b=2' }],
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import { test } from '@japa/runner'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
|
||||
import { createAdminWithToken } from '#tests/utils/auth'
|
||||
import { contentComponent, pageInput } from '#tests/utils/web_manager'
|
||||
|
||||
const ALL_PERMISSIONS = [
|
||||
'web_manager.view',
|
||||
'web_manager.create',
|
||||
'web_manager.update',
|
||||
'web_manager.publish',
|
||||
'web_manager.delete',
|
||||
'web_manager.settings',
|
||||
]
|
||||
|
||||
/** [method, path, permission the action authorizes against] */
|
||||
const ADMIN_ROUTES = [
|
||||
['get', '/web-manager/pages', 'web_manager.view'],
|
||||
['get', '/web-manager/pages/group-keys', 'web_manager.view'],
|
||||
['post', '/web-manager/pages', 'web_manager.create'],
|
||||
['get', '/web-manager/pages/1', 'web_manager.view'],
|
||||
['put', '/web-manager/pages/1/draft', 'web_manager.update'],
|
||||
['post', '/web-manager/pages/1/publish', 'web_manager.publish'],
|
||||
['post', '/web-manager/pages/1/unpublish', 'web_manager.publish'],
|
||||
['post', '/web-manager/pages/1/preview-token', 'web_manager.view'],
|
||||
['delete', '/web-manager/pages/1', 'web_manager.delete'],
|
||||
['get', '/web-manager/settings', 'web_manager.view'],
|
||||
['put', '/web-manager/settings', 'web_manager.settings'],
|
||||
] as const
|
||||
|
||||
test.group('WebManager admin API auth', (group) => {
|
||||
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
|
||||
|
||||
test('every admin route rejects an unauthenticated request', async ({ client }) => {
|
||||
for (const [method, url] of ADMIN_ROUTES) {
|
||||
const response = await client[method](url)
|
||||
response.assertStatus(401)
|
||||
}
|
||||
})
|
||||
|
||||
test('every action rejects a token that lacks its permission', async ({ client }) => {
|
||||
// holds every permission except the one each action checks
|
||||
for (const [method, url, permission] of ADMIN_ROUTES) {
|
||||
const { token } = await createAdminWithToken(
|
||||
ALL_PERMISSIONS.filter((slug) => slug !== permission)
|
||||
)
|
||||
const response = await client[method](url).bearerToken(token)
|
||||
response.assertStatus(403)
|
||||
}
|
||||
})
|
||||
|
||||
test('a viewer can read but not mutate', async ({ client }) => {
|
||||
const { token } = await createAdminWithToken(['web_manager.view'])
|
||||
|
||||
const list = await client.get('/web-manager/pages').bearerToken(token)
|
||||
list.assertStatus(200)
|
||||
|
||||
const create = await client.post('/web-manager/pages').bearerToken(token).json(pageInput())
|
||||
create.assertStatus(403)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('WebManager admin API', (group) => {
|
||||
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
|
||||
|
||||
test('create → publish → unpublish round trip reports revalidation', async ({
|
||||
assert,
|
||||
client,
|
||||
}) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const created = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.json(pageInput({ components: [contentComponent()] }))
|
||||
created.assertStatus(201)
|
||||
const pageId = created.body().data.page.id
|
||||
assert.equal(created.body().data.page.status, 'draft')
|
||||
|
||||
const published = await client.post(`/web-manager/pages/${pageId}/publish`).bearerToken(token)
|
||||
published.assertStatus(200)
|
||||
assert.equal(published.body().data.page.status, 'published')
|
||||
assert.equal(published.body().meta.revalidation.status, 'disabled')
|
||||
assert.isNotEmpty(published.body().meta.revalidation.tags)
|
||||
|
||||
const unpublished = await client
|
||||
.post(`/web-manager/pages/${pageId}/unpublish`)
|
||||
.bearerToken(token)
|
||||
unpublished.assertStatus(200)
|
||||
assert.equal(unpublished.body().data.page.status, 'unpublished')
|
||||
|
||||
const again = await client.post(`/web-manager/pages/${pageId}/unpublish`).bearerToken(token)
|
||||
again.assertStatus(409)
|
||||
assert.equal(again.body().code, 'E_PAGE_NOT_PUBLISHED')
|
||||
})
|
||||
|
||||
test('page list filters by status and search', async ({ assert, client }) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
const service = new WebManagerService()
|
||||
const { page: draft } = await service.createDraft(pageInput({ title: 'Needle draft' }))
|
||||
const { page: live } = await service.createDraft(pageInput({ title: 'Other page' }))
|
||||
await service.publish(live.id)
|
||||
|
||||
const drafts = await client
|
||||
.get('/web-manager/pages')
|
||||
.qs({ status: 'draft', search: 'needle' })
|
||||
.bearerToken(token)
|
||||
drafts.assertStatus(200)
|
||||
assert.deepEqual(
|
||||
drafts.body().data.map((row: { id: number }) => row.id),
|
||||
[draft.id]
|
||||
)
|
||||
})
|
||||
|
||||
test('validation rejects a malformed path with 422', async ({ client }) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const response = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.json(pageInput({ path: 'About/Us' }))
|
||||
response.assertStatus(422)
|
||||
})
|
||||
|
||||
test('an unknown block type is rejected with 422', async ({ assert, client }) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const response = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.json(pageInput({ components: [{ type: 'carousel', data: {} }] }))
|
||||
response.assertStatus(422)
|
||||
assert.equal(response.body().code, 'E_INVALID_PAGE_PAYLOAD')
|
||||
})
|
||||
|
||||
test('a multipart payload field is parsed as JSON', async ({ assert, client }) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const response = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.field('payload', JSON.stringify(pageInput({ components: [contentComponent()] })))
|
||||
response.assertStatus(201)
|
||||
assert.equal(response.body().data.components.length, 1)
|
||||
})
|
||||
|
||||
test('empty strings survive the multipart payload but not a JSON body', async ({
|
||||
assert,
|
||||
client,
|
||||
}) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
const input = pageInput({
|
||||
components: [
|
||||
{
|
||||
type: 'content',
|
||||
data: { title: 'Section', subtitle: '', columns: [] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// The JSON bodyparser converts '' to null, so the strict block validator
|
||||
// rejects it — this is why the admin sends the page as one JSON string.
|
||||
const asJson = await client.post('/web-manager/pages').bearerToken(token).json(input)
|
||||
asJson.assertStatus(422)
|
||||
assert.equal(asJson.body().code, 'E_INVALID_PAGE_PAYLOAD')
|
||||
|
||||
const asPayload = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.field('payload', JSON.stringify(input))
|
||||
asPayload.assertStatus(201)
|
||||
})
|
||||
|
||||
test('a malformed multipart payload is rejected with 422', async ({ assert, client }) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const response = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.field('payload', 'not-json')
|
||||
response.assertStatus(422)
|
||||
assert.equal(response.body().code, 'E_INVALID_PAGE_PAYLOAD')
|
||||
})
|
||||
|
||||
test('a media value referencing a missing upload is rejected with 422', async ({
|
||||
assert,
|
||||
client,
|
||||
}) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
|
||||
const input = pageInput({
|
||||
hero: { type: 'middle', ctaItems: [], media: { name: 'x.png', fileKey: 'file-0' } },
|
||||
})
|
||||
const response = await client
|
||||
.post('/web-manager/pages')
|
||||
.bearerToken(token)
|
||||
.field('payload', JSON.stringify(input))
|
||||
response.assertStatus(422)
|
||||
assert.equal(response.body().code, 'E_PAGE_MEDIA_FILE_MISSING')
|
||||
})
|
||||
|
||||
test('settings upsert requires its own permission and reports revalidation', async ({
|
||||
assert,
|
||||
client,
|
||||
}) => {
|
||||
const settings = {
|
||||
locale: 'en',
|
||||
header: { menus: [], ctaItems: [] },
|
||||
footer: { copyright: '© Spec', menus: [] },
|
||||
legal: { privacyPath: '/privacy', termsPath: '/terms' },
|
||||
}
|
||||
|
||||
const { token: viewer } = await createAdminWithToken(['web_manager.view'])
|
||||
const denied = await client.put('/web-manager/settings').bearerToken(viewer).json(settings)
|
||||
denied.assertStatus(403)
|
||||
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
const saved = await client.put('/web-manager/settings').bearerToken(token).json(settings)
|
||||
saved.assertStatus(200)
|
||||
assert.equal(saved.body().meta.revalidation.status, 'disabled')
|
||||
assert.deepEqual(saved.body().meta.revalidation.tags, ['web-manager:settings:en'])
|
||||
})
|
||||
})
|
||||
|
||||
test.group('WebManager public API', (group) => {
|
||||
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
|
||||
|
||||
test('resolve serves published pages only', async ({ assert, client }) => {
|
||||
const service = new WebManagerService()
|
||||
const { page } = await service.createDraft(pageInput({ title: 'Public page' }))
|
||||
|
||||
const asDraft = await client
|
||||
.get('/web-manager/public/pages/resolve')
|
||||
.qs({ locale: 'en', path: page.path })
|
||||
asDraft.assertStatus(404)
|
||||
|
||||
await service.publish(page.id)
|
||||
|
||||
const asPublished = await client
|
||||
.get('/web-manager/public/pages/resolve')
|
||||
.qs({ locale: 'en', path: page.path })
|
||||
asPublished.assertStatus(200)
|
||||
assert.equal(asPublished.body().page.title, 'Public page')
|
||||
assert.equal(asPublished.body().page.status, 'published')
|
||||
assert.isArray(asPublished.body().breadcrumbs)
|
||||
assert.isArray(asPublished.body().languageAlternatives)
|
||||
})
|
||||
|
||||
test('preview serves a draft through a signed token and rejects tampering', async ({
|
||||
assert,
|
||||
client,
|
||||
}) => {
|
||||
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
|
||||
const service = new WebManagerService()
|
||||
const { page } = await service.createDraft(pageInput({ title: 'Draft preview' }))
|
||||
|
||||
const minted = await client
|
||||
.post(`/web-manager/pages/${page.id}/preview-token`)
|
||||
.bearerToken(token)
|
||||
.json({})
|
||||
minted.assertStatus(201)
|
||||
const previewToken: string = minted.body().data.token
|
||||
|
||||
const preview = await client
|
||||
.get('/web-manager/public/pages/preview')
|
||||
.qs({ token: previewToken })
|
||||
preview.assertStatus(200)
|
||||
assert.equal(preview.body().page.title, 'Draft preview')
|
||||
assert.equal(preview.body().page.status, 'draft')
|
||||
|
||||
const tampered = await client
|
||||
.get('/web-manager/public/pages/preview')
|
||||
.qs({ token: `${previewToken}x` })
|
||||
tampered.assertStatus(404)
|
||||
})
|
||||
|
||||
test('sitemap lists published root pages with pagination', async ({ assert, client }) => {
|
||||
const service = new WebManagerService()
|
||||
const { page } = await service.createDraft(pageInput())
|
||||
await service.publish(page.id)
|
||||
await service.createDraft(pageInput()) // stays draft, must not appear
|
||||
|
||||
const response = await client.get('/web-manager/public/pages/sitemap')
|
||||
response.assertStatus(200)
|
||||
const items = response.body().items as Array<{ id: number }>
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.id),
|
||||
[page.id]
|
||||
)
|
||||
assert.properties(response.body().pagination, ['page', 'perPage', 'total', 'lastPage'])
|
||||
})
|
||||
|
||||
test('blog listing returns published blog items, newest first', async ({ assert, client }) => {
|
||||
const service = new WebManagerService()
|
||||
const { page: older } = await service.createDraft(
|
||||
pageInput({ pageType: 'blog-item', title: 'Older post' })
|
||||
)
|
||||
await service.publish(older.id)
|
||||
const { page: newer } = await service.createDraft(
|
||||
pageInput({ pageType: 'blog-item', title: 'Newer post' })
|
||||
)
|
||||
await service.publish(newer.id)
|
||||
const { page: landing } = await service.createDraft(pageInput({ title: 'Not a post' }))
|
||||
await service.publish(landing.id)
|
||||
|
||||
const response = await client.get('/web-manager/public/blog/pages').qs({ locale: 'en' })
|
||||
response.assertStatus(200)
|
||||
const titles = (response.body().items as Array<{ title: string }>).map((item) => item.title)
|
||||
assert.deepEqual(titles, ['Newer post', 'Older post'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from '@japa/runner'
|
||||
import PreviewTokenService from '#base_modules/WebManager/services/preview_token_service'
|
||||
|
||||
test.group('Web Manager preview tokens', () => {
|
||||
test('creates a verifiable token and rejects tampering', ({ assert }) => {
|
||||
const service = new PreviewTokenService()
|
||||
const { token } = service.create(42, 900)
|
||||
|
||||
assert.equal(service.verify(token)?.pageId, 42)
|
||||
assert.isNull(service.verify(`${token}tampered`))
|
||||
})
|
||||
|
||||
test('rejects expired tokens', ({ assert }) => {
|
||||
const service = new PreviewTokenService()
|
||||
const { token } = service.create(42, -1)
|
||||
|
||||
assert.isNull(service.verify(token))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from '@japa/runner'
|
||||
import PublicContentService from '#base_modules/WebManager/services/public_content_service'
|
||||
|
||||
test.group('Web Manager public content', () => {
|
||||
test('keeps safe rich text and removes executable markup', async ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
const content = await service.prepare(
|
||||
{
|
||||
content:
|
||||
'<p class="lead" onclick="alert(1)">Hello <strong>world</strong></p><script>alert(1)</script><svg><a onload="alert(1)"></a></svg>',
|
||||
},
|
||||
new Map()
|
||||
)
|
||||
|
||||
assert.deepEqual(content, {
|
||||
content: '<p class="lead">Hello <strong>world</strong></p>',
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects encoded unsafe links and resolves internal page links', async ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
const content = await service.prepare(
|
||||
{
|
||||
html: '<a href="javascript:alert(1)" target="_blank">Unsafe</a>',
|
||||
ctaItems: [{ linkType: 'internal', pageId: 42, label: 'About' }],
|
||||
externalUrl: 'javascript:alert(1)',
|
||||
},
|
||||
new Map([['42', '/en/about']])
|
||||
)
|
||||
|
||||
assert.deepEqual(content, {
|
||||
html: '<a target="_blank" rel="noopener noreferrer">Unsafe</a>',
|
||||
ctaItems: [{ linkType: 'internal', pageId: 42, label: 'About', href: '/en/about' }],
|
||||
externalUrl: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('survives out-of-range numeric character references', async ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
const content = await service.prepare({ html: '<a href="�">Link</a>' }, new Map())
|
||||
|
||||
assert.deepEqual(content, { html: '<a href="�">Link</a>' })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The link map is fetched by id, so anything this collector misses renders as an empty href
|
||||
* on the public site.
|
||||
*/
|
||||
test.group('Web Manager internal link collection', () => {
|
||||
test('finds internal page ids at any depth and normalizes them to strings', ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
|
||||
const ids = service.collectInternalPageIds({
|
||||
menus: [
|
||||
{
|
||||
items: [
|
||||
{ linkType: 'internal', pageId: 13 },
|
||||
{ linkType: 'external', url: '/x' },
|
||||
],
|
||||
},
|
||||
{ items: [{ linkType: 'internal', pageId: '16' }] },
|
||||
],
|
||||
nested: { deep: { cta: { linkType: 'internal', pageId: 7 } } },
|
||||
})
|
||||
|
||||
assert.deepEqual([...ids].sort(), ['13', '16', '7'])
|
||||
})
|
||||
|
||||
test('ignores links that are not internal and empty content', ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
|
||||
assert.deepEqual([...service.collectInternalPageIds({ linkType: 'external', pageId: 5 })], [])
|
||||
assert.deepEqual([...service.collectInternalPageIds(null)], [])
|
||||
assert.deepEqual([...service.collectInternalPageIds({ title: 'No links here' })], [])
|
||||
})
|
||||
|
||||
test('accumulates across several content sections into one set', ({ assert }) => {
|
||||
const service = new PublicContentService()
|
||||
const ids = new Set<string>()
|
||||
|
||||
service.collectInternalPageIds({ cta: { linkType: 'internal', pageId: 3 } }, ids)
|
||||
service.collectInternalPageIds({ cta: { linkType: 'internal', pageId: 3 } }, ids)
|
||||
service.collectInternalPageIds({ cta: { linkType: 'internal', pageId: 4 } }, ids)
|
||||
|
||||
assert.deepEqual([...ids].sort(), ['3', '4'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
import { test } from '@japa/runner'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
|
||||
import WebPage from '#base_modules/WebManager/model/web_page'
|
||||
import WebComponent from '#base_modules/WebManager/model/web_component'
|
||||
import { WEB_MANAGER_CACHE_TAG } from '#base_modules/WebManager/services/web_revalidation_service'
|
||||
import {
|
||||
PageNotPublishedException,
|
||||
PageRouteConflictException,
|
||||
} from '#exceptions/web_manager_exceptions'
|
||||
import { bannerComponent, contentComponent, pageInput } from '#tests/utils/web_manager'
|
||||
|
||||
/**
|
||||
* Lifecycle specs for the draft → published → unpublished state machine.
|
||||
*
|
||||
* `.env.test` leaves WEB_REVALIDATE_SECRET empty, so revalidation reports
|
||||
* `disabled` but still returns the tags it would have purged — the assertions
|
||||
* below use those tags to tell a full-site purge from a targeted one.
|
||||
*/
|
||||
test.group('WebManager page lifecycle', (group) => {
|
||||
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
|
||||
|
||||
const service = () => new WebManagerService()
|
||||
|
||||
test('createDraft stores a root draft with components in submitted order', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(
|
||||
pageInput({
|
||||
path: 'about-us',
|
||||
components: [contentComponent('first'), bannerComponent('second')],
|
||||
})
|
||||
)
|
||||
|
||||
const row = await WebPage.findOrFail(page.id)
|
||||
assert.equal(row.status, 'draft')
|
||||
assert.isNull(row.draftOfId)
|
||||
assert.isNull(row.publishedAt)
|
||||
|
||||
const components = await WebComponent.query().where('page_id', row.id).orderBy('sort_order')
|
||||
assert.deepEqual(
|
||||
components.map((component) => [component.type, component.sortOrder]),
|
||||
[
|
||||
['content', 0],
|
||||
['banner', 1],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('createDraft normalizes the path before storing it', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput({ path: '/about//team/' }))
|
||||
assert.equal(page.path, 'about/team')
|
||||
})
|
||||
|
||||
test('createDraft rejects a route held by any root row, whatever its status', async ({
|
||||
assert,
|
||||
}) => {
|
||||
await service().createDraft(pageInput({ path: 'taken', groupKey: 'taken-one' }))
|
||||
|
||||
await assert.rejects(
|
||||
() => service().createDraft(pageInput({ path: 'taken', groupKey: 'taken-two' })),
|
||||
PageRouteConflictException
|
||||
)
|
||||
})
|
||||
|
||||
test('saveDraft edits a draft in place and replaces components wholesale', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(
|
||||
pageInput({ components: [contentComponent(), bannerComponent()] })
|
||||
)
|
||||
|
||||
const { page: saved } = await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({
|
||||
path: page.path,
|
||||
groupKey: page.groupKey,
|
||||
title: 'Renamed',
|
||||
components: [bannerComponent('only one now')],
|
||||
})
|
||||
)
|
||||
|
||||
assert.equal(saved.id, page.id)
|
||||
assert.equal(saved.title, 'Renamed')
|
||||
assert.lengthOf(await WebPage.query(), 1, 'no clone was created for an unpublished draft')
|
||||
|
||||
const components = await WebComponent.query().where('page_id', page.id)
|
||||
assert.lengthOf(components, 1)
|
||||
assert.equal(components[0].type, 'banner')
|
||||
assert.equal(components[0].sortOrder, 0)
|
||||
})
|
||||
|
||||
test('saveDraft on a published page creates one clone and reuses it', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput({ components: [contentComponent()] }))
|
||||
await service().publish(page.id)
|
||||
|
||||
const input = pageInput({
|
||||
path: page.path,
|
||||
groupKey: page.groupKey,
|
||||
title: 'Edited copy',
|
||||
components: [bannerComponent()],
|
||||
})
|
||||
const { page: firstClone } = await service().saveDraft(page.id, input)
|
||||
|
||||
assert.notEqual(firstClone.id, page.id)
|
||||
assert.equal(firstClone.draftOfId, page.id)
|
||||
assert.equal(firstClone.status, 'draft')
|
||||
|
||||
const original = await WebPage.findOrFail(page.id)
|
||||
assert.equal(original.status, 'published')
|
||||
assert.equal(original.title, 'Spec page')
|
||||
|
||||
const { page: secondClone } = await service().saveDraft(page.id, input)
|
||||
assert.equal(secondClone.id, firstClone.id)
|
||||
|
||||
const clones = await WebPage.query().where('draft_of_id', page.id)
|
||||
assert.lengthOf(clones, 1)
|
||||
})
|
||||
|
||||
test('publish flips a root draft live and purges the whole site', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput())
|
||||
|
||||
const result = await service().publish(page.id)
|
||||
|
||||
assert.equal(result.page.page.status, 'published')
|
||||
assert.isNotNull(result.page.page.publishedAt)
|
||||
assert.equal(result.revalidation.status, 'disabled')
|
||||
assert.deepEqual(result.revalidation.tags, [WEB_MANAGER_CACHE_TAG])
|
||||
})
|
||||
|
||||
test('publish merges a pending clone; a content-only change purges only page tags', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { page } = await service().createDraft(pageInput({ components: [contentComponent()] }))
|
||||
await service().publish(page.id)
|
||||
|
||||
const { page: clone } = await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({
|
||||
path: page.path,
|
||||
groupKey: page.groupKey,
|
||||
title: page.title,
|
||||
components: [bannerComponent('new content')],
|
||||
})
|
||||
)
|
||||
|
||||
const result = await service().publish(page.id)
|
||||
|
||||
assert.notInclude(result.revalidation.tags, WEB_MANAGER_CACHE_TAG)
|
||||
assert.include(result.revalidation.tags, `${WEB_MANAGER_CACHE_TAG}:page:en:${page.path}`)
|
||||
|
||||
assert.isNull(await WebPage.find(clone.id))
|
||||
const components = await WebComponent.query().where('page_id', page.id)
|
||||
assert.lengthOf(components, 1)
|
||||
assert.equal(components[0].type, 'banner')
|
||||
})
|
||||
|
||||
test('publish treats a changed title as structural and purges the whole site', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { page } = await service().createDraft(pageInput())
|
||||
await service().publish(page.id)
|
||||
|
||||
await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({ path: page.path, groupKey: page.groupKey, title: 'New title' })
|
||||
)
|
||||
const result = await service().publish(page.id)
|
||||
|
||||
assert.deepEqual(result.revalidation.tags, [WEB_MANAGER_CACHE_TAG])
|
||||
assert.equal(result.page.page.title, 'New title')
|
||||
})
|
||||
|
||||
test('publishing an already-published page with no clone only re-purges its cache', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { page } = await service().createDraft(pageInput())
|
||||
const first = await service().publish(page.id)
|
||||
|
||||
const again = await service().publish(page.id)
|
||||
|
||||
assert.equal(again.page.page.publishedAt, first.page.page.publishedAt)
|
||||
assert.notInclude(again.revalidation.tags, WEB_MANAGER_CACHE_TAG)
|
||||
assert.include(again.revalidation.tags, `${WEB_MANAGER_CACHE_TAG}:page:en:${page.path}`)
|
||||
})
|
||||
|
||||
test('publish refuses when another published page holds the address', async ({ assert }) => {
|
||||
// Two root rows on the same route can only exist when one is not published
|
||||
// (assertNewRouteAvailable blocks the service path), so build them directly.
|
||||
await WebPage.create({
|
||||
status: 'published',
|
||||
draftOfId: null,
|
||||
locale: 'en',
|
||||
path: 'clash',
|
||||
groupKey: 'clash-live',
|
||||
pageType: 'landing',
|
||||
title: 'Live page',
|
||||
hero: null,
|
||||
seoTitle: '',
|
||||
seoDescription: '',
|
||||
})
|
||||
const contender = await WebPage.create({
|
||||
status: 'draft',
|
||||
draftOfId: null,
|
||||
locale: 'en',
|
||||
path: 'clash',
|
||||
groupKey: 'clash-draft',
|
||||
pageType: 'landing',
|
||||
title: 'Contender',
|
||||
hero: null,
|
||||
seoTitle: '',
|
||||
seoDescription: '',
|
||||
})
|
||||
|
||||
await assert.rejects(() => service().publish(contender.id), PageRouteConflictException)
|
||||
})
|
||||
|
||||
test('unpublish takes the page down but keeps content, date, and address', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput({ components: [contentComponent()] }))
|
||||
const published = await service().publish(page.id)
|
||||
|
||||
const result = await service().unpublish(page.id)
|
||||
|
||||
assert.equal(result.page.page.status, 'unpublished')
|
||||
assert.equal(result.page.page.publishedAt, published.page.page.publishedAt)
|
||||
assert.deepEqual(result.revalidation.tags, [WEB_MANAGER_CACHE_TAG])
|
||||
assert.lengthOf(await WebComponent.query().where('page_id', page.id), 1)
|
||||
|
||||
// the address stays reserved for the unpublished page
|
||||
await assert.rejects(
|
||||
() => service().createDraft(pageInput({ path: page.path, groupKey: 'squatter' })),
|
||||
PageRouteConflictException
|
||||
)
|
||||
})
|
||||
|
||||
test('unpublish rejects anything that is not currently published', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput())
|
||||
await assert.rejects(() => service().unpublish(page.id), PageNotPublishedException)
|
||||
})
|
||||
|
||||
test('an unpublished page is edited in place and can be republished', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput())
|
||||
await service().publish(page.id)
|
||||
await service().unpublish(page.id)
|
||||
|
||||
const { page: edited } = await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({ path: page.path, groupKey: page.groupKey, title: 'Back soon' })
|
||||
)
|
||||
assert.equal(edited.id, page.id, 'no clone for a page that is not serving')
|
||||
|
||||
const republished = await service().publish(page.id)
|
||||
assert.equal(republished.page.page.status, 'published')
|
||||
assert.deepEqual(republished.revalidation.tags, [WEB_MANAGER_CACHE_TAG])
|
||||
})
|
||||
|
||||
test('discarding a clone leaves the published original untouched', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput({ components: [contentComponent()] }))
|
||||
await service().publish(page.id)
|
||||
const { page: clone } = await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({ path: page.path, groupKey: page.groupKey, title: 'Scrapped edit' })
|
||||
)
|
||||
|
||||
await service().discard(clone.id)
|
||||
|
||||
assert.isNull(await WebPage.find(clone.id))
|
||||
const original = await WebPage.findOrFail(page.id)
|
||||
assert.equal(original.status, 'published')
|
||||
assert.lengthOf(await WebComponent.query().where('page_id', page.id), 1)
|
||||
})
|
||||
|
||||
test('deleting a published page cascades its clone and components', async ({ assert }) => {
|
||||
const { page } = await service().createDraft(pageInput({ components: [contentComponent()] }))
|
||||
await service().publish(page.id)
|
||||
const { page: clone } = await service().saveDraft(
|
||||
page.id,
|
||||
pageInput({ path: page.path, groupKey: page.groupKey, title: 'Open draft' })
|
||||
)
|
||||
|
||||
await service().discard(page.id)
|
||||
|
||||
assert.isNull(await WebPage.find(page.id))
|
||||
assert.isNull(await WebPage.find(clone.id))
|
||||
assert.lengthOf(await WebComponent.query().whereIn('page_id', [page.id, clone.id]), 0)
|
||||
})
|
||||
|
||||
test('resolvePublicPage serves published pages only and normalizes the path', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { page } = await service().createDraft(pageInput({ title: 'Public page' }))
|
||||
assert.isNull(await service().resolvePublicPage('en', page.path))
|
||||
|
||||
await service().publish(page.id)
|
||||
|
||||
const resolved = await service().resolvePublicPage('en', `/${page.path}/`)
|
||||
assert.isNotNull(resolved)
|
||||
assert.equal(resolved!.page.title, 'Public page')
|
||||
assert.equal(resolved!.page.seo.title, 'Public page', 'seo title falls back to the page title')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import { test } from '@japa/runner'
|
||||
import WebRevalidationService, {
|
||||
blogCacheTag,
|
||||
flushPendingRevalidations,
|
||||
pageCacheTag,
|
||||
REVALIDATION_MAX_ATTEMPTS,
|
||||
settingsCacheTag,
|
||||
WEB_MANAGER_CACHE_TAG,
|
||||
type RevalidationResult,
|
||||
} from '#base_modules/WebManager/services/web_revalidation_service'
|
||||
|
||||
/** Captures the tags instead of posting them to the web app. */
|
||||
class RecordingRevalidationService extends WebRevalidationService {
|
||||
readonly purged: string[][] = []
|
||||
|
||||
override async revalidate(tags: string[]): Promise<RevalidationResult> {
|
||||
this.purged.push(tags)
|
||||
return { status: 'purged', tags }
|
||||
}
|
||||
|
||||
get lastPurge() {
|
||||
return this.purged.at(-1) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercises the delivery path itself: counts attempts and fails the first `failures` of
|
||||
* them, with the backoff collapsed so the retries do not slow the suite down.
|
||||
*/
|
||||
class FlakyRevalidationService extends WebRevalidationService {
|
||||
attempts = 0
|
||||
|
||||
constructor(private failures: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
protected override resolveTarget() {
|
||||
return { webAppUrl: 'http://web.test', secret: 'secret' }
|
||||
}
|
||||
|
||||
protected override async attempt() {
|
||||
this.attempts++
|
||||
return this.attempts <= this.failures ? 'connection refused' : null
|
||||
}
|
||||
|
||||
protected override backoffFor() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
test.group('Web Manager revalidation tags', () => {
|
||||
/**
|
||||
* These literals are duplicated in `apps/web/web-runtime/api/cache-tags.ts`; a change
|
||||
* here without a matching change there silently stops purging cached content.
|
||||
*/
|
||||
test('builds the tag names the web app labels its fetches with', ({ assert }) => {
|
||||
assert.equal(pageCacheTag('en', 'pricing'), 'web-manager:page:en:pricing')
|
||||
assert.equal(settingsCacheTag('uk'), 'web-manager:settings:uk')
|
||||
assert.equal(blogCacheTag('de'), 'web-manager:blog:de')
|
||||
})
|
||||
|
||||
test('gives the home page a tag that cannot collide with a real path', ({ assert }) => {
|
||||
assert.equal(pageCacheTag('en', ''), 'web-manager:page:en:/')
|
||||
assert.notEqual(pageCacheTag('en', ''), pageCacheTag('en', 'index'))
|
||||
})
|
||||
|
||||
test('purges the page and its sibling translations', async ({ assert }) => {
|
||||
const service = new RecordingRevalidationService()
|
||||
|
||||
await service.revalidatePage({
|
||||
locale: 'en',
|
||||
path: 'pricing',
|
||||
pageType: 'landing',
|
||||
translations: [
|
||||
{ locale: 'uk', path: 'tsiny' },
|
||||
{ locale: 'de', path: 'preise' },
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(service.lastPurge, [
|
||||
'web-manager:page:en:pricing',
|
||||
'web-manager:page:uk:tsiny',
|
||||
'web-manager:page:de:preise',
|
||||
])
|
||||
assert.notInclude(service.lastPurge, WEB_MANAGER_CACHE_TAG)
|
||||
})
|
||||
|
||||
test('purges the blog listing only when a post changed', async ({ assert }) => {
|
||||
const service = new RecordingRevalidationService()
|
||||
const target = { locale: 'en', path: 'blog/hello', translations: [] }
|
||||
|
||||
await service.revalidatePage({ ...target, pageType: 'blog-item' })
|
||||
assert.include(service.lastPurge, 'web-manager:blog:en')
|
||||
|
||||
await service.revalidatePage({ ...target, pageType: 'landing' })
|
||||
assert.notInclude(service.lastPurge, 'web-manager:blog:en')
|
||||
})
|
||||
|
||||
test('deduplicates when a translation repeats the page itself', async ({ assert }) => {
|
||||
const service = new RecordingRevalidationService()
|
||||
|
||||
await service.revalidatePage({
|
||||
locale: 'en',
|
||||
path: 'pricing',
|
||||
pageType: 'landing',
|
||||
translations: [{ locale: 'en', path: 'pricing' }],
|
||||
})
|
||||
|
||||
assert.deepEqual(service.lastPurge, ['web-manager:page:en:pricing'])
|
||||
})
|
||||
|
||||
test('settings purge is scoped to its locale', async ({ assert }) => {
|
||||
const service = new RecordingRevalidationService()
|
||||
|
||||
await service.revalidateSettings('uk')
|
||||
|
||||
assert.deepEqual(service.lastPurge, ['web-manager:settings:uk'])
|
||||
})
|
||||
|
||||
test('structural changes still purge everything', async ({ assert }) => {
|
||||
const service = new RecordingRevalidationService()
|
||||
|
||||
await service.revalidatePublicContent()
|
||||
|
||||
assert.deepEqual(service.lastPurge, [WEB_MANAGER_CACHE_TAG])
|
||||
})
|
||||
})
|
||||
|
||||
test.group('Web Manager revalidation delivery', (group) => {
|
||||
group.each.teardown(() => flushPendingRevalidations())
|
||||
|
||||
test('reports a purge the web app confirmed', async ({ assert }) => {
|
||||
const service = new FlakyRevalidationService(0)
|
||||
|
||||
const result = await service.revalidate([WEB_MANAGER_CACHE_TAG])
|
||||
|
||||
assert.equal(result.status, 'purged')
|
||||
assert.equal(service.attempts, 1)
|
||||
})
|
||||
|
||||
test('does not keep the caller waiting when the first attempt fails', async ({ assert }) => {
|
||||
const service = new FlakyRevalidationService(1)
|
||||
|
||||
const result = await service.revalidate([WEB_MANAGER_CACHE_TAG])
|
||||
|
||||
assert.equal(result.status, 'retrying')
|
||||
assert.equal(service.attempts, 1, 'retries must not run on the request path')
|
||||
|
||||
await flushPendingRevalidations()
|
||||
assert.equal(service.attempts, 2, 'the background retry recovers the purge')
|
||||
})
|
||||
|
||||
test('gives up after the attempt budget', async ({ assert }) => {
|
||||
const service = new FlakyRevalidationService(Number.POSITIVE_INFINITY)
|
||||
|
||||
await service.revalidate([WEB_MANAGER_CACHE_TAG])
|
||||
await flushPendingRevalidations()
|
||||
|
||||
assert.equal(service.attempts, REVALIDATION_MAX_ATTEMPTS)
|
||||
})
|
||||
|
||||
test('the last retry still counts', async ({ assert }) => {
|
||||
const service = new FlakyRevalidationService(REVALIDATION_MAX_ATTEMPTS - 1)
|
||||
|
||||
await service.revalidate([WEB_MANAGER_CACHE_TAG])
|
||||
await flushPendingRevalidations()
|
||||
|
||||
assert.equal(service.attempts, REVALIDATION_MAX_ATTEMPTS)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user