Files
chat/api/server/services/Config/loadCustomConfig.spec.js
zeekay 551236b1a5 route: one namespace — everything chat serves lives under /v1/chat
Five things answered outside /v1/chat/. Four of them are chat's; they move.

  /health                -> /v1/chat/health
  POST /oauth/iam/session-> POST /v1/chat/auth/iam/session   (it is auth)
  GET  /oauth/openid[/callback], /oauth/error
                         -> /v1/chat/auth/openid[/callback], /v1/chat/auth/error
  /images/               -> /v1/chat/images/

The fifth — the built assets at / and the SPA catch-all — is the SPA's, and
stays. Every caller moved in the same commit: api-endpoints (health, iamSession),
the metrics + telemetry health filter, the helm probes, the PR-preview workflow,
the PWA nav-fallback denylist. `ErrorController` matched a failed OIDC callback
by the substring '/oauth/', which the move would have silently broken; it now
matches the route.

Images are the one persisted case: a conversation stores the served path, and
history is immutable. New writes emit /v1/chat/images/…; /images/* answers one
permanent redirect; `isServedImage` (data-provider, the one place that knows the
namespace) recognises both. Local deletion maps the served path back to disk.

Two routers were written, tested, advertised to the browser — and never mounted,
so both were a guaranteed 404: skills (types say /v1/chat/skills) and the RUM
OTLP proxy (`rum/proxy` advertises /v1/chat/rum). Mounted where they said.

Deleted, with zero callers: server/experimental.js — a drifted second copy of
the whole mount table, still referencing a router that no longer exists, that
would have silently absorbed this move and diverged again; and nginx.hanzo.conf,
referenced by no compose/Dockerfile/chart, whose own /health shadowed chat's.

Gates collapsed to their one behavior, and the env deleted:

  ALLOW_SOCIAL_LOGIN     IAM is the one identity provider; false produced a
                         server with no way to sign in. socialLogins.js is now
                         iamLogin.js — one name for one thing.
  CONTINUE_ON_UNCAUGHT_EXCEPTION  keeps a process alive in an unknown state
                         instead of letting the kubelet replace it.
  CONFIG_BYPASS_VALIDATION        boots on invalid config, on request.
  MCP_SKIP_CODE_CHALLENGE_CHECK   turns off PKCE verification.
  OPENID_JWKS_URL_CACHE_ENABLED   read as `isEnabled(x) || true` — never off.
                         A second, differently-defaulted read lived in
                         remoteAgentAuth; both are now just on.
  NO_INDEX               a third boolean idiom for a header we always want.
  DISABLE_COMPRESSION, ENABLE_IMAGE_OUTPUT_GZIP_SCAN — we write these images
                         ourselves, as PNG/GIF; none is gzipped on disk.

Tests fail without the change: the auth router spec (6 of 7) against the old
router, the static spec against the old env-gated one, the config spec now sets
CONFIG_BYPASS_VALIDATION=true and still expects the exit, the error spec against
the '/oauth/' matcher, and index.spec asserts the top level serves HTML — so a
probe or caller left behind fails loudly instead of looking green.
2026-07-26 17:04:33 -07:00

331 lines
9.5 KiB
JavaScript

jest.mock('axios');
jest.mock('~/cache/getLogStores');
jest.mock('@hanzochat/api', () => ({
...jest.requireActual('@hanzochat/api'),
loadYaml: jest.fn(),
}));
jest.mock('@hanzochat/data-provider', () => {
const actual = jest.requireActual('@hanzochat/data-provider');
return {
...actual,
paramSettings: { foo: {}, bar: {}, custom: {} },
agentParamSettings: {
custom: [],
google: [
{
key: 'pressure',
type: 'string',
component: 'input',
},
{
key: 'temperature',
type: 'number',
component: 'slider',
default: 0.5,
range: {
min: 0,
max: 2,
step: 0.01,
},
},
],
},
};
});
jest.mock('@hanzochat/data-schemas', () => {
return {
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
};
});
const axios = require('axios');
const { loadYaml } = require('@hanzochat/api');
const { logger } = require('@hanzochat/data-schemas');
const loadCustomConfig = require('./loadCustomConfig');
describe('loadCustomConfig', () => {
const originalExit = process.exit;
const mockExit = jest.fn((code) => {
throw new Error(`process.exit called with "${code}"`);
});
beforeAll(() => {
process.exit = mockExit;
});
afterAll(() => {
process.exit = originalExit;
});
beforeEach(() => {
jest.resetAllMocks();
// Re-apply the exit mock implementation after resetAllMocks
mockExit.mockImplementation((code) => {
throw new Error(`process.exit called with "${code}"`);
});
delete process.env.CONFIG_PATH;
});
it('should return null and log error if remote config fetch fails', async () => {
process.env.CONFIG_PATH = 'http://example.com/config.yaml';
axios.get.mockRejectedValue(new Error('Network error'));
const result = await loadCustomConfig();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it('should return null for an invalid local config file', async () => {
process.env.CONFIG_PATH = 'localConfig.yaml';
loadYaml.mockReturnValueOnce(null);
const result = await loadCustomConfig();
expect(result).toBeNull();
});
it('should parse, validate, and cache a valid local configuration', async () => {
const mockConfig = {
version: '1.0',
cache: true,
endpoints: {
custom: [
{
name: 'mistral',
apiKey: 'user_provided',
baseURL: 'https://api.mistral.ai/v1',
},
],
},
};
process.env.CONFIG_PATH = 'validConfig.yaml';
loadYaml.mockReturnValueOnce(mockConfig);
const result = await loadCustomConfig();
expect(result).toEqual(mockConfig);
});
it('exits on an invalid config, and no env can talk it out of that', async () => {
const invalidConfig = { invalidField: true };
process.env.CONFIG_PATH = 'invalidConfig.yaml';
process.env.CONFIG_BYPASS_VALIDATION = 'true';
loadYaml.mockReturnValueOnce(invalidConfig);
try {
await expect(loadCustomConfig()).rejects.toThrow('process.exit called with "1"');
expect(logger.error).toHaveBeenCalledWith('Exiting due to invalid configuration.');
} finally {
delete process.env.CONFIG_BYPASS_VALIDATION;
}
});
it('should handle and return null on YAML parse error for a string response from remote', async () => {
process.env.CONFIG_PATH = 'http://example.com/config.yaml';
axios.get.mockResolvedValue({ data: 'invalidYAMLContent' });
const result = await loadCustomConfig();
expect(result).toBeNull();
});
it('should return the custom config object for a valid remote config file', async () => {
const mockConfig = {
version: '1.0',
cache: true,
endpoints: {
custom: [
{
name: 'mistral',
apiKey: 'user_provided',
baseURL: 'https://api.mistral.ai/v1',
},
],
},
};
process.env.CONFIG_PATH = 'http://example.com/config.yaml';
axios.get.mockResolvedValue({ data: mockConfig });
const result = await loadCustomConfig();
expect(result).toEqual(mockConfig);
});
it('should return null if the remote config file is not found', async () => {
process.env.CONFIG_PATH = 'http://example.com/config.yaml';
axios.get.mockRejectedValue({ response: { status: 404 } });
const result = await loadCustomConfig();
expect(result).toBeNull();
});
it('should return null if the local config file is not found', async () => {
process.env.CONFIG_PATH = 'nonExistentConfig.yaml';
loadYaml.mockReturnValueOnce(null);
const result = await loadCustomConfig();
expect(result).toBeNull();
});
it('should not cache the config if cache is set to false', async () => {
const mockConfig = {
version: '1.0',
cache: false,
endpoints: {
custom: [
{
name: 'mistral',
apiKey: 'user_provided',
baseURL: 'https://api.mistral.ai/v1',
},
],
},
};
process.env.CONFIG_PATH = 'validConfig.yaml';
loadYaml.mockReturnValueOnce(mockConfig);
await loadCustomConfig();
});
it('should log the loaded custom config', async () => {
const mockConfig = {
version: '1.0',
cache: true,
endpoints: {
custom: [
{
name: 'mistral',
apiKey: 'user_provided',
baseURL: 'https://api.mistral.ai/v1',
},
],
},
};
process.env.CONFIG_PATH = 'validConfig.yaml';
loadYaml.mockReturnValueOnce(mockConfig);
await loadCustomConfig();
expect(logger.info).toHaveBeenCalledWith('Custom config file loaded:');
expect(logger.info).toHaveBeenCalledWith(JSON.stringify(mockConfig, null, 2));
expect(logger.debug).toHaveBeenCalledWith('Custom config:', mockConfig);
});
describe('parseCustomParams', () => {
const mockConfig = {
version: '1.0',
cache: false,
endpoints: {
custom: [
{
name: 'Google',
apiKey: 'user_provided',
customParams: {},
},
],
},
};
async function loadCustomParams(customParams) {
mockConfig.endpoints.custom[0].customParams = customParams;
loadYaml.mockReturnValue(mockConfig);
return await loadCustomConfig();
}
beforeEach(() => {
jest.resetAllMocks();
process.env.CONFIG_PATH = 'validConfig.yaml';
});
it('returns no error when customParams is undefined', async () => {
const result = await loadCustomParams(undefined);
expect(result).toEqual(mockConfig);
});
it('returns no error when customParams is valid', async () => {
const result = await loadCustomParams({
defaultParamsEndpoint: 'google',
paramDefinitions: [
{
key: 'temperature',
default: 0.5,
},
],
});
expect(result).toEqual(mockConfig);
});
it('throws an error when paramDefinitions contain unsupported keys', async () => {
const malformedCustomParams = {
defaultParamsEndpoint: 'google',
paramDefinitions: [
{ key: 'temperature', default: 0.5 },
{ key: 'unsupportedKey', range: 0.5 },
],
};
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
'paramDefinitions of "Google" endpoint contains invalid key(s). Valid parameter keys are pressure, temperature',
);
});
it('throws an error when paramDefinitions is malformed', async () => {
const malformedCustomParams = {
defaultParamsEndpoint: 'google',
paramDefinitions: [
{
key: 'temperature',
type: 'noomba',
component: 'inpoot',
optionType: 'custom',
},
],
};
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
/Custom parameter definitions for "Google" endpoint is malformed:/,
);
});
it('throws an error when defaultParamsEndpoint is not provided', async () => {
const malformedCustomParams = { defaultParamsEndpoint: undefined };
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, google',
);
});
it('fills the paramDefinitions with missing values', async () => {
const customParams = {
defaultParamsEndpoint: 'google',
paramDefinitions: [
{ key: 'temperature', default: 0.7, range: { min: 0.1, max: 0.9, step: 0.1 } },
{ key: 'pressure', component: 'textarea' },
],
};
const parsedConfig = await loadCustomParams(customParams);
const paramDefinitions = parsedConfig.endpoints.custom[0].customParams.paramDefinitions;
expect(paramDefinitions).toEqual([
{
columnSpan: 1,
component: 'slider',
default: 0.7, // overridden
includeInput: true,
key: 'temperature',
label: 'temperature',
optionType: 'custom',
range: {
// overridden
max: 0.9,
min: 0.1,
step: 0.1,
},
type: 'number',
},
{
columnSpan: 1,
component: 'textarea', // overridden
key: 'pressure',
label: 'pressure',
optionType: 'custom',
placeholder: '',
type: 'string',
},
]);
});
});
});