From d19920fc65747f71d8dc7bf2ac7aba2b22aac604 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 18 May 2026 14:57:39 -0400 Subject: [PATCH] test: add E2E integration tests for typed config endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP layer for GET/PATCH /config/typed had zero test coverage — Rust unit tests validate schema structs but not request deserialization, sparse patch semantics, or secret routing over the wire. These 15 tests hit a real goosed server and cover type diversity (string, bool, int, float, enum, array), sparse patch invariants (omit preserves, null preserves, empty body no-ops), nested object whole-value replacement, and secret field non-leakage in responses. Signed-off-by: Will Pfleger --- .../tests/integration/typed_config.test.ts | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 ui/desktop/tests/integration/typed_config.test.ts diff --git a/ui/desktop/tests/integration/typed_config.test.ts b/ui/desktop/tests/integration/typed_config.test.ts new file mode 100644 index 0000000000..576467a54b --- /dev/null +++ b/ui/desktop/tests/integration/typed_config.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'node:path'; +import { setupGoosed, type GoosedTestContext } from './setup'; +import { readTypedConfig, patchTypedConfig } from '../../src/api'; + +const SECRET_FIELDS = [ + 'ANTHROPIC_API_KEY', + 'AVIAN_API_KEY', + 'AZURE_OPENAI_API_KEY', + 'DATABRICKS_TOKEN', + 'GOOGLE_API_KEY', + 'GROQ_API_KEY', + 'LITELLM_API_KEY', + 'LITELLM_CUSTOM_HEADERS', + 'NANOGPT_API_KEY', + 'OPENAI_API_KEY', + 'OPENROUTER_API_KEY', + 'SNOWFLAKE_TOKEN', + 'TETRATE_API_KEY', + 'VENICE_API_KEY', + 'XAI_API_KEY', +]; + +describe('typed config HTTP endpoints', () => { + let ctx: GoosedTestContext; + + beforeAll(async () => { + process.env.GOOSED_BINARY = path.resolve(__dirname, '..', '..', '..', '..', 'target', 'debug', 'goosed'); + ctx = await setupGoosed({ + configYaml: 'GOOSE_DISABLE_KEYRING: true\n', + }); + }, 120_000); + + afterAll(async () => { + await ctx.cleanup(); + }); + + // --- Group A: GET baseline --- + + it('GET /config/typed returns 200 with a GooseConfigSchema object', async () => { + const response = await readTypedConfig({ client: ctx.client }); + expect(response.response).toBeOkResponse(); + expect(response.data).toBeDefined(); + expect(typeof response.data).toBe('object'); + }); + + it('GET /config/typed response has no secret fields', async () => { + const response = await readTypedConfig({ client: ctx.client }); + expect(response.response).toBeOkResponse(); + const keys = Object.keys(response.data!); + for (const secret of SECRET_FIELDS) { + expect(keys).not.toContain(secret); + } + }); + + // --- Group B: PATCH single-field updates by type --- + + it('PATCH string field: GOOSE_PROVIDER', async () => { + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_PROVIDER: 'openai' }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.GOOSE_PROVIDER).toBe('openai'); + + const getRes = await readTypedConfig({ client: ctx.client }); + expect(getRes.data!.GOOSE_PROVIDER).toBe('openai'); + }); + + it('PATCH boolean field: GOOSE_DEBUG', async () => { + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_DEBUG: true }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.GOOSE_DEBUG).toBe(true); + + const getRes = await readTypedConfig({ client: ctx.client }); + expect(getRes.data!.GOOSE_DEBUG).toBe(true); + }); + + it('PATCH integer field: GOOSE_MAX_TOKENS', async () => { + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_MAX_TOKENS: 8192 }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.GOOSE_MAX_TOKENS).toBe(8192); + + const getRes = await readTypedConfig({ client: ctx.client }); + expect(getRes.data!.GOOSE_MAX_TOKENS).toBe(8192); + }); + + it('PATCH enum field: GOOSE_MODE', async () => { + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_MODE: 'approve' }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.GOOSE_MODE).toBe('approve'); + + const getRes = await readTypedConfig({ client: ctx.client }); + expect(getRes.data!.GOOSE_MODE).toBe('approve'); + }); + + it('PATCH float field: SECURITY_PROMPT_THRESHOLD', async () => { + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { SECURITY_PROMPT_THRESHOLD: 0.85 }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.SECURITY_PROMPT_THRESHOLD).toBe(0.85); + + const getRes = await readTypedConfig({ client: ctx.client }); + expect(getRes.data!.SECURITY_PROMPT_THRESHOLD).toBe(0.85); + }); + + it('PATCH array field: GOOSE_SEARCH_PATHS', async () => { + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_SEARCH_PATHS: ['/tmp/a', '/tmp/b'] }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.GOOSE_SEARCH_PATHS).toEqual(['/tmp/a', '/tmp/b']); + + const getRes = await readTypedConfig({ client: ctx.client }); + expect(getRes.data!.GOOSE_SEARCH_PATHS).toEqual(['/tmp/a', '/tmp/b']); + }); + + // --- Group C: PATCH sparse semantics --- + + it('omitted fields stay unchanged after PATCH', async () => { + await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_DEBUG: true }, + }); + + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_MAX_TOKENS: 4096 }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.GOOSE_DEBUG).toBe(true); + expect(patchRes.data!.GOOSE_MAX_TOKENS).toBe(4096); + + const getRes = await readTypedConfig({ client: ctx.client }); + expect(getRes.data!.GOOSE_DEBUG).toBe(true); + expect(getRes.data!.GOOSE_MAX_TOKENS).toBe(4096); + }); + + it('empty PATCH body is a no-op', async () => { + await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_DEBUG: false }, + }); + + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: {}, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.GOOSE_DEBUG).toBe(false); + }); + + it('null does not clear a previously set value', async () => { + await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_PROVIDER: 'anthropic' }, + }); + + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_PROVIDER: null }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.GOOSE_PROVIDER).toBe('anthropic'); + + const getRes = await readTypedConfig({ client: ctx.client }); + expect(getRes.data!.GOOSE_PROVIDER).toBe('anthropic'); + }); + + // --- Group D: Nested object whole-value replacement --- + + it('extensions field uses whole-value replacement, not deep merge', async () => { + await patchTypedConfig({ + client: ctx.client, + body: { + extensions: { + 'custom-ext-a': { + enabled: true, + type: 'builtin', + name: 'custom-ext-a', + description: 'First custom extension', + }, + }, + }, + }); + + const midGet = await readTypedConfig({ client: ctx.client }); + expect(midGet.data!.extensions!['custom-ext-a']).toBeDefined(); + + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { + extensions: { + 'custom-ext-b': { + enabled: false, + type: 'builtin', + name: 'custom-ext-b', + description: 'Second custom extension', + }, + }, + }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.extensions).toBeDefined(); + expect(patchRes.data!.extensions!['custom-ext-b']).toBeDefined(); + expect(patchRes.data!.extensions!['custom-ext-a']).toBeUndefined(); + + const getRes = await readTypedConfig({ client: ctx.client }); + expect(getRes.data!.extensions!['custom-ext-b']).toBeDefined(); + expect(getRes.data!.extensions!['custom-ext-a']).toBeUndefined(); + }); + + it('experiments field uses whole-value replacement', async () => { + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { experiments: { 'feature-a': true } }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data!.experiments).toEqual({ 'feature-a': true }); + + const getRes = await readTypedConfig({ client: ctx.client }); + expect(getRes.data!.experiments).toEqual({ 'feature-a': true }); + }); + + // --- Group E: Secret field routing --- + + it('PATCH with secret API key returns 200 and does not leak secret in response', async () => { + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { OPENAI_API_KEY: 'sk-test-sentinel-value' }, + }); + expect(patchRes.response).toBeOkResponse(); + expect(patchRes.data).toBeDefined(); + + const keys = Object.keys(patchRes.data!); + for (const secret of SECRET_FIELDS) { + expect(keys).not.toContain(secret); + } + }); + + // --- Group F: Response shape validation --- + + it('PATCH response does not contain GooseConfigUpdate-only fields', async () => { + const patchRes = await patchTypedConfig({ + client: ctx.client, + body: { GOOSE_DEBUG: true }, + }); + expect(patchRes.response).toBeOkResponse(); + + const keys = Object.keys(patchRes.data!); + for (const secret of SECRET_FIELDS) { + expect(keys).not.toContain(secret); + } + }); +});