test/chat-p2-identity-rotation.test.js
Rama master · réplica de lectura del repositorio original.
// ReviewChatCore P2-1/P2-2 — identity writes must follow the account id, never
// the acquire-time apiKey snapshot.
//
// Every mark*/report*/updateCapability helper in auth.js resolves its account
// with `accounts.find(a => a.apiKey === apiKey)`. The Cascade arms passed the
// snapshot captured when the turn started, so a credential rotation that lands
// mid-flight (dashboard token paste-over, or a connect re-login on the same
// shared account) made each write a silent no-op:
// - a 429's cooldown was lost → the pool immediately re-picked the throttled
// account (the #224 shape the connect lane fixed at finalizeConnectAccount);
// - capability updates vanished;
// - the connect dead-token arm's 'd' health event was dropped, so the
// dashboard's dead-token count under-reported exactly the rotation-prone
// accounts.
//
// These are BEHAVIOUR tests, not source greps: the upstream is faked at the
// handler seams (WindsurfClient for Cascade, __setConnectDeps for connect), the
// rotation uses the real setAccountTokens, and every assertion reads the real
// account state. Two accounts are seeded in every case so "landed on the right
// account" and "left the peer alone" are both observable.
//
// The credential-epoch discipline is pinned too: an old-key success must not
// clear the NEW credential's error budget, and an old-key 401 must not charge
// it — the same split finalizeConnectAccount already enforces (2803/2802).
import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
acquireAccountByKey,
addAccountByKey,
getAccountInternal,
removeAccount,
setAccountTokens,
__resetReloginState,
__setReloginDeps,
} from '../src/auth.js';
import {
handleChatCompletions,
__resetConnectDeps,
__setConnectDeps,
} from '../src/handlers/chat.js';
import { cacheClear } from '../src/cache.js';
import { poolClear } from '../src/conversation-pool.js';
const createdIds = [];
const prevEnv = {};
const MODEL = 'gemini-2.5-flash';
const CONNECT_MODEL = 'swe-1-6-slow';
function seed(label) {
const key = `devin-session-token$p2id-${label}-${Math.random().toString(36).slice(2)}`;
const a = addAccountByKey(key, label);
createdIds.push(a.id);
const acct = getAccountInternal(a.id);
acct.tier = 'pro';
acct.status = 'active';
return acct;
}
/** What a dashboard paste-over / background re-login does to a live account. */
function rotate(acct) {
const next = `${acct.apiKey}$ROTATED-${Math.random().toString(36).slice(2, 8)}`;
assert.equal(setAccountTokens(acct.id, { apiKey: next }), true,
'precondition: the rotation landed in the pool');
return next;
}
const live = (acct) => getAccountInternal(acct.id);
function modelCooldowns(acct) {
return Object.keys(live(acct)._modelRateLimits || {});
}
function healthCounts(acct) {
const win = live(acct)._health || [];
return {
dead: win.filter((e) => e.k === 'd').length,
throttle: win.filter((e) => e.k === 't' || e.k === 'c').length,
};
}
function successCapabilities(acct) {
return Object.values(live(acct).capabilities || {})
.filter((c) => c && c.ok === true && c.reason === 'success');
}
function streamSuccess(opts) {
opts.onChunk({ text: 'OK' });
return { text: '', toolCalls: [], usage: { inputTokens: 10, outputTokens: 2 } };
}
function nonStreamSuccess() {
// The non-stream path consumes an ARRAY of chunks with toolCalls attached;
// a plain object yields a 502.
return Object.assign([{ text: 'OK' }], { toolCalls: [], usage: { inputTokens: 10, outputTokens: 2 } });
}
/**
* Cascade handler context. `served` is the ordered pool handout (attempt 1, 2,
* …), so the test — not auth.js sort order — decides who serves.
*/
function cascadeCtx({ served, onAttempt, callerKey }) {
let handouts = 0;
class FakeClient {
constructor(apiKey) { this.apiKey = apiKey; }
async cascadeChat(_messages, _modelEnum, _modelUid, opts = {}) {
return onAttempt(this.apiKey, opts);
}
}
return {
callerKey,
waitForAccount() {
const acct = served[Math.min(handouts, served.length - 1)];
handouts++;
return acquireAccountByKey(acct.apiKey);
},
ensureLs: async () => {},
getLsFor: () => ({ port: 17771, csrfToken: 'csrf', generation: 1 }),
WindsurfClient: FakeClient,
};
}
function fakeRes() {
const listeners = new Map();
return {
body: '',
writableEnded: false,
write(chunk) { this.body += String(chunk); return true; },
end(chunk) {
if (chunk) this.write(chunk);
this.writableEnded = true;
for (const cb of listeners.get('close') || []) cb();
},
on(event, cb) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(cb);
return this;
},
};
}
const unique = (label) => `p2-identity ${label} ${Math.random().toString(36).slice(2)}`;
beforeEach(() => {
prevEnv.DEVIN_CONNECT = process.env.DEVIN_CONNECT;
prevEnv.DEVIN_ONLY = process.env.DEVIN_ONLY;
delete process.env.DEVIN_CONNECT;
delete process.env.DEVIN_ONLY;
cacheClear();
poolClear();
});
afterEach(() => {
__resetConnectDeps();
__resetReloginState();
__setReloginDeps(null);
while (createdIds.length) {
try { removeAccount(createdIds.pop()); } catch { /* already gone */ }
}
cacheClear();
poolClear();
if (prevEnv.DEVIN_CONNECT === undefined) delete process.env.DEVIN_CONNECT;
else process.env.DEVIN_CONNECT = prevEnv.DEVIN_CONNECT;
if (prevEnv.DEVIN_ONLY === undefined) delete process.env.DEVIN_ONLY;
else process.env.DEVIN_ONLY = prevEnv.DEVIN_ONLY;
});
describe('P2-1 — Cascade cooldowns follow the account id across a rotation', () => {
it('non-stream: a 429 that races the rotation still cools the account that failed', async () => {
const a = seed('ns-rl-a');
const b = seed('ns-rl-b');
const acquireKey = a.apiKey;
let rotatedKey = '';
let peerServed = false;
const ctx = cascadeCtx({
callerKey: 'session:p2id-ns-rl',
served: [a, b],
onAttempt(apiKey) {
if (apiKey === acquireKey) {
rotatedKey = rotate(a);
throw Object.assign(new Error('429 rate limit exceeded for this model'), { code: 'RATE_LIMITED' });
}
assert.equal(apiKey, b.apiKey, 'the retry presents the peer, not the retired credential');
peerServed = true;
return nonStreamSuccess();
},
});
const result = await handleChatCompletions(
{ model: MODEL, messages: [{ role: 'user', content: unique('ns-rl') }] },
ctx,
);
assert.equal(result.status, 200, 'the peer completed the turn');
assert.ok(rotatedKey && peerServed, 'precondition: rotation and failover both happened');
assert.equal(live(a).apiKey, rotatedKey, 'precondition: A now carries the new key');
assert.ok(modelCooldowns(a).length > 0,
'the 429 cooldown must land on A through its immutable id — keyed on the snapshot it is dropped and the pool re-picks the throttled account');
assert.ok(healthCounts(a).throttle > 0, 'the throttle health event is recorded on A');
assert.deepEqual(modelCooldowns(b), [], 'the peer was never rate-limited');
});
it('non-stream: a success that races the rotation records the capability on the account', async () => {
const a = seed('ns-ok-a');
const b = seed('ns-ok-b');
const acquireKey = a.apiKey;
let rotatedKey = '';
const ctx = cascadeCtx({
callerKey: 'session:p2id-ns-ok',
served: [a, b],
onAttempt(apiKey) {
assert.equal(apiKey, acquireKey, 'the attempt presents the acquire-time credential');
rotatedKey = rotate(a);
return nonStreamSuccess();
},
});
const result = await handleChatCompletions(
{ model: MODEL, messages: [{ role: 'user', content: unique('ns-ok') }] },
ctx,
);
assert.equal(result.status, 200);
assert.equal(live(a).apiKey, rotatedKey, 'precondition: the rotation landed');
assert.equal(successCapabilities(a).length, 1,
'updateCapability must resolve A by id; the stale snapshot misses and the capability write is lost');
assert.deepEqual(Object.keys(live(b).capabilities || {}), [], 'the peer gained no capability from A\'s turn');
});
it('stream: a 429 that races the rotation still cools the account that failed', async () => {
const a = seed('st-rl-a');
const b = seed('st-rl-b');
const acquireKey = a.apiKey;
let rotatedKey = '';
let peerServed = false;
const ctx = cascadeCtx({
callerKey: 'session:p2id-st-rl',
served: [a, b],
onAttempt(apiKey, opts) {
if (apiKey === acquireKey) {
rotatedKey = rotate(a);
throw Object.assign(new Error('429 rate limit exceeded for this model'), { code: 'RATE_LIMITED' });
}
assert.equal(apiKey, b.apiKey, 'the retry presents the peer, not the retired credential');
peerServed = true;
return streamSuccess(opts);
},
});
const result = await handleChatCompletions(
{ model: MODEL, stream: true, messages: [{ role: 'user', content: unique('st-rl') }] },
ctx,
);
assert.equal(result.status, 200);
const res = fakeRes();
await result.handler(res);
assert.ok(rotatedKey && peerServed, 'precondition: rotation and failover both happened');
assert.equal(live(a).apiKey, rotatedKey, 'precondition: A now carries the new key');
assert.ok(modelCooldowns(a).length > 0,
'the stream 429 cooldown must land on A through its immutable id');
assert.deepEqual(modelCooldowns(b), [], 'the peer was never rate-limited');
assert.ok(res.body.includes('[DONE]'), 'the peer completed the stream');
});
it('stream: a success that races the rotation records the capability on the account', async () => {
const a = seed('st-ok-a');
const b = seed('st-ok-b');
const acquireKey = a.apiKey;
let rotatedKey = '';
const ctx = cascadeCtx({
callerKey: 'session:p2id-st-ok',
served: [a, b],
onAttempt(apiKey, opts) {
assert.equal(apiKey, acquireKey);
rotatedKey = rotate(a);
return streamSuccess(opts);
},
});
const result = await handleChatCompletions(
{ model: MODEL, stream: true, messages: [{ role: 'user', content: unique('st-ok') }] },
ctx,
);
assert.equal(result.status, 200);
const res = fakeRes();
await result.handler(res);
assert.equal(live(a).apiKey, rotatedKey, 'precondition: the rotation landed');
assert.equal(successCapabilities(a).length, 1,
'updateCapability must resolve A by id; the stale snapshot misses and the capability write is lost');
assert.deepEqual(Object.keys(live(b).capabilities || {}), [], 'the peer gained no capability from A\'s turn');
assert.ok(res.body.includes('[DONE]'));
});
});
describe('P2-1 — credential-epoch split is preserved on the Cascade arms', () => {
it('an auth failure on the CURRENT credential still charges the account', async () => {
const a = seed('epoch-current');
const b = seed('epoch-current-peer');
const ctx = cascadeCtx({
callerKey: 'session:p2id-epoch-current',
served: [a, b],
onAttempt() {
throw new Error('unauthenticated: invalid api key');
},
});
const result = await handleChatCompletions(
{ model: MODEL, stream: true, messages: [{ role: 'user', content: unique('epoch-current') }] },
ctx,
);
const res = fakeRes();
await result.handler(res);
assert.equal(live(a).errorCount || 0, 1,
'a genuine auth failure on the live credential must still charge the error budget');
assert.equal(healthCounts(a).dead, 0, 'this is not a dead-token event');
});
it('non-stream: an auth failure on the CURRENT credential still charges the account', async () => {
const a = seed('epoch-ns-current');
const b = seed('epoch-ns-current-peer');
const ctx = cascadeCtx({
callerKey: 'session:p2id-epoch-ns-current',
served: [a, b],
onAttempt() {
throw new Error('unauthenticated: invalid api key');
},
});
const result = await handleChatCompletions(
{ model: MODEL, messages: [{ role: 'user', content: unique('epoch-ns-current') }] },
ctx,
);
assert.ok(result.status >= 400, 'the auth failure surfaces to the client');
assert.equal(live(a).errorCount || 0, 1,
'the non-stream arm must charge a live credential\'s auth failure too');
});
it('non-stream: an old-key auth failure does not charge the new credential', async () => {
const a = seed('epoch-ns-stale');
const b = seed('epoch-ns-stale-peer');
const acquireKey = a.apiKey;
const ctx = cascadeCtx({
callerKey: 'session:p2id-epoch-ns-stale',
served: [a, b],
onAttempt(apiKey) {
assert.equal(apiKey, acquireKey);
rotate(a);
throw new Error('unauthenticated: invalid api key');
},
});
const result = await handleChatCompletions(
{ model: MODEL, messages: [{ role: 'user', content: unique('epoch-ns-stale') }] },
ctx,
);
assert.ok(result.status >= 400);
assert.equal(live(a).errorCount || 0, 0,
'the retired credential\'s 401 must not charge the fresh key (non-stream twin of 2802)');
});
it('an old-key auth failure does not charge the new credential', async () => {
const a = seed('epoch-stale');
const b = seed('epoch-stale-peer');
const acquireKey = a.apiKey;
const ctx = cascadeCtx({
callerKey: 'session:p2id-epoch-stale',
served: [a, b],
onAttempt(apiKey) {
assert.equal(apiKey, acquireKey);
rotate(a);
throw new Error('unauthenticated: invalid api key');
},
});
const result = await handleChatCompletions(
{ model: MODEL, stream: true, messages: [{ role: 'user', content: unique('epoch-stale') }] },
ctx,
);
const res = fakeRes();
await result.handler(res);
assert.equal(live(a).errorCount || 0, 0,
'the retired credential\'s 401 must not charge the fresh key (finalizeConnectAccount 2802 discipline)');
});
it('an old-key success does not clear the new credential\'s error budget', async () => {
const a = seed('epoch-stale-ok');
const b = seed('epoch-stale-ok-peer');
const acquireKey = a.apiKey;
live(a).errorCount = 2; // demoted by earlier failures on the account
const ctx = cascadeCtx({
callerKey: 'session:p2id-epoch-stale-ok',
served: [a, b],
onAttempt(apiKey, opts) {
assert.equal(apiKey, acquireKey);
rotate(a);
return streamSuccess(opts);
},
});
const result = await handleChatCompletions(
{ model: MODEL, stream: true, messages: [{ role: 'user', content: unique('epoch-stale-ok') }] },
ctx,
);
const res = fakeRes();
await result.handler(res);
assert.equal(live(a).errorCount, 2,
'a success from a retired credential does not prove the new key works — the budget must stay (finalizeConnectAccount 2803 discipline)');
});
it('non-stream: an old-key success does not clear the new credential\'s error budget', async () => {
const a = seed('epoch-ns-stale-ok');
const b = seed('epoch-ns-stale-ok-peer');
const acquireKey = a.apiKey;
live(a).errorCount = 2;
const ctx = cascadeCtx({
callerKey: 'session:p2id-epoch-ns-stale-ok',
served: [a, b],
onAttempt(apiKey) {
assert.equal(apiKey, acquireKey);
rotate(a);
return nonStreamSuccess();
},
});
const result = await handleChatCompletions(
{ model: MODEL, messages: [{ role: 'user', content: unique('epoch-ns-stale-ok') }] },
ctx,
);
assert.equal(result.status, 200);
assert.equal(live(a).errorCount, 2,
'the non-stream success verdict must stay on the epoch that produced it');
});
});
describe('P2-2 — connect dead-token verdict follows the account id across a rotation', () => {
function unauthorized() {
return Object.assign(new Error('retired session token'), { code: 'UNAUTHORIZED' });
}
beforeEach(() => {
process.env.DEVIN_CONNECT = '1';
// The dead arm fires the free liveness probe fire-and-forget; stub it so the
// test never reaches the network and the verdict it records is deterministic.
__setReloginDeps({ checkSessionLiveness: async () => ({ alive: true }) });
});
it('stream: the \'d\' health event lands on the rotated account, not the snapshot', async () => {
const a = seed('dead-stream-a');
const b = seed('dead-stream-b');
const acquireKey = a.apiKey;
let rotatedKey = '';
let attempts = 0;
__setConnectDeps({
streamChatCompletion: async (params, send) => {
attempts++;
if (params.token === acquireKey) {
rotatedKey = rotate(a);
throw unauthorized();
}
// Whatever live credential the failover hands out completes the turn.
send({ id: 's', object: 'chat.completion.chunk', choices: [{ index: 0, delta: { content: 'OK' }, finish_reason: null }] });
send({ id: 's', object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
return { usage: { total_tokens: 3 } };
},
});
const result = await handleChatCompletions(
{ model: CONNECT_MODEL, stream: true, messages: [{ role: 'user', content: unique('dead-stream') }] },
{ callerKey: '' },
);
assert.equal(result.status, 200);
const res = fakeRes();
await result.handler(res);
assert.ok(rotatedKey, 'precondition: the rotation happened while the attempt was in flight');
assert.ok(attempts >= 2, 'precondition: the dead verdict triggered a failover hop');
assert.equal(healthCounts(a).dead, 1,
'reportDeadToken must resolve A by id; the snapshot misses and the only record that the token came back dead is lost');
assert.equal(healthCounts(b).dead, 0, 'the peer is not blamed for A\'s dead token');
});
it('non-stream: the \'d\' health event lands on the rotated account, not the snapshot', async () => {
const a = seed('dead-ns-a');
const b = seed('dead-ns-b');
const acquireKey = a.apiKey;
let rotatedKey = '';
let attempts = 0;
__setConnectDeps({
toChatCompletion: async (params) => {
attempts++;
if (params.token === acquireKey) {
rotatedKey = rotate(a);
throw unauthorized();
}
return {
status: 200,
body: {
id: 'x', object: 'chat.completion', created: 0, model: CONNECT_MODEL,
choices: [{ index: 0, message: { role: 'assistant', content: 'OK' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 },
},
};
},
});
const result = await handleChatCompletions(
{ model: CONNECT_MODEL, messages: [{ role: 'user', content: unique('dead-ns') }] },
{ callerKey: '' },
);
assert.equal(result.status, 200, 'the failover hop completed the turn');
assert.ok(rotatedKey, 'precondition: the rotation happened while the attempt was in flight');
assert.ok(attempts >= 2, 'precondition: the dead verdict triggered a failover hop');
assert.equal(healthCounts(a).dead, 1,
'reportDeadToken must resolve A by id; the snapshot misses and the dead-token health event is dropped');
assert.equal(healthCounts(b).dead, 0, 'the peer is not blamed for A\'s dead token');
});
});