mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-05-23 04:28:06 +00:00
479 lines
17 KiB
TypeScript
479 lines
17 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import os from "os";
|
|
import path from "path";
|
|
import fs from "fs/promises";
|
|
|
|
// Pure-function mirrors of helpers from geminiAuthFile.ts — no DB/cliRuntime deps.
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
function buildJwt(payload: JsonRecord): string {
|
|
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
|
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
return `${header}.${body}.fake-signature`;
|
|
}
|
|
|
|
function toRecord(value: unknown): JsonRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
|
}
|
|
|
|
function toNonEmptyString(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
|
|
function decodeJwtPayload(jwt: string): JsonRecord | null {
|
|
try {
|
|
const parts = jwt.split(".");
|
|
if (parts.length !== 3) return null;
|
|
const payload = Buffer.from(parts[1], "base64url").toString("utf8");
|
|
return toRecord(JSON.parse(payload));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function sanitizeFileNamePart(value: string): string {
|
|
const normalized = value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._@-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return normalized || "account";
|
|
}
|
|
|
|
interface GeminiConnectionLike {
|
|
id?: string;
|
|
name?: string;
|
|
email?: string;
|
|
displayName?: string;
|
|
accessToken?: string | null;
|
|
refreshToken?: string | null;
|
|
idToken?: string | null;
|
|
expiresAt?: string | null;
|
|
providerSpecificData?: JsonRecord | null;
|
|
}
|
|
|
|
class GeminiAuthFileError extends Error {
|
|
status: number;
|
|
code: string;
|
|
constructor(message: string, status = 400, code = "invalid_request") {
|
|
super(message);
|
|
this.name = "GeminiAuthFileError";
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
interface GeminiAuthFilePayload {
|
|
access_token: string;
|
|
scope: string;
|
|
token_type: string;
|
|
id_token: string;
|
|
expiry_date: number;
|
|
refresh_token: string;
|
|
}
|
|
|
|
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
|
|
function shouldRefreshGeminiConnection(connection: GeminiConnectionLike): boolean {
|
|
if (!toNonEmptyString(connection.accessToken)) return true;
|
|
const expiresAt = toNonEmptyString(connection.expiresAt);
|
|
if (!expiresAt) return false;
|
|
const expiresAtMs = new Date(expiresAt).getTime();
|
|
if (Number.isNaN(expiresAtMs)) return false;
|
|
return expiresAtMs - Date.now() <= REFRESH_BUFFER_MS;
|
|
}
|
|
|
|
function extractGeminiEmail(connection: GeminiConnectionLike): string | null {
|
|
const idToken = toNonEmptyString(connection.idToken);
|
|
if (idToken) {
|
|
const payload = decodeJwtPayload(idToken);
|
|
if (payload) {
|
|
const fromClaim = toNonEmptyString(payload.email);
|
|
if (fromClaim) return fromClaim;
|
|
}
|
|
}
|
|
return toNonEmptyString(connection.email) || toNonEmptyString(connection.displayName);
|
|
}
|
|
|
|
function buildGeminiAuthPayload(connection: GeminiConnectionLike): GeminiAuthFilePayload {
|
|
const accessToken = toNonEmptyString(connection.accessToken);
|
|
const refreshToken = toNonEmptyString(connection.refreshToken);
|
|
const idToken = toNonEmptyString(connection.idToken);
|
|
|
|
if (!accessToken) {
|
|
throw new GeminiAuthFileError(
|
|
"Gemini connection is missing access_token.",
|
|
409,
|
|
"access_token_missing"
|
|
);
|
|
}
|
|
if (!refreshToken) {
|
|
throw new GeminiAuthFileError(
|
|
"Gemini connection is missing refresh_token.",
|
|
409,
|
|
"reauth_required"
|
|
);
|
|
}
|
|
if (!idToken) {
|
|
throw new GeminiAuthFileError(
|
|
"Gemini connection is missing id_token.",
|
|
409,
|
|
"id_token_missing"
|
|
);
|
|
}
|
|
|
|
const psd = toRecord(connection.providerSpecificData);
|
|
const scope = toNonEmptyString(psd.scope) ?? "";
|
|
const tokenType = toNonEmptyString(psd.tokenType) ?? "Bearer";
|
|
|
|
let expiryDate: number;
|
|
const expiresAt = toNonEmptyString(connection.expiresAt);
|
|
if (expiresAt) {
|
|
const ms = new Date(expiresAt).getTime();
|
|
expiryDate = Number.isNaN(ms) ? Date.now() + 3600 * 1000 : ms;
|
|
} else {
|
|
expiryDate = Date.now() + 3600 * 1000;
|
|
}
|
|
|
|
return {
|
|
access_token: accessToken,
|
|
scope,
|
|
token_type: tokenType,
|
|
id_token: idToken,
|
|
expiry_date: expiryDate,
|
|
refresh_token: refreshToken,
|
|
};
|
|
}
|
|
|
|
interface GoogleAccountsSidecar {
|
|
active: string;
|
|
old: string[];
|
|
}
|
|
|
|
async function mergeGoogleAccountsFile(
|
|
accountsPath: string,
|
|
newEmail: string
|
|
): Promise<{ updated: boolean; savedBakPath: string | null }> {
|
|
let existing: GoogleAccountsSidecar = { active: "", old: [] };
|
|
let fileExists = false;
|
|
try {
|
|
const raw = await fs.readFile(accountsPath, "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === "object") {
|
|
existing = {
|
|
active: typeof parsed.active === "string" ? parsed.active : "",
|
|
old: Array.isArray(parsed.old)
|
|
? parsed.old.filter((s: unknown) => typeof s === "string")
|
|
: [],
|
|
};
|
|
}
|
|
fileExists = true;
|
|
} catch {
|
|
// absent or invalid
|
|
}
|
|
|
|
if (existing.active === newEmail) {
|
|
return { updated: false, savedBakPath: null };
|
|
}
|
|
|
|
let savedBakPath: string | null = null;
|
|
if (fileExists) {
|
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
savedBakPath = `${path.dirname(accountsPath)}${path.sep}google_accounts-${ts}.bak`;
|
|
await fs.copyFile(accountsPath, savedBakPath).catch(() => {});
|
|
}
|
|
|
|
const newOld = [
|
|
existing.active,
|
|
...existing.old.filter((e) => e !== existing.active && e !== newEmail),
|
|
].filter(Boolean);
|
|
|
|
const newDoc: GoogleAccountsSidecar = {
|
|
active: newEmail,
|
|
old: Array.from(new Set(newOld)),
|
|
};
|
|
|
|
await fs.writeFile(accountsPath, JSON.stringify(newDoc, null, 2) + "\n", {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
|
|
return { updated: true, savedBakPath };
|
|
}
|
|
|
|
// ──── Tests: buildGeminiAuthPayload ───────────────────────────────────────────
|
|
|
|
test("buildGeminiAuthPayload: produces correct Google OAuth2 shape", () => {
|
|
const idToken = buildJwt({ email: "user@google.com", sub: "12345" });
|
|
const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
|
|
const conn: GeminiConnectionLike = {
|
|
accessToken: "fake-access-token-aaa",
|
|
refreshToken: "1//refresh",
|
|
idToken,
|
|
expiresAt,
|
|
providerSpecificData: {
|
|
scope: "https://www.googleapis.com/auth/cloud-platform openid email",
|
|
tokenType: "Bearer",
|
|
},
|
|
};
|
|
const payload = buildGeminiAuthPayload(conn);
|
|
assert.equal(payload.access_token, "fake-access-token-aaa");
|
|
assert.equal(payload.refresh_token, "1//refresh");
|
|
assert.equal(payload.id_token, idToken);
|
|
assert.equal(payload.token_type, "Bearer");
|
|
assert.equal(payload.scope, "https://www.googleapis.com/auth/cloud-platform openid email");
|
|
assert.ok(typeof payload.expiry_date === "number", "expiry_date must be a number");
|
|
});
|
|
|
|
test("buildGeminiAuthPayload: expiry_date is unix ms, not ISO", () => {
|
|
const idToken = buildJwt({ email: "u@g.com" });
|
|
const expiresAt = "2026-01-01T00:00:00.000Z";
|
|
const conn: GeminiConnectionLike = {
|
|
accessToken: "fake-access-token-x",
|
|
refreshToken: "rt",
|
|
idToken,
|
|
expiresAt,
|
|
providerSpecificData: {},
|
|
};
|
|
const payload = buildGeminiAuthPayload(conn);
|
|
assert.equal(payload.expiry_date, new Date(expiresAt).getTime());
|
|
assert.ok(payload.expiry_date > 1_000_000_000_000, "should be ms-epoch, not seconds");
|
|
});
|
|
|
|
test("buildGeminiAuthPayload: scope is string with spaces, not array", () => {
|
|
const idToken = buildJwt({ email: "u@g.com" });
|
|
const conn: GeminiConnectionLike = {
|
|
accessToken: "fake-access-token-x",
|
|
refreshToken: "rt",
|
|
idToken,
|
|
providerSpecificData: {
|
|
scope: "https://www.googleapis.com/auth/cloud-platform openid email",
|
|
},
|
|
};
|
|
const payload = buildGeminiAuthPayload(conn);
|
|
assert.ok(typeof payload.scope === "string", "scope must be a string");
|
|
assert.ok(payload.scope.includes(" "), "scope must use spaces as separator");
|
|
assert.ok(!Array.isArray(payload.scope), "scope must not be an array");
|
|
});
|
|
|
|
test("buildGeminiAuthPayload: token_type defaults to Bearer when providerSpecificData.tokenType absent", () => {
|
|
const idToken = buildJwt({ email: "u@g.com" });
|
|
const conn: GeminiConnectionLike = {
|
|
accessToken: "fake-access-token-x",
|
|
refreshToken: "rt",
|
|
idToken,
|
|
providerSpecificData: {},
|
|
};
|
|
const payload = buildGeminiAuthPayload(conn);
|
|
assert.equal(payload.token_type, "Bearer");
|
|
});
|
|
|
|
test("buildGeminiAuthPayload: throws access_token_missing when accessToken absent", () => {
|
|
const idToken = buildJwt({ email: "u@g.com" });
|
|
const conn: GeminiConnectionLike = {
|
|
accessToken: null,
|
|
refreshToken: "rt",
|
|
idToken,
|
|
providerSpecificData: {},
|
|
};
|
|
assert.throws(
|
|
() => buildGeminiAuthPayload(conn),
|
|
(err: GeminiAuthFileError) => {
|
|
assert.equal(err.code, "access_token_missing");
|
|
assert.equal(err.status, 409);
|
|
return true;
|
|
}
|
|
);
|
|
});
|
|
|
|
test("buildGeminiAuthPayload: throws reauth_required when refreshToken absent", () => {
|
|
const idToken = buildJwt({ email: "u@g.com" });
|
|
const conn: GeminiConnectionLike = {
|
|
accessToken: "fake-access-token-x",
|
|
refreshToken: null,
|
|
idToken,
|
|
providerSpecificData: {},
|
|
};
|
|
assert.throws(
|
|
() => buildGeminiAuthPayload(conn),
|
|
(err: GeminiAuthFileError) => {
|
|
assert.equal(err.code, "reauth_required");
|
|
assert.equal(err.status, 409);
|
|
return true;
|
|
}
|
|
);
|
|
});
|
|
|
|
test("buildGeminiAuthPayload: throws id_token_missing when idToken absent", () => {
|
|
const conn: GeminiConnectionLike = {
|
|
accessToken: "fake-access-token-x",
|
|
refreshToken: "rt",
|
|
idToken: null,
|
|
providerSpecificData: {},
|
|
};
|
|
assert.throws(
|
|
() => buildGeminiAuthPayload(conn),
|
|
(err: GeminiAuthFileError) => {
|
|
assert.equal(err.code, "id_token_missing");
|
|
assert.equal(err.status, 409);
|
|
return true;
|
|
}
|
|
);
|
|
});
|
|
|
|
// ──── Tests: shouldRefreshGeminiConnection ────────────────────────────────────
|
|
|
|
test("shouldRefreshGeminiConnection: true when expiresAt < now+5min", () => {
|
|
const conn: GeminiConnectionLike = {
|
|
accessToken: "fake-access-token-x",
|
|
expiresAt: new Date(Date.now() + 2 * 60 * 1000).toISOString(),
|
|
};
|
|
assert.ok(shouldRefreshGeminiConnection(conn) === true);
|
|
});
|
|
|
|
test("shouldRefreshGeminiConnection: false when expiresAt > now+10min", () => {
|
|
const conn: GeminiConnectionLike = {
|
|
accessToken: "fake-access-token-x",
|
|
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
|
|
};
|
|
assert.ok(shouldRefreshGeminiConnection(conn) === false);
|
|
});
|
|
|
|
test("shouldRefreshGeminiConnection: true when accessToken absent", () => {
|
|
const conn: GeminiConnectionLike = {
|
|
accessToken: null,
|
|
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
|
|
};
|
|
assert.ok(shouldRefreshGeminiConnection(conn) === true);
|
|
});
|
|
|
|
// ──── Tests: extractGeminiEmail ───────────────────────────────────────────────
|
|
|
|
test("extractGeminiEmail: extracts email from id_token claim first", () => {
|
|
const idToken = buildJwt({ email: "jwt@google.com" });
|
|
const conn: GeminiConnectionLike = { idToken, email: "other@google.com" };
|
|
assert.equal(extractGeminiEmail(conn), "jwt@google.com");
|
|
});
|
|
|
|
test("extractGeminiEmail: falls back to connection.email", () => {
|
|
const conn: GeminiConnectionLike = { email: "conn@google.com" };
|
|
assert.equal(extractGeminiEmail(conn), "conn@google.com");
|
|
});
|
|
|
|
test("extractGeminiEmail: falls back to displayName if no email", () => {
|
|
const conn: GeminiConnectionLike = { displayName: "My Account" };
|
|
assert.equal(extractGeminiEmail(conn), "My Account");
|
|
});
|
|
|
|
// ──── Tests: filename ─────────────────────────────────────────────────────────
|
|
|
|
test("filename: gemini-auth-{email}.json when email available", () => {
|
|
const sanitized = sanitizeFileNamePart("user@google.com");
|
|
const filename = `gemini-auth-${sanitized}.json`;
|
|
assert.equal(filename, "gemini-auth-user@google.com.json");
|
|
});
|
|
|
|
test("filename: gemini-auth-{label}.json fallback when no email", () => {
|
|
const sanitized = sanitizeFileNamePart("Production Account");
|
|
const filename = `gemini-auth-${sanitized}.json`;
|
|
assert.equal(filename, "gemini-auth-production-account.json");
|
|
});
|
|
|
|
// ──── Tests: mergeGoogleAccountsFile ─────────────────────────────────────────
|
|
|
|
test("mergeGoogleAccountsFile: absent file creates fresh doc with active=newEmail", async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"));
|
|
const accountsPath = path.join(tmpDir, "google_accounts.json");
|
|
try {
|
|
const result = await mergeGoogleAccountsFile(accountsPath, "new@google.com");
|
|
assert.equal(result.updated, true);
|
|
assert.equal(result.savedBakPath, null);
|
|
const written = JSON.parse(await fs.readFile(accountsPath, "utf8")) as GoogleAccountsSidecar;
|
|
assert.equal(written.active, "new@google.com");
|
|
assert.deepEqual(written.old, []);
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("mergeGoogleAccountsFile: noop when active already equals newEmail", async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"));
|
|
const accountsPath = path.join(tmpDir, "google_accounts.json");
|
|
try {
|
|
await fs.writeFile(accountsPath, JSON.stringify({ active: "same@google.com", old: [] }) + "\n");
|
|
const result = await mergeGoogleAccountsFile(accountsPath, "same@google.com");
|
|
assert.equal(result.updated, false);
|
|
assert.equal(result.savedBakPath, null);
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("mergeGoogleAccountsFile: moves old active to old[] when active differs", async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"));
|
|
const accountsPath = path.join(tmpDir, "google_accounts.json");
|
|
try {
|
|
await fs.writeFile(
|
|
accountsPath,
|
|
JSON.stringify({ active: "old@google.com", old: ["ancient@google.com"] }) + "\n"
|
|
);
|
|
const result = await mergeGoogleAccountsFile(accountsPath, "new@google.com");
|
|
assert.equal(result.updated, true);
|
|
assert.ok(result.savedBakPath !== null, "should have created a .bak file");
|
|
const written = JSON.parse(await fs.readFile(accountsPath, "utf8")) as GoogleAccountsSidecar;
|
|
assert.equal(written.active, "new@google.com");
|
|
assert.ok(written.old.includes("old@google.com"));
|
|
assert.ok(written.old.includes("ancient@google.com"));
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("mergeGoogleAccountsFile: overwrites invalid JSON file, updated=true", async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-"));
|
|
const accountsPath = path.join(tmpDir, "google_accounts.json");
|
|
try {
|
|
await fs.writeFile(accountsPath, "not valid json");
|
|
const result = await mergeGoogleAccountsFile(accountsPath, "new@google.com");
|
|
assert.equal(result.updated, true);
|
|
const written = JSON.parse(await fs.readFile(accountsPath, "utf8")) as GoogleAccountsSidecar;
|
|
assert.equal(written.active, "new@google.com");
|
|
} finally {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ──── Tests: sanitizeFileNamePart ─────────────────────────────────────────────
|
|
|
|
test("sanitizeFileNamePart keeps @ and . for emails", () => {
|
|
assert.equal(sanitizeFileNamePart("user@google.com"), "user@google.com");
|
|
assert.equal(sanitizeFileNamePart("User.Name@Example.Org"), "user.name@example.org");
|
|
});
|
|
|
|
test("sanitizeFileNamePart strips filesystem-invalid chars", () => {
|
|
assert.equal(sanitizeFileNamePart("name with spaces"), "name-with-spaces");
|
|
assert.equal(sanitizeFileNamePart("a\\b:c*d?"), "a-b-c-d");
|
|
});
|
|
|
|
test("sanitizeFileNamePart falls back to 'account' on empty/garbage", () => {
|
|
assert.equal(sanitizeFileNamePart(""), "account");
|
|
assert.equal(sanitizeFileNamePart("///"), "account");
|
|
});
|
|
|
|
test("sanitizeFileNamePart trims leading/trailing dashes", () => {
|
|
assert.equal(sanitizeFileNamePart("--foo--"), "foo");
|
|
});
|
|
|
|
// ──── Tests: .bak timestamp format ───────────────────────────────────────────
|
|
|
|
test(".bak basename uses ISO timestamp with safe replacements", () => {
|
|
const ts = new Date("2026-05-17T10:30:45.123Z").toISOString().replace(/[:.]/g, "-");
|
|
const basename = `oauth_creds-${ts}.bak`;
|
|
assert.equal(basename, "oauth_creds-2026-05-17T10-30-45-123Z.bak");
|
|
assert.ok(!ts.includes(":"), "timestamp should not contain ':'");
|
|
assert.ok(!ts.includes("."), "timestamp should not contain '.'");
|
|
});
|