Files
openclaw/src/agents/auth-profiles/oauth-refresh-error.test.ts
2026-04-18 22:49:54 +01:00

138 lines
5.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
makeSeededRandom,
randomAsciiString as randomJunk,
randomlyCased,
} from "./oauth-test-utils.js";
import { isRefreshTokenReusedError } from "./oauth.js";
// Direct tests for the refresh_token_reused classifier. This is the gate that
// triggers the retry/adoption recovery path; a false negative here means we
// fail over to an expensive model instead of adopting the winner's fresh
// token.
describe("isRefreshTokenReusedError", () => {
describe("positive cases", () => {
it("detects the canonical OAuth snake_case code", () => {
expect(isRefreshTokenReusedError(new Error("refresh_token_reused"))).toBe(true);
});
it("detects mixed-case variants", () => {
expect(isRefreshTokenReusedError(new Error("REFRESH_TOKEN_REUSED"))).toBe(true);
expect(isRefreshTokenReusedError(new Error("Refresh_Token_Reused"))).toBe(true);
});
it("detects OpenAI-style natural-language variants", () => {
expect(
isRefreshTokenReusedError(
new Error("Your refresh token has already been used to generate a new access token."),
),
).toBe(true);
expect(
isRefreshTokenReusedError(
new Error("The refresh token has already been used to generate a new access token."),
),
).toBe(true);
});
it("detects full JSON-wrapped 401 payloads", () => {
expect(
isRefreshTokenReusedError(
new Error(
'401 {"error":{"message":"Your refresh token has already been used to generate a new access token.","type":"invalid_request_error","code":"refresh_token_reused"}}',
),
),
).toBe(true);
});
it("detects when message is a plain string (non-Error throw)", () => {
expect(isRefreshTokenReusedError("refresh_token_reused")).toBe(true);
});
it("detects when message is wrapped via Error.cause (single level)", () => {
// formatErrorMessage traverses the .cause chain and concatenates
// messages with " | ", so a marker hidden in the cause still counts.
const inner = new Error("refresh_token_reused");
const outer = new Error("OAuth token refresh failed", { cause: inner });
expect(isRefreshTokenReusedError(outer)).toBe(true);
});
it("detects when message is wrapped in a multi-level cause chain", () => {
const root = new Error("already been used to generate a new access token");
const mid = new Error("plugin adapter failure", { cause: root });
const outer = new Error("OAuth token refresh failed", { cause: mid });
expect(isRefreshTokenReusedError(outer)).toBe(true);
});
it("detects when cause is a bare string (no Error wrapper)", () => {
const outer = new Error("upstream", { cause: "refresh_token_reused" });
expect(isRefreshTokenReusedError(outer)).toBe(true);
});
it("still matches when the marker phrase is embedded in a longer message", () => {
expect(
isRefreshTokenReusedError(
new Error("auth failed: already been used to generate a new access token (retry)"),
),
).toBe(true);
});
});
describe("negative cases", () => {
it("returns false for unrelated auth errors", () => {
expect(isRefreshTokenReusedError(new Error("invalid_grant"))).toBe(false);
expect(isRefreshTokenReusedError(new Error("HTTP 500 Internal Server Error"))).toBe(false);
expect(isRefreshTokenReusedError(new Error("network timeout"))).toBe(false);
expect(isRefreshTokenReusedError(new Error("expired or revoked"))).toBe(false);
});
it("returns false for null/undefined/non-stringable values", () => {
expect(isRefreshTokenReusedError(null)).toBe(false);
expect(isRefreshTokenReusedError(undefined)).toBe(false);
expect(isRefreshTokenReusedError(42)).toBe(false);
expect(isRefreshTokenReusedError({})).toBe(false);
});
it("returns false for an empty error message", () => {
expect(isRefreshTokenReusedError(new Error(""))).toBe(false);
});
});
describe("fuzz: random noisy messages", () => {
it("always detects the marker when embedded at random positions with noise", () => {
const rng = makeSeededRandom(0xabad1dea);
const markers = [
"refresh_token_reused",
"Your refresh token has already been used to generate a new access token",
"already been used to generate a new access token",
];
for (let i = 0; i < 500; i += 1) {
const marker = randomlyCased(markers[i % markers.length], rng);
const prefix = randomJunk(rng, 64);
const suffix = randomJunk(rng, 64);
const msg = `${prefix}${marker}${suffix}`;
expect(isRefreshTokenReusedError(new Error(msg))).toBe(true);
// Same for plain-string throws.
expect(isRefreshTokenReusedError(msg)).toBe(true);
}
});
it("never yields a false positive on marker-free random messages", () => {
const rng = makeSeededRandom(0x1337_beef);
for (let i = 0; i < 500; i += 1) {
// Bound length so we never randomly emit one of the marker substrings.
const msg = randomJunk(rng, 32);
if (
msg.toLowerCase().includes("refresh_token_reused") ||
msg.toLowerCase().includes("refresh token has already been used") ||
msg.toLowerCase().includes("already been used to generate a new access token")
) {
// Extremely unlikely with 32-char random junk; skip if it happens.
continue;
}
expect(isRefreshTokenReusedError(new Error(msg))).toBe(false);
}
});
});
});