Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"devDependencies": {
"@ai-sdk/provider": "catalog:dev",
"@ai-sdk/provider-utils": "catalog:dev",
"@fast-check/vitest": "catalog:dev",
"@hono/mcp": "catalog:dev",
"@types/node": "catalog:dev",
"@typescript/native-preview": "catalog:dev",
Expand Down
29 changes: 29 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ catalogs:
dev:
'@ai-sdk/openai': ^2.0.80
'@ai-sdk/provider': ^2.0.0
'@fast-check/vitest': ^0.2.0
'@ai-sdk/provider-utils': ^3.0.18
'@clack/prompts': ^0.11.0
'@hono/mcp': ^0.1.4
Expand Down
264 changes: 214 additions & 50 deletions src/requestBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fc, test as fcTest } from '@fast-check/vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/node';
import { type HttpExecuteConfig, type JsonObject, ParameterLocation } from './types';
Expand Down Expand Up @@ -307,23 +308,6 @@ describe('RequestBuilder', () => {
});

describe('Security and Performance Improvements', () => {
it('should throw error when recursion depth limit is exceeded', async () => {
// Create a deeply nested object that exceeds the default depth limit of 10
let deepObject: JsonObject = { value: 'test' };
for (let i = 0; i < 12; i++) {
deepObject = { nested: deepObject };
}

const params = {
pathParam: 'test-value',
deepFilter: deepObject,
} satisfies JsonObject;

await expect(builder.execute(params, { dryRun: true })).rejects.toThrow(
'Maximum nesting depth (10) exceeded for parameter serialization',
);
});

it('should throw error when circular reference is detected', async () => {
// Test runtime behavior when circular reference is passed
// Note: This tests error handling for malformed input at runtime
Expand All @@ -341,20 +325,6 @@ describe('RequestBuilder', () => {
);
});

it('should validate parameter keys and reject invalid characters', async () => {
const params = {
pathParam: 'test-value',
filter: {
valid_key: 'test',
'invalid key with spaces': 'test', // Should trigger validation error
},
};

await expect(builder.execute(params, { dryRun: true })).rejects.toThrow(
'Invalid parameter key: invalid key with spaces',
);
});

it('should handle special types correctly at runtime', async () => {
// Test runtime behavior when non-JSON types are passed
// Note: Date and RegExp are not valid JsonValue types, but we test
Expand Down Expand Up @@ -424,25 +394,6 @@ describe('RequestBuilder', () => {
expect(url.searchParams.get('filter[validField]')).toBe('test');
});

it('should handle arrays correctly within objects', async () => {
const params = {
pathParam: 'test-value',
filter: {
arrayField: [1, 2, 3],
stringArray: ['a', 'b', 'c'],
mixed: ['string', 42, true],
},
};

const result = await builder.execute(params, { dryRun: true });
const url = new URL(result.url as string);

// Arrays should be converted to JSON strings
expect(url.searchParams.get('filter[arrayField]')).toBe('[1,2,3]');
expect(url.searchParams.get('filter[stringArray]')).toBe('["a","b","c"]');
expect(url.searchParams.get('filter[mixed]')).toBe('["string",42,true]');
});

it('should handle nested objects with special types at runtime', async () => {
// Test runtime serialization of nested non-JSON types
const params = {
Expand Down Expand Up @@ -522,3 +473,216 @@ describe('RequestBuilder', () => {
});
});
});

describe('RequestBuilder - Property-Based Tests', () => {
const baseConfig = {
kind: 'http',
method: 'GET',
url: 'https://api.example.com/test',
bodyType: 'json',
params: [{ name: 'filter', location: ParameterLocation.QUERY, type: 'object' }],
} satisfies HttpExecuteConfig;

// Arbitrary for valid parameter keys (alphanumeric, underscore, dot, hyphen)
const validKeyArbitrary = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_.-]{0,19}$/);

// Arbitrary for invalid parameter keys (contains spaces or special chars)
const invalidKeyArbitrary = fc
.string({ minLength: 1, maxLength: 20 })
.filter((s) => /[^a-zA-Z0-9_.-]/.test(s) && s.trim().length > 0);

describe('Parameter Key Validation', () => {
fcTest.prop([validKeyArbitrary, fc.string()], { numRuns: 100 })(
'accepts valid parameter keys',
async (key, value) => {
const builder = new RequestBuilder(baseConfig);
const params = {
filter: { [key]: value },
};

// Should not throw for valid keys
const result = await builder.execute(params, { dryRun: true });
expect(result.url).toBeDefined();
},
);

fcTest.prop([invalidKeyArbitrary, fc.string()], { numRuns: 100 })(
'rejects invalid parameter keys',
async (key, value) => {
const builder = new RequestBuilder(baseConfig);
const params = {
filter: { [key]: value },
};

await expect(builder.execute(params, { dryRun: true })).rejects.toThrow(
/Invalid parameter key/,
);
},
);
});

describe('Header Management', () => {
// Arbitrary for header key-value pairs
const headerArbitrary = fc.dictionary(
fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9-]{0,29}$/),
fc.string({ minLength: 1, maxLength: 100 }),
{ minKeys: 1, maxKeys: 5 },
);

fcTest.prop([headerArbitrary, headerArbitrary], { numRuns: 50 })(
'setHeaders accumulates headers without losing existing ones',
(headers1, headers2) => {
const builder = new RequestBuilder(baseConfig, headers1);
builder.setHeaders(headers2);

const result = builder.getHeaders();

// All headers from both sets should be present (with headers2 overriding duplicates)
for (const [key, value] of Object.entries(headers2)) {
expect(result[key]).toBe(value);
}
for (const [key, value] of Object.entries(headers1)) {
if (!(key in headers2)) {
expect(result[key]).toBe(value);
}
}
},
);

fcTest.prop([headerArbitrary], { numRuns: 50 })(
'prepareHeaders always includes User-Agent',
(headers) => {
const builder = new RequestBuilder(baseConfig, headers);
const prepared = builder.prepareHeaders();

expect(prepared['User-Agent']).toBe('stackone-ai-node');
},
);

fcTest.prop([headerArbitrary], { numRuns: 50 })(
'getHeaders returns a copy, not the original',
(headers) => {
const builder = new RequestBuilder(baseConfig, headers);
const retrieved = builder.getHeaders();

// Mutating the returned object should not affect internal state
retrieved['Mutated-Header'] = 'mutated';

expect(builder.getHeaders()['Mutated-Header']).toBeUndefined();
},
);
});

describe('Value Serialization', () => {
fcTest.prop([fc.string()], { numRuns: 100 })(
'string values serialize to themselves',
async (str) => {
const builder = new RequestBuilder(baseConfig);
const params = { filter: { key: str } };

const result = await builder.execute(params, { dryRun: true });
const url = new URL(result.url as string);

expect(url.searchParams.get('filter[key]')).toBe(str);
},
);

fcTest.prop([fc.integer()], { numRuns: 100 })(
'integer values serialize to string',
async (num) => {
const builder = new RequestBuilder(baseConfig);
const params = { filter: { key: num } };

const result = await builder.execute(params, { dryRun: true });
const url = new URL(result.url as string);

expect(url.searchParams.get('filter[key]')).toBe(String(num));
},
);

fcTest.prop([fc.boolean()], { numRuns: 10 })(
'boolean values serialize to string',
async (bool) => {
const builder = new RequestBuilder(baseConfig);
const params = { filter: { key: bool } };

const result = await builder.execute(params, { dryRun: true });
const url = new URL(result.url as string);

expect(url.searchParams.get('filter[key]')).toBe(String(bool));
},
);

fcTest.prop(
[fc.array(fc.oneof(fc.string(), fc.integer(), fc.boolean()), { minLength: 1, maxLength: 5 })],
{
numRuns: 50,
},
)('arrays serialize to JSON string', async (arr) => {
const builder = new RequestBuilder(baseConfig);
const params = { filter: { key: arr } };

const result = await builder.execute(params, { dryRun: true });
const url = new URL(result.url as string);

expect(url.searchParams.get('filter[key]')).toBe(JSON.stringify(arr));
});
});

describe('Deep Object Nesting', () => {
fcTest.prop([fc.integer({ min: 1, max: 9 })], { numRuns: 20 })(
'accepts objects within depth limit',
async (depth) => {
const builder = new RequestBuilder(baseConfig);
const deepObject = {};
let current: Record<string, unknown> = deepObject;
for (let i = 0; i < depth; i++) {
current.nested = {};
current = current.nested as Record<string, unknown>;
}
current.value = 'test';

const params = { filter: deepObject } as JsonObject;
const result = await builder.execute(params, { dryRun: true });

expect(result.url).toBeDefined();
},
);

test('rejects objects exceeding depth limit of 10', async () => {
const builder = new RequestBuilder(baseConfig);
let deepObject: Record<string, unknown> = { value: 'test' };
for (let i = 0; i < 12; i++) {
deepObject = { nested: deepObject };
}

const params = { filter: deepObject } as JsonObject;

await expect(builder.execute(params, { dryRun: true })).rejects.toThrow(
/Maximum nesting depth.*exceeded/,
);
});
});

describe('Body Type Handling', () => {
const bodyTypes = ['json', 'form', 'multipart-form'] as const;

fcTest.prop(
[
fc.constantFrom(...bodyTypes),
fc.dictionary(fc.string(), fc.string(), { minKeys: 1, maxKeys: 3 }),
],
{
numRuns: 30,
},
)('all valid body types produce valid fetch options', (bodyType, bodyParams) => {
const config = { ...baseConfig, bodyType };
const builder = new RequestBuilder(config);

const options = builder.buildFetchOptions(bodyParams);

expect(options.method).toBe('GET');
expect(options.body).toBeDefined();
});
});
});
Loading
Loading