@nextsparkjs/theme-blog 0.1.0-beta.19 → 0.1.0-beta.191
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/authors/[username]/route.ts +5 -2
- package/api/authors/docs.md +135 -0
- package/api/authors/presets.ts +45 -0
- package/api/authors/route.ts +4 -1
- package/api/posts/public/docs.md +124 -0
- package/api/posts/public/presets.ts +65 -0
- package/api/posts/public/route.ts +4 -1
- package/components/ExportPostsButton.tsx +2 -1
- package/components/ImportPostsDialog.tsx +2 -1
- package/components/editor/FeaturedImageUpload.tsx +2 -1
- package/components/editor/WysiwygEditor.tsx +56 -7
- package/components/public/AuthorBio.tsx +3 -2
- package/components/public/AuthorCard.tsx +3 -2
- package/components/public/BlogFooter.tsx +2 -1
- package/components/public/PostCard.tsx +8 -7
- package/components/public/RelatedPosts.tsx +2 -1
- package/config/app.config.ts +8 -15
- package/config/billing.config.ts +4 -7
- package/config/dashboard.config.ts +13 -0
- package/config/permissions.config.ts +11 -0
- package/entities/categories/api/docs.md +119 -0
- package/entities/categories/api/presets.ts +67 -0
- package/entities/posts/api/docs.md +174 -0
- package/entities/posts/api/presets.ts +137 -0
- package/lib/sanitize-post-html.ts +52 -0
- package/lib/selectors.ts +2 -3
- package/migrations/090_billing_sample_data.sql +99 -0
- package/migrations/999_theme_sample_data.sql +13 -13
- package/nextsparkjs-theme-blog-0.1.0-beta.137.tgz +0 -0
- package/package.json +13 -5
- package/styles/globals.css +58 -0
- package/templates/(public)/author/[username]/page.tsx +6 -5
- package/templates/(public)/authors/page.tsx +3 -2
- package/templates/(public)/page.tsx +2 -1
- package/templates/(public)/posts/[slug]/page.tsx +7 -5
- package/templates/dashboard/(main)/page.tsx +2 -1
- package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +4 -3
- package/templates/dashboard/(main)/posts/create/page.tsx +2 -1
- package/templates/dashboard/(main)/posts/page.tsx +7 -6
- package/tests/cypress/e2e/README.md +170 -0
- package/tests/cypress/e2e/categories/categories-crud.cy.ts +322 -0
- package/tests/cypress/e2e/categories/categories-crud.md +73 -0
- package/tests/cypress/e2e/posts/posts-crud.cy.ts +460 -0
- package/tests/cypress/e2e/posts/posts-crud.md +115 -0
- package/tests/cypress/e2e/posts/posts-editor.cy.ts +290 -0
- package/tests/cypress/e2e/posts/posts-editor.md +139 -0
- package/tests/cypress/e2e/posts/posts-status-workflow.cy.ts +302 -0
- package/tests/cypress/e2e/posts/posts-status-workflow.md +83 -0
- package/tests/cypress/fixtures/blocks.json +9 -0
- package/tests/cypress/fixtures/entities.json +51 -0
- package/tests/cypress/src/FeaturedImageUpload.js +131 -0
- package/tests/cypress/src/PostEditor.js +386 -0
- package/tests/cypress/src/PostsList.js +350 -0
- package/tests/cypress/src/WysiwygEditor.js +373 -0
- package/tests/cypress/src/components/EntityForm.ts +378 -0
- package/tests/cypress/src/components/EntityList.ts +378 -0
- package/tests/cypress/src/components/PostEditorPOM.ts +447 -0
- package/tests/cypress/src/components/PostsPOM.ts +362 -0
- package/tests/cypress/src/components/index.ts +18 -0
- package/tests/cypress/src/index.js +33 -0
- package/tests/cypress/src/selectors.ts +49 -0
- package/tests/cypress/src/session-helpers.ts +151 -0
- package/tests/cypress/support/e2e.ts +90 -0
- package/tests/cypress.config.ts +154 -0
- package/tests/jest/__mocks__/jose.js +22 -0
- package/tests/jest/__mocks__/next-server.js +56 -0
- package/tests/jest/components/WysiwygEditor.test.tsx +117 -0
- package/tests/jest/jest.config.cjs +148 -0
- package/tests/jest/lib/sanitize-post-html.test.ts +47 -0
- package/tests/jest/setup.ts +170 -0
- package/tests/jest/tsconfig.jest.json +6 -0
- package/tests/tsconfig.json +15 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cypress Configuration for blog Theme
|
|
3
|
+
*
|
|
4
|
+
* This config works in both monorepo and npm mode.
|
|
5
|
+
* Run with: NEXT_PUBLIC_ACTIVE_THEME=default pnpm cy:open
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineConfig } from 'cypress'
|
|
9
|
+
import { fileURLToPath } from 'url'
|
|
10
|
+
import path from 'path'
|
|
11
|
+
import fs from 'fs'
|
|
12
|
+
|
|
13
|
+
// ESM-compatible __dirname
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
15
|
+
const __dirname = path.dirname(__filename)
|
|
16
|
+
|
|
17
|
+
// Paths relative to this config file
|
|
18
|
+
const themeRoot = path.resolve(__dirname, '..')
|
|
19
|
+
const projectRoot = path.resolve(__dirname, '../../../..')
|
|
20
|
+
const narrationsOutputDir = path.join(__dirname, 'cypress/videos/narrations')
|
|
21
|
+
|
|
22
|
+
// Load environment variables
|
|
23
|
+
import dotenv from 'dotenv'
|
|
24
|
+
dotenv.config({ path: path.join(projectRoot, '.env') })
|
|
25
|
+
|
|
26
|
+
// Server port (from .env or default 3000)
|
|
27
|
+
const port = process.env.PORT || 3000
|
|
28
|
+
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
e2e: {
|
|
31
|
+
// Base URL for the application
|
|
32
|
+
baseUrl: `http://localhost:${port}`,
|
|
33
|
+
|
|
34
|
+
// Spec patterns: theme tests only
|
|
35
|
+
specPattern: [
|
|
36
|
+
path.join(__dirname, 'cypress/e2e/**/*.cy.{js,ts}'),
|
|
37
|
+
],
|
|
38
|
+
|
|
39
|
+
// Support file (always theme-local)
|
|
40
|
+
supportFile: path.join(__dirname, 'cypress/support/e2e.ts'),
|
|
41
|
+
|
|
42
|
+
// Fixtures folder (theme-specific)
|
|
43
|
+
fixturesFolder: path.join(__dirname, 'cypress/fixtures'),
|
|
44
|
+
|
|
45
|
+
// Output folders (theme-specific)
|
|
46
|
+
downloadsFolder: path.join(__dirname, 'cypress/downloads'),
|
|
47
|
+
screenshotsFolder: path.join(__dirname, 'cypress/screenshots'),
|
|
48
|
+
videosFolder: path.join(__dirname, 'cypress/videos'),
|
|
49
|
+
|
|
50
|
+
// Viewport settings
|
|
51
|
+
viewportWidth: 1280,
|
|
52
|
+
viewportHeight: 720,
|
|
53
|
+
|
|
54
|
+
// Video and screenshot settings
|
|
55
|
+
video: true,
|
|
56
|
+
screenshotOnRunFailure: true,
|
|
57
|
+
|
|
58
|
+
// Timeouts
|
|
59
|
+
defaultCommandTimeout: 10000,
|
|
60
|
+
requestTimeout: 10000,
|
|
61
|
+
responseTimeout: 10000,
|
|
62
|
+
pageLoadTimeout: 30000,
|
|
63
|
+
|
|
64
|
+
// Browser settings
|
|
65
|
+
chromeWebSecurity: false,
|
|
66
|
+
|
|
67
|
+
// Test isolation
|
|
68
|
+
testIsolation: true,
|
|
69
|
+
|
|
70
|
+
// Retry settings
|
|
71
|
+
retries: {
|
|
72
|
+
runMode: 1,
|
|
73
|
+
openMode: 0,
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// Environment variables
|
|
77
|
+
env: {
|
|
78
|
+
// Theme info
|
|
79
|
+
ACTIVE_THEME: 'blog',
|
|
80
|
+
THEME_PATH: themeRoot,
|
|
81
|
+
|
|
82
|
+
// Test user credentials
|
|
83
|
+
TEST_USER_EMAIL: 'user@example.com',
|
|
84
|
+
TEST_USER_PASSWORD: 'Testing1234',
|
|
85
|
+
|
|
86
|
+
// Feature flags
|
|
87
|
+
ENABLE_ALLURE: true,
|
|
88
|
+
|
|
89
|
+
// Allure reporting
|
|
90
|
+
allureResultsPath: path.join(__dirname, 'cypress/allure-results'),
|
|
91
|
+
|
|
92
|
+
// API settings
|
|
93
|
+
API_URL: `http://localhost:${port}/api`,
|
|
94
|
+
API_BASE_URL: `http://localhost:${port}`,
|
|
95
|
+
|
|
96
|
+
// @cypress/grep - filter specs by tags
|
|
97
|
+
grepFilterSpecs: true,
|
|
98
|
+
grepOmitFiltered: true,
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async setupNodeEvents(on, config) {
|
|
102
|
+
// Allure plugin setup (allure-cypress)
|
|
103
|
+
const { allureCypress } = await import('allure-cypress/reporter')
|
|
104
|
+
allureCypress(on, config, {
|
|
105
|
+
resultsDir: path.join(__dirname, 'cypress/allure-results'),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// @cypress/grep plugin for test filtering by tags
|
|
109
|
+
// v5.x uses named export { plugin } from '@cypress/grep/plugin'
|
|
110
|
+
const { plugin: grepPlugin } = await import('@cypress/grep/plugin')
|
|
111
|
+
grepPlugin(config)
|
|
112
|
+
|
|
113
|
+
// Documentation video tasks
|
|
114
|
+
on('task', {
|
|
115
|
+
/**
|
|
116
|
+
* Save narrations to JSON file for post-processing
|
|
117
|
+
*/
|
|
118
|
+
saveNarrations({ specName, narrations }: { specName: string; narrations: unknown[] }) {
|
|
119
|
+
// Ensure output directory exists
|
|
120
|
+
if (!fs.existsSync(narrationsOutputDir)) {
|
|
121
|
+
fs.mkdirSync(narrationsOutputDir, { recursive: true })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const filename = `${specName}-narrations.json`
|
|
125
|
+
const filepath = path.join(narrationsOutputDir, filename)
|
|
126
|
+
|
|
127
|
+
fs.writeFileSync(filepath, JSON.stringify(narrations, null, 2))
|
|
128
|
+
console.log(`📝 Narrations saved to: ${filepath}`)
|
|
129
|
+
|
|
130
|
+
return null
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Add narration entry (called per narration)
|
|
135
|
+
*/
|
|
136
|
+
addNarration(narration: unknown) {
|
|
137
|
+
// This could be used for real-time streaming to a narration service
|
|
138
|
+
console.log('🎙️ Narration:', narration)
|
|
139
|
+
return null
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
return config
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
// Component testing (future use)
|
|
148
|
+
component: {
|
|
149
|
+
devServer: {
|
|
150
|
+
framework: 'next',
|
|
151
|
+
bundler: 'webpack',
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock for jose package
|
|
3
|
+
* Resolves ES module import issues in Jest tests
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
jwtVerify: jest.fn(),
|
|
8
|
+
SignJWT: jest.fn().mockImplementation(() => ({
|
|
9
|
+
setProtectedHeader: jest.fn().mockReturnThis(),
|
|
10
|
+
setIssuedAt: jest.fn().mockReturnThis(),
|
|
11
|
+
setExpirationTime: jest.fn().mockReturnThis(),
|
|
12
|
+
sign: jest.fn().mockResolvedValue('mock-jwt-token')
|
|
13
|
+
})),
|
|
14
|
+
importJWK: jest.fn(),
|
|
15
|
+
generateSecret: jest.fn(),
|
|
16
|
+
createRemoteJWKSet: jest.fn(),
|
|
17
|
+
errors: {
|
|
18
|
+
JWTExpired: class JWTExpired extends Error {},
|
|
19
|
+
JWTInvalid: class JWTInvalid extends Error {},
|
|
20
|
+
JWTClaimValidationFailed: class JWTClaimValidationFailed extends Error {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock for next/server
|
|
3
|
+
* Provides NextRequest and NextResponse mocks for API testing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class MockNextRequest {
|
|
7
|
+
constructor(url, options = {}) {
|
|
8
|
+
this.url = url
|
|
9
|
+
this.method = options.method || 'GET'
|
|
10
|
+
this.headers = new Map(Object.entries(options.headers || {}))
|
|
11
|
+
this._body = options.body
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async json() {
|
|
15
|
+
if (!this._body) return {}
|
|
16
|
+
try {
|
|
17
|
+
return typeof this._body === 'string' ? JSON.parse(this._body) : this._body
|
|
18
|
+
} catch {
|
|
19
|
+
throw new SyntaxError('Invalid JSON')
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async text() {
|
|
24
|
+
return this._body || ''
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class MockNextResponse {
|
|
29
|
+
constructor(body, options = {}) {
|
|
30
|
+
this.body = body
|
|
31
|
+
this.status = options.status || 200
|
|
32
|
+
this.statusText = options.statusText || 'OK'
|
|
33
|
+
this.headers = new Map(Object.entries(options.headers || {}))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async json() {
|
|
37
|
+
if (!this.body) return {}
|
|
38
|
+
try {
|
|
39
|
+
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body
|
|
40
|
+
} catch {
|
|
41
|
+
throw new SyntaxError('Invalid JSON')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static json(data, options = {}) {
|
|
46
|
+
return new MockNextResponse(data, {
|
|
47
|
+
...options,
|
|
48
|
+
headers: { 'Content-Type': 'application/json', ...options.headers }
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
NextRequest: MockNextRequest,
|
|
55
|
+
NextResponse: MockNextResponse
|
|
56
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { render, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { WysiwygEditor } from '@/themes/blog/components/editor/WysiwygEditor'
|
|
3
|
+
|
|
4
|
+
// What a team member can store as a post body through the API.
|
|
5
|
+
const STORED = [
|
|
6
|
+
'<p>kept <strong>bold</strong></p>',
|
|
7
|
+
'<img src="x" onerror="globalThis.pwned = 1">',
|
|
8
|
+
'<script>globalThis.pwned = 1</script>',
|
|
9
|
+
'<a href="javascript:globalThis.pwned = 1">link</a>',
|
|
10
|
+
].join('')
|
|
11
|
+
|
|
12
|
+
function expectInert(root: Element) {
|
|
13
|
+
expect(root.querySelector('script')).toBeNull()
|
|
14
|
+
expect(root.querySelector('[onerror]')).toBeNull()
|
|
15
|
+
expect(root.querySelector('a[href^="javascript:"]')).toBeNull()
|
|
16
|
+
expect(root.querySelector('strong')?.textContent).toBe('bold')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('WysiwygEditor with stored HTML', () => {
|
|
20
|
+
it('renders formatting but no script in the editable area', () => {
|
|
21
|
+
const { container } = render(<WysiwygEditor value={STORED} onChange={jest.fn()} />)
|
|
22
|
+
expectInert(container.querySelector('[contenteditable]')!)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('drops script that reaches the editable area while editing', () => {
|
|
26
|
+
const onChange = jest.fn()
|
|
27
|
+
const { container, rerender } = render(<WysiwygEditor value="" onChange={onChange} />)
|
|
28
|
+
const editable = container.querySelector('[contenteditable]')!
|
|
29
|
+
editable.innerHTML = STORED
|
|
30
|
+
fireEvent.input(editable)
|
|
31
|
+
const emitted = onChange.mock.calls[onChange.mock.calls.length - 1][0]
|
|
32
|
+
rerender(<WysiwygEditor value={emitted} onChange={onChange} />)
|
|
33
|
+
expectInert(editable)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('drops a handler the sanitiser only reports as a rewritten attribute', () => {
|
|
37
|
+
const onChange = jest.fn()
|
|
38
|
+
const { container, rerender } = render(<WysiwygEditor value="" onChange={onChange} />)
|
|
39
|
+
const editable = container.querySelector('[contenteditable]')!
|
|
40
|
+
editable.innerHTML = '<p><strong>bold</strong></p><img title=" x " onerror="globalThis.pwned = 1" src="bad">'
|
|
41
|
+
fireEvent.input(editable)
|
|
42
|
+
const emitted = onChange.mock.calls[onChange.mock.calls.length - 1][0]
|
|
43
|
+
rerender(<WysiwygEditor value={emitted} onChange={onChange} />)
|
|
44
|
+
expectInert(editable)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('renders formatting but no script in the preview', () => {
|
|
48
|
+
const { container } = render(<WysiwygEditor value={STORED} onChange={jest.fn()} />)
|
|
49
|
+
fireEvent.click(container.querySelector('[data-cy="wysiwyg-preview-toggle"]')!)
|
|
50
|
+
expectInert(container.querySelector('[data-cy="wysiwyg-preview"]')!)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('WysiwygEditor with markup pasted or dropped in', () => {
|
|
55
|
+
const UNSAFE = '<p><strong>bold</strong></p><img title=" x " onerror="globalThis.pwned = 1" src="bad">'
|
|
56
|
+
const transfer = (html: string) => ({ getData: (type: string) => (type === 'text/html' ? html : '') })
|
|
57
|
+
let execCommand: jest.Mock
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
execCommand = jest.fn()
|
|
61
|
+
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
delete (document as unknown as { execCommand?: unknown }).execCommand
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
function expectSanitisedInsertion() {
|
|
69
|
+
expect(execCommand).toHaveBeenCalledTimes(1)
|
|
70
|
+
const [command, , inserted] = execCommand.mock.calls[0]
|
|
71
|
+
expect(command).toBe('insertHTML')
|
|
72
|
+
expect(inserted).toContain('<strong>bold</strong>')
|
|
73
|
+
expect(inserted).not.toContain('onerror')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
it('inserts a paste with a handler sanitised, instead of the browser paste', () => {
|
|
77
|
+
const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
|
|
78
|
+
const proceeded = fireEvent.paste(container.querySelector('[contenteditable]')!, { clipboardData: transfer(UNSAFE) })
|
|
79
|
+
expect(proceeded).toBe(false)
|
|
80
|
+
expectSanitisedInsertion()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('inserts a drop with a handler sanitised, instead of the browser drop', () => {
|
|
84
|
+
const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
|
|
85
|
+
const proceeded = fireEvent.drop(container.querySelector('[contenteditable]')!, { dataTransfer: transfer(UNSAFE) })
|
|
86
|
+
expect(proceeded).toBe(false)
|
|
87
|
+
expectSanitisedInsertion()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('inserts a paste whose script a document parse would move out of the body, sanitised', () => {
|
|
91
|
+
const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
|
|
92
|
+
const proceeded = fireEvent.paste(container.querySelector('[contenteditable]')!, {
|
|
93
|
+
clipboardData: transfer('<script>globalThis.pwned = 1</script><p><strong>bold</strong></p>'),
|
|
94
|
+
})
|
|
95
|
+
expect(proceeded).toBe(false)
|
|
96
|
+
expectSanitisedInsertion()
|
|
97
|
+
expect(execCommand.mock.calls[0][2]).not.toContain('<script')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('leaves a paste with nothing to take out to the browser', () => {
|
|
101
|
+
const { container } = render(<WysiwygEditor value="" onChange={jest.fn()} />)
|
|
102
|
+
const proceeded = fireEvent.paste(container.querySelector('[contenteditable]')!, { clipboardData: transfer('<p>plain <em>text</em></p>') })
|
|
103
|
+
expect(proceeded).toBe(true)
|
|
104
|
+
expect(execCommand).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('WysiwygEditor preview toggle', () => {
|
|
109
|
+
it('keeps the content in the editable area after leaving the preview', () => {
|
|
110
|
+
const { container } = render(<WysiwygEditor value="<p>kept <strong>bold</strong></p>" onChange={jest.fn()} />)
|
|
111
|
+
const toggle = container.querySelector('[data-cy="wysiwyg-preview-toggle"]')!
|
|
112
|
+
fireEvent.click(toggle)
|
|
113
|
+
expect(container.querySelector('[contenteditable]')).toBeNull()
|
|
114
|
+
fireEvent.click(toggle)
|
|
115
|
+
expect(container.querySelector('[contenteditable]')!.innerHTML).toBe('<p>kept <strong>bold</strong></p>')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jest Configuration for Blog Theme
|
|
3
|
+
*
|
|
4
|
+
* This config works in both monorepo and npm mode.
|
|
5
|
+
* Run with: pnpm test:theme
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path')
|
|
9
|
+
const fs = require('fs')
|
|
10
|
+
|
|
11
|
+
// Paths relative to this config file
|
|
12
|
+
const themeTestsRoot = __dirname
|
|
13
|
+
const themeRoot = path.resolve(__dirname, '../..')
|
|
14
|
+
|
|
15
|
+
// In monorepo: themes/blog/tests/jest -> apps/dev (via symlink in contents/)
|
|
16
|
+
// In npm mode: contents/themes/blog/tests/jest -> project root (5 levels up)
|
|
17
|
+
const monorepoRepoRoot = path.resolve(__dirname, '../../../..')
|
|
18
|
+
const npmModeRoot = path.resolve(__dirname, '../../../../..')
|
|
19
|
+
|
|
20
|
+
// Detect if running in npm mode (no packages/core folder) vs monorepo
|
|
21
|
+
const isNpmMode = !fs.existsSync(path.join(monorepoRepoRoot, 'packages/core'))
|
|
22
|
+
|
|
23
|
+
// In monorepo, use apps/dev as rootDir since contents/ symlinks to themes/
|
|
24
|
+
const monorepoAppRoot = path.join(monorepoRepoRoot, 'apps/dev')
|
|
25
|
+
const projectRoot = isNpmMode ? npmModeRoot : monorepoAppRoot
|
|
26
|
+
|
|
27
|
+
// Module name mapper based on mode
|
|
28
|
+
const moduleNameMapper = isNpmMode
|
|
29
|
+
? {
|
|
30
|
+
// NPM mode: explicitly resolve @nextsparkjs/core subpaths to dist directory
|
|
31
|
+
// Jest doesn't respect package.json exports, so we map directly to dist files
|
|
32
|
+
'^@nextsparkjs/core/lib/(.*)$': '<rootDir>/node_modules/@nextsparkjs/core/dist/lib/$1',
|
|
33
|
+
'^@nextsparkjs/core/hooks/(.*)$': '<rootDir>/node_modules/@nextsparkjs/core/dist/hooks/$1',
|
|
34
|
+
'^@nextsparkjs/core/components/(.*)$': '<rootDir>/node_modules/@nextsparkjs/core/dist/components/$1',
|
|
35
|
+
'^@nextsparkjs/core/(.*)$': '<rootDir>/node_modules/@nextsparkjs/core/dist/$1',
|
|
36
|
+
'^@nextsparkjs/core$': '<rootDir>/node_modules/@nextsparkjs/core/dist/index.js',
|
|
37
|
+
'^@/contents/(.*)$': '<rootDir>/contents/$1',
|
|
38
|
+
'^@/entities/(.*)$': '<rootDir>/contents/entities/$1',
|
|
39
|
+
'^@/plugins/(.*)$': '<rootDir>/contents/plugins/$1',
|
|
40
|
+
'^@/themes/(.*)$': '<rootDir>/contents/themes/$1',
|
|
41
|
+
'^@/(.*)$': '<rootDir>/$1',
|
|
42
|
+
// Mocks from theme-local folder
|
|
43
|
+
'next/server': path.join(themeTestsRoot, '__mocks__/next-server.js'),
|
|
44
|
+
'^jose$': path.join(themeTestsRoot, '__mocks__/jose.js'),
|
|
45
|
+
'^jose/(.*)$': path.join(themeTestsRoot, '__mocks__/jose.js'),
|
|
46
|
+
}
|
|
47
|
+
: {
|
|
48
|
+
// Monorepo mode: resolve from packages/core/src (rootDir is apps/dev)
|
|
49
|
+
'^@nextsparkjs/core/(.*)$': '<rootDir>/../../packages/core/src/$1',
|
|
50
|
+
'^@nextsparkjs/core$': '<rootDir>/../../packages/core/src',
|
|
51
|
+
// core's components import the UI package, resolved from source as core's own jest config does
|
|
52
|
+
'^@nextsparkjs/ui$': '<rootDir>/../../packages/ui/src/index.ts',
|
|
53
|
+
'^@/contents/(.*)$': '<rootDir>/contents/$1',
|
|
54
|
+
'^@/entities/(.*)$': '<rootDir>/contents/entities/$1',
|
|
55
|
+
'^@/plugins/(.*)$': '<rootDir>/contents/plugins/$1',
|
|
56
|
+
'^@/themes/(.*)$': '<rootDir>/contents/themes/$1',
|
|
57
|
+
'^@/(.*)$': '<rootDir>/$1',
|
|
58
|
+
// Mocks from core
|
|
59
|
+
'next/server': '<rootDir>/../../packages/core/tests/jest/__mocks__/next-server.js',
|
|
60
|
+
'^jose$': '<rootDir>/../../packages/core/tests/jest/__mocks__/jose.js',
|
|
61
|
+
'^jose/(.*)$': '<rootDir>/../../packages/core/tests/jest/__mocks__/jose.js',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Setup files based on mode
|
|
65
|
+
const setupFilesAfterEnv = isNpmMode
|
|
66
|
+
? [
|
|
67
|
+
// NPM mode: use theme's local setup only (it includes everything needed)
|
|
68
|
+
path.join(themeTestsRoot, 'setup.ts'),
|
|
69
|
+
]
|
|
70
|
+
: [
|
|
71
|
+
// Monorepo mode: use local core setup (rootDir is apps/dev)
|
|
72
|
+
'<rootDir>/../../packages/core/tests/jest/setup.ts',
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
/** @type {import('jest').Config} */
|
|
76
|
+
module.exports = {
|
|
77
|
+
displayName: 'theme-blog',
|
|
78
|
+
rootDir: projectRoot,
|
|
79
|
+
|
|
80
|
+
// Use roots to explicitly set test location (handles symlinks better)
|
|
81
|
+
roots: [themeTestsRoot],
|
|
82
|
+
|
|
83
|
+
// Test file patterns
|
|
84
|
+
testMatch: [
|
|
85
|
+
'**/*.{test,spec}.{js,ts,tsx}',
|
|
86
|
+
],
|
|
87
|
+
testPathIgnorePatterns: [
|
|
88
|
+
'<rootDir>/node_modules/',
|
|
89
|
+
'<rootDir>/.next/',
|
|
90
|
+
],
|
|
91
|
+
|
|
92
|
+
// Preset and environment
|
|
93
|
+
preset: 'ts-jest',
|
|
94
|
+
testEnvironment: 'jsdom',
|
|
95
|
+
|
|
96
|
+
// Module resolution
|
|
97
|
+
moduleNameMapper,
|
|
98
|
+
|
|
99
|
+
// Setup files
|
|
100
|
+
setupFilesAfterEnv,
|
|
101
|
+
|
|
102
|
+
// In npm mode, use project's tsconfig with jsx override
|
|
103
|
+
// In monorepo, use the local tsconfig.jest.json
|
|
104
|
+
transform: {
|
|
105
|
+
'^.+\\.(ts|tsx)$': ['ts-jest', {
|
|
106
|
+
tsconfig: isNpmMode
|
|
107
|
+
? {
|
|
108
|
+
jsx: 'react-jsx',
|
|
109
|
+
esModuleInterop: true,
|
|
110
|
+
allowSyntheticDefaultImports: true,
|
|
111
|
+
module: 'ESNext',
|
|
112
|
+
moduleResolution: 'bundler',
|
|
113
|
+
strict: true,
|
|
114
|
+
baseUrl: projectRoot,
|
|
115
|
+
paths: {
|
|
116
|
+
'@/*': ['./*'],
|
|
117
|
+
'@/contents/*': ['./contents/*'],
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
: path.join(themeTestsRoot, 'tsconfig.jest.json'),
|
|
121
|
+
}],
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// Transform ignore patterns - allow TypeScript from core's jest-setup
|
|
125
|
+
transformIgnorePatterns: [
|
|
126
|
+
'node_modules/(?!(uncrypto|better-auth|@noble|.*jose.*|remark.*|unified.*|@nextsparkjs/core/tests|.*\\.mjs$))',
|
|
127
|
+
'node_modules/\\.pnpm/(?!(.*uncrypto.*|.*better-auth.*|.*@noble.*|.*jose.*|.*remark.*|.*unified.*|@nextsparkjs.*core.*tests|.*\\.mjs$))',
|
|
128
|
+
],
|
|
129
|
+
|
|
130
|
+
// File extensions
|
|
131
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
|
132
|
+
|
|
133
|
+
// Test timeout
|
|
134
|
+
testTimeout: 10000,
|
|
135
|
+
|
|
136
|
+
// Verbose output
|
|
137
|
+
verbose: true,
|
|
138
|
+
|
|
139
|
+
// Force exit after tests complete
|
|
140
|
+
forceExit: true,
|
|
141
|
+
|
|
142
|
+
// Disable watchman for symlink support
|
|
143
|
+
watchman: false,
|
|
144
|
+
|
|
145
|
+
// Coverage output directory
|
|
146
|
+
coverageDirectory: path.join(themeTestsRoot, 'coverage'),
|
|
147
|
+
coverageReporters: ['text', 'lcov', 'html'],
|
|
148
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { postHtmlHasUnsafeMarkup, sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
|
|
2
|
+
|
|
3
|
+
describe('sanitizePostHtml', () => {
|
|
4
|
+
it('keeps formatting and drops what scripts', () => {
|
|
5
|
+
const out = sanitizePostHtml('<p><strong>bold</strong></p><img src="x" onerror="globalThis.pwned = 1"><script>globalThis.pwned = 1</script>')
|
|
6
|
+
expect(out).toContain('<strong>bold</strong>')
|
|
7
|
+
expect(out).not.toMatch(/onerror|<script/)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns an empty string for no input', () => {
|
|
11
|
+
expect(sanitizePostHtml('')).toBe('')
|
|
12
|
+
expect(sanitizePostHtml(null)).toBe('')
|
|
13
|
+
expect(sanitizePostHtml(undefined)).toBe('')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('postHtmlHasUnsafeMarkup', () => {
|
|
18
|
+
it('is true when sanitising removes or alters anything', () => {
|
|
19
|
+
for (const unsafe of [
|
|
20
|
+
'<img src="x" onerror="globalThis.pwned = 1">',
|
|
21
|
+
'<p>x</p><script>globalThis.pwned = 1</script>',
|
|
22
|
+
'<a href="javascript:globalThis.pwned = 1">x</a>',
|
|
23
|
+
'<img title=" x " onerror="globalThis.pwned = 1" src="bad">',
|
|
24
|
+
'<p onclick="globalThis.pwned = 1">x</p>',
|
|
25
|
+
'<a title=" y " href=" javascript:globalThis.pwned = 1">x</a>',
|
|
26
|
+
'<script>globalThis.pwned = 1</script>',
|
|
27
|
+
'<head><base href="https://attacker.test/"></head><p>safe</p>',
|
|
28
|
+
'<template><img src="x" onerror="globalThis.pwned = 1"></template>',
|
|
29
|
+
'<template><template><img src="x" onerror="globalThis.pwned = 1"></template></template>',
|
|
30
|
+
]) {
|
|
31
|
+
expect(postHtmlHasUnsafeMarkup(unsafe)).toBe(true)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('is false for markup the parser only re-nests or re-serialises', () => {
|
|
36
|
+
for (const safe of [
|
|
37
|
+
'<p><ul><li>item</li></ul></p>',
|
|
38
|
+
'<p>plain <em>text</em></p>',
|
|
39
|
+
' <p>leading whitespace</p>',
|
|
40
|
+
'<p>a b<br></p>',
|
|
41
|
+
'<ol><li>one</li><li><b>two</b></li></ol>',
|
|
42
|
+
'<p style="text-align: center">centred</p>',
|
|
43
|
+
]) {
|
|
44
|
+
expect(postHtmlHasUnsafeMarkup(safe)).toBe(false)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
})
|