@knpkv/jira-cli 1.3.1 → 1.4.1
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 +31 -0
- package/README.md +5 -5
- package/dist/JiraAuth.d.ts +23 -8
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js +8 -303
- package/dist/JiraAuth.js.map +1 -1
- package/dist/bin.js +1 -1
- package/dist/bin.js.map +1 -1
- package/dist/commands/auth.d.ts +2 -2
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +8 -8
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/edit.d.ts +10 -1
- package/dist/commands/edit.d.ts.map +1 -1
- package/dist/commands/edit.js +9 -9
- package/dist/commands/edit.js.map +1 -1
- package/dist/commands/get.d.ts +6 -1
- package/dist/commands/get.d.ts.map +1 -1
- package/dist/commands/get.js +3 -3
- package/dist/commands/get.js.map +1 -1
- package/dist/commands/issue.d.ts +1 -1
- package/dist/commands/issue.d.ts.map +1 -1
- package/dist/commands/issue.js +1 -1
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/issueAttachment.d.ts +1 -1
- package/dist/commands/issueAttachment.d.ts.map +1 -1
- package/dist/commands/issueAttachment.js +13 -11
- package/dist/commands/issueAttachment.js.map +1 -1
- package/dist/commands/layers.d.ts +2 -2
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/search.d.ts +6 -1
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +7 -7
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/version.d.ts +9 -1
- package/dist/commands/version.d.ts.map +1 -1
- package/dist/commands/version.js +23 -23
- package/dist/commands/version.js.map +1 -1
- package/package.json +10 -10
- package/skills/jira/SKILL.md +4 -4
- package/src/JiraAuth.ts +24 -477
- package/src/bin.ts +1 -1
- package/src/commands/auth.ts +8 -8
- package/src/commands/edit.ts +9 -9
- package/src/commands/get.ts +3 -3
- package/src/commands/issue.ts +1 -1
- package/src/commands/issueAttachment.ts +21 -20
- package/src/commands/search.ts +7 -7
- package/src/commands/version.ts +26 -29
- package/test/IssueService.test.ts +2 -2
- package/test/JiraAuth.test.ts +79 -0
- package/test/VersionService.test.ts +2 -2
- package/test/commandTree.test.ts +1 -1
- package/test/integration.test.ts +7 -7
- package/test/remoteWrites.test.ts +3 -3
- package/vitest.config.integration.ts +4 -1
- package/vitest.config.ts +4 -1
- package/dist/internal/NodeLayers.d.ts +0 -7
- package/dist/internal/NodeLayers.d.ts.map +0 -1
- package/dist/internal/NodeLayers.js +0 -15
- package/dist/internal/NodeLayers.js.map +0 -1
- package/dist/internal/oauthServer.d.ts +0 -72
- package/dist/internal/oauthServer.d.ts.map +0 -1
- package/dist/internal/oauthServer.js +0 -96
- package/dist/internal/oauthServer.js.map +0 -1
- package/dist/internal/openBrowser.d.ts +0 -10
- package/dist/internal/openBrowser.d.ts.map +0 -1
- package/dist/internal/openBrowser.js +0 -17
- package/dist/internal/openBrowser.js.map +0 -1
- package/src/internal/NodeLayers.ts +0 -17
- package/src/internal/oauthServer.ts +0 -186
- package/src/internal/openBrowser.ts +0 -31
- package/test/JiraAuthRefresh.test.ts +0 -269
- package/test/oauthServer.test.ts +0 -171
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What a failed token refresh is allowed to do to the stored credential.
|
|
3
|
-
*
|
|
4
|
-
* Atlassian rotates refresh tokens, so a refresh that does not complete leaves
|
|
5
|
-
* the client unable to tell whether the grant landed. Deleting the profile in
|
|
6
|
-
* that state is unrecoverable and unattended — `JiraApiConfigLive` builds on
|
|
7
|
-
* every CLI invocation, and jcf's statusline runs one every 30 seconds — so a
|
|
8
|
-
* bad network window could silently log the user out with nobody watching.
|
|
9
|
-
*
|
|
10
|
-
* The rule: discard the stored token only when Atlassian actually rejected it
|
|
11
|
-
* (a 4xx). Anything else — timeout, transport failure — keeps the credential so
|
|
12
|
-
* the next run can retry.
|
|
13
|
-
*/
|
|
14
|
-
import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"
|
|
15
|
-
import { describe, expect, it } from "@effect/vitest"
|
|
16
|
-
import {
|
|
17
|
-
HomeDirectoryLive,
|
|
18
|
-
loadActiveProfileToken,
|
|
19
|
-
saveOAuthConfig,
|
|
20
|
-
saveProfileToken
|
|
21
|
-
} from "@knpkv/atlassian-common/config"
|
|
22
|
-
import { ConfigProvider } from "effect"
|
|
23
|
-
import * as Deferred from "effect/Deferred"
|
|
24
|
-
import * as Effect from "effect/Effect"
|
|
25
|
-
import * as Fiber from "effect/Fiber"
|
|
26
|
-
import * as FileSystem from "effect/FileSystem"
|
|
27
|
-
import * as Layer from "effect/Layer"
|
|
28
|
-
import { TestClock } from "effect/testing"
|
|
29
|
-
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
30
|
-
import { JiraAuth, layer as jiraAuthLayer } from "../src/JiraAuth.js"
|
|
31
|
-
|
|
32
|
-
const TOOL = "jira-cli"
|
|
33
|
-
|
|
34
|
-
const expiredToken = {
|
|
35
|
-
access_token: "expired-access",
|
|
36
|
-
refresh_token: "rotating-refresh",
|
|
37
|
-
expires_at: 0,
|
|
38
|
-
token_type: "Bearer",
|
|
39
|
-
scope: "offline_access",
|
|
40
|
-
cloud_id: "cloud-1",
|
|
41
|
-
site_url: "https://example.atlassian.net"
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const storage = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, HomeDirectoryLive)
|
|
45
|
-
|
|
46
|
-
// JiraAuth also needs a spawner (browser launch during login) and Crypto (PKCE).
|
|
47
|
-
// Without them the layer dies as a defect, which no `catchTag` would see — and
|
|
48
|
-
// the assertions below would then pass without the refresh ever running.
|
|
49
|
-
const authLayerWith = (client: Layer.Layer<HttpClient.HttpClient>) =>
|
|
50
|
-
jiraAuthLayer.pipe(Layer.provide(Layer.mergeAll(client, NodeServices.layer)))
|
|
51
|
-
|
|
52
|
-
// A client that never answers — the stall case, driven by TestClock. It reports
|
|
53
|
-
// when the request has actually been issued so the test can advance the clock at
|
|
54
|
-
// a point where the refresh deadline is registered; adjusting before that would
|
|
55
|
-
// move past the deadline before it exists and hang.
|
|
56
|
-
const stalledClient = (issued: Deferred.Deferred<void>) =>
|
|
57
|
-
Layer.succeed(
|
|
58
|
-
HttpClient.HttpClient,
|
|
59
|
-
HttpClient.make(() => Effect.flatMap(Deferred.succeed(issued, undefined), () => Effect.never))
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
const respondingClient = (status: number, body: string) =>
|
|
63
|
-
Layer.succeed(
|
|
64
|
-
HttpClient.HttpClient,
|
|
65
|
-
HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, { status }))))
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
// A client that answers with the one thing that does prove the token is dead.
|
|
69
|
-
const rejectingClient = respondingClient(400, JSON.stringify({ error: "invalid_grant" }))
|
|
70
|
-
|
|
71
|
-
// A rotation that only completes once the test says so, so interruption can be
|
|
72
|
-
// aimed at the exact window between the grant and the token being persisted.
|
|
73
|
-
const gatedClient = (issued: Deferred.Deferred<void>, release: Deferred.Deferred<void>) =>
|
|
74
|
-
Layer.succeed(
|
|
75
|
-
HttpClient.HttpClient,
|
|
76
|
-
HttpClient.make((request) =>
|
|
77
|
-
Effect.gen(function*() {
|
|
78
|
-
yield* Deferred.succeed(issued, undefined)
|
|
79
|
-
yield* Deferred.await(release)
|
|
80
|
-
return HttpClientResponse.fromWeb(
|
|
81
|
-
request,
|
|
82
|
-
new Response(
|
|
83
|
-
JSON.stringify({
|
|
84
|
-
access_token: "fresh-access",
|
|
85
|
-
refresh_token: "rotated-refresh",
|
|
86
|
-
expires_in: 3600,
|
|
87
|
-
scope: "offline_access",
|
|
88
|
-
token_type: "Bearer"
|
|
89
|
-
}),
|
|
90
|
-
{ status: 200, headers: { "content-type": "application/json" } }
|
|
91
|
-
)
|
|
92
|
-
)
|
|
93
|
-
})
|
|
94
|
-
)
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
// `HomeDirectoryLive` resolves HOME through the ambient ConfigProvider, so the
|
|
98
|
-
// test home can be injected rather than set on `process.env` — a global that
|
|
99
|
-
// parallel tests would race each other to overwrite and restore. The directory
|
|
100
|
-
// is scoped, so it is removed when the effect finishes either way.
|
|
101
|
-
const withHome = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
102
|
-
Effect.gen(function*() {
|
|
103
|
-
const fs = yield* FileSystem.FileSystem
|
|
104
|
-
const home = yield* fs.makeTempDirectoryScoped({ prefix: "jcf-auth-" })
|
|
105
|
-
return yield* effect.pipe(
|
|
106
|
-
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })))
|
|
107
|
-
)
|
|
108
|
-
}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer))
|
|
109
|
-
|
|
110
|
-
const storedToken = loadActiveProfileToken(TOOL).pipe(Effect.provide(storage))
|
|
111
|
-
|
|
112
|
-
// Both the token and the client config have to be on disk, or `getConfig`
|
|
113
|
-
// short-circuits the refresh before any request is made and the assertions
|
|
114
|
-
// below would pass without exercising anything.
|
|
115
|
-
const seedExpiredToken = Effect.gen(function*() {
|
|
116
|
-
yield* saveOAuthConfig(TOOL, { clientId: "client-1", clientSecret: "secret-1" })
|
|
117
|
-
yield* saveProfileToken(TOOL, expiredToken)
|
|
118
|
-
}).pipe(Effect.provide(storage))
|
|
119
|
-
|
|
120
|
-
describe("JiraAuth token refresh", () => {
|
|
121
|
-
it.effect("keeps the stored credential when the refresh never answers", () =>
|
|
122
|
-
withHome(Effect.gen(function*() {
|
|
123
|
-
yield* seedExpiredToken
|
|
124
|
-
|
|
125
|
-
const issued = yield* Deferred.make<void>()
|
|
126
|
-
const attempt = JiraAuth.pipe(
|
|
127
|
-
Effect.flatMap((auth) => auth.getAccessToken()),
|
|
128
|
-
Effect.provide(authLayerWith(stalledClient(issued))),
|
|
129
|
-
Effect.exit
|
|
130
|
-
)
|
|
131
|
-
const fiber = yield* attempt.pipe(Effect.forkChild({ startImmediately: true }))
|
|
132
|
-
|
|
133
|
-
yield* Deferred.await(issued)
|
|
134
|
-
yield* TestClock.adjust("31 seconds")
|
|
135
|
-
const exit = yield* Fiber.join(fiber)
|
|
136
|
-
|
|
137
|
-
expect(exit._tag).toBe("Failure")
|
|
138
|
-
// The point of the test: a stall is not evidence, so the token survives.
|
|
139
|
-
const after = yield* storedToken
|
|
140
|
-
expect(after?.refresh_token).toBe("rotating-refresh")
|
|
141
|
-
})))
|
|
142
|
-
|
|
143
|
-
// Rate limiting is the case this most needs to survive: several `jira`/`jcf`
|
|
144
|
-
// processes on one expired token hit the endpoint together, one wins the
|
|
145
|
-
// rotation and the rest get 429. Treating that as a verdict on the token
|
|
146
|
-
// would log the user out precisely when things are busiest.
|
|
147
|
-
it.effect("keeps the stored credential when the provider rate-limits", () =>
|
|
148
|
-
withHome(Effect.gen(function*() {
|
|
149
|
-
yield* seedExpiredToken
|
|
150
|
-
|
|
151
|
-
const exit = yield* JiraAuth.pipe(
|
|
152
|
-
Effect.flatMap((auth) => auth.getAccessToken()),
|
|
153
|
-
Effect.provide(authLayerWith(respondingClient(429, "Too Many Requests"))),
|
|
154
|
-
Effect.exit
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
expect(exit._tag).toBe("Failure")
|
|
158
|
-
const after = yield* storedToken
|
|
159
|
-
expect(after?.refresh_token).toBe("rotating-refresh")
|
|
160
|
-
})))
|
|
161
|
-
|
|
162
|
-
// `refreshToken` sends the client credentials in the body, so a wrong or
|
|
163
|
-
// rotated secret comes back as `400 invalid_client` — a config problem, not a
|
|
164
|
-
// dead token. Deleting here would destroy a working credential and force a
|
|
165
|
-
// re-login that fails identically until `jira auth configure` is run.
|
|
166
|
-
it.effect("keeps the stored credential when the client credentials are rejected", () =>
|
|
167
|
-
withHome(Effect.gen(function*() {
|
|
168
|
-
yield* seedExpiredToken
|
|
169
|
-
|
|
170
|
-
const exit = yield* JiraAuth.pipe(
|
|
171
|
-
Effect.flatMap((auth) => auth.getAccessToken()),
|
|
172
|
-
Effect.provide(authLayerWith(respondingClient(400, JSON.stringify({ error: "invalid_client" })))),
|
|
173
|
-
Effect.exit
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
expect(exit._tag).toBe("Failure")
|
|
177
|
-
const after = yield* storedToken
|
|
178
|
-
expect(after?.refresh_token).toBe("rotating-refresh")
|
|
179
|
-
})))
|
|
180
|
-
|
|
181
|
-
// A bare 403 is as likely to be a proxy or WAF as Atlassian revoking
|
|
182
|
-
// anything, so the code has to say so explicitly.
|
|
183
|
-
it.effect("keeps the stored credential on a 403 that does not say invalid_grant", () =>
|
|
184
|
-
withHome(Effect.gen(function*() {
|
|
185
|
-
yield* seedExpiredToken
|
|
186
|
-
|
|
187
|
-
const exit = yield* JiraAuth.pipe(
|
|
188
|
-
Effect.flatMap((auth) => auth.getAccessToken()),
|
|
189
|
-
Effect.provide(authLayerWith(respondingClient(403, "<html>Forbidden</html>"))),
|
|
190
|
-
Effect.exit
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
expect(exit._tag).toBe("Failure")
|
|
194
|
-
const after = yield* storedToken
|
|
195
|
-
expect(after?.refresh_token).toBe("rotating-refresh")
|
|
196
|
-
})))
|
|
197
|
-
|
|
198
|
-
it.effect("discards the stored credential on a 403 revocation", () =>
|
|
199
|
-
withHome(Effect.gen(function*() {
|
|
200
|
-
yield* seedExpiredToken
|
|
201
|
-
|
|
202
|
-
const exit = yield* JiraAuth.pipe(
|
|
203
|
-
Effect.flatMap((auth) => auth.getAccessToken()),
|
|
204
|
-
Effect.provide(authLayerWith(respondingClient(403, JSON.stringify({ error: "invalid_grant" })))),
|
|
205
|
-
Effect.exit
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
expect(exit._tag).toBe("Failure")
|
|
209
|
-
const after = yield* storedToken
|
|
210
|
-
expect(after).toBeNull()
|
|
211
|
-
})))
|
|
212
|
-
|
|
213
|
-
// An unparseable body is no verdict at all, so the conservative reading wins.
|
|
214
|
-
it.effect("keeps the stored credential when the error body is unparseable", () =>
|
|
215
|
-
withHome(Effect.gen(function*() {
|
|
216
|
-
yield* seedExpiredToken
|
|
217
|
-
|
|
218
|
-
const exit = yield* JiraAuth.pipe(
|
|
219
|
-
Effect.flatMap((auth) => auth.getAccessToken()),
|
|
220
|
-
Effect.provide(authLayerWith(respondingClient(400, "not json at all"))),
|
|
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* JiraAuth.pipe(
|
|
238
|
-
Effect.flatMap((auth) => auth.getAccessToken()),
|
|
239
|
-
Effect.provide(authLayerWith(gatedClient(issued, release))),
|
|
240
|
-
Effect.exit,
|
|
241
|
-
Effect.forkChild({ startImmediately: true })
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
yield* Deferred.await(issued) // the grant is in flight
|
|
245
|
-
const interrupting = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild({ startImmediately: true }))
|
|
246
|
-
yield* Deferred.succeed(release, undefined) // now let it answer
|
|
247
|
-
yield* Fiber.join(interrupting)
|
|
248
|
-
|
|
249
|
-
// The interrupt could not tear the rotation in half, so the replacement
|
|
250
|
-
// is on disk rather than lost with the token it consumed.
|
|
251
|
-
const after = yield* storedToken
|
|
252
|
-
expect(after?.refresh_token).toBe("rotated-refresh")
|
|
253
|
-
})))
|
|
254
|
-
|
|
255
|
-
it.effect("discards the stored credential only when the provider rejects it", () =>
|
|
256
|
-
withHome(Effect.gen(function*() {
|
|
257
|
-
yield* seedExpiredToken
|
|
258
|
-
|
|
259
|
-
const exit = yield* JiraAuth.pipe(
|
|
260
|
-
Effect.flatMap((auth) => auth.getAccessToken()),
|
|
261
|
-
Effect.provide(authLayerWith(rejectingClient)),
|
|
262
|
-
Effect.exit
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
expect(exit._tag).toBe("Failure")
|
|
266
|
-
const after = yield* storedToken
|
|
267
|
-
expect(after).toBeNull()
|
|
268
|
-
})))
|
|
269
|
-
})
|
package/test/oauthServer.test.ts
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
|
|
2
|
-
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
|
|
3
|
-
import { describe, expect, it } from "@effect/vitest"
|
|
4
|
-
import * as Effect from "effect/Effect"
|
|
5
|
-
import * as Fiber from "effect/Fiber"
|
|
6
|
-
import * as Layer from "effect/Layer"
|
|
7
|
-
import * as Ref from "effect/Ref"
|
|
8
|
-
import { HttpClient, HttpClientRequest, HttpServer, HttpServerError } from "effect/unstable/http"
|
|
9
|
-
import { createServer } from "node:http"
|
|
10
|
-
import { HttpServerFactoryLive } from "../src/internal/NodeLayers.js"
|
|
11
|
-
import {
|
|
12
|
-
callbackServerListenOptions,
|
|
13
|
-
callbackUrl,
|
|
14
|
-
HttpServerFactoryTag,
|
|
15
|
-
makeHttpServerFactory,
|
|
16
|
-
startCallbackServer
|
|
17
|
-
} from "../src/internal/oauthServer.js"
|
|
18
|
-
|
|
19
|
-
const EphemeralHttpServerFactoryLive = makeHttpServerFactory(
|
|
20
|
-
() => NodeHttpServer.layerServer(createServer, { port: 0 })
|
|
21
|
-
)
|
|
22
|
-
const HttpClientLive = NodeHttpClient.layerUndici
|
|
23
|
-
const fakeHttpServer = (port: number): HttpServer.HttpServer["Service"] =>
|
|
24
|
-
HttpServer.make({
|
|
25
|
-
address: { _tag: "TcpAddress", hostname: "127.0.0.1", port },
|
|
26
|
-
serve: () => Effect.void
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
describe("oauth callback server lifecycle", () => {
|
|
30
|
-
it.effect("binds the same localhost name advertised by the callback URL", () =>
|
|
31
|
-
Effect.scoped(
|
|
32
|
-
Effect.gen(function*() {
|
|
33
|
-
const listenOptions = callbackServerListenOptions(0)
|
|
34
|
-
const factory = yield* HttpServerFactoryTag
|
|
35
|
-
const server = yield* HttpServer.HttpServer.pipe(
|
|
36
|
-
Effect.provide(factory.createServerLayer(listenOptions))
|
|
37
|
-
)
|
|
38
|
-
const advertisedUrl = new URL(callbackUrl(8585))
|
|
39
|
-
const ipv6OnlyLocalhost = (hostname: string) => (hostname === "localhost" ? "::1" : undefined)
|
|
40
|
-
expect(server.address._tag).toBe("TcpAddress")
|
|
41
|
-
expect(listenOptions.host).toBe(advertisedUrl.hostname)
|
|
42
|
-
expect(ipv6OnlyLocalhost(listenOptions.host)).toBe("::1")
|
|
43
|
-
expect(ipv6OnlyLocalhost(advertisedUrl.hostname)).toBe("::1")
|
|
44
|
-
}).pipe(Effect.provide(HttpServerFactoryLive))
|
|
45
|
-
))
|
|
46
|
-
|
|
47
|
-
it.effect("releases the port when authorization exits before receiving a code", () =>
|
|
48
|
-
Effect.gen(function*() {
|
|
49
|
-
yield* Effect.scoped(
|
|
50
|
-
startCallbackServer("first").pipe(
|
|
51
|
-
Effect.provide(EphemeralHttpServerFactoryLive),
|
|
52
|
-
Effect.andThen(Effect.fail("browser-open-failed")),
|
|
53
|
-
Effect.flip
|
|
54
|
-
)
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
const port = yield* Effect.scoped(
|
|
58
|
-
startCallbackServer("second").pipe(
|
|
59
|
-
Effect.provide(EphemeralHttpServerFactoryLive),
|
|
60
|
-
Effect.map((server) => server.port)
|
|
61
|
-
)
|
|
62
|
-
)
|
|
63
|
-
expect(port).toBeGreaterThan(0)
|
|
64
|
-
}))
|
|
65
|
-
|
|
66
|
-
it.effect("starts concurrent real callback servers on distinct OS-assigned ports", () =>
|
|
67
|
-
Effect.scoped(
|
|
68
|
-
Effect.gen(function*() {
|
|
69
|
-
const [first, second] = yield* Effect.all(
|
|
70
|
-
[startCallbackServer("first"), startCallbackServer("second")],
|
|
71
|
-
{ concurrency: "unbounded" }
|
|
72
|
-
)
|
|
73
|
-
expect(first.port).toBeGreaterThan(0)
|
|
74
|
-
expect(second.port).toBeGreaterThan(0)
|
|
75
|
-
expect(first.port).not.toBe(second.port)
|
|
76
|
-
}).pipe(Effect.provide(EphemeralHttpServerFactoryLive))
|
|
77
|
-
))
|
|
78
|
-
|
|
79
|
-
it.effect("retries occupied fixed-range ports with the injected factory", () => {
|
|
80
|
-
const attempts = Ref.makeUnsafe<ReadonlyArray<number>>([])
|
|
81
|
-
const occupiedFactory = makeHttpServerFactory(({ port }) =>
|
|
82
|
-
Layer.effect(
|
|
83
|
-
HttpServer.HttpServer,
|
|
84
|
-
Ref.update(attempts, (ports) => [...ports, port]).pipe(
|
|
85
|
-
Effect.andThen(
|
|
86
|
-
port < 8587
|
|
87
|
-
? Effect.fail(new HttpServerError.ServeError({ cause: { code: "EADDRINUSE" } }))
|
|
88
|
-
: Effect.succeed(fakeHttpServer(port))
|
|
89
|
-
)
|
|
90
|
-
)
|
|
91
|
-
)
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
return Effect.scoped(
|
|
95
|
-
Effect.gen(function*() {
|
|
96
|
-
const server = yield* startCallbackServer("state")
|
|
97
|
-
expect(server.port).toBe(8587)
|
|
98
|
-
expect(yield* Ref.get(attempts)).toEqual([8585, 8586, 8587])
|
|
99
|
-
}).pipe(Effect.provide(occupiedFactory))
|
|
100
|
-
)
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it.effect("does not retry a non-address-in-use server failure", () => {
|
|
104
|
-
const attempts = Ref.makeUnsafe(0)
|
|
105
|
-
const failingFactory = makeHttpServerFactory(() =>
|
|
106
|
-
Layer.effect(
|
|
107
|
-
HttpServer.HttpServer,
|
|
108
|
-
Ref.update(attempts, (count) => count + 1).pipe(
|
|
109
|
-
Effect.andThen(Effect.fail(
|
|
110
|
-
new HttpServerError.ServeError({
|
|
111
|
-
cause: { code: "ECONNREFUSED" }
|
|
112
|
-
})
|
|
113
|
-
))
|
|
114
|
-
)
|
|
115
|
-
)
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
return Effect.scoped(
|
|
119
|
-
startCallbackServer("state").pipe(
|
|
120
|
-
Effect.provide(failingFactory),
|
|
121
|
-
Effect.flip,
|
|
122
|
-
Effect.tap(() => Ref.get(attempts).pipe(Effect.map((count) => expect(count).toBe(1))))
|
|
123
|
-
)
|
|
124
|
-
)
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
it.effect("ignores a forged error callback before accepting the legitimate state", () =>
|
|
128
|
-
Effect.scoped(
|
|
129
|
-
Effect.gen(function*() {
|
|
130
|
-
const expectedState = "expected-state"
|
|
131
|
-
const { codePromise, port } = yield* startCallbackServer(expectedState)
|
|
132
|
-
const codeReceiver = yield* Effect.forkChild(codePromise)
|
|
133
|
-
const client = yield* HttpClient.HttpClient
|
|
134
|
-
|
|
135
|
-
const forgedResponse = yield* client.execute(
|
|
136
|
-
HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
|
|
137
|
-
HttpClientRequest.setUrlParam("error", "access_denied")
|
|
138
|
-
)
|
|
139
|
-
)
|
|
140
|
-
expect(forgedResponse.status).toBe(403)
|
|
141
|
-
yield* client.execute(
|
|
142
|
-
HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
|
|
143
|
-
HttpClientRequest.setUrlParam("code", "legitimate-code"),
|
|
144
|
-
HttpClientRequest.setUrlParam("state", expectedState)
|
|
145
|
-
)
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
expect(yield* Fiber.join(codeReceiver)).toBe("legitimate-code")
|
|
149
|
-
}).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
|
|
150
|
-
))
|
|
151
|
-
|
|
152
|
-
it.effect("accepts a provider error only after validating state", () =>
|
|
153
|
-
Effect.scoped(
|
|
154
|
-
Effect.gen(function*() {
|
|
155
|
-
const expectedState = "expected-state"
|
|
156
|
-
const { codePromise, port } = yield* startCallbackServer(expectedState)
|
|
157
|
-
const codeReceiver = yield* Effect.forkChild(codePromise)
|
|
158
|
-
const client = yield* HttpClient.HttpClient
|
|
159
|
-
|
|
160
|
-
yield* client.execute(
|
|
161
|
-
HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
|
|
162
|
-
HttpClientRequest.setUrlParam("error", "access_denied"),
|
|
163
|
-
HttpClientRequest.setUrlParam("state", expectedState)
|
|
164
|
-
)
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
const error = yield* Fiber.join(codeReceiver).pipe(Effect.flip)
|
|
168
|
-
expect(String(error.cause)).toContain("access_denied")
|
|
169
|
-
}).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
|
|
170
|
-
))
|
|
171
|
-
})
|