test/auth-snapshot-epoch.test.js
Rama master · réplica de lectura del repositorio original.
// V-26 (audit CORE-007, NEEDS-EVIDENCE) — background billing/status snapshots
// across credential lifecycle epochs.
//
// A snapshot is a statement about ONE credential at ONE point in time. Both
// background readers capture an account row, await an upstream RPC, and then
// publish what they learned. If a re-login installs a new apiKey on that same
// row while the RPC is in flight, the published tier/entitlement/cost data
// describes the OLD credential and is installed on the NEW one.
//
// The pool already has the primitive for this: the guarded credit path
// (`_refreshCreditsImpl`) rejects a result whose account row or apiKey changed
// during the await, and requestReauthCheck / probeAndRecoverConnectAccount use
// the same isCurrentAccount + key-capture pair. These tests first REPRODUCE the
// race on the two unguarded paths (user status, billing) and then pin the
// guard. The delete/re-add ABA sub-claim is probed separately: object identity
// already rejects it, so it is documented, not "fixed" again.
//
// fetchUserStatus is exercised through its real source text with the network
// boundary substituted (the established seam used by s4-r3.test.js for
// refreshCredits): its dependencies are module-level dynamic imports.
import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
addAccountByKey, removeAccount, setAccountTokens, refreshAccountBilling,
__setModelCatalogDeps, sanitizeLabelText,
} from '../src/auth.js';
const created = [];
function deferred() {
let resolve, reject;
const promise = new Promise((a, b) => { resolve = a; reject = b; });
return { promise, resolve, reject };
}
async function drain() { for (let i = 0; i < 12; i++) await Promise.resolve(); }
// ─── fetchUserStatus harness (real source text, substituted RPC boundary) ───
function loadFetchUserStatus({ statuses, ls = {} }) {
const source = readFileSync(new URL('../src/auth.js', import.meta.url), 'utf8');
const identity = source.match(/function isCurrentAccount\(account\) \{[^}]*\}/);
assert.ok(identity, 'isCurrentAccount is present in the production source');
const start = source.indexOf('export async function fetchUserStatus(');
const end = source.indexOf('\n// Expanded canary set', start);
assert.ok(start >= 0 && end > start, 'load the actual production fetchUserStatus block');
const block = source.slice(start, end).replace(/^export /gm, '')
.replace("await import('./client.js')", 'await Promise.resolve(_clientMod)')
.replace("await import('./langserver.js')", 'await Promise.resolve(_lsMod)');
const account = {
id: 'a', email: 'v26@example.test', apiKey: 'key-old', tier: 'unknown',
capabilities: {}, userStatus: null, userStatusLastFetched: 0, tierManual: false,
};
const accounts = [account];
const tokens = [];
const saves = { count: 0 };
const _clientMod = {
WindsurfClient: class {
constructor(token) { this.token = token; }
getUserStatus() { tokens.push(this.token); return statuses.promise; }
},
};
const _lsMod = {
ensureLs: ls.ensureLs || (async () => {}),
getLsFor: ls.getLsFor || (() => ({ port: 1, csrfToken: 'csrf' })),
};
const deps = {
accounts, _clientMod, _lsMod,
getEffectiveProxy: () => null,
getLsAdmissionForAccount: () => ({ wouldStart: false, key: null }),
residentProbeSkip: () => null,
safeAccountRef: a => a?.id || '?',
// The eval'd source calls the real label boundary on upstream fields —
// inject the actual helper so commit-path tests cannot hit ReferenceError.
sanitizeLabelText,
MODELS: {},
log: { info() {}, warn() {}, debug() {} },
saveAccounts: () => { saves.count++; },
};
const fetchUserStatus = new Function(
...Object.keys(deps),
`${identity[0]}\n${block}\nreturn fetchUserStatus;`,
)(...Object.values(deps));
return { account, accounts, tokens, saves, fetchUserStatus };
}
const STATUS_OK = {
tierName: 'pro', teamsTier: 0, pro: true, planName: 'Pro', email: 'v26@example.test',
displayName: 'v26', teamId: '', isTeams: false, isEnterprise: false,
hasPaidFeatures: true, trialEndMs: 0, userUsedPromptCredits: 1, userUsedFlowCredits: 2,
monthlyPromptCredits: 100, monthlyFlowCredits: 100, maxPremiumChatMessages: 10,
allowedModels: [],
};
function add(label) {
const account = addAccountByKey(
`v26-${label}-${Date.now()}-${created.length}-${Math.random().toString(36).slice(2)}`,
label,
);
created.push(account.id);
return account;
}
afterEach(() => {
__setModelCatalogDeps(null);
while (created.length) removeAccount(created.pop());
});
describe('V-26 — background snapshots are bound to one credential epoch', () => {
it('a deferred old-key user-status snapshot cannot commit onto the new credential epoch', async () => {
const status = deferred();
const s = loadFetchUserStatus({ statuses: status });
const pending = s.fetchUserStatus('a');
await drain();
assert.deepEqual(s.tokens, ['key-old'], 'the RPC ran under the credential captured at entry');
// A re-login installs a new upstream key on the SAME row (setAccountTokens /
// reLoginAccount do exactly this) while the status RPC is still in flight.
s.account.apiKey = 'key-new';
status.resolve(STATUS_OK);
const result = await pending;
assert.equal(result, null, 'the stale-epoch snapshot is discarded, not published');
assert.equal(s.account.tier, 'unknown', 'the old credential plan does not set the new tier');
assert.equal(s.account.userStatus, null, 'no entitlement snapshot from the old credential');
assert.equal(s.account.userStatusLastFetched, 0, 'the new epoch is still unprobed');
assert.deepEqual(s.account.capabilities, {}, 'no capability verdicts from the old credential');
assert.equal(s.saves.count, 0, 'nothing is persisted for a discarded snapshot');
});
it('a deferred snapshot from a deleted/re-added account cannot commit to the new epoch', async () => {
const status = deferred();
const s = loadFetchUserStatus({ statuses: status });
const pending = s.fetchUserStatus('a');
await drain();
// Delete + re-add that REUSES the id (the ABA case a lifecycle epoch would
// have to catch). Object identity is what rejects it here.
const readded = {
id: 'a', email: 'v26@example.test', apiKey: 'key-old', tier: 'unknown',
capabilities: {}, userStatus: null, userStatusLastFetched: 0, tierManual: false,
};
s.accounts.splice(0, s.accounts.length, readded);
status.resolve(STATUS_OK);
await pending;
assert.equal(readded.tier, 'unknown', 'the new row keeps its own tier');
assert.equal(readded.userStatus, null, 'the new row gets no entitlement snapshot');
assert.equal(readded.userStatusLastFetched, 0);
});
it('a deferred old-key billing snapshot cannot publish onto the new credential epoch', async () => {
const account = add('billing-epoch');
const configs = deferred();
const tokens = [];
__setModelCatalogDeps({
getCascadeModelConfigs: (token) => { tokens.push(token); return configs.promise; },
});
const pending = refreshAccountBilling(account.id, { force: true, now: 40_000_000 });
await drain();
assert.deepEqual(tokens, [account.apiKey], 'the RPC ran under the captured credential');
setAccountTokens(account.id, { apiKey: `${account.apiKey}-rotated` });
configs.resolve({ configs: [{ modelUid: 'uid-v26', creditMultiplier: 3 }] });
const result = await pending;
assert.equal(result.ok, false, 'the stale-epoch billing response is rejected');
assert.equal(result.applied, undefined, 'no cost tier / rate was published');
assert.equal(result.error, 'ACCOUNT_CHANGED');
});
it('a same-epoch billing snapshot still publishes (control)', async () => {
const account = add('billing-control');
const configs = deferred();
__setModelCatalogDeps({
getCascadeModelConfigs: () => configs.promise,
});
const pending = refreshAccountBilling(account.id, { force: true, now: 41_000_000 });
await drain();
configs.resolve({ configs: [{ modelUid: 'uid-v26-control', creditMultiplier: 1.5 }] });
const result = await pending;
assert.equal(result.ok, true);
assert.equal(result.applied, true);
assert.equal(result.rateRows, 1);
});
});