import { describe, expect, it } from "vitest"; import {
isSafeToCopyOAuthIdentity,
isSameOAuthIdentity,
normalizeAuthEmailToken,
normalizeAuthIdentityToken,
shouldMirrorRefreshedOAuthCredential,
} from "./oauth-identity.js"; import { makeSeededRandom, maybe, randomAsciiString as randomString } from "./oauth-test-utils.js"; import type { AuthProfileCredential } from "./types.js";
// Direct unit + fuzz tests for the cross-agent credential-mirroring identity // gate introduced for #26322 (CWE-284). These helpers are on the hot-path of // `mirrorRefreshedCredentialIntoMainStore` and must be strictly correct: a // false positive means a sub-agent could poison the main-agent auth store.
describe("normalizeAuthIdentityToken", () => {
it("returns trimmed value when non-empty", () => {
expect(normalizeAuthIdentityToken("acct-123")).toBe("acct-123");
expect(normalizeAuthIdentityToken(" acct-123 ")).toBe("acct-123");
});
it("returns undefined for undefined, empty, or whitespace-only input", () => {
expect(normalizeAuthIdentityToken(undefined)).toBeUndefined();
expect(normalizeAuthIdentityToken("")).toBeUndefined();
expect(normalizeAuthIdentityToken(" ")).toBeUndefined();
expect(normalizeAuthIdentityToken("\t\n\r")).toBeUndefined();
});
it("preserves case (accountIds are case-sensitive)", () => {
expect(normalizeAuthIdentityToken("Acct-ABC")).toBe("Acct-ABC");
expect(normalizeAuthIdentityToken("acct-abc")).toBe("acct-abc");
});
});
it("matches when main has accountId+email and incoming has only matching email", () => { // Not asymmetric: both sides carry identity (main has more, but // incoming still has email). Email is a shared field with a // matching value — positive-identity match, safe to mirror.
expect(
isSameOAuthIdentity(
{ accountId: "acct-1", email: "user@example.com" },
{ email: "user@example.com" },
),
).toBe(true);
});
it("matches when accountIds on one side are whitespace-only and both sides expose matching email", () => { // Whitespace-only accountId is treated as absent; email falls back // symmetrically on both sides so the positive email match wins.
expect(
isSameOAuthIdentity(
{ accountId: " ", email: "user@example.com" },
{ accountId: "", email: "USER@example.com" },
),
).toBe(true);
});
});
describe("asymmetric identity evidence is refused", () => {
it("refuses when main has accountId and incoming has neither", () => {
expect(isSameOAuthIdentity({ accountId: "acct-1" }, {})).toBe(false);
});
it("refuses when main has email and incoming has neither", () => {
expect(isSameOAuthIdentity({ email: "user@example.com" }, {})).toBe(false);
});
it("refuses when incoming has identity but main does not", () => {
expect(isSameOAuthIdentity({}, { accountId: "acct-1" })).toBe(false);
expect(isSameOAuthIdentity({}, { email: "user@example.com" })).toBe(false);
});
it("refuses when main has only accountId and incoming has only email (non-overlapping fields)", () => {
expect(isSameOAuthIdentity({ accountId: "acct-1" }, { email: "user@example.com" })).toBe( false,
);
});
});
describe("no identity metadata on either side", () => {
it("returns true (no evidence of mismatch) when both sides lack accountId and email", () => { // This matches the looser behaviour of the pre-existing // adoptNewerMainOAuthCredential gate; provider equality is the // caller's responsibility.
expect(isSameOAuthIdentity({}, {})).toBe(true);
});
it("returns true when one side has empty strings for both fields", () => {
expect(
isSameOAuthIdentity(
{ accountId: "", email: "" },
{ accountId: undefined, email: undefined },
),
).toBe(true);
});
});
describe("reflexivity and symmetry", () => {
it("is reflexive: share(a,a) === true for any non-conflicting identity", () => { const a = { accountId: "acct-1", email: "a@example.com" };
expect(isSameOAuthIdentity(a, a)).toBe(true);
});
// --------------------------------------------------------------------------- // Fuzz tests. Seeded Mulberry32 so the run is reproducible. // ---------------------------------------------------------------------------
describe("isSafeToCopyOAuthIdentity (unified copy gate, used for mirror and adopt)", () => {
describe("positive matches", () => {
it("accepts matching accountIds", () => {
expect(isSafeToCopyOAuthIdentity({ accountId: "x" }, { accountId: "x" })).toBe(true);
});
it("accepts when both sides lack identity metadata", () => {
expect(isSafeToCopyOAuthIdentity({}, {})).toBe(true);
});
});
describe("identity regression is refused (incoming drops existing's identity)", () => {
it("refuses when incoming has no identity and existing has accountId", () => { // Was previously allowed under the permissive relaxed rule; the // narrower rule refuses because it would strip identity evidence.
expect(isSafeToCopyOAuthIdentity({ accountId: "x" }, {})).toBe(false);
});
it("refuses when incoming has no identity and existing has email", () => {
expect(isSafeToCopyOAuthIdentity({ email: "u@example.com" }, {})).toBe(false);
});
});
describe("non-overlapping identity fields are refused", () => {
it("refuses when existing has only accountId and incoming has only email", () => {
expect(isSafeToCopyOAuthIdentity({ accountId: "x" }, { email: "u@example.com" })).toBe(false);
});
it("refuses when existing has only email and incoming has only accountId", () => {
expect(isSafeToCopyOAuthIdentity({ email: "u@example.com" }, { accountId: "x" })).toBe(false);
});
});
describe("relationship to the strict isSameOAuthIdentity reference", () => {
it("is at least as permissive as the strict rule (strict implies safe-to-copy)", () => { // Pure-symmetric match cases accepted by the strict rule must also // be accepted by the unified copy gate.
expect(isSameOAuthIdentity({ accountId: "x" }, { accountId: "x" })).toBe(true);
expect(isSafeToCopyOAuthIdentity({ accountId: "x" }, { accountId: "x" })).toBe(true);
});
it("only relaxes the strict rule in the pure-upgrade direction", () => { // Existing has no identity, incoming has identity: strict refuses, // unified accepts.
expect(isSameOAuthIdentity({}, { accountId: "x" })).toBe(false);
expect(isSafeToCopyOAuthIdentity({}, { accountId: "x" })).toBe(true);
});
it("does NOT relax in the regression direction (strict and unified both refuse)", () => {
expect(isSameOAuthIdentity({ accountId: "x" }, {})).toBe(false);
expect(isSafeToCopyOAuthIdentity({ accountId: "x" }, {})).toBe(false);
});
});
});
describe("isSafeToCopyOAuthIdentity fuzz", () => {
it("is reflexive: share(a, a) is always true", () => { const rng = makeSeededRandom(0x0172_0417); for (let i = 0; i < 1000; i += 1) { const a = {
accountId: maybe(rng, randomString(rng, 64)),
email: maybe(rng, randomString(rng, 64)),
};
expect(isSafeToCopyOAuthIdentity(a, a)).toBe(true);
}
});
it("always refuses distinct non-empty accountIds (primary CWE-284 invariant)", () => { const rng = makeSeededRandom(0xfaceb00c); for (let i = 0; i < 500; i += 1) { const idA = `A-${randomString(rng, 32) || "x"}`; const idB = `B-${randomString(rng, 32) || "y"}`;
expect(isSafeToCopyOAuthIdentity({ accountId: idA }, { accountId: idB })).toBe(false);
}
});
it("strict → unified: if isSameOAuthIdentity accepts, isSafeToCopyOAuthIdentity accepts", () => { // Monotonic relaxation property over random inputs. const rng = makeSeededRandom(0x7777_7777); for (let i = 0; i < 1000; i += 1) { const a = {
accountId: maybe(rng, randomString(rng, 32)),
email: maybe(rng, randomString(rng, 32)),
}; const b = {
accountId: maybe(rng, randomString(rng, 32)),
email: maybe(rng, randomString(rng, 32)),
}; if (isSameOAuthIdentity(a, b)) {
expect(isSafeToCopyOAuthIdentity(a, b)).toBe(true);
}
}
});
it("unified rule never refuses a same-account pair and never accepts a different-account pair", () => { // Over random identity pairs that share accountId but vary in every // other field, the gate must always accept. Over pairs with distinct // non-empty accountIds it must always refuse. const rng = makeSeededRandom(0x9a_9b_9c_9d); for (let i = 0; i < 500; i += 1) { const shared = `acct-${randomString(rng, 32) || "x"}`; const a = {
accountId: shared,
email: maybe(rng, randomString(rng, 32)),
}; const b = {
accountId: shared,
email: maybe(rng, randomString(rng, 32)),
};
expect(isSafeToCopyOAuthIdentity(a, b)).toBe(true);
}
});
});
describe("isSameOAuthIdentity fuzz", () => {
it("is always symmetric regardless of input shape", () => { const rng = makeSeededRandom(0x0426_0417); for (let i = 0; i < 1000; i += 1) { const a = {
accountId: maybe(rng, randomString(rng, 64)),
email: maybe(rng, randomString(rng, 64)),
}; const b = {
accountId: maybe(rng, randomString(rng, 64)),
email: maybe(rng, randomString(rng, 64)),
};
expect(isSameOAuthIdentity(a, b)).toBe(isSameOAuthIdentity(b, a));
}
});
it("is always reflexive: share(a, a) is true", () => { const rng = makeSeededRandom(0x1234_abcd); for (let i = 0; i < 1000; i += 1) { const a = {
accountId: maybe(rng, randomString(rng, 64)),
email: maybe(rng, randomString(rng, 64)),
};
expect(isSameOAuthIdentity(a, a)).toBe(true);
}
});
it("never returns true for distinct non-empty accountIds (regardless of email)", () => { const rng = makeSeededRandom(0xfeedc0de); for (let i = 0; i < 500; i += 1) { const idA = `A-${randomString(rng, 32) || "x"}`; const idB = `B-${randomString(rng, 32) || "y"}`; // Shared email; mismatched accountId must still refuse. const email = `${randomString(rng, 16) || "u"}@example.com`;
expect(isSameOAuthIdentity({ accountId: idA, email }, { accountId: idB, email })).toBe(false);
}
});
it("email comparison is case-insensitive for random email bodies", () => { const rng = makeSeededRandom(0xcafef00d); for (let i = 0; i < 500; i += 1) { const local = randomString(rng, 16).replace(/[^A-Za-z0-9+._-]/g, "") || "user"; const domain = (randomString(rng, 12).replace(/[^A-Za-z0-9.-]/g, "") || "example") + ".com"; const email = `${local}@${domain}`; const randomizedCase = email
.split("")
.map((c) => (rng() < 0.5 ? c.toUpperCase() : c.toLowerCase()))
.join("");
expect(isSameOAuthIdentity({ email }, { email: randomizedCase })).toBe(true);
}
});
});
Messung V0.5 in Prozent
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-06-05)
¤
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.