Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions shared/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export const translations = {
dockerUpdateCmd: "Run to update:",
downloadUpdate: "Download",
electronUpdateHint: "Updates are handled automatically. Restart the app or check the system tray for update notifications.",
electronDownloadBtn: "Download Latest DMG",
electronDownloadBtn: "Download Latest Version",
dockerAutoUpdateHint: "Or enable Watchtower in docker-compose.yml for automatic updates",
updateTitle: "Update Available",
updateRestarting: "Restarting server...",
Expand Down Expand Up @@ -442,7 +442,7 @@ export const translations = {
dockerUpdateCmd: "\u6267\u884c\u66f4\u65b0\u547d\u4ee4\uff1a",
downloadUpdate: "\u4e0b\u8f7d",
electronUpdateHint: "\u66f4\u65b0\u7531\u684c\u9762\u7aef\u81ea\u52a8\u5904\u7406\u3002\u91cd\u542f\u5e94\u7528\u6216\u68c0\u67e5\u7cfb\u7edf\u6258\u76d8\u7684\u66f4\u65b0\u901a\u77e5\u3002",
electronDownloadBtn: "\u4e0b\u8f7d\u6700\u65b0 DMG",
electronDownloadBtn: "\u4e0b\u8f7d\u6700\u65b0\u7248\u672c",
dockerAutoUpdateHint: "\u6216\u5728 docker-compose.yml \u4e2d\u542f\u7528 Watchtower \u5b9e\u73b0\u81ea\u52a8\u66f4\u65b0",
updateTitle: "\u6709\u53ef\u7528\u66f4\u65b0",
updateRestarting: "\u6b63\u5728\u91cd\u542f\u670d\u52a1...",
Expand Down
225 changes: 225 additions & 0 deletions src/auth/__tests__/refresh-token-preservation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* E2E test: refresh token must never be lost during token refresh.
*
* Exercises the full RefreshScheduler → AccountPool → AccountRegistry → persistence
* pipeline with three server response variants:
* 1. Server returns a new refresh_token (normal)
* 2. Server returns refresh_token: null (edge case)
* 3. Server omits refresh_token entirely (edge case)
*
* In all cases the persisted RT must be non-null after refresh completes.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createMockConfig } from "@helpers/config.js";
import { setConfigForTesting, resetConfigForTesting } from "../../config.js";

// ── Controllable mock for refreshAccessToken ─────────────────────

type TokenResponse = {
access_token: string;
refresh_token?: string | null;
token_type: string;
};

let nextRefreshResponse: TokenResponse;

vi.mock("../oauth-pkce.js", () => ({
refreshAccessToken: vi.fn(async () => nextRefreshResponse),
}));

vi.mock("../jwt-utils.js", () => ({
decodeJwtPayload: (token: string) => {
try {
return JSON.parse(atob(token.split(".")[1]));
} catch {
return null;
}
},
extractChatGptAccountId: () => "acct-test",
extractUserProfile: () => ({
email: "test@test.com",
chatgpt_plan_type: "plus",
chatgpt_user_id: "uid-test",
}),
isTokenExpired: (token: string) => {
try {
const p = JSON.parse(atob(token.split(".")[1]));
return typeof p.exp === "number" && p.exp < Date.now() / 1000;
} catch {
return true;
}
},
}));

vi.mock("../../utils/jitter.js", () => ({
jitter: (val: number) => val,
jitterInt: (val: number) => val,
}));

vi.mock("../../paths.js", () => ({
getDataDir: () => "/tmp/test-rt-preservation",
getConfigDir: () => "/tmp/test-rt-preservation",
}));

vi.mock("fs", async () => {
const actual = await vi.importActual<typeof import("fs")>("fs");
return {
...actual,
readFileSync: vi.fn(() => { throw new Error("ENOENT"); }),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
existsSync: vi.fn(() => false),
mkdirSync: vi.fn(),
};
});

// ── Helpers ──────────────────────────────────────────────────────

function makeFreshJwt(expOffsetSec: number): string {
const header = btoa(JSON.stringify({ alg: "RS256" }));
const payload = btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + expOffsetSec }));
return `${header}.${payload}.sig`;
}

function makeExpiredJwt(): string {
return makeFreshJwt(-10);
}

// ── Tests ────────────────────────────────────────────────────────

describe("refresh token preservation", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
setConfigForTesting(createMockConfig({
auth: { refresh_enabled: true, refresh_margin_seconds: 300, refresh_concurrency: 5 },
}));
});

afterEach(() => {
vi.useRealTimers();
resetConfigForTesting();
});

it("preserves RT when server returns a new refresh_token", async () => {
const originalRT = "rt_original_permanent";
const newRT = "rt_rotated_new";
nextRefreshResponse = {
access_token: makeFreshJwt(3600),
refresh_token: newRT,
token_type: "Bearer",
};

const { AccountPool } = await import("../account-pool.js");
const pool = new AccountPool({
persistence: { load: () => ({ entries: [], needsPersist: false }), save: vi.fn() },
rotationStrategy: "least_used",
initialToken: null,
rateLimitBackoffSeconds: 60,
});

const entryId = pool.addAccount(makeExpiredJwt(), originalRT);

const { RefreshScheduler } = await import("../refresh-scheduler.js");
const scheduler = new RefreshScheduler(pool);

// Let refresh complete
await vi.advanceTimersByTimeAsync(35_000);

const entry = pool.getEntry(entryId);
expect(entry?.refreshToken).toBe(newRT);

scheduler.destroy();
pool.destroy();
});

it("preserves RT when server returns refresh_token: null", async () => {
const originalRT = "rt_must_survive_null";
nextRefreshResponse = {
access_token: makeFreshJwt(3600),
refresh_token: null as unknown as undefined,
token_type: "Bearer",
};

const { AccountPool } = await import("../account-pool.js");
const pool = new AccountPool({
persistence: { load: () => ({ entries: [], needsPersist: false }), save: vi.fn() },
rotationStrategy: "least_used",
initialToken: null,
rateLimitBackoffSeconds: 60,
});

const entryId = pool.addAccount(makeExpiredJwt(), originalRT);

const { RefreshScheduler } = await import("../refresh-scheduler.js");
const scheduler = new RefreshScheduler(pool);

await vi.advanceTimersByTimeAsync(35_000);

const entry = pool.getEntry(entryId);
expect(entry?.refreshToken).toBe(originalRT);

scheduler.destroy();
pool.destroy();
});

it("preserves RT when server omits refresh_token field entirely", async () => {
const originalRT = "rt_must_survive_undefined";
nextRefreshResponse = {
access_token: makeFreshJwt(3600),
// no refresh_token field at all
token_type: "Bearer",
};

const { AccountPool } = await import("../account-pool.js");
const pool = new AccountPool({
persistence: { load: () => ({ entries: [], needsPersist: false }), save: vi.fn() },
rotationStrategy: "least_used",
initialToken: null,
rateLimitBackoffSeconds: 60,
});

const entryId = pool.addAccount(makeExpiredJwt(), originalRT);

const { RefreshScheduler } = await import("../refresh-scheduler.js");
const scheduler = new RefreshScheduler(pool);

await vi.advanceTimersByTimeAsync(35_000);

const entry = pool.getEntry(entryId);
expect(entry?.refreshToken).toBe(originalRT);

scheduler.destroy();
pool.destroy();
});

it("preserves oaistb_rt_ when server returns no new RT", async () => {
const originalRT = "oaistb_rt_one_time_token";
nextRefreshResponse = {
access_token: makeFreshJwt(3600),
token_type: "Bearer",
// No refresh_token — previously this would set RT to null
};

const { AccountPool } = await import("../account-pool.js");
const pool = new AccountPool({
persistence: { load: () => ({ entries: [], needsPersist: false }), save: vi.fn() },
rotationStrategy: "least_used",
initialToken: null,
rateLimitBackoffSeconds: 60,
});

const entryId = pool.addAccount(makeExpiredJwt(), originalRT);

const { RefreshScheduler } = await import("../refresh-scheduler.js");
const scheduler = new RefreshScheduler(pool);

await vi.advanceTimersByTimeAsync(35_000);

const entry = pool.getEntry(entryId);
expect(entry?.refreshToken).toBe(originalRT);

scheduler.destroy();
pool.destroy();
});
});
2 changes: 1 addition & 1 deletion src/auth/account-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ function loadPersisted(): { entries: AccountEntry[]; needsPersist: boolean } {
needsPersist = true;
}
// Backfill window_reset_at (missing causes NaN in refreshStatus)
if ((entry.usage as Record<string, unknown>).window_reset_at === undefined) {
if (!("window_reset_at" in entry.usage)) {
entry.usage.window_reset_at = null;
needsPersist = true;
}
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/UpdateModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export function UpdateModal({
</span>
</div>
) : (
// electron mode: no code signing on macOS — direct user to download DMG
// electron mode: link to GitHub release page for manual download
release?.url ? (
<a
href={release.url}
Expand Down
Loading