Skip to content

Commit 79f455f

Browse files
committed
test: harden unit fixtures and audit logging coverage
1 parent 8316453 commit 79f455f

6 files changed

Lines changed: 165 additions & 51 deletions

File tree

lib/http-server.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export function applyClientApiKey(req: AuthenticatedRequest): void {
5757
}
5858
// `authHeader` is trimmed and non-empty, so splitting on whitespace always
5959
// yields a non-empty first element.
60-
const [type, token] = authHeader.split(/\s+/u)
60+
const { 0: type, 1: token } = authHeader.split(/\s+/u)
6161
if (type!.toLowerCase() !== 'bearer' || !token) {
6262
return
6363
}
@@ -250,8 +250,6 @@ export async function routeRequest(
250250
}
251251

252252
const origin = getRequestHeaderValue(req.headers.origin).trim()
253-
// Strict host matching prevents spoofing via subdomains like
254-
// "malicious-localhost.evil.com".
255253
const host = getRequestHeaderValue(req.headers.host).trim()
256254

257255
if (!validateOriginAndHost(origin, host, port)) {
@@ -350,16 +348,21 @@ export function startHttpServer(port: number): void {
350348
{ onerror: handleMcpAdapterError },
351349
)
352350

351+
let listeningPort = port
353352
const httpServer = createServer((req, res) => {
354-
routeRequest(mcpHandler, req, res, port).catch(
353+
routeRequest(mcpHandler, req, res, listeningPort).catch(
355354
createRouteFailureHandler(res),
356355
)
357356
})
358357

359358
httpServer.listen(port, () => {
359+
const address = httpServer.address()
360+
if (typeof address === 'object' && address !== null) {
361+
listeningPort = address.port
362+
}
360363
logger.info(
361-
`Socket MCP HTTP server version ${VERSION} started successfully on port ${port}`,
364+
`Socket MCP HTTP server version ${VERSION} started successfully on port ${listeningPort}`,
362365
)
363-
logger.info(`Connect to: http://localhost:${port}/`)
366+
logger.info(`Connect to: http://localhost:${listeningPort}/`)
364367
})
365368
}

test/unit/blob-cache.test.mts

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,15 @@ import type { BlobResult } from '../../lib/blob.ts'
55

66
const BLOB_HOST = 'https://socketusercontent.com'
77

8-
let savedCap: string | undefined
9-
108
beforeEach(() => {
11-
savedCap = process.env['SOCKET_BLOB_CACHE_BYTES']
12-
delete process.env['SOCKET_BYPASS_HEADER_NAME']
13-
delete process.env['SOCKET_BYPASS_HEADER_VALUE']
9+
vi.stubEnv('SOCKET_BLOB_CACHE_BYTES', undefined)
10+
vi.stubEnv('SOCKET_BYPASS_HEADER_NAME', undefined)
11+
vi.stubEnv('SOCKET_BYPASS_HEADER_VALUE', undefined)
1412
nock.disableNetConnect()
15-
vi.resetModules()
1613
})
1714

1815
afterEach(() => {
19-
if (savedCap === undefined) {
20-
delete process.env['SOCKET_BLOB_CACHE_BYTES']
21-
} else {
22-
process.env['SOCKET_BLOB_CACHE_BYTES'] = savedCap
23-
}
24-
delete process.env['SOCKET_BYPASS_HEADER_NAME']
25-
delete process.env['SOCKET_BYPASS_HEADER_VALUE']
16+
vi.unstubAllEnvs()
2617
nock.cleanAll()
2718
nock.enableNetConnect()
2819
})
@@ -31,7 +22,7 @@ afterEach(() => {
3122
// cap is read at import time, so it must be set before the dynamic import.
3223
async function freshCache(capBytes?: number | undefined) {
3324
if (capBytes !== undefined) {
34-
process.env['SOCKET_BLOB_CACHE_BYTES'] = String(capBytes)
25+
vi.stubEnv('SOCKET_BLOB_CACHE_BYTES', String(capBytes))
3526
}
3627
vi.resetModules()
3728
return import('../../lib/blob-cache.ts')
@@ -67,7 +58,7 @@ describe('getOrFetchBlob', () => {
6758
.get('/blob/Qrace')
6859
.reply(200, 'shared', { 'content-type': 'text/plain' })
6960

70-
const [a, b] = await Promise.all([
61+
const { 0: a, 1: b } = await Promise.all([
7162
getOrFetchBlob('Qrace'),
7263
getOrFetchBlob('Qrace'),
7364
])
@@ -113,8 +104,8 @@ describe('getOrFetchBlob', () => {
113104

114105
describe('WAF bypass header', () => {
115106
test('sends the configured bypass header on every blob request', async () => {
116-
process.env['SOCKET_BYPASS_HEADER_NAME'] = 'x-waf-bypass'
117-
process.env['SOCKET_BYPASS_HEADER_VALUE'] = 'let-me-through'
107+
vi.stubEnv('SOCKET_BYPASS_HEADER_NAME', 'x-waf-bypass')
108+
vi.stubEnv('SOCKET_BYPASS_HEADER_VALUE', 'let-me-through')
118109
const { getOrFetchBlob } = await freshCache()
119110
// The matchHeader is the assertion: without the pair configured at module
120111
// init the interceptor never matches and the request fails.
@@ -127,7 +118,7 @@ describe('WAF bypass header', () => {
127118
})
128119

129120
test('sends no bypass header when only the name is configured', async () => {
130-
process.env['SOCKET_BYPASS_HEADER_NAME'] = 'x-waf-bypass'
121+
vi.stubEnv('SOCKET_BYPASS_HEADER_NAME', 'x-waf-bypass')
131122
const { getOrFetchBlob } = await freshCache()
132123
// A half-configured pair must not produce a header with an empty value.
133124
nock(BLOB_HOST)

test/unit/http-server-start.test.mts

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
*/
88

99
import { once } from 'node:events'
10-
import { createServer } from 'node:http'
1110
import type { Server } from 'node:http'
1211

1312
import { httpRequest } from '@socketsecurity/lib-stable/http-request/request'
@@ -34,28 +33,16 @@ vi.mock(import('node:http'), async importOriginal => {
3433
return { ...actual, default: actual, createServer: capturingCreateServer }
3534
})
3635

37-
// Bind an ephemeral port, read it back, and release it — startHttpServer
38-
// takes a concrete port, so the free one has to be found first.
39-
async function reserveFreePort(): Promise<number> {
40-
const probe = createServer()
41-
await new Promise<void>(resolve => {
42-
probe.listen(0, '127.0.0.1', resolve)
43-
})
44-
const address = probe.address()
45-
const port =
46-
typeof address === 'object' && address !== null ? address.port : 0
47-
await new Promise<void>(resolve => {
48-
probe.close(() => resolve())
49-
})
50-
return port
51-
}
52-
5336
// Boot the server the way production does and wait for the socket to be up.
54-
async function bootServer(port: number): Promise<Server> {
55-
startHttpServer(port)
37+
async function bootServer(): Promise<number> {
38+
startHttpServer(0)
5639
const server = createdServers.at(-1)!
5740
await once(server, 'listening')
58-
return server
41+
const address = server.address()
42+
if (typeof address !== 'object' || address === null) {
43+
throw new Error('Expected a listening TCP server')
44+
}
45+
return address.port
5946
}
6047

6148
afterEach(async () => {
@@ -70,18 +57,16 @@ afterEach(async () => {
7057
)
7158
})
7259

73-
test('startHttpServer serves /health on the requested port', async () => {
74-
const port = await reserveFreePort()
75-
await bootServer(port)
60+
test('startHttpServer serves /health on its assigned port', async () => {
61+
const port = await bootServer()
7662

7763
const res = await httpRequest(`http://127.0.0.1:${port}/health`)
7864
expect(res.status).toBe(200)
7965
expect(res.json()).toMatchObject({ service: 'socket-mcp', status: 'healthy' })
8066
})
8167

8268
test('startHttpServer wires the MCP handler onto the listening server', async () => {
83-
const port = await reserveFreePort()
84-
await bootServer(port)
69+
const port = await bootServer()
8570

8671
const res = await httpRequest(`http://127.0.0.1:${port}/`, {
8772
method: 'POST',

test/unit/tool-alerts.test.mts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,15 @@ describe('alerts tool handler', () => {
8787
})
8888

8989
test('returns an isError result on upstream failure', async () => {
90-
nock(API).get('/v0/orgs/my-org/alerts').query(true).reply(403, 'forbidden')
90+
const scope = nock(API)
91+
.get('/v0/orgs/my-org/alerts')
92+
.query(true)
93+
.reply(403, 'forbidden')
9194
const result = await defineAlertsTool().handler(
9295
{ org_slug: 'my-org' },
9396
withToken,
9497
)
98+
expect(scope.isDone()).toBe(true)
9599
expect(result.isError).toBe(true)
96100
expect(result.content[0]!.text).toMatch(/Error fetching alerts for my-org/)
97101
})

test/unit/tool-audit.test.mts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
5+
import { safeDelete } from '@socketsecurity/lib-stable/fs/safe'
6+
import { afterAll, afterEach, expect, test, vi } from 'vitest'
7+
8+
import { logger } from '../../lib/logger.ts'
9+
import {
10+
auditLogPath,
11+
emitAuditEvent,
12+
extractResources,
13+
maskArgs,
14+
newRequestId,
15+
tokenIdentity,
16+
} from '../../lib/tool-audit.ts'
17+
import type { AuditEntry } from '../../lib/tool-audit.ts'
18+
19+
const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'socket-audit-test-'))
20+
21+
afterEach(() => {
22+
vi.unstubAllEnvs()
23+
vi.restoreAllMocks()
24+
})
25+
26+
afterAll(async () => {
27+
await safeDelete(scratchDir)
28+
})
29+
30+
test('uses the operator directory when no audit path is configured', () => {
31+
vi.stubEnv('SOCKET_MCP_AUDIT_LOG', undefined)
32+
expect(auditLogPath()).toBe(
33+
path.join(os.homedir(), '.socket', 'mcp-audit.jsonl'),
34+
)
35+
})
36+
37+
test('creates parent directories and preserves prior audit entries', () => {
38+
const file = path.join(scratchDir, 'nested', 'events.jsonl')
39+
vi.stubEnv('SOCKET_MCP_AUDIT_LOG', file)
40+
const entry: AuditEntry = {
41+
timestamp: '2026-01-01T00:00:00.000Z',
42+
identity: 'operator',
43+
requestId: newRequestId(),
44+
tool: 'organizations',
45+
status: 'success',
46+
resources: ['org:example-org'],
47+
args: {},
48+
}
49+
emitAuditEvent(entry)
50+
const denied = {
51+
...entry,
52+
requestId: newRequestId(),
53+
status: 'denied' as const,
54+
}
55+
emitAuditEvent(denied)
56+
expect(
57+
readFileSync(file, 'utf8')
58+
.trim()
59+
.split(/\r?\n/)
60+
.map(line => JSON.parse(line)),
61+
).toEqual([entry, denied])
62+
})
63+
64+
test('logs an audit write failure without failing the tool call', () => {
65+
const parentFile = path.join(scratchDir, 'parent-file')
66+
writeFileSync(parentFile, 'example')
67+
vi.stubEnv('SOCKET_MCP_AUDIT_LOG', path.join(parentFile, 'events.jsonl'))
68+
const report = vi.spyOn(logger, 'error').mockImplementation(() => logger)
69+
expect(() =>
70+
emitAuditEvent({
71+
timestamp: '2026-01-01T00:00:00.000Z',
72+
identity: 'operator',
73+
requestId: newRequestId(),
74+
tool: 'organizations',
75+
status: 'failure',
76+
resources: [],
77+
args: {},
78+
}),
79+
).not.toThrow()
80+
expect(report).toHaveBeenCalledTimes(1)
81+
})
82+
83+
test.each([
84+
[{}, []],
85+
[{ org: 'example-org', organization: 'other-org' }, ['org:example-org']],
86+
[{ organization: 'example-org' }, ['org:example-org']],
87+
[{ org: 42, ecosystem: 42, name: 'example-package', purl: '' }, []],
88+
[
89+
{ ecosystem: 'npm', depname: 'example-package', version: '1.0.0' },
90+
['pkg:npm/example-package@1.0.0'],
91+
],
92+
[
93+
{ ecosystem: 'npm', name: 'example-package', version: 42 },
94+
['pkg:npm/example-package'],
95+
],
96+
[
97+
{ purl: 'pkg:npm/example-package@1.0.0' },
98+
['purl:pkg:npm/example-package@1.0.0'],
99+
],
100+
])('extracts available resource identifiers', (args, resources) => {
101+
expect(extractResources(args)).toEqual(resources)
102+
})
103+
104+
test('redacts nested sensitive keys without mutating caller arguments', () => {
105+
const args = {
106+
Authorization: 'test_fake_token',
107+
metadata: { API_KEY: 'test_fake_key', name: 'example-package' },
108+
count: 2,
109+
}
110+
expect(maskArgs(args)).toEqual({
111+
Authorization: '***REDACTED***',
112+
metadata: { API_KEY: '***REDACTED***', name: 'example-package' },
113+
count: 2,
114+
})
115+
expect(args.metadata.API_KEY).toBe('test_fake_key')
116+
})
117+
118+
test('uses operator identity without a bearer token', () => {
119+
expect(tokenIdentity(undefined)).toBe('operator')
120+
expect(tokenIdentity('')).toBe('operator')
121+
})
122+
123+
test('produces stable distinct pseudonymous token identities', () => {
124+
const identity = tokenIdentity('test_fake_token')
125+
expect(identity).toMatch(/^sha256:[a-f0-9]{16}$/u)
126+
expect(tokenIdentity('test_fake_token')).toBe(identity)
127+
expect(tokenIdentity('test_fake_other_token')).not.toBe(identity)
128+
})

test/unit/tool-organizations.test.mts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ describe('organizations tool handler', () => {
3838
})
3939

4040
test('returns an isError result on upstream failure', async () => {
41-
nock(API).get('/v0/organizations').reply(401, { error: 'unauthorized' })
41+
const scope = nock(API)
42+
.get('/v0/organizations')
43+
.reply(401, { error: 'unauthorized' })
4244
const result = await defineOrganizationsTool().handler({}, withToken)
45+
expect(scope.isDone()).toBe(true)
4346
expect(result.isError).toBe(true)
4447
expect(result.content[0]!.text).toMatch(/Error fetching organizations/)
4548
})

0 commit comments

Comments
 (0)