Ctrl K
ComunidadGuías…

test/auth-snapshot-epoch.test.js

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

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