From 83563e36ef06dfed1c2711c327a732d61b720116 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 19 Oct 2026 23:43:28 +0900 Subject: [PATCH] #428 --- .../post_locale_filter_identity_spec.rb | 1 + backend/spec/requests/wiki_locale_spec.rb | 24 +- frontend/server.ssr.test.mjs | 230 +++++++++++++++--- frontend/src/lib/wiki.test.ts | 15 ++ frontend/src/lib/wiki.ts | 2 +- 5 files changed, 238 insertions(+), 34 deletions(-) diff --git a/backend/spec/requests/post_locale_filter_identity_spec.rb b/backend/spec/requests/post_locale_filter_identity_spec.rb index f8d496e..99a6416 100644 --- a/backend/spec/requests/post_locale_filter_identity_spec.rb +++ b/backend/spec/requests/post_locale_filter_identity_spec.rb @@ -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) } diff --git a/backend/spec/requests/wiki_locale_spec.rb b/backend/spec/requests/wiki_locale_spec.rb index 2a190aa..33c781c 100644 --- a/backend/spec/requests/wiki_locale_spec.rb +++ b/backend/spec/requests/wiki_locale_spec.rb @@ -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([]) diff --git a/frontend/server.ssr.test.mjs b/frontend/server.ssr.test.mjs index c2e313d..15120b1 100644 --- a/frontend/server.ssr.test.mjs +++ b/frontend/server.ssr.test.mjs @@ -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, / { + 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'} diff --git a/frontend/src/lib/wiki.test.ts b/frontend/src/lib/wiki.test.ts index e679e7b..601c281 100644 --- a/frontend/src/lib/wiki.test.ts +++ b/frontend/src/lib/wiki.test.ts @@ -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 }) diff --git a/frontend/src/lib/wiki.ts b/frontend/src/lib/wiki.ts index 2a42ad2..743a6ee 100644 --- a/frontend/src/lib/wiki.ts +++ b/frontend/src/lib/wiki.ts @@ -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}`,