agent-messenger 2.0.0 → 2.1.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/.claude-plugin/marketplace.json +14 -1
- package/.claude-plugin/plugin.json +4 -2
- package/README.md +33 -29
- package/dist/package.json +10 -2
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +3 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/platforms/webex/app-config.d.ts +7 -0
- package/dist/src/platforms/webex/app-config.d.ts.map +1 -0
- package/dist/src/platforms/webex/app-config.js +20 -0
- package/dist/src/platforms/webex/app-config.js.map +1 -0
- package/dist/src/platforms/webex/cli.d.ts +5 -0
- package/dist/src/platforms/webex/cli.d.ts.map +1 -0
- package/dist/src/platforms/webex/cli.js +32 -0
- package/dist/src/platforms/webex/cli.js.map +1 -0
- package/dist/src/platforms/webex/client.d.ts +45 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -0
- package/dist/src/platforms/webex/client.js +175 -0
- package/dist/src/platforms/webex/client.js.map +1 -0
- package/dist/src/platforms/webex/commands/auth.d.ts +15 -0
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/auth.js +124 -0
- package/dist/src/platforms/webex/commands/auth.js.map +1 -0
- package/dist/src/platforms/webex/commands/index.d.ts +6 -0
- package/dist/src/platforms/webex/commands/index.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/index.js +6 -0
- package/dist/src/platforms/webex/commands/index.js.map +1 -0
- package/dist/src/platforms/webex/commands/member.d.ts +7 -0
- package/dist/src/platforms/webex/commands/member.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/member.js +34 -0
- package/dist/src/platforms/webex/commands/member.js.map +1 -0
- package/dist/src/platforms/webex/commands/message.d.ts +26 -0
- package/dist/src/platforms/webex/commands/message.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/message.js +153 -0
- package/dist/src/platforms/webex/commands/message.js.map +1 -0
- package/dist/src/platforms/webex/commands/snapshot.d.ts +9 -0
- package/dist/src/platforms/webex/commands/snapshot.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/snapshot.js +72 -0
- package/dist/src/platforms/webex/commands/snapshot.js.map +1 -0
- package/dist/src/platforms/webex/commands/space.d.ts +11 -0
- package/dist/src/platforms/webex/commands/space.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/space.js +59 -0
- package/dist/src/platforms/webex/commands/space.js.map +1 -0
- package/dist/src/platforms/webex/credential-manager.d.ts +23 -0
- package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -0
- package/dist/src/platforms/webex/credential-manager.js +148 -0
- package/dist/src/platforms/webex/credential-manager.js.map +1 -0
- package/dist/src/platforms/webex/ensure-auth.d.ts +2 -0
- package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -0
- package/dist/src/platforms/webex/ensure-auth.js +20 -0
- package/dist/src/platforms/webex/ensure-auth.js.map +1 -0
- package/dist/src/platforms/webex/index.d.ts +6 -0
- package/dist/src/platforms/webex/index.d.ts.map +1 -0
- package/dist/src/platforms/webex/index.js +5 -0
- package/dist/src/platforms/webex/index.js.map +1 -0
- package/dist/src/platforms/webex/types.d.ts +124 -0
- package/dist/src/platforms/webex/types.d.ts.map +1 -0
- package/dist/src/platforms/webex/types.js +63 -0
- package/dist/src/platforms/webex/types.js.map +1 -0
- package/dist/src/tui/adapters/webex-adapter.d.ts +14 -0
- package/dist/src/tui/adapters/webex-adapter.d.ts.map +1 -0
- package/dist/src/tui/adapters/webex-adapter.js +79 -0
- package/dist/src/tui/adapters/webex-adapter.js.map +1 -0
- package/dist/src/tui/app.d.ts.map +1 -1
- package/dist/src/tui/app.js +2 -0
- package/dist/src/tui/app.js.map +1 -1
- package/docs/content/docs/cli/meta.json +1 -0
- package/docs/content/docs/cli/webex.mdx +291 -0
- package/docs/content/docs/sdk/meta.json +1 -1
- package/docs/content/docs/sdk/webex.mdx +260 -0
- package/docs/content/docs/tui.mdx +4 -3
- package/docs/src/app/page.tsx +2 -2
- package/package.json +10 -2
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +386 -0
- package/skills/agent-webex/references/authentication.md +318 -0
- package/skills/agent-webex/references/common-patterns.md +723 -0
- package/skills/agent-webex/templates/monitor-space.sh +165 -0
- package/skills/agent-webex/templates/post-message.sh +170 -0
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/cli.ts +4 -0
- package/src/platforms/webex/app-config.test.ts +98 -0
- package/src/platforms/webex/app-config.ts +31 -0
- package/src/platforms/webex/cli.test.ts +58 -0
- package/src/platforms/webex/cli.ts +39 -0
- package/src/platforms/webex/client.test.ts +429 -0
- package/src/platforms/webex/client.ts +247 -0
- package/src/platforms/webex/commands/auth.test.ts +222 -0
- package/src/platforms/webex/commands/auth.ts +180 -0
- package/src/platforms/webex/commands/index.ts +5 -0
- package/src/platforms/webex/commands/member.test.ts +103 -0
- package/src/platforms/webex/commands/member.ts +45 -0
- package/src/platforms/webex/commands/message.test.ts +231 -0
- package/src/platforms/webex/commands/message.ts +204 -0
- package/src/platforms/webex/commands/snapshot.test.ts +96 -0
- package/src/platforms/webex/commands/snapshot.ts +91 -0
- package/src/platforms/webex/commands/space.test.ts +206 -0
- package/src/platforms/webex/commands/space.ts +74 -0
- package/src/platforms/webex/credential-manager.test.ts +314 -0
- package/src/platforms/webex/credential-manager.ts +197 -0
- package/src/platforms/webex/ensure-auth.test.ts +85 -0
- package/src/platforms/webex/ensure-auth.ts +19 -0
- package/src/platforms/webex/index.test.ts +25 -0
- package/src/platforms/webex/index.ts +17 -0
- package/src/platforms/webex/types.test.ts +307 -0
- package/src/platforms/webex/types.ts +127 -0
- package/src/tui/adapters/webex-adapter.ts +103 -0
- package/src/tui/app.ts +2 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { WebexClient } from '../client'
|
|
4
|
+
import { WebexError } from '../types'
|
|
5
|
+
import { infoAction, listAction } from './space'
|
|
6
|
+
|
|
7
|
+
const mockSpaces = [
|
|
8
|
+
{
|
|
9
|
+
id: 'space-1',
|
|
10
|
+
title: 'General',
|
|
11
|
+
type: 'group' as const,
|
|
12
|
+
isLocked: false,
|
|
13
|
+
lastActivity: '2024-01-02T00:00:00.000Z',
|
|
14
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
15
|
+
creatorId: 'person-1',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'space-2',
|
|
19
|
+
title: 'Direct with Alice',
|
|
20
|
+
type: 'direct' as const,
|
|
21
|
+
isLocked: false,
|
|
22
|
+
lastActivity: '2024-01-03T00:00:00.000Z',
|
|
23
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
24
|
+
creatorId: 'person-2',
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const mockSpace = {
|
|
29
|
+
id: 'space-1',
|
|
30
|
+
title: 'General',
|
|
31
|
+
type: 'group' as const,
|
|
32
|
+
isLocked: false,
|
|
33
|
+
teamId: 'team-abc',
|
|
34
|
+
lastActivity: '2024-01-02T00:00:00.000Z',
|
|
35
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
36
|
+
creatorId: 'person-1',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let clientLoginSpy: ReturnType<typeof spyOn>
|
|
40
|
+
let clientListSpacesSpy: ReturnType<typeof spyOn>
|
|
41
|
+
let clientGetSpaceSpy: ReturnType<typeof spyOn>
|
|
42
|
+
let consoleLogSpy: ReturnType<typeof spyOn>
|
|
43
|
+
let consoleErrorSpy: ReturnType<typeof spyOn>
|
|
44
|
+
let processExitSpy: ReturnType<typeof spyOn>
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
clientLoginSpy = spyOn(WebexClient.prototype, 'login').mockResolvedValue(
|
|
48
|
+
new WebexClient() as InstanceType<typeof WebexClient>,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
clientListSpacesSpy = spyOn(WebexClient.prototype, 'listSpaces').mockResolvedValue(mockSpaces)
|
|
52
|
+
|
|
53
|
+
clientGetSpaceSpy = spyOn(WebexClient.prototype, 'getSpace').mockResolvedValue(mockSpace)
|
|
54
|
+
|
|
55
|
+
consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {})
|
|
56
|
+
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
|
57
|
+
|
|
58
|
+
processExitSpy = spyOn(process, 'exit').mockImplementation((_code?: number) => {
|
|
59
|
+
throw new Error(`process.exit(${_code})`)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
clientLoginSpy?.mockRestore()
|
|
65
|
+
clientListSpacesSpy?.mockRestore()
|
|
66
|
+
clientGetSpaceSpy?.mockRestore()
|
|
67
|
+
consoleLogSpy?.mockRestore()
|
|
68
|
+
consoleErrorSpy?.mockRestore()
|
|
69
|
+
processExitSpy?.mockRestore()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('listAction', () => {
|
|
73
|
+
test('calls listSpaces and outputs mapped array', async () => {
|
|
74
|
+
await listAction({})
|
|
75
|
+
|
|
76
|
+
expect(clientListSpacesSpy).toHaveBeenCalled()
|
|
77
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
78
|
+
JSON.stringify([
|
|
79
|
+
{
|
|
80
|
+
id: 'space-1',
|
|
81
|
+
title: 'General',
|
|
82
|
+
type: 'group',
|
|
83
|
+
lastActivity: '2024-01-02T00:00:00.000Z',
|
|
84
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'space-2',
|
|
88
|
+
title: 'Direct with Alice',
|
|
89
|
+
type: 'direct',
|
|
90
|
+
lastActivity: '2024-01-03T00:00:00.000Z',
|
|
91
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
92
|
+
},
|
|
93
|
+
]),
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('passes type and limit options to listSpaces', async () => {
|
|
98
|
+
await listAction({ type: 'group', limit: 10 })
|
|
99
|
+
|
|
100
|
+
expect(clientListSpacesSpy).toHaveBeenCalledWith({ type: 'group', max: 10 })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('passes undefined type and limit when not provided', async () => {
|
|
104
|
+
await listAction({})
|
|
105
|
+
|
|
106
|
+
expect(clientListSpacesSpy).toHaveBeenCalledWith({ type: undefined, max: undefined })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('outputs pretty-printed JSON when pretty is true', async () => {
|
|
110
|
+
await listAction({ pretty: true })
|
|
111
|
+
|
|
112
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
113
|
+
JSON.stringify(
|
|
114
|
+
[
|
|
115
|
+
{
|
|
116
|
+
id: 'space-1',
|
|
117
|
+
title: 'General',
|
|
118
|
+
type: 'group',
|
|
119
|
+
lastActivity: '2024-01-02T00:00:00.000Z',
|
|
120
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'space-2',
|
|
124
|
+
title: 'Direct with Alice',
|
|
125
|
+
type: 'direct',
|
|
126
|
+
lastActivity: '2024-01-03T00:00:00.000Z',
|
|
127
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
null,
|
|
131
|
+
2,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('not authenticated: outputs error and exits', async () => {
|
|
137
|
+
clientLoginSpy.mockRejectedValue(new WebexError('No Webex credentials found.', 'no_credentials'))
|
|
138
|
+
|
|
139
|
+
await expect(listAction({})).rejects.toThrow('process.exit(1)')
|
|
140
|
+
|
|
141
|
+
expect(clientListSpacesSpy).not.toHaveBeenCalled()
|
|
142
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No Webex credentials found'))
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('infoAction', () => {
|
|
147
|
+
test('calls getSpace with spaceId and outputs space details', async () => {
|
|
148
|
+
await infoAction('space-1', {})
|
|
149
|
+
|
|
150
|
+
expect(clientGetSpaceSpy).toHaveBeenCalledWith('space-1')
|
|
151
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
152
|
+
JSON.stringify({
|
|
153
|
+
id: 'space-1',
|
|
154
|
+
title: 'General',
|
|
155
|
+
type: 'group',
|
|
156
|
+
isLocked: false,
|
|
157
|
+
teamId: 'team-abc',
|
|
158
|
+
lastActivity: '2024-01-02T00:00:00.000Z',
|
|
159
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
160
|
+
creatorId: 'person-1',
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('outputs null for teamId when not present', async () => {
|
|
166
|
+
const spaceWithoutTeam = { ...mockSpace, teamId: undefined }
|
|
167
|
+
clientGetSpaceSpy.mockResolvedValue(spaceWithoutTeam)
|
|
168
|
+
|
|
169
|
+
await infoAction('space-1', {})
|
|
170
|
+
|
|
171
|
+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) as {
|
|
172
|
+
teamId: null
|
|
173
|
+
}
|
|
174
|
+
expect(output.teamId).toBeNull()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('outputs pretty-printed JSON when pretty is true', async () => {
|
|
178
|
+
await infoAction('space-1', { pretty: true })
|
|
179
|
+
|
|
180
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
181
|
+
JSON.stringify(
|
|
182
|
+
{
|
|
183
|
+
id: 'space-1',
|
|
184
|
+
title: 'General',
|
|
185
|
+
type: 'group',
|
|
186
|
+
isLocked: false,
|
|
187
|
+
teamId: 'team-abc',
|
|
188
|
+
lastActivity: '2024-01-02T00:00:00.000Z',
|
|
189
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
190
|
+
creatorId: 'person-1',
|
|
191
|
+
},
|
|
192
|
+
null,
|
|
193
|
+
2,
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('not authenticated: outputs error and exits', async () => {
|
|
199
|
+
clientLoginSpy.mockRejectedValue(new WebexError('No Webex credentials found.', 'no_credentials'))
|
|
200
|
+
|
|
201
|
+
await expect(infoAction('space-1', {})).rejects.toThrow('process.exit(1)')
|
|
202
|
+
|
|
203
|
+
expect(clientGetSpaceSpy).not.toHaveBeenCalled()
|
|
204
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No Webex credentials found'))
|
|
205
|
+
})
|
|
206
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
|
|
3
|
+
import { handleError } from '@/shared/utils/error-handler'
|
|
4
|
+
import { formatOutput } from '@/shared/utils/output'
|
|
5
|
+
|
|
6
|
+
import { WebexClient } from '../client'
|
|
7
|
+
|
|
8
|
+
export async function listAction(options: {
|
|
9
|
+
type?: string
|
|
10
|
+
limit?: number
|
|
11
|
+
pretty?: boolean
|
|
12
|
+
}): Promise<void> {
|
|
13
|
+
try {
|
|
14
|
+
const client = await new WebexClient().login()
|
|
15
|
+
const spaces = await client.listSpaces({ type: options.type, max: options.limit })
|
|
16
|
+
const output = spaces.map((s) => ({
|
|
17
|
+
id: s.id,
|
|
18
|
+
title: s.title,
|
|
19
|
+
type: s.type,
|
|
20
|
+
lastActivity: s.lastActivity,
|
|
21
|
+
created: s.created,
|
|
22
|
+
}))
|
|
23
|
+
console.log(formatOutput(output, options.pretty))
|
|
24
|
+
} catch (error) {
|
|
25
|
+
handleError(error as Error)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function infoAction(
|
|
30
|
+
spaceId: string,
|
|
31
|
+
options: { pretty?: boolean },
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
try {
|
|
34
|
+
const client = await new WebexClient().login()
|
|
35
|
+
const space = await client.getSpace(spaceId)
|
|
36
|
+
const output = {
|
|
37
|
+
id: space.id,
|
|
38
|
+
title: space.title,
|
|
39
|
+
type: space.type,
|
|
40
|
+
isLocked: space.isLocked,
|
|
41
|
+
teamId: space.teamId || null,
|
|
42
|
+
lastActivity: space.lastActivity,
|
|
43
|
+
created: space.created,
|
|
44
|
+
creatorId: space.creatorId,
|
|
45
|
+
}
|
|
46
|
+
console.log(formatOutput(output, options.pretty))
|
|
47
|
+
} catch (error) {
|
|
48
|
+
handleError(error as Error)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const spaceCommand = new Command('space')
|
|
53
|
+
.description('Space commands')
|
|
54
|
+
.addCommand(
|
|
55
|
+
new Command('list')
|
|
56
|
+
.description('List spaces')
|
|
57
|
+
.option('--type <type>', 'Filter by type (group or direct)')
|
|
58
|
+
.option('--limit <n>', 'Number of spaces to retrieve', '50')
|
|
59
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
60
|
+
.action((options) =>
|
|
61
|
+
listAction({
|
|
62
|
+
type: options.type,
|
|
63
|
+
limit: parseInt(options.limit, 10),
|
|
64
|
+
pretty: options.pretty,
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
.addCommand(
|
|
69
|
+
new Command('info')
|
|
70
|
+
.description('Get space details')
|
|
71
|
+
.argument('<space-id>', 'Space ID')
|
|
72
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
73
|
+
.action(infoAction),
|
|
74
|
+
)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { WebexCredentialManager } from './credential-manager'
|
|
7
|
+
|
|
8
|
+
describe('WebexCredentialManager', () => {
|
|
9
|
+
let tempDir: string
|
|
10
|
+
let credManager: WebexCredentialManager
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tempDir = await mkdtemp(join(tmpdir(), 'webex-cred-test-'))
|
|
14
|
+
credManager = new WebexCredentialManager(tempDir)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
19
|
+
mock.restore()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('loadConfig returns null when no file exists', async () => {
|
|
23
|
+
expect(await credManager.loadConfig()).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('saveConfig + loadConfig round trip with OAuth tokens', async () => {
|
|
27
|
+
const config = {
|
|
28
|
+
accessToken: 'test-access-token',
|
|
29
|
+
refreshToken: 'test-refresh-token',
|
|
30
|
+
expiresAt: Date.now() + 3600000,
|
|
31
|
+
}
|
|
32
|
+
await credManager.saveConfig(config)
|
|
33
|
+
const loaded = await credManager.loadConfig()
|
|
34
|
+
expect(loaded).toEqual(config)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('getToken returns accessToken when not expired', async () => {
|
|
38
|
+
await credManager.saveConfig({
|
|
39
|
+
accessToken: 'valid-token',
|
|
40
|
+
refreshToken: 'refresh',
|
|
41
|
+
expiresAt: Date.now() + 3600000, // 1 hour from now
|
|
42
|
+
})
|
|
43
|
+
const token = await credManager.getToken()
|
|
44
|
+
expect(token).toBe('valid-token')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('getToken returns null when expired and no refresh available', async () => {
|
|
48
|
+
await credManager.saveConfig({
|
|
49
|
+
accessToken: 'expired-token',
|
|
50
|
+
refreshToken: 'bad-refresh',
|
|
51
|
+
expiresAt: Date.now() - 1000, // Already expired
|
|
52
|
+
})
|
|
53
|
+
const token = await credManager.getToken()
|
|
54
|
+
expect(token).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('getToken auto-refreshes expired token', async () => {
|
|
58
|
+
const originalFetch = globalThis.fetch
|
|
59
|
+
globalThis.fetch = mock(() =>
|
|
60
|
+
Promise.resolve(
|
|
61
|
+
new Response(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
access_token: 'new-access-token',
|
|
64
|
+
refresh_token: 'new-refresh-token',
|
|
65
|
+
expires_in: 3600,
|
|
66
|
+
}),
|
|
67
|
+
{ status: 200 },
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
) as typeof fetch
|
|
71
|
+
|
|
72
|
+
await credManager.saveConfig({
|
|
73
|
+
accessToken: 'expired-token',
|
|
74
|
+
refreshToken: 'valid-refresh',
|
|
75
|
+
expiresAt: Date.now() - 1000,
|
|
76
|
+
clientId: 'test-client-id',
|
|
77
|
+
clientSecret: 'test-client-secret',
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const token = await credManager.getToken()
|
|
81
|
+
expect(token).toBe('new-access-token')
|
|
82
|
+
|
|
83
|
+
// Verify updated config was saved
|
|
84
|
+
const config = await credManager.loadConfig()
|
|
85
|
+
expect(config?.accessToken).toBe('new-access-token')
|
|
86
|
+
expect(config?.refreshToken).toBe('new-refresh-token')
|
|
87
|
+
|
|
88
|
+
globalThis.fetch = originalFetch
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('requestDeviceCode calls device authorize endpoint', async () => {
|
|
92
|
+
const originalFetch = globalThis.fetch
|
|
93
|
+
globalThis.fetch = mock(() =>
|
|
94
|
+
Promise.resolve(
|
|
95
|
+
new Response(
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
device_code: 'device-123',
|
|
98
|
+
user_code: '123456',
|
|
99
|
+
verification_uri: 'https://login-k.webex.com/verify',
|
|
100
|
+
verification_uri_complete: 'https://login-k.webex.com/verify?userCode=abc',
|
|
101
|
+
expires_in: 300,
|
|
102
|
+
interval: 2,
|
|
103
|
+
}),
|
|
104
|
+
{ status: 200 },
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
) as typeof fetch
|
|
108
|
+
|
|
109
|
+
const result = await credManager.requestDeviceCode('test-client-id')
|
|
110
|
+
expect(result.deviceCode).toBe('device-123')
|
|
111
|
+
expect(result.userCode).toBe('123456')
|
|
112
|
+
expect(result.verificationUri).toBe('https://login-k.webex.com/verify')
|
|
113
|
+
expect(result.interval).toBe(2)
|
|
114
|
+
|
|
115
|
+
globalThis.fetch = originalFetch
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('requestDeviceCode throws on failure', async () => {
|
|
119
|
+
const originalFetch = globalThis.fetch
|
|
120
|
+
globalThis.fetch = mock(() =>
|
|
121
|
+
Promise.resolve(new Response('{"error":"invalid_client"}', { status: 400 })),
|
|
122
|
+
) as typeof fetch
|
|
123
|
+
|
|
124
|
+
await expect(credManager.requestDeviceCode('test-client-id')).rejects.toThrow('Device authorization failed')
|
|
125
|
+
|
|
126
|
+
globalThis.fetch = originalFetch
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('pollDeviceToken polls until authorized', async () => {
|
|
130
|
+
const originalFetch = globalThis.fetch
|
|
131
|
+
let callCount = 0
|
|
132
|
+
globalThis.fetch = mock(() => {
|
|
133
|
+
callCount++
|
|
134
|
+
if (callCount <= 2) {
|
|
135
|
+
return Promise.resolve(new Response('', { status: 428 }))
|
|
136
|
+
}
|
|
137
|
+
return Promise.resolve(
|
|
138
|
+
new Response(
|
|
139
|
+
JSON.stringify({
|
|
140
|
+
access_token: 'device-access-token',
|
|
141
|
+
refresh_token: 'device-refresh-token',
|
|
142
|
+
expires_in: 3600,
|
|
143
|
+
}),
|
|
144
|
+
{ status: 200 },
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
}) as typeof fetch
|
|
148
|
+
|
|
149
|
+
const config = await credManager.pollDeviceToken('device-123', 0.01, 30, 'test-client-id', 'test-client-secret')
|
|
150
|
+
expect(config.accessToken).toBe('device-access-token')
|
|
151
|
+
expect(config.refreshToken).toBe('device-refresh-token')
|
|
152
|
+
|
|
153
|
+
globalThis.fetch = originalFetch
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('clearCredentials removes the file', async () => {
|
|
157
|
+
await credManager.saveConfig({
|
|
158
|
+
accessToken: 'token',
|
|
159
|
+
refreshToken: 'refresh',
|
|
160
|
+
expiresAt: Date.now() + 3600000,
|
|
161
|
+
})
|
|
162
|
+
await credManager.clearCredentials()
|
|
163
|
+
expect(await credManager.loadConfig()).toBeNull()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('clearCredentials does nothing when no file', async () => {
|
|
167
|
+
await credManager.clearCredentials() // Should not throw
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('credentials file has 0o600 permissions', async () => {
|
|
171
|
+
await credManager.saveConfig({
|
|
172
|
+
accessToken: 'token',
|
|
173
|
+
refreshToken: 'refresh',
|
|
174
|
+
expiresAt: Date.now() + 3600000,
|
|
175
|
+
})
|
|
176
|
+
const { stat } = await import('node:fs/promises')
|
|
177
|
+
const credPath = join(tempDir, 'webex-credentials.json')
|
|
178
|
+
const stats = await stat(credPath)
|
|
179
|
+
const mode = stats.mode & 0o777
|
|
180
|
+
expect(mode).toBe(0o600)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('pollDeviceToken with undefined clientSecret uses empty Basic auth', async () => {
|
|
184
|
+
const originalFetch = globalThis.fetch
|
|
185
|
+
let capturedAuth: string | null = null
|
|
186
|
+
globalThis.fetch = mock((url: string, init?: RequestInit) => {
|
|
187
|
+
capturedAuth = (init?.headers as Record<string, string>)?.Authorization ?? null
|
|
188
|
+
return Promise.resolve(
|
|
189
|
+
new Response(
|
|
190
|
+
JSON.stringify({
|
|
191
|
+
access_token: 'token',
|
|
192
|
+
refresh_token: 'refresh',
|
|
193
|
+
expires_in: 3600,
|
|
194
|
+
}),
|
|
195
|
+
{ status: 200 },
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
}) as typeof fetch
|
|
199
|
+
|
|
200
|
+
await credManager.pollDeviceToken('device-123', 0.01, 30, 'test-client-id')
|
|
201
|
+
expect(capturedAuth).toBe(`Basic ${btoa('test-client-id:')}`)
|
|
202
|
+
|
|
203
|
+
globalThis.fetch = originalFetch
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('pollDeviceToken does not auto-save config', async () => {
|
|
207
|
+
const originalFetch = globalThis.fetch
|
|
208
|
+
globalThis.fetch = mock(() =>
|
|
209
|
+
Promise.resolve(
|
|
210
|
+
new Response(
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
access_token: 'token',
|
|
213
|
+
refresh_token: 'refresh',
|
|
214
|
+
expires_in: 3600,
|
|
215
|
+
}),
|
|
216
|
+
{ status: 200 },
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
) as typeof fetch
|
|
220
|
+
|
|
221
|
+
await credManager.pollDeviceToken('device-123', 0.01, 30, 'test-client-id', 'test-client-secret')
|
|
222
|
+
|
|
223
|
+
const loaded = await credManager.loadConfig()
|
|
224
|
+
expect(loaded).toBeNull()
|
|
225
|
+
|
|
226
|
+
globalThis.fetch = originalFetch
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('getToken returns null when expired and no client credentials available', async () => {
|
|
230
|
+
await credManager.saveConfig({
|
|
231
|
+
accessToken: 'expired-token',
|
|
232
|
+
refreshToken: 'valid-refresh',
|
|
233
|
+
expiresAt: Date.now() - 1000,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const token = await credManager.getToken()
|
|
237
|
+
expect(token).toBeNull()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('getToken returns manual token without attempting refresh', async () => {
|
|
241
|
+
await credManager.saveConfig({
|
|
242
|
+
accessToken: 'my-bot-token',
|
|
243
|
+
refreshToken: '',
|
|
244
|
+
expiresAt: 0,
|
|
245
|
+
tokenType: 'manual',
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const token = await credManager.getToken()
|
|
249
|
+
expect(token).toBe('my-bot-token')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('getToken uses stored clientId/clientSecret for refresh', async () => {
|
|
253
|
+
const originalFetch = globalThis.fetch
|
|
254
|
+
globalThis.fetch = mock(() =>
|
|
255
|
+
Promise.resolve(
|
|
256
|
+
new Response(
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
access_token: 'refreshed-token',
|
|
259
|
+
refresh_token: 'new-refresh',
|
|
260
|
+
expires_in: 3600,
|
|
261
|
+
}),
|
|
262
|
+
{ status: 200 },
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
) as typeof fetch
|
|
266
|
+
|
|
267
|
+
await credManager.saveConfig({
|
|
268
|
+
accessToken: 'expired-token',
|
|
269
|
+
refreshToken: 'valid-refresh',
|
|
270
|
+
expiresAt: Date.now() - 1000,
|
|
271
|
+
clientId: 'stored-client-id',
|
|
272
|
+
clientSecret: 'stored-client-secret',
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const token = await credManager.getToken()
|
|
276
|
+
expect(token).toBe('refreshed-token')
|
|
277
|
+
|
|
278
|
+
globalThis.fetch = originalFetch
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('saveConfig persists clientId and clientSecret', async () => {
|
|
282
|
+
await credManager.saveConfig({
|
|
283
|
+
accessToken: 'token',
|
|
284
|
+
refreshToken: 'refresh',
|
|
285
|
+
expiresAt: Date.now() + 3600000,
|
|
286
|
+
clientId: 'my-client-id',
|
|
287
|
+
clientSecret: 'my-client-secret',
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
const loaded = await credManager.loadConfig()
|
|
291
|
+
expect(loaded?.clientId).toBe('my-client-id')
|
|
292
|
+
expect(loaded?.clientSecret).toBe('my-client-secret')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('loadConfig backward compat — old config without clientId/clientSecret', async () => {
|
|
296
|
+
// Write raw JSON without clientId/clientSecret fields
|
|
297
|
+
const credPath = join(tempDir, 'webex-credentials.json')
|
|
298
|
+
await writeFile(
|
|
299
|
+
credPath,
|
|
300
|
+
JSON.stringify({
|
|
301
|
+
accessToken: 'old-token',
|
|
302
|
+
refreshToken: 'old-refresh',
|
|
303
|
+
expiresAt: Date.now() + 3600000,
|
|
304
|
+
}),
|
|
305
|
+
'utf-8',
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
const loaded = await credManager.loadConfig()
|
|
309
|
+
expect(loaded).not.toBeNull()
|
|
310
|
+
expect(loaded?.accessToken).toBe('old-token')
|
|
311
|
+
expect(loaded?.clientId).toBeUndefined()
|
|
312
|
+
expect(loaded?.clientSecret).toBeUndefined()
|
|
313
|
+
})
|
|
314
|
+
})
|