このコミットが含まれているのは:
2026-10-19 23:43:28 +09:00
コミット 83563e36ef
5個のファイルの変更、238行の追加、34行の削除
+1
ファイルの表示
@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe 'Post filter identity across locales', type: :request do
include_context 'Ainu locale'
let!(:activated_locale) { locale }
let!(:tag) { create(:tag, primary_name: 'legacy_union_source') }
let!(:external) { create(:external_tag, name: 'shared') }
let!(:internal_post) { create(:post) }
+21 -3
ファイルの表示
@@ -53,14 +53,32 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
expect(response).to have_http_status(:not_found)
end
it 'does not disclose another language through ID, existence, diff or history' do
it 'resolves a foreign description ID to only the requested language body' do
get "/wiki/#{ japanese.id }", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
expect(json).to include('id' => ainu.id, 'body' => 'Ainu body')
expect(response.body).not_to include('Japanese body')
end
it 'does not reuse foreign revision IDs when resolving a description' do
get "/wiki/#{ japanese.id }", params: {
locale: 'ain-Kana', version: current_description_version(japanese).id }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('Japanese body')
end
it 'keeps existence and diff scoped to the specified language and description ID' do
revision = current_description_version(japanese)
["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists",
"/wiki/#{ japanese.id }/diff"].each do |path|
["/wiki/#{ japanese.id }/exists", "/wiki/#{ japanese.id }/diff"].each do |path|
get path, params: { locale: 'ain-Kana', from: revision.id }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('Japanese body')
end
get "/wiki/#{ ainu.id }/exists", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:no_content)
end
it 'keeps history scoped to the requested language without foreign revisions' do
get '/wiki/changes', params: { locale: 'ain-Kana', id: japanese.id }
expect(response).to have_http_status(:ok)
expect(json).to eq([])
+200 -30
ファイルの表示
@@ -11,6 +11,26 @@ 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,
@@ -40,7 +60,6 @@ const responseFor = url => {
const path = url.pathname
if (path === '/locales')
return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] }
if (path === '/users/session') return { id: 1, name: 'SSR account', role: 'admin' }
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 }
@@ -63,24 +82,98 @@ const responseFor = url => {
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 ((request, response) => {
requests.push ({ method: request.method, url: request.url })
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 (failAPI ? 503 : (data == null ? 404 : 200), {
'Content-Type': 'application/json' })
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,
SSR_COOKIE_SECRET: 'automated-check-only-not-a-production-secret' },
PUBLIC_ORIGIN: origin, SSR_API_BASE_URL: apiURL },
stdio: ['ignore', 'pipe', 'pipe'] })
httpOrigin = await new Promise ((resolve, reject) => {
node.stdout.on ('data', data => {
@@ -99,6 +192,22 @@ before (async () => {
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 ()
@@ -107,13 +216,14 @@ after (async () => {
})
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, { transferCode: 'test-secret' })
const result = await render (url, origin, apiURL, { cookie })
assert.ok (result != null)
if (route.handle?.redirectTo) {
assert.equal (result.status, 308)
@@ -124,7 +234,7 @@ test ('every canonical screen route returns page HTML without JavaScript', async
assert.match (result.head, /<title/)
assert.match (result.head, /rel="canonical"/)
assert.equal (result.locale, locale)
assert.doesNotMatch (result.html + result.bootstrap, /test-secret/)
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')
@@ -142,6 +252,7 @@ test ('every canonical screen route returns page HTML without JavaScript', async
})
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 ()) {
@@ -152,7 +263,7 @@ test ('parallel localized requests keep list data isolated', async () => {
assert.equal (state.locale, locale)
}
assert.ok (requests.some (request => request.url.includes ('page=2')))
assert.ok (requests.every (request => request.method === 'GET'))
assert.ok (requests.slice (start).every (request => request.method === 'GET'))
})
test ('unknown resources and legacy URLs have correct status', async () => {
@@ -164,26 +275,52 @@ test ('unknown resources and legacy URLs have correct status', async () => {
assert.equal (redirect.location, '/ja/help/home?edition=2026-09-15')
})
test ('HTTP cookie migration, CSRF protection, and API failure', async () => {
const rejected = await fetch (`${httpOrigin}/_ssr/session`, {
method: 'POST', headers: { Origin: 'https://other.invalid', 'X-Transfer-Code': 'test-secret' } })
assert.equal (rejected.status, 403)
const created = await fetch (`${httpOrigin}/_ssr/session`, {
method: 'POST', headers: { Origin: origin, 'X-Transfer-Code': 'test-secret' } })
assert.equal (created.status, 204)
const cookie = created.headers.get ('set-cookie')
assert.match (cookie, /HttpOnly; Secure; SameSite=Lax/)
assert.doesNotMatch (cookie, /test-secret/)
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-secret/)
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)
@@ -192,6 +329,43 @@ test ('HTTP cookie migration, CSRF protection, and API failure', async () => {
}
})
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 => {
@@ -230,14 +404,11 @@ test ('hydrate every route with stored browser preferences', {
} else if (message.method === 'Fetch.requestPaused') {
const { requestId, request } = message.params
const url = new URL (request.url)
if (url.origin === httpOrigin && url.pathname !== '/_ssr/session') {
if (url.origin === httpOrigin) {
void send ('Fetch.continueRequest', { requestId }, message.sessionId)
return
}
const data = url.pathname === '/users/verify'
? { valid: true, user: { id: 1, name: 'SSR account', role: 'admin',
inheritance_code: 'test-secret' } }
: responseFor (url)
const data = responseFor (url)
void send ('Fetch.fulfillRequest', { requestId,
responseCode: request.method === 'OPTIONS' ? 204 : 200,
responseHeaders: [{ name: 'Content-Type', value: 'application/json' },
@@ -259,12 +430,11 @@ test ('hydrate every route with stored browser preferences', {
await send ('Page.enable', {}, sessionId)
await send ('Network.enable', {}, sessionId)
await send ('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId)
const session = await fetch (`${httpOrigin}/_ssr/session`, {
method: 'POST', headers: { Origin: origin, 'X-Transfer-Code': 'test-secret' } })
const cookie = await authenticatedCookie ()
await send ('Network.setExtraHTTPHeaders', { headers: {
Cookie: session.headers.get ('set-cookie').split (';')[0] } }, sessionId)
Cookie: cookie } }, sessionId)
await send ('Page.addScriptToEvaluateOnNewDocument', { source: `
localStorage.setItem('user_code','test-secret');
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'}
+15
ファイルの表示
@@ -65,6 +65,21 @@ describe ('wiki API functions', () => {
expect (path).toBe ('/ain-Kana/wiki/changes?id=202&keep=1')
})
it ('keeps history and clears its ID filter when the translation is missing', async () => {
api.apiGet.mockResolvedValueOnce ({ id: 101, resourceType: 'tag_description' })
api.apiGet.mockRejectedValueOnce ({
isAxiosError: true, response: { status: 404 } })
api.apiGet.mockResolvedValueOnce ({ id: 7, name: 'シロシ' })
api.apiGet.mockRejectedValueOnce ({
isAxiosError: true, response: { status: 404 } })
api.apiGet.mockResolvedValueOnce ({ id: 7, name: 'シロシ' })
const path = await switchWikiLocale ({
location: { pathname: '/ja/wiki/changes',
search: '?id=101&page=2&keep=1', hash: '#history' },
params: { }, locale: 'ja', targetLocale: 'ain-Kana' }, 'history')
expect (path).toBe ('/ain-Kana/wiki/changes?page=2&keep=1#history')
})
it ('fetches wiki index and show pages with expected parameters', async () => {
api.apiGet.mockResolvedValueOnce ([])
api.apiGet.mockResolvedValueOnce ({ id: 1 })
+1 -1
ファイルの表示
@@ -129,7 +129,7 @@ export const switchWikiLocale = async (
const suffix = (translated != null && (view === 'edit' || view === 'diff'))
? `/${view}` : ''
return switchLocalePath ({
pathname: historyId != null
pathname: view === 'history'
? location.pathname
: `/wiki/${encodeURIComponent (destination)}${suffix}`,
search: query.size === 0 ? '' : `?${query}`,