ファイル
btrc-hub/frontend/server.ssr.test.mjs
T
2026-10-19 23:43:28 +09:00

479 行
23 KiB
JavaScript
Raw Blame 履歴

このファイルには不可視のUnicode文字が含まれています
このファイルには人間が識別できない不可視のUnicode文字が含まれており、コンピューターによって特殊な処理が行われる可能性があります。 それが意図的なものと考えられる場合は、この警告を無視して構いません。 不可視文字を表示するにはエスケープボタンを使用します。
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { after, before, test } from 'node:test'
import { createServer as createViteServer } from 'vite'
import { render } from './dist/server/entry-server.js'
const origin = 'https://ssr.example.invalid'
const date = '2026-09-15T00:00:00+09:00'
let api, apiURL, routes, vite, node, httpOrigin
let failAPI = false
const requests = []
// Rails owns encryption and verification; this upstream fixture tests Node's forwarding contract.
const recoveryCode = 'test-recovery-secret'
const users = new Map ([
[recoveryCode, { id: 1, name: 'SSR account', role: 'admin' }],
['second-recovery-secret', { id: 2, name: 'Second account', role: 'member' }]])
const sessions = new Map ()
let sessionSequence = 0
const cookieName = '__Host-btrc-session'
const cookieFor = value => `${cookieName}=${value}`
const sessionValue = request => request.headers.cookie?.split (';')
.map (part => part.trim ()).find (part => part.startsWith (`${cookieName}=`))
?.slice (cookieName.length + 1)
const issueSession = (response, code = null) => {
const value = `opaque-rails-session-${++sessionSequence}`
sessions.set (value, { code, token: `csrf-${sessionSequence}` })
refreshCookie (response, value)
return value
}
const refreshCookie = (response, value) => response.setHeader ('Set-Cookie',
`${cookieFor (value)}; Path=/; Max-Age=630720000; HttpOnly; Secure; SameSite=Lax`)
const tag = locale => ({
id: 1, name: locale === 'ja' ? 'SSR タグ' : 'SSR シロシ', category: 'general',
deprecated_at: null, aliases: [], parents: [], children: [], post_count: 1,
created_at: date, updated_at: date, has_wiki: true, material_id: null,
has_deerjikists: false, sections: [] })
const post = locale => ({
id: 1, version_no: 1, title: `SSR post ${locale}`, url: 'https://example.invalid/post',
thumbnail: null, thumbnail_base: null, video_ms: null, tags: [tag (locale)],
parent_posts: [], child_posts: [], sibling_posts: {}, viewed: false, related: [],
original_created_from: date, original_created_before: date,
created_at: date, updated_at: date, uploaded_user: null })
const wiki = locale => ({
id: 1, tag_id: 1, resource_type: 'tag_description', language_code: locale === 'ja'
? 'ja' : 'ain', title: tag (locale).name, body: `SSR wiki body ${locale}`,
created_at: date, updated_at: date, revision_id: 1, pred: null, succ: null,
deprecated_at: null })
const material = locale => ({
id: 1, version_no: 1, tag: tag (locale), file: null, url: 'https://example.invalid',
thumbnail: null, thumbnail_fallback_text: 'SSR material',
thumbnail_fallback_kind: 'tag_name', media_kind: 'url_only', content_type: null,
file_byte_size: null, export_paths: {}, export_items: [], created_at: date,
updated_at: date, created_by_user: { id: 1, name: 'SSR' },
updated_by_user: { id: 1, name: 'SSR' } })
const responseFor = url => {
const locale = url.searchParams.get ('locale') ?? 'ja'
const path = url.pathname
if (path === '/locales')
return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] }
if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' }
if (path === '/users/theme_slots') return []
if (path === '/posts') return { posts: [post (locale)], count: 1 }
if (path === '/gekanator/posts') return { posts: [post (locale)] }
if (path === '/gekanator/questions') return { questions: [] }
if (path === '/posts/1') return post (locale)
if (path === '/tags') return { tags: [tag (locale)], count: 1 }
if (path === '/tags/1' || path.startsWith ('/tags/name/')) return tag (locale)
if (path === '/tags/1/deerjikists') return { tag: tag (locale), deerjikists: [] }
if (path === '/tags/with-depth') return []
if (path === '/tags/nico' || path === '/nico/tags') return { tags: [], count: 0 }
if (path.endsWith ('/versions')) return { versions: [], count: 0 }
if (path === '/wiki') return [wiki (locale)]
if (path === '/wiki/changes') return []
if (path.includes ('autolink')) return []
if (path === '/wiki/1/diff') return { title: 'SSR wiki diff', diff: [] }
if (path.startsWith ('/wiki/')) return wiki (locale)
if (path === '/materials') return { materials: [material (locale)], count: 1 }
if (path === '/materials/1') return material (locale)
if (path.includes ('suppressions')) return { suppressions: [] }
if (path === '/theatres/1') return { id: 1, name: 'SSR theatre', created_at: date }
if (path === '/theatres/1/programmes') return []
if (path === '/theatres/1/comments') return []
if (path === '/theatres/1/post_selection_weights')
return { tag_penalties: [], lightest_posts: [], heaviest_posts: [] }
if (path === '/theatres/1/watching')
return { host_flg: false, post_id: null, post_started_at: null, post_elapsed_ms: null,
watching_users: [], skip_vote: {
votes_count: 0, required_count: 1, watching_users_count: 0, voted: false } }
return null
}
before (async () => {
api = createServer (async (request, response) => {
requests.push ({ method: request.method, url: request.url,
cookie: request.headers.cookie, csrf: request.headers['x-csrf-token'],
transferCode: request.headers['x-transfer-code'] })
const url = new URL (request.url, 'http://localhost')
response.setHeader ('Content-Type', 'application/json')
response.setHeader ('Cache-Control', 'private, no-store')
if (failAPI) {
response.writeHead (503).end ()
return
}
if (url.pathname.startsWith ('/users')) {
const value = sessionValue (request)
const session = sessions.get (value)
const user = users.get (session?.code)
if (request.method === 'GET') {
if (url.pathname === '/users/csrf') {
const id = session != null ? value : issueSession (response)
response.end (JSON.stringify ({ token: sessions.get (id).token }))
return
}
if (user == null) {
response.writeHead (401).end ()
return
}
refreshCookie (response, value)
const data = url.pathname === '/users/session' ? user
: (url.pathname === '/users/me' ? { ...user, inheritance_code: session.code }
: responseFor (url))
response.writeHead (data == null ? 404 : 200)
response.end (JSON.stringify (data))
return
}
if (![origin, httpOrigin].includes (request.headers.origin)
|| session == null || request.headers['x-csrf-token'] !== session.token) {
response.writeHead (403, { 'X-CSRF-Invalid': '1' }).end ()
return
}
let body = ''
for await (const chunk of request) body += chunk
const params = JSON.parse (body || '{}')
if (request.method === 'POST' && url.pathname === '/users/verify') {
const code = params.restore && user != null ? session.code : params.code
const verified = users.get (code)
if (verified == null) {
response.end (JSON.stringify ({ valid: false }))
return
}
sessions.delete (value)
issueSession (response, code)
response.end (JSON.stringify ({ valid: true,
user: { ...verified, inheritance_code: code } }))
return
}
if (request.method === 'DELETE' && url.pathname === '/users/session') {
sessions.delete (value)
response.writeHead (204, { 'Set-Cookie':
`${cookieName}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax` }).end ()
return
}
if (request.method === 'POST' && url.pathname === '/users/code/renew' && user != null) {
const code = `rotated-recovery-${sessionSequence}`
users.delete (session.code)
users.set (code, user)
sessions.delete (value)
issueSession (response, code)
response.end (JSON.stringify ({ code }))
return
}
response.writeHead (404).end ()
return
}
const data = responseFor (url)
response.writeHead (data == null ? 404 : 200)
response.end (JSON.stringify (data))
})
await new Promise (resolve => api.listen (0, '127.0.0.1', resolve))
apiURL = `http://127.0.0.1:${api.address ().port}`
node = spawn (process.execPath, ['server.mjs'], { env: {
...process.env, NODE_ENV: 'production', PORT: '0', HOST: '127.0.0.1',
PUBLIC_ORIGIN: origin, SSR_API_BASE_URL: apiURL },
stdio: ['ignore', 'pipe', 'pipe'] })
httpOrigin = await new Promise ((resolve, reject) => {
node.stdout.on ('data', data => {
const match = data.toString ().match (/listening on (\d+)/)
if (match) resolve (`http://127.0.0.1:${match[1]}`)
})
node.on ('error', reject)
node.on ('exit', code => reject (new Error (`SSR server exited ${code}`)))
})
vite = await createViteServer ({ server: { middlewareMode: true }, appType: 'custom' })
const { pageRoutes } = await vite.ssrLoadModule ('/src/App.tsx')
const flatten = (entries, parent = '') => entries.flatMap (route => {
const path = [parent, route.path].filter (Boolean).join ('/')
return route.children ? flatten (route.children, path) : [{ ...route, path }]
})
routes = flatten (pageRoutes (null, () => {}))
})
const authenticatedCookie = async (code = recoveryCode) => {
const csrf = await fetch (`${httpOrigin}/api/users/csrf`)
assert.equal (csrf.status, 200)
const { token } = await csrf.json ()
const verified = await fetch (`${httpOrigin}/api/users/verify`, {
method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json',
Cookie: csrf.headers.get ('set-cookie').split (';')[0], 'X-CSRF-Token': token },
body: JSON.stringify ({ code, restore: true }) })
assert.equal (verified.status, 200)
assert.equal ((await verified.json ()).valid, true)
const cookie = verified.headers.get ('set-cookie')
assert.match (cookie, /HttpOnly; Secure; SameSite=Lax/)
assert.doesNotMatch (cookie, new RegExp (code))
return cookie.split (';')[0]
}
after (async () => {
await vite?.close ()
node?.kill ()
api?.closeAllConnections ()
await new Promise (resolve => api?.close (resolve))
})
test ('every canonical screen route returns page HTML without JavaScript', async t => {
const cookie = await authenticatedCookie ()
for (const route of routes) {
for (const locale of ['ja', 'ain-Kana']) {
const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1')
.replace ('*', 'unknown-screen')
const url = `/${locale}/${path}?from=2026-09-15&to=2026-09-15`
await t.test (`${locale} ${route.path}`, async () => {
const result = await render (url, origin, apiURL, { cookie })
assert.ok (result != null)
if (route.handle?.redirectTo) {
assert.equal (result.status, 308)
return
}
assert.ok (result.html?.length > 300, 'actual page HTML is required')
assert.equal (result.status, route.path === '*' ? 404 : 200)
assert.match (result.head, /<title/)
assert.match (result.head, /rel="canonical"/)
assert.equal (result.locale, locale)
assert.doesNotMatch (result.html + result.bootstrap, /test-recovery-secret|opaque-rails/)
if (route.path === 'wiki/:id/edit') assert.match (result.html, /<textarea/)
if (route.path === 'users/settings') assert.match (result.html, /SSR account/)
if (route.path === 'posts')
assert.match (result.html, new RegExp (`SSR post ${locale}`))
if (route.path === 'posts/:id')
assert.match (result.head, new RegExp (`SSR post ${locale}`))
if (route.path === 'theatres/:id')
assert.match (result.html.replace (/<[^>]*>/g, ''), /SSR[  ]theatre/)
if (route.path === 'wiki' || route.path === 'wiki/:title')
assert.match (result.html, new RegExp (`SSR wiki body ${locale}|SSR`))
assert.doesNotMatch (result.html, /Switched to client rendering|template id="B:/)
})
}
}
})
test ('parallel localized requests keep list data isolated', async () => {
const start = requests.length
const pages = await Promise.all (['ja', 'ain-Kana', 'ja', 'ain-Kana'].map (locale =>
render (`/${locale}/posts?limit=50&page=2`, origin, apiURL)))
for (const [index, page] of pages.entries ()) {
const locale = index % 2 === 0 ? 'ja' : 'ain-Kana'
assert.match (page.html, new RegExp (`SSR post ${locale}`))
assert.equal (page.status, 200)
const state = JSON.parse (page.bootstrap)
assert.equal (state.locale, locale)
}
assert.ok (requests.some (request => request.url.includes ('page=2')))
assert.ok (requests.slice (start).every (request => request.method === 'GET'))
})
test ('unknown resources and legacy URLs have correct status', async () => {
assert.equal ((await render ('/ja/unknown', origin, apiURL)).status, 404)
assert.equal ((await render ('/ja/help/unknown', origin, apiURL)).status, 404)
assert.equal ((await render ('/ja/posts/999', origin, apiURL)).status, 404)
const redirect = await render ('/help/home?edition=2026-09-15', origin, apiURL)
assert.equal (redirect.status, 308)
assert.equal (redirect.location, '/ja/help/home?edition=2026-09-15')
})
test ('HTTP forwards Rails cookies, CSRF, expiration and upstream errors', async () => {
const cookie = await authenticatedCookie ()
const settings = await fetch (`${httpOrigin}/ja/users/settings`, { headers: { Cookie: cookie } })
assert.equal (settings.status, 200)
const html = await settings.text ()
assert.match (html, /SSR account/)
assert.doesNotMatch (html, /test-recovery-secret|opaque-rails-session/)
assert.match (settings.headers.get ('cache-control'), /private, no-store/)
assert.match (settings.headers.get ('set-cookie'), /Max-Age=630720000/)
assert.ok (requests.some (request => request.url === '/users/session'
&& request.cookie === cookie))
assert.ok (requests.every (request => request.transferCode == null))
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`)).status, 401)
const invalid = cookie.replace ('=', '=invalid')
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: invalid } })).status, 401)
const publicPage = await fetch (`${httpOrigin}/ain-Kana/posts`)
assert.equal (publicPage.status, 200)
assert.match (await publicPage.text (), /SSR post ain-Kana/)
assert.equal ((await fetch (`${httpOrigin}/_ssr/session`, { method: 'POST' })).status, 405)
const csrf = await fetch (`${httpOrigin}/api/users/csrf`, { headers: { Cookie: cookie } })
const { token } = await csrf.json ()
const write = (csrfToken, requestOrigin = origin) => fetch (
`${httpOrigin}/api/users/verify`, { method: 'POST',
headers: { Cookie: cookie, Origin: requestOrigin, 'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken }, body: JSON.stringify ({ code: recoveryCode }) })
assert.equal ((await write ('invalid')).status, 403)
assert.equal ((await write (token, 'https://other.invalid')).status, 403)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: cookie } })).status, 200)
const renewed = await write (token)
assert.equal (renewed.status, 200)
const renewedCookie = renewed.headers.get ('set-cookie').split (';')[0]
assert.notEqual (renewedCookie, cookie)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: cookie } })).status, 401)
const renewedCSRF = await fetch (`${httpOrigin}/api/users/csrf`, {
headers: { Cookie: renewedCookie } })
const newToken = (await renewedCSRF.json ()).token
const logout = await fetch (`${httpOrigin}/api/users/session`, { method: 'DELETE',
headers: { Cookie: renewedCookie, Origin: origin, 'X-CSRF-Token': newToken } })
assert.equal (logout.status, 204)
assert.match (logout.headers.get ('set-cookie'), /Max-Age=0/)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: renewedCookie } })).status, 401)
failAPI = true
try {
assert.equal ((await fetch (`${httpOrigin}/ja/posts`)).status, 503)
} finally {
failAPI = false
}
})
test ('parallel user and Locale requests never share authentication or query state', async () => {
const cookies = await Promise.all ([authenticatedCookie (),
authenticatedCookie ('second-recovery-secret')])
const inputs = cookies.flatMap ((cookie, user) => ['ja', 'ain-Kana'].map (locale => ({
cookie, locale, name: user === 0 ? 'SSR account' : 'Second account',
other: user === 0 ? 'Second account' : 'SSR account' })))
const pages = await Promise.all (inputs.map (async input => {
const response = await fetch (`${httpOrigin}/${input.locale}/users/settings`, {
headers: { Cookie: input.cookie } })
assert.equal (response.status, 200)
return response.text ()
}))
for (const [index, html] of pages.entries ()) {
const input = inputs[index]
assert.match (html, new RegExp (input.name))
assert.doesNotMatch (html, new RegExp (input.other))
assert.match (html, new RegExp (`<html lang="${input.locale}"`))
assert.doesNotMatch (html, /recovery-secret|opaque-rails-session|csrf-/)
}
})
test ('renewed recovery codes invalidate old sessions and forward the new Rails cookie',
async () => {
const cookie = await authenticatedCookie ('second-recovery-secret')
const csrf = await fetch (`${httpOrigin}/api/users/csrf`, { headers: { Cookie: cookie } })
const { token } = await csrf.json ()
const renewed = await fetch (`${httpOrigin}/api/users/code/renew`, {
method: 'POST', headers: { Cookie: cookie, Origin: origin, 'X-CSRF-Token': token } })
assert.equal (renewed.status, 200)
const newCookie = renewed.headers.get ('set-cookie').split (';')[0]
assert.notEqual (newCookie, cookie)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: cookie } })).status, 401)
assert.equal ((await fetch (`${httpOrigin}/ain-Kana/users/settings`, {
headers: { Cookie: newCookie } })).status, 200)
})
test ('hydrate every route with stored browser preferences', {
skip: !process.env.SSR_CHROME_PATH, timeout: 180_000,
}, async t => {
const { default: WebSocket } = await import ('ws')
const chrome = spawn (process.env.SSR_CHROME_PATH, [
'--headless', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
'--remote-debugging-port=0', `--user-data-dir=/tmp/btrc-ssr-browser-${process.pid}`],
{ stdio: ['ignore', 'ignore', 'pipe'] })
let ws
try {
const endpoint = await new Promise ((resolve, reject) => {
let output = ''
chrome.stderr.on ('data', data => {
output += data
const match = output.match (/DevTools listening on (ws:\/\/\S+)/)
if (match) resolve (match[1])
})
chrome.on ('error', reject)
})
ws = new WebSocket (endpoint)
await new Promise (resolve => ws.on ('open', resolve))
const pending = new Map ()
let sequence = 0, errors = []
const send = (method, params = {}, sessionId) => new Promise ((resolve, reject) => {
const id = ++sequence
pending.set (id, { resolve, reject })
ws.send (JSON.stringify ({ id, method, params, sessionId }))
})
ws.on ('message', raw => {
const message = JSON.parse (raw)
if (message.id) {
const entry = pending.get (message.id)
pending.delete (message.id)
if (message.error) entry?.reject (message.error)
else entry?.resolve (message.result)
} else if (message.method === 'Fetch.requestPaused') {
const { requestId, request } = message.params
const url = new URL (request.url)
if (url.origin === httpOrigin) {
void send ('Fetch.continueRequest', { requestId }, message.sessionId)
return
}
const data = responseFor (url)
void send ('Fetch.fulfillRequest', { requestId,
responseCode: request.method === 'OPTIONS' ? 204 : 200,
responseHeaders: [{ name: 'Content-Type', value: 'application/json' },
{ name: 'Access-Control-Allow-Origin', value: httpOrigin },
{ name: 'Access-Control-Allow-Headers', value: '*' },
{ name: 'Access-Control-Allow-Methods', value: '*' }],
body: Buffer.from (JSON.stringify (data ?? {})).toString ('base64') }, message.sessionId)
} else if (message.method === 'Runtime.exceptionThrown') {
errors.push (message.params.exceptionDetails.exception?.description
?? message.params.exceptionDetails.text)
} else if (message.method === 'Runtime.consoleAPICalled'
&& message.params.type === 'error') {
errors.push (message.params.args.map (arg => arg.value ?? arg.description).join (' '))
}
})
const { targetId } = await send ('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send ('Target.attachToTarget', { targetId, flatten: true })
await send ('Runtime.enable', {}, sessionId)
await send ('Page.enable', {}, sessionId)
await send ('Network.enable', {}, sessionId)
await send ('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId)
const cookie = await authenticatedCookie ()
await send ('Network.setExtraHTTPHeaders', { headers: {
Cookie: cookie } }, sessionId)
await send ('Page.addScriptToEvaluateOnNewDocument', { source: `
localStorage.setItem('user_code','test-recovery-secret');
localStorage.setItem('btrc_hub.client_settings',JSON.stringify({
lists:{postList:{limit:50},tagList:{limit:50},postSearch:{limit:50}},
behavior:{animation:'off'},theatre:{layoutMode:'tagsBottom',tagFlow:'horizontal'}
}));
new MutationObserver(()=>{
const child=document.getElementById('root')?.firstElementChild;
if(child&&!window.__initialSSRNode)window.__initialSSRNode=child;
}).observe(document,{childList:true,subtree:true});` }, sessionId)
const evaluate = async expression => (await send ('Runtime.evaluate', {
expression, returnByValue: true }, sessionId)).result.value
for (const route of routes.filter (entry => !entry.handle?.redirectTo)) {
for (const locale of ['ja', 'ain-Kana']) {
await t.test (`hydrate ${locale} ${route.path}`, async () => {
const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1')
.replace ('*', 'unknown-screen')
errors = []
await send ('Page.navigate', {
url: `${httpOrigin}/${locale}/${path}?from=2026-09-15&to=2026-09-15` }, sessionId)
let hydrated = false
for (let attempt = 0; attempt < 100; ++attempt) {
await new Promise (resolve => setTimeout (resolve, 75))
try {
hydrated = await evaluate (`Object.keys(document.getElementById('root')??{})
.some(key=>key.startsWith('__reactContainer'))`)
} catch {}
if (hydrated) break
}
assert.ok (hydrated, 'hydrateRoot must run')
await new Promise (resolve => setTimeout (resolve, 150))
assert.deepEqual (errors, [])
assert.ok (await evaluate (`document.getElementById('root').firstElementChild
===window.__initialSSRNode`), 'server DOM must be preserved')
assert.equal (await evaluate ('document.documentElement.lang'), locale)
})
}
}
} finally {
ws?.close ()
chrome.kill ()
}
})