switchroom 0.8.1 → 0.10.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/README.md +49 -57
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +285 -45
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +15931 -12778
- package/dist/host-control/main.js +582 -43
- package/dist/vault/approvals/kernel-server.js +276 -47
- package/dist/vault/broker/server.js +333 -69
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +3 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/default/CLAUDE.md +10 -0
- package/profiles/default/CLAUDE.md.hbs +16 -0
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +67 -15
- package/skills/switchroom-status/SKILL.md +26 -1
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +22 -36
- package/telegram-plugin/gateway/boot-probes.ts +3 -3
- package/telegram-plugin/gateway/gateway.ts +313 -798
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-probes.test.ts +11 -4
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/uat/SETUP.md +31 -1
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/dist/foreman/foreman.js +0 -31358
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for telegram-plugin/foreman/foreman-handlers.ts
|
|
3
|
-
*
|
|
4
|
-
* Tests the real handler implementations imported from foreman-handlers.ts,
|
|
5
|
-
* using injected mocks for execFileSync and switchroomExecJson rather than
|
|
6
|
-
* re-implementing the logic locally.
|
|
7
|
-
*
|
|
8
|
-
* Covers:
|
|
9
|
-
* - assertSafeAgentName: valid and invalid agent names
|
|
10
|
-
* - handleLogsCommand: agent name validation, --tail parsing, execFileSync args,
|
|
11
|
-
* bad-name rejection, empty output, paginated output
|
|
12
|
-
* - buildFleetSummary: calls switchroomExecJson(['agent', 'list']),
|
|
13
|
-
* formats HTML output correctly
|
|
14
|
-
* - private-chat guard: middleware rejects non-private chats
|
|
15
|
-
* - parseTailN: tail-N parsing rules
|
|
16
|
-
* - chunkText: pagination boundary logic
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
20
|
-
import {
|
|
21
|
-
assertSafeAgentName,
|
|
22
|
-
handleLogsCommand,
|
|
23
|
-
buildFleetSummary,
|
|
24
|
-
parseTailN,
|
|
25
|
-
chunkText,
|
|
26
|
-
type SwitchroomExecJsonFn,
|
|
27
|
-
} from '../foreman/foreman-handlers.js'
|
|
28
|
-
import { isAllowedSender } from '../shared/bot-runtime.js'
|
|
29
|
-
import type { Context } from 'grammy'
|
|
30
|
-
|
|
31
|
-
// ─── assertSafeAgentName ──────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
describe('foreman-handlers: assertSafeAgentName', () => {
|
|
34
|
-
it('accepts simple lowercase names', () => {
|
|
35
|
-
expect(() => assertSafeAgentName('gymbro')).not.toThrow()
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('accepts names with hyphens', () => {
|
|
39
|
-
expect(() => assertSafeAgentName('my-agent')).not.toThrow()
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('accepts names with underscores', () => {
|
|
43
|
-
expect(() => assertSafeAgentName('my_agent')).not.toThrow()
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('accepts lowercase names with digits', () => {
|
|
47
|
-
expect(() => assertSafeAgentName('agent1')).not.toThrow()
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('rejects uppercase names', () => {
|
|
51
|
-
expect(() => assertSafeAgentName('Agent1')).toThrow('invalid agent name')
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('accepts 51-char name (Telegram callback_data max)', () => {
|
|
55
|
-
expect(() => assertSafeAgentName('a'.repeat(51))).not.toThrow()
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('rejects 52-char name (exceeds callback_data budget)', () => {
|
|
59
|
-
expect(() => assertSafeAgentName('a'.repeat(52))).toThrow('invalid agent name')
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('rejects empty name', () => {
|
|
63
|
-
expect(() => assertSafeAgentName('')).toThrow('invalid agent name')
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('rejects name with space', () => {
|
|
67
|
-
expect(() => assertSafeAgentName('my agent')).toThrow('invalid agent name')
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('rejects name with semicolon (shell injection attempt)', () => {
|
|
71
|
-
expect(() => assertSafeAgentName('agent; rm -rf /')).toThrow('invalid agent name')
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('rejects name with dollar sign', () => {
|
|
75
|
-
expect(() => assertSafeAgentName('agent$(evil)')).toThrow('invalid agent name')
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('rejects path traversal', () => {
|
|
79
|
-
expect(() => assertSafeAgentName('../etc/passwd')).toThrow('invalid agent name')
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('rejects name with colon', () => {
|
|
83
|
-
expect(() => assertSafeAgentName('agent:bad')).toThrow('invalid agent name')
|
|
84
|
-
})
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
// ─── parseTailN ─────────────────────────────────────────────────────────
|
|
88
|
-
|
|
89
|
-
describe('foreman-handlers: parseTailN', () => {
|
|
90
|
-
it('defaults to 50 when no --tail', () => {
|
|
91
|
-
expect(parseTailN(['gymbro'])).toBe(50)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it('parses explicit --tail N', () => {
|
|
95
|
-
expect(parseTailN(['gymbro', '--tail', '100'])).toBe(100)
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
it('clamps to 500 max', () => {
|
|
99
|
-
expect(parseTailN(['gymbro', '--tail', '9999'])).toBe(500)
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('ignores --tail without value', () => {
|
|
103
|
-
expect(parseTailN(['gymbro', '--tail'])).toBe(50)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('ignores non-numeric --tail value', () => {
|
|
107
|
-
expect(parseTailN(['gymbro', '--tail', 'abc'])).toBe(50)
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('ignores zero --tail value', () => {
|
|
111
|
-
expect(parseTailN(['gymbro', '--tail', '0'])).toBe(50)
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it('ignores negative --tail value', () => {
|
|
115
|
-
expect(parseTailN(['gymbro', '--tail', '-10'])).toBe(50)
|
|
116
|
-
})
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
// ─── chunkText ───────────────────────────────────────────────────────────
|
|
120
|
-
|
|
121
|
-
describe('foreman-handlers: chunkText', () => {
|
|
122
|
-
it('returns single chunk when under limit', () => {
|
|
123
|
-
const text = 'x'.repeat(3800)
|
|
124
|
-
expect(chunkText(text, 3800)).toHaveLength(1)
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
it('splits into two chunks when over limit', () => {
|
|
128
|
-
const text = 'x'.repeat(4097)
|
|
129
|
-
const chunks = chunkText(text, 4096)
|
|
130
|
-
expect(chunks).toHaveLength(2)
|
|
131
|
-
expect(chunks[0]).toHaveLength(4096)
|
|
132
|
-
expect(chunks[1]).toHaveLength(1)
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
it('all chunks reconstruct the original', () => {
|
|
136
|
-
const text = 'abcdefgh'.repeat(1000)
|
|
137
|
-
const chunks = chunkText(text, 3000)
|
|
138
|
-
expect(chunks.join('')).toBe(text)
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
it('handles exactly limit-length text', () => {
|
|
142
|
-
const text = 'x'.repeat(4096)
|
|
143
|
-
expect(chunkText(text, 4096)).toHaveLength(1)
|
|
144
|
-
})
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
// ─── handleLogsCommand ───────────────────────────────────────────────────
|
|
148
|
-
|
|
149
|
-
describe('foreman-handlers: handleLogsCommand — agent name validation', () => {
|
|
150
|
-
it('returns usage when no args', () => {
|
|
151
|
-
const result = handleLogsCommand('')
|
|
152
|
-
expect(result.replies).toHaveLength(1)
|
|
153
|
-
expect(result.replies[0].text).toContain('Usage')
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
it('rejects a bad agent name and returns Invalid agent name', () => {
|
|
157
|
-
const execFile = vi.fn()
|
|
158
|
-
const result = handleLogsCommand('agent; rm -rf /', execFile as never)
|
|
159
|
-
expect(result.replies[0].text).toBe('Invalid agent name.')
|
|
160
|
-
expect(execFile).not.toHaveBeenCalled()
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
it('rejects agent name with colon (callback_data delimiter)', () => {
|
|
164
|
-
const execFile = vi.fn()
|
|
165
|
-
const result = handleLogsCommand('bad:name', execFile as never)
|
|
166
|
-
expect(result.replies[0].text).toBe('Invalid agent name.')
|
|
167
|
-
expect(execFile).not.toHaveBeenCalled()
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('accepts a valid agent name with hyphens', () => {
|
|
171
|
-
const execFile = vi.fn().mockReturnValue('log line 1\nlog line 2\n')
|
|
172
|
-
const result = handleLogsCommand('my-agent', execFile as never)
|
|
173
|
-
expect(execFile).toHaveBeenCalled()
|
|
174
|
-
expect(result.replies[0].text).toContain('log line 1')
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
it('accepts a valid agent name with underscores', () => {
|
|
178
|
-
const execFile = vi.fn().mockReturnValue('some log\n')
|
|
179
|
-
const result = handleLogsCommand('my_agent', execFile as never)
|
|
180
|
-
expect(execFile).toHaveBeenCalled()
|
|
181
|
-
})
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
describe('foreman-handlers: handleLogsCommand — execFileSync args', () => {
|
|
185
|
-
it('calls journalctl with correct argv array (no shell)', () => {
|
|
186
|
-
const execFile = vi.fn().mockReturnValue('line1\n')
|
|
187
|
-
handleLogsCommand('gymbro', execFile as never)
|
|
188
|
-
|
|
189
|
-
expect(execFile).toHaveBeenCalledOnce()
|
|
190
|
-
const [cmd, args] = execFile.mock.calls[0] as [string, string[]]
|
|
191
|
-
expect(cmd).toBe('journalctl')
|
|
192
|
-
expect(args).toContain('--user')
|
|
193
|
-
expect(args).toContain('-u')
|
|
194
|
-
expect(args).toContain('switchroom-gymbro')
|
|
195
|
-
expect(args).toContain('-n')
|
|
196
|
-
expect(args).toContain('50') // default tail
|
|
197
|
-
expect(args).toContain('--no-pager')
|
|
198
|
-
// Must NOT be a shell string — the second arg must be an array
|
|
199
|
-
expect(Array.isArray(args)).toBe(true)
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
it('passes --tail N to journalctl -n', () => {
|
|
203
|
-
const execFile = vi.fn().mockReturnValue('line\n')
|
|
204
|
-
handleLogsCommand('gymbro --tail 200', execFile as never)
|
|
205
|
-
|
|
206
|
-
const [, args] = execFile.mock.calls[0] as [string, string[]]
|
|
207
|
-
const nIdx = args.indexOf('-n')
|
|
208
|
-
expect(nIdx).toBeGreaterThan(-1)
|
|
209
|
-
expect(args[nIdx + 1]).toBe('200')
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
it('clamps --tail above 500 to 500', () => {
|
|
213
|
-
const execFile = vi.fn().mockReturnValue('line\n')
|
|
214
|
-
handleLogsCommand('gymbro --tail 9999', execFile as never)
|
|
215
|
-
|
|
216
|
-
const [, args] = execFile.mock.calls[0] as [string, string[]]
|
|
217
|
-
const nIdx = args.indexOf('-n')
|
|
218
|
-
expect(args[nIdx + 1]).toBe('500')
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
it('unit name includes agent name', () => {
|
|
222
|
-
const execFile = vi.fn().mockReturnValue('line\n')
|
|
223
|
-
handleLogsCommand('my-agent', execFile as never)
|
|
224
|
-
|
|
225
|
-
const [, args] = execFile.mock.calls[0] as [string, string[]]
|
|
226
|
-
const uIdx = args.indexOf('-u')
|
|
227
|
-
expect(args[uIdx + 1]).toBe('switchroom-my-agent')
|
|
228
|
-
})
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
describe('foreman-handlers: handleLogsCommand — output handling', () => {
|
|
232
|
-
it('returns empty-log message when journalctl returns blank', () => {
|
|
233
|
-
const execFile = vi.fn().mockReturnValue(' \n')
|
|
234
|
-
const result = handleLogsCommand('gymbro', execFile as never)
|
|
235
|
-
expect(result.replies[0].text).toContain('No logs found')
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
it('returns error message when execFileSync throws', () => {
|
|
239
|
-
const execFile = vi.fn().mockImplementation(() => {
|
|
240
|
-
throw Object.assign(new Error('no such unit'), { stderr: 'Unit not found.' })
|
|
241
|
-
})
|
|
242
|
-
const result = handleLogsCommand('gymbro', execFile as never)
|
|
243
|
-
expect(result.replies[0].text).toContain('logs failed for')
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
it('returns paginated replies for large output', () => {
|
|
247
|
-
const bigOutput = 'x'.repeat(4000) // > 3 KB
|
|
248
|
-
const execFile = vi.fn().mockReturnValue(bigOutput)
|
|
249
|
-
const result = handleLogsCommand('gymbro', execFile as never)
|
|
250
|
-
// Should be chunked into multiple replies
|
|
251
|
-
expect(result.replies.length).toBeGreaterThan(1)
|
|
252
|
-
})
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
// ─── buildFleetSummary ────────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
describe('foreman-handlers: buildFleetSummary — calls switchroomExecJson correctly', () => {
|
|
258
|
-
it('calls execJson with ["agent", "list"]', () => {
|
|
259
|
-
const mockExecJson = vi.fn().mockReturnValue({
|
|
260
|
-
agents: [{ name: 'gymbro', status: 'active', uptime: '1h' }],
|
|
261
|
-
}) as SwitchroomExecJsonFn
|
|
262
|
-
buildFleetSummary(mockExecJson)
|
|
263
|
-
expect(mockExecJson).toHaveBeenCalledWith(['agent', 'list'])
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
it('renders fleet HTML with agent name and status', () => {
|
|
267
|
-
const mockExecJson = vi.fn().mockReturnValue({
|
|
268
|
-
agents: [{ name: 'gymbro', status: 'active', uptime: '2h' }],
|
|
269
|
-
}) as SwitchroomExecJsonFn
|
|
270
|
-
const html = buildFleetSummary(mockExecJson)
|
|
271
|
-
expect(html).toContain('gymbro')
|
|
272
|
-
expect(html).toContain('active')
|
|
273
|
-
expect(html).toContain('Fleet status')
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
it('returns empty message when no agents', () => {
|
|
277
|
-
const mockExecJson = vi.fn().mockReturnValue({ agents: [] }) as SwitchroomExecJsonFn
|
|
278
|
-
const html = buildFleetSummary(mockExecJson)
|
|
279
|
-
expect(html).toContain('No agents defined')
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
it('handles execJson throwing (CLI unreachable)', () => {
|
|
283
|
-
const mockExecJson = vi.fn().mockImplementation(() => {
|
|
284
|
-
throw new Error('switchroom CLI not found')
|
|
285
|
-
}) as SwitchroomExecJsonFn
|
|
286
|
-
const html = buildFleetSummary(mockExecJson)
|
|
287
|
-
expect(html).toContain('agent list failed')
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
it('escapes HTML-unsafe characters in agent names', () => {
|
|
291
|
-
const mockExecJson = vi.fn().mockReturnValue({
|
|
292
|
-
agents: [{ name: '<script>', status: 'active', uptime: '1h' }],
|
|
293
|
-
}) as SwitchroomExecJsonFn
|
|
294
|
-
const html = buildFleetSummary(mockExecJson)
|
|
295
|
-
expect(html).not.toContain('<script>')
|
|
296
|
-
expect(html).toContain('<script>')
|
|
297
|
-
})
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
// ─── private-chat guard ───────────────────────────────────────────────────
|
|
301
|
-
// The guard lives in foreman.ts middleware but we test the isAllowedSender
|
|
302
|
-
// helper that it composes with, plus verify the type check logic directly.
|
|
303
|
-
|
|
304
|
-
describe('foreman-handlers: private-chat guard (middleware logic)', () => {
|
|
305
|
-
function makeCtx(chatType: string | undefined, userId: number | undefined): Context {
|
|
306
|
-
return {
|
|
307
|
-
chat: chatType != null ? { type: chatType } : undefined,
|
|
308
|
-
from: userId != null ? { id: userId } : undefined,
|
|
309
|
-
} as unknown as Context
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
it('isAllowedSender allows configured user in private chat', () => {
|
|
313
|
-
const ctx = makeCtx('private', 42)
|
|
314
|
-
expect(isAllowedSender(ctx, ['42'])).toBe(true)
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
it('isAllowedSender blocks configured user in group chat', () => {
|
|
318
|
-
// The middleware checks chat.type !== 'private' BEFORE isAllowedSender.
|
|
319
|
-
// Here we just verify that the chat type is the signal to bail.
|
|
320
|
-
const ctx = makeCtx('group', 42)
|
|
321
|
-
// Simulate middleware: if not private, return early (do NOT call isAllowedSender)
|
|
322
|
-
const isPrivate = ctx.chat?.type === 'private'
|
|
323
|
-
expect(isPrivate).toBe(false)
|
|
324
|
-
// isAllowedSender itself would allow the user — the guard is in middleware
|
|
325
|
-
expect(isAllowedSender(ctx, ['42'])).toBe(true) // guard is upstream
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
it('isAllowedSender blocks unknown user in private chat', () => {
|
|
329
|
-
const ctx = makeCtx('private', 99)
|
|
330
|
-
expect(isAllowedSender(ctx, ['42'])).toBe(false)
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
it('isAllowedSender blocks when ctx.from is missing', () => {
|
|
334
|
-
const ctx = makeCtx('private', undefined)
|
|
335
|
-
expect(isAllowedSender(ctx, ['42'])).toBe(false)
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
it('group chat is detected as non-private (middleware would bail)', () => {
|
|
339
|
-
const ctx = makeCtx('supergroup', 42)
|
|
340
|
-
expect(ctx.chat?.type !== 'private').toBe(true)
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
it('undefined chat type is treated as non-private (middleware would bail)', () => {
|
|
344
|
-
const ctx = makeCtx(undefined, 42)
|
|
345
|
-
expect(ctx.chat?.type !== 'private').toBe(true)
|
|
346
|
-
})
|
|
347
|
-
})
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for telegram-plugin/foreman/state.ts — SQLite-backed conversation state.
|
|
3
|
-
*
|
|
4
|
-
* Uses bun:test (not vitest) because it imports bun:sqlite.
|
|
5
|
-
* Run with: bun test telegram-plugin/tests/foreman-state.test.ts
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
9
|
-
import { mkdtempSync, rmSync } from 'fs'
|
|
10
|
-
import { tmpdir } from 'os'
|
|
11
|
-
|
|
12
|
-
// We override SWITCHROOM_FOREMAN_DIR before importing state so each test
|
|
13
|
-
// gets a fresh DB in a temp directory.
|
|
14
|
-
|
|
15
|
-
let tmpDir: string
|
|
16
|
-
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
tmpDir = mkdtempSync(tmpdir() + '/foreman-state-test-')
|
|
19
|
-
process.env.SWITCHROOM_FOREMAN_DIR = tmpDir
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
afterEach(async () => {
|
|
23
|
-
// Must reset the DB singleton between tests so the next test gets a fresh one
|
|
24
|
-
const { _resetDbForTest } = await import('../foreman/state.js')
|
|
25
|
-
_resetDbForTest()
|
|
26
|
-
delete process.env.SWITCHROOM_FOREMAN_DIR
|
|
27
|
-
try { rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
// ─── Round-trip: setState + getState ─────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
describe('foreman-state: setState + getState round-trip', () => {
|
|
33
|
-
it('returns null for unknown chat', async () => {
|
|
34
|
-
const { getState } = await import('../foreman/state.js')
|
|
35
|
-
const result = getState('unknown-chat')
|
|
36
|
-
expect(result).toBeNull()
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('persists and retrieves state', async () => {
|
|
40
|
-
const { setState, getState } = await import('../foreman/state.js')
|
|
41
|
-
const now = Date.now()
|
|
42
|
-
setState({
|
|
43
|
-
chatId: 'chat-1',
|
|
44
|
-
step: 'asked-name',
|
|
45
|
-
name: null,
|
|
46
|
-
profile: null,
|
|
47
|
-
botToken: null,
|
|
48
|
-
authSessionName: null,
|
|
49
|
-
loginUrl: null,
|
|
50
|
-
startedAt: now,
|
|
51
|
-
updatedAt: now,
|
|
52
|
-
})
|
|
53
|
-
const retrieved = getState('chat-1')
|
|
54
|
-
expect(retrieved).not.toBeNull()
|
|
55
|
-
expect(retrieved!.chatId).toBe('chat-1')
|
|
56
|
-
expect(retrieved!.step).toBe('asked-name')
|
|
57
|
-
expect(retrieved!.name).toBeNull()
|
|
58
|
-
expect(retrieved!.startedAt).toBe(now)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('persists all fields', async () => {
|
|
62
|
-
const { setState, getState } = await import('../foreman/state.js')
|
|
63
|
-
const now = Date.now()
|
|
64
|
-
setState({
|
|
65
|
-
chatId: 'chat-2',
|
|
66
|
-
step: 'asked-oauth-code',
|
|
67
|
-
name: 'gymbro',
|
|
68
|
-
profile: 'health-coach',
|
|
69
|
-
botToken: '1234567890:AAH...',
|
|
70
|
-
authSessionName: 'gymbro-auth-session',
|
|
71
|
-
loginUrl: 'https://example.com/oauth',
|
|
72
|
-
startedAt: now - 5000,
|
|
73
|
-
updatedAt: now,
|
|
74
|
-
})
|
|
75
|
-
const retrieved = getState('chat-2')
|
|
76
|
-
expect(retrieved!.step).toBe('asked-oauth-code')
|
|
77
|
-
expect(retrieved!.name).toBe('gymbro')
|
|
78
|
-
expect(retrieved!.profile).toBe('health-coach')
|
|
79
|
-
expect(retrieved!.botToken).toBe('1234567890:AAH...')
|
|
80
|
-
expect(retrieved!.authSessionName).toBe('gymbro-auth-session')
|
|
81
|
-
expect(retrieved!.loginUrl).toBe('https://example.com/oauth')
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('upserts on conflict (same chat_id)', async () => {
|
|
85
|
-
const { setState, getState } = await import('../foreman/state.js')
|
|
86
|
-
const now = Date.now()
|
|
87
|
-
setState({ chatId: 'chat-3', step: 'asked-name', name: null, profile: null, botToken: null, authSessionName: null, loginUrl: null, startedAt: now, updatedAt: now })
|
|
88
|
-
setState({ chatId: 'chat-3', step: 'asked-profile', name: 'gymbro', profile: null, botToken: null, authSessionName: null, loginUrl: null, startedAt: now, updatedAt: now + 100 })
|
|
89
|
-
|
|
90
|
-
const retrieved = getState('chat-3')
|
|
91
|
-
expect(retrieved!.step).toBe('asked-profile')
|
|
92
|
-
expect(retrieved!.name).toBe('gymbro')
|
|
93
|
-
})
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
// ─── clearState ───────────────────────────────────────────────────────────
|
|
97
|
-
|
|
98
|
-
describe('foreman-state: clearState', () => {
|
|
99
|
-
it('removes state so getState returns null', async () => {
|
|
100
|
-
const { setState, getState, clearState } = await import('../foreman/state.js')
|
|
101
|
-
const now = Date.now()
|
|
102
|
-
setState({ chatId: 'chat-4', step: 'asked-name', name: null, profile: null, botToken: null, authSessionName: null, loginUrl: null, startedAt: now, updatedAt: now })
|
|
103
|
-
clearState('chat-4')
|
|
104
|
-
expect(getState('chat-4')).toBeNull()
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('is idempotent on unknown chat', async () => {
|
|
108
|
-
const { clearState } = await import('../foreman/state.js')
|
|
109
|
-
expect(() => clearState('nonexistent-chat')).not.toThrow()
|
|
110
|
-
})
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
// ─── listActiveFlows ──────────────────────────────────────────────────────
|
|
114
|
-
|
|
115
|
-
describe('foreman-state: listActiveFlows', () => {
|
|
116
|
-
it('returns empty when no flows', async () => {
|
|
117
|
-
const { listActiveFlows } = await import('../foreman/state.js')
|
|
118
|
-
expect(listActiveFlows()).toHaveLength(0)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('returns in-progress flows updated within window', async () => {
|
|
122
|
-
const { setState, listActiveFlows } = await import('../foreman/state.js')
|
|
123
|
-
const now = Date.now()
|
|
124
|
-
setState({ chatId: 'chat-5', step: 'asked-bot-token', name: 'gymbro', profile: 'health-coach', botToken: null, authSessionName: null, loginUrl: null, startedAt: now - 1000, updatedAt: now - 500 })
|
|
125
|
-
|
|
126
|
-
const flows = listActiveFlows(60 * 60 * 1000)
|
|
127
|
-
expect(flows).toHaveLength(1)
|
|
128
|
-
expect(flows[0].chatId).toBe('chat-5')
|
|
129
|
-
expect(flows[0].step).toBe('asked-bot-token')
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('excludes flows with step=done', async () => {
|
|
133
|
-
const { setState, listActiveFlows } = await import('../foreman/state.js')
|
|
134
|
-
const now = Date.now()
|
|
135
|
-
setState({ chatId: 'chat-6', step: 'done', name: 'gymbro', profile: null, botToken: null, authSessionName: null, loginUrl: null, startedAt: now - 1000, updatedAt: now - 500 })
|
|
136
|
-
|
|
137
|
-
const flows = listActiveFlows(60 * 60 * 1000)
|
|
138
|
-
const match = flows.find(f => f.chatId === 'chat-6')
|
|
139
|
-
expect(match).toBeUndefined()
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('excludes flows older than maxAgeMs', async () => {
|
|
143
|
-
const { setState, listActiveFlows } = await import('../foreman/state.js')
|
|
144
|
-
const now = Date.now()
|
|
145
|
-
// updated_at is 2 hours ago
|
|
146
|
-
setState({ chatId: 'chat-7', step: 'asked-oauth-code', name: 'gymbro', profile: null, botToken: null, authSessionName: null, loginUrl: null, startedAt: now - 2 * 3600 * 1000, updatedAt: now - 2 * 3600 * 1000 })
|
|
147
|
-
|
|
148
|
-
const flows = listActiveFlows(60 * 60 * 1000) // 1 hour window
|
|
149
|
-
const match = flows.find(f => f.chatId === 'chat-7')
|
|
150
|
-
expect(match).toBeUndefined()
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
it('returns multiple in-progress flows', async () => {
|
|
154
|
-
const { setState, listActiveFlows } = await import('../foreman/state.js')
|
|
155
|
-
const now = Date.now()
|
|
156
|
-
setState({ chatId: 'chat-8', step: 'asked-name', name: null, profile: null, botToken: null, authSessionName: null, loginUrl: null, startedAt: now, updatedAt: now })
|
|
157
|
-
setState({ chatId: 'chat-9', step: 'asked-profile', name: 'agent2', profile: null, botToken: null, authSessionName: null, loginUrl: null, startedAt: now, updatedAt: now })
|
|
158
|
-
|
|
159
|
-
const flows = listActiveFlows()
|
|
160
|
-
const chatIds = flows.map(f => f.chatId)
|
|
161
|
-
expect(chatIds).toContain('chat-8')
|
|
162
|
-
expect(chatIds).toContain('chat-9')
|
|
163
|
-
})
|
|
164
|
-
})
|