Ctrl K
ComunidadGuías…

test/ban-detection.test.js

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

Archivo original ↗Preguntar a KiBot
// v2.0.56 — ban-shaped error detection. windsurf-assistant-pub
// inspiration: when upstream returns "Account suspended" / "API key
// revoked" we promote the account to status='banned' after a 2-strike
// streak, so the pool stops handing out a known-dead key.

import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
  addAccountByKey, removeAccount, getAccountInternal,
  looksLikeBanSignal, reportBanSignal, reportSuccess,
  __serializeAccounts, __deserializeAccount,
} from '../src/auth.js';

const created = [];
function mkAccount(label = 'ban-test') {
  const a = addAccountByKey('sk-ban-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8), label);
  created.push(a.id);
  return a;
}

afterEach(() => {
  while (created.length) removeAccount(created.pop());
});

describe('looksLikeBanSignal (audit windsurf-assistant-pub)', () => {
  const positives = [
    'Your account has been suspended for terms-of-service violation',
    'account_disabled',
    'Account banned by upstream',
    'User suspended due to abuse',
    'Subscription cancelled',
    'subscription expired',
    'Authentication failed: invalid credentials',
    'Invalid API key',
    'API key revoked',
    'API key disabled',
    'unauthorized: account does not exist',
    '账号已停用',
    '账号封禁',
    '用户已禁用',
    '订阅已过期',
  ];
  const negatives = [
    'rate limit exceeded',
    'too many requests',
    'context deadline exceeded',
    'internal error occurred (error ID: abc)',
    'cascade transport failure',
    'panel state not found',
    'temporary upstream stall',
    '',
    null,
    undefined,
    42,
  ];

  const labelOf = (msg) => {
    const s = (msg === null) ? 'null' : (msg === undefined) ? 'undefined' : String(msg);
    return s.slice(0, 60).replace(/\s+/g, ' ');
  };
  for (const msg of positives) {
    it(`detects ban signal: ${labelOf(msg)}`, () => {
      assert.equal(looksLikeBanSignal(msg), true);
    });
  }
  for (const msg of negatives) {
    it(`ignores non-ban: ${labelOf(msg) || '(empty)'}`, () => {
      assert.equal(looksLikeBanSignal(msg), false);
    });
  }
});

describe('reportBanSignal: 2-strike → status=banned', () => {
  it('first ban-shaped error does NOT flip status', () => {
    const a = mkAccount();
    reportBanSignal(a.apiKey, 'Account suspended');
    assert.equal(getAccountInternal(a.id).status, 'active', 'one strike must not ban');
  });

  it('two ban-shaped errors within window → status=banned', () => {
    const a = mkAccount();
    reportBanSignal(a.apiKey, 'Account suspended');
    reportBanSignal(a.apiKey, 'Invalid API key');
    const after = getAccountInternal(a.id);
    assert.equal(after.status, 'banned', 'two strikes must promote to banned');
    assert.ok(after.bannedAt > 0, 'bannedAt timestamp must be set');
    assert.ok(after.bannedReason, 'bannedReason must be set');
  });

  it('reportSuccess between two ban signals resets the streak', () => {
    const a = mkAccount();
    reportBanSignal(a.apiKey, 'Authentication failed');
    reportSuccess(a.apiKey);
    reportBanSignal(a.apiKey, 'Authentication failed');
    assert.equal(getAccountInternal(a.id).status, 'active', 'success must clear ban-streak');
  });

  it('signal on unknown apiKey is a no-op', () => {
    const result = reportBanSignal('sk-does-not-exist', 'Account suspended');
    assert.equal(result, false);
  });
});

describe('R3: transient-first guard prevents ban promotion of transient errors', () => {
  // The upstream sometimes wraps a transient stall in a 401/403 shell whose text
  // matches BAN_PATTERNS (e.g. "authentication ... failed"). The handler guard is
  // `!isRateLimit && !isInternal && !isTransient && looksLikeBanSignal(msg)`, so a
  // transient/internal error must NEVER reach reportBanSignal, even though its text
  // is ban-shaped — otherwise two transient stalls would burn a healthy account.
  const banShapedButTransient = 'Authentication failed'; // matches BAN_PATTERNS

  it('the text alone still looks ban-shaped (guard is not about the text)', () => {
    assert.equal(looksLikeBanSignal(banShapedButTransient), true);
  });

  it('twice-repeated transient error carrying ban-shaped text does NOT ban when guard skips it', () => {
    const a = mkAccount();
    // Simulate the handler: because isInternal/isTransient is true, the ban branch
    // is guarded off and reportBanSignal is never called — so no promotion.
    const isInternal = true; // e.g. "internal error occurred (error ID ...)" shell
    for (let strike = 0; strike < 2; strike++) {
      if (!isInternal && looksLikeBanSignal(banShapedButTransient)) {
        reportBanSignal(a.apiKey, banShapedButTransient);
      }
    }
    assert.equal(getAccountInternal(a.id).status, 'active',
      'a transient error wrapped in an auth-shaped shell must not burn the account');
  });

  it('genuine ban signal (not transient) still promotes after 2 strikes', () => {
    const a = mkAccount();
    const isInternal = false; // real auth failure, not a transient shell
    for (let strike = 0; strike < 2; strike++) {
      if (!isInternal && looksLikeBanSignal('Account suspended')) {
        reportBanSignal(a.apiKey, 'Account suspended');
      }
    }
    assert.equal(getAccountInternal(a.id).status, 'banned',
      'a genuine ban signal must still promote — the guard only spares transient errors');
  });

  it('persists bannedAt/bannedReason across a serialize→deserialize round-trip', () => {
    const a = mkAccount();
    reportBanSignal(a.apiKey, 'Account suspended');
    reportBanSignal(a.apiKey, 'Account suspended');
    const stored = getAccountInternal(a.id);
    assert.equal(stored.status, 'banned');
    assert.ok(stored.bannedAt > 0);
    assert.equal(stored.bannedReason, 'Account suspended');

    const row = __serializeAccounts().find((x) => x.id === a.id);
    assert.equal(row.status, 'banned');
    assert.equal(row.bannedAt, stored.bannedAt);
    assert.equal(row.bannedReason, 'Account suspended');

    const restored = __deserializeAccount(row);
    assert.equal(restored.status, 'banned');
    assert.equal(restored.bannedAt, stored.bannedAt);
    assert.equal(restored.bannedReason, 'Account suspended');
  });
});