diff --git a/src/auth.ts b/src/auth.ts index 2670e5ed..66503b6a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -58,6 +58,8 @@ type Login = { pending?: PendingLogin; /** Set on Ctrl-C, so its cleanup doesn't read as a logout. */ cancelled?: boolean; + /** Set once the login is saved, so a later Ctrl-C doesn't call it cancelled. */ + saved?: SavedLogin; }; type SavedLogin = Pick & { token: string }; @@ -214,9 +216,18 @@ export async function logIn(options: { browser?: boolean } = {}): Promise { login.cancelled = true; console.error(); + // Waits for a save in progress, which holds the lock. if (login.pending) await setPendingLogin(login.pending, undefined); - recordLogin({ result: "cancelled", resumed: login.resumed, startedAt: login.startedAt }); - await endCommand("Login cancelled.", 130); + const { resumed, startedAt, saved } = login; + const result = saved ? "success" : "cancelled"; + recordLogin({ + result, + resumed, + startedAt, + store: saved?.store, + storeReason: saved?.storeReason, + }); + await endCommand(saved ? "Logged in to Prismic." : "Login cancelled.", 130); }); let outcome: LoginOutcome; @@ -464,13 +475,14 @@ async function saveLogin( : await prepareLogin(lock); const session = { host: login.host, ...login.auth0, ...tokens }; await writeCredentials(lock, { ...store, session }); + login.saved = { ...store, token: tokens.accessToken }; // The new login is saved first, so a failed revoke only leaves the // previous session to expire. const previous = stored?.login?.session; if (previous?.refreshToken) { await revokeRefreshToken(previous.refreshToken, previous).catch(() => {}); } - return { ...store, token: tokens.accessToken }; + return login.saved; }); } diff --git a/test/auth0.ts b/test/auth0.ts index 05a2082c..865ea25f 100644 --- a/test/auth0.ts +++ b/test/auth0.ts @@ -1,13 +1,14 @@ import { randomBytes } from "node:crypto"; import { createServer, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; +import { setTimeout as sleep } from "node:timers/promises"; // A fake Auth0 tenant for login tests. A real device login needs a person to // approve it in a browser, so automated tests can't use Auth0 itself. const INTERVAL = 0.1; -type FakeAuth0Response = { status: number; body?: unknown }; +type FakeAuth0Response = { status: number; body?: unknown; delayMs?: number }; export type FakeAuth0 = { auth0Domain: string; @@ -48,6 +49,7 @@ export async function createFakeAuth0(options: { accessToken: string }): Promise const response = responses.get(path)?.shift(); if (response) { + await sleep(response.delayMs ?? 0); send(res, response.status, response.body); return; } diff --git a/test/login.test.ts b/test/login.test.ts index e7c357b5..34da8bc8 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -88,6 +88,23 @@ it.skipIf(process.platform === "win32")( }, ); +it.skipIf(process.platform === "win32")( + "reports a login that saved before Ctrl-C as saved", + async ({ expect, prismic, login, auth0 }) => { + // The new login revokes this session while it still holds the lock after saving. + await login({ refreshToken: auth0.issueRefreshToken() }); + auth0.respondOnce("/oauth/revoke", { status: 200, delayMs: 2000 }); + const proc = prismic("login"); + const output = captureOutput(proc); + await approveLogin(expect, output, auth0); + await expect.poll(() => auth0.requestsTo("/oauth/revoke")).toHaveLength(1); + proc.kill("SIGINT"); + expect((await proc).exitCode).toBe(130); + expect(output()).toContain("Logged in to Prismic."); + expect(output()).not.toContain("Login cancelled."); + }, +); + it("makes another command wait for the login in progress", async ({ expect, prismic,