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
8 changes: 4 additions & 4 deletions apps/code/src/main/services/auth/port-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ import {

@injectable()
export class TokenCipherPortAdapter implements IAuthTokenCipher {
encrypt(plaintext: string): string {
return encrypt(plaintext);
encrypt(plaintext: string): Promise<string> {
return Promise.resolve(encrypt(plaintext));
}

decrypt(encrypted: string): string | null {
return decrypt(encrypted);
decrypt(encrypted: string): Promise<string | null> {
return Promise.resolve(decrypt(encrypted));
}
}

Expand Down
185 changes: 183 additions & 2 deletions packages/core/src/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ function createPreferencePort(): IAuthPreferenceStore {
}

const identityCipher: IAuthTokenCipher = {
encrypt: (plaintext) => plaintext,
decrypt: (encrypted) => encrypted,
encrypt: (plaintext) => Promise.resolve(plaintext),
decrypt: (encrypted) => Promise.resolve(encrypted),
};

const mockLogger: RootLogger = {
Expand Down Expand Up @@ -413,6 +413,52 @@ describe("AuthService", () => {
}
});

it("dedupes concurrent refreshes into a single token request", async () => {
oauthFlow.startFlow.mockResolvedValue(
mockTokenResponse({
accessToken: "initial-access-token",
refreshToken: "initial-refresh-token",
}),
);
stubAuthFetch();

// Keep the refresh in flight so both callers overlap; if the refresh
// isn't deduped, the rotating refresh token is spent twice.
let resolveRefresh!: (value: unknown) => void;
oauthFlow.refreshToken.mockReturnValue(
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);

await service.initialize();
await service.login("us");

// Two forced refreshes fired in the same tick. The dedup guard must close
// synchronously — resolving the stored session now awaits decryption, so
// if that await sat between the guard and the refreshPromise assignment,
// both callers would slip through and fire their own request.
oauthFlow.refreshToken.mockClear();
const both = Promise.all([
service.refreshAccessToken(),
service.refreshAccessToken(),
]);

await new Promise((r) => setTimeout(r, 0));
expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1);

resolveRefresh(
mockTokenResponse({
accessToken: "refreshed-access-token",
refreshToken: "refreshed-refresh-token",
}),
);
const [a, b] = await both;
expect(a.accessToken).toBe("refreshed-access-token");
expect(b.accessToken).toBe("refreshed-access-token");
expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1);
});

it("forces a token refresh when explicitly requested", async () => {
oauthFlow.startFlow.mockResolvedValue(
mockTokenResponse({
Expand Down Expand Up @@ -500,6 +546,141 @@ describe("AuthService", () => {
});
});

it("keeps the prior selection committed when persisting a new selection fails", async () => {
const orgs = {
"org-1": {
name: "Org 1",
projects: [
{ id: 42, name: "Project 42" },
{ id: 84, name: "Project 84" },
],
},
};
oauthFlow.startFlow.mockResolvedValue(
mockTokenResponse({
accessToken: "initial-access-token",
refreshToken: "initial-refresh-token",
}),
);
stubAuthFetch({ orgs });

// Encrypts fine during login, then rejects so the selectProject persist
// fails mid-flow (mirrors the browser cipher's Web Crypto rejecting).
let failEncrypt = false;
const flakyCipher: IAuthTokenCipher = {
encrypt: (plaintext) =>
failEncrypt
? Promise.reject(new Error("encryption unavailable"))
: Promise.resolve(plaintext),
decrypt: (encrypted) => Promise.resolve(encrypted),
};
service = new AuthService(
preferencePort,
sessionPort,
oauthFlow as unknown as IAuthOAuthFlowService,
connectivity,
flakyCipher,
mockPowerManager as unknown as IPowerManager,
mockLogger,
null,
);

// Initialize once while the session store is empty so login/selectProject
// don't later trigger the stored-session restore path.
service.init();
await service.initialize();

await service.login("us");
const priorProjectId = service.getState().currentProjectId;
expect(priorProjectId).toBe(42);

failEncrypt = true;
await expect(service.selectProject(84)).rejects.toThrow(
"encryption unavailable",
);

// A failed persist must not commit the new project anywhere: published
// state, the stored session, and the saved preference all stay on the
// prior selection (the preference is the tell — the buggy order saved it
// to 84 before the encrypt rejected).
expect(service.getState().currentProjectId).toBe(priorProjectId);
expect(sessionPort.getCurrent()?.selectedProjectId).toBe(priorProjectId);
expect(preferencePort.get("user-1", "us")?.lastSelectedProjectId).toBe(
priorProjectId,
);
});

it("keeps overlapping selections consistent so the latest one wins", async () => {
const orgs = {
"org-1": {
name: "Org 1",
projects: [
{ id: 42, name: "Project 42" },
{ id: 84, name: "Project 84" },
{ id: 99, name: "Project 99" },
],
},
};
oauthFlow.startFlow.mockResolvedValue(
mockTokenResponse({
accessToken: "initial-access-token",
refreshToken: "initial-refresh-token",
}),
);
stubAuthFetch({ orgs });

// Encryption is gated so overlapping selection commits stay in flight and
// can be resolved out of order below.
let deferEncrypt = false;
const pending: Array<() => void> = [];
const gatedCipher: IAuthTokenCipher = {
encrypt: (plaintext) =>
deferEncrypt
? new Promise<string>((resolve) => {
pending.push(() => resolve(plaintext));
})
: Promise.resolve(plaintext),
decrypt: (encrypted) => Promise.resolve(encrypted),
};
service = new AuthService(
preferencePort,
sessionPort,
oauthFlow as unknown as IAuthOAuthFlowService,
connectivity,
gatedCipher,
mockPowerManager as unknown as IPowerManager,
mockLogger,
null,
);
service.init();
await service.initialize();
await service.login("us");
expect(service.getState().currentProjectId).toBe(42);

deferEncrypt = true;
// Two overlapping selections: 84 first, then 99 (the latest intent).
const first = service.selectProject(84);
const second = service.selectProject(99);

// Drain pending encryptions newest-first each round to surface any
// out-of-order completion, until both commits settle. Serialized commits
// only expose one pending encryption at a time; unserialized ones would
// let the stale 84 commit land last.
const flush = () => new Promise((r) => setTimeout(r, 0));
await flush();
while (pending.length > 0) {
for (const resolve of pending.splice(0).reverse()) resolve();
await flush();
}
await Promise.all([first, second]);

// The latest selection wins everywhere — no stale overwrite and no split
// between the in-memory session (getState), stored session, and preference.
expect(service.getState().currentProjectId).toBe(99);
expect(sessionPort.getCurrent()?.selectedProjectId).toBe(99);
expect(preferencePort.get("user-1", "us")?.lastSelectedProjectId).toBe(99);
});

it("restores the selected project after app restart while logged out", async () => {
const orgs = {
"org-1": {
Expand Down
Loading
Loading