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
16 changes: 16 additions & 0 deletions src/lib/auth-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { registerProcessCleanup } from "./process-cleanup"
import { emitAuthChanged, registerAuthStatusProjector } from "./settings-events"
import { clearLastUpstreamRejection, state } from "./state"
import { setupCopilotToken, stopCopilotRefreshLoop } from "./token"
import { cacheModels } from "./utils"

// Auth events go to the console AND a dated `auth-*.log` so they're observable
// after the fact (sign-in, degrade, refresh failures, sign-out) instead of
Expand Down Expand Up @@ -509,6 +510,21 @@ async function runPoller(flow: ActiveFlow): Promise<void> {
// signOut() may have fired and wiped the token. Don't latch signed-in over
// a just-cleared session.
if (flow.abort.signal.aborted) return

// Prime the models cache so the catalog is populated the moment sign-in
// completes. The cold-boot path (bootstrap.ts) does this after
// setupCopilotToken; the device-flow path must too. The lazy
// stale-refresh middleware only REVALIDATES an already-primed cache
// (`loadedAtMs === null` → "not_primed", no-op), so on a fresh install —
// boot has no token, so boot never primes — without an explicit prime
// here the models list stays empty until a forced refresh. Best-effort:
// a models-fetch failure must not fail sign-in (mirrors setupCopilotToken).
try {
await cacheModels()
} catch (err) {
log.warn("Auth-controller: failed to cache models after sign-in:", err)
}

setAuthState({
kind: "signed-in",
login,
Expand Down
11 changes: 11 additions & 0 deletions tests/auth-controller-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const realGetDeviceCodeModule =
await import("~/services/github/get-device-code")
const realGetUserModule = await import("~/services/github/get-user")
const realTokenModule = await import("~/lib/token")
const realUtilsModule = await import("~/lib/utils")
const realFsPromisesModule = await import("node:fs/promises")

void mock.module("~/services/github/get-device-code", () => ({
Expand All @@ -84,6 +85,15 @@ void mock.module("~/lib/token", () => ({
stopCopilotRefreshLoop: () => {},
}))

// Sign-in now primes the models cache (cacheModels) after minting the
// Copilot token. Stub it so completing a device flow doesn't make a real
// Copilot /models fetch; spread the real namespace so the other utils
// exports survive.
void mock.module("~/lib/utils", () => ({
...realUtilsModule,
cacheModels: () => Promise.resolve(),
}))

// Spread the real namespace so `readFile` / `writeFile` / etc. survive
// the override — `tests/github-token-store.test.ts` reads/writes via
// the same module and gets undefined functions otherwise.
Expand All @@ -109,6 +119,7 @@ afterAll(() => {
)
void mock.module("~/services/github/get-user", () => realGetUserModule)
void mock.module("~/lib/token", () => realTokenModule)
void mock.module("~/lib/utils", () => realUtilsModule)
void mock.module("node:fs/promises", () => realFsPromisesModule)
})

Expand Down
50 changes: 50 additions & 0 deletions tests/auth-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const harness = {
addAccountCalls: [] as Array<AccountRecord>,
setupCopilotTokenImpl: (): Promise<void> => Promise.resolve(),
setupCopilotTokenCalls: 0,
cacheModelsImpl: (): Promise<void> => Promise.resolve(),
cacheModelsCalls: 0,
deactivateCalls: 0,
markNeedsReauthCalls: [] as Array<{
status: number | null
Expand All @@ -69,6 +71,7 @@ const realGetDeviceCodeModule =
await import("~/services/github/get-device-code")
const realGetUserModule = await import("~/services/github/get-user")
const realTokenModule = await import("~/lib/token")
const realUtilsModule = await import("~/lib/utils")
const realFsPromisesModule = await import("node:fs/promises")

void mock.module("~/services/github/get-device-code", () => ({
Expand All @@ -88,6 +91,17 @@ void mock.module("~/lib/token", () => ({
stopCopilotRefreshLoop: () => {},
}))

// Spread the real namespace so the many OTHER utils exports (getUUID,
// parseUserIdMetadata, sleep, …) survive; only count/stub cacheModels so
// the sign-in success path doesn't make a real Copilot /models fetch.
void mock.module("~/lib/utils", () => ({
...realUtilsModule,
cacheModels: () => {
harness.cacheModelsCalls++
return harness.cacheModelsImpl()
},
}))

// Spread the real namespace so `readFile` / `writeFile` / etc. survive
// the override — `tests/github-token-store.test.ts` reads/writes via
// the same module and gets undefined functions otherwise.
Expand All @@ -113,6 +127,7 @@ afterAll(() => {
)
void mock.module("~/services/github/get-user", () => realGetUserModule)
void mock.module("~/lib/token", () => realTokenModule)
void mock.module("~/lib/utils", () => realUtilsModule)
void mock.module("node:fs/promises", () => realFsPromisesModule)
})

Expand Down Expand Up @@ -172,6 +187,8 @@ beforeEach(() => {
harness.pollAccessTokenCalls = 0
harness.setupCopilotTokenImpl = () => Promise.resolve()
harness.setupCopilotTokenCalls = 0
harness.cacheModelsImpl = () => Promise.resolve()
harness.cacheModelsCalls = 0
})

afterEach(() => {
Expand Down Expand Up @@ -228,6 +245,39 @@ describe("getAuthStatus", () => {
}
})

test("primes the models cache after a successful device-flow sign-in", async () => {
// Regression: the cold-boot path (bootstrap) calls cacheModels() after
// minting the Copilot token, but the device-flow sign-in path did not.
// On a fresh install (boot has no token → boot never primes), the lazy
// stale-refresh middleware can't help (it no-ops on an unprimed cache),
// so the models list stayed empty until a forced refresh. Sign-in must
// prime it itself.
const poll = deferred<string>()
harness.pollAccessTokenImpl = () => poll.promise

await startDeviceFlow()
poll.resolve("ghu_ok")
await flushMicrotasks(10)

expect(getAuthStatus().state).toBe("authenticated")
expect(harness.cacheModelsCalls).toBe(1)
})

test("a models-cache failure does not fail sign-in (best-effort)", async () => {
// cacheModels is best-effort: a Copilot /models hiccup must not block the
// user from reaching the signed-in state.
harness.cacheModelsImpl = () => Promise.reject(new Error("models 503"))
const poll = deferred<string>()
harness.pollAccessTokenImpl = () => poll.promise

await startDeviceFlow()
poll.resolve("ghu_ok")
await flushMicrotasks(10)

expect(harness.cacheModelsCalls).toBe(1)
expect(getAuthStatus().state).toBe("authenticated")
})

test("returns { state: 'unauthenticated' } literal when no token and no flow", () => {
const status = getAuthStatus()
expect(status).toEqual({ state: "unauthenticated" })
Expand Down
9 changes: 9 additions & 0 deletions tests/auth-status-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const realGetDeviceCodeModule =
await import("~/services/github/get-device-code")
const realGetUserModule = await import("~/services/github/get-user")
const realTokenModule = await import("~/lib/token")
const realUtilsModule = await import("~/lib/utils")

void mock.module("~/services/github/get-device-code", () => ({
getDeviceCode: () =>
Expand All @@ -74,13 +75,21 @@ void mock.module("~/lib/token", () => ({
setupCopilotToken: () => Promise.resolve(),
}))

// Sign-in primes the models cache after minting the Copilot token; stub it
// so completing a device flow doesn't make a real Copilot /models fetch.
void mock.module("~/lib/utils", () => ({
...realUtilsModule,
cacheModels: () => Promise.resolve(),
}))

afterAll(() => {
void mock.module(
"~/services/github/get-device-code",
() => realGetDeviceCodeModule,
)
void mock.module("~/services/github/get-user", () => realGetUserModule)
void mock.module("~/lib/token", () => realTokenModule)
void mock.module("~/lib/utils", () => realUtilsModule)
})

const {
Expand Down
Loading