import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' import { existsSync, rmSync } from 'node:fs' import { mkdir, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { SlackBotCredentialManager } from './credential-manager' const CREDS_A = { token: 'xoxb-token-a', workspace_id: 'T123', workspace_name: 'Workspace A', bot_id: 'deploy', bot_name: 'Deploy Bot', } const CREDS_B = { token: 'xoxb-token-b', workspace_id: 'T123', workspace_name: 'Workspace A', bot_id: 'alert', bot_name: 'Alert Bot', } const CREDS_C = { token: 'xoxb-token-c', workspace_id: 'T999', workspace_name: 'Workspace B', bot_id: 'deploy', bot_name: 'Deploy Bot (B)', } describe('SlackBotCredentialManager', () => { let tempDir: string let manager: SlackBotCredentialManager beforeEach(async () => { tempDir = join(tmpdir(), `slackbot-cred-test-${Date.now()}`) await mkdir(tempDir, { recursive: true }) manager = new SlackBotCredentialManager(tempDir) }) afterEach(() => { if (existsSync(tempDir)) { rmSync(tempDir, { recursive: true }) } delete process.env.E2E_SLACKBOT_TOKEN delete process.env.E2E_SLACKBOT_WORKSPACE_ID delete process.env.E2E_SLACKBOT_WORKSPACE_NAME }) describe('load', () => { it('returns empty config when no file exists', async () => { const config = await manager.load() expect(config.current).toBeNull() expect(config.workspaces).toEqual({}) }) }) describe('save and load', () => { it('persists config to file', async () => { const config = { current: { workspace_id: 'T123', bot_id: 'deploy' }, workspaces: { T123: { workspace_id: 'T123', workspace_name: 'Test Workspace', bots: { deploy: { bot_id: 'deploy', bot_name: 'Deploy Bot', token: 'xoxb-test-token' }, }, }, }, } await manager.save(config) const loaded = await manager.load() expect(loaded).toEqual(config) }) }) describe('getCredentials', () => { it('returns null when no credentials exist', async () => { expect(await manager.getCredentials()).toBeNull() }) it('returns current bot credentials', async () => { await manager.setCredentials(CREDS_A) const creds = await manager.getCredentials() expect(creds).toEqual(CREDS_A) }) it('returns specific bot by id', async () => { await manager.setCredentials(CREDS_A) await manager.setCredentials(CREDS_B) const creds = await manager.getCredentials('deploy') expect(creds).toEqual(CREDS_A) }) it('returns specific bot by workspace_id/bot_id', async () => { await manager.setCredentials(CREDS_A) await manager.setCredentials(CREDS_C) const creds = await manager.getCredentials('T123/deploy') expect(creds).toEqual(CREDS_A) }) it('returns null for ambiguous bot_id across workspaces', async () => { await manager.setCredentials(CREDS_A) await manager.setCredentials(CREDS_C) const creds = await manager.getCredentials('deploy') expect(creds).toBeNull() }) it('env vars take precedence over file', async () => { await manager.setCredentials(CREDS_A) process.env.E2E_SLACKBOT_TOKEN = 'xoxb-env-token' process.env.E2E_SLACKBOT_WORKSPACE_ID = 'T789' process.env.E2E_SLACKBOT_WORKSPACE_NAME = 'Env Workspace' const creds = await manager.getCredentials() expect(creds?.token).toBe('xoxb-env-token') expect(creds?.workspace_id).toBe('T789') expect(creds?.bot_id).toBe('env') }) }) describe('setCredentials', () => { it('stores bot and sets as current', async () => { await manager.setCredentials(CREDS_A) const config = await manager.load() expect(config.current).toEqual({ workspace_id: 'T123', bot_id: 'deploy' }) expect(config.workspaces.T123.bots.deploy.token).toBe('xoxb-token-a') }) it('stores multiple bots in same workspace', async () => { await manager.setCredentials(CREDS_A) await manager.setCredentials(CREDS_B) const config = await manager.load() expect(Object.keys(config.workspaces.T123.bots)).toEqual(['deploy', 'alert']) expect(config.current).toEqual({ workspace_id: 'T123', bot_id: 'alert' }) }) it('stores bots across workspaces', async () => { await manager.setCredentials(CREDS_A) await manager.setCredentials(CREDS_C) const config = await manager.load() expect(Object.keys(config.workspaces)).toEqual(['T123', 'T999']) }) }) describe('listAll', () => { it('returns all bots with current flag', async () => { await manager.setCredentials(CREDS_A) await manager.setCredentials(CREDS_B) const all = await manager.listAll() expect(all).toHaveLength(2) expect(all.find((b) => b.bot_id === 'deploy')?.is_current).toBe(false) expect(all.find((b) => b.bot_id === 'alert')?.is_current).toBe(true) }) }) describe('setCurrent', () => { it('switches current bot', async () => { await manager.setCredentials(CREDS_A) await manager.setCredentials(CREDS_B) const switched = await manager.setCurrent('deploy') expect(switched).toBe(true) const creds = await manager.getCredentials() expect(creds?.bot_id).toBe('deploy') }) it('returns false for unknown bot', async () => { expect(await manager.setCurrent('nonexistent')).toBe(false) }) }) describe('removeBot', () => { it('removes a bot by id', async () => { await manager.setCredentials(CREDS_A) await manager.setCredentials(CREDS_B) const removed = await manager.removeBot('deploy') expect(removed).toBe(true) const config = await manager.load() expect(Object.keys(config.workspaces.T123.bots)).toEqual(['alert']) }) it('removes workspace when last bot removed', async () => { await manager.setCredentials(CREDS_A) await manager.removeBot('deploy') const config = await manager.load() expect(config.workspaces.T123).toBeUndefined() }) it('clears current when current bot removed', async () => { await manager.setCredentials(CREDS_A) await manager.removeBot('deploy') const config = await manager.load() expect(config.current).toBeNull() }) it('returns false for unknown bot', async () => { expect(await manager.removeBot('nonexistent')).toBe(false) }) it('returns false for ambiguous bot_id across workspaces', async () => { await manager.setCredentials(CREDS_A) await manager.setCredentials(CREDS_C) const removed = await manager.removeBot('deploy') expect(removed).toBe(false) const config = await manager.load() expect(config.workspaces.T123.bots.deploy).toBeDefined() expect(config.workspaces.T999.bots.deploy).toBeDefined() }) }) describe('clearCredentials', () => { it('removes all credentials', async () => { await manager.setCredentials(CREDS_A) await manager.setCredentials(CREDS_B) await manager.clearCredentials() const config = await manager.load() expect(config.current).toBeNull() expect(config.workspaces).toEqual({}) }) }) describe('file permissions', () => { it('saves file with secure permissions (600)', async () => { await manager.setCredentials(CREDS_A) const credPath = join(tempDir, 'slackbot-credentials.json') const stats = await stat(credPath) expect(stats.mode & 0o777).toBe(0o600) }) }) describe('schema validation', () => { let stderrSpy: ReturnType let originalWrite: typeof process.stderr.write beforeEach(() => { originalWrite = process.stderr.write stderrSpy = mock(() => true) process.stderr.write = stderrSpy as unknown as typeof process.stderr.write }) afterEach(() => { process.stderr.write = originalWrite }) it('returns empty config on invalid JSON', async () => { const credPath = join(tempDir, 'slackbot-credentials.json') await writeFile(credPath, '{not valid json') const config = await manager.load() expect(config.current).toBeNull() expect(config.workspaces).toEqual({}) }) it('preserves entries when stored token does not match xoxb- prefix and warns to stderr', async () => { const credPath = join(tempDir, 'slackbot-credentials.json') await writeFile( credPath, JSON.stringify({ current: { workspace_id: 'T123', bot_id: 'deploy' }, workspaces: { T123: { workspace_id: 'T123', workspace_name: 'Acme', bots: { deploy: { bot_id: 'deploy', bot_name: 'Deploy Bot', token: 'legacy-token-without-prefix' }, }, }, }, }), ) const config = await manager.load() expect(config.current).toEqual({ workspace_id: 'T123', bot_id: 'deploy' }) expect(config.workspaces['T123']?.bots['deploy']?.token).toBe('legacy-token-without-prefix') expect(stderrSpy).toHaveBeenCalled() }) it('returns empty config when JSON is valid but not an object', async () => { const credPath = join(tempDir, 'slackbot-credentials.json') await writeFile(credPath, JSON.stringify(['not', 'an', 'object'])) const config = await manager.load() expect(config.current).toBeNull() expect(config.workspaces).toEqual({}) }) }) })