import { describe, expect, it, vi } from 'vitest' import { CookieJar, UpstreamClient } from '../../src/server/upstream.js' import type { UpstreamRequestOptions } from '../../src/server/upstream.js' describe('upstream request boundary', () => { it('rejects a path that URL normalization would escape', async () => { const fetchImpl = vi.fn() const client = new UpstreamClient(new URL('http://127.0.0.1:9119'), fetchImpl) await expect(client.request('/api/sessions/..', new CookieJar())).rejects.toThrow(/normalization/i) expect(fetchImpl).not.toHaveBeenCalled() }) it('derives proxy scheme from public 15300 TLS and forwards only the trusted peer IP', async () => { let headers = new Headers() const fetchImpl = vi.fn(async (_input, init) => { headers = new Headers(init?.headers) return new Response('{}', { status: 200, headers: { 'content-type': 'application/json', 'set-cookie': 'session=test; Path=/; HttpOnly; SameSite=Lax' }, }) }) const jar = new CookieJar() const client = new UpstreamClient(new URL('http://127.0.0.1:9119'), fetchImpl, true) await client.request('/api/status', jar, { clientAddress: '::ffff:192.168.1.22' }) expect(headers.get('x-forwarded-proto')).toBe('https') expect(headers.get('x-forwarded-for')).toBe('192.168.1.22') expect(jar.browserCookies[0]).toContain('Secure') expect(jar.browserCookies[0]).not.toMatch(/Domain=/i) }) }) it.each([ {path:'/api/config', options:{search:new URLSearchParams({profile:'yaoer',token:'private-query'})}, stage:'Profile「yaoer」读取配置'}, {path:'/api/plugins/yaoyao-bot-bridge/capabilities', options:{search:new URLSearchParams({profile:'default'})}, stage:'Profile「default」检查工具桥'}, {path:'/api/plugins/yaoyao-bot-bridge/bind', options:{method:'POST',body:{profile:'yaoer',token:'private-bridge-token'}}, stage:'Profile「yaoer」绑定工具桥'}, {path:'/api/config', options:{search:new URLSearchParams({profile:'invalid\nprivate-value'})}, stage:'读取配置'}, ] satisfies Array<{path:string;options:UpstreamRequestOptions;stage:string}>)('identifies a timed-out $path without exposing request secrets',async({path,options,stage})=>{ vi.useFakeTimers() const fetchImpl=vi.fn((_url,init)=>new Promise((_resolve,reject)=>{ init?.signal?.addEventListener('abort',()=>reject(new Error('aborted')),{once:true}) })) const client=new UpstreamClient(new URL('http://127.0.0.1:9119'),fetchImpl) try{ const failed=expect(client.request(path,new CookieJar(),options)).rejects.toMatchObject({ code:'upstream_unavailable',message:`Hermes 请求超时(30 秒):${stage}。`, }) await vi.advanceTimersByTimeAsync(30001);await failed expect(fetchImpl).toHaveBeenCalledTimes(1) }finally{client.close();vi.clearAllTimers();vi.useRealTimers()} }) it('identifies the Profile when Hermes returns headers but the response body stalls',async()=>{ vi.useFakeTimers() const cancel=vi.fn(),stream=new ReadableStream({cancel}) const client=new UpstreamClient(new URL('http://127.0.0.1:9119'),vi.fn(async()=>new Response(stream))) try{ const failed=expect(client.request('/api/config',new CookieJar(),{search:new URLSearchParams({profile:'yaoer'})})).rejects.toMatchObject({ code:'upstream_unavailable',message:'Hermes 请求超时(30 秒):Profile「yaoer」读取配置。', }) await vi.advanceTimersByTimeAsync(30001);await failed expect(cancel).toHaveBeenCalledOnce() }finally{client.close();vi.clearAllTimers();vi.useRealTimers()} }) it('allows the Hermes memory helper to finish within its 90-second budget without changing normal request deadlines',async()=>{ vi.useFakeTimers() const fetchImpl=vi.fn(()=>new Promise(done=>setTimeout(()=>done(new Response('{"text":"memory"}')),40000))) const client=new UpstreamClient(new URL('http://127.0.0.1:9119'),fetchImpl) try{ const normal=client.request('/api/status',new CookieJar()),rejected=expect(normal).rejects.toThrow('request timed out') await vi.advanceTimersByTimeAsync(30001);await rejected const memory=client.request('/api/plugins/yaoyao-bot-bridge/memory-extract',new CookieJar(),{method:'POST',body:{profile:'default',prompt:'facts'}}) await vi.advanceTimersByTimeAsync(40001) expect(JSON.parse((await memory).body.toString())).toEqual({text:'memory'}) }finally{client.close();vi.clearAllTimers();vi.useRealTimers()} })