@knpkv/atlassian-common 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +31 -0
  3. package/dist/cli-auth/AtlassianCliAuth.d.ts +110 -0
  4. package/dist/cli-auth/AtlassianCliAuth.d.ts.map +1 -0
  5. package/dist/cli-auth/AtlassianCliAuth.js +298 -0
  6. package/dist/cli-auth/AtlassianCliAuth.js.map +1 -0
  7. package/dist/cli-auth/authCommand.d.ts +34 -0
  8. package/dist/cli-auth/authCommand.d.ts.map +1 -0
  9. package/dist/cli-auth/authCommand.js +127 -0
  10. package/dist/cli-auth/authCommand.js.map +1 -0
  11. package/dist/cli-auth/index.d.ts +14 -0
  12. package/dist/cli-auth/index.d.ts.map +1 -0
  13. package/dist/cli-auth/index.js +14 -0
  14. package/dist/cli-auth/index.js.map +1 -0
  15. package/dist/cli-auth/internal/NodeLayers.d.ts +5 -0
  16. package/dist/cli-auth/internal/NodeLayers.d.ts.map +1 -0
  17. package/dist/cli-auth/internal/NodeLayers.js +17 -0
  18. package/dist/cli-auth/internal/NodeLayers.js.map +1 -0
  19. package/dist/cli-auth/internal/oauthServer.d.ts +54 -0
  20. package/dist/cli-auth/internal/oauthServer.d.ts.map +1 -0
  21. package/dist/cli-auth/internal/oauthServer.js +94 -0
  22. package/dist/cli-auth/internal/oauthServer.js.map +1 -0
  23. package/dist/cli-auth/openBrowser.d.ts +16 -0
  24. package/dist/cli-auth/openBrowser.d.ts.map +1 -0
  25. package/dist/cli-auth/openBrowser.js +30 -0
  26. package/dist/cli-auth/openBrowser.js.map +1 -0
  27. package/package.json +5 -1
  28. package/src/cli-auth/AtlassianCliAuth.ts +539 -0
  29. package/src/cli-auth/authCommand.ts +227 -0
  30. package/src/cli-auth/index.ts +20 -0
  31. package/src/cli-auth/internal/NodeLayers.ts +25 -0
  32. package/src/cli-auth/internal/oauthServer.ts +168 -0
  33. package/src/cli-auth/openBrowser.ts +47 -0
  34. package/test/ApiUpdateWorkflows.test.ts +78 -0
  35. package/test/cliAuth.authCommand.test.ts +153 -0
  36. package/test/cliAuth.guardrails.test.ts +76 -0
  37. package/test/cliAuth.login.test.ts +319 -0
  38. package/test/cliAuth.oauthServer.test.ts +276 -0
  39. package/test/cliAuth.openBrowser.test.ts +70 -0
  40. package/test/cliAuth.refresh.test.ts +304 -0
@@ -0,0 +1,276 @@
1
+ /** @effect-diagnostics strictEffectProvide:skip-file */
2
+ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
3
+ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
4
+ import { describe, expect, it } from "@effect/vitest"
5
+ import * as Cause from "effect/Cause"
6
+ import * as Effect from "effect/Effect"
7
+ import * as Fiber from "effect/Fiber"
8
+ import { HttpClient, HttpClientRequest, HttpServer, HttpServerError } from "effect/http"
9
+ import * as Layer from "effect/Layer"
10
+ import { NetAddress } from "effect/net"
11
+ import * as Predicate from "effect/Predicate"
12
+ import * as Ref from "effect/Ref"
13
+ import { createServer } from "node:http"
14
+ import { HttpServerFactoryLive } from "../src/cli-auth/internal/NodeLayers.js"
15
+ import {
16
+ callbackServerListenOptions,
17
+ callbackUrl,
18
+ HttpServerFactoryTag,
19
+ makeHttpServerFactory,
20
+ startCallbackServer
21
+ } from "../src/cli-auth/internal/oauthServer.js"
22
+
23
+ const HttpClientLive = NodeHttpClient.layerUndici
24
+ const EphemeralHttpServerFactoryLive = makeHttpServerFactory(
25
+ () => NodeHttpServer.layerServer(createServer, { port: 0 })
26
+ )
27
+ const fakeHttpServer = (port: number): HttpServer.HttpServer["Service"] =>
28
+ HttpServer.make({
29
+ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", port),
30
+ serve: () => Effect.void
31
+ })
32
+
33
+ describe("oauthServer", () => {
34
+ describe("startCallbackServer", () => {
35
+ it.effect("binds the same localhost name advertised by the callback URL", () =>
36
+ Effect.scoped(
37
+ Effect.gen(function*() {
38
+ const listenOptions = callbackServerListenOptions(0)
39
+ const factory = yield* HttpServerFactoryTag
40
+ const server = yield* HttpServer.HttpServer.pipe(
41
+ Effect.provide(factory.createServerLayer(listenOptions))
42
+ )
43
+ const advertisedUrl = new URL(callbackUrl(8585))
44
+ const ipv6OnlyLocalhost = (hostname: string) => (hostname === "localhost" ? "::1" : undefined)
45
+ expect(NetAddress.isInetAddress(server.address)).toBe(true)
46
+ expect(listenOptions.host).toBe(advertisedUrl.hostname)
47
+ expect(ipv6OnlyLocalhost(listenOptions.host)).toBe("::1")
48
+ expect(ipv6OnlyLocalhost(advertisedUrl.hostname)).toBe("::1")
49
+ }).pipe(Effect.provide(HttpServerFactoryLive))
50
+ ))
51
+
52
+ it.effect("starts server and returns port", () =>
53
+ Effect.scoped(
54
+ Effect.gen(function*() {
55
+ const expectedState = "test-state-123"
56
+ const result = yield* startCallbackServer(expectedState)
57
+
58
+ expect(result.port).toBeGreaterThan(0)
59
+ expect(Predicate.isObject(result.codePromise)).toBe(true)
60
+ }).pipe(Effect.provide(EphemeralHttpServerFactoryLive))
61
+ ))
62
+
63
+ it.effect("handles successful callback with code", () =>
64
+ Effect.scoped(
65
+ Effect.gen(function*() {
66
+ const expectedState = "test-state-456"
67
+ const { codePromise, port } = yield* startCallbackServer(expectedState)
68
+
69
+ // Make callback request in background
70
+ const codeReceiver = yield* Effect.forkChild(codePromise)
71
+
72
+ // Simulate OAuth callback using Effect HttpClient
73
+ const client = yield* HttpClient.HttpClient
74
+ const request = HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
75
+ HttpClientRequest.setUrlParam("code", "auth_code_123"),
76
+ HttpClientRequest.setUrlParam("state", expectedState)
77
+ )
78
+ yield* client.execute(request)
79
+
80
+ const code = yield* Fiber.join(codeReceiver)
81
+ expect(code).toBe("auth_code_123")
82
+ }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
83
+ ))
84
+
85
+ it.effect("releases the callback port when the owning workflow fails", () =>
86
+ Effect.gen(function*() {
87
+ yield* Effect.scoped(
88
+ startCallbackServer("first").pipe(
89
+ Effect.provide(EphemeralHttpServerFactoryLive),
90
+ Effect.andThen(Effect.fail("browser-open-failed")),
91
+ Effect.flip
92
+ )
93
+ )
94
+
95
+ const port = yield* Effect.scoped(
96
+ startCallbackServer("second").pipe(
97
+ Effect.provide(EphemeralHttpServerFactoryLive),
98
+ Effect.map((server) => server.port)
99
+ )
100
+ )
101
+ expect(port).toBeGreaterThan(0)
102
+ }))
103
+
104
+ it.effect("starts concurrent real callback servers on distinct OS-assigned ports", () =>
105
+ Effect.scoped(
106
+ Effect.gen(function*() {
107
+ const [first, second] = yield* Effect.all(
108
+ [startCallbackServer("first"), startCallbackServer("second")],
109
+ { concurrency: "unbounded" }
110
+ )
111
+ expect(first.port).toBeGreaterThan(0)
112
+ expect(second.port).toBeGreaterThan(0)
113
+ expect(first.port).not.toBe(second.port)
114
+ }).pipe(Effect.provide(EphemeralHttpServerFactoryLive))
115
+ ))
116
+
117
+ it.effect("retries occupied fixed-range ports with the injected factory", () => {
118
+ const attempts = Ref.makeUnsafe<ReadonlyArray<number>>([])
119
+ const occupiedFactory = makeHttpServerFactory(({ port }) =>
120
+ Layer.effect(
121
+ HttpServer.HttpServer,
122
+ Ref.update(attempts, (ports) => [...ports, port]).pipe(
123
+ Effect.andThen(
124
+ port < 8587
125
+ ? Effect.fail(new HttpServerError.ServeError({ cause: { code: "EADDRINUSE" } }))
126
+ : Effect.succeed(fakeHttpServer(port))
127
+ )
128
+ )
129
+ )
130
+ )
131
+
132
+ return Effect.scoped(
133
+ Effect.gen(function*() {
134
+ const server = yield* startCallbackServer("state")
135
+ expect(server.port).toBe(8587)
136
+ expect(yield* Ref.get(attempts)).toEqual([8585, 8586, 8587])
137
+ }).pipe(Effect.provide(occupiedFactory))
138
+ )
139
+ })
140
+
141
+ it.effect("maps a multiply retried server failure to one OAuth error", () => {
142
+ const attempts = Ref.makeUnsafe<ReadonlyArray<number>>([])
143
+ const terminalFailure = new HttpServerError.ServeError({ cause: { code: "EACCES" } })
144
+ const failingFactory = makeHttpServerFactory(({ port }) =>
145
+ Layer.effect(
146
+ HttpServer.HttpServer,
147
+ Ref.update(attempts, (ports) => [...ports, port]).pipe(
148
+ Effect.andThen(
149
+ port < 8587
150
+ ? Effect.fail(new HttpServerError.ServeError({ cause: { code: "EADDRINUSE" } }))
151
+ : Effect.fail(terminalFailure)
152
+ )
153
+ )
154
+ )
155
+ )
156
+
157
+ return Effect.scoped(
158
+ Effect.gen(function*() {
159
+ const error = yield* startCallbackServer("state").pipe(Effect.flip)
160
+ expect(error.cause).toBe(terminalFailure)
161
+ expect(error.message.split("OAuth authorize failed:").length - 1).toBe(1)
162
+ expect(yield* Ref.get(attempts)).toEqual([8585, 8586, 8587])
163
+ }).pipe(Effect.provide(failingFactory))
164
+ )
165
+ })
166
+
167
+ it.effect("does not retry a non-address-in-use server failure", () => {
168
+ const attempts = Ref.makeUnsafe(0)
169
+ const failingFactory = makeHttpServerFactory(() =>
170
+ Layer.effect(
171
+ HttpServer.HttpServer,
172
+ Ref.update(attempts, (count) => count + 1).pipe(
173
+ Effect.andThen(Effect.fail(
174
+ new HttpServerError.ServeError({
175
+ cause: { code: "ECONNREFUSED" }
176
+ })
177
+ ))
178
+ )
179
+ )
180
+ )
181
+
182
+ return Effect.scoped(
183
+ startCallbackServer("state").pipe(
184
+ Effect.provide(failingFactory),
185
+ Effect.flip,
186
+ Effect.tap(() => Ref.get(attempts).pipe(Effect.map((count) => expect(count).toBe(1))))
187
+ )
188
+ )
189
+ })
190
+
191
+ it.effect("ignores a forged error callback before accepting the legitimate state", () =>
192
+ Effect.scoped(
193
+ Effect.gen(function*() {
194
+ const expectedState = "expected-state"
195
+ const { codePromise, port } = yield* startCallbackServer(expectedState)
196
+ const codeReceiver = yield* Effect.forkChild(codePromise)
197
+ const client = yield* HttpClient.HttpClient
198
+
199
+ const forgedResponse = yield* client.execute(
200
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
201
+ HttpClientRequest.setUrlParam("error", "access_denied")
202
+ )
203
+ )
204
+ expect(forgedResponse.status).toBe(403)
205
+ yield* client.execute(
206
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
207
+ HttpClientRequest.setUrlParam("code", "legitimate-code"),
208
+ HttpClientRequest.setUrlParam("state", expectedState)
209
+ )
210
+ )
211
+
212
+ expect(yield* Fiber.join(codeReceiver)).toBe("legitimate-code")
213
+ }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
214
+ ))
215
+
216
+ it.effect("accepts a provider error only after validating state", () =>
217
+ Effect.scoped(
218
+ Effect.gen(function*() {
219
+ const expectedState = "expected-state"
220
+ const { codePromise, port } = yield* startCallbackServer(expectedState)
221
+ const codeReceiver = yield* Effect.forkChild(codePromise)
222
+ const client = yield* HttpClient.HttpClient
223
+
224
+ yield* client.execute(
225
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
226
+ HttpClientRequest.setUrlParam("error", "access_denied"),
227
+ HttpClientRequest.setUrlParam("state", expectedState)
228
+ )
229
+ )
230
+
231
+ const error = yield* Fiber.join(codeReceiver).pipe(Effect.flip)
232
+ expect(String(error.cause)).toContain("access_denied")
233
+ expect(error.message).toBe("OAuth authorize failed: access_denied")
234
+ }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
235
+ ))
236
+ it.effect("reports the provider error code when its description is empty", () =>
237
+ Effect.scoped(
238
+ Effect.gen(function*() {
239
+ const expectedState = "expected-state"
240
+ const { codePromise, port } = yield* startCallbackServer(expectedState)
241
+ const codeReceiver = yield* Effect.forkChild(codePromise)
242
+ const client = yield* HttpClient.HttpClient
243
+
244
+ yield* client.execute(
245
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
246
+ HttpClientRequest.setUrlParam("error", "access_denied"),
247
+ HttpClientRequest.setUrlParam("error_description", ""),
248
+ HttpClientRequest.setUrlParam("state", expectedState)
249
+ )
250
+ )
251
+
252
+ const error = yield* Fiber.join(codeReceiver).pipe(Effect.flip)
253
+ expect(error.cause).toBe("access_denied")
254
+ }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
255
+ ))
256
+ it.effect("fails promptly when the handler cannot be installed", () =>
257
+ Effect.scoped(
258
+ Effect.gen(function*() {
259
+ const exit = yield* startCallbackServer("state").pipe(
260
+ Effect.provide(makeHttpServerFactory(() =>
261
+ Layer.succeed(
262
+ HttpServer.HttpServer,
263
+ HttpServer.make({
264
+ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 8585),
265
+ serve: () => Effect.die("handler-install-failed")
266
+ })
267
+ )
268
+ )),
269
+ Effect.exit
270
+ )
271
+ expect(exit._tag).toBe("Failure")
272
+ expect(String(exit._tag === "Failure" ? Cause.squash(exit.cause) : "")).toContain("handler-install-failed")
273
+ })
274
+ ))
275
+ })
276
+ })
@@ -0,0 +1,70 @@
1
+ /** @effect-diagnostics strictEffectProvide:skip-file */
2
+ /**
3
+ * The launcher order and when it stops. A launcher that is missing or exits
4
+ * non-zero falls through to the next; when none succeeds the failure is typed so
5
+ * the caller decides whether that is fatal.
6
+ */
7
+ import { describe, expect, it } from "@effect/vitest"
8
+ import * as Effect from "effect/Effect"
9
+ import * as Layer from "effect/Layer"
10
+ import * as PlatformError from "effect/PlatformError"
11
+ import { ChildProcessSpawner } from "effect/process"
12
+ import * as Sink from "effect/Sink"
13
+ import * as Stream from "effect/Stream"
14
+ import { openBrowser } from "../src/cli-auth/index.js"
15
+
16
+ // `outcomes` maps a launcher to its exit code; a launcher not listed is missing.
17
+ const launchers = (outcomes: Readonly<Record<string, number>>, attempts: Array<string>) =>
18
+ Layer.succeed(
19
+ ChildProcessSpawner.ChildProcessSpawner,
20
+ ChildProcessSpawner.make((command) => {
21
+ const name = command._tag === "StandardCommand" ? command.command : "piped"
22
+ attempts.push(name)
23
+ const exitCode = outcomes[name]
24
+ if (exitCode === undefined) {
25
+ return Effect.fail(PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn" }))
26
+ }
27
+ return Effect.succeed(ChildProcessSpawner.makeHandle({
28
+ all: Stream.empty,
29
+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
30
+ getInputFd: () => Sink.drain,
31
+ getOutputFd: () => Stream.empty,
32
+ isRunning: Effect.succeed(false),
33
+ kill: () => Effect.void,
34
+ pid: ChildProcessSpawner.ProcessId(1),
35
+ reref: Effect.void,
36
+ stderr: Stream.empty,
37
+ stdin: Sink.drain,
38
+ stdout: Stream.empty,
39
+ unref: Effect.succeed(Effect.void)
40
+ }))
41
+ })
42
+ )
43
+
44
+ describe("openBrowser", () => {
45
+ it.effect("stops at the first launcher that exits 0", () =>
46
+ Effect.gen(function*() {
47
+ const attempts: Array<string> = []
48
+ yield* openBrowser("https://example.test").pipe(Effect.provide(launchers({ "xdg-open": 0 }, attempts)))
49
+ expect(attempts).toEqual(["open", "xdg-open"])
50
+ }))
51
+
52
+ it.effect("falls through a launcher that exits non-zero", () =>
53
+ Effect.gen(function*() {
54
+ const attempts: Array<string> = []
55
+ yield* openBrowser("https://example.test").pipe(
56
+ Effect.provide(launchers({ open: 1, "xdg-open": 3, "rundll32.exe": 0 }, attempts))
57
+ )
58
+ expect(attempts).toEqual(["open", "xdg-open", "rundll32.exe"])
59
+ }))
60
+
61
+ it.effect("fails with the last launcher's exit when none succeeds", () =>
62
+ Effect.gen(function*() {
63
+ const attempts: Array<string> = []
64
+ const error = yield* openBrowser("https://example.test").pipe(
65
+ Effect.provide(launchers({ open: 1, "xdg-open": 4, "rundll32.exe": 2 }, attempts)),
66
+ Effect.flip
67
+ )
68
+ expect(error).toMatchObject({ _tag: "BrowserOpenError", command: "rundll32.exe", exitCode: 2 })
69
+ }))
70
+ })
@@ -0,0 +1,304 @@
1
+ /** @effect-diagnostics strictEffectProvide:skip-file */
2
+ /**
3
+ * What a failed token refresh is allowed to do to the stored credential.
4
+ *
5
+ * Atlassian rotates refresh tokens, so a refresh that does not complete leaves
6
+ * the client unable to tell whether the grant landed. Deleting the profile in
7
+ * that state is unrecoverable and unattended — `JiraApiConfigLive` builds on
8
+ * every CLI invocation, and jcf's statusline runs one every 30 seconds — so a
9
+ * bad network window could silently log the user out with nobody watching.
10
+ *
11
+ * The rule: discard the stored token only when Atlassian actually rejected it
12
+ * (a 4xx). Anything else — timeout, transport failure — keeps the credential so
13
+ * the next run can retry.
14
+ */
15
+ import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"
16
+ import { describe, expect, it } from "@effect/vitest"
17
+ import { ConfigProvider } from "effect"
18
+ import * as Data from "effect/Data"
19
+ import * as Deferred from "effect/Deferred"
20
+ import * as Effect from "effect/Effect"
21
+ import * as Fiber from "effect/Fiber"
22
+ import * as FileSystem from "effect/FileSystem"
23
+ import { HttpClient, HttpClientResponse } from "effect/http"
24
+ import * as Layer from "effect/Layer"
25
+ import { TestClock } from "effect/testing"
26
+ import { makeAtlassianCliAuth, NodeCliAuthLive } from "../src/cli-auth/index.js"
27
+ import { loadActiveProfileToken, saveProfileToken } from "../src/config/AuthProfiles.js"
28
+ import { HomeDirectoryLive } from "../src/config/ConfigPaths.js"
29
+ import { saveOAuthConfig } from "../src/config/TokenStorage.js"
30
+
31
+ const TOOL = "test-cli"
32
+
33
+ class TestAuthMissing extends Data.TaggedError("TestAuthMissing") {}
34
+
35
+ const authOptions = {
36
+ toolName: TOOL,
37
+ commandName: "test",
38
+ productName: "Test",
39
+ scopes: ["offline_access"],
40
+ authMissing: () => new TestAuthMissing()
41
+ }
42
+
43
+ const expiredToken = {
44
+ access_token: "expired-access",
45
+ refresh_token: "rotating-refresh",
46
+ expires_at: 0,
47
+ token_type: "Bearer",
48
+ scope: "offline_access",
49
+ cloud_id: "cloud-1",
50
+ site_url: "https://example.atlassian.net"
51
+ }
52
+
53
+ const storage = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, HomeDirectoryLive)
54
+
55
+ // The auth also needs a spawner (browser launch during login) and Crypto (PKCE).
56
+ // Without them construction dies as a defect, which no `catchTag` would see — and
57
+ // the assertions below would then pass without the refresh ever running.
58
+ const getAccessTokenWith = (client: Layer.Layer<HttpClient.HttpClient>) =>
59
+ makeAtlassianCliAuth(authOptions).pipe(
60
+ Effect.flatMap((auth) => auth.getAccessToken()),
61
+ Effect.provide(Layer.mergeAll(client, NodeServices.layer, NodeCliAuthLive))
62
+ )
63
+
64
+ // A client that never answers — the stall case, driven by TestClock. It reports
65
+ // when the request has actually been issued so the test can advance the clock at
66
+ // a point where the refresh deadline is registered; adjusting before that would
67
+ // move past the deadline before it exists and hang.
68
+ const stalledClient = (issued: Deferred.Deferred<void>) =>
69
+ Layer.succeed(
70
+ HttpClient.HttpClient,
71
+ HttpClient.make(() => Effect.flatMap(Deferred.succeed(issued, undefined), () => Effect.never))
72
+ )
73
+
74
+ const respondingClient = (status: number, body: string) =>
75
+ Layer.succeed(
76
+ HttpClient.HttpClient,
77
+ HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, { status }))))
78
+ )
79
+
80
+ // A client that answers with the one thing that does prove the token is dead.
81
+ const rejectingClient = respondingClient(400, JSON.stringify({ error: "invalid_grant" }))
82
+
83
+ // A rotation that only completes once the test says so, so interruption can be
84
+ // aimed at the exact window between the grant and the token being persisted.
85
+ const gatedClient = (issued: Deferred.Deferred<void>, release: Deferred.Deferred<void>) =>
86
+ Layer.succeed(
87
+ HttpClient.HttpClient,
88
+ HttpClient.make((request) =>
89
+ Effect.gen(function*() {
90
+ yield* Deferred.succeed(issued, undefined)
91
+ yield* Deferred.await(release)
92
+ return HttpClientResponse.fromWeb(
93
+ request,
94
+ new Response(
95
+ JSON.stringify({
96
+ access_token: "fresh-access",
97
+ refresh_token: "rotated-refresh",
98
+ expires_in: 3600,
99
+ scope: "offline_access",
100
+ token_type: "Bearer"
101
+ }),
102
+ { status: 200, headers: { "content-type": "application/json" } }
103
+ )
104
+ )
105
+ })
106
+ )
107
+ )
108
+
109
+ // `HomeDirectoryLive` resolves HOME through the ambient ConfigProvider, so the
110
+ // test home can be injected rather than set on `process.env` — a global that
111
+ // parallel tests would race each other to overwrite and restore. The directory
112
+ // is scoped, so it is removed when the effect finishes either way.
113
+ const withHome = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
114
+ Effect.gen(function*() {
115
+ const fs = yield* FileSystem.FileSystem
116
+ const home = yield* fs.makeTempDirectoryScoped({ prefix: "atlassian-cli-auth-" })
117
+ return yield* effect.pipe(
118
+ Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })))
119
+ )
120
+ }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer))
121
+
122
+ const storedToken = loadActiveProfileToken(TOOL).pipe(Effect.provide(storage))
123
+
124
+ // Both the token and the client config have to be on disk, or `getConfig`
125
+ // short-circuits the refresh before any request is made and the assertions
126
+ // below would pass without exercising anything.
127
+ const seedExpiredToken = Effect.gen(function*() {
128
+ yield* saveOAuthConfig(TOOL, { clientId: "client-1", clientSecret: "secret-1" })
129
+ yield* saveProfileToken(TOOL, expiredToken)
130
+ }).pipe(Effect.provide(storage))
131
+
132
+ describe("Atlassian CLI token refresh", () => {
133
+ it.effect("keeps the stored credential when the refresh never answers", () =>
134
+ withHome(Effect.gen(function*() {
135
+ yield* seedExpiredToken
136
+
137
+ const issued = yield* Deferred.make<void>()
138
+ const attempt = getAccessTokenWith(stalledClient(issued)).pipe(
139
+ Effect.exit
140
+ )
141
+ const fiber = yield* attempt.pipe(Effect.forkChild({ startImmediately: true }))
142
+
143
+ yield* Deferred.await(issued)
144
+ yield* TestClock.adjust("31 seconds")
145
+ const exit = yield* Fiber.join(fiber)
146
+
147
+ expect(exit._tag).toBe("Failure")
148
+ // The point of the test: a stall is not evidence, so the token survives.
149
+ const after = yield* storedToken
150
+ expect(after?.refresh_token).toBe("rotating-refresh")
151
+ })))
152
+
153
+ // Rate limiting is the case this most needs to survive: several `jira`/`jcf`
154
+ // processes on one expired token hit the endpoint together, one wins the
155
+ // rotation and the rest get 429. Treating that as a verdict on the token
156
+ // would log the user out precisely when things are busiest.
157
+ it.effect("keeps the stored credential when the provider rate-limits", () =>
158
+ withHome(Effect.gen(function*() {
159
+ yield* seedExpiredToken
160
+
161
+ const exit = yield* getAccessTokenWith(respondingClient(429, "Too Many Requests")).pipe(
162
+ Effect.exit
163
+ )
164
+
165
+ expect(exit._tag).toBe("Failure")
166
+ const after = yield* storedToken
167
+ expect(after?.refresh_token).toBe("rotating-refresh")
168
+ })))
169
+
170
+ // `refreshToken` sends the client credentials in the body, so a wrong or
171
+ // rotated secret comes back as `400 invalid_client` — a config problem, not a
172
+ // dead token. Deleting here would destroy a working credential and force a
173
+ // re-login that fails identically until `jira auth configure` is run.
174
+ it.effect("keeps the stored credential when the client credentials are rejected", () =>
175
+ withHome(Effect.gen(function*() {
176
+ yield* seedExpiredToken
177
+
178
+ const exit = yield* getAccessTokenWith(respondingClient(400, JSON.stringify({ error: "invalid_client" }))).pipe(
179
+ Effect.exit
180
+ )
181
+
182
+ expect(exit._tag).toBe("Failure")
183
+ const after = yield* storedToken
184
+ expect(after?.refresh_token).toBe("rotating-refresh")
185
+ })))
186
+
187
+ // A bare 403 is as likely to be a proxy or WAF as Atlassian revoking
188
+ // anything, so the code has to say so explicitly.
189
+ it.effect("keeps the stored credential on a 403 that does not say invalid_grant", () =>
190
+ withHome(Effect.gen(function*() {
191
+ yield* seedExpiredToken
192
+
193
+ const exit = yield* getAccessTokenWith(respondingClient(403, "<html>Forbidden</html>")).pipe(
194
+ Effect.exit
195
+ )
196
+
197
+ expect(exit._tag).toBe("Failure")
198
+ const after = yield* storedToken
199
+ expect(after?.refresh_token).toBe("rotating-refresh")
200
+ })))
201
+
202
+ it.effect("discards the stored credential on a 403 revocation", () =>
203
+ withHome(Effect.gen(function*() {
204
+ yield* seedExpiredToken
205
+
206
+ const exit = yield* getAccessTokenWith(respondingClient(403, JSON.stringify({ error: "invalid_grant" }))).pipe(
207
+ Effect.exit
208
+ )
209
+
210
+ expect(exit._tag).toBe("Failure")
211
+ const after = yield* storedToken
212
+ expect(after).toBeNull()
213
+ })))
214
+
215
+ // An unparseable body is no verdict at all, so the conservative reading wins.
216
+ it.effect("keeps the stored credential when the error body is unparseable", () =>
217
+ withHome(Effect.gen(function*() {
218
+ yield* seedExpiredToken
219
+
220
+ const exit = yield* getAccessTokenWith(respondingClient(400, "not json at all")).pipe(
221
+ Effect.exit
222
+ )
223
+
224
+ expect(exit._tag).toBe("Failure")
225
+ const after = yield* storedToken
226
+ expect(after?.refresh_token).toBe("rotating-refresh")
227
+ })))
228
+
229
+ // The atomicity half: once the grant is out, the replacement must reach disk
230
+ // even though the caller has already given up on the fiber.
231
+ it.effect("persists the rotated token even when interrupted mid-rotation", () =>
232
+ withHome(Effect.gen(function*() {
233
+ yield* seedExpiredToken
234
+ const issued = yield* Deferred.make<void>()
235
+ const release = yield* Deferred.make<void>()
236
+
237
+ const fiber = yield* getAccessTokenWith(gatedClient(issued, release)).pipe(
238
+ Effect.exit,
239
+ Effect.forkChild({ startImmediately: true })
240
+ )
241
+
242
+ yield* Deferred.await(issued) // the grant is in flight
243
+ const interrupting = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild({ startImmediately: true }))
244
+ yield* Deferred.succeed(release, undefined) // now let it answer
245
+ yield* Fiber.join(interrupting)
246
+
247
+ // The interrupt could not tear the rotation in half, so the replacement
248
+ // is on disk rather than lost with the token it consumed.
249
+ const after = yield* storedToken
250
+ expect(after?.refresh_token).toBe("rotated-refresh")
251
+ })))
252
+
253
+ // A second caller that arrives mid-refresh waits on the first caller's
254
+ // result. If the first caller is interrupted, the waiter must still settle
255
+ // instead of blocking forever on a result nobody will publish.
256
+ it.live("settles a waiting caller when the refreshing caller is interrupted", () =>
257
+ withHome(Effect.gen(function*() {
258
+ yield* seedExpiredToken
259
+ const issued = yield* Deferred.make<void>()
260
+ const release = yield* Deferred.make<void>()
261
+ const auth = yield* makeAtlassianCliAuth(authOptions).pipe(
262
+ Effect.provide(Layer.mergeAll(gatedClient(issued, release), NodeServices.layer, NodeCliAuthLive))
263
+ )
264
+
265
+ const owner = yield* auth.getAccessToken().pipe(Effect.forkChild({ startImmediately: true }))
266
+ yield* Deferred.await(issued) // the owner holds the refresh lock, grant in flight
267
+ const waiter = yield* auth.getAccessToken().pipe(Effect.exit, Effect.forkChild({ startImmediately: true }))
268
+ const interrupting = yield* Fiber.interrupt(owner).pipe(Effect.forkChild({ startImmediately: true }))
269
+ yield* Deferred.succeed(release, undefined)
270
+ yield* Fiber.join(interrupting)
271
+
272
+ const settled = yield* Fiber.join(waiter).pipe(Effect.timeoutOption("2 seconds"))
273
+ expect(settled._tag).toBe("Some")
274
+ })))
275
+
276
+ it.effect("discards the stored credential only when the provider rejects it", () =>
277
+ withHome(Effect.gen(function*() {
278
+ yield* seedExpiredToken
279
+
280
+ const exit = yield* getAccessTokenWith(rejectingClient).pipe(
281
+ Effect.exit
282
+ )
283
+
284
+ expect(exit._tag).toBe("Failure")
285
+ const after = yield* storedToken
286
+ expect(after).toBeNull()
287
+ })))
288
+
289
+ it.effect("names the CLI's own login command when the token is spent", () =>
290
+ withHome(Effect.gen(function*() {
291
+ yield* seedExpiredToken
292
+
293
+ const error = yield* getAccessTokenWith(rejectingClient).pipe(Effect.flip)
294
+
295
+ expect(error).toMatchObject({ _tag: "OAuthError", step: "refresh", status: 400, errorCode: "invalid_grant" })
296
+ expect(error.message).toContain("Please run 'test auth login'")
297
+ })))
298
+
299
+ it.effect("fails with the CLI's own error when nobody is logged in", () =>
300
+ withHome(Effect.gen(function*() {
301
+ const error = yield* getAccessTokenWith(rejectingClient).pipe(Effect.flip)
302
+ expect(error._tag).toBe("TestAuthMissing")
303
+ })))
304
+ })