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, /