test/devin-connect-unary-body-bound.test.js
Rama master · réplica de lectura del repositorio original.
// Bounded reads for the two unary upstream readers that sit next to the
// streamChat non-200 guard (whose ceiling is covered in test/devin-connect.test.js):
//
// - postConnectUnary (GetUserJwt) accumulated a whole response body and then
// discarded it on non-200. A 500 with a 600MiB body was measured holding
// 599.6MiB mid-read on node 24.21 (arrayBuffers, post-gc) before the mint
// degraded to null — unbounded memory for a body nothing reads.
// - the catalog unary probe (GetCliModelConfigs / GetUserStatus / AssignModel)
// did `raw.toString('utf8').slice(0, 200)` — toString BEFORE the slice — so
// a 500 with a 600MiB body threw ERR_STRING_TOO_LONG inside the 'end'
// listener. Nothing catches it there: it escaped as an uncaughtException and
// src/index.js:42-44 exits on that, so one bad response dropped every tenant.
//
// Both now share createBoundedBodyAccumulator with the streamChat branch. As
// with the streamChat ceiling, the value is read at module load: set the env
// BEFORE the first import of the module graph in this process. Nothing in
// test/setup-env.mjs imports product modules (its header explains why), so
// devin-connect.js is still fresh here; if that ever changes, the first case
// below goes red (no marker / no warning) rather than silently measuring the
// 8MiB default.
//
// The assertions compare against the CEILING, never against the input size — an
// uncapped reader also "handles" the input, so input-sized expectations cannot
// tell the two apart. Multi-chunk bodies throughout: a single big chunk would
// not exercise the crossing-chunk slice this ceiling depends on.
process.env.DEVIN_CONNECT_MAX_ERROR_BODY_BYTES = '64';
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
const connect = await import('../src/devin-connect.js');
const catalog = await import('../src/devin-connect-catalog.js');
const { log } = await import('../src/config.js');
const { writeStringField } = await import('../src/proto.js');
const { wrapEnvelope } = await import('../src/connect.js');
const CAP = 64; // must match DEVIN_CONNECT_MAX_ERROR_BODY_BYTES above
const MARKER = '\n[... truncated: upstream error body exceeded the cap ...]';
/** Fake https.request: one response whose chunks arrive as separate 'data' events. */
function fakeResponse(statusCode, chunks) {
return (_opts, cb) => {
const res = new EventEmitter();
res.statusCode = statusCode;
const req = new EventEmitter();
req.write = () => {};
req.destroy = () => {};
req.end = () => {
// Deliver asynchronously, the way a real socket does.
setImmediate(() => {
cb(res);
for (const chunk of chunks) res.emit('data', chunk);
res.emit('end');
req.emit('close'); // unaryCall clears its 30s guard timer on 'close'
});
};
return req;
};
}
/** Run `run` with log.warn captured; returns the joined warning lines. */
async function captureWarns(run) {
const warns = [];
const original = log.warn;
log.warn = (...args) => { warns.push(args.join(' ')); };
try { await run(); } finally { log.warn = original; }
return warns;
}
afterEach(() => {
connect.__setRequestImpl(null);
catalog.__setCatalogRequestImpl(null);
connect.invalidateUserJwtCache();
});
describe('postConnectUnary bounds the unary response read (GetUserJwt)', () => {
it('marks the truncation when a multi-chunk body crosses the ceiling', async () => {
const warns = await captureWarns(async () => {
connect.__setRequestImpl(fakeResponse(500, [
Buffer.alloc(40, 0x41), Buffer.alloc(40, 0x42), Buffer.alloc(40, 0x43),
]));
assert.equal(
await connect.mintUserJwt('devin-session-token$cap-over'),
null,
'a non-200 mint still degrades to null',
);
});
const capWarns = warns.filter((w) => w.includes(`exceeded ${CAP} bytes`));
assert.equal(capWarns.length, 1, `exactly one truncation warning per response; got ${capWarns.length}`);
assert.match(capWarns[0], /truncated/, 'the warning must name the truncation');
});
it('warns only once even when chunks keep arriving past the ceiling', async () => {
const warns = await captureWarns(async () => {
connect.__setRequestImpl(fakeResponse(500, [
Buffer.alloc(40), Buffer.alloc(40), Buffer.alloc(40), Buffer.alloc(40), Buffer.alloc(40),
]));
await connect.mintUserJwt('devin-session-token$cap-many');
});
assert.equal(warns.filter((w) => w.includes(`exceeded ${CAP} bytes`)).length, 1);
});
it('does not truncate a body exactly at the ceiling (boundary)', async () => {
const warns = await captureWarns(async () => {
connect.__setRequestImpl(fakeResponse(500, [Buffer.alloc(32), Buffer.alloc(32)]));
await connect.mintUserJwt('devin-session-token$cap-exact');
});
assert.equal(
warns.filter((w) => w.includes(`exceeded ${CAP} bytes`)).length, 0,
`a body of exactly ${CAP} bytes must not be marked truncated`,
);
});
it('truncates a body one byte over the ceiling (boundary)', async () => {
const warns = await captureWarns(async () => {
connect.__setRequestImpl(fakeResponse(500, [Buffer.alloc(32), Buffer.alloc(33)]));
await connect.mintUserJwt('devin-session-token$cap-plus1');
});
assert.equal(
warns.filter((w) => w.includes(`exceeded ${CAP} bytes`)).length, 1,
`a body of ${CAP + 1} bytes must be marked truncated`,
);
});
it('does not parse a truncated 200 frame as a credential', async () => {
// A complete, valid frame (field #1 = the JWT) followed by filler so the
// total crosses the ceiling. Uncapped, the frame parses and the filler is
// never looked at, so the mint would return the credential from a response
// that is not the whole response.
const framed = wrapEnvelope(writeStringField(1, 'jwt-value'), { compress: false });
const warns = await captureWarns(async () => {
connect.__setRequestImpl(fakeResponse(200, [
Buffer.concat([framed, Buffer.alloc(20, 0x46)]),
Buffer.alloc(40, 0x46),
]));
assert.equal(
await connect.mintUserJwt('devin-session-token$cap-200'),
null,
'a truncated frame must not be parsed as a credential',
);
});
assert.equal(
warns.filter((w) => w.includes(`exceeded ${CAP} bytes`)).length, 1,
'the discarded oversized 200 must be visible in the log',
);
});
});
describe('catalog unary probe bounds the body and slices before stringifying', () => {
it('keeps exactly the ceiling-sized prefix and marks the truncation', async () => {
catalog.__setCatalogRequestImpl(fakeResponse(500, [
Buffer.alloc(30, 0x41), Buffer.alloc(30, 0x42), Buffer.alloc(30, 0x43),
]));
const err = await catalog.fetchUserStatus({ token: 'devin-session-token$cat-over' })
.then(() => null, (e) => e);
assert.ok(err, 'an oversized non-200 must still reject with a classified error');
assert.ok(
err.message.endsWith(MARKER),
'the clipped body must be marked, not silently described as a shorter error',
);
const payload = err.message.slice(0, -MARKER.length).split('HTTP 500: ').pop();
assert.equal(
payload.length, CAP,
`the kept body must be the ceiling (${CAP}), not the 200-byte classification window`,
);
assert.equal(
payload, 'A'.repeat(30) + 'B'.repeat(30) + 'C'.repeat(4),
'the crossing chunk must be sliced at the ceiling, not dropped whole',
);
});
it('leaves a body at exactly the ceiling whole and unmarked (boundary)', async () => {
catalog.__setCatalogRequestImpl(fakeResponse(500, [
Buffer.alloc(32, 0x44), Buffer.alloc(32, 0x45),
]));
const err = await catalog.fetchUserStatus({ token: 'devin-session-token$cat-exact' })
.then(() => null, (e) => e);
assert.ok(err.message.endsWith('D'.repeat(32) + 'E'.repeat(32)),
'a body of exactly the ceiling must pass through whole');
assert.doesNotMatch(err.message, /truncated/);
});
it('leaves a short body whole and unmarked', async () => {
catalog.__setCatalogRequestImpl(fakeResponse(429, [Buffer.from('too many requests')]));
const err = await catalog.fetchUserStatus({ token: 'devin-session-token$cat-short' })
.then(() => null, (e) => e);
assert.equal(err.code, 'RATE_LIMITED');
assert.match(err.message, /too many requests/);
assert.doesNotMatch(err.message, /truncated/, 'a body that fit must not be marked truncated');
});
it('rejects an oversized 200 body instead of decoding a prefix as the message', async () => {
// Valid protobuf (field #99 is ignored by decodeUserStatusFull), so an
// uncapped reader resolves normally and only the truncation guard rejects.
const body = writeStringField(99, 'x'.repeat(80));
catalog.__setCatalogRequestImpl(fakeResponse(200, [
body.subarray(0, 40), body.subarray(40),
]));
await assert.rejects(
catalog.fetchUserStatus({ token: 'devin-session-token$cat-200' }),
(e) => e.code === 'UPSTREAM_ERROR' && e.message.includes(`exceeded ${CAP} bytes`),
'a truncated single-message response must fail, not decode its prefix',
);
});
});