[web] migrate to SvelteKit 3 #176
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
---
|
||||
|
||||
Finish the SvelteKit 3 API migration with native JSON responses and refresh APIs,
|
||||
retaining response statuses/cache headers, local navigation and editor draft guards.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
---
|
||||
|
||||
Adopt SvelteKit 3-aware navigation and reactivity lint rules, resolve internal
|
||||
navigation without changing resource URLs, and validate asset-editor return
|
||||
destinations with the existing local-redirect policy.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
"@luzzle/web.worker": patch
|
||||
"@luzzle/web.templates": patch
|
||||
"@luzzle/web.jobs": patch
|
||||
---
|
||||
|
||||
Prepare the shared build and test toolchain for SvelteKit 3 while retaining Kit 2
|
||||
and the current application behavior. Align the web, worker and template Svelte
|
||||
compiler/runtime at 5.57.3, preserving native runtime OG compilation and rendering.
|
||||
Keep workflow-spec declarations portable with the updated TypeScript compiler.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
---
|
||||
|
||||
Integrate SvelteKit 3 with the stock web build, native fixtures and runtime YAML
|
||||
configuration. Preserve configured public origins before native CSRF checks,
|
||||
stateless authentication, built-in errors and draft-preserving editor behavior.
|
||||
@@ -34,7 +34,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"@vitest/ui": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-prettier": "5.2.1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"**/*.test.ts"
|
||||
"**/*.test.ts",
|
||||
"**/*.mock.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"],
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"allowJs": true,
|
||||
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
test: {
|
||||
exclude: ['generated/**', 'dist/**', 'build/**', 'node_modules/**'],
|
||||
coverage: {
|
||||
include: ['src/**/*.{js,ts}'],
|
||||
thresholds: {
|
||||
lines: 92,
|
||||
branches: 92,
|
||||
@@ -23,7 +24,6 @@ export default defineConfig({
|
||||
'**/*.d.ts',
|
||||
'**/virtual:*',
|
||||
'**/__x00__*',
|
||||
'**/\x00*',
|
||||
'cypress/**',
|
||||
'test?(s)/**',
|
||||
'test?(-*).?(c|m)[jt]s?(x)',
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@types/mdast": "4.0.4",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"@vitest/ui": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"globals": "catalog:",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
test: {
|
||||
exclude: ['generated/**', 'dist/**', 'build/**', 'node_modules/**'],
|
||||
coverage: {
|
||||
include: ['src/**/*.{js,ts}'],
|
||||
thresholds: {
|
||||
lines: 92,
|
||||
branches: 92,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo" },
|
||||
"exclude": [
|
||||
"**/*.test.ts"
|
||||
]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "./src",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
test: {
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
coverage: {
|
||||
include: ['src/**/*.{js,ts}'],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
branches: 80,
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ node_modules
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/.svelte-kit
|
||||
.svelte-kit/
|
||||
/build
|
||||
/package-lock.json
|
||||
/src/lib/ui/styles/theme.build.css
|
||||
|
||||
@@ -30,7 +30,6 @@ services:
|
||||
- ./static:/m/apps/web/static
|
||||
- ./scripts:/m/apps/web/scripts
|
||||
- ./tsconfig.json:/m/apps/web/tsconfig.json
|
||||
- ./svelte.config.js:/m/apps/web/svelte.config.js
|
||||
- ./vite.config.ts:/m/apps/web/vite.config.ts
|
||||
networks:
|
||||
- luzzle
|
||||
|
||||
@@ -3,6 +3,12 @@ import ts from 'typescript-eslint'
|
||||
import svelte from 'eslint-plugin-svelte'
|
||||
import prettier from 'eslint-config-prettier'
|
||||
import globals from 'globals'
|
||||
import { loadConfig } from '@sveltejs/load-config'
|
||||
|
||||
const loadedConfig = await loadConfig(import.meta.dirname, { traverse: false })
|
||||
if (!loadedConfig) throw new Error('Svelte configuration not found')
|
||||
if ('error' in loadedConfig) throw loadedConfig.error
|
||||
const svelteConfig = loadedConfig.config
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
@@ -33,7 +39,8 @@ export default [
|
||||
files: ['**/*.svelte'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+11
-7
@@ -45,10 +45,11 @@
|
||||
"@luzzle/core": "workspace:*",
|
||||
"@luzzle/web.config": "workspace:*",
|
||||
"@luzzle/web.jobs": "workspace:*",
|
||||
"@sveltejs/adapter-node": "5.3.2",
|
||||
"@sveltejs/enhanced-img": "0.8.1",
|
||||
"@sveltejs/kit": "2.43.0",
|
||||
"@sveltejs/vite-plugin-svelte": "6.2.0",
|
||||
"@sveltejs/adapter-node": "6.0.1",
|
||||
"@sveltejs/enhanced-img": "1.0.0",
|
||||
"@sveltejs/kit": "3.1.1",
|
||||
"@sveltejs/load-config": "0.2.2",
|
||||
"@sveltejs/vite-plugin-svelte": "7.1.2",
|
||||
"@types/eslint": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/node": "catalog:",
|
||||
@@ -56,7 +57,7 @@
|
||||
"codemirror": "6.0.2",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-plugin-svelte": "3.9.3",
|
||||
"eslint-plugin-svelte": "3.23.1",
|
||||
"feedsmith": "2.1.0",
|
||||
"globals": "catalog:",
|
||||
"mime-types": "3.0.2",
|
||||
@@ -68,8 +69,8 @@
|
||||
"remark-lint-no-duplicate-headings": "4.0.1",
|
||||
"remark-preset-lint-consistent": "6.0.1",
|
||||
"remark-preset-lint-recommended": "7.0.1",
|
||||
"svelte": "5.55.9",
|
||||
"svelte-check": "4.3.2",
|
||||
"svelte": "5.57.3",
|
||||
"svelte-check": "4.7.7",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"unplugin-icons": "22.3.0",
|
||||
@@ -90,5 +91,8 @@
|
||||
"kysely": "0.28.17",
|
||||
"micromark": "4.0.2",
|
||||
"openworkflow": "0.11.0"
|
||||
},
|
||||
"imports": {
|
||||
"#lib/*": "./src/lib/*"
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,13 @@ test.each([
|
||||
expect(volumes.some((volume) => volume.startsWith('./demo/config.yaml:'))).toBe(false)
|
||||
})
|
||||
|
||||
test('development mounts the Vite configuration without the retired Svelte config', async () => {
|
||||
const compose = parse(await readFile(path.join(web, 'docker-compose.dev.yml'), 'utf8'))
|
||||
const volumes: string[] = compose.services['luzzle-dev-web'].volumes
|
||||
expect(volumes).toContain('./vite.config.ts:/m/apps/web/vite.config.ts')
|
||||
expect(volumes.some((volume) => volume.startsWith('./svelte.config.js:'))).toBe(false)
|
||||
})
|
||||
|
||||
test('the demo configuration is a regular file in its mounted directory', async () => {
|
||||
const canonical = path.join(web, 'demo/config/config.yaml')
|
||||
expect((await lstat(canonical)).isFile()).toBe(true)
|
||||
|
||||
@@ -2,11 +2,11 @@ import { loadConfig } from '@luzzle/web.templates/config'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createServerEnvironment } from './runtime-environment.js'
|
||||
|
||||
const PATHS = {
|
||||
CONFIG: path.resolve('config.yaml'),
|
||||
LOCAL_BUILD: path.resolve('build'),
|
||||
LOG: path.resolve('data/build.log')
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ class ExplorerManager {
|
||||
const command = this.isDev ? 'pnpm' : 'node'
|
||||
const args = this.isDev
|
||||
? ['run', 'dev', '--', '--host']
|
||||
: [path.join(PATHS.LOCAL_BUILD, 'index.js')]
|
||||
: [fileURLToPath(new URL('./start-server.js', import.meta.url))]
|
||||
|
||||
this.serverProcess = spawn(command, args, spawnOptions)
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ import { promisify } from 'node:util'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
const manager = fileURLToPath(new URL('./manager.js', import.meta.url))
|
||||
const launcher = fileURLToPath(new URL('./start-server.js', import.meta.url))
|
||||
const absoluteEnvironment = {
|
||||
HOST: '127.0.0.1',
|
||||
PROTOCOL_HEADER: 'x-luzzle-public-protocol',
|
||||
HOST_HEADER: 'x-luzzle-public-authority',
|
||||
PORT_HEADER: ''
|
||||
}
|
||||
const cache = fileURLToPath(new URL('../node_modules/.cache/', import.meta.url))
|
||||
let directory: string
|
||||
|
||||
@@ -37,7 +44,17 @@ afterEach(async () => {
|
||||
|
||||
function run(dev = true, environment: NodeJS.ProcessEnv = {}) {
|
||||
const inherited = { ...process.env }
|
||||
for (const key of ['ORIGIN', 'BETTER_AUTH_URL', 'AUTH_URL', 'NEXTAUTH_URL']) delete inherited[key]
|
||||
for (const key of [
|
||||
'HOST',
|
||||
'ORIGIN',
|
||||
'PROTOCOL_HEADER',
|
||||
'HOST_HEADER',
|
||||
'PORT_HEADER',
|
||||
'BETTER_AUTH_URL',
|
||||
'AUTH_URL',
|
||||
'NEXTAUTH_URL'
|
||||
])
|
||||
delete inherited[key]
|
||||
return exec(process.execPath, [manager], {
|
||||
cwd: directory,
|
||||
env: {
|
||||
@@ -73,16 +90,20 @@ test('development startup loads config from the mounted working directory withou
|
||||
expect(stdout).not.toContain('Initiating production build sequence')
|
||||
})
|
||||
|
||||
test('production startup uses build and log files in the same working directory', async () => {
|
||||
test('production delegates to the absolute real launcher with build and log files in the working directory', async () => {
|
||||
await configure()
|
||||
await writeFile(
|
||||
path.join(directory, 'build/index.js'),
|
||||
'console.log("manager-server " + JSON.stringify({cwd:process.cwd(),host:process.env.HOST}))'
|
||||
`import { createServer } from 'node:http'
|
||||
export const server = createServer()
|
||||
console.log("manager-server " + JSON.stringify({
|
||||
cwd:process.cwd(),host:process.env.HOST,launcher:process.argv[1]
|
||||
}))`
|
||||
)
|
||||
const { stdout } = await run(false)
|
||||
expect(stdout).toContain('manager-command run build')
|
||||
expect(stdout).toContain(
|
||||
`manager-server ${JSON.stringify({ cwd: directory, host: '127.0.0.1' })}`
|
||||
`manager-server ${JSON.stringify({ cwd: directory, host: '127.0.0.1', launcher })}`
|
||||
)
|
||||
const log = await readFile(path.join(directory, 'data/build.log'), 'utf8')
|
||||
expect(log).toContain('Config App URL: https://manager.example/garden')
|
||||
@@ -98,7 +119,9 @@ test('fresh production startups follow the compatibility link after atomic confi
|
||||
await symlink('config/config.yaml', filename)
|
||||
await writeFile(
|
||||
path.join(directory, 'build/index.js'),
|
||||
'console.log("manager-server " + JSON.stringify({cwd:process.cwd(),host:process.env.HOST}))'
|
||||
`import { createServer } from 'node:http'
|
||||
export const server = createServer()
|
||||
console.log("manager-server " + JSON.stringify({cwd:process.cwd(),host:process.env.HOST}))`
|
||||
)
|
||||
|
||||
// Each run is a fresh manager process, not a live-server config reload.
|
||||
@@ -132,21 +155,23 @@ test('startup still fails when the working-directory config is missing', async (
|
||||
async function captureServerEnvironment() {
|
||||
await writeFile(
|
||||
path.join(directory, 'build/index.js'),
|
||||
'console.log("manager-environment " + JSON.stringify({host:process.env.HOST,origin:process.env.ORIGIN}))'
|
||||
`import { createServer } from 'node:http'
|
||||
// Keep the native export unbound so this environment snapshot exits naturally.
|
||||
export const server = createServer()
|
||||
console.log("manager-environment " + JSON.stringify({
|
||||
HOST:process.env.HOST,ORIGIN:process.env.ORIGIN,
|
||||
PROTOCOL_HEADER:process.env.PROTOCOL_HEADER,HOST_HEADER:process.env.HOST_HEADER,
|
||||
PORT_HEADER:process.env.PORT_HEADER
|
||||
}))`
|
||||
)
|
||||
}
|
||||
|
||||
test('production derives the canonical Kit origin from configured url.app', async () => {
|
||||
test('production selects internal protocol and authority headers from configured url.app', async () => {
|
||||
await configure('https://Manager.EXAMPLE:443/garden/', true)
|
||||
await captureServerEnvironment()
|
||||
const { stdout } = await run(false)
|
||||
expect(stdout).toContain('manager-command run build')
|
||||
expect(stdout).toContain(
|
||||
`manager-environment ${JSON.stringify({
|
||||
host: '127.0.0.1',
|
||||
origin: 'https://manager.example'
|
||||
})}`
|
||||
)
|
||||
expect(stdout).toContain(`manager-environment ${JSON.stringify(absoluteEnvironment)}`)
|
||||
})
|
||||
|
||||
test('production uses YAML even when ambient URL variables disagree', async () => {
|
||||
@@ -154,40 +179,52 @@ test('production uses YAML even when ambient URL variables disagree', async () =
|
||||
await captureServerEnvironment()
|
||||
const { stdout } = await run(false, {
|
||||
ORIGIN: 'not-a-url',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto',
|
||||
HOST_HEADER: 'x-forwarded-host',
|
||||
PORT_HEADER: 'x-forwarded-port',
|
||||
BETTER_AUTH_URL: 'https://unexpected.example',
|
||||
AUTH_URL: '',
|
||||
NEXTAUTH_URL: ''
|
||||
})
|
||||
expect(stdout).toContain(
|
||||
'manager-environment {"host":"127.0.0.1","origin":"https://manager.example"}'
|
||||
)
|
||||
expect(stdout).toContain(`manager-environment ${JSON.stringify(absoluteEnvironment)}`)
|
||||
})
|
||||
|
||||
test.each(['', '/garden'])('production keeps app URL %s request-derived', async (app) => {
|
||||
await configure(app, false)
|
||||
await captureServerEnvironment()
|
||||
const { stdout } = await run(false)
|
||||
expect(stdout).toContain('manager-environment {"host":"127.0.0.1"}')
|
||||
})
|
||||
test.each(['', '/garden'])(
|
||||
'production keeps app URL %s request-derived and clears ambient ORIGIN',
|
||||
async (app) => {
|
||||
await configure(app, false)
|
||||
await captureServerEnvironment()
|
||||
const { stdout } = await run(false, {
|
||||
ORIGIN: 'https://unexpected.example',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto'
|
||||
})
|
||||
expect(stdout).toContain(
|
||||
`manager-environment ${JSON.stringify({
|
||||
HOST: '127.0.0.1',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto'
|
||||
})}`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('production derives Kit origin even with authentication disabled', async () => {
|
||||
test('production selects configured header settings even with authentication disabled', async () => {
|
||||
await configure()
|
||||
await captureServerEnvironment()
|
||||
const { stdout } = await run(false)
|
||||
expect(stdout).toContain(
|
||||
'manager-environment {"host":"127.0.0.1","origin":"https://manager.example"}'
|
||||
)
|
||||
expect(stdout).toContain(`manager-environment ${JSON.stringify(absoluteEnvironment)}`)
|
||||
})
|
||||
|
||||
test('development derives the same internal Kit origin from YAML', async () => {
|
||||
test('development selects the same internal headers from YAML and clears ambient ORIGIN', async () => {
|
||||
await configure(undefined, true)
|
||||
await writeFile(
|
||||
path.join(directory, 'bin/pnpm'),
|
||||
'#!/bin/sh\nprintf "manager-environment HOST=%s ORIGIN=%s\\n" "$HOST" "${ORIGIN-unset}"\n',
|
||||
'#!/bin/sh\nprintf "manager-environment HOST=%s ORIGIN=%s PROTOCOL_HEADER=%s HOST_HEADER=%s PORT_HEADER=%s\\n" "$HOST" "${ORIGIN-unset}" "$PROTOCOL_HEADER" "$HOST_HEADER" "${PORT_HEADER-unset}"\n',
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
const { stdout } = await run(true, { ORIGIN: 'https://unexpected.example' })
|
||||
expect(stdout).toContain('manager-environment HOST=127.0.0.1 ORIGIN=https://manager.example')
|
||||
expect(stdout).toContain(
|
||||
'manager-environment HOST=127.0.0.1 ORIGIN=unset PROTOCOL_HEADER=x-luzzle-public-protocol HOST_HEADER=x-luzzle-public-authority PORT_HEADER=\n'
|
||||
)
|
||||
expect(stdout).not.toContain('Initiating production build sequence')
|
||||
})
|
||||
|
||||
@@ -206,7 +243,13 @@ test('production rejects unsafe configured URLs before logging values, building
|
||||
|
||||
test('authenticated production rejects relative app URLs instead of using an ambient origin', async () => {
|
||||
await configure('/garden', true)
|
||||
await expect(run(false, { ORIGIN: 'https://unexpected.example' })).rejects.toMatchObject({
|
||||
await expect(
|
||||
run(false, {
|
||||
ORIGIN: 'https://unexpected.example',
|
||||
BETTER_AUTH_URL: 'https://unexpected.example',
|
||||
AUTH_URL: 'https://legacy.example'
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stdout: 'FATAL ERROR: Authentication requires an absolute url.app\n'
|
||||
})
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
const protocolHeader = 'x-luzzle-public-protocol'
|
||||
const authorityHeader = 'x-luzzle-public-authority'
|
||||
|
||||
/** @param {string} value */
|
||||
function parseAppUrl(value) {
|
||||
const invalid = () => new Error('Invalid url.app: expected an HTTP(S) URL without credentials')
|
||||
@@ -16,7 +19,7 @@ function parseAppUrl(value) {
|
||||
}
|
||||
|
||||
/** @param {string} app */
|
||||
function originFromAppUrl(app) {
|
||||
export function resolveAppOrigin(app) {
|
||||
const schemeCandidate = app.replace(/[\s\p{Cc}]/gu, '')
|
||||
if (!/^[a-z][a-z\d+.-]*:/i.test(schemeCandidate)) return undefined
|
||||
return parseAppUrl(app).origin
|
||||
@@ -24,11 +27,25 @@ function originFromAppUrl(app) {
|
||||
|
||||
/** @param {string} app */
|
||||
export function resolveAuthUrl(app) {
|
||||
const origin = originFromAppUrl(app)
|
||||
const origin = resolveAppOrigin(app)
|
||||
if (origin) return origin
|
||||
throw new Error('Authentication requires an absolute url.app')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('node:http').Server} server
|
||||
* @param {string | undefined} origin
|
||||
*/
|
||||
export function installRequestOrigin(server, origin) {
|
||||
if (!origin) return
|
||||
const url = new URL(origin)
|
||||
const protocol = url.protocol.slice(0, -1)
|
||||
server.prependListener('request', (request) => {
|
||||
request.headers[protocolHeader] = protocol
|
||||
request.headers[authorityHeader] = url.host
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Pick<import('@luzzle/web.templates/config').TemplateConfig, 'network' | 'url' | 'auth'>} config
|
||||
* @param {NodeJS.ProcessEnv} environment
|
||||
@@ -37,10 +54,13 @@ export function resolveAuthUrl(app) {
|
||||
export function createServerEnvironment(config, environment = process.env) {
|
||||
/** @type {NodeJS.ProcessEnv} */
|
||||
const env = { ...environment, HOST: config.network?.public?.host || '0.0.0.0' }
|
||||
const origin = originFromAppUrl(config.url.app)
|
||||
const origin = resolveAppOrigin(config.url.app)
|
||||
if (config.auth && !origin) throw new Error('Authentication requires an absolute url.app')
|
||||
|
||||
if (origin) env.ORIGIN = origin
|
||||
else delete env.ORIGIN
|
||||
delete env.ORIGIN
|
||||
if (origin) {
|
||||
env.PROTOCOL_HEADER = protocolHeader
|
||||
env.HOST_HEADER = authorityHeader
|
||||
env.PORT_HEADER = ''
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import type { TemplateConfig } from '@luzzle/web.templates/config'
|
||||
import { createServerEnvironment, resolveAuthUrl } from './runtime-environment.js'
|
||||
import { createServer, IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { Socket } from 'node:net'
|
||||
import {
|
||||
createServerEnvironment,
|
||||
installRequestOrigin,
|
||||
resolveAppOrigin,
|
||||
resolveAuthUrl
|
||||
} from './runtime-environment.js'
|
||||
|
||||
type RuntimeConfig = Pick<TemplateConfig, 'network' | 'url' | 'auth'>
|
||||
const auth: NonNullable<RuntimeConfig['auth']> = {
|
||||
@@ -38,12 +45,29 @@ test('returns a fresh environment without mutating config or supplied environmen
|
||||
expect(result).toEqual({
|
||||
HOST: '127.0.0.1',
|
||||
UNRELATED: 'keep',
|
||||
ORIGIN: 'https://configured.example'
|
||||
PROTOCOL_HEADER: 'x-luzzle-public-protocol',
|
||||
HOST_HEADER: 'x-luzzle-public-authority',
|
||||
PORT_HEADER: ''
|
||||
})
|
||||
expect(environment).toEqual({ HOST: 'old-host', UNRELATED: 'keep' })
|
||||
expect(configured.url.app).toBe('https://configured.example/garden/?page=2#top')
|
||||
})
|
||||
|
||||
test('absolute YAML replaces ambient header selection without putting the authority in the environment', () => {
|
||||
const result = createServerEnvironment(config('https://configured.example:8443/garden'), {
|
||||
ORIGIN: 'https://unexpected.example',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto',
|
||||
HOST_HEADER: 'x-forwarded-host',
|
||||
PORT_HEADER: 'x-forwarded-port'
|
||||
})
|
||||
expect(result).toEqual({
|
||||
HOST: '127.0.0.1',
|
||||
PROTOCOL_HEADER: 'x-luzzle-public-protocol',
|
||||
HOST_HEADER: 'x-luzzle-public-authority',
|
||||
PORT_HEADER: ''
|
||||
})
|
||||
})
|
||||
|
||||
test('falls back to the existing public bind host when the configured host is empty', () => {
|
||||
const configured = config()
|
||||
configured.network.public.host = ''
|
||||
@@ -56,9 +80,15 @@ test.each([
|
||||
['https://example.com:8443/garden/', 'https://example.com:8443'],
|
||||
['http://localhost:8080', 'http://localhost:8080'],
|
||||
['https://[::1]:443/', 'https://[::1]']
|
||||
])('Kit and auth derive the same canonical origin from %s', (app, expected) => {
|
||||
expect(createServerEnvironment(config(app, true), {}).ORIGIN).toBe(expected)
|
||||
])('request boundary and auth derive the same canonical origin from %s', (app, expected) => {
|
||||
expect(resolveAppOrigin(app)).toBe(expected)
|
||||
expect(resolveAuthUrl(app)).toBe(expected)
|
||||
expect(createServerEnvironment(config(app, true), {})).toEqual({
|
||||
HOST: '127.0.0.1',
|
||||
PROTOCOL_HEADER: 'x-luzzle-public-protocol',
|
||||
HOST_HEADER: 'x-luzzle-public-authority',
|
||||
PORT_HEADER: ''
|
||||
})
|
||||
})
|
||||
|
||||
test('ambient URL variables cannot replace the configured public URL', () => {
|
||||
@@ -67,7 +97,12 @@ test('ambient URL variables cannot replace the configured public URL', () => {
|
||||
vi.stubEnv('AUTH_URL', 'https://legacy.example')
|
||||
vi.stubEnv('NEXTAUTH_URL', '')
|
||||
const before = { ...process.env }
|
||||
expect(createServerEnvironment(config(undefined, true)).ORIGIN).toBe('https://configured.example')
|
||||
expect(createServerEnvironment(config(undefined, true))).toMatchObject({
|
||||
PROTOCOL_HEADER: 'x-luzzle-public-protocol',
|
||||
HOST_HEADER: 'x-luzzle-public-authority',
|
||||
PORT_HEADER: ''
|
||||
})
|
||||
expect(createServerEnvironment(config(undefined, true))).not.toHaveProperty('ORIGIN')
|
||||
expect(resolveAuthUrl('https://configured.example/garden')).toBe('https://configured.example')
|
||||
expect(process.env).toEqual(before)
|
||||
})
|
||||
@@ -75,8 +110,19 @@ test('ambient URL variables cannot replace the configured public URL', () => {
|
||||
test.each(['', '/', '/garden/', 'garden', './garden', '../garden', '//example.com/garden'])(
|
||||
'public-only relative app URL %s leaves Kit request-derived, ignoring ambient ORIGIN',
|
||||
(app) => {
|
||||
const result = createServerEnvironment(config(app), { ORIGIN: 'https://unexpected.example' })
|
||||
expect(result).not.toHaveProperty('ORIGIN')
|
||||
const result = createServerEnvironment(config(app), {
|
||||
ORIGIN: 'https://unexpected.example',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto',
|
||||
HOST_HEADER: 'x-forwarded-host',
|
||||
PORT_HEADER: 'x-forwarded-port'
|
||||
})
|
||||
expect(result).toEqual({
|
||||
HOST: '127.0.0.1',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto',
|
||||
HOST_HEADER: 'x-forwarded-host',
|
||||
PORT_HEADER: 'x-forwarded-port'
|
||||
})
|
||||
expect(resolveAppOrigin(app)).toBeUndefined()
|
||||
expect(() => resolveAuthUrl(app)).toThrow('Authentication requires an absolute url.app')
|
||||
expect(() => createServerEnvironment(config(app, true), {})).toThrow(
|
||||
'Authentication requires an absolute url.app'
|
||||
@@ -84,6 +130,67 @@ test.each(['', '/', '/garden/', 'garden', './garden', '../garden', '//example.co
|
||||
}
|
||||
)
|
||||
|
||||
test('relative public mode does not introduce header settings when none were supplied', () => {
|
||||
expect(
|
||||
createServerEnvironment(config('/garden'), { ORIGIN: 'https://unexpected.example' })
|
||||
).toEqual({
|
||||
HOST: '127.0.0.1'
|
||||
})
|
||||
})
|
||||
|
||||
test('origin listener precedes the native listener on the first and later requests, replacing only internal headers', () => {
|
||||
const received: Record<string, unknown>[] = []
|
||||
const nativeListener = vi.fn((request: IncomingMessage) => {
|
||||
received.push({ method: request.method, url: request.url, headers: { ...request.headers } })
|
||||
})
|
||||
const server = createServer(nativeListener)
|
||||
installRequestOrigin(server, resolveAppOrigin('https://Configured.EXAMPLE:8443/garden'))
|
||||
expect(server.listeners('request')[1]).toBe(nativeListener)
|
||||
|
||||
for (const origin of ['https://configured.example:8443', 'https://browser.example']) {
|
||||
const request = new IncomingMessage(new Socket())
|
||||
request.method = 'POST'
|
||||
request.url = '/garden/action?next=%2Fprivate&tag=a&tag=b'
|
||||
request.headers = {
|
||||
host: 'internal.example:3000',
|
||||
origin,
|
||||
cookie: 'session=unchanged',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'x-forwarded-proto': 'http',
|
||||
'x-forwarded-host': 'spoofed.example:9999',
|
||||
'x-forwarded-port': '9999',
|
||||
'x-luzzle-public-protocol': 'http',
|
||||
'x-luzzle-public-authority': 'spoofed.example:9999'
|
||||
}
|
||||
const original = { ...request.headers }
|
||||
server.emit('request', request, new ServerResponse(request))
|
||||
expect(received.at(-1)).toEqual({
|
||||
method: 'POST',
|
||||
url: '/garden/action?next=%2Fprivate&tag=a&tag=b',
|
||||
headers: {
|
||||
...original,
|
||||
'x-luzzle-public-protocol': 'https',
|
||||
'x-luzzle-public-authority': 'configured.example:8443'
|
||||
}
|
||||
})
|
||||
request.destroy()
|
||||
}
|
||||
expect(nativeListener).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('relative public origin leaves the native request listener and headers untouched', () => {
|
||||
const nativeListener = vi.fn()
|
||||
const server = createServer(nativeListener)
|
||||
installRequestOrigin(server, resolveAppOrigin('/garden'))
|
||||
expect(server.listeners('request')).toEqual([nativeListener])
|
||||
const request = new IncomingMessage(new Socket())
|
||||
request.headers = { host: 'native.example', 'x-forwarded-proto': 'https' }
|
||||
server.emit('request', request, new ServerResponse(request))
|
||||
expect(request.headers).toEqual({ host: 'native.example', 'x-forwarded-proto': 'https' })
|
||||
expect(nativeListener).toHaveBeenCalledOnce()
|
||||
request.destroy()
|
||||
})
|
||||
|
||||
test.each([
|
||||
'ftp://example.com/private-token',
|
||||
'ws://example.com',
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { loadConfig } from '@luzzle/web.templates/config'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { createServerEnvironment } from './runtime-environment.js'
|
||||
import {
|
||||
createServerEnvironment,
|
||||
installRequestOrigin,
|
||||
resolveAppOrigin
|
||||
} from './runtime-environment.js'
|
||||
|
||||
/** @param {NodeJS.ProcessEnv} environment */
|
||||
function applyServerEnvironment(environment) {
|
||||
for (const key of ['HOST', 'ORIGIN']) {
|
||||
for (const key of ['HOST', 'ORIGIN', 'PROTOCOL_HEADER', 'HOST_HEADER', 'PORT_HEADER']) {
|
||||
const value = environment[key]
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const config = loadConfig(path.resolve('config.yaml'))
|
||||
const environment = createServerEnvironment(config)
|
||||
applyServerEnvironment(environment)
|
||||
/** @param {string} entry */
|
||||
export async function startServer(entry = path.resolve('build/index.js')) {
|
||||
const config = loadConfig(path.resolve('config.yaml'))
|
||||
const origin = resolveAppOrigin(config.url.app)
|
||||
applyServerEnvironment(createServerEnvironment(config))
|
||||
const { server } = await import(pathToFileURL(entry).href)
|
||||
installRequestOrigin(server, origin)
|
||||
return server
|
||||
}
|
||||
|
||||
await import(pathToFileURL(path.resolve('build/index.js')).href)
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
||||
await startServer()
|
||||
}
|
||||
@@ -2,12 +2,18 @@ import { afterEach, beforeEach, expect, test } from 'vitest'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
const entry = fileURLToPath(new URL('./start-server.js', import.meta.url))
|
||||
const cache = fileURLToPath(new URL('../node_modules/.cache/', import.meta.url))
|
||||
const absoluteEnvironment = {
|
||||
HOST: '127.0.0.1',
|
||||
PROTOCOL_HEADER: 'x-luzzle-public-protocol',
|
||||
HOST_HEADER: 'x-luzzle-public-authority',
|
||||
PORT_HEADER: ''
|
||||
}
|
||||
let directory: string
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -16,10 +22,17 @@ beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(cache, 'server-startup #-'))
|
||||
await mkdir(path.join(directory, 'build'))
|
||||
// Snapshot at module evaluation: the entry must apply the environment before import.
|
||||
// Export the native Node server, unbound so this snapshot fixture exits naturally.
|
||||
// Never print the entire environment or any auth secrets in the child fixture.
|
||||
await writeFile(
|
||||
path.join(directory, 'build/index.js'),
|
||||
'console.log(JSON.stringify({HOST:process.env.HOST,ORIGIN:process.env.ORIGIN}))'
|
||||
`import { createServer } from 'node:http'
|
||||
export const server = createServer()
|
||||
console.log(JSON.stringify({
|
||||
HOST:process.env.HOST,ORIGIN:process.env.ORIGIN,
|
||||
PROTOCOL_HEADER:process.env.PROTOCOL_HEADER,HOST_HEADER:process.env.HOST_HEADER,
|
||||
PORT_HEADER:process.env.PORT_HEADER
|
||||
}))`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -27,11 +40,20 @@ afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function run(environment: NodeJS.ProcessEnv = {}) {
|
||||
function run(environment: NodeJS.ProcessEnv = {}, args = [entry]) {
|
||||
const inherited = { ...process.env }
|
||||
for (const key of ['HOST', 'ORIGIN', 'BETTER_AUTH_URL', 'AUTH_URL', 'NEXTAUTH_URL'])
|
||||
for (const key of [
|
||||
'HOST',
|
||||
'ORIGIN',
|
||||
'PROTOCOL_HEADER',
|
||||
'HOST_HEADER',
|
||||
'PORT_HEADER',
|
||||
'BETTER_AUTH_URL',
|
||||
'AUTH_URL',
|
||||
'NEXTAUTH_URL'
|
||||
])
|
||||
delete inherited[key]
|
||||
return exec(process.execPath, [entry], {
|
||||
return exec(process.execPath, args, {
|
||||
cwd: directory,
|
||||
env: { ...inherited, ...environment },
|
||||
timeout: 10000
|
||||
@@ -56,56 +78,161 @@ async function configure(
|
||||
await writeFile(path.join(directory, 'config.yaml'), JSON.stringify(config))
|
||||
}
|
||||
|
||||
test('loads working-directory config and applies the canonical environment before importing the server', async () => {
|
||||
test('loads working-directory config and applies internal header settings before importing the server', async () => {
|
||||
await configure()
|
||||
const { stdout } = await run({ HOST: 'ignored-host' })
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
HOST: '127.0.0.1',
|
||||
ORIGIN: 'https://startup.example'
|
||||
})
|
||||
expect(JSON.parse(stdout)).toEqual(absoluteEnvironment)
|
||||
})
|
||||
|
||||
test('configured url.app supplies internal ORIGIN despite ambient URL settings', async () => {
|
||||
test('configured url.app selects internal headers and clears ORIGIN despite ambient URL settings', async () => {
|
||||
await configure()
|
||||
const { stdout } = await run({
|
||||
ORIGIN: 'not-a-url',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto',
|
||||
HOST_HEADER: 'x-forwarded-host',
|
||||
PORT_HEADER: 'x-forwarded-port',
|
||||
BETTER_AUTH_URL: 'https://unexpected.example',
|
||||
AUTH_URL: 'https://legacy.example',
|
||||
NEXTAUTH_URL: ''
|
||||
})
|
||||
expect(JSON.parse(stdout)).toEqual({ HOST: '127.0.0.1', ORIGIN: 'https://startup.example' })
|
||||
expect(JSON.parse(stdout)).toEqual(absoluteEnvironment)
|
||||
})
|
||||
|
||||
test.each(['', '/garden', './garden', '//relative.example/garden'])(
|
||||
'leaves app URL %s request-derived before importing the server',
|
||||
async (app) => {
|
||||
await configure(app, false)
|
||||
const { stdout } = await run({ ORIGIN: 'https://unexpected.example' })
|
||||
expect(JSON.parse(stdout)).toEqual({ HOST: '127.0.0.1' })
|
||||
const { stdout } = await run({
|
||||
ORIGIN: 'https://unexpected.example',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto'
|
||||
})
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
HOST: '127.0.0.1',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('does not add an auth URL when authentication is disabled', async () => {
|
||||
test('uses configured header settings even when authentication is disabled', async () => {
|
||||
await configure(undefined, false)
|
||||
const { stdout } = await run()
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
HOST: '127.0.0.1',
|
||||
ORIGIN: 'https://startup.example'
|
||||
})
|
||||
expect(JSON.parse(stdout)).toEqual(absoluteEnvironment)
|
||||
})
|
||||
|
||||
test('uses the existing HOST fallback for an empty configured bind host', async () => {
|
||||
await configure(undefined, true, '')
|
||||
const { stdout } = await run()
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
HOST: '0.0.0.0',
|
||||
ORIGIN: 'https://startup.example'
|
||||
})
|
||||
expect(JSON.parse(stdout)).toEqual({ ...absoluteEnvironment, HOST: '0.0.0.0' })
|
||||
})
|
||||
|
||||
test('import alone exports startServer without loading config or starting the default entry', async () => {
|
||||
// No config exists; the default entry would print a different snapshot if imported.
|
||||
const { stdout, stderr } = await run({}, [
|
||||
'--input-type=module',
|
||||
'--eval',
|
||||
`const { startServer } = await import(${JSON.stringify(pathToFileURL(entry).href)})
|
||||
console.log(typeof startServer)`
|
||||
])
|
||||
expect(stdout).toBe('function\n')
|
||||
expect(stderr).toBe('')
|
||||
})
|
||||
|
||||
test.each(['https://Public.EXAMPLE:8443/garden', 'http://Other.EXAMPLE:8080/base'])(
|
||||
'startServer returns the native server and normalizes its first HTTP request using %s',
|
||||
async (app) => {
|
||||
await configure(app)
|
||||
const compiledEntry = path.join(directory, 'build/probe #.js')
|
||||
// This is a native HTTP transport probe, not proof of Kit pre-handle CSRF.
|
||||
// Like the adapter entry, it installs its request listener and starts listening on import.
|
||||
await writeFile(
|
||||
compiledEntry,
|
||||
`import { createServer } from 'node:http'
|
||||
export const server = createServer(async (request, response) => {
|
||||
const protocol = request.headers[process.env.PROTOCOL_HEADER]
|
||||
const authority = request.headers[process.env.HOST_HEADER]
|
||||
const url = new URL(request.url, protocol + '://' + authority).href
|
||||
const headers = { ...request.headers }
|
||||
let body = ''
|
||||
for await (const chunk of request) body += chunk
|
||||
response.setHeader('content-type', 'application/json')
|
||||
response.end(JSON.stringify({ url, method: request.method, headers, body }))
|
||||
})
|
||||
server.listen(0, '127.0.0.1')`
|
||||
)
|
||||
const { stdout } = await run({ ORIGIN: 'https://ambient.example' }, [
|
||||
'--input-type=module',
|
||||
'--eval',
|
||||
`import { request } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
const { startServer } = await import(${JSON.stringify(pathToFileURL(entry).href)})
|
||||
const server = await startServer(${JSON.stringify(compiledEntry)})
|
||||
const compiled = await import(${JSON.stringify(pathToFileURL(compiledEntry).href)})
|
||||
if (server !== compiled.server) throw new Error('Native server was replaced')
|
||||
try {
|
||||
if (!server.listening) await once(server, 'listening')
|
||||
const results = []
|
||||
for (const origin of ['https://browser.example', 'null']) {
|
||||
results.push(await new Promise((resolve, reject) => {
|
||||
const outgoing = request({
|
||||
hostname: '127.0.0.1', port: server.address().port, agent: false,
|
||||
method: 'POST', path: '/submit?next=%2Fprivate&tag=a&tag=b',
|
||||
headers: {
|
||||
host: 'internal.example:3000', origin, cookie: 'session=unchanged',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'x-forwarded-proto': 'http', 'x-forwarded-host': 'spoofed.example:9999',
|
||||
'x-forwarded-port': '9999', 'x-luzzle-public-protocol': 'http',
|
||||
'x-luzzle-public-authority': 'spoofed.example:9999'
|
||||
}
|
||||
}, async (response) => {
|
||||
try {
|
||||
let body = ''
|
||||
for await (const chunk of response) body += chunk
|
||||
resolve(JSON.parse(body))
|
||||
} catch (error) { reject(error) }
|
||||
})
|
||||
outgoing.on('error', reject)
|
||||
outgoing.end('title=one%26two&tag=a&tag=b')
|
||||
}))
|
||||
}
|
||||
console.log(JSON.stringify(results))
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}`
|
||||
])
|
||||
const canonical = new URL(app)
|
||||
const results = JSON.parse(stdout)
|
||||
expect(results).toHaveLength(2)
|
||||
for (const [index, origin] of ['https://browser.example', 'null'].entries()) {
|
||||
expect(results[index]).toEqual({
|
||||
url: `${canonical.origin}/submit?next=%2Fprivate&tag=a&tag=b`,
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
host: 'internal.example:3000',
|
||||
origin,
|
||||
cookie: 'session=unchanged',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'x-forwarded-proto': 'http',
|
||||
'x-forwarded-host': 'spoofed.example:9999',
|
||||
'x-forwarded-port': '9999',
|
||||
'x-luzzle-public-protocol': canonical.protocol.slice(0, -1),
|
||||
'x-luzzle-public-authority': canonical.host
|
||||
}),
|
||||
body: 'title=one%26two&tag=a&tag=b'
|
||||
})
|
||||
}
|
||||
},
|
||||
15000
|
||||
)
|
||||
|
||||
test('authentication requires an absolute configured app URL before server import', async () => {
|
||||
await configure('/garden')
|
||||
await expect(run({ ORIGIN: 'https://unexpected.example' })).rejects.toMatchObject({
|
||||
await expect(
|
||||
run({
|
||||
ORIGIN: 'https://unexpected.example',
|
||||
BETTER_AUTH_URL: 'https://unexpected.example',
|
||||
AUTH_URL: 'https://legacy.example'
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stdout: '',
|
||||
stderr: expect.stringContaining('Authentication requires an absolute url.app')
|
||||
|
||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { type AppConfigPublic } from '$lib/server/config'
|
||||
import type { LuzzleSession as Session } from '$lib/server/auth'
|
||||
import { type AppConfigPublic } from '#lib/server/config.js'
|
||||
import type { LuzzleSession as Session } from '#lib/server/auth/index.js'
|
||||
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks'
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
||||
import type { Handle } from '@sveltejs/kit'
|
||||
import type { Config } from '@luzzle/web.config'
|
||||
import { credentialsAuth, oidcAuth } from '$lib/server/config.fixture'
|
||||
import { credentialsAuth, oidcAuth } from '#lib/server/config.fixture.js'
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
building: false,
|
||||
@@ -15,18 +15,18 @@ const fixture = vi.hoisted(() => ({
|
||||
createAuth: vi.fn(() => ({ id: 'native-auth' })),
|
||||
createAuthHandle: vi.fn(() => (({ event, resolve }) => resolve(event)) satisfies Handle)
|
||||
}))
|
||||
vi.mock('$app/environment', () => ({
|
||||
vi.mock('$app/env', () => ({
|
||||
get building() {
|
||||
return fixture.building
|
||||
}
|
||||
}))
|
||||
vi.mock('$lib/server/config', () => ({ config: fixture.config }))
|
||||
vi.mock('$lib/server/configured-styles', () => ({
|
||||
vi.mock('#lib/server/config.js', () => ({ config: fixture.config }))
|
||||
vi.mock('#lib/server/configured-styles.js', () => ({
|
||||
prepareConfiguredStyles: fixture.prepareConfiguredStyles,
|
||||
renderConfiguredStyles: ({ event, resolve }: Parameters<Handle>[0]) => resolve(event)
|
||||
}))
|
||||
vi.mock('$lib/server/database', () => ({}))
|
||||
vi.mock('$lib/server/asset-csp', () => ({
|
||||
vi.mock('#lib/server/database/index.js', () => ({}))
|
||||
vi.mock('#lib/server/asset-csp.js', () => ({
|
||||
createAssetCspHandle:
|
||||
() =>
|
||||
({ event, resolve }: Parameters<Handle>[0]) =>
|
||||
@@ -36,10 +36,11 @@ vi.mock('$lib/server/asset-csp', () => ({
|
||||
vi.mock('@sveltejs/kit/hooks', () => ({
|
||||
sequence: (...handles: Handle[]) => handles[handles.length - 1]
|
||||
}))
|
||||
vi.mock('$lib/server/auth', () => ({
|
||||
vi.mock('#lib/server/auth/index.js', () => ({
|
||||
createAuth: fixture.createAuth,
|
||||
createAuthHandle: fixture.createAuthHandle
|
||||
}))
|
||||
|
||||
vi.mock('../scripts/runtime-environment.js', () => ({
|
||||
resolveAuthUrl: () => fixture.config.url.app
|
||||
}))
|
||||
@@ -49,6 +50,7 @@ beforeEach(() => {
|
||||
fixture.config.auth = credentialsAuth
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => vi.resetModules())
|
||||
|
||||
function event(
|
||||
@@ -85,12 +87,12 @@ function event(
|
||||
}
|
||||
|
||||
test('awaits configured CSS once when the server hooks load, not per request', async () => {
|
||||
const { handle } = await import('./hooks.server')
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
expect(fixture.prepareConfiguredStyles).toHaveBeenCalledExactlyOnceWith(fixture.config)
|
||||
for (let request = 0; request < 2; request++) {
|
||||
await handle({
|
||||
event: event(null, '/', { routeId: '/(root)' }),
|
||||
resolve: vi.fn().mockResolvedValue(new Response('public'))
|
||||
resolve: vi.fn<Parameters<Handle>[0]['resolve']>().mockResolvedValue(new Response('public'))
|
||||
})
|
||||
}
|
||||
expect(fixture.prepareConfiguredStyles).toHaveBeenCalledTimes(1)
|
||||
@@ -98,7 +100,7 @@ test('awaits configured CSS once when the server hooks load, not per request', a
|
||||
|
||||
test('does not prepare configured CSS during framework building', async () => {
|
||||
fixture.building = true
|
||||
await import('./hooks.server')
|
||||
await import('./hooks.server.js')
|
||||
expect(fixture.prepareConfiguredStyles).not.toHaveBeenCalled()
|
||||
expect(fixture.createAuth).not.toHaveBeenCalled()
|
||||
expect(fixture.createAuthHandle).toHaveBeenCalledExactlyOnceWith(undefined, true)
|
||||
@@ -106,19 +108,22 @@ test('does not prepare configured CSS during framework building', async () => {
|
||||
|
||||
test('failed configured CSS preparation rejects server hook loading', async () => {
|
||||
const failure = new Error('Configured CSS preparation failed')
|
||||
|
||||
fixture.prepareConfiguredStyles.mockRejectedValueOnce(failure)
|
||||
await expect(import('./hooks.server')).rejects.toBe(failure)
|
||||
await expect(import('./hooks.server.js')).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
test.each(['/admin', '/admin/pieces', '/api/admin', '/api/admin/generate', '/admin/lsp'])(
|
||||
'the protected route %s requires a session before resolving its handler',
|
||||
async (pathname) => {
|
||||
const { handle } = await import('./hooks.server')
|
||||
const resolve = vi.fn()
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>(async () => new Response('resolved'))
|
||||
|
||||
await expect(handle({ event: event(null, pathname), resolve })).rejects.toMatchObject({
|
||||
status: 302,
|
||||
location: `/signin?${new URLSearchParams({ redirectTo: pathname })}`
|
||||
})
|
||||
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
@@ -127,9 +132,11 @@ test.each(['/admin', '/admin/lsp', '/api/admin/generate'])(
|
||||
'%s remains inaccessible without configured auth, even with a session',
|
||||
async (pathname) => {
|
||||
fixture.config.auth = undefined
|
||||
const { handle } = await import('./hooks.server')
|
||||
const resolve = vi.fn()
|
||||
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>(async () => new Response('resolved'))
|
||||
const request = event({ user: { name: 'test' } }, pathname)
|
||||
|
||||
await expect(handle({ event: request, resolve })).rejects.toMatchObject({
|
||||
status: 302,
|
||||
location: '/'
|
||||
@@ -142,12 +149,14 @@ test.each(['/admin', '/admin/lsp', '/api/admin/generate'])(
|
||||
|
||||
test('public-only requests resolve without constructing native auth or reading a session', async () => {
|
||||
fixture.config.auth = undefined
|
||||
const { handle } = await import('./hooks.server')
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const request = event(null, '/', { routeId: '/(root)' })
|
||||
const response = new Response('public', {
|
||||
headers: { 'Cache-Control': 'public, max-age=31536000, immutable' }
|
||||
})
|
||||
const resolve = vi.fn().mockResolvedValue(response)
|
||||
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>().mockResolvedValue(response)
|
||||
|
||||
await expect(handle({ event: request, resolve })).resolves.toBe(response)
|
||||
expect(request.locals.auth).not.toHaveBeenCalled()
|
||||
expect(request.setHeaders).not.toHaveBeenCalled()
|
||||
@@ -158,21 +167,26 @@ test('public-only requests resolve without constructing native auth or reading a
|
||||
test.each([false, true])(
|
||||
'signin preserves an encoded path and its query as one return parameter (data=%s)',
|
||||
async (isDataRequest) => {
|
||||
const { handle } = await import('./hooks.server')
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const pathname = '/garden/%61dmin/piece/books/%E6%BA%90%20%23%3F%25.md/published'
|
||||
const search = '?tag=a%26b&filter=a+b&redirectTo=https%3A%2F%2Fexternal.example'
|
||||
const resolve = vi.fn()
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>(async () => new Response('resolved'))
|
||||
|
||||
const request = event(null, pathname, {
|
||||
routeId: '/(auth)/admin/piece/[...path]/published',
|
||||
isDataRequest,
|
||||
search,
|
||||
headers: { accept: isDataRequest ? 'application/json' : 'text/html' }
|
||||
})
|
||||
|
||||
const failure = await Promise.resolve(handle({ event: request, resolve })).catch(
|
||||
(error) => error
|
||||
)
|
||||
|
||||
expect(failure).toMatchObject({ status: 302, location: expect.any(String) })
|
||||
|
||||
const signin = new URL(failure.location, request.url.origin)
|
||||
|
||||
expect(signin.pathname).toBe('/signin')
|
||||
expect([...signin.searchParams]).toEqual([['redirectTo', pathname + search]])
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
@@ -182,23 +196,27 @@ test.each([false, true])(
|
||||
test.each(['/admin', '/admin/preview/job/asset/private.png', '/api/admin/generate'])(
|
||||
'%s sets the framework cache policy before resolving protected content',
|
||||
async (pathname) => {
|
||||
const { handle } = await import('./hooks.server')
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const request = event({ user: { name: 'test' } }, pathname)
|
||||
const resolve = vi.fn(async () => {
|
||||
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>(async () => {
|
||||
expect(request.setHeaders).toHaveBeenCalledExactlyOnceWith({
|
||||
'Cache-Control': 'private, no-store'
|
||||
})
|
||||
|
||||
return new Response('private')
|
||||
})
|
||||
|
||||
await handle({ event: request, resolve })
|
||||
expect(resolve).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
test('an authenticated generation request reaches its handler', async () => {
|
||||
const { handle } = await import('./hooks.server')
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const response = new Response('resolved')
|
||||
const resolve = vi.fn().mockResolvedValue(response)
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>().mockResolvedValue(response)
|
||||
|
||||
await expect(handle({ event: event({ user: { name: 'test' } }), resolve })).resolves.toBe(
|
||||
response
|
||||
)
|
||||
@@ -229,8 +247,8 @@ const encodedRequests = [
|
||||
test.each(encodedRequests)(
|
||||
'$method $pathname (data=$isDataRequest) requires a session by matched route identity',
|
||||
async ({ pathname, ...options }) => {
|
||||
const { handle } = await import('./hooks.server')
|
||||
const resolve = vi.fn()
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>(async () => new Response('resolved'))
|
||||
const request = event(null, pathname, options)
|
||||
if (options.method === 'GET') {
|
||||
await expect(handle({ event: request, resolve })).rejects.toMatchObject({
|
||||
@@ -243,6 +261,7 @@ test.each(encodedRequests)(
|
||||
expect(response.headers.has('location')).toBe(false)
|
||||
expect(await response.json()).toEqual({ message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
expect(request.locals.auth).toHaveBeenCalledOnce()
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
}
|
||||
@@ -252,9 +271,11 @@ test.each(encodedRequests)(
|
||||
'$pathname remains inaccessible with auth disabled, even with a session (data=$isDataRequest)',
|
||||
async ({ pathname, ...options }) => {
|
||||
fixture.config.auth = undefined
|
||||
const { handle } = await import('./hooks.server')
|
||||
const resolve = vi.fn()
|
||||
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>(async () => new Response('resolved'))
|
||||
const request = event({ user: { name: 'test' } }, pathname, options)
|
||||
|
||||
if (options.method === 'GET') {
|
||||
await expect(handle({ event: request, resolve })).rejects.toMatchObject({
|
||||
status: 302,
|
||||
@@ -262,10 +283,12 @@ test.each(encodedRequests)(
|
||||
})
|
||||
} else {
|
||||
const response = await handle({ event: request, resolve })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.headers.has('location')).toBe(false)
|
||||
expect(await response.json()).toEqual({ message: 'Forbidden' })
|
||||
}
|
||||
|
||||
expect(request.locals.auth).not.toHaveBeenCalled()
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
}
|
||||
@@ -274,10 +297,11 @@ test.each(encodedRequests)(
|
||||
test.each(encodedRequests)(
|
||||
'authenticated $pathname resolves normally (data=$isDataRequest)',
|
||||
async ({ pathname, ...options }) => {
|
||||
const { handle } = await import('./hooks.server')
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const response = new Response('resolved')
|
||||
const resolve = vi.fn().mockResolvedValue(response)
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>().mockResolvedValue(response)
|
||||
const request = event({ user: { name: 'test' } }, pathname, options)
|
||||
|
||||
await expect(handle({ event: request, resolve })).resolves.toBe(response)
|
||||
expect(request.locals.auth).toHaveBeenCalledOnce()
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith(request)
|
||||
@@ -295,10 +319,11 @@ test.each([
|
||||
{ pathname: '/admin-looking-group', routeId: '/(auth)extra/admin' },
|
||||
{ pathname: '/admin/not-a-route', routeId: null }
|
||||
])('public/unmatched $routeId is not classified by URL spelling', async ({ pathname, routeId }) => {
|
||||
const { handle } = await import('./hooks.server')
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const response = new Response('public or not found')
|
||||
const resolve = vi.fn().mockResolvedValue(response)
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>().mockResolvedValue(response)
|
||||
const request = event(null, pathname, { routeId })
|
||||
|
||||
await expect(handle({ event: request, resolve })).resolves.toBe(response)
|
||||
expect(request.locals.auth).not.toHaveBeenCalled()
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith(request)
|
||||
@@ -308,7 +333,9 @@ test.each([true, false])(
|
||||
'non-navigation requests get generic errors (auth=%s)',
|
||||
async (enabled) => {
|
||||
fixture.config.auth = enabled ? credentialsAuth : undefined
|
||||
const { handle } = await import('./hooks.server')
|
||||
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
|
||||
for (const pathname of ['/admin/tools', '/api/admin/generate', '/future-tool']) {
|
||||
for (const [method, accept] of [
|
||||
['POST', 'application/json'],
|
||||
@@ -318,7 +345,9 @@ test.each([true, false])(
|
||||
['HEAD', 'text/html']
|
||||
]) {
|
||||
const request = event(null, pathname, { method, headers: { accept } })
|
||||
const resolve = vi.fn()
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>(
|
||||
async () => new Response('resolved')
|
||||
)
|
||||
const response = await handle({ event: request, resolve })
|
||||
expect(response.status).toBe(enabled ? 401 : 403)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
@@ -335,17 +364,18 @@ test.each([true, false])(
|
||||
'enhanced forms keep their existing redirects (auth=%s)',
|
||||
async (enabled) => {
|
||||
fixture.config.auth = enabled ? credentialsAuth : undefined
|
||||
const { handle } = await import('./hooks.server')
|
||||
const { handle } = await import('./hooks.server.js')
|
||||
const pathname = '/admin/piece/books/book.books.md/source'
|
||||
const request = event(null, pathname, {
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'x-sveltekit-action': 'true' }
|
||||
})
|
||||
const resolve = vi.fn()
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>(async () => new Response('resolved'))
|
||||
await expect(handle({ event: request, resolve })).rejects.toMatchObject({
|
||||
status: 302,
|
||||
location: enabled ? `/signin?${new URLSearchParams({ redirectTo: pathname })}` : '/'
|
||||
})
|
||||
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
@@ -355,6 +385,6 @@ test.each([
|
||||
{ auth: oidcAuth, provider: 'oidc' }
|
||||
])('initializes the native core with the configured $provider provider', async ({ auth }) => {
|
||||
fixture.config.auth = auth
|
||||
await import('./hooks.server')
|
||||
await import('./hooks.server.js')
|
||||
expect(fixture.createAuth).toHaveBeenCalledExactlyOnceWith(auth, fixture.config.url.app)
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
import { building } from '$app/environment'
|
||||
import { config } from '$lib/server/config'
|
||||
import { createAuth, createAuthHandle } from '$lib/server/auth'
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
import { building } from '$app/env'
|
||||
import { config } from '#lib/server/config.js'
|
||||
import { createAuth, createAuthHandle } from '#lib/server/auth/index.js'
|
||||
import { resolveAuthUrl } from '../scripts/runtime-environment.js'
|
||||
import { createAssetCspHandle } from '$lib/server/asset-csp'
|
||||
import { prepareConfiguredStyles, renderConfiguredStyles } from '$lib/server/configured-styles'
|
||||
import '$lib/server/database'
|
||||
import { sequence } from '@sveltejs/kit/hooks'
|
||||
import { json, redirect, type Handle } from '@sveltejs/kit'
|
||||
import { createAssetCspHandle } from '#lib/server/asset-csp.js'
|
||||
import { prepareConfiguredStyles, renderConfiguredStyles } from '#lib/server/configured-styles.js'
|
||||
import '#lib/server/database/index.js'
|
||||
import { sequence, type Handle } from '@sveltejs/kit/hooks'
|
||||
|
||||
async function prepareStartupStyles() {
|
||||
if (building) return ''
|
||||
@@ -24,8 +24,8 @@ function createAssetPolicy(): Handle {
|
||||
|
||||
const configuredStyles = await prepareStartupStyles()
|
||||
const handleAssetCsp = createAssetPolicy()
|
||||
|
||||
const auth = config.auth
|
||||
|
||||
function initializeAuth() {
|
||||
if (building || !auth) return undefined
|
||||
return createAuth(auth, resolveAuthUrl(config.url.app))
|
||||
@@ -40,7 +40,9 @@ const guardHandle: Handle = async ({ event, resolve }) => {
|
||||
if (!isProtectedRoute(event.route.id)) return resolve(event)
|
||||
|
||||
const headers = { 'Cache-Control': 'private, no-store' }
|
||||
|
||||
event.setHeaders(headers)
|
||||
|
||||
const { request } = event
|
||||
const browserGet =
|
||||
request.method === 'GET' &&
|
||||
@@ -51,14 +53,16 @@ const guardHandle: Handle = async ({ event, resolve }) => {
|
||||
|
||||
if (!auth) {
|
||||
if (shouldRedirect) throw redirect(302, '/')
|
||||
return json({ message: 'Forbidden' }, { status: 403, headers })
|
||||
return Response.json({ message: 'Forbidden' }, { status: 403, headers })
|
||||
}
|
||||
|
||||
const session = await event.locals.auth()
|
||||
if (session) return resolve(event)
|
||||
|
||||
if (!shouldRedirect) return json({ message: 'Unauthorized' }, { status: 401, headers })
|
||||
if (session) return resolve(event)
|
||||
if (!shouldRedirect) return Response.json({ message: 'Unauthorized' }, { status: 401, headers })
|
||||
|
||||
const params = new URLSearchParams({ redirectTo: event.url.pathname + event.url.search })
|
||||
|
||||
throw redirect(302, `/signin?${params}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import CircleNotch from 'virtual:icons/ph/circle-notch-bold'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import Button from '#lib/components/ui/Button.svelte'
|
||||
import GenerationFields from './GenerationFields.svelte'
|
||||
import { startGeneration } from '$lib/generation/client'
|
||||
import { startGeneration } from '#lib/generation/client.js'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import { previewHref, publishedHref } from '$lib/pieces/navigation'
|
||||
import { resolve } from '$app/paths'
|
||||
import Button from '#lib/components/ui/Button.svelte'
|
||||
import { encodeArchivePath } from '#lib/pieces/navigation.js'
|
||||
|
||||
type Props = {
|
||||
file: string
|
||||
@@ -22,6 +23,7 @@
|
||||
hasPublishedVersion,
|
||||
children
|
||||
}: Props = $props()
|
||||
const path = $derived(encodeArchivePath(file))
|
||||
</script>
|
||||
|
||||
<div class="toolbar" role="group" aria-label="Editor actions">
|
||||
@@ -38,7 +40,11 @@
|
||||
{:else if isDirty}
|
||||
Unsaved
|
||||
{:else}
|
||||
<a href={previewHref(file)} aria-label="Preview saved piece" title="Preview saved piece">
|
||||
<a
|
||||
href={resolve('/(auth)/admin/piece/[...path]/preview', { path })}
|
||||
aria-label="Preview saved piece"
|
||||
title="Preview saved piece"
|
||||
>
|
||||
Saved
|
||||
</a>
|
||||
{/if}
|
||||
@@ -47,7 +53,7 @@
|
||||
{#if isUnpublishing}
|
||||
Unpublishing…
|
||||
{:else if hasPublishedVersion}
|
||||
<a href={publishedHref(file)}>Published</a>
|
||||
<a href={resolve('/(auth)/admin/piece/[...path]/published', { path })}>Published</a>
|
||||
{:else}
|
||||
Unpublished
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { createRawSnippet, type ComponentProps } from 'svelte'
|
||||
import { render } from 'svelte/server'
|
||||
import { expect, test } from 'vitest'
|
||||
import { beforeEach, expect, test, vi } from 'vitest'
|
||||
import { resolve } from '$app/paths'
|
||||
import {
|
||||
encodeArchivePath,
|
||||
previewHref as piecePreviewHref,
|
||||
publishedHref as piecePublishedHref
|
||||
} from '#lib/pieces/navigation.js'
|
||||
import PieceToolbar from './PieceToolbar.svelte'
|
||||
|
||||
vi.mock('$app/paths', () => ({
|
||||
resolve: vi.fn((route: string, params: { path: string }) =>
|
||||
route.replace('/(auth)', '').replace('[...path]', () => params.path)
|
||||
)
|
||||
}))
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
const file = 'books/源 #?%.books.md'
|
||||
const previewHref = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/preview'
|
||||
const publishedHref = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/published'
|
||||
@@ -107,4 +120,20 @@ test('editor actions do not duplicate archive navigation', () => {
|
||||
expect(output).not.toContain('Back to archive')
|
||||
})
|
||||
|
||||
// SSR verifies labels, links and disabled/form semantics, not clicks or responsive layout.
|
||||
test('encoded file paths enter the app resolver unchanged', () => {
|
||||
const file = 'books/literal%2F 源 #?%.books.md'
|
||||
const preview = piecePreviewHref(file)
|
||||
const published = piecePublishedHref(file)
|
||||
const output = html({ file })
|
||||
|
||||
expect(preview).toContain('literal%252F%20%E6%BA%90%20%23%3F%25.books.md')
|
||||
expect(vi.mocked(resolve).mock.calls).toEqual([
|
||||
['/(auth)/admin/piece/[...path]/preview', { path: encodeArchivePath(file) }],
|
||||
['/(auth)/admin/piece/[...path]/published', { path: encodeArchivePath(file) }]
|
||||
])
|
||||
expect(output).toContain(`href="${preview}"`)
|
||||
expect(output).toContain(`href="${published}"`)
|
||||
})
|
||||
|
||||
// Mocked SSR verifies resolver inputs, labels, links and disabled/form semantics,
|
||||
// not browser/native resolution, clicks or responsive layout.
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths'
|
||||
import NavigationIcon from 'virtual:icons/ph/arrow-up-left'
|
||||
import SearchIcon from 'virtual:icons/ph/magnifying-glass'
|
||||
import DiceIcon from 'virtual:icons/ph/dice-three'
|
||||
@@ -47,7 +48,7 @@
|
||||
>
|
||||
<div class="left">
|
||||
{#if showHome}
|
||||
<a href="/" aria-label="main page"><NavigationIcon style="font-size: 1em;" /></a>
|
||||
<a href={resolve('')} aria-label="main page"><NavigationIcon style="font-size: 1em;" /></a>
|
||||
{/if}
|
||||
{#if items?.left}
|
||||
{@render items.left()}
|
||||
@@ -56,13 +57,13 @@
|
||||
<div class="right">
|
||||
{#if showSearch}
|
||||
{#await import('./SearchDialog.svelte')}
|
||||
<a href="/search" aria-label="search">
|
||||
<a href={resolve('search')} aria-label="search">
|
||||
<SearchIcon style="font-size: 1em;" />
|
||||
</a>
|
||||
{:then { default: SearchDialog }}
|
||||
<SearchDialog />
|
||||
{:catch}
|
||||
<a href="/search" aria-label="search">
|
||||
<a href={resolve('search')} aria-label="search">
|
||||
<SearchIcon style="font-size: 1em;" />
|
||||
</a>
|
||||
{/await}
|
||||
@@ -71,7 +72,7 @@
|
||||
{@render items.right()}
|
||||
{/if}
|
||||
{#if showRandom}
|
||||
<a href="/random" aria-label="random" data-sveltekit-reload>
|
||||
<a href={resolve('random')} aria-label="random" data-sveltekit-reload>
|
||||
<DiceIcon style="font-size: 1em;" />
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
import CaretUpDownIcon from 'virtual:icons/ph/caret-up-down'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { Dialog, Select } from 'bits-ui'
|
||||
import DatePicker from '$lib/components/ui/DatePicker.svelte'
|
||||
import DatePicker from '#lib/components/ui/DatePicker.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { afterNavigate, goto } from '$app/navigation'
|
||||
import { resolve } from '$app/paths'
|
||||
|
||||
const pieceTypes = $derived(page.data.pieceTypes ?? [])
|
||||
|
||||
let query = $state('')
|
||||
let type = $state('')
|
||||
let after = $state('')
|
||||
let before = $state('')
|
||||
|
||||
let isOpen = $state(false)
|
||||
let navigationError = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
query = page.url.searchParams.get('query') || ''
|
||||
@@ -31,21 +31,30 @@
|
||||
before = ''
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
afterNavigate(({ shallow, type }) => {
|
||||
if (shallow && type === 'goto') return
|
||||
|
||||
isOpen = false
|
||||
navigationError = false
|
||||
})
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const params = new URLSearchParams()
|
||||
if (query) params.set('query', query)
|
||||
if (type) params.set('type', type)
|
||||
if (after) params.set('after', after)
|
||||
if (before) params.set('before', before)
|
||||
navigationError = false
|
||||
|
||||
const searchString = params.toString()
|
||||
const url = `/search${searchString ? `?${searchString}` : ''}`
|
||||
goto(url)
|
||||
const filters: Record<string, string> = {}
|
||||
if (query) filters.query = query
|
||||
if (type) filters.type = type
|
||||
if (after) filters.after = after
|
||||
if (before) filters.before = before
|
||||
|
||||
const searchString = new URLSearchParams(filters).toString()
|
||||
try {
|
||||
const destination = searchString ? resolve(`search?${searchString}`) : resolve('search')
|
||||
await goto(destination)
|
||||
} catch {
|
||||
navigationError = true
|
||||
}
|
||||
}
|
||||
|
||||
function responsiveFly(node: HTMLElement, { duration = 300 }) {
|
||||
@@ -69,7 +78,7 @@
|
||||
props: Record<string, unknown> & { onclick?: (e: Event) => unknown }
|
||||
})}
|
||||
<a
|
||||
href="/search"
|
||||
href={resolve('search')}
|
||||
aria-label="search"
|
||||
{...props}
|
||||
onclick={(e) => {
|
||||
@@ -172,6 +181,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if navigationError}
|
||||
<p role="alert">
|
||||
Search navigation did not complete. Your filters are still here.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions-container">
|
||||
{#if query || type || after || before}
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment'
|
||||
import { browser } from '$app/env'
|
||||
import { page } from '$app/state'
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import SunIcon from 'virtual:icons/ph/sun-dim'
|
||||
import MoonIcon from 'virtual:icons/ph/moon'
|
||||
import FaderIcon from 'virtual:icons/ph/faders'
|
||||
import themes, { type Theme } from '$lib/ui/styles/themes'
|
||||
import themes, { type Theme } from '#lib/ui/styles/themes.js'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
function getThemePreference(): Theme {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths'
|
||||
import ContentBanner from './ContentBanner.svelte'
|
||||
|
||||
let { message }: { message?: string } = $props()
|
||||
@@ -12,7 +13,7 @@
|
||||
<h1>404</h1>
|
||||
<h2>page not found</h2>
|
||||
<p>{message || 'there are no pieces at this path.'}</p>
|
||||
<a href="/" class="home-button">go home</a>
|
||||
<a href={resolve('')} class="home-button">go home</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte'
|
||||
import Nav from '$lib/components/layout/simple/nav.svelte'
|
||||
import Nav from '#lib/components/layout/simple/nav.svelte'
|
||||
import AtIcon from 'virtual:icons/ph/at'
|
||||
import { onMount } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { resolve } from '$app/paths'
|
||||
|
||||
let { items, showRandom = false, ...rest }: ComponentProps<typeof Nav> = $props()
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
|
||||
{#snippet leftSnippet()}
|
||||
{#if isAdmin && page.url.pathname === '/'}
|
||||
<a href="/admin" aria-label="admin">
|
||||
<a href={resolve('admin')} aria-label="admin">
|
||||
<AtIcon style="font-size: 1em;" />
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths'
|
||||
import ContentBanner from './ContentBanner.svelte'
|
||||
|
||||
type Props = { status: number; message?: string }
|
||||
@@ -12,7 +13,7 @@
|
||||
<div class="error-card">
|
||||
<h1>error {status}</h1>
|
||||
<p>{message}</p>
|
||||
<a href="/" class="home-button">go home</a>
|
||||
<a href={resolve('')} class="home-button">go home</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths'
|
||||
import type { FeedData } from './types.js'
|
||||
|
||||
let { data }: { data: FeedData } = $props()
|
||||
@@ -17,7 +18,7 @@
|
||||
|
||||
<div class="pieces-list">
|
||||
{#each data.pieces as piece (piece.id)}
|
||||
<a href="/pieces/{piece.type}/{piece.slug}" class="item-card">
|
||||
<a href={resolve(`pieces/${piece.type}/${piece.slug}`)} class="item-card">
|
||||
<div class="item-link">
|
||||
<span class="item-date">
|
||||
{new Date(piece.date_consumed || piece.date_added).toLocaleDateString('en-US', {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { FeedData } from './types.js'
|
||||
import FeedList from './list.svelte'
|
||||
import NavBanner from '$lib/components/layout/simple/NavBanner.svelte'
|
||||
import SeoHead from '$lib/components/layout/simple/SeoHead.svelte'
|
||||
import Markdown from '$lib/content/markdown/Markdown.svelte'
|
||||
import NavBanner from '#lib/components/layout/simple/NavBanner.svelte'
|
||||
import SeoHead from '#lib/components/layout/simple/SeoHead.svelte'
|
||||
import Markdown from '#lib/content/markdown/Markdown.svelte'
|
||||
import Subscription from './subscription.svelte'
|
||||
|
||||
type Props = FeedData & { about?: string; links: boolean }
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
|
||||
<aside class="subscription" aria-label="RSS subscription">
|
||||
<p>
|
||||
View the raw data files: <a href="feed.xml">XML</a> | <a href="feed.json">JSON</a> |
|
||||
<a href="feed.md">Markdown</a>
|
||||
View the raw data files: <a href="feed.xml" rel="external">XML</a> |
|
||||
<a href="feed.json" rel="external">JSON</a> |
|
||||
<a href="feed.md" rel="external">Markdown</a>
|
||||
</p>
|
||||
<p id="rss-instructions">Copy the link and paste it into an RSS reader.</p>
|
||||
<div class="copy-controls">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '$lib/server/database'
|
||||
import { db } from '#lib/server/database/index.js'
|
||||
import type { PublicWebPiece } from '@luzzle/web.pieces'
|
||||
import { hydrateWithAssets } from '$lib/pieces/assets.server'
|
||||
import { hydrateWithAssets } from '#lib/pieces/assets.server.js'
|
||||
|
||||
const MAX_FEED_ITEMS = 50
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { encodeEvent, SSE_HEADERS } from '$lib/server/sse'
|
||||
import { encodeEvent, SSE_HEADERS } from '#lib/server/sse.js'
|
||||
import { startGeneration } from './client'
|
||||
import type { GenerationResult } from './types'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readJsonEvents, type JsonEvent } from '$lib/sse'
|
||||
import { readJsonEvents, type JsonEvent } from '#lib/sse.js'
|
||||
import type { GenerationEvent, GenerationResult } from './types'
|
||||
import type { ProgressPhase } from '$lib/components/progress'
|
||||
import type { ProgressPhase } from '#lib/components/progress.js'
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProgressPhase } from '$lib/components/progress'
|
||||
import type { ProgressPhase } from '#lib/components/progress.js'
|
||||
|
||||
export type GenerationResult = { markdown: string }
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
// place files you want to import through the `#lib` alias in this folder.
|
||||
@@ -11,7 +11,7 @@ const { selectFrom, query } = vi.hoisted(() => {
|
||||
}
|
||||
return { selectFrom: vi.fn(() => query), query }
|
||||
})
|
||||
vi.mock('$lib/server/config', () => ({
|
||||
vi.mock('#lib/server/config.js', () => ({
|
||||
config: {
|
||||
pieces: [
|
||||
{
|
||||
@@ -28,7 +28,7 @@ vi.mock('$lib/server/config', () => ({
|
||||
]
|
||||
}
|
||||
}))
|
||||
vi.mock('$lib/server/database', () => ({ db: { selectFrom } }))
|
||||
vi.mock('#lib/server/database/index.js', () => ({ db: { selectFrom } }))
|
||||
|
||||
function makePiece(overrides: Partial<WebPieces> = {}) {
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { db } from '$lib/server/database'
|
||||
import { db } from '#lib/server/database/index.js'
|
||||
import type { WebPieces, WebPiecesAsset } from '@luzzle/web.db'
|
||||
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
|
||||
import { mapPieceTemplates, type TemplatedPiece } from '@luzzle/web.templates/pieces/fields'
|
||||
import { config } from '$lib/server/config'
|
||||
import { config } from '#lib/server/config.js'
|
||||
|
||||
type WebPiece = WebPieces & { assets: WebPiecesAsset[] }
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { getIcon } from '@luzzle/web.templates/icons'
|
||||
import type { PublicWebPiece } from '@luzzle/web.pieces'
|
||||
import type { PieceTemplateData } from '@luzzle/web.templates/pieces/fields'
|
||||
import { getPieceHelpers } from '$lib/pieces/helpers.js'
|
||||
import { getPieceHelpers } from '#lib/pieces/helpers.js'
|
||||
|
||||
type Props = {
|
||||
piece: PublicWebPiece & { templates?: PieceTemplateData }
|
||||
|
||||
@@ -29,8 +29,11 @@ let page: { data: PageData }
|
||||
beforeAll(async () => {
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: fileURLToPath(
|
||||
new URL('../../../../node_modules/.cache/catalog-icon', import.meta.url)
|
||||
),
|
||||
root: fileURLToPath(new URL('../../../../', import.meta.url)),
|
||||
server: { middlewareMode: true },
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
optimizeDeps: { noDiscovery: true },
|
||||
plugins: [
|
||||
{
|
||||
@@ -43,9 +46,9 @@ beforeAll(async () => {
|
||||
if (id === '\0icon-test-page') return 'export const page = { data: {} }'
|
||||
}
|
||||
},
|
||||
svelte({ configFile: false, hot: false })
|
||||
svelte({ configFile: false })
|
||||
],
|
||||
resolve: { alias: { $lib: lib } }
|
||||
resolve: { alias: { '#lib': lib } }
|
||||
})
|
||||
Icon = (await server.ssrLoadModule(wrapper)).default
|
||||
render = (await server.ssrLoadModule('svelte/server')).render
|
||||
|
||||
@@ -25,6 +25,9 @@ let page: {
|
||||
beforeAll(async () => {
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: fileURLToPath(
|
||||
new URL('../../../../node_modules/.cache/catalog-opengraph', import.meta.url)
|
||||
),
|
||||
root: fileURLToPath(new URL('../../../../', import.meta.url)),
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
optimizeDeps: { noDiscovery: true },
|
||||
@@ -39,9 +42,9 @@ beforeAll(async () => {
|
||||
if (id === '\0og-test-page') return 'export const page = { data: {} }'
|
||||
}
|
||||
},
|
||||
svelte({ configFile: false, hot: false })
|
||||
svelte({ configFile: false })
|
||||
],
|
||||
resolve: { alias: { $lib: fileURLToPath(new URL('../../', import.meta.url)) } }
|
||||
resolve: { alias: { '#lib': fileURLToPath(new URL('../../', import.meta.url)) } }
|
||||
})
|
||||
Opengraph = (
|
||||
await server.ssrLoadModule(fileURLToPath(new URL('./opengraph.svelte', import.meta.url)))
|
||||
|
||||
@@ -26,10 +26,14 @@ let getPage: (name?: string) => Component<Props>
|
||||
beforeAll(async () => {
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: fileURLToPath(
|
||||
new URL('../../../../node_modules/.cache/catalog-pages', import.meta.url)
|
||||
),
|
||||
root: fileURLToPath(new URL('../../../../', import.meta.url)),
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
optimizeDeps: { noDiscovery: true },
|
||||
plugins: [svelte({ configFile: false, hot: false })]
|
||||
plugins: [svelte({ configFile: false })],
|
||||
resolve: { alias: { '#lib': fileURLToPath(new URL('../../', import.meta.url)) } }
|
||||
})
|
||||
getPage = (await server.ssrLoadModule('@luzzle/web.templates/pages')).getPage
|
||||
render = (await server.ssrLoadModule('svelte/server')).render
|
||||
|
||||
@@ -19,10 +19,14 @@ let render: typeof Render
|
||||
beforeAll(async () => {
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: fileURLToPath(
|
||||
new URL('../../../../node_modules/.cache/catalog-default-page', import.meta.url)
|
||||
),
|
||||
root: fileURLToPath(new URL('../../../../', import.meta.url)),
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
optimizeDeps: { noDiscovery: true },
|
||||
plugins: [svelte({ configFile: false, hot: false })]
|
||||
plugins: [svelte({ configFile: false })],
|
||||
resolve: { alias: { '#lib': fileURLToPath(new URL('../../', import.meta.url)) } }
|
||||
})
|
||||
// Compile the actual shipped catalog and selected default, not a fixture or legacy adapter.
|
||||
Page = (await server.ssrLoadModule('@luzzle/web.templates/pages')).getPage('default')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { getPage } from '@luzzle/web.templates/pages'
|
||||
import type { TemplatedPiece } from '@luzzle/web.templates/pieces/fields'
|
||||
import NavBanner from '$lib/components/layout/simple/NavBanner.svelte'
|
||||
import { getPieceHelpers } from '$lib/pieces/helpers.js'
|
||||
import NavBanner from '#lib/components/layout/simple/NavBanner.svelte'
|
||||
import { getPieceHelpers } from '#lib/pieces/helpers.js'
|
||||
|
||||
type Props = {
|
||||
piece: TemplatedPiece
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { editorHref, getPieceLinks, previewHref, publishedHref, publishHref } from './navigation'
|
||||
import {
|
||||
editorHref,
|
||||
encodeArchivePath,
|
||||
previewHref,
|
||||
publishedHref,
|
||||
publishHref
|
||||
} from './navigation'
|
||||
|
||||
const { resolve } = vi.hoisted(() => ({
|
||||
resolve: vi.fn(() => {
|
||||
@@ -9,9 +15,6 @@ const { resolve } = vi.hoisted(() => ({
|
||||
}))
|
||||
vi.mock('$app/paths', () => ({ resolve }))
|
||||
|
||||
const piecePath = 'books/%E6%BA%90%20%23%3F%25%26%22.books.md'
|
||||
const route = '/(auth)/admin/piece/[...path]/published'
|
||||
|
||||
test('Published replaces Live without keeping a legacy handler or redirect alias', () => {
|
||||
const routes = new URL('../../routes/(auth)/admin/piece/[...path]/', import.meta.url)
|
||||
expect(existsSync(new URL('live/', routes))).toBe(false)
|
||||
@@ -75,57 +78,13 @@ test('publish navigation encodes a query value rather than a pathname', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.each(
|
||||
['', '/garden'].flatMap((mount) =>
|
||||
['', '/page', '/icon', '/share'].map((suffix) => ({ mount, suffix }))
|
||||
)
|
||||
)(
|
||||
'builds all five links for mount=$mount view=$suffix without a request store',
|
||||
({ mount, suffix }) => {
|
||||
const root = `${mount}/admin/piece/${piecePath}`
|
||||
const url = new URL(`https://app.test${root}/published${suffix}?private=query#fragment`)
|
||||
const before = url.href
|
||||
const links = getPieceLinks(url, route + suffix)
|
||||
expect(links).toEqual({
|
||||
source: `${root}/source`,
|
||||
overview: `${root}/published`,
|
||||
page: `${root}/published/page`,
|
||||
icon: `${root}/published/icon`,
|
||||
share: `${root}/published/share`
|
||||
})
|
||||
expect(JSON.parse(JSON.stringify(links))).toEqual(links)
|
||||
expect(url.href).toBe(before)
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['', '/page', '/icon', '/share'])(
|
||||
'trailing slash on %s does not change piece identity',
|
||||
(suffix) => {
|
||||
const url = new URL(
|
||||
`https://app.test/admin/piece/films/mars-express.films.md/published${suffix}/`
|
||||
)
|
||||
expect(getPieceLinks(url, route + suffix).overview).toBe(
|
||||
'/admin/piece/films/mars-express.films.md/published'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('already-encoded literal percent filenames are preserved, not encoded twice or decoded', () => {
|
||||
const url = new URL('https://app.test/admin/piece/nested/cover%2520name.md/published')
|
||||
expect(getPieceLinks(url, route).source).toBe('/admin/piece/nested/cover%2520name.md/source')
|
||||
})
|
||||
|
||||
test('nested source folders named after view routes remain part of source identity', () => {
|
||||
const url = new URL(
|
||||
'https://app.test/garden/admin/piece/published/page/icon/share/file.md/published/share'
|
||||
)
|
||||
expect(getPieceLinks(url, route + '/share').source).toBe(
|
||||
'/garden/admin/piece/published/page/icon/share/file.md/source'
|
||||
)
|
||||
})
|
||||
|
||||
test('encoded static route segments stay navigable under the matched route identity', () => {
|
||||
const url = new URL('https://app.test/%61dmin/piece/books/book.md/published/icon')
|
||||
expect(getPieceLinks(url, route + '/icon').source).toBe('/%61dmin/piece/books/book.md/source')
|
||||
test.each([
|
||||
['books/源 #?%.books.md', 'books/%E6%BA%90%20%23%3F%25.books.md'],
|
||||
['folder with spaces/a book.books.md', 'folder%20with%20spaces/a%20book.books.md'],
|
||||
['nested/literal%2F.books.md', 'nested/literal%252F.books.md'],
|
||||
['books/📚.books.md', 'books/%F0%9F%93%9A.books.md'],
|
||||
['published/page/icon/share/source/file.md', 'published/page/icon/share/source/file.md']
|
||||
])('encodes raw piece path %s segment-wise without a request store', (file, encoded) => {
|
||||
expect(encodeArchivePath(file)).toBe(encoded)
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,37 +1,24 @@
|
||||
function piecePath(file: string): string {
|
||||
return file.split('/').map(encodeURIComponent).join('/')
|
||||
export function encodeArchivePath(path: string): string {
|
||||
return path.split('/').map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
export function editorHref(file: string): string {
|
||||
return `/admin/piece/${piecePath(file)}/source`
|
||||
return `/admin/piece/${encodeArchivePath(file)}/source`
|
||||
}
|
||||
|
||||
export function publishedHref(file: string): string {
|
||||
return `/admin/piece/${piecePath(file)}/published`
|
||||
return `/admin/piece/${encodeArchivePath(file)}/published`
|
||||
}
|
||||
|
||||
export function publishQuery(file: string): URLSearchParams {
|
||||
return new URLSearchParams({ check: '1', file })
|
||||
}
|
||||
|
||||
export function publishHref(file: string): string {
|
||||
const query = new URLSearchParams({ check: '1', file })
|
||||
return `/admin/publish?${query}`
|
||||
return `/admin/publish?${publishQuery(file)}`
|
||||
}
|
||||
|
||||
export function previewHref(file: string, job?: string): string {
|
||||
const href = `/admin/piece/${piecePath(file)}/preview`
|
||||
const href = `/admin/piece/${encodeArchivePath(file)}/preview`
|
||||
return job === undefined ? href : `${href}/${encodeURIComponent(job)}`
|
||||
}
|
||||
|
||||
export function getPieceLinks(url: URL, routeId: string) {
|
||||
const current = new URL(url)
|
||||
current.pathname = current.pathname.replace(/\/$/, '')
|
||||
const published = new URL(routeId.endsWith('/published') ? 'published/' : './', current)
|
||||
|
||||
return {
|
||||
source: new URL('../source', published).pathname,
|
||||
overview: published.pathname.replace(/\/$/, ''),
|
||||
page: new URL('page', published).pathname,
|
||||
icon: new URL('icon', published).pathname,
|
||||
share: new URL('share', published).pathname
|
||||
}
|
||||
}
|
||||
|
||||
export type PieceLinks = ReturnType<typeof getPieceLinks>
|
||||
@@ -2,7 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest'
|
||||
import type { TemplateConfig as Config } from '@luzzle/web.templates/config'
|
||||
import type { PreviewResult } from '@luzzle/web.jobs'
|
||||
import { createPieceHelpers, type PublicWebPieceAsset } from '@luzzle/web.pieces'
|
||||
import { makeConfig } from '$lib/server/config.fixture.js'
|
||||
import { makeConfig } from '#lib/server/config.fixture.js'
|
||||
import { assemblePreview, type PreviewWorkerResult } from './assemble.server.js'
|
||||
import { previewOutputUrl } from './output-url.js'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { encodeAssetPath } from '$lib/pieces/assets'
|
||||
import { encodeAssetPath } from '#lib/pieces/assets.js'
|
||||
|
||||
export function previewOutputUrl(path: string, job: string): string | undefined {
|
||||
const encoded = encodeAssetPath(path)
|
||||
|
||||
@@ -9,15 +9,15 @@ import {
|
||||
type PieceFrontmatterSchema,
|
||||
type PieceFrontmatter
|
||||
} from '@luzzle/core'
|
||||
import { db } from '$lib/server/database'
|
||||
import { db } from '#lib/server/database/index.js'
|
||||
import type { AppDatabase } from '@luzzle/web.db'
|
||||
import { getPieces } from '$lib/server/pieces'
|
||||
import { getStorage } from '$lib/server/storage'
|
||||
import { getPieces } from '#lib/server/pieces.js'
|
||||
import { getStorage } from '#lib/server/storage.js'
|
||||
import { actions } from '../../routes/(auth)/admin/piece/[...path]/source/+page.server'
|
||||
import { getMarkdownPublicationStatus } from './publication.server.js'
|
||||
import { MarkdownPublicationStatus } from './types.js'
|
||||
|
||||
vi.mock('$lib/server/database', async () => {
|
||||
vi.mock('#lib/server/database/index.js', async () => {
|
||||
const { runWebMigrations } = await import('@luzzle/web.db')
|
||||
const { migrate, getDatabaseClient } = await import('@luzzle/core')
|
||||
const coreDb = getDatabaseClient(':memory:')
|
||||
@@ -28,10 +28,10 @@ vi.mock('$lib/server/database', async () => {
|
||||
if (web.error) throw new Error(`Test web migration failed: ${web.error}`)
|
||||
return { db }
|
||||
})
|
||||
vi.mock('$lib/server/pieces', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() }))
|
||||
vi.mock('$lib/server/storage', () => ({ getStorage: vi.fn() }))
|
||||
vi.mock('$lib/server/config', async () => {
|
||||
const { makeConfig } = await import('$lib/server/config.fixture')
|
||||
vi.mock('#lib/server/pieces.js', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() }))
|
||||
vi.mock('#lib/server/storage.js', () => ({ getStorage: vi.fn() }))
|
||||
vi.mock('#lib/server/config.js', async () => {
|
||||
const { makeConfig } = await import('#lib/server/config.fixture.js')
|
||||
return { config: makeConfig() }
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db } from '$lib/server/database'
|
||||
import { db } from '#lib/server/database/index.js'
|
||||
import { MarkdownPublicationStatus } from './types.js'
|
||||
|
||||
export async function getMarkdownPublicationStatus(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, expect, test, vi } from 'vitest'
|
||||
import { getConfiguredPieceTypes, isConfiguredPieceType } from './types.server.js'
|
||||
|
||||
const { config } = vi.hoisted(() => ({ config: { pieces: [] as Array<{ type: string }> } }))
|
||||
vi.mock('$lib/server/config', () => ({ config }))
|
||||
vi.mock('#lib/server/config.js', () => ({ config }))
|
||||
|
||||
beforeEach(() => {
|
||||
config.pieces = [{ type: 'arbitrary-kind' }, { type: '__proto__' }, { type: 'constructor' }]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { config } from '$lib/server/config'
|
||||
import { config } from '#lib/server/config.js'
|
||||
|
||||
export function getConfiguredPieceTypes(): string[] {
|
||||
return [...new Set(config.pieces.map((piece) => piece.type))]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
||||
import { encodeEvent, SSE_HEADERS } from '$lib/server/sse'
|
||||
import { encodeEvent, SSE_HEADERS } from '#lib/server/sse.js'
|
||||
import { submitUnpublish } from './unpublish-client'
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readJsonEvents } from '$lib/sse'
|
||||
import { readJsonEvents } from '#lib/sse.js'
|
||||
|
||||
export type UnpublishFailureReason =
|
||||
| 'busy'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
|
||||
import { groupAssetOutputs, type OutputFile, type OutputValue } from './assets'
|
||||
import { groupAssetOutputs, groupOutputRows, type OutputFile, type OutputValue } from './assets'
|
||||
import { transformCaption, fileType } from './presentation'
|
||||
import { toggleDisclosure } from './disclosure'
|
||||
import SourceContent from './SourceContent.svelte'
|
||||
@@ -17,34 +17,19 @@
|
||||
const grouped = $derived(groupAssetOutputs(assets, fileUrl))
|
||||
// Presentation ordinals follow root input order, even for file/value subsets.
|
||||
const ordinalMap = $derived.by(() => {
|
||||
const ordinals = new Map<string, number>()
|
||||
for (const asset of assets) {
|
||||
if (!ordinals.has(asset.asset_key)) ordinals.set(asset.asset_key, ordinals.size + 1)
|
||||
}
|
||||
return ordinals
|
||||
const keys = new Set(assets.map((asset) => asset.asset_key))
|
||||
return new Map<string, number>(Array.from(keys, (key, index) => [key, index + 1]))
|
||||
})
|
||||
|
||||
function assetLabel(key: string) {
|
||||
return `Asset ${ordinalMap.get(key)}`
|
||||
}
|
||||
|
||||
function groupByKey<T extends { key: string }>(items: T[]) {
|
||||
const groups = new Map<string, T[]>()
|
||||
for (const item of items) {
|
||||
const group = groups.get(item.key)
|
||||
if (group) group.push(item)
|
||||
else groups.set(item.key, [item])
|
||||
}
|
||||
return Array.from(groups, ([key, items]) => ({ key, items })).sort(
|
||||
(first, second) => ordinalMap.get(first.key)! - ordinalMap.get(second.key)!
|
||||
)
|
||||
}
|
||||
type OutputRow = OutputFile | OutputValue
|
||||
// Keep projected model identities as row and disclosure keys.
|
||||
const families = $derived(
|
||||
grouped.transforms.map((transform) => ({
|
||||
name: transform.name,
|
||||
groups: groupByKey<OutputRow>([...transform.files, ...transform.values])
|
||||
groups: groupOutputRows([...transform.files, ...transform.values], ordinalMap)
|
||||
}))
|
||||
)
|
||||
let expanded = $state.raw<Set<OutputValue>>(new Set())
|
||||
@@ -78,7 +63,7 @@
|
||||
aria-label={`View ${file.label}: ${file.path}`}
|
||||
title={file.path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">View</a
|
||||
rel="external noopener noreferrer">View</a
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
|
||||
import AssetOutputs from './AssetOutputs.svelte'
|
||||
import { asset } from './fixtures/published'
|
||||
import { groupAssetOutputs } from './assets'
|
||||
import { assetPathToUrl } from '$lib/pieces/assets'
|
||||
import { assetPathToUrl } from '#lib/pieces/assets.js'
|
||||
import { fileType, transformCaption, transformFamily } from './presentation'
|
||||
|
||||
function groupAssets(assets: Parameters<typeof groupAssetOutputs>[0], base: string) {
|
||||
@@ -177,7 +177,7 @@ function expectFileViews(html: string) {
|
||||
expect(link).toContain('title=')
|
||||
expect(link).not.toContain('truncated')
|
||||
expect(link).toContain('target="_blank"')
|
||||
expect(link).toContain('rel="noopener noreferrer"')
|
||||
expect(link).toContain('rel="external noopener noreferrer"')
|
||||
expect(link).not.toMatch(/\sdownload(?:\s|=|>)/)
|
||||
const href = link.match(/\bhref="([^"]*)"/)?.[1]
|
||||
expect(href).toBeDefined()
|
||||
@@ -1019,7 +1019,6 @@ test('parent owns identity-keyed disclosure state and lazily delegates only open
|
||||
expect(effect).not.toContain('expanded.')
|
||||
expect(effect).not.toContain('toggleDisclosure')
|
||||
expect(source).toContain('{#each group.items as row, rowIndex (row)}')
|
||||
expect(source).toContain('groupByKey<OutputRow>([...transform.files, ...transform.values])')
|
||||
expect(source).toContain('`${titleId}-source-${familyIndex}-${groupIndex}-${rowIndex}`')
|
||||
expect(source).toContain('`${transformCaption(output)}: ${assetLabel(group.key)}, ${field}`')
|
||||
expect(source).toContain('onclick={() => toggle(row)}')
|
||||
@@ -1427,7 +1426,7 @@ test('generic preview-shaped callback URLs retain only native View, full outputs
|
||||
for (const link of [view]) {
|
||||
expect(link).toContain(`href="${url}"`)
|
||||
expect(link).toContain(`title="${escapedAttribute(path)}"`)
|
||||
expect(link).toContain('rel="noopener noreferrer"')
|
||||
expect(link).toContain('rel="external noopener noreferrer"')
|
||||
}
|
||||
expect(view).toContain(`aria-label="${escapedAttribute(`View custom.full.output: ${path}`)}"`)
|
||||
expect(view).toContain('target="_blank"')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { expect, test } from 'vitest'
|
||||
import { render } from 'svelte/server'
|
||||
import { createRawSnippet } from 'svelte'
|
||||
import { assetPathToUrl } from '$lib/pieces/assets'
|
||||
import { assetPathToUrl } from '#lib/pieces/assets.js'
|
||||
import { asset } from './fixtures/published'
|
||||
import PieceOverview from './PieceOverview.svelte'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import type { TemplatedPiece } from '@luzzle/web.templates/pieces/fields'
|
||||
import Opengraph from '$lib/pieces/components/opengraph.svelte'
|
||||
import Opengraph from '#lib/pieces/components/opengraph.svelte'
|
||||
import { OpengraphImageWidth, OpengraphImageHeight } from '@luzzle/web.pieces'
|
||||
|
||||
let { piece }: { piece: TemplatedPiece } = $props()
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock('$app/paths', () => ({
|
||||
}))
|
||||
// This synchronous stub establishes viewport markup and helper URLs,
|
||||
// not the actual static renderer or client prop updates.
|
||||
vi.mock('$lib/pieces/components/opengraph.svelte', async () => ({
|
||||
vi.mock('#lib/pieces/components/opengraph.svelte', async () => ({
|
||||
default: (await import('./fixtures/Composition.svelte')).default
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { OutputValue } from './assets'
|
||||
import { formatSource, type SourceToken } from './source'
|
||||
import '$lib/components/editor/themes/token-colors.css'
|
||||
import '#lib/components/editor/themes/token-colors.css'
|
||||
|
||||
let { value, label }: { value: OutputValue; label: string } = $props()
|
||||
const source = $derived(formatSource(value.value ?? '', value.mime))
|
||||
@@ -20,7 +20,7 @@
|
||||
const text = source
|
||||
const mime = value.mime
|
||||
let active = true
|
||||
import('./source-highlight')
|
||||
import('./source-highlight.js')
|
||||
.then(({ highlightSource }) => highlightSource(text, mime))
|
||||
.then((tokens) => {
|
||||
if (active) highlighted = { source: text, mime, tokens }
|
||||
|
||||
@@ -67,7 +67,7 @@ test('large source remains available in full rather than truncating the record',
|
||||
|
||||
test('source view uses the editor token palette with a bounded keyboard-scrollable viewport', () => {
|
||||
const file = readFileSync(new URL('./SourceContent.svelte', import.meta.url), 'utf8')
|
||||
expect(file).toContain("import '$lib/components/editor/themes/token-colors.css'")
|
||||
expect(file).toContain("import '#lib/components/editor/themes/token-colors.css'")
|
||||
expect(file).toContain('class="syntax-highlight"')
|
||||
expect(file).toContain('max-height: 24rem')
|
||||
expect(file).toContain('overflow: auto')
|
||||
@@ -79,7 +79,7 @@ test('source view uses the editor token palette with a bounded keyboard-scrollab
|
||||
|
||||
test('highlighting is loaded after expansion/mount with a current-source guard and safe plain fallback', () => {
|
||||
const file = readFileSync(new URL('./SourceContent.svelte', import.meta.url), 'utf8')
|
||||
expect(file).toContain("import('./source-highlight')")
|
||||
expect(file).toContain("import('./source-highlight.js')")
|
||||
expect(file).toContain('highlighted?.source === source && highlighted.mime === value.mime')
|
||||
expect(file).toContain('if (active) highlighted =')
|
||||
expect(file).toContain('active = false')
|
||||
|
||||
@@ -2,31 +2,44 @@
|
||||
import './ViewerBar.css'
|
||||
import { page } from '$app/state'
|
||||
import ArrowLeftIcon from 'virtual:icons/ph/arrow-left'
|
||||
import type { PieceLinks } from '$lib/pieces/navigation'
|
||||
import { resolve } from '$app/paths'
|
||||
import { encodeArchivePath } from '#lib/pieces/navigation.js'
|
||||
|
||||
let { links }: { links: PieceLinks } = $props()
|
||||
const tabs = $derived(
|
||||
(['overview', 'page', 'icon', 'share'] as const).map((tab) => ({
|
||||
label: tab[0].toUpperCase() + tab.slice(1),
|
||||
href: links[tab]
|
||||
}))
|
||||
)
|
||||
let { file }: { file: string } = $props()
|
||||
const path = $derived(encodeArchivePath(file))
|
||||
const publishedRoute = '/(auth)/admin/piece/[...path]/published'
|
||||
</script>
|
||||
|
||||
<header class="viewer-bar">
|
||||
<a href={links.source} class="viewer-bar-back" aria-label="Back to editor" title="Back to editor">
|
||||
<a
|
||||
href={resolve('/(auth)/admin/piece/[...path]/source', { path })}
|
||||
class="viewer-bar-back"
|
||||
aria-label="Back to editor"
|
||||
title="Back to editor"
|
||||
>
|
||||
<ArrowLeftIcon aria-hidden="true" focusable="false" />
|
||||
</a>
|
||||
<nav class="viewer-bar-links" aria-label="Published views">
|
||||
{#each tabs as tab (tab.label)}
|
||||
<a
|
||||
class="viewer-bar-link"
|
||||
href={tab.href}
|
||||
aria-current={page.url.pathname.replace(/\/$/, '') === new URL(tab.href, page.url).pathname
|
||||
? 'page'
|
||||
: undefined}>{tab.label}</a
|
||||
>
|
||||
{/each}
|
||||
<a
|
||||
class="viewer-bar-link"
|
||||
href={resolve(publishedRoute, { path })}
|
||||
aria-current={page.route.id === publishedRoute ? 'page' : undefined}>Overview</a
|
||||
>
|
||||
<a
|
||||
class="viewer-bar-link"
|
||||
href={resolve(`${publishedRoute}/page`, { path })}
|
||||
aria-current={page.route.id === `${publishedRoute}/page` ? 'page' : undefined}>Page</a
|
||||
>
|
||||
<a
|
||||
class="viewer-bar-link"
|
||||
href={resolve(`${publishedRoute}/icon`, { path })}
|
||||
aria-current={page.route.id === `${publishedRoute}/icon` ? 'page' : undefined}>Icon</a
|
||||
>
|
||||
<a
|
||||
class="viewer-bar-link"
|
||||
href={resolve(`${publishedRoute}/share`, { path })}
|
||||
aria-current={page.route.id === `${publishedRoute}/share` ? 'page' : undefined}>Share</a
|
||||
>
|
||||
</nav>
|
||||
<span class="viewer-bar-label">Published</span>
|
||||
</header>
|
||||
@@ -1,19 +1,33 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { beforeEach, expect, test, vi } from 'vitest'
|
||||
import { render } from 'svelte/server'
|
||||
import type { PieceLinks } from '$lib/pieces/navigation'
|
||||
import ViewerHeader from './ViewerHeader.svelte'
|
||||
|
||||
const { page, resolve } = vi.hoisted(() => ({
|
||||
page: { url: new URL('https://app.test/') },
|
||||
resolve: vi.fn(() => {
|
||||
throw new Error('Could not get the request store. This is an internal error.')
|
||||
})
|
||||
}))
|
||||
const { page, mount, resolve } = vi.hoisted(() => {
|
||||
const mount = { prefix: '' }
|
||||
return {
|
||||
mount,
|
||||
page: {
|
||||
url: new URL('https://app.test/'),
|
||||
route: { id: '/(auth)/admin/piece/[...path]/published' }
|
||||
},
|
||||
resolve: vi.fn(
|
||||
(route: string, params: { path: string }) =>
|
||||
`${mount.prefix}${route.replace('/(auth)', '').replace('[...path]', params.path)}`
|
||||
)
|
||||
}
|
||||
})
|
||||
vi.mock('$app/state', () => ({ page }))
|
||||
vi.mock('$app/paths', () => ({ resolve }))
|
||||
|
||||
function links(root = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md'): PieceLinks {
|
||||
// This resolver double tests delegation and encoded params, not native Kit/browser routing.
|
||||
const file = 'books/源 #?%.books.md'
|
||||
const encodedPath = 'books/%E6%BA%90%20%23%3F%25.books.md'
|
||||
const publishedRoute = '/(auth)/admin/piece/[...path]/published'
|
||||
const tabSuffixes = { overview: '', page: '/page', icon: '/icon', share: '/share' }
|
||||
|
||||
function links(path = encodedPath, prefix = '') {
|
||||
const root = `${prefix}/admin/piece/${path}`
|
||||
return {
|
||||
source: `${root}/source`,
|
||||
overview: `${root}/published`,
|
||||
@@ -23,17 +37,29 @@ function links(root = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md'): Piec
|
||||
}
|
||||
}
|
||||
|
||||
function expectNavigation(path = encodedPath) {
|
||||
const routes = [
|
||||
'/(auth)/admin/piece/[...path]/source',
|
||||
...Object.values(tabSuffixes).map((suffix) => publishedRoute + suffix)
|
||||
]
|
||||
expect(resolve).toHaveBeenCalledTimes(routes.length)
|
||||
for (const route of routes) expect(resolve).toHaveBeenCalledWith(route, { path })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
page.url = new URL('https://app.test/')
|
||||
page.route.id = publishedRoute
|
||||
mount.prefix = ''
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test.each(['overview', 'page', 'icon', 'share'] as const)(
|
||||
'%s is active using serialized loader links without a render request store',
|
||||
'%s is active by route ID and delegates navigation to resolve',
|
||||
(tab) => {
|
||||
const navigation = links()
|
||||
page.url = new URL(navigation[tab], 'https://app.test')
|
||||
const html = render(ViewerHeader, { props: { links: navigation } }).body
|
||||
page.route.id = publishedRoute + tabSuffixes[tab]
|
||||
const html = render(ViewerHeader, { props: { file } }).body
|
||||
expect(html).toContain(`href="${navigation.source}"`)
|
||||
expect(html).toContain('aria-label="Back to editor"')
|
||||
expect(html).toContain('title="Back to editor"')
|
||||
@@ -44,42 +70,69 @@ test.each(['overview', 'page', 'icon', 'share'] as const)(
|
||||
expect(html).not.toContain('Logs')
|
||||
expect(html).not.toContain('<main')
|
||||
expect(html).not.toContain('target="_blank"')
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
expectNavigation()
|
||||
}
|
||||
)
|
||||
|
||||
test('mounted loader links preserve active tab and editor destination', () => {
|
||||
const navigation = links('/garden/admin/piece/books/book.md')
|
||||
page.url = new URL(navigation.icon, 'https://app.test')
|
||||
const html = render(ViewerHeader, { props: { links: navigation } }).body
|
||||
expect(html).toContain(`href="${navigation.icon}" aria-current="page"`)
|
||||
expect(html).toContain(`href="${navigation.source}"`)
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
test.each([
|
||||
[file, encodedPath],
|
||||
['folder with spaces/a book.books.md', 'folder%20with%20spaces/a%20book.books.md'],
|
||||
['nested/literal%2F.books.md', 'nested/literal%252F.books.md'],
|
||||
['books/📚.books.md', 'books/%F0%9F%93%9A.books.md'],
|
||||
['published/page/icon/share/source/file.md', 'published/page/icon/share/source/file.md']
|
||||
])('raw file %s becomes an encoded-once path param for all five routes', (raw, encoded) => {
|
||||
// A different URL identity must not be used to derive the file or its mount.
|
||||
page.url = new URL('https://app.test/garden/admin/piece/other.md/published/share?x=1#old')
|
||||
page.route.id = publishedRoute + '/share'
|
||||
const html = render(ViewerHeader, { props: { file: raw } }).body
|
||||
for (const href of Object.values(links(encoded))) expect(html).toContain(`href="${href}"`)
|
||||
expectNavigation(encoded)
|
||||
expect(html).not.toContain('/garden/')
|
||||
expect(html).not.toContain('other.md')
|
||||
})
|
||||
|
||||
test('trailing slash still identifies the active view', () => {
|
||||
const navigation = links()
|
||||
page.url = new URL(navigation.overview + '/', 'https://app.test')
|
||||
const html = render(ViewerHeader, { props: { links: navigation } }).body
|
||||
expect(html).toContain(`href="${navigation.overview}" aria-current="page"`)
|
||||
test.each(['overview', 'page', 'icon', 'share'] as const)(
|
||||
'controlled resolver prefix and trailing slash leave %s active by route ID',
|
||||
(tab) => {
|
||||
mount.prefix = '/garden'
|
||||
const navigation = links(encodedPath, mount.prefix)
|
||||
page.url = new URL(`${navigation[tab]}/?x=1#fragment`, 'https://app.test')
|
||||
page.route.id = publishedRoute + tabSuffixes[tab]
|
||||
const html = render(ViewerHeader, { props: { file } }).body
|
||||
expect(html).toContain(`href="${navigation[tab]}" aria-current="page"`)
|
||||
expect(html.match(/aria-current="page"/g)).toHaveLength(1)
|
||||
expect(html).toContain(`href="${navigation.source}"`)
|
||||
// Prefix handling belongs to resolve, not the raw file or its encoded param.
|
||||
expectNavigation()
|
||||
}
|
||||
)
|
||||
|
||||
test('active tab follows route ID rather than a coincidentally matching URL pathname', () => {
|
||||
page.url = new URL(links().icon, 'https://app.test')
|
||||
page.route.id = publishedRoute + '/page'
|
||||
const html = render(ViewerHeader, { props: { file } }).body
|
||||
expect(html).toContain(`href="${links().page}" aria-current="page"`)
|
||||
expect(html).not.toContain(`href="${links().icon}" aria-current="page"`)
|
||||
expect(html.match(/aria-current="page"/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('replacement loader links do not retain a previous source identity', () => {
|
||||
render(ViewerHeader, { props: { links: links('/admin/piece/first.md') } })
|
||||
const navigation = links('/admin/piece/second.md')
|
||||
page.url = new URL(navigation.share, 'https://app.test')
|
||||
const html = render(ViewerHeader, { props: { links: navigation } }).body
|
||||
test('replacement file renders do not retain a previous source identity', () => {
|
||||
render(ViewerHeader, { props: { file: 'first.md' } })
|
||||
vi.clearAllMocks()
|
||||
page.url = new URL(links('second.md').share, 'https://app.test')
|
||||
page.route.id = publishedRoute + '/share'
|
||||
const html = render(ViewerHeader, { props: { file: 'second.md' } }).body
|
||||
expect(html).toContain('/second.md/published/share')
|
||||
expect(html).toContain('aria-current="page"')
|
||||
expect(html).not.toContain('first')
|
||||
expectNavigation('second.md')
|
||||
})
|
||||
|
||||
test('back link uses the same decorative arrow icon as Preview, not a visible text label', () => {
|
||||
const navigation = links()
|
||||
const html = render(ViewerHeader, { props: { links: navigation } }).body
|
||||
const html = render(ViewerHeader, { props: { file } }).body
|
||||
const anchors = html.match(/<a\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g) ?? []
|
||||
const back = anchors.find((anchor) => anchor.includes('aria-label="Back to editor"'))!
|
||||
expect(back).toContain(`href="${navigation.source}"`)
|
||||
expect(back).toContain(`href="${links().source}"`)
|
||||
expect(back).toContain('title="Back to editor"')
|
||||
expect(back).toMatch(/<svg\b[^>]*aria-hidden="true"/)
|
||||
expect(back).toMatch(/<svg\b[^>]*focusable="false"/)
|
||||
@@ -90,7 +143,7 @@ test('back link uses the same decorative arrow icon as Preview, not a visible te
|
||||
.trim()
|
||||
).toBe('')
|
||||
expect(back).not.toContain('target=')
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
expectNavigation()
|
||||
})
|
||||
|
||||
test('Published header consumes the narrowly scoped shared bar stylesheet', () => {
|
||||
@@ -141,7 +194,7 @@ test('shared bar stacks mode/back above full-width wrapping tabs on mobile, with
|
||||
expect(css).toContain('min-height: 44px')
|
||||
expect(mobile).not.toContain('display: none')
|
||||
expect(mobile).not.toContain('position: absolute')
|
||||
const html = render(ViewerHeader, { props: { links: links() } }).body
|
||||
const html = render(ViewerHeader, { props: { file } }).body
|
||||
expect(html).toContain('<nav class="viewer-bar-links" aria-label="Published views"')
|
||||
expect(html).toContain('class="viewer-bar-label">Published</span>')
|
||||
for (const label of ['Overview', 'Page', 'Icon', 'Share']) expect(html).toContain(`>${label}</a>`)
|
||||
@@ -168,9 +221,9 @@ test('shared active/hover/focus states are token-based, visibly distinguish sele
|
||||
expect(css).not.toContain('rgba(')
|
||||
})
|
||||
|
||||
test('rendering after an await still needs no paths API', async () => {
|
||||
test('mocked SSR after an await still delegates encoded route params', async () => {
|
||||
await Promise.resolve()
|
||||
const html = render(ViewerHeader, { props: { links: links() } }).body
|
||||
const html = render(ViewerHeader, { props: { file } }).body
|
||||
expect(html).toContain('Back to editor')
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
expectNavigation()
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { groupAssetOutputs } from './assets'
|
||||
import { assetPathToUrl } from '$lib/pieces/assets'
|
||||
import { groupAssetOutputs, groupOutputRows } from './assets'
|
||||
import { assetPathToUrl } from '#lib/pieces/assets.js'
|
||||
import { asset } from './fixtures/published'
|
||||
|
||||
function groupAssets(assets: Parameters<typeof groupAssetOutputs>[0], base: string) {
|
||||
@@ -13,6 +13,33 @@ function transform(records: Parameters<typeof groupAssetOutputs>[0], name: strin
|
||||
return section!
|
||||
}
|
||||
|
||||
test('groups projected rows in asset order without replacing row identities or reordering input', () => {
|
||||
const section = transform(
|
||||
[
|
||||
asset('image.original', 'second.jpg', 'second'),
|
||||
asset('image.original', 'first.jpg', 'first'),
|
||||
{ ...asset('image.palette', null, 'first', 'text/plain'), content: '#123456' },
|
||||
{ ...asset('image.palette', null, 'second', 'text/plain'), content: '#abcdef' }
|
||||
],
|
||||
'image'
|
||||
)
|
||||
const rows = [...section.files, ...section.values]
|
||||
const before = [...rows]
|
||||
const ordinals = new Map([
|
||||
['first', 1],
|
||||
['second', 2]
|
||||
])
|
||||
const groups = groupOutputRows(rows, ordinals)
|
||||
|
||||
expect(groups.map((group) => group.key)).toEqual(['first', 'second'])
|
||||
expect(groups[0].items[0]).toBe(section.files[1])
|
||||
expect(groups[0].items[1]).toBe(section.values[0])
|
||||
expect(groups[1].items[0]).toBe(section.files[0])
|
||||
expect(groups[1].items[1]).toBe(section.values[1])
|
||||
expect(rows).toEqual(before)
|
||||
expect(groupOutputRows([], ordinals)).toEqual([])
|
||||
})
|
||||
|
||||
test('groups every producer uniformly while retaining keys, originals, variants and attachments as recorded', () => {
|
||||
const original = asset('image.original', 'first/cover.jpg', 'first')
|
||||
const records = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
|
||||
import { encodeAssetPath } from '$lib/pieces/assets'
|
||||
import { encodeAssetPath } from '#lib/pieces/assets.js'
|
||||
import { transformFamily } from './presentation'
|
||||
|
||||
export type OutputFile = {
|
||||
@@ -18,6 +18,20 @@ export type OutputValue = {
|
||||
mime: string
|
||||
}
|
||||
|
||||
export type OutputRow = OutputFile | OutputValue
|
||||
|
||||
export function groupOutputRows(items: OutputRow[], ordinals: ReadonlyMap<string, number>) {
|
||||
const groups = new Map<string, OutputRow[]>()
|
||||
for (const item of items) {
|
||||
const group = groups.get(item.key)
|
||||
if (group) group.push(item)
|
||||
else groups.set(item.key, [item])
|
||||
}
|
||||
return Array.from(groups, ([key, items]) => ({ key, items })).sort(
|
||||
(first, second) => ordinals.get(first.key)! - ordinals.get(second.key)!
|
||||
)
|
||||
}
|
||||
|
||||
export type OutputFamily = {
|
||||
name: string
|
||||
files: OutputFile[]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PublicWebPiece } from '@luzzle/web.pieces'
|
||||
import { getPieceHelpers } from '$lib/pieces/helpers'
|
||||
import { getPieceHelpers } from '#lib/pieces/helpers.js'
|
||||
let { piece }: { piece: PublicWebPiece } = $props()
|
||||
const helpers = $derived(getPieceHelpers(piece))
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PublicWebPiece } from '@luzzle/web.pieces'
|
||||
import { getPieceHelpers } from '$lib/pieces/helpers'
|
||||
import { getPieceHelpers } from '#lib/pieces/helpers.js'
|
||||
let { piece, size, lazy }: { piece: PublicWebPiece; size: { width: number }; lazy: boolean } =
|
||||
$props()
|
||||
const helpers = $derived(getPieceHelpers(piece))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { PublicWebPiece } from '@luzzle/web.pieces'
|
||||
import { getPieceHelpers } from '$lib/pieces/helpers'
|
||||
import { resolve } from '$app/paths'
|
||||
import { getPieceHelpers } from '#lib/pieces/helpers.js'
|
||||
let { piece, tags }: { piece: PublicWebPiece; tags: Array<{ slug: string; tag: string }> } =
|
||||
$props()
|
||||
const helpers = $derived(getPieceHelpers(piece))
|
||||
@@ -10,6 +11,6 @@
|
||||
<h1>{piece.title}</h1>
|
||||
<img src={helpers.getPieceImageUrl('cover', 500, 'jpg')} alt="Published cover" />
|
||||
{#each tags as tag (tag.slug)}
|
||||
<a href="/tags/{tag.slug}">{tag.tag}</a>
|
||||
<a href={resolve('/tags/[tag]', { tag: encodeURIComponent(tag.slug) })}>{tag.tag}</a>
|
||||
{/each}
|
||||
</main>
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import type { Handle } from '@sveltejs/kit'
|
||||
import { createAssetCspHandle } from './asset-csp'
|
||||
|
||||
const policy = [
|
||||
@@ -31,7 +31,7 @@ function htmlResponse(csp = policy) {
|
||||
async function apply(handle: Handle, response: Response) {
|
||||
return handle({
|
||||
event: {} as Parameters<Handle>[0]['event'],
|
||||
resolve: vi.fn().mockResolvedValue(response)
|
||||
resolve: vi.fn<Parameters<Handle>[0]['resolve']>().mockResolvedValue(response)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,9 +63,11 @@ test('adds runtime prefixes to existing source lists while preserving framework
|
||||
"'nonce-framework'",
|
||||
'https://app.example.test/public/'
|
||||
])
|
||||
|
||||
for (const name of ['default-src', 'connect-src', 'base-uri'])
|
||||
expect(csp[name]).toEqual(["'self'"])
|
||||
for (const name of ['object-src', 'frame-ancestors']) expect(csp[name]).toEqual(["'none'"])
|
||||
|
||||
expect(csp['style-src']).toContain("'unsafe-inline'")
|
||||
expect(csp['img-src']).toContain('data:')
|
||||
expect(response.headers.get('content-security-policy-report-only')).toBe("default-src 'none'")
|
||||
@@ -81,6 +83,7 @@ test.each(['', '/local/assets', 'relative/assets'])(
|
||||
async (app_assets) => {
|
||||
const handle = createAssetCspHandle({ app_assets, luzzle_assets: '' })
|
||||
const response = htmlResponse()
|
||||
|
||||
expect(await apply(handle, response)).toBe(response)
|
||||
expect(response.headers.get('content-security-policy')).toBe(policy)
|
||||
}
|
||||
@@ -93,7 +96,9 @@ test('the piece CDN is image-only and does not widen script/style/font policy',
|
||||
})
|
||||
const response = await apply(handle, htmlResponse())
|
||||
const csp = directives(response)
|
||||
|
||||
expect(csp['img-src']).toContain('https://pieces.test/files/')
|
||||
|
||||
for (const name of ['script-src', 'style-src', 'font-src'])
|
||||
expect(csp[name].join(' ')).not.toContain('pieces.test')
|
||||
})
|
||||
@@ -102,6 +107,7 @@ test('does not repeat shared or already-present sources', async () => {
|
||||
const source = 'https://shared.test/prefix/'
|
||||
const handle = createAssetCspHandle({ app_assets: source, luzzle_assets: source })
|
||||
const first = await apply(handle, htmlResponse())
|
||||
|
||||
expect(directives(first)['img-src'].filter((value) => value === source)).toHaveLength(1)
|
||||
expect(await apply(handle, first)).toBe(first)
|
||||
})
|
||||
@@ -118,7 +124,9 @@ test('does not mutate immutable-style upstream headers or lose multiple cookies'
|
||||
const mutation = vi.spyOn(original.headers, 'set').mockImplementation(() => {
|
||||
throw new TypeError('immutable')
|
||||
})
|
||||
|
||||
const response = await apply(handle, original)
|
||||
|
||||
expect(mutation).not.toHaveBeenCalled()
|
||||
expect(original.headers.get('content-security-policy')).toBe(policy)
|
||||
expect(response.headers.getSetCookie()).toEqual(cookies)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks'
|
||||
import type { Config } from '@luzzle/web.config'
|
||||
import type { Handle } from '@sveltejs/kit'
|
||||
|
||||
type AssetUrls = Pick<Config['url'], 'app_assets' | 'luzzle_assets'>
|
||||
type AssetSources = ReadonlyMap<string, readonly string[]>
|
||||
|
||||
function assetSource(value: string, field: string): string | undefined {
|
||||
if (!value) return undefined
|
||||
|
||||
const invalid = () =>
|
||||
new Error(
|
||||
`${field} must be an HTTP(S) URL prefix or a same-origin path without credentials, query, fragment or CSP delimiters.`
|
||||
)
|
||||
|
||||
if (/[\s\p{Cc}\\;,'"<>`?#]/u.test(value)) throw invalid()
|
||||
|
||||
const absolute = /^[a-z][a-z\d+.-]*:/i.test(value)
|
||||
|
||||
if (!absolute) {
|
||||
if (value.startsWith('//')) throw invalid()
|
||||
return undefined
|
||||
@@ -35,8 +38,11 @@ function assetSources(urls: AssetUrls): AssetSources {
|
||||
const app = assetSource(urls.app_assets, 'url.app_assets')
|
||||
const pieces = assetSource(urls.luzzle_assets, 'url.luzzle_assets')
|
||||
const appSources: string[] = []
|
||||
|
||||
if (app) appSources.push(app)
|
||||
|
||||
const imageSources = [...appSources]
|
||||
|
||||
if (pieces && !imageSources.includes(pieces)) imageSources.push(pieces)
|
||||
|
||||
return new Map([
|
||||
@@ -83,12 +89,18 @@ export function createAssetCspHandle(urls: AssetUrls): Handle {
|
||||
return async ({ event, resolve }) => {
|
||||
const response = await resolve(event)
|
||||
const policy = response.headers.get('content-security-policy')
|
||||
|
||||
if (!policy || !/^text\/html(?:;|$)/i.test(response.headers.get('content-type') ?? ''))
|
||||
return response
|
||||
|
||||
const updated = addAssetSources(policy, additions)
|
||||
|
||||
if (updated === policy) return response
|
||||
|
||||
const headers = new Headers(response.headers)
|
||||
|
||||
headers.set('content-security-policy', updated)
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks'
|
||||
import { afterAll, afterEach, beforeEach, expect, test, vi } from 'vitest'
|
||||
import { validateConfig } from '@luzzle/web.config'
|
||||
import { parseSetCookieHeader } from 'better-auth/cookies'
|
||||
import { credentialsAuth, oidcAuth } from '$lib/server/config.fixture'
|
||||
import type { Handle } from '@sveltejs/kit'
|
||||
import { createAuth, createAuthHandle, createSessionReader } from './index'
|
||||
import { credentialsAuth, oidcAuth } from '#lib/server/config.fixture.js'
|
||||
import { createAuth, createAuthHandle, createSessionReader } from './index.js'
|
||||
|
||||
// Exercise production rate limiting and origin checks, not the library's test defaults.
|
||||
vi.hoisted(() => vi.stubEnv('NODE_ENV', 'production'))
|
||||
@@ -27,11 +27,13 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
afterAll(() => vi.unstubAllEnvs())
|
||||
|
||||
function request(path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
let method = 'GET'
|
||||
let requestBody: string | undefined
|
||||
|
||||
if (body !== undefined) {
|
||||
method = 'POST'
|
||||
requestBody = JSON.stringify(body)
|
||||
@@ -58,8 +60,10 @@ function appSession(cookie: string) {
|
||||
|
||||
test('public-only mode needs neither a secret nor a valid auth URL', async () => {
|
||||
expect(createAuth(undefined, '')).toBeUndefined()
|
||||
|
||||
const read = createSessionReader(undefined, new Headers())
|
||||
const first = read()
|
||||
|
||||
expect(read()).toBe(first)
|
||||
expect(await first).toBeNull()
|
||||
})
|
||||
@@ -76,6 +80,7 @@ test('32-character resolved secrets are accepted after interpolating a shorter p
|
||||
{ auth: { ...credentialsAuth, secret: '${TEST_AUTH_SECRET}' } },
|
||||
{ TEST_AUTH_SECRET: secret }
|
||||
)
|
||||
|
||||
auth = createAuth(config.auth, origin)!
|
||||
expect((await auth.$context).secret).toBe(secret)
|
||||
expect((await request('/sign-in/credentials', credentials)).status).toBe(200)
|
||||
@@ -91,10 +96,14 @@ test('an environment keyring cannot replace YAML secrets or keep cookies valid a
|
||||
currentVersion: 1,
|
||||
legacySecret: credentialsAuth.secret
|
||||
})
|
||||
|
||||
const login = await request('/sign-in/credentials', credentials)
|
||||
|
||||
expect(login.status).toBe(200)
|
||||
|
||||
const cookie = cookies(login)
|
||||
const rotated = 'replacement-session-secret-with-at-least-32-characters'
|
||||
|
||||
auth = createAuth({ ...credentialsAuth, secret: rotated }, origin)!
|
||||
expect((await auth.$context).secret).toBe(rotated)
|
||||
expect(await appSession(cookie)).toBeNull()
|
||||
@@ -103,6 +112,7 @@ test('an environment keyring cannot replace YAML secrets or keep cookies valid a
|
||||
test('native log severity is retained without forwarding sensitive messages or arguments', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const info = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
|
||||
auth.options.logger?.log?.('error', 'sensitive native message', { token: 'private-token' })
|
||||
auth.options.logger?.log?.('info', 'another sensitive message')
|
||||
expect(error).toHaveBeenLastCalledWith('Authentication event', { source: 'better-auth' })
|
||||
@@ -111,6 +121,7 @@ test('native log severity is retained without forwarding sensitive messages or a
|
||||
|
||||
test('native HTTP login validates YAML credentials and cookie-less first-login origin', async () => {
|
||||
const bad = await request('/sign-in/credentials', { ...credentials, password: 'wrong' })
|
||||
|
||||
expect(bad.status).toBe(401)
|
||||
expect((await bad.json()).code).toBe('CredentialsSignin')
|
||||
expect(cookies(bad)).toBe('')
|
||||
@@ -118,10 +129,12 @@ test('native HTTP login validates YAML credentials and cookie-less first-login o
|
||||
const crossOrigin = await request('/sign-in/credentials', credentials, {
|
||||
origin: 'https://another.example.test'
|
||||
})
|
||||
|
||||
expect(crossOrigin.status).toBe(403)
|
||||
expect(cookies(crossOrigin)).toBe('')
|
||||
|
||||
const good = await request('/sign-in/credentials', credentials)
|
||||
|
||||
expect(good.status).toBe(200)
|
||||
expect(await good.json()).toEqual({ success: true })
|
||||
expect(cookies(good)).toContain('session_data=')
|
||||
@@ -133,7 +146,9 @@ test('native HTTP login validates YAML credentials and cookie-less first-login o
|
||||
|
||||
test('native auth endpoints accept a canonical URL with a trailing slash', async () => {
|
||||
auth = createAuth(credentialsAuth, `${origin}/`)!
|
||||
|
||||
const login = await request('/sign-in/credentials', credentials)
|
||||
|
||||
expect(login.status).toBe(200)
|
||||
expect((await request('/sign-out', {}, { cookie: cookies(login) })).status).toBe(200)
|
||||
})
|
||||
@@ -168,6 +183,7 @@ test('the lazy app session reader memoizes and preserves fixed expiry across an
|
||||
})
|
||||
|
||||
const damaged = cookie.replace(/(session_data=.{12})/, '$1x')
|
||||
|
||||
expect(await appSession(damaged)).toBeNull()
|
||||
vi.setSystemTime(new Date('2026-11-04T12:00:00Z'))
|
||||
expect(await appSession(cookie)).toEqual(session)
|
||||
@@ -178,21 +194,30 @@ test('the lazy app session reader memoizes and preserves fixed expiry across an
|
||||
test('bypassing the cookie cache cannot renew the underlying session beyond seven days', async () => {
|
||||
const login = await request('/sign-in/credentials', credentials)
|
||||
const cookie = cookies(login)
|
||||
|
||||
vi.setSystemTime(new Date('2026-10-31T12:00:00Z'))
|
||||
|
||||
const response = await request('/get-session?disableCookieCache=true', undefined, { cookie })
|
||||
|
||||
expect((await response.json()).session.expiresAt).toBe('2026-11-05T12:00:00.000Z')
|
||||
vi.setSystemTime(new Date('2026-11-05T12:00:01Z'))
|
||||
|
||||
const expired = await request('/get-session?disableCookieCache=true', undefined, { cookie })
|
||||
|
||||
expect(await expired.json()).toBeNull()
|
||||
})
|
||||
|
||||
test('native logout clears browser cookies and unconfigured native strategies cannot create sessions', async () => {
|
||||
const login = await request('/sign-in/credentials', credentials)
|
||||
const logout = await request('/sign-out', {}, { cookie: cookies(login) })
|
||||
|
||||
expect(logout.status).toBe(200)
|
||||
|
||||
const cleared = parseSetCookieHeader(logout.headers.get('set-cookie') ?? '')
|
||||
const sessionCookies = [...cleared].filter(([name]) => name.includes('session_'))
|
||||
|
||||
expect(sessionCookies.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
for (const [, attributes] of sessionCookies) expect(attributes.value).toBe('')
|
||||
|
||||
const signup = await request('/sign-up/email', {
|
||||
@@ -206,9 +231,12 @@ test('native logout clears browser cookies and unconfigured native strategies ca
|
||||
email: `${credentials.username}@luzzle.local`,
|
||||
password: credentials.password
|
||||
})
|
||||
|
||||
expect((await passwordLogin.json()).code).toBe('EMAIL_PASSWORD_DISABLED')
|
||||
expect(cookies(passwordLogin)).toBe('')
|
||||
|
||||
const social = await request('/sign-in/social', { provider: 'oidc', callbackURL: '/admin' })
|
||||
|
||||
expect(social.ok).toBe(false)
|
||||
expect(cookies(social)).not.toContain('session_data=')
|
||||
})
|
||||
@@ -225,12 +253,15 @@ test('the Kit adapter handles native login and leaves proxy verification outside
|
||||
}),
|
||||
locals: {}
|
||||
} as Parameters<Handle>[0]['event']
|
||||
|
||||
const resolve = vi.fn()
|
||||
const login = await handle({ event: loginEvent, resolve })
|
||||
|
||||
expect(login.status).toBe(200)
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
|
||||
const verifyUrl = new URL('/api/session/verify', origin)
|
||||
|
||||
const verifyEvent = {
|
||||
url: verifyUrl,
|
||||
request: new Request(verifyUrl, { headers: { cookie: cookies(login) } }),
|
||||
@@ -246,6 +277,7 @@ test('the Kit adapter handles native login and leaves proxy verification outside
|
||||
return Response.json(await first)
|
||||
}
|
||||
})
|
||||
|
||||
expect(getSession).toHaveBeenCalledOnce()
|
||||
expect((await response.json()).user.name).toBe(credentials.username)
|
||||
})
|
||||
@@ -292,6 +324,7 @@ test.each([
|
||||
event: proxyEvent('/sign-out', {}, canonicalOrigin, cookies(login)),
|
||||
resolve
|
||||
})
|
||||
|
||||
expect(logout.status).toBe(200)
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
}
|
||||
@@ -304,10 +337,12 @@ test.each([true, false])(
|
||||
const getSession = vi.spyOn(auth.api, 'getSession')
|
||||
const url = new URL('/admin', origin)
|
||||
const event = { url, request: new Request(url), locals: {} } as Parameters<Handle>[0]['event']
|
||||
|
||||
const response = await handle({
|
||||
event,
|
||||
resolve: async (event) => Response.json(await event.locals.auth())
|
||||
})
|
||||
|
||||
expect(await response.json()).toBeNull()
|
||||
expect(getSession).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks'
|
||||
import type { Config } from '@luzzle/web.config'
|
||||
import type { Handle } from '@sveltejs/kit'
|
||||
import { betterAuth, type BetterAuthOptions } from 'better-auth/minimal'
|
||||
import { isAuthPath } from 'better-auth/svelte-kit'
|
||||
import { buildAuthOptions } from './providers'
|
||||
import { buildAuthOptions } from './providers.js'
|
||||
|
||||
const WEEK_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import { credentialsAuth, oidcAuth } from '$lib/server/config.fixture'
|
||||
import { credentialsAuth, oidcAuth } from '#lib/server/config.fixture.js'
|
||||
import { buildAuthOptions, verifyCredentials } from './providers'
|
||||
|
||||
const origin = 'https://luzzle.example.test'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import type { Handle } from '@sveltejs/kit'
|
||||
import { loadConfig } from '@luzzle/web.templates/config'
|
||||
import { prepareConfiguredStyles, renderConfiguredStyles } from './configured-styles'
|
||||
|
||||
@@ -37,10 +37,12 @@ const document =
|
||||
|
||||
test('prepares only configured theme and editor CSS, not fixed stylesheets', async () => {
|
||||
const config = loadConfig()
|
||||
|
||||
config.theme.light['color-primary'] = 'color-mix(in srgb, red 25%, blue)'
|
||||
config.theme.dark['color-primary'] = '#abcdef'
|
||||
config.theme.globals['space-4'] = 'calc(1rem + 2px)'
|
||||
config.theme.markdown.code = { light: 'rose-pine-dawn', dark: 'rose-pine' }
|
||||
|
||||
const styles = await prepareConfiguredStyles(config)
|
||||
|
||||
expect(styles).toContain('--color-primary: color-mix(in srgb, red 25%, blue);')
|
||||
@@ -50,6 +52,7 @@ test('prepares only configured theme and editor CSS, not fixed stylesheets', asy
|
||||
expect(styles).toContain('@media (prefers-color-scheme: dark)')
|
||||
expect(styles).toContain('--editor-background: #faf4ed;')
|
||||
expect(styles).toContain('--editor-background: #191724;')
|
||||
|
||||
for (const fixedRule of ['box-sizing:', '.button', 'pre.shiki', '.markdown {'])
|
||||
expect(styles).not.toContain(fixedRule)
|
||||
})
|
||||
@@ -57,6 +60,7 @@ test('prepares only configured theme and editor CSS, not fixed stylesheets', asy
|
||||
test('layout requests insert one prepared block at the explicit token', async () => {
|
||||
const styles = await prepareConfiguredStyles(loadConfig())
|
||||
const html = await renderChunks(styles, [document])
|
||||
|
||||
expect(html).toBe(document.replace('%luzzle.styles%', () => styles))
|
||||
expect(html.match(/<style data-luzzle-configured-styles>/g)).toHaveLength(1)
|
||||
expect(html.indexOf(styles)).toBeLessThan(html.indexOf('/fixed.css'))
|
||||
@@ -66,6 +70,7 @@ test('layout requests insert one prepared block at the explicit token', async ()
|
||||
test('no layout request removes the token without adding CSS or leaking prior requests', async () => {
|
||||
const styles = await prepareConfiguredStyles(loadConfig())
|
||||
const unstyled = document.replace('%luzzle.styles%', '')
|
||||
|
||||
expect(await renderChunks(styles, [document], false)).toBe(unstyled)
|
||||
expect(await renderChunks(styles, [document])).toContain('data-luzzle-configured-styles')
|
||||
expect(await renderChunks(styles, [document], false)).toBe(unstyled)
|
||||
@@ -73,9 +78,13 @@ test('no layout request removes the token without adding CSS or leaking prior re
|
||||
|
||||
test('configured CSS is captured at preparation, not regenerated by requests', async () => {
|
||||
const config = loadConfig()
|
||||
|
||||
config.theme.light['color-primary'] = '#123456'
|
||||
|
||||
const original = await prepareConfiguredStyles(config)
|
||||
|
||||
config.theme.light['color-primary'] = '#654321'
|
||||
|
||||
const next = await prepareConfiguredStyles(config)
|
||||
|
||||
for (let request = 0; request < 2; request++) {
|
||||
@@ -92,9 +101,11 @@ test.each([
|
||||
'"fonts/custom.woff2"'
|
||||
])('preserves the authored CSS font URL %s without rebasing or publishing files', async (url) => {
|
||||
const config = loadConfig()
|
||||
|
||||
config.theme.globals['font-sans-url'] = url
|
||||
config.theme.globals['font-sans-name'] = '"Custom Sans"'
|
||||
config.theme.globals['font-sans-weight'] = '200 800'
|
||||
|
||||
const styles = await prepareConfiguredStyles(config)
|
||||
|
||||
expect(styles).toContain(`src: url(${url}) format('woff2');`)
|
||||
@@ -105,8 +116,10 @@ test.each([
|
||||
test('CSS raw-text escaping preserves case, other less-than signs and dollar literals', async () => {
|
||||
const config = loadConfig()
|
||||
const content = '"$& $` $\' $$ \\</StYlE><script id=style-breakout>text</script>"'
|
||||
|
||||
config.theme.markdown.sidenote['sidenote-callout-before-content'] = content
|
||||
config.theme.globals['font-sans-name'] = '"Custom < Sans"'
|
||||
|
||||
const html = await renderChunks(await prepareConfiguredStyles(config), [document])
|
||||
|
||||
expect(html.match(/<\/style/gi)).toHaveLength(1)
|
||||
@@ -142,7 +155,8 @@ test('leaves non-HTML bodies and framework cache/CSP headers unchanged', async (
|
||||
'content-security-policy': "script-src 'self' 'nonce-framework'"
|
||||
}
|
||||
})
|
||||
const resolve = vi.fn().mockResolvedValue(response)
|
||||
|
||||
const resolve = vi.fn<Parameters<Handle>[0]['resolve']>().mockResolvedValue(response)
|
||||
|
||||
expect(await renderConfiguredStyles({ event, resolve }, styles)).toBe(response)
|
||||
expect(typeof event.locals.inlineConfiguredStyles).toBe('function')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Handle } from '@sveltejs/kit/hooks'
|
||||
import { generateThemeCss } from '@luzzle/web.theme'
|
||||
import { generateEditorThemeCss } from '@luzzle/web.theme/editor'
|
||||
import type { Handle } from '@sveltejs/kit'
|
||||
import type { AppConfig } from './config'
|
||||
|
||||
export async function prepareConfiguredStyles(config: AppConfig): Promise<string> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { building } from '$app/environment'
|
||||
import { config } from '$lib/server/config'
|
||||
import { building } from '$app/env'
|
||||
import { config } from '#lib/server/config.js'
|
||||
import { sql } from '@luzzle/core'
|
||||
import { runWebMigrations, createAppDb } from '@luzzle/web.db'
|
||||
import type { WebDatabase } from '@luzzle/web.db'
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type PieceMarkdown,
|
||||
findFrontmatterField
|
||||
} from '@luzzle/core'
|
||||
import { normalizeMarkdown } from '$lib/server/markdown'
|
||||
import { normalizeMarkdown } from '#lib/server/markdown.js'
|
||||
|
||||
type FormOperations = {
|
||||
removes: Set<string>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type PieceFrontmatter,
|
||||
type PieceFrontmatterSchema
|
||||
} from '@luzzle/core'
|
||||
import type { GenerationEvent } from '$lib/generation/types'
|
||||
import type { GenerationEvent } from '#lib/generation/types.js'
|
||||
import { POST } from '../../routes/(auth)/api/admin/generate/+server'
|
||||
|
||||
const fixture = vi.hoisted(() => {
|
||||
@@ -58,7 +58,7 @@ vi.mock('../../../../core/node_modules/@google/genai', async (original) => ({
|
||||
}
|
||||
})
|
||||
}))
|
||||
vi.mock('$lib/server/database', () => {
|
||||
vi.mock('#lib/server/database/index.js', () => {
|
||||
throw new Error('Generation must not import the database')
|
||||
})
|
||||
vi.mock('@luzzle/web.jobs', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type PieceFrontmatter,
|
||||
type PieceFrontmatterSchema
|
||||
} from '@luzzle/core'
|
||||
import type { GenerationResult } from '$lib/generation/types'
|
||||
import type { GenerationResult } from '#lib/generation/types.js'
|
||||
import { config } from './config'
|
||||
import { getPieces } from './pieces'
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { DEFAULT_GENERATION_LIMITS, GenerationValidationError } from '@luzzle/core'
|
||||
import type { GenerationEvent, GenerationResult } from '$lib/generation/types'
|
||||
import type { GenerationEvent, GenerationResult } from '#lib/generation/types.js'
|
||||
import { createGenerationStream } from './stream'
|
||||
|
||||
const result: GenerationResult = { markdown: 'café\n🧩 日本語' }
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
GenerationValidationError,
|
||||
type GenerationProgress
|
||||
} from '@luzzle/core'
|
||||
import type { ProgressPhase } from '$lib/components/progress'
|
||||
import type { GenerationEvent, GenerationResult } from '$lib/generation/types'
|
||||
import type { ProgressPhase } from '#lib/components/progress.js'
|
||||
import type { GenerationEvent, GenerationResult } from '#lib/generation/types.js'
|
||||
import { createEventStream } from '../sse'
|
||||
|
||||
export function createGenerationStream(request: Request) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { validateApiKey } from '@luzzle/core'
|
||||
import type { AppConfig } from '$lib/server/config'
|
||||
import type { AppConfig } from '#lib/server/config.js'
|
||||
import type { ProbeResult } from './probes.js'
|
||||
|
||||
export async function validateAiKeyIfConfigured(config: AppConfig): Promise<ProbeResult> {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest'
|
||||
import { getOpenWorkflow, getOpenWorkflowBackend } from '$lib/server/workflow/index.js'
|
||||
import { getOpenWorkflow, getOpenWorkflowBackend } from '#lib/server/workflow/index.js'
|
||||
import { getWorkflowRun } from '@luzzle/web.jobs'
|
||||
import { testConnectivity } from './connectivity.js'
|
||||
|
||||
vi.mock('$lib/server/workflow/index.js', () => ({
|
||||
vi.mock('#lib/server/workflow/index.js', () => ({
|
||||
getOpenWorkflow: vi.fn(),
|
||||
getOpenWorkflowBackend: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getOpenWorkflow, getOpenWorkflowBackend } from '$lib/server/workflow/index.js'
|
||||
import { getOpenWorkflow, getOpenWorkflowBackend } from '#lib/server/workflow/index.js'
|
||||
import { getWorkflowRun, type TestConnectivityResult } from '@luzzle/web.jobs'
|
||||
import { testConnectivitySpec } from '@luzzle/web.jobs/specs'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest'
|
||||
import { config } from '$lib/server/config'
|
||||
import { config } from '#lib/server/config.js'
|
||||
import { getPieceStats, getAssetStats } from '../dashboardStats.js'
|
||||
import { getRecentlyEditedPieces } from '../pieces.js'
|
||||
import { getOpenWorkflowBackend } from '../workflow/index.js'
|
||||
@@ -7,10 +7,10 @@ import { getLatestWorkflowRun, type WorkflowRunRow } from '@luzzle/web.jobs'
|
||||
import { makeConfig, credentialsAuth, oidcAuth } from '../config.fixture'
|
||||
import { loadDashboardPage } from './dashboard.js'
|
||||
|
||||
vi.mock('$lib/server/database', () => ({ db: {} }))
|
||||
vi.mock('#lib/server/database/index.js', () => ({ db: {} }))
|
||||
|
||||
vi.mock('$lib/server/config', async () => {
|
||||
const { makeConfig, oidcAuth } = await import('../config.fixture')
|
||||
vi.mock('#lib/server/config.js', async () => {
|
||||
const { makeConfig, oidcAuth } = await import('../config.fixture.js')
|
||||
const defaults = makeConfig()
|
||||
return {
|
||||
config: makeConfig({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '$lib/server/database'
|
||||
import { config } from '$lib/server/config'
|
||||
import { db } from '#lib/server/database/index.js'
|
||||
import { config } from '#lib/server/config.js'
|
||||
import { getPieceStats, getAssetStats, type PieceStats } from '../dashboardStats.js'
|
||||
import { getRecentlyEditedPieces } from '../pieces.js'
|
||||
import { getOpenWorkflowBackend } from '../workflow/index.js'
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest'
|
||||
import { config } from '$lib/server/config'
|
||||
import { config } from '#lib/server/config.js'
|
||||
import { makeConfig, credentialsAuth, oidcAuth } from '../config.fixture'
|
||||
import { buildHealthConfigSummary } from '../health.js'
|
||||
import type * as Health from '../health.js'
|
||||
import { probeStorage, probeWorker, probeOidcIssuer } from '../health/probes.js'
|
||||
import { loadHealthPage, probeWorkerIfConfigured, probeOidcIssuerIfConfigured } from './health.js'
|
||||
|
||||
vi.mock('$lib/server/config', async () => {
|
||||
const { makeConfig } = await import('../config.fixture')
|
||||
vi.mock('#lib/server/config.js', async () => {
|
||||
const { makeConfig } = await import('../config.fixture.js')
|
||||
return { config: makeConfig() }
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AppConfig } from '$lib/server/config'
|
||||
import { config } from '$lib/server/config'
|
||||
import type { AppConfig } from '#lib/server/config.js'
|
||||
import { config } from '#lib/server/config.js'
|
||||
import { buildHealthConfigSummary, type HealthConfigSummary } from '../health.js'
|
||||
import { probeStorage, probeWorker, probeOidcIssuer, type ProbeResult } from '../health/probes.js'
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ const { config, loadMarkdownFile } = vi.hoisted(() => ({
|
||||
config: { paths: { config: '/deployment/config/config.yaml' } },
|
||||
loadMarkdownFile: vi.fn()
|
||||
}))
|
||||
vi.mock('$lib/server/config', () => ({ config }))
|
||||
vi.mock('$lib/content/markdown/files.server.js', () => ({ loadMarkdownFile }))
|
||||
vi.mock('#lib/server/config.js', () => ({ config }))
|
||||
vi.mock('#lib/content/markdown/files.server.js', () => ({ loadMarkdownFile }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'node:path'
|
||||
import { config } from '$lib/server/config'
|
||||
import { loadMarkdownFile } from '$lib/content/markdown/files.server.js'
|
||||
import { config } from '#lib/server/config.js'
|
||||
import { loadMarkdownFile } from '#lib/content/markdown/files.server.js'
|
||||
|
||||
export function loadPageMarkdown(file?: string): Promise<string | undefined> {
|
||||
if (file === undefined) return Promise.resolve(undefined)
|
||||
|
||||
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user