Ctrl K
ComunidadGuías…

test/devin-connect-unary-body-bound.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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',
    );
  });
});