@knpkv/atlassian-common 1.4.1 → 1.6.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.
- package/CHANGELOG.md +14 -0
- package/README.md +32 -1
- package/dist/Brand.d.ts +2 -2
- package/dist/Brand.d.ts.map +1 -1
- package/dist/Brand.js.map +1 -1
- package/dist/Hash.js +2 -2
- package/dist/Hash.js.map +1 -1
- package/dist/auth/OAuthEndpoints.js +3 -3
- package/dist/auth/OAuthEndpoints.js.map +1 -1
- package/dist/auth/OAuthOperations.d.ts +1 -1
- package/dist/auth/OAuthOperations.d.ts.map +1 -1
- package/dist/auth/OAuthOperations.js +8 -8
- package/dist/auth/OAuthOperations.js.map +1 -1
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/cli-auth/AtlassianCliAuth.d.ts +102 -0
- package/dist/cli-auth/AtlassianCliAuth.d.ts.map +1 -0
- package/dist/cli-auth/AtlassianCliAuth.js +298 -0
- package/dist/cli-auth/AtlassianCliAuth.js.map +1 -0
- package/dist/cli-auth/index.d.ts +13 -0
- package/dist/cli-auth/index.d.ts.map +1 -0
- package/dist/cli-auth/index.js +13 -0
- package/dist/cli-auth/index.js.map +1 -0
- package/dist/cli-auth/internal/NodeLayers.d.ts +5 -0
- package/dist/cli-auth/internal/NodeLayers.d.ts.map +1 -0
- package/dist/cli-auth/internal/NodeLayers.js +17 -0
- package/dist/cli-auth/internal/NodeLayers.js.map +1 -0
- package/dist/cli-auth/internal/oauthServer.d.ts +54 -0
- package/dist/cli-auth/internal/oauthServer.d.ts.map +1 -0
- package/dist/cli-auth/internal/oauthServer.js +94 -0
- package/dist/cli-auth/internal/oauthServer.js.map +1 -0
- package/dist/cli-auth/openBrowser.d.ts +16 -0
- package/dist/cli-auth/openBrowser.d.ts.map +1 -0
- package/dist/cli-auth/openBrowser.js +30 -0
- package/dist/cli-auth/openBrowser.js.map +1 -0
- package/dist/config/ConfigPaths.js +2 -2
- package/dist/config/ProfileManager.d.ts +1 -1
- package/dist/config/ProfileManager.d.ts.map +1 -1
- package/dist/config/ProfileManager.js +3 -3
- package/dist/config/ProfileManager.js.map +1 -1
- package/package.json +11 -7
- package/src/Brand.ts +5 -5
- package/src/Hash.ts +2 -2
- package/src/auth/OAuthEndpoints.ts +3 -3
- package/src/auth/OAuthOperations.ts +14 -24
- package/src/bin.ts +2 -2
- package/src/cli-auth/AtlassianCliAuth.ts +530 -0
- package/src/cli-auth/index.ts +18 -0
- package/src/cli-auth/internal/NodeLayers.ts +25 -0
- package/src/cli-auth/internal/oauthServer.ts +168 -0
- package/src/cli-auth/openBrowser.ts +47 -0
- package/src/config/ConfigPaths.ts +3 -3
- package/src/config/ProfileManager.ts +3 -3
- package/test/ApiUpdateWorkflows.test.ts +286 -10
- package/test/OAuthOperations.test.ts +2 -2
- package/test/ProfileManager.test.ts +1 -1
- package/test/cliAuth.guardrails.test.ts +76 -0
- package/test/cliAuth.login.test.ts +319 -0
- package/test/cliAuth.oauthServer.test.ts +276 -0
- package/test/cliAuth.openBrowser.test.ts +70 -0
- package/test/cliAuth.refresh.test.ts +304 -0
|
@@ -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
|
+
})
|