mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
App REST surface moves from /api/* to /v1/chat/* in lockstep across server
mounts, client URL builders, SSE stream paths, OAuth redirect_uris, telemetry
route-grouping, RUM proxy, and the nginx bridge.
Namespace /v1/chat/* (not flat /v1/) to avoid colliding with the OpenAI-compat
inference surface (/v1/chat/completions, /v1/models -> router_backend) and to
match the canonical chat/openapi.yaml. Social OIDC login (/oauth/*) and /health
are unchanged (login-safe).
Levers:
- server: api/server/index.js 26 app.use('/v1/chat/*') mounts (+ experimental.js)
- client: packages/data-provider/src/api-endpoints.ts (75 builders) + config.ts
Lockstep: checkBan matcher, Files/Code download path, actions/mcp CSRF cookie
paths, ActionService + mcp/oauth handler redirect_uris, admin OpenID callback,
telemetry middleware/sdk/stream, rum proxy path, vite dev proxy + PWA denylist,
robots.txt, .env.example, pr-preview health-path (/api/health -> /health).
Third-party APIs chat CALLS are untouched (Ollama, Wolfram, Discord, GitHub
Enterprise, Mistral, OpenRouter, DataDog). Live-surface /api/: 683 -> 0.
Also untracks runtime log artifacts (api/logs, client/junit.xml; already gitignored).
305 lines
7.5 KiB
TypeScript
305 lines
7.5 KiB
TypeScript
/**
|
|
* @jest-environment @happy-dom/jest-environment
|
|
*/
|
|
import axios from 'axios';
|
|
import { setTokenHeader } from '../src/headers-helpers';
|
|
|
|
/**
|
|
* The response interceptor in request.ts registers at import time when
|
|
* `typeof window !== 'undefined'` (happy-dom provides window).
|
|
*
|
|
* We use axios's built-in request adapter mock to avoid real HTTP calls,
|
|
* and verify the interceptor's behavior by observing whether a 401 triggers
|
|
* a refresh POST or is immediately rejected.
|
|
*
|
|
* happy-dom is used instead of jsdom because it allows overriding
|
|
* window.location via Object.defineProperty, which jsdom 26+ blocks.
|
|
*/
|
|
|
|
const mockAdapter = jest.fn();
|
|
let originalAdapter: typeof axios.defaults.adapter;
|
|
let savedLocation: Location;
|
|
|
|
beforeAll(async () => {
|
|
originalAdapter = axios.defaults.adapter;
|
|
axios.defaults.adapter = mockAdapter;
|
|
|
|
await import('../src/request');
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockAdapter.mockReset();
|
|
savedLocation = window.location;
|
|
});
|
|
|
|
afterAll(() => {
|
|
axios.defaults.adapter = originalAdapter;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete axios.defaults.headers.common['Authorization'];
|
|
Object.defineProperty(window, 'location', {
|
|
value: savedLocation,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
function setWindowLocation(overrides: Partial<Location>) {
|
|
Object.defineProperty(window, 'location', {
|
|
value: { ...window.location, ...overrides },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
describe('axios 401 interceptor — Authorization header guard', () => {
|
|
it('skips refresh and rejects when Authorization header is cleared', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader(undefined);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/v1/chat/messages', headers: {} },
|
|
});
|
|
|
|
try {
|
|
await axios.get('/v1/chat/messages');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(mockAdapter).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('attempts refresh on shared link page even without Authorization header', async () => {
|
|
expect.assertions(2);
|
|
setTokenHeader(undefined);
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/share/abc123',
|
|
pathname: '/share/abc123',
|
|
search: '',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/v1/chat/share/abc123', headers: {} },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: 'new-token' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { sharedLink: {} },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/v1/chat/share/abc123');
|
|
} catch {
|
|
// may reject depending on exact flow
|
|
}
|
|
|
|
expect(mockAdapter.mock.calls.length).toBe(3);
|
|
|
|
const refreshCall = mockAdapter.mock.calls[1];
|
|
expect(refreshCall[0].url).toContain('api/auth/refresh');
|
|
});
|
|
|
|
it('does not bypass guard when share/ appears only in query params', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader(undefined);
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/c/chat?ref=share/token',
|
|
pathname: '/c/chat',
|
|
search: '?ref=share/token',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/v1/chat/messages', headers: {} },
|
|
});
|
|
|
|
try {
|
|
await axios.get('/v1/chat/messages');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(mockAdapter).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('redirects to login with redirect_to when unauthenticated on share page and refresh fails', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader(undefined);
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/share/abc123',
|
|
pathname: '/share/abc123',
|
|
search: '',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/v1/chat/share/abc123', headers: {} },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: '' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/v1/chat/share/abc123');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123');
|
|
});
|
|
|
|
it('redirects to login with redirect_to when authenticated and refresh returns no token on share page', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader('some-token');
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/share/abc123',
|
|
pathname: '/share/abc123',
|
|
search: '',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/v1/chat/share/abc123', headers: {} },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: '' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/v1/chat/share/abc123');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123');
|
|
});
|
|
|
|
it('redirects to login with redirect_to when refresh returns no token on regular page', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader('some-token');
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/c/some-conversation',
|
|
pathname: '/c/some-conversation',
|
|
search: '',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/v1/chat/messages', headers: {} },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: '' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/v1/chat/messages');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(window.location.href).toBe('/login?redirect_to=%2Fc%2Fsome-conversation');
|
|
});
|
|
|
|
it('redirects to plain /login without redirect_to when already on a login path', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader('some-token');
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/login/2fa',
|
|
pathname: '/login/2fa',
|
|
search: '',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/v1/chat/messages', headers: {} },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: '' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/v1/chat/messages');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(window.location.href).toBe('/login');
|
|
});
|
|
|
|
it('attempts refresh when Authorization header is present', async () => {
|
|
expect.assertions(2);
|
|
setTokenHeader('valid-token');
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/v1/chat/messages', headers: {}, _retry: false },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: 'new-token' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { messages: [] },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/v1/chat/messages');
|
|
} catch {
|
|
// may reject depending on exact flow
|
|
}
|
|
|
|
expect(mockAdapter.mock.calls.length).toBe(3);
|
|
|
|
const refreshCall = mockAdapter.mock.calls[1];
|
|
expect(refreshCall[0].url).toContain('api/auth/refresh');
|
|
});
|
|
});
|