@knpkv/atlassian-common 0.3.0 → 1.0.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 +23 -0
- package/README.md +29 -13
- package/dist/Hash.d.ts +4 -4
- package/dist/Hash.js +6 -6
- package/dist/Hash.js.map +1 -1
- package/dist/auth/OAuthEndpoints.d.ts +6 -4
- package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
- package/dist/auth/OAuthEndpoints.js +12 -7
- package/dist/auth/OAuthEndpoints.js.map +1 -1
- package/dist/auth/OAuthErrors.d.ts.map +1 -1
- package/dist/auth/OAuthErrors.js +4 -3
- package/dist/auth/OAuthErrors.js.map +1 -1
- package/dist/auth/OAuthOperations.d.ts +0 -20
- package/dist/auth/OAuthOperations.d.ts.map +1 -1
- package/dist/auth/OAuthOperations.js +3 -1
- package/dist/auth/OAuthOperations.js.map +1 -1
- package/dist/auth/uuid.d.ts +5 -3
- package/dist/auth/uuid.d.ts.map +1 -1
- package/dist/auth/uuid.js +7 -3
- package/dist/auth/uuid.js.map +1 -1
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +58 -0
- package/dist/bin.js.map +1 -0
- package/dist/config/AuthProfiles.d.ts +342 -0
- package/dist/config/AuthProfiles.d.ts.map +1 -0
- package/dist/config/AuthProfiles.js +275 -0
- package/dist/config/AuthProfiles.js.map +1 -0
- package/dist/config/ConfigPaths.d.ts +6 -0
- package/dist/config/ConfigPaths.d.ts.map +1 -1
- package/dist/config/ConfigPaths.js +10 -0
- package/dist/config/ConfigPaths.js.map +1 -1
- package/dist/config/ProfileManager.d.ts +92 -0
- package/dist/config/ProfileManager.d.ts.map +1 -0
- package/dist/config/ProfileManager.js +175 -0
- package/dist/config/ProfileManager.js.map +1 -0
- package/dist/config/index.d.ts +3 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +5 -1
- package/dist/config/index.js.map +1 -1
- package/package.json +11 -3
- package/src/Hash.ts +6 -6
- package/src/auth/OAuthEndpoints.ts +18 -11
- package/src/auth/OAuthErrors.ts +4 -3
- package/src/auth/OAuthOperations.ts +3 -1
- package/src/auth/uuid.ts +9 -3
- package/src/bin.ts +115 -0
- package/src/config/AuthProfiles.ts +384 -0
- package/src/config/ConfigPaths.ts +14 -0
- package/src/config/ProfileManager.ts +268 -0
- package/src/config/index.ts +42 -0
- package/test/AuthProfiles.test.ts +131 -0
- package/test/OAuthEndpoints.test.ts +20 -15
- package/test/OAuthOperations.test.ts +5 -1
- package/test/ProfileManager.test.ts +188 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { ConfigProvider, Effect, Layer } from "effect"
|
|
3
|
+
import * as FileSystem from "effect/FileSystem"
|
|
4
|
+
import * as Path from "effect/Path"
|
|
5
|
+
import { SystemError } from "effect/PlatformError"
|
|
6
|
+
import {
|
|
7
|
+
type AtlassianToolDefinition,
|
|
8
|
+
HomeDirectoryLive,
|
|
9
|
+
inspectAllToolProfiles,
|
|
10
|
+
migrateLegacyProfiles,
|
|
11
|
+
MissingOAuthConfigError,
|
|
12
|
+
missingScopes,
|
|
13
|
+
type OAuthToken,
|
|
14
|
+
ProfileNotFoundError,
|
|
15
|
+
refreshActiveProfiles,
|
|
16
|
+
saveProfileToken,
|
|
17
|
+
useProfileForAllTools
|
|
18
|
+
} from "../src/config/index.js"
|
|
19
|
+
|
|
20
|
+
const TEST_HOME = "/tmp/atlassian-common-manager-test"
|
|
21
|
+
const tools: ReadonlyArray<AtlassianToolDefinition> = [
|
|
22
|
+
{ toolName: "tool-a", label: "Tool A", loginHint: "tool-a login", requiredScopes: ["read:me", "offline_access"] },
|
|
23
|
+
{ toolName: "tool-b", label: "Tool B", loginHint: "tool-b login", requiredScopes: ["read:me", "write:jira-work"] }
|
|
24
|
+
]
|
|
25
|
+
const sharedStoreTools: ReadonlyArray<AtlassianToolDefinition> = [
|
|
26
|
+
tools[0]!,
|
|
27
|
+
{
|
|
28
|
+
toolName: "tool-c",
|
|
29
|
+
authStoreName: "tool-a",
|
|
30
|
+
label: "Tool C",
|
|
31
|
+
loginHint: "tool-c login",
|
|
32
|
+
requiredScopes: ["read:me"]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const fsError = (method: string) =>
|
|
37
|
+
Effect.fail(new SystemError({ _tag: "NotFound", module: "FileSystem", method, description: "mock not found" }))
|
|
38
|
+
|
|
39
|
+
const makeToken = (n: number, scope = "read:me offline_access", expiresAt = Date.now() + 60_000): OAuthToken => ({
|
|
40
|
+
access_token: `access-${n}`,
|
|
41
|
+
refresh_token: `refresh-${n}`,
|
|
42
|
+
expires_at: expiresAt,
|
|
43
|
+
scope,
|
|
44
|
+
cloud_id: `cloud-${n}`,
|
|
45
|
+
site_url: `https://site-${n}.atlassian.net`,
|
|
46
|
+
user: { account_id: `account-${n}`, name: `User ${n}`, email: `user-${n}@example.com` }
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const makeMockFS = () => {
|
|
50
|
+
const store: Record<string, string> = {}
|
|
51
|
+
const partial: Partial<FileSystem.FileSystem> = {
|
|
52
|
+
exists: (path) => Effect.succeed(path in store),
|
|
53
|
+
readFileString: (path) => path in store ? Effect.succeed(store[path]!) : fsError("readFileString"),
|
|
54
|
+
writeFileString: (path: string, content: string) => {
|
|
55
|
+
store[path] = content
|
|
56
|
+
return Effect.void
|
|
57
|
+
},
|
|
58
|
+
makeDirectory: () => Effect.void,
|
|
59
|
+
chmod: () => Effect.void,
|
|
60
|
+
remove: (path: string) => {
|
|
61
|
+
delete store[path]
|
|
62
|
+
return Effect.void
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
store,
|
|
67
|
+
layer: Layer.succeed(FileSystem.FileSystem, FileSystem.FileSystem.of(partial as FileSystem.FileSystem))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ConfigProviderLive = ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: TEST_HOME } }))
|
|
72
|
+
|
|
73
|
+
const run = async <A>(effect: Effect.Effect<A, unknown, FileSystem.FileSystem | Path.Path>) => {
|
|
74
|
+
const mock = makeMockFS()
|
|
75
|
+
const layer = Layer.mergeAll(mock.layer, Path.layer, HomeDirectoryLive, ConfigProviderLive)
|
|
76
|
+
const result = await Effect.runPromise(effect.pipe(Effect.provide(layer)))
|
|
77
|
+
return { result, store: mock.store }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("ProfileManager", () => {
|
|
81
|
+
it("reports missing scopes from a token", () => {
|
|
82
|
+
expect(missingScopes(makeToken(1), ["read:me", "write:jira-work"])).toEqual(["write:jira-work"])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("inspects active profile usage and scope status per tool", async () => {
|
|
86
|
+
const { result } = await run(
|
|
87
|
+
Effect.gen(function*() {
|
|
88
|
+
yield* saveProfileToken("tool-a", makeToken(1))
|
|
89
|
+
yield* saveProfileToken("tool-b", makeToken(2))
|
|
90
|
+
return yield* inspectAllToolProfiles(tools)
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(result.map((status) => status.activeProfile?.id)).toEqual(["account-1@cloud-1", "account-2@cloud-2"])
|
|
95
|
+
expect(result[0]!.missingScopes).toEqual([])
|
|
96
|
+
expect(result[1]!.missingScopes).toEqual(["write:jira-work"])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("switches matching profiles without copying tokens between auth stores", async () => {
|
|
100
|
+
const { result, store } = await run(
|
|
101
|
+
Effect.gen(function*() {
|
|
102
|
+
yield* saveProfileToken("tool-a", makeToken(1))
|
|
103
|
+
yield* saveProfileToken("tool-b", makeToken(2, "read:me write:jira-work"))
|
|
104
|
+
return yield* useProfileForAllTools("https://site-2.atlassian.net", tools)
|
|
105
|
+
})
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
expect(result.map((status) => status.activeProfile?.id)).toEqual(["account-1@cloud-1", "account-2@cloud-2"])
|
|
109
|
+
expect(JSON.parse(store[`${TEST_HOME}/.config/atlassian/tool-a/auth.json`]!)["access_token"]).toBe("access-1")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("reports multiple tools that share one auth store", async () => {
|
|
113
|
+
const { result } = await run(
|
|
114
|
+
Effect.gen(function*() {
|
|
115
|
+
yield* saveProfileToken("tool-a", makeToken(1))
|
|
116
|
+
return yield* inspectAllToolProfiles(sharedStoreTools)
|
|
117
|
+
})
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(result.map((status) => status.authStoreName)).toEqual(["tool-a", "tool-a"])
|
|
121
|
+
expect(result.map((status) => status.activeProfile?.id)).toEqual(["account-1@cloud-1", "account-1@cloud-1"])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("migrates XDG legacy auth.json tokens into profile stores", async () => {
|
|
125
|
+
const token = makeToken(3)
|
|
126
|
+
const { result, store } = await run(
|
|
127
|
+
Effect.gen(function*() {
|
|
128
|
+
yield* FileSystem.FileSystem.pipe(
|
|
129
|
+
Effect.flatMap((fs) =>
|
|
130
|
+
fs.writeFileString(`${TEST_HOME}/.config/atlassian/tool-a/auth.json`, JSON.stringify(token, null, 2))
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return yield* migrateLegacyProfiles(tools)
|
|
134
|
+
})
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
expect(result[0]!.activeProfile?.id).toBe("account-3@cloud-3")
|
|
138
|
+
expect(JSON.parse(store[`${TEST_HOME}/.config/atlassian/tool-a/profiles.json`]!)["profiles"]).toHaveLength(1)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("migrates tool-specific legacy auth.json tokens into profile stores", async () => {
|
|
142
|
+
const token = makeToken(4)
|
|
143
|
+
const legacyTools: ReadonlyArray<AtlassianToolDefinition> = [
|
|
144
|
+
{
|
|
145
|
+
toolName: "legacy-tool",
|
|
146
|
+
legacyAuthPath: [".legacy-tool", "auth.json"],
|
|
147
|
+
label: "Legacy Tool",
|
|
148
|
+
loginHint: "legacy login",
|
|
149
|
+
requiredScopes: ["read:me"]
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
const { result, store } = await run(
|
|
153
|
+
Effect.gen(function*() {
|
|
154
|
+
yield* FileSystem.FileSystem.pipe(
|
|
155
|
+
Effect.flatMap((fs) =>
|
|
156
|
+
fs.writeFileString(`${TEST_HOME}/.legacy-tool/auth.json`, JSON.stringify(token, null, 2))
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return yield* migrateLegacyProfiles(legacyTools)
|
|
160
|
+
})
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
expect(result[0]!.activeProfile?.id).toBe("account-4@cloud-4")
|
|
164
|
+
expect(JSON.parse(store[`${TEST_HOME}/.config/atlassian/legacy-tool/profiles.json`]!)["profiles"]).toHaveLength(1)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("fails when selecting a missing profile", async () => {
|
|
168
|
+
await expect(
|
|
169
|
+
run(
|
|
170
|
+
Effect.gen(function*() {
|
|
171
|
+
yield* saveProfileToken("tool-a", makeToken(1))
|
|
172
|
+
return yield* useProfileForAllTools("missing", tools)
|
|
173
|
+
})
|
|
174
|
+
)
|
|
175
|
+
).rejects.toBeInstanceOf(ProfileNotFoundError)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("fails expired-token refresh when OAuth config is missing", async () => {
|
|
179
|
+
await expect(
|
|
180
|
+
run(
|
|
181
|
+
Effect.gen(function*() {
|
|
182
|
+
yield* saveProfileToken("tool-a", makeToken(1, "read:me offline_access", Date.now() - 1_000))
|
|
183
|
+
return yield* refreshActiveProfiles([tools[0]!])
|
|
184
|
+
})
|
|
185
|
+
)
|
|
186
|
+
).rejects.toBeInstanceOf(MissingOAuthConfigError)
|
|
187
|
+
})
|
|
188
|
+
})
|