|
1 | 1 | /** |
2 | | - * Tests for buildDeviceFlowDisplay — the extracted display logic from the |
3 | | - * interactive login flow's onUserCode callback. |
| 2 | + * Tests for interactive login flow. |
| 3 | + * |
| 4 | + * - buildDeviceFlowDisplay: extracted display logic (pure function) |
| 5 | + * - runInteractiveLogin: full OAuth device flow with mocked dependencies |
4 | 6 | * |
5 | 7 | * Uses SENTRY_PLAIN_OUTPUT=1 to get predictable raw markdown output |
6 | 8 | * (no ANSI codes) for string assertions. |
7 | 9 | */ |
8 | 10 |
|
9 | | -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
10 | | -import { buildDeviceFlowDisplay } from "../../src/lib/interactive-login.js"; |
| 11 | +import { |
| 12 | + afterAll, |
| 13 | + afterEach, |
| 14 | + beforeAll, |
| 15 | + beforeEach, |
| 16 | + describe, |
| 17 | + expect, |
| 18 | + spyOn, |
| 19 | + test, |
| 20 | +} from "bun:test"; |
| 21 | +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking |
| 22 | +import * as browser from "../../src/lib/browser.js"; |
| 23 | +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking |
| 24 | +import * as clipboard from "../../src/lib/clipboard.js"; |
| 25 | +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking |
| 26 | +import * as dbInstance from "../../src/lib/db/index.js"; |
| 27 | +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking |
| 28 | +import * as dbUser from "../../src/lib/db/user.js"; |
| 29 | +import { |
| 30 | + buildDeviceFlowDisplay, |
| 31 | + runInteractiveLogin, |
| 32 | +} from "../../src/lib/interactive-login.js"; |
| 33 | +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking |
| 34 | +import * as oauth from "../../src/lib/oauth.js"; |
| 35 | +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking |
| 36 | +import * as qrcode from "../../src/lib/qrcode.js"; |
| 37 | +import type { TokenResponse } from "../../src/types/index.js"; |
11 | 38 |
|
12 | 39 | // Force plain output for predictable string matching |
13 | 40 | let origPlain: string | undefined; |
@@ -84,3 +111,134 @@ describe("buildDeviceFlowDisplay", () => { |
84 | 111 | expect(withoutBrowser.length).toBeGreaterThan(withBrowser.length); |
85 | 112 | }); |
86 | 113 | }); |
| 114 | + |
| 115 | +describe("runInteractiveLogin", () => { |
| 116 | + let performDeviceFlowSpy: ReturnType<typeof spyOn>; |
| 117 | + let completeOAuthFlowSpy: ReturnType<typeof spyOn>; |
| 118 | + let openBrowserSpy: ReturnType<typeof spyOn>; |
| 119 | + let generateQRCodeSpy: ReturnType<typeof spyOn>; |
| 120 | + let setupCopyKeyListenerSpy: ReturnType<typeof spyOn>; |
| 121 | + let setUserInfoSpy: ReturnType<typeof spyOn>; |
| 122 | + let getDbPathSpy: ReturnType<typeof spyOn>; |
| 123 | + |
| 124 | + /** Helper to build a mock TokenResponse with a user whose fields may be null. */ |
| 125 | + function makeTokenResponse(user?: { |
| 126 | + id: string; |
| 127 | + name: string | null; |
| 128 | + email: string | null; |
| 129 | + }): TokenResponse { |
| 130 | + return { |
| 131 | + access_token: "sntrys_test_token", |
| 132 | + token_type: "Bearer", |
| 133 | + expires_in: 3600, |
| 134 | + ...(user ? { user } : {}), |
| 135 | + }; |
| 136 | + } |
| 137 | + |
| 138 | + beforeEach(() => { |
| 139 | + completeOAuthFlowSpy = spyOn(oauth, "completeOAuthFlow").mockResolvedValue( |
| 140 | + undefined |
| 141 | + ); |
| 142 | + openBrowserSpy = spyOn(browser, "openBrowser").mockResolvedValue(false); |
| 143 | + generateQRCodeSpy = spyOn(qrcode, "generateQRCode").mockResolvedValue( |
| 144 | + "[QR]" |
| 145 | + ); |
| 146 | + setupCopyKeyListenerSpy = spyOn( |
| 147 | + clipboard, |
| 148 | + "setupCopyKeyListener" |
| 149 | + ).mockReturnValue(() => { |
| 150 | + // no-op cleanup |
| 151 | + }); |
| 152 | + setUserInfoSpy = spyOn(dbUser, "setUserInfo").mockReturnValue(undefined); |
| 153 | + getDbPathSpy = spyOn(dbInstance, "getDbPath").mockReturnValue("/tmp/db"); |
| 154 | + }); |
| 155 | + |
| 156 | + afterEach(() => { |
| 157 | + performDeviceFlowSpy.mockRestore(); |
| 158 | + completeOAuthFlowSpy.mockRestore(); |
| 159 | + openBrowserSpy.mockRestore(); |
| 160 | + generateQRCodeSpy.mockRestore(); |
| 161 | + setupCopyKeyListenerSpy.mockRestore(); |
| 162 | + setUserInfoSpy.mockRestore(); |
| 163 | + getDbPathSpy.mockRestore(); |
| 164 | + }); |
| 165 | + |
| 166 | + test("null user.name is converted to undefined in result and setUserInfo", async () => { |
| 167 | + performDeviceFlowSpy = spyOn(oauth, "performDeviceFlow").mockImplementation( |
| 168 | + async (callbacks) => { |
| 169 | + await callbacks.onUserCode( |
| 170 | + "ABCD", |
| 171 | + "https://sentry.io/auth/device/", |
| 172 | + "https://sentry.io/auth/device/?user_code=ABCD" |
| 173 | + ); |
| 174 | + return makeTokenResponse({ |
| 175 | + id: "48168", |
| 176 | + name: null, |
| 177 | + email: "user@example.com", |
| 178 | + }); |
| 179 | + } |
| 180 | + ); |
| 181 | + |
| 182 | + const result = await runInteractiveLogin({ timeout: 1000 }); |
| 183 | + |
| 184 | + expect(result).not.toBeNull(); |
| 185 | + expect(result!.user).toBeDefined(); |
| 186 | + expect(result!.user!.name).toBeUndefined(); |
| 187 | + expect(result!.user!.email).toBe("user@example.com"); |
| 188 | + expect(result!.user!.id).toBe("48168"); |
| 189 | + |
| 190 | + expect(setUserInfoSpy).toHaveBeenCalledWith({ |
| 191 | + userId: "48168", |
| 192 | + email: "user@example.com", |
| 193 | + name: undefined, |
| 194 | + }); |
| 195 | + }); |
| 196 | + |
| 197 | + test("null user.email is converted to undefined in result", async () => { |
| 198 | + performDeviceFlowSpy = spyOn(oauth, "performDeviceFlow").mockImplementation( |
| 199 | + async (callbacks) => { |
| 200 | + await callbacks.onUserCode( |
| 201 | + "EFGH", |
| 202 | + "https://sentry.io/auth/device/", |
| 203 | + "https://sentry.io/auth/device/?user_code=EFGH" |
| 204 | + ); |
| 205 | + return makeTokenResponse({ |
| 206 | + id: "123", |
| 207 | + name: "Jane Doe", |
| 208 | + email: null, |
| 209 | + }); |
| 210 | + } |
| 211 | + ); |
| 212 | + |
| 213 | + const result = await runInteractiveLogin({ timeout: 1000 }); |
| 214 | + |
| 215 | + expect(result).not.toBeNull(); |
| 216 | + expect(result!.user!.name).toBe("Jane Doe"); |
| 217 | + expect(result!.user!.email).toBeUndefined(); |
| 218 | + |
| 219 | + expect(setUserInfoSpy).toHaveBeenCalledWith({ |
| 220 | + userId: "123", |
| 221 | + email: undefined, |
| 222 | + name: "Jane Doe", |
| 223 | + }); |
| 224 | + }); |
| 225 | + |
| 226 | + test("no user in token response: result.user is undefined, setUserInfo not called", async () => { |
| 227 | + performDeviceFlowSpy = spyOn(oauth, "performDeviceFlow").mockImplementation( |
| 228 | + async (callbacks) => { |
| 229 | + await callbacks.onUserCode( |
| 230 | + "WXYZ", |
| 231 | + "https://sentry.io/auth/device/", |
| 232 | + "https://sentry.io/auth/device/?user_code=WXYZ" |
| 233 | + ); |
| 234 | + return makeTokenResponse(); // no user |
| 235 | + } |
| 236 | + ); |
| 237 | + |
| 238 | + const result = await runInteractiveLogin({ timeout: 1000 }); |
| 239 | + |
| 240 | + expect(result).not.toBeNull(); |
| 241 | + expect(result!.user).toBeUndefined(); |
| 242 | + expect(setUserInfoSpy).not.toHaveBeenCalled(); |
| 243 | + }); |
| 244 | +}); |
0 commit comments