Files
gitmost/apps/server/src/integrations/environment/environment.service.spec.ts
claude_code 204cf9dfe7 test(sandbox): address PR #250 round-4 review — SSRF accept-path tests, MCP structuredContent (#243)
Mandatory (test-coverage):
- internal-file-urls.test: pin the SSRF/traversal ACCEPT path of
  resolveInternalFilePath (the sole guard for content-controlled `src`): an
  absolute/protocol-relative URL has its foreign host dropped and only an
  /api/files/ pathname survives (http://evil.com/api/files/x/y.png -> /files/x/y.png),
  while a host-dropped path that escapes /api/files/ (https://evil.com/api/auth/whoami)
  or a backslash-traversal (/api/files\..\auth\whoami) is rejected. Locks the
  behavior so a future prefix-only refactor cannot silently open a bypass.

Suggestions:
- index.ts: the stash_page MCP tool now returns structuredContent
  { uri, sha256, size, images } alongside the resource_link, so the MCP output
  matches the documented shape (clients get the blob's sha256/ETag and the
  mirror counts, not just the link). No outputSchema registered. Rebuilt build/.
- new stash-page-mcp-result.test: server round-trip via InMemoryTransport asserts
  both the resource_link and the structuredContent mirror.
- internal-file-urls.test: cover the new URL parse-failure catch branch
  (http://[ -> "Invalid internal file src").
- environment.service.spec: assert getPositiveIntEnv warns once per key and
  independently across keys (the invalidPositiveIntWarned dedup).

Tests: packages/mcp 383 pass; apps/server sandbox/environment/mcp 235 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:58:36 +03:00

162 lines
5.5 KiB
TypeScript

import { EnvironmentService } from './environment.service';
// Direct instantiation with a stub ConfigService, mirroring the rest of these
// unit specs.
describe('EnvironmentService', () => {
let service: EnvironmentService;
beforeEach(() => {
service = new EnvironmentService(
{} as any, // configService
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getSandboxTtlMs', () => {
// ConfigService stub: get(key, def) returns the configured value for the key
// (falling back to def), matching the @nestjs/config contract the service
// calls with (key, default).
const build = (sandboxTtl?: string) =>
new EnvironmentService({
get: (key: string, def?: string) =>
key === 'SANDBOX_TTL_MS' ? (sandboxTtl ?? def) : def,
} as any);
it.each(['0', '-5', 'abc'])(
'falls back to the 3600000 default for invalid value %s',
(value) => {
expect(build(value).getSandboxTtlMs()).toBe(3_600_000);
},
);
it('returns the parsed value for a valid positive integer', () => {
expect(build('120000').getSandboxTtlMs()).toBe(120_000);
});
it('uses the 3600000 default when SANDBOX_TTL_MS is unset', () => {
expect(build(undefined).getSandboxTtlMs()).toBe(3_600_000);
});
});
// The three byte caps share the same getPositiveIntEnv() helper as the TTL,
// so a non-integer / non-positive value ('0'/'-5'/'abc') falls back to the
// documented default and a valid positive integer is returned parsed. Note
// parseInt truncates '1.5' -> 1 (a valid positive integer), so that value is
// accepted, not rejected — same as the pre-existing TTL getter.
describe.each([
{
name: 'getSandboxMaxBytes',
key: 'SANDBOX_MAX_BYTES',
def: 8_388_608,
getter: (s: EnvironmentService) => s.getSandboxMaxBytes(),
},
{
name: 'getSandboxMaxImageBytes',
key: 'SANDBOX_MAX_IMAGE_BYTES',
def: 20_971_520,
getter: (s: EnvironmentService) => s.getSandboxMaxImageBytes(),
},
{
name: 'getSandboxMaxTotalBytes',
key: 'SANDBOX_MAX_TOTAL_BYTES',
def: 134_217_728,
getter: (s: EnvironmentService) => s.getSandboxMaxTotalBytes(),
},
])('$name', ({ key, def, getter }) => {
// ConfigService stub: get(k, d) returns the configured value for THIS cap's
// key (falling back to d), and the default for every other key.
const build = (value?: string) =>
new EnvironmentService({
get: (k: string, d?: string) =>
k === key ? (value ?? d) : d,
} as any);
it.each(['0', '-5', 'abc'])(
`falls back to the ${def} default for invalid value %s`,
(value) => {
expect(getter(build(value))).toBe(def);
},
);
it('returns the parsed value for a valid positive integer', () => {
expect(getter(build('4096'))).toBe(4096);
});
it('truncates a non-integer like "1.5" to 1 via parseInt (not rejected)', () => {
expect(getter(build('1.5'))).toBe(1);
});
it(`uses the ${def} default when the env is unset`, () => {
expect(getter(build(undefined))).toBe(def);
});
});
// getPositiveIntEnv keeps a one-shot `invalidPositiveIntWarned` set so a bad
// value is logged ONCE per key (not on every getter call, which the sandbox
// hits per-put). These tests pin that dedup so a regression to per-call logging
// would fail loudly.
describe('invalid-value warn dedup', () => {
it('warns only once per key across repeated getter calls', () => {
const service = new EnvironmentService({
get: (k: string, d?: string) =>
k === 'SANDBOX_MAX_TOTAL_BYTES' ? '-5' : d,
} as any);
const warnSpy = jest
.spyOn((service as any).logger, 'warn')
.mockImplementation(() => undefined);
service.getSandboxMaxTotalBytes();
service.getSandboxMaxTotalBytes();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it('warns independently per key (dedup is per-key, not global)', () => {
// Two DIFFERENT SANDBOX_* keys are both invalid -> each warns once, so two
// warns total. This proves the dedup set is keyed, not a single global flag.
const service = new EnvironmentService({
get: (k: string, d?: string) =>
k === 'SANDBOX_MAX_BYTES' || k === 'SANDBOX_MAX_TOTAL_BYTES'
? '-5'
: d,
} as any);
const warnSpy = jest
.spyOn((service as any).logger, 'warn')
.mockImplementation(() => undefined);
service.getSandboxMaxBytes();
service.getSandboxMaxTotalBytes();
expect(warnSpy).toHaveBeenCalledTimes(2);
});
});
describe('getSandboxPublicUrl', () => {
// Stub that resolves BOTH keys the public-url logic consults.
const build = (vals: { sandboxUrl?: string; appUrl?: string }) =>
new EnvironmentService({
get: (key: string, def?: string) =>
key === 'SANDBOX_PUBLIC_URL'
? (vals.sandboxUrl ?? def)
: key === 'APP_URL'
? (vals.appUrl ?? def)
: def,
} as any);
it('uses SANDBOX_PUBLIC_URL and trims a trailing slash', () => {
expect(
build({ sandboxUrl: 'https://docs.example.com/' }).getSandboxPublicUrl(),
).toBe('https://docs.example.com');
});
it('falls back to APP_URL (origin) when SANDBOX_PUBLIC_URL is unset', () => {
expect(
build({ appUrl: 'https://app.example.com' }).getSandboxPublicUrl(),
).toBe('https://app.example.com');
});
});
});