test/ban-detection.test.js
Rama master · réplica de lectura del repositorio original.
// 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');
});
});