Ctrl K
ComunidadGuías…

test/breaker-tunable-hotswap.test.js

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

Archivo original ↗Preguntar a KiBot
// v3.0.3 — circuit-breaker / rate-limit tunables env→runtime-config migration.
// Confirms the three-tier resolution (override → env → historical default), the
// setter whitelist/clamp/null-clear semantics, the breakerBaseMs→errorRecoveryMs
// dynamic default, and that an env-only deploy is byte-identical to the old
// hardcoded behaviour. In-memory only (temp DATA_DIR from setup-env.mjs).

import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
  _resetRuntimeConfigForTests,
  getBreakerTunable, getBreakerTunables, getBreakerOverrides, setBreakerTunables,
} from '../src/runtime-config.js';

// Save/restore the env vars these tests poke so cross-test state can't leak.
const ENV_KEYS = [
  'WINDSURFAPI_ERROR_STREAK_THRESHOLD', 'WINDSURFAPI_ERROR_WINDOW_MS',
  'WINDSURFAPI_INTERNAL_ERROR_THRESHOLD', 'WINDSURFAPI_INTERNAL_QUARANTINE_MS',
  'WINDSURFAPI_ERROR_RECOVERY_MS', 'WINDSURFAPI_BREAKER', 'WINDSURFAPI_BREAKER_BASE_MS',
  'WINDSURFAPI_BREAKER_FACTOR', 'WINDSURFAPI_BREAKER_MAX_MS', 'WINDSURFAPI_BREAKER_STREAK_START',
  'WINDSURFAPI_NEW_ACCOUNT_GRACE_MS', 'WINDSURFAPI_LAST_ACCOUNT_EXEMPT', 'WINDSURFAPI_NEW_ACCOUNT_BASELINE',
];
let saved;

describe('breaker tunables — three-tier resolution (v3.0.3)', () => {
  beforeEach(() => {
    saved = {};
    for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
    _resetRuntimeConfigForTests();
  });
  afterEach(() => {
    for (const k of ENV_KEYS) {
      if (saved[k] === undefined) delete process.env[k];
      else process.env[k] = saved[k];
    }
  });

  it('empty config falls back to the historical defaults', () => {
    // No override, no env → the old hardcoded values.
    assert.equal(getBreakerTunable('errorStreakThreshold'), 3);
    assert.equal(getBreakerTunable('errorWindowMs'), 1800000);
    assert.equal(getBreakerTunable('internalErrorThreshold'), 2);
    assert.equal(getBreakerTunable('internalQuarantineMs'), 120000); // L1: cut 5min→2min (KiroStudio short-cooldown philosophy)
    assert.equal(getBreakerTunable('errorRecoveryMs'), 900000);
    assert.equal(getBreakerTunable('breakerFactor'), 1.5);
    assert.equal(getBreakerTunable('breakerMaxMs'), 3600000);
    assert.equal(getBreakerTunable('breakerStreakStart'), 2);
    assert.equal(getBreakerTunable('newAccountGraceMs'), 600000);
    assert.equal(getBreakerTunable('breakerEnabled'), true);
    assert.equal(getBreakerTunable('lastAccountExempt'), true);
    assert.equal(getBreakerTunable('newAccountBaseline'), true);
    // 429 mitigation ON by default as of 2026-07-12: floor=30000 (30s min client
    // backoff, breaks the re-hammer loop), ceil=600000 (safety only), burst=15000
    // (bare-429 self-heals fast). Set env/tunable to revert any of these.
    assert.equal(getBreakerTunable('rlClientBackoffFloorMs'), 30000);
    assert.equal(getBreakerTunable('rlClientBackoffCeilMs'), 600000);
    assert.equal(getBreakerTunable('rlBurstMs'), 15000);
    // L2 degraded-serve defaults ON (serve a slightly-cooled account beats a 429).
    assert.equal(getBreakerTunable('degradedServe'), true);
  });

  it('env overrides the default; injected env is honored (env-only deploy path)', () => {
    const env = { WINDSURFAPI_ERROR_STREAK_THRESHOLD: '5', WINDSURFAPI_BREAKER: '0' };
    assert.equal(getBreakerTunable('errorStreakThreshold', env), 5);
    assert.equal(getBreakerTunable('breakerEnabled', env), false);
    // default process.env path (nothing set) still gives the default
    assert.equal(getBreakerTunable('errorStreakThreshold'), 3);
  });

  it('runtime-config override beats env', () => {
    setBreakerTunables({ errorStreakThreshold: 7 });
    const env = { WINDSURFAPI_ERROR_STREAK_THRESHOLD: '5' };
    assert.equal(getBreakerTunable('errorStreakThreshold', env), 7);
  });

  it('breakerBaseMs resolves to errorRecoveryMs when unset (dynamic default)', () => {
    assert.equal(getBreakerTunable('breakerBaseMs'), getBreakerTunable('errorRecoveryMs'));
    setBreakerTunables({ errorRecoveryMs: 1200000 });
    assert.equal(getBreakerTunable('breakerBaseMs'), 1200000, 'base follows recovery');
    setBreakerTunables({ breakerBaseMs: 1000000 });
    assert.equal(getBreakerTunable('breakerBaseMs'), 1000000, 'explicit base wins');
  });

  it('breakerFactor is strict >1: env "1" falls to default, override clamps to min', () => {
    assert.equal(getBreakerTunable('breakerFactor', { WINDSURFAPI_BREAKER_FACTOR: '1' }), 1.5);
    assert.equal(getBreakerTunable('breakerFactor', { WINDSURFAPI_BREAKER_FACTOR: '2' }), 2);
    setBreakerTunables({ breakerFactor: 1.05 });
    assert.equal(getBreakerTunable('breakerFactor'), 1.1, 'override clamped to min 1.1');
  });
});

describe('setBreakerTunables — whitelist / clamp / clear (v3.0.3)', () => {
  beforeEach(() => { for (const k of ENV_KEYS) delete process.env[k]; _resetRuntimeConfigForTests(); });

  it('rejects unknown keys', () => {
    const ov = setBreakerTunables({ bogusKnob: 999, errorStreakThreshold: 4 });
    assert.equal('bogusKnob' in ov, false);
    assert.equal(ov.errorStreakThreshold, 4);
  });

  it('clamps numerics to [min,max]', () => {
    assert.equal(setBreakerTunables({ errorStreakThreshold: 999 }).errorStreakThreshold, 50);
    assert.equal(setBreakerTunables({ errorStreakThreshold: 0 }).errorStreakThreshold, 1);
  });

  it('coerces booleans', () => {
    assert.strictEqual(setBreakerTunables({ breakerEnabled: 0 }).breakerEnabled, false);
    assert.strictEqual(setBreakerTunables({ breakerEnabled: 'yes' }).breakerEnabled, true);
  });

  it('empty / whitespace string is ignored (never silently 0)', () => {
    setBreakerTunables({ errorStreakThreshold: 9 });
    setBreakerTunables({ errorStreakThreshold: '' });
    assert.equal(getBreakerTunable('errorStreakThreshold'), 9, 'blank left the prior value');
    setBreakerTunables({ errorStreakThreshold: '   ' });
    assert.equal(getBreakerTunable('errorStreakThreshold'), 9);
  });

  it('null clears an override → back to env/default', () => {
    setBreakerTunables({ errorStreakThreshold: 8 });
    assert.equal(getBreakerTunable('errorStreakThreshold'), 8);
    setBreakerTunables({ errorStreakThreshold: null });
    assert.equal(getBreakerOverrides().errorStreakThreshold, null);
    assert.equal(getBreakerTunable('errorStreakThreshold'), 3, 'fell back to default');
  });

  it('getBreakerTunables returns every knob', () => {
    const all = getBreakerTunables();
    // 13 original breaker knobs + F3 (rlClientBackoffFloorMs, rlClientBackoffCeilMs)
    // + F2 (rlBurstMs) + L2 (degradedServe) + queue-on-pin (stickyQueueOnPinMs) = 18.
    assert.equal(Object.keys(all).length, 18);
    // Name the newest one explicitly: a bare count tells the next person a knob was
    // added but not which, and it passes just as well if one is added while another
    // is dropped.
    assert.ok('stickyQueueOnPinMs' in all,
      'queue-on-pin must be reachable through getBreakerTunables, or Settings cannot show it');
  });
});

describe('breaker hot-swap drives reportError live (v3.0.3)', () => {
  beforeEach(() => { for (const k of ENV_KEYS) delete process.env[k]; _resetRuntimeConfigForTests(); });

  it('lowering errorStreakThreshold trips an already-near-limit account on the next error', async () => {
    // Half-new-half-old lock: reportError reads the getter live, so an account
    // sitting at errorCount=2 trips the moment the threshold drops to 2.
    //
    // 2026-11-01: this case used to be a tautology. It opened with
    //   `if (typeof auth.reportError !== 'function') return;`
    // — a guard that can never fire (auth.js:4259 always exports it) — and then
    // only did setBreakerTunables + a getter read-back. reportError was never
    // called, so the name's promise ("trips an already-near-limit account") was
    // never exercised. The guard is gone; the scenario below is real.
    //
    // The sequence RAISES the threshold before lowering it. That is what makes
    // the case bite: with a fixed count it could not tell a live read from a
    // hardcoded `>= 3` (both true at errorCount=3 — measured: mutating the read
    // to a constant left the earlier version green). Here a constant cannot
    // satisfy both halves: 3 must NOT trip at threshold 5, and 4 MUST trip at 3.
    const auth = await import('../src/auth.js');
    // A peer must exist or lastAccountExempt (default true) keeps the tripping
    // account in rotation — see connect-error-blame.test.js:331.
    const hot = auth.addAccountByKey('hot-key-hotswap', 'hot@example.invalid');
    auth.addAccountByKey('peer-key-hotswap', 'peer@example.invalid');
    assert.ok(hot, 'precondition: the account was added');

    auth.reportError('hot-key-hotswap');
    auth.reportError('hot-key-hotswap');
    assert.equal(hot.errorCount, 2, 'precondition: two errors recorded');

    // Raised above the count: the next error must NOT trip.
    setBreakerTunables({ errorStreakThreshold: 5 });
    assert.equal(getBreakerTunable('errorStreakThreshold'), 5);
    auth.reportError('hot-key-hotswap');
    assert.equal(hot.errorCount, 3);
    assert.notEqual(hot.status, 'error', 'threshold 5 must hold a count of 3 in rotation');

    // Lowered under the running process: the next error MUST trip. A hardcoded
    // threshold fails one of these two assertions whichever constant it picks.
    setBreakerTunables({ errorStreakThreshold: 3 });
    assert.equal(getBreakerTunable('errorStreakThreshold'), 3);
    auth.reportError('hot-key-hotswap');
    assert.equal(hot.errorCount, 4);
    assert.equal(hot.status, 'error', 'the lowered threshold tripped the account live');
  });
});