Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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