@knpkv/atlassian-common 0.2.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 +11 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/Brand.d.ts +118 -0
- package/dist/Brand.d.ts.map +1 -0
- package/dist/Brand.js +107 -0
- package/dist/Brand.js.map +1 -0
- package/dist/Hash.d.ts +53 -0
- package/dist/Hash.d.ts.map +1 -0
- package/dist/Hash.js +63 -0
- package/dist/Hash.js.map +1 -0
- package/dist/SerializeError.d.ts +48 -0
- package/dist/SerializeError.d.ts.map +1 -0
- package/dist/SerializeError.js +42 -0
- package/dist/SerializeError.js.map +1 -0
- package/dist/ast/BlockNode.d.ts +429 -0
- package/dist/ast/BlockNode.d.ts.map +1 -0
- package/dist/ast/BlockNode.js +279 -0
- package/dist/ast/BlockNode.js.map +1 -0
- package/dist/ast/Document.d.ts +255 -0
- package/dist/ast/Document.d.ts.map +1 -0
- package/dist/ast/Document.js +80 -0
- package/dist/ast/Document.js.map +1 -0
- package/dist/ast/InlineNode.d.ts +483 -0
- package/dist/ast/InlineNode.d.ts.map +1 -0
- package/dist/ast/InlineNode.js +268 -0
- package/dist/ast/InlineNode.js.map +1 -0
- package/dist/ast/MacroNode.d.ts +214 -0
- package/dist/ast/MacroNode.d.ts.map +1 -0
- package/dist/ast/MacroNode.js +110 -0
- package/dist/ast/MacroNode.js.map +1 -0
- package/dist/ast/index.d.ts +10 -0
- package/dist/ast/index.d.ts.map +1 -0
- package/dist/ast/index.js +14 -0
- package/dist/ast/index.js.map +1 -0
- package/dist/auth/OAuthEndpoints.d.ts +95 -0
- package/dist/auth/OAuthEndpoints.d.ts.map +1 -0
- package/dist/auth/OAuthEndpoints.js +112 -0
- package/dist/auth/OAuthEndpoints.js.map +1 -0
- package/dist/auth/OAuthErrors.d.ts +76 -0
- package/dist/auth/OAuthErrors.d.ts.map +1 -0
- package/dist/auth/OAuthErrors.js +85 -0
- package/dist/auth/OAuthErrors.js.map +1 -0
- package/dist/auth/OAuthOperations.d.ts +87 -0
- package/dist/auth/OAuthOperations.d.ts.map +1 -0
- package/dist/auth/OAuthOperations.js +169 -0
- package/dist/auth/OAuthOperations.js.map +1 -0
- package/dist/auth/OAuthResponseSchemas.d.ts +65 -0
- package/dist/auth/OAuthResponseSchemas.d.ts.map +1 -0
- package/dist/auth/OAuthResponseSchemas.js +47 -0
- package/dist/auth/OAuthResponseSchemas.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +16 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/uuid.d.ts +14 -0
- package/dist/auth/uuid.d.ts.map +1 -0
- package/dist/auth/uuid.js +14 -0
- package/dist/auth/uuid.js.map +1 -0
- package/dist/config/ConfigPaths.d.ts +95 -0
- package/dist/config/ConfigPaths.d.ts.map +1 -0
- package/dist/config/ConfigPaths.js +102 -0
- package/dist/config/ConfigPaths.js.map +1 -0
- package/dist/config/OAuthSchemas.d.ts +144 -0
- package/dist/config/OAuthSchemas.d.ts.map +1 -0
- package/dist/config/OAuthSchemas.js +107 -0
- package/dist/config/OAuthSchemas.js.map +1 -0
- package/dist/config/TokenStorage.d.ts +96 -0
- package/dist/config/TokenStorage.d.ts.map +1 -0
- package/dist/config/TokenStorage.js +128 -0
- package/dist/config/TokenStorage.js.map +1 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +12 -0
- package/dist/config/index.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers/index.d.ts +7 -0
- package/dist/parsers/index.d.ts.map +1 -0
- package/dist/parsers/index.js +8 -0
- package/dist/parsers/index.js.map +1 -0
- package/dist/serializers/MarkdownSerializer.d.ts +63 -0
- package/dist/serializers/MarkdownSerializer.d.ts.map +1 -0
- package/dist/serializers/MarkdownSerializer.js +367 -0
- package/dist/serializers/MarkdownSerializer.js.map +1 -0
- package/dist/serializers/index.d.ts +7 -0
- package/dist/serializers/index.d.ts.map +1 -0
- package/dist/serializers/index.js +7 -0
- package/dist/serializers/index.js.map +1 -0
- package/package.json +86 -0
- package/src/Brand.ts +142 -0
- package/src/Hash.ts +68 -0
- package/src/SerializeError.ts +50 -0
- package/src/ast/BlockNode.ts +386 -0
- package/src/ast/Document.ts +101 -0
- package/src/ast/InlineNode.ts +328 -0
- package/src/ast/MacroNode.ts +172 -0
- package/src/ast/index.ts +87 -0
- package/src/auth/OAuthEndpoints.ts +142 -0
- package/src/auth/OAuthErrors.ts +101 -0
- package/src/auth/OAuthOperations.ts +276 -0
- package/src/auth/OAuthResponseSchemas.ts +70 -0
- package/src/auth/index.ts +47 -0
- package/src/auth/uuid.ts +14 -0
- package/src/config/ConfigPaths.ts +179 -0
- package/src/config/OAuthSchemas.ts +146 -0
- package/src/config/TokenStorage.ts +228 -0
- package/src/config/index.ts +43 -0
- package/src/index.ts +34 -0
- package/src/parsers/index.ts +8 -0
- package/src/serializers/MarkdownSerializer.ts +498 -0
- package/src/serializers/index.ts +7 -0
- package/test/Brand.test.ts +90 -0
- package/test/MarkdownSerializer.test.ts +82 -0
- package/test/OAuthEndpoints.test.ts +109 -0
- package/test/OAuthOperations.test.ts +315 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { it as effectIt } from "@effect/vitest"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
import { AUTH_URL, buildAuthUrl, computeCodeChallenge, generateCodeVerifier } from "../src/auth/OAuthEndpoints.js"
|
|
5
|
+
|
|
6
|
+
describe("OAuthEndpoints", () => {
|
|
7
|
+
// ---- PKCE ----
|
|
8
|
+
|
|
9
|
+
describe("generateCodeVerifier", () => {
|
|
10
|
+
// 32 random bytes base64url-encoded = 43 chars; verifies output shape for RFC 7636 compliance
|
|
11
|
+
it("returns a base64url string of 43 characters (32 random bytes)", () => {
|
|
12
|
+
const verifier = generateCodeVerifier()
|
|
13
|
+
expect(verifier).toHaveLength(43)
|
|
14
|
+
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// PKCE verifiers must be unique per auth request — reuse enables replay attacks
|
|
18
|
+
it("produces unique values", () => {
|
|
19
|
+
const a = generateCodeVerifier()
|
|
20
|
+
const b = generateCodeVerifier()
|
|
21
|
+
expect(a).not.toBe(b)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe("computeCodeChallenge", () => {
|
|
26
|
+
// Known test vector from RFC 7636 appendix B — proves SHA-256 + base64url is correct
|
|
27
|
+
effectIt.effect("matches RFC 7636 appendix B test vector", () =>
|
|
28
|
+
Effect.gen(function*() {
|
|
29
|
+
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
30
|
+
const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
|
31
|
+
const challenge = yield* computeCodeChallenge(verifier)
|
|
32
|
+
expect(challenge).toBe(expected)
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
// Same verifier must always produce same challenge — server verifies this on token exchange
|
|
36
|
+
effectIt.effect("is deterministic", () =>
|
|
37
|
+
Effect.gen(function*() {
|
|
38
|
+
const v = generateCodeVerifier()
|
|
39
|
+
const a = yield* computeCodeChallenge(v)
|
|
40
|
+
const b = yield* computeCodeChallenge(v)
|
|
41
|
+
expect(a).toBe(b)
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
// base64url must not contain +, /, = — these break URL query params
|
|
45
|
+
effectIt.effect("produces base64url output (no +, /, =)", () =>
|
|
46
|
+
Effect.gen(function*() {
|
|
47
|
+
const challenge = yield* computeCodeChallenge("test-verifier-string")
|
|
48
|
+
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/)
|
|
49
|
+
}))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ---- buildAuthUrl ----
|
|
53
|
+
|
|
54
|
+
describe("buildAuthUrl", () => {
|
|
55
|
+
// Verifies all required OAuth2 params (audience, client_id, scope, redirect_uri, state, response_type, prompt)
|
|
56
|
+
it("includes required parameters", () => {
|
|
57
|
+
const url = buildAuthUrl({
|
|
58
|
+
clientId: "cid",
|
|
59
|
+
state: "state-123",
|
|
60
|
+
port: 9876,
|
|
61
|
+
scopes: ["read:jira-work", "offline_access"]
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const parsed = new URL(url)
|
|
65
|
+
expect(parsed.origin + parsed.pathname).toBe(AUTH_URL)
|
|
66
|
+
expect(parsed.searchParams.get("client_id")).toBe("cid")
|
|
67
|
+
expect(parsed.searchParams.get("state")).toBe("state-123")
|
|
68
|
+
expect(parsed.searchParams.get("redirect_uri")).toBe("http://localhost:9876/callback")
|
|
69
|
+
expect(parsed.searchParams.get("scope")).toBe("read:jira-work offline_access")
|
|
70
|
+
expect(parsed.searchParams.get("response_type")).toBe("code")
|
|
71
|
+
expect(parsed.searchParams.get("audience")).toBe("api.atlassian.com")
|
|
72
|
+
expect(parsed.searchParams.get("prompt")).toBe("consent")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// PKCE is optional — omitting codeChallenge must not add params (backwards compat)
|
|
76
|
+
it("omits PKCE params when codeChallenge not provided", () => {
|
|
77
|
+
const url = buildAuthUrl({
|
|
78
|
+
clientId: "cid",
|
|
79
|
+
state: "s",
|
|
80
|
+
port: 3000,
|
|
81
|
+
scopes: ["read:me"]
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const parsed = new URL(url)
|
|
85
|
+
expect(parsed.searchParams.has("code_challenge")).toBe(false)
|
|
86
|
+
expect(parsed.searchParams.has("code_challenge_method")).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// When PKCE is used, both code_challenge and method=S256 must appear for server to validate
|
|
90
|
+
effectIt.effect(
|
|
91
|
+
"includes code_challenge and code_challenge_method=S256 when provided",
|
|
92
|
+
() =>
|
|
93
|
+
Effect.gen(function*() {
|
|
94
|
+
const challenge = yield* computeCodeChallenge("my-verifier")
|
|
95
|
+
const url = buildAuthUrl({
|
|
96
|
+
clientId: "cid",
|
|
97
|
+
state: "s",
|
|
98
|
+
port: 3000,
|
|
99
|
+
scopes: ["read:me"],
|
|
100
|
+
codeChallenge: challenge
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const parsed = new URL(url)
|
|
104
|
+
expect(parsed.searchParams.get("code_challenge")).toBe(challenge)
|
|
105
|
+
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256")
|
|
106
|
+
})
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import * as HttpClient from "@effect/platform/HttpClient"
|
|
2
|
+
import type * as HttpClientRequest from "@effect/platform/HttpClientRequest"
|
|
3
|
+
import * as HttpClientResponse from "@effect/platform/HttpClientResponse"
|
|
4
|
+
import { describe, it } from "@effect/vitest"
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import * as Layer from "effect/Layer"
|
|
7
|
+
import { expect } from "vitest"
|
|
8
|
+
import { OAuthError } from "../src/auth/OAuthErrors.js"
|
|
9
|
+
import {
|
|
10
|
+
buildOAuthToken,
|
|
11
|
+
exchangeCodeForTokens,
|
|
12
|
+
getAccessibleResources,
|
|
13
|
+
getUserInfo,
|
|
14
|
+
refreshToken,
|
|
15
|
+
revokeToken
|
|
16
|
+
} from "../src/auth/OAuthOperations.js"
|
|
17
|
+
import type { OAuthConfig, OAuthToken } from "../src/config/OAuthSchemas.js"
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const createMockHttpClient = (
|
|
24
|
+
responses: Array<{ status: number; body: unknown }>
|
|
25
|
+
) => {
|
|
26
|
+
let requestIndex = 0
|
|
27
|
+
const capturedRequests: Array<HttpClientRequest.HttpClientRequest> = []
|
|
28
|
+
|
|
29
|
+
const mockClient: HttpClient.HttpClient = HttpClient.make((request) =>
|
|
30
|
+
Effect.gen(function*() {
|
|
31
|
+
capturedRequests.push(request)
|
|
32
|
+
const response = responses[requestIndex] ?? { status: 200, body: {} }
|
|
33
|
+
requestIndex++
|
|
34
|
+
return HttpClientResponse.fromWeb(
|
|
35
|
+
request,
|
|
36
|
+
new Response(
|
|
37
|
+
JSON.stringify(response.body),
|
|
38
|
+
{ status: response.status, headers: { "content-type": "application/json" } }
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return { mockClient, capturedRequests }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const provide = (client: HttpClient.HttpClient) => Effect.provide(Layer.succeed(HttpClient.HttpClient, client))
|
|
48
|
+
|
|
49
|
+
const validTokenBody = {
|
|
50
|
+
access_token: "access-123",
|
|
51
|
+
refresh_token: "refresh-456",
|
|
52
|
+
expires_in: 3600,
|
|
53
|
+
scope: "read:jira-work",
|
|
54
|
+
token_type: "Bearer"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const oauthConfig: OAuthConfig = {
|
|
58
|
+
clientId: "cid",
|
|
59
|
+
clientSecret: "csecret"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const oauthToken: OAuthToken = {
|
|
63
|
+
access_token: "old-access",
|
|
64
|
+
refresh_token: "old-refresh",
|
|
65
|
+
expires_at: Date.now() - 1000,
|
|
66
|
+
scope: "read:jira-work",
|
|
67
|
+
cloud_id: "cloud-1",
|
|
68
|
+
site_url: "https://site.atlassian.net"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Tests
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe("OAuthOperations", () => {
|
|
76
|
+
// ---- exchangeCodeForTokens ----
|
|
77
|
+
|
|
78
|
+
describe("exchangeCodeForTokens", () => {
|
|
79
|
+
// Verifies grant_type, client_id, client_secret, code, redirect_uri are sent — the 5 required fields for token exchange
|
|
80
|
+
// Also confirms code_verifier is absent when PKCE not used
|
|
81
|
+
it.effect("sends correct body without code_verifier", () =>
|
|
82
|
+
Effect.gen(function*() {
|
|
83
|
+
const { capturedRequests, mockClient } = createMockHttpClient([
|
|
84
|
+
{ status: 200, body: validTokenBody }
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
const result = yield* exchangeCodeForTokens("auth-code", oauthConfig, { port: 9876 }).pipe(
|
|
88
|
+
provide(mockClient)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
expect(result.access_token).toBe("access-123")
|
|
92
|
+
expect(capturedRequests).toHaveLength(1)
|
|
93
|
+
const req = capturedRequests[0]!
|
|
94
|
+
expect(req.url).toContain("auth.atlassian.com/oauth/token")
|
|
95
|
+
|
|
96
|
+
// body is an Effect — decode from the captured request
|
|
97
|
+
const body = yield* req.body._tag === "Uint8Array"
|
|
98
|
+
? Effect.succeed(JSON.parse(new TextDecoder().decode(req.body.body)))
|
|
99
|
+
: Effect.succeed({})
|
|
100
|
+
|
|
101
|
+
expect(body.grant_type).toBe("authorization_code")
|
|
102
|
+
expect(body.client_id).toBe("cid")
|
|
103
|
+
expect(body.client_secret).toBe("csecret")
|
|
104
|
+
expect(body.code).toBe("auth-code")
|
|
105
|
+
expect(body.redirect_uri).toBe("http://localhost:9876/callback")
|
|
106
|
+
expect(body.code_verifier).toBeUndefined()
|
|
107
|
+
}))
|
|
108
|
+
|
|
109
|
+
// PKCE requires code_verifier on token exchange — server checks SHA256(verifier) == challenge from auth URL
|
|
110
|
+
it.effect("includes code_verifier when provided", () =>
|
|
111
|
+
Effect.gen(function*() {
|
|
112
|
+
const { capturedRequests, mockClient } = createMockHttpClient([
|
|
113
|
+
{ status: 200, body: validTokenBody }
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
yield* exchangeCodeForTokens("auth-code", oauthConfig, { port: 9876, codeVerifier: "my-verifier" }).pipe(
|
|
117
|
+
provide(mockClient)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const req = capturedRequests[0]!
|
|
121
|
+
const body = yield* req.body._tag === "Uint8Array"
|
|
122
|
+
? Effect.succeed(JSON.parse(new TextDecoder().decode(req.body.body)))
|
|
123
|
+
: Effect.succeed({})
|
|
124
|
+
|
|
125
|
+
expect(body.code_verifier).toBe("my-verifier")
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
// Token exchange failures (invalid code, expired code) must surface as OAuthError with step="token"
|
|
129
|
+
it.effect("returns OAuthError with step=token on HTTP 400", () =>
|
|
130
|
+
Effect.gen(function*() {
|
|
131
|
+
const { mockClient } = createMockHttpClient([
|
|
132
|
+
{ status: 400, body: { error: "invalid_grant" } }
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
const exit = yield* exchangeCodeForTokens("bad-code", oauthConfig, { port: 9876 }).pipe(
|
|
136
|
+
provide(mockClient),
|
|
137
|
+
Effect.flip
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
expect(exit).toBeInstanceOf(OAuthError)
|
|
141
|
+
expect(exit.step).toBe("token")
|
|
142
|
+
}))
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// ---- getAccessibleResources ----
|
|
146
|
+
|
|
147
|
+
describe("getAccessibleResources", () => {
|
|
148
|
+
// Verifies Bearer token is applied to Authorization header and response is decoded into typed array
|
|
149
|
+
it.effect("applies Bearer token and decodes response", () =>
|
|
150
|
+
Effect.gen(function*() {
|
|
151
|
+
const { capturedRequests, mockClient } = createMockHttpClient([
|
|
152
|
+
{
|
|
153
|
+
status: 200,
|
|
154
|
+
body: [
|
|
155
|
+
{ id: "cloud-1", name: "My Site", url: "https://site.atlassian.net", scopes: ["read:jira-work"] }
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
const result = yield* getAccessibleResources("tok-abc").pipe(provide(mockClient))
|
|
161
|
+
|
|
162
|
+
expect(result).toHaveLength(1)
|
|
163
|
+
expect(result[0]!.id).toBe("cloud-1")
|
|
164
|
+
|
|
165
|
+
const req = capturedRequests[0]!
|
|
166
|
+
expect(req.headers.authorization).toBe("Bearer tok-abc")
|
|
167
|
+
}))
|
|
168
|
+
|
|
169
|
+
// Resources failures must use step="resources" (not "authorize") so callers can distinguish from other phases
|
|
170
|
+
it.effect("returns OAuthError with step=resources on HTTP 401", () =>
|
|
171
|
+
Effect.gen(function*() {
|
|
172
|
+
const { mockClient } = createMockHttpClient([{ status: 401, body: {} }])
|
|
173
|
+
|
|
174
|
+
const exit = yield* getAccessibleResources("bad-tok").pipe(
|
|
175
|
+
provide(mockClient),
|
|
176
|
+
Effect.flip
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(exit).toBeInstanceOf(OAuthError)
|
|
180
|
+
expect(exit.step).toBe("resources")
|
|
181
|
+
}))
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// ---- getUserInfo ----
|
|
185
|
+
|
|
186
|
+
describe("getUserInfo", () => {
|
|
187
|
+
// Verifies Bearer token and correct decoding of account_id, name, email from /me endpoint
|
|
188
|
+
it.effect("applies Bearer token and decodes response", () =>
|
|
189
|
+
Effect.gen(function*() {
|
|
190
|
+
const { capturedRequests, mockClient } = createMockHttpClient([
|
|
191
|
+
{ status: 200, body: { account_id: "uid-1", name: "Alice", email: "a@b.com" } }
|
|
192
|
+
])
|
|
193
|
+
|
|
194
|
+
const result = yield* getUserInfo("tok-xyz").pipe(provide(mockClient))
|
|
195
|
+
|
|
196
|
+
expect(result.account_id).toBe("uid-1")
|
|
197
|
+
expect(result.name).toBe("Alice")
|
|
198
|
+
expect(result.email).toBe("a@b.com")
|
|
199
|
+
|
|
200
|
+
const req = capturedRequests[0]!
|
|
201
|
+
expect(req.headers.authorization).toBe("Bearer tok-xyz")
|
|
202
|
+
}))
|
|
203
|
+
|
|
204
|
+
// User info failures must use step="user-info" (not "authorize") for per-phase error handling
|
|
205
|
+
it.effect("returns OAuthError with step=user-info on HTTP 403", () =>
|
|
206
|
+
Effect.gen(function*() {
|
|
207
|
+
const { mockClient } = createMockHttpClient([{ status: 403, body: {} }])
|
|
208
|
+
|
|
209
|
+
const exit = yield* getUserInfo("bad-tok").pipe(
|
|
210
|
+
provide(mockClient),
|
|
211
|
+
Effect.flip
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(exit).toBeInstanceOf(OAuthError)
|
|
215
|
+
expect(exit.step).toBe("user-info")
|
|
216
|
+
}))
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// ---- refreshToken ----
|
|
220
|
+
|
|
221
|
+
describe("refreshToken", () => {
|
|
222
|
+
// Verifies grant_type=refresh_token is sent and returned token preserves cloud_id/site_url from original
|
|
223
|
+
it.effect("sends refresh_token and returns updated OAuthToken", () =>
|
|
224
|
+
Effect.gen(function*() {
|
|
225
|
+
const { capturedRequests, mockClient } = createMockHttpClient([
|
|
226
|
+
{ status: 200, body: { ...validTokenBody, access_token: "new-access", refresh_token: "new-refresh" } }
|
|
227
|
+
])
|
|
228
|
+
|
|
229
|
+
const result = yield* refreshToken(oauthToken, oauthConfig).pipe(provide(mockClient))
|
|
230
|
+
|
|
231
|
+
expect(result.access_token).toBe("new-access")
|
|
232
|
+
expect(result.refresh_token).toBe("new-refresh")
|
|
233
|
+
// preserves existing fields
|
|
234
|
+
expect(result.cloud_id).toBe("cloud-1")
|
|
235
|
+
expect(result.site_url).toBe("https://site.atlassian.net")
|
|
236
|
+
expect(result.expires_at).toBeGreaterThan(Date.now())
|
|
237
|
+
|
|
238
|
+
const req = capturedRequests[0]!
|
|
239
|
+
const body = yield* req.body._tag === "Uint8Array"
|
|
240
|
+
? Effect.succeed(JSON.parse(new TextDecoder().decode(req.body.body)))
|
|
241
|
+
: Effect.succeed({})
|
|
242
|
+
|
|
243
|
+
expect(body.grant_type).toBe("refresh_token")
|
|
244
|
+
expect(body.refresh_token).toBe("old-refresh")
|
|
245
|
+
}))
|
|
246
|
+
|
|
247
|
+
// Refresh failures (revoked token, expired refresh) must surface as step="refresh"
|
|
248
|
+
it.effect("returns OAuthError with step=refresh on HTTP 400", () =>
|
|
249
|
+
Effect.gen(function*() {
|
|
250
|
+
const { mockClient } = createMockHttpClient([
|
|
251
|
+
{ status: 400, body: { error: "invalid_grant" } }
|
|
252
|
+
])
|
|
253
|
+
|
|
254
|
+
const exit = yield* refreshToken(oauthToken, oauthConfig).pipe(
|
|
255
|
+
provide(mockClient),
|
|
256
|
+
Effect.flip
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
expect(exit).toBeInstanceOf(OAuthError)
|
|
260
|
+
expect(exit.step).toBe("refresh")
|
|
261
|
+
}))
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// ---- revokeToken ----
|
|
265
|
+
|
|
266
|
+
describe("revokeToken", () => {
|
|
267
|
+
// Verifies revoke hits the correct endpoint — logout must invalidate server-side token
|
|
268
|
+
it.effect("sends revoke request", () =>
|
|
269
|
+
Effect.gen(function*() {
|
|
270
|
+
const { capturedRequests, mockClient } = createMockHttpClient([
|
|
271
|
+
{ status: 200, body: {} }
|
|
272
|
+
])
|
|
273
|
+
|
|
274
|
+
yield* revokeToken(oauthToken, oauthConfig).pipe(provide(mockClient))
|
|
275
|
+
|
|
276
|
+
expect(capturedRequests).toHaveLength(1)
|
|
277
|
+
const req = capturedRequests[0]!
|
|
278
|
+
expect(req.url).toContain("auth.atlassian.com/oauth/revoke")
|
|
279
|
+
}))
|
|
280
|
+
|
|
281
|
+
// Revoke failures must surface as step="revoke" for caller error handling
|
|
282
|
+
it.effect("returns OAuthError with step=revoke on HTTP 400", () =>
|
|
283
|
+
Effect.gen(function*() {
|
|
284
|
+
const { mockClient } = createMockHttpClient([{ status: 400, body: {} }])
|
|
285
|
+
|
|
286
|
+
const exit = yield* revokeToken(oauthToken, oauthConfig).pipe(
|
|
287
|
+
provide(mockClient),
|
|
288
|
+
Effect.flip
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
expect(exit).toBeInstanceOf(OAuthError)
|
|
292
|
+
expect(exit.step).toBe("revoke")
|
|
293
|
+
}))
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// ---- buildOAuthToken ----
|
|
297
|
+
|
|
298
|
+
describe("buildOAuthToken", () => {
|
|
299
|
+
// Pure assembly function — verifies token response + site + user are merged correctly with computed expires_at
|
|
300
|
+
it("assembles OAuthToken from response + site + user", () => {
|
|
301
|
+
const token = buildOAuthToken(
|
|
302
|
+
validTokenBody,
|
|
303
|
+
{ id: "cloud-1", name: "Site", url: "https://site.atlassian.net", scopes: [] },
|
|
304
|
+
{ account_id: "uid-1", name: "Alice", email: "a@b.com" }
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
expect(token.access_token).toBe("access-123")
|
|
308
|
+
expect(token.refresh_token).toBe("refresh-456")
|
|
309
|
+
expect(token.cloud_id).toBe("cloud-1")
|
|
310
|
+
expect(token.site_url).toBe("https://site.atlassian.net")
|
|
311
|
+
expect(token.user).toEqual({ account_id: "uid-1", name: "Alice", email: "a@b.com" })
|
|
312
|
+
expect(token.expires_at).toBeGreaterThan(Date.now() - 1000)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
})
|
package/tsconfig.json
ADDED