diff --git a/package-lock.json b/package-lock.json index 6b2c830..13d28df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mimo-3/slack-cli", - "version": "0.24.0", + "version": "0.24.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mimo-3/slack-cli", - "version": "0.24.0", + "version": "0.24.1", "license": "MIT", "dependencies": { "@slack/web-api": "7.15.0", diff --git a/package.json b/package.json index 8f32fff..c9d1bf8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-3/slack-cli", - "version": "0.24.0", + "version": "0.24.1", "description": "A command-line tool for sending messages to Slack", "main": "dist/index.js", "bin": { diff --git a/src/commands/usergroups.ts b/src/commands/usergroups.ts index 225ffe0..897cebb 100644 --- a/src/commands/usergroups.ts +++ b/src/commands/usergroups.ts @@ -5,15 +5,15 @@ import { renderByFormat, withSlackClient } from '../utils/command-support'; import { wrapCommand } from '../utils/command-wrapper'; import { createMembersFormatter, MemberInfo } from '../utils/formatters/members-formatters'; import { parseFormat } from '../utils/option-parsers'; -import { sanitizeTerminalData, sanitizeTerminalText } from '../utils/terminal-sanitizer'; +import { sanitizeSingleLineText, sanitizeTerminalData } from '../utils/terminal-sanitizer'; import { createValidationHook, optionValidators } from '../utils/validators'; function renderUsergroupTable(usergroups: SlackUsergroup[]) { const rows = usergroups.map((usergroup) => ({ - id: sanitizeTerminalText(usergroup.id || ''), - handle: sanitizeTerminalText(usergroup.handle || ''), - name: sanitizeTerminalText(usergroup.name || ''), - description: sanitizeTerminalText(usergroup.description || ''), + id: sanitizeSingleLineText(usergroup.id || ''), + handle: sanitizeSingleLineText(usergroup.handle || ''), + name: sanitizeSingleLineText(usergroup.name || ''), + description: sanitizeSingleLineText(usergroup.description || ''), user_count: usergroup.user_count ?? '', })); @@ -23,9 +23,9 @@ function renderUsergroupTable(usergroups: SlackUsergroup[]) { function renderUsergroupSimple(usergroups: SlackUsergroup[]) { for (const usergroup of usergroups) { console.log( - `${sanitizeTerminalText(usergroup.id || '')}\t@${sanitizeTerminalText( + `${sanitizeSingleLineText(usergroup.id || '')}\t@${sanitizeSingleLineText( usergroup.handle || '' - )}\t${sanitizeTerminalText(usergroup.name || '')}` + )}\t${sanitizeSingleLineText(usergroup.name || '')}` ); } } diff --git a/src/commands/users.ts b/src/commands/users.ts index 6531593..9e2f561 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -9,7 +9,11 @@ import { SlackUser, UserPresence } from '../types/slack'; import { renderByFormat, withSlackClient } from '../utils/command-support'; import { wrapCommand } from '../utils/command-wrapper'; import { parseLimit } from '../utils/option-parsers'; -import { sanitizeTerminalData, sanitizeTerminalText } from '../utils/terminal-sanitizer'; +import { + sanitizeSingleLineText, + sanitizeTerminalData, + sanitizeTerminalText, +} from '../utils/terminal-sanitizer'; import { createValidationHook, optionValidators } from '../utils/validators'; function renderUserTable(users: SlackUser[]) { @@ -27,11 +31,11 @@ function renderUserTable(users: SlackUser[]) { function renderUserSimple(users: SlackUser[]) { for (const user of users) { - const email = user.profile?.email ? ` <${sanitizeTerminalText(user.profile.email)}>` : ''; + const email = user.profile?.email ? ` <${sanitizeSingleLineText(user.profile.email)}>` : ''; console.log( - `${sanitizeTerminalText(user.id || '')}\t${sanitizeTerminalText( + `${sanitizeSingleLineText(user.id || '')}\t${sanitizeSingleLineText( user.name || '' - )}\t${sanitizeTerminalText(user.real_name || '')}${email}` + )}\t${sanitizeSingleLineText(user.real_name || '')}${email}` ); } } @@ -61,15 +65,15 @@ function renderUserInfo(user: SlackUser) { function renderPresenceTable(userId: string, presence: UserPresence) { const rows = [ { - user: sanitizeTerminalText(userId), - presence: sanitizeTerminalText(presence.presence), + user: sanitizeSingleLineText(userId), + presence: sanitizeSingleLineText(presence.presence), }, ]; console.table(sanitizeTerminalData(rows)); } function renderPresenceSimple(userId: string, presence: UserPresence) { - console.log(`${sanitizeTerminalText(userId)}\t${sanitizeTerminalText(presence.presence)}`); + console.log(`${sanitizeSingleLineText(userId)}\t${sanitizeSingleLineText(presence.presence)}`); } export function setupUsersCommand(): Command { diff --git a/src/utils/command-wrapper.ts b/src/utils/command-wrapper.ts index 2a5da90..b81ca32 100644 --- a/src/utils/command-wrapper.ts +++ b/src/utils/command-wrapper.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import { extractErrorMessage } from './error-utils'; +import { sanitizeTerminalText } from './terminal-sanitizer'; import { redactSlackTokens } from './token-utils'; export type CommandAction = (options: T) => Promise | void; @@ -9,10 +10,14 @@ export function wrapCommand(action: CommandAction): CommandActio try { await action(options); } catch (error) { - console.error(chalk.red('✗ Error:'), redactSlackTokens(extractErrorMessage(error))); + // Sanitize first so tokens split by injected escape sequences are still redacted. + console.error( + chalk.red('✗ Error:'), + redactSlackTokens(sanitizeTerminalText(extractErrorMessage(error))) + ); if (process.env.NODE_ENV === 'development' && error instanceof Error) { - console.error(chalk.gray(redactSlackTokens(error.stack))); + console.error(chalk.gray(redactSlackTokens(sanitizeTerminalText(error.stack ?? '')))); } process.exit(1); diff --git a/src/utils/formatters/bookmark-formatters.ts b/src/utils/formatters/bookmark-formatters.ts index 746a754..5285766 100644 --- a/src/utils/formatters/bookmark-formatters.ts +++ b/src/utils/formatters/bookmark-formatters.ts @@ -1,4 +1,4 @@ -import { sanitizeTerminalText } from '../terminal-sanitizer'; +import { sanitizeSingleLineText } from '../terminal-sanitizer'; import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter'; export interface BookmarkItem { @@ -35,9 +35,9 @@ class BookmarkTableFormatter extends AbstractFormatter console.log('\u2500'.repeat(channelWidth + tsWidth + textWidth + savedAtWidth)); items.forEach((item) => { - const channel = sanitizeTerminalText(item.channel || '').padEnd(channelWidth); - const ts = sanitizeTerminalText(item.message?.ts || '').padEnd(tsWidth); - const text = sanitizeTerminalText(item.message?.text || '') + const channel = sanitizeSingleLineText(item.channel || '').padEnd(channelWidth); + const ts = sanitizeSingleLineText(item.message?.ts || '').padEnd(tsWidth); + const text = sanitizeSingleLineText(item.message?.text || '') .slice(0, textWidth - 2) .padEnd(textWidth); const savedAt = formatDate(item.date_create).padEnd(savedAtWidth); @@ -52,7 +52,7 @@ class BookmarkSimpleFormatter extends AbstractFormatter { const savedAt = formatDate(item.date_create); console.log( - `${sanitizeTerminalText(item.channel || '')}\t${sanitizeTerminalText(item.message?.ts || '')}\t${sanitizeTerminalText(item.message?.text || '')}\t${savedAt}` + `${sanitizeSingleLineText(item.channel || '')}\t${sanitizeSingleLineText(item.message?.ts || '')}\t${sanitizeSingleLineText(item.message?.text || '')}\t${savedAt}` ); }); } diff --git a/src/utils/formatters/channel-formatters.ts b/src/utils/formatters/channel-formatters.ts index a68aad5..5d6ef43 100644 --- a/src/utils/formatters/channel-formatters.ts +++ b/src/utils/formatters/channel-formatters.ts @@ -2,8 +2,13 @@ import chalk from 'chalk'; import { Channel } from '../../types/slack'; import { formatChannelName } from '../channel-formatter'; import { formatSlackTimestamp } from '../date-utils'; +import { sanitizeSingleLineText } from '../terminal-sanitizer'; import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter'; +function singleLineChannelName(channel: Channel): string { + return sanitizeSingleLineText(channel.display_name || formatChannelName(channel.name)); +} + export interface ChannelFormatterOptions { channels: Channel[]; countOnly?: boolean; @@ -15,8 +20,7 @@ class ChannelTableFormatter extends AbstractFormatter { console.log('─'.repeat(50)); channels.forEach((channel) => { - const channelName = channel.display_name || formatChannelName(channel.name); - const paddedName = channelName.padEnd(16); + const paddedName = singleLineChannelName(channel).padEnd(16); const count = (channel.unread_count || 0).toString().padEnd(6); const lastRead = channel.last_read ? formatSlackTimestamp(channel.last_read) : 'Unknown'; console.log(`${paddedName} ${count} ${lastRead}`); @@ -27,8 +31,7 @@ class ChannelTableFormatter extends AbstractFormatter { class ChannelSimpleFormatter extends AbstractFormatter { format({ channels }: ChannelFormatterOptions): void { channels.forEach((channel) => { - const channelName = channel.display_name || formatChannelName(channel.name); - console.log(`${channelName} (${channel.unread_count || 0})`); + console.log(`${singleLineChannelName(channel)} (${channel.unread_count || 0})`); }); } } @@ -49,8 +52,7 @@ class ChannelCountFormatter extends AbstractFormatter { channels.forEach((channel) => { const count = channel.unread_count || 0; totalUnread += count; - const channelName = channel.display_name || formatChannelName(channel.name); - console.log(`${channelName}: ${count}`); + console.log(`${singleLineChannelName(channel)}: ${count}`); }); console.log(chalk.bold(`Total: ${totalUnread} unread messages`)); } diff --git a/src/utils/formatters/channels-list-formatters.ts b/src/utils/formatters/channels-list-formatters.ts index 90c63d4..e0e7279 100644 --- a/src/utils/formatters/channels-list-formatters.ts +++ b/src/utils/formatters/channels-list-formatters.ts @@ -1,5 +1,5 @@ import { ChannelInfo } from '../channel-formatter'; -import { sanitizeTerminalText } from '../terminal-sanitizer'; +import { sanitizeSingleLineText } from '../terminal-sanitizer'; import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter'; export interface ChannelsListFormatterOptions { @@ -14,8 +14,8 @@ class ChannelsTableFormatter extends AbstractFormatter { - const safeName = sanitizeTerminalText(channel.name); - const safePurpose = sanitizeTerminalText(channel.purpose); + const safeName = sanitizeSingleLineText(channel.name); + const safePurpose = sanitizeSingleLineText(channel.purpose); const name = safeName.padEnd(17); const type = channel.type.padEnd(9); const members = channel.members.toString().padEnd(8); @@ -29,7 +29,7 @@ class ChannelsTableFormatter extends AbstractFormatter { format({ channels }: ChannelsListFormatterOptions): void { - channels.forEach((channel) => console.log(sanitizeTerminalText(channel.name))); + channels.forEach((channel) => console.log(sanitizeSingleLineText(channel.name))); } } diff --git a/src/utils/formatters/members-formatters.ts b/src/utils/formatters/members-formatters.ts index 210fb8d..baac79f 100644 --- a/src/utils/formatters/members-formatters.ts +++ b/src/utils/formatters/members-formatters.ts @@ -1,4 +1,4 @@ -import { sanitizeTerminalText } from '../terminal-sanitizer'; +import { sanitizeSingleLineText } from '../terminal-sanitizer'; import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter'; export interface MemberInfo { @@ -17,9 +17,9 @@ class MembersTableFormatter extends AbstractFormatter { console.log('\u2500'.repeat(60)); members.forEach((member) => { - const id = sanitizeTerminalText(member.id || '').padEnd(17); - const name = sanitizeTerminalText(member.name || '').padEnd(17); - const realName = sanitizeTerminalText(member.realName || ''); + const id = sanitizeSingleLineText(member.id || '').padEnd(17); + const name = sanitizeSingleLineText(member.name || '').padEnd(17); + const realName = sanitizeSingleLineText(member.realName || ''); console.log(`${id} ${name} ${realName}`); }); @@ -30,7 +30,7 @@ class MembersSimpleFormatter extends AbstractFormatter format({ members }: MembersFormatterOptions): void { members.forEach((member) => { console.log( - `${sanitizeTerminalText(member.id || '')}\t${sanitizeTerminalText(member.name || '')}\t${sanitizeTerminalText(member.realName || '')}` + `${sanitizeSingleLineText(member.id || '')}\t${sanitizeSingleLineText(member.name || '')}\t${sanitizeSingleLineText(member.realName || '')}` ); }); } diff --git a/src/utils/formatters/reminder-formatters.ts b/src/utils/formatters/reminder-formatters.ts index 31b9326..86999c3 100644 --- a/src/utils/formatters/reminder-formatters.ts +++ b/src/utils/formatters/reminder-formatters.ts @@ -1,4 +1,4 @@ -import { sanitizeTerminalText } from '../terminal-sanitizer'; +import { sanitizeSingleLineText } from '../terminal-sanitizer'; import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter'; export interface ReminderInfo { @@ -37,8 +37,8 @@ class ReminderTableFormatter extends AbstractFormatter console.log('\u2500'.repeat(idWidth + textWidth + timeWidth + statusWidth)); reminders.forEach((reminder) => { - const id = sanitizeTerminalText(reminder.id || '').padEnd(idWidth); - const text = sanitizeTerminalText(reminder.text || '') + const id = sanitizeSingleLineText(reminder.id || '').padEnd(idWidth); + const text = sanitizeSingleLineText(reminder.text || '') .slice(0, textWidth - 2) .padEnd(textWidth); const time = formatTime(reminder.time).padEnd(timeWidth); @@ -55,7 +55,7 @@ class ReminderSimpleFormatter extends AbstractFormatter { console.log(''); matches.forEach((match) => { const channel = match.channel.name - ? `#${sanitizeTerminalText(match.channel.name)}` - : sanitizeTerminalText(match.channel.id || 'unknown'); - const username = sanitizeTerminalText(match.username || match.user || 'Unknown'); + ? `#${sanitizeSingleLineText(match.channel.name)}` + : sanitizeSingleLineText(match.channel.id || 'unknown'); + const username = sanitizeSingleLineText(match.username || match.user || 'Unknown'); const timestamp = match.ts ? formatTimestampFixed(match.ts) : ''; const text = sanitizeTerminalText(match.text || '(no text)'); console.log(`${chalk.gray(`[${timestamp}]`)} ${chalk.blue(channel)} ${chalk.cyan(username)}`); console.log(text); if (match.permalink) { - console.log(chalk.gray(sanitizeTerminalText(match.permalink))); + console.log(chalk.gray(sanitizeSingleLineText(match.permalink))); } console.log(''); }); @@ -59,11 +59,11 @@ class SimpleSearchFormatter extends AbstractFormatter { matches.forEach((match) => { const channel = match.channel.name - ? `#${sanitizeTerminalText(match.channel.name)}` - : sanitizeTerminalText(match.channel.id || 'unknown'); - const username = sanitizeTerminalText(match.username || match.user || 'Unknown'); + ? `#${sanitizeSingleLineText(match.channel.name)}` + : sanitizeSingleLineText(match.channel.id || 'unknown'); + const username = sanitizeSingleLineText(match.username || match.user || 'Unknown'); const timestamp = match.ts ? formatTimestampFixed(match.ts) : ''; - const text = sanitizeTerminalText(match.text || '(no text)'); + const text = sanitizeSingleLineText(match.text || '(no text)'); console.log(`[${channel}] ${username} (${timestamp}): ${text}`); }); diff --git a/src/utils/slack-operations/user-operations.ts b/src/utils/slack-operations/user-operations.ts index 72c13b4..5e2ac0d 100644 --- a/src/utils/slack-operations/user-operations.ts +++ b/src/utils/slack-operations/user-operations.ts @@ -35,7 +35,7 @@ export class UserOperations extends BaseSlackClient { } async getUserInfo(userId: string): Promise { - const response = await this.client.users.info({ user: userId }); + const response = await this.rateLimiter(() => this.client.users.info({ user: userId })); return response.user as SlackUser; } diff --git a/src/utils/terminal-sanitizer.ts b/src/utils/terminal-sanitizer.ts index 14d9ddd..ffc4a65 100644 --- a/src/utils/terminal-sanitizer.ts +++ b/src/utils/terminal-sanitizer.ts @@ -32,6 +32,15 @@ export function sanitizeTerminalText(value: string): string { return sanitized; } +/** + * Sanitize a value for single-line output (TSV rows, table cells). + * Collapses whitespace runs (including newlines and tabs) into single spaces + * so untrusted values cannot forge extra rows or columns. + */ +export function sanitizeSingleLineText(value: string): string { + return sanitizeTerminalText(value).replace(/\s+/g, ' ').trim(); +} + function isPlainObject(value: unknown): value is Record { if (!value || typeof value !== 'object') { return false; diff --git a/tests/commands/usergroups.test.ts b/tests/commands/usergroups.test.ts index 04806a8..f39d18a 100644 --- a/tests/commands/usergroups.test.ts +++ b/tests/commands/usergroups.test.ts @@ -111,6 +111,29 @@ describe('usergroups command', () => { expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('engineers')); }); + it('should collapse newlines in simple format fields', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([ + { + id: 'S123', + name: 'Engineering\nfake-row', + handle: 'eng\tineers', + description: 'team', + user_count: 10, + }, + ]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--format', 'simple']); + + const output = mockConsole.logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('Engineering fake-row'); + expect(output).toContain('eng ineers'); + }); + it('should show message when no usergroups found', async () => { vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ token: 'test-token', diff --git a/tests/commands/users.test.ts b/tests/commands/users.test.ts index 0bdb7dc..b736480 100644 --- a/tests/commands/users.test.ts +++ b/tests/commands/users.test.ts @@ -102,6 +102,30 @@ describe('users command', () => { expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('alice')); }); + it('should collapse newlines in simple format fields', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsers).mockResolvedValue([ + { + id: 'U123', + name: 'alice\nfake-row', + real_name: 'Alice\tSmith', + profile: { email: 'alice@example.com', display_name: 'alice' }, + is_bot: false, + deleted: false, + }, + ]); + + await program.parseAsync(['node', 'slack-cli', 'users', 'list', '--format', 'simple']); + + const output = mockConsole.logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('alice fake-row'); + expect(output).toContain('Alice Smith'); + }); + it('should show message when no users found', async () => { vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ token: 'test-token', @@ -375,6 +399,31 @@ describe('users command', () => { expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('active')); }); + it('should collapse newlines in simple presence output', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.getUserPresence).mockResolvedValue({ + presence: 'active\nfake-row', + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'users', + 'presence', + '--id', + 'U123', + '--format', + 'simple', + ]); + + const output = mockConsole.logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('active fake-row'); + }); + it('should display away presence in table format', async () => { vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ token: 'test-token', diff --git a/tests/utils/command-wrapper.test.ts b/tests/utils/command-wrapper.test.ts new file mode 100644 index 0000000..5495956 --- /dev/null +++ b/tests/utils/command-wrapper.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapCommand } from '../../src/utils/command-wrapper'; + +describe('wrapCommand', () => { + let errorSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should run the action and not exit on success', async () => { + const action = vi.fn().mockResolvedValue(undefined); + + await wrapCommand(action)({}); + + expect(action).toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('should print the error message and exit with code 1', async () => { + const action = vi.fn().mockRejectedValue(new Error('channel_not_found')); + + await wrapCommand(action)({}); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.stringContaining('channel_not_found') + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('should strip ANSI escape sequences from error messages', async () => { + const action = vi.fn().mockRejectedValue(new Error('bad_request injected')); + + await wrapCommand(action)({}); + + const message = errorSpy.mock.calls[0][1] as string; + expect(message).not.toContain(''); + expect(message).toContain('bad_request'); + expect(message).toContain('injected'); + }); + + it('should strip OSC sequences from error messages', async () => { + const action = vi.fn().mockRejectedValue(new Error('failed ]0;evil titlerest')); + + await wrapCommand(action)({}); + + const message = errorSpy.mock.calls[0][1] as string; + expect(message).not.toContain(''); + expect(message).not.toContain('evil title'); + expect(message).toContain('failed'); + expect(message).toContain('rest'); + }); + + it('should still redact Slack tokens in error messages', async () => { + const fakeToken = ['xoxb', '1234567890', 'abcdefghij'].join('-'); + const action = vi.fn().mockRejectedValue(new Error(`invalid_auth: ${fakeToken}`)); + + await wrapCommand(action)({}); + + const message = errorSpy.mock.calls[0][1] as string; + expect(message).not.toContain(fakeToken); + expect(message).toContain('invalid_auth'); + }); + + it('should redact tokens split by injected escape sequences', async () => { + const tokenHead = ['xoxb', '12345'].join('-'); + const tokenTail = '67890-abcdefghij'; + const action = vi + .fn() + .mockRejectedValue(new Error(`invalid_auth: ${tokenHead}${tokenTail}`)); + + await wrapCommand(action)({}); + + const message = errorSpy.mock.calls[0][1] as string; + expect(message).not.toContain(tokenTail); + expect(message).toContain('invalid_auth'); + }); +}); diff --git a/tests/utils/formatters/bookmark-formatters.test.ts b/tests/utils/formatters/bookmark-formatters.test.ts index 3e98dc7..47e2e60 100644 --- a/tests/utils/formatters/bookmark-formatters.test.ts +++ b/tests/utils/formatters/bookmark-formatters.test.ts @@ -118,4 +118,37 @@ describe('bookmark formatters', () => { expect(logSpy).toHaveBeenCalled(); }); }); + + describe('single-line output hardening', () => { + const itemsWithNewline: BookmarkFormatterOptions = { + items: [ + { + type: 'message', + channel: 'C123', + message: { text: 'first line\nfake-row', ts: '1234567890.123456' }, + date_create: 1709290800, + }, + ], + }; + + it('should collapse newlines in simple format text', () => { + const formatter = createBookmarkFormatter('simple'); + formatter.format(itemsWithNewline); + + const output = logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('first line fake-row'); + }); + + it('should collapse newlines in table format text', () => { + const formatter = createBookmarkFormatter('table'); + formatter.format(itemsWithNewline); + + const dataRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('C123')); + expect(dataRow).toBeDefined(); + expect(dataRow).not.toContain('\n'); + }); + }); }); diff --git a/tests/utils/formatters/channel-formatters.test.ts b/tests/utils/formatters/channel-formatters.test.ts new file mode 100644 index 0000000..9b1fff1 --- /dev/null +++ b/tests/utils/formatters/channel-formatters.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Channel } from '../../../src/types/slack'; +import { createChannelFormatter } from '../../../src/utils/formatters/channel-formatters'; + +describe('channel formatters', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const channelsWithNewline: Channel[] = [ + { + id: 'C123', + name: 'general', + display_name: 'general\nfake-row', + is_private: false, + created: 1709290800, + unread_count: 2, + last_read: '1709290800.000100', + }, + ]; + + describe('single-line output hardening', () => { + it('should collapse newlines in table format channel names', () => { + const formatter = createChannelFormatter('table', false); + formatter.format({ channels: channelsWithNewline }); + + const dataRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('general')); + expect(dataRow).toBeDefined(); + expect(dataRow).not.toContain('\n'); + expect(dataRow).toContain('general fake-row'); + }); + + it('should collapse newlines in simple format channel names', () => { + const formatter = createChannelFormatter('simple', false); + formatter.format({ channels: channelsWithNewline }); + + const output = logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('general fake-row'); + }); + + it('should collapse newlines in count format channel names', () => { + const formatter = createChannelFormatter('count', true); + formatter.format({ channels: channelsWithNewline }); + + const countRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('general')); + expect(countRow).toBeDefined(); + expect(countRow).not.toContain('\n'); + }); + }); +}); diff --git a/tests/utils/formatters/channels-list-formatters.test.ts b/tests/utils/formatters/channels-list-formatters.test.ts new file mode 100644 index 0000000..62d7055 --- /dev/null +++ b/tests/utils/formatters/channels-list-formatters.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createChannelsListFormatter } from '../../../src/utils/formatters/channels-list-formatters'; + +describe('channels list formatters', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const channelsWithNewline = [ + { + id: 'C123', + name: 'general\nfake-row', + type: 'public', + members: 10, + created: '2026-01-01', + purpose: 'first line\nsecond line', + is_archived: false, + }, + ]; + + describe('single-line output hardening', () => { + it('should collapse newlines in table format fields', () => { + const formatter = createChannelsListFormatter('table'); + formatter.format({ channels: channelsWithNewline }); + + const dataRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('general')); + expect(dataRow).toBeDefined(); + expect(dataRow).not.toContain('\n'); + expect(dataRow).toContain('general fake-row'); + }); + + it('should collapse newlines in simple format channel names', () => { + const formatter = createChannelsListFormatter('simple'); + formatter.format({ channels: channelsWithNewline }); + + const output = logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('general fake-row'); + }); + }); +}); diff --git a/tests/utils/formatters/members-formatters.test.ts b/tests/utils/formatters/members-formatters.test.ts index f895b8b..129cac5 100644 --- a/tests/utils/formatters/members-formatters.test.ts +++ b/tests/utils/formatters/members-formatters.test.ts @@ -88,6 +88,34 @@ describe('members formatters', () => { }); }); + describe('single-line output hardening', () => { + it('should collapse newlines and tabs in simple format fields', () => { + const formatter = createMembersFormatter('simple'); + formatter.format({ + members: [{ id: 'U01', name: 'alice\nfake-row', realName: 'Alice\tSmith' }], + }); + + const output = logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('alice fake-row'); + expect(output).toContain('Alice Smith'); + }); + + it('should collapse newlines in table format fields', () => { + const formatter = createMembersFormatter('table'); + formatter.format({ + members: [{ id: 'U01', name: 'alice\nfake-row', realName: 'Alice Smith' }], + }); + + const dataRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('U01')); + expect(dataRow).toBeDefined(); + expect(dataRow).not.toContain('\n'); + expect(dataRow).toContain('alice fake-row'); + }); + }); + describe('factory', () => { it('should default to table format for unknown format', () => { const formatter = createMembersFormatter('unknown'); diff --git a/tests/utils/formatters/reminder-formatters.test.ts b/tests/utils/formatters/reminder-formatters.test.ts index da604c7..00bd998 100644 --- a/tests/utils/formatters/reminder-formatters.test.ts +++ b/tests/utils/formatters/reminder-formatters.test.ts @@ -119,4 +119,36 @@ describe('reminder formatters', () => { expect(logSpy).toHaveBeenCalled(); }); }); + + describe('single-line output hardening', () => { + const remindersWithNewline = [ + { + id: 'Rm01', + text: 'first line\nfake-row', + time: 1709290800, + complete_ts: 0, + recurring: false, + }, + ]; + + it('should collapse newlines in simple format text', () => { + const formatter = createReminderFormatter('simple'); + formatter.format({ reminders: remindersWithNewline }); + + const output = logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('first line fake-row'); + }); + + it('should collapse newlines in table format text', () => { + const formatter = createReminderFormatter('table'); + formatter.format({ reminders: remindersWithNewline }); + + const dataRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('Rm01')); + expect(dataRow).toBeDefined(); + expect(dataRow).not.toContain('\n'); + }); + }); }); diff --git a/tests/utils/formatters/search-formatters.test.ts b/tests/utils/formatters/search-formatters.test.ts index a603615..ff2893a 100644 --- a/tests/utils/formatters/search-formatters.test.ts +++ b/tests/utils/formatters/search-formatters.test.ts @@ -136,4 +136,40 @@ describe('SearchFormatters', () => { expect(mockConsole).toHaveBeenCalled(); }); }); + + describe('single-line output hardening', () => { + const matchWithNewline: SearchMatch = { + text: 'first line\nfake-row', + user: 'U123', + username: 'john.doe', + ts: '1609459200.000100', + channel: { id: 'C123', name: 'general' }, + permalink: 'https://slack.com/archives/C123/p1609459200000100', + }; + + it('should keep each simple format match on a single line', () => { + mockConsole.mockClear(); + const formatter = createSearchFormatter('simple'); + formatter.format(createOptions({ matches: [matchWithNewline], totalCount: 1 })); + + const matchLine = mockConsole.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('john.doe')); + expect(matchLine).toBeDefined(); + expect(matchLine).not.toContain('\n'); + expect(matchLine).toContain('first line fake-row'); + }); + + it('should keep each table format match on a single line', () => { + mockConsole.mockClear(); + const formatter = createSearchFormatter('table'); + formatter.format(createOptions({ matches: [matchWithNewline], totalCount: 1 })); + + const matchLine = mockConsole.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('john.doe')); + expect(matchLine).toBeDefined(); + expect(matchLine).not.toContain('\n'); + }); + }); }); diff --git a/tests/utils/slack-operations/user-operations.test.ts b/tests/utils/slack-operations/user-operations.test.ts index 092660d..c29de63 100644 --- a/tests/utils/slack-operations/user-operations.test.ts +++ b/tests/utils/slack-operations/user-operations.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RATE_LIMIT } from '../../../src/utils/constants'; import { UserOperations } from '../../../src/utils/slack-operations/user-operations'; vi.mock('@slack/web-api', () => ({ @@ -182,6 +183,25 @@ describe('UserOperations', () => { await expect(userOps.getUserInfo('UINVALID')).rejects.toThrow('user_not_found'); }); + + it('should limit concurrent users.info calls through the rate limiter', async () => { + let inFlight = 0; + let maxInFlight = 0; + mockClient.users.info.mockImplementation(async ({ user }: { user: string }) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight--; + return { ok: true, user: { id: user } }; + }); + + const userIds = Array.from({ length: 10 }, (_, index) => `U${index}`); + await Promise.all(userIds.map((userId) => userOps.getUserInfo(userId))); + + expect(mockClient.users.info).toHaveBeenCalledTimes(10); + expect(maxInFlight).toBeGreaterThan(1); + expect(maxInFlight).toBeLessThanOrEqual(RATE_LIMIT.CONCURRENT_REQUESTS); + }); }); describe('lookupByEmail', () => { diff --git a/tests/utils/terminal-sanitizer.test.ts b/tests/utils/terminal-sanitizer.test.ts index 7d3abb7..4b8a139 100644 --- a/tests/utils/terminal-sanitizer.test.ts +++ b/tests/utils/terminal-sanitizer.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { sanitizeTerminalData, sanitizeTerminalText } from '../../src/utils/terminal-sanitizer'; +import { + sanitizeSingleLineText, + sanitizeTerminalData, + sanitizeTerminalText, +} from '../../src/utils/terminal-sanitizer'; describe('terminal-sanitizer', () => { it('removes ANSI escape sequences and control characters', () => { @@ -31,4 +35,22 @@ describe('terminal-sanitizer', () => { expect(sanitizeTerminalData({ createdAt: date })).toEqual({ createdAt: date }); }); + + describe('sanitizeSingleLineText', () => { + it('collapses newlines and tabs into single spaces', () => { + expect(sanitizeSingleLineText('a\nb\tc')).toBe('a b c'); + }); + + it('collapses whitespace runs and trims the result', () => { + expect(sanitizeSingleLineText(' a \r\n\t b ')).toBe('a b'); + }); + + it('removes ANSI escape sequences before collapsing', () => { + expect(sanitizeSingleLineText('red\nnext')).toBe('red next'); + }); + + it('returns empty string for empty input', () => { + expect(sanitizeSingleLineText('')).toBe(''); + }); + }); });