jira-ai 0.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/README.md +364 -0
- package/dist/cli.js +87 -0
- package/dist/commands/about.js +83 -0
- package/dist/commands/add-comment.js +109 -0
- package/dist/commands/me.js +23 -0
- package/dist/commands/project-statuses.js +23 -0
- package/dist/commands/projects.js +35 -0
- package/dist/commands/run-jql.js +44 -0
- package/dist/commands/task-with-details.js +23 -0
- package/dist/commands/update-description.js +109 -0
- package/dist/lib/formatters.js +193 -0
- package/dist/lib/jira-client.js +216 -0
- package/dist/lib/settings.js +68 -0
- package/dist/lib/utils.js +79 -0
- package/jest.config.js +21 -0
- package/package.json +47 -0
- package/settings.yaml +24 -0
- package/src/cli.ts +97 -0
- package/src/commands/about.ts +98 -0
- package/src/commands/add-comment.ts +94 -0
- package/src/commands/me.ts +18 -0
- package/src/commands/project-statuses.ts +18 -0
- package/src/commands/projects.ts +32 -0
- package/src/commands/run-jql.ts +40 -0
- package/src/commands/task-with-details.ts +18 -0
- package/src/commands/update-description.ts +94 -0
- package/src/lib/formatters.ts +224 -0
- package/src/lib/jira-client.ts +319 -0
- package/src/lib/settings.ts +77 -0
- package/src/lib/utils.ts +76 -0
- package/src/types/md-to-adf.d.ts +14 -0
- package/tests/README.md +97 -0
- package/tests/__mocks__/jira.js.ts +4 -0
- package/tests/__mocks__/md-to-adf.ts +7 -0
- package/tests/__mocks__/mdast-util-from-adf.ts +4 -0
- package/tests/__mocks__/mdast-util-to-markdown.ts +1 -0
- package/tests/add-comment.test.ts +226 -0
- package/tests/cli-permissions.test.ts +156 -0
- package/tests/jira-client.test.ts +123 -0
- package/tests/projects.test.ts +205 -0
- package/tests/settings.test.ts +288 -0
- package/tests/task-with-details.test.ts +83 -0
- package/tests/update-description.test.ts +262 -0
- package/to-do.md +9 -0
- package/tsconfig.json +18 -0
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { fromADF } from 'mdast-util-from-adf';
|
|
3
|
+
import { toMarkdown } from 'mdast-util-to-markdown';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validate required environment variables
|
|
7
|
+
*/
|
|
8
|
+
export function validateEnvVars(): void {
|
|
9
|
+
const required = [
|
|
10
|
+
'JIRA_HOST',
|
|
11
|
+
'JIRA_USER_EMAIL',
|
|
12
|
+
'JIRA_API_TOKEN',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const missing = required.filter((key) => !process.env[key]);
|
|
16
|
+
|
|
17
|
+
if (missing.length > 0) {
|
|
18
|
+
console.error(chalk.red('✗ Missing required environment variables:\n'));
|
|
19
|
+
missing.forEach((key) => console.error(chalk.red(` - ${key}`)));
|
|
20
|
+
console.log('\nPlease create a .env file with the following variables:');
|
|
21
|
+
console.log(' JIRA_HOST=https://your-domain.atlassian.net');
|
|
22
|
+
console.log(' JIRA_USER_EMAIL=your-email@example.com');
|
|
23
|
+
console.log(' JIRA_API_TOKEN=your-api-token');
|
|
24
|
+
console.log('\nGet your API token from: https://id.atlassian.com/manage-profile/security/api-tokens');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format timestamp for display
|
|
31
|
+
*/
|
|
32
|
+
export function formatTimestamp(date: string | Date): string {
|
|
33
|
+
const d = typeof date === 'string' ? new Date(date) : date;
|
|
34
|
+
return d.toLocaleString('en-US', {
|
|
35
|
+
year: 'numeric',
|
|
36
|
+
month: 'short',
|
|
37
|
+
day: 'numeric',
|
|
38
|
+
hour: '2-digit',
|
|
39
|
+
minute: '2-digit',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Truncate text to max length with ellipsis
|
|
45
|
+
*/
|
|
46
|
+
export function truncate(text: string, maxLength: number): string {
|
|
47
|
+
if (text.length <= maxLength) return text;
|
|
48
|
+
return text.substring(0, maxLength - 3) + '...';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert Atlassian Document Format (ADF) to Markdown
|
|
53
|
+
* Handles both string content and ADF JSON objects
|
|
54
|
+
*/
|
|
55
|
+
export function convertADFToMarkdown(content: any): string {
|
|
56
|
+
// If content is already a string, return it as-is
|
|
57
|
+
if (typeof content === 'string') {
|
|
58
|
+
return content;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// If content is null or undefined, return empty string
|
|
62
|
+
if (!content) {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Convert ADF to mdast, then mdast to markdown
|
|
68
|
+
const mdastTree = fromADF(content);
|
|
69
|
+
const markdown = toMarkdown(mdastTree);
|
|
70
|
+
return markdown.trim();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// If conversion fails, fall back to JSON string representation
|
|
73
|
+
console.error('Failed to convert ADF to Markdown:', error);
|
|
74
|
+
return JSON.stringify(content, null, 2);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
declare module 'md-to-adf' {
|
|
2
|
+
/**
|
|
3
|
+
* Convert Markdown to Atlassian Document Format (ADF)
|
|
4
|
+
* @param markdown - GitHub Flavored Markdown text
|
|
5
|
+
* @returns ADF document object
|
|
6
|
+
*/
|
|
7
|
+
function mdToAdf(markdown: string): {
|
|
8
|
+
version: number;
|
|
9
|
+
type: string;
|
|
10
|
+
content: any[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export = mdToAdf;
|
|
14
|
+
}
|
package/tests/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Test Suite Documentation
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This test suite provides comprehensive coverage for the Jira AI settings and permissions system.
|
|
6
|
+
|
|
7
|
+
## Test Files
|
|
8
|
+
|
|
9
|
+
### 1. `settings.test.ts` (16 tests)
|
|
10
|
+
Tests the core settings module functionality:
|
|
11
|
+
- **Loading settings**: Reading and parsing settings.yaml
|
|
12
|
+
- **Project filtering**: Testing project allow/deny lists
|
|
13
|
+
- **Command filtering**: Testing command permissions
|
|
14
|
+
- **Caching**: Ensuring settings are cached properly
|
|
15
|
+
- **Error handling**: Invalid YAML, missing files
|
|
16
|
+
|
|
17
|
+
**Coverage**: 100% of settings.ts module
|
|
18
|
+
|
|
19
|
+
### 2. `projects.test.ts` (6 tests)
|
|
20
|
+
Tests the projects command with filtering:
|
|
21
|
+
- Displaying all projects when "all" is allowed
|
|
22
|
+
- Filtering projects based on settings.yaml
|
|
23
|
+
- Handling empty results
|
|
24
|
+
- API error handling
|
|
25
|
+
|
|
26
|
+
**Coverage**: 100% of projects.ts command
|
|
27
|
+
|
|
28
|
+
### 3. `cli-permissions.test.ts` (9 tests)
|
|
29
|
+
Tests the CLI permission wrapper:
|
|
30
|
+
- Command allow/deny logic
|
|
31
|
+
- Permission enforcement at CLI level
|
|
32
|
+
- Error messages for blocked commands
|
|
33
|
+
- Integration scenarios
|
|
34
|
+
|
|
35
|
+
**Total: 31 tests passing**
|
|
36
|
+
|
|
37
|
+
## Running Tests
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Run all tests
|
|
41
|
+
npm test
|
|
42
|
+
|
|
43
|
+
# Run tests in watch mode
|
|
44
|
+
npm run test:watch
|
|
45
|
+
|
|
46
|
+
# Run tests with coverage report
|
|
47
|
+
npm run test:coverage
|
|
48
|
+
|
|
49
|
+
# Run tests with verbose output
|
|
50
|
+
npm run test:verbose
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Test Coverage
|
|
54
|
+
|
|
55
|
+
Current coverage for tested modules:
|
|
56
|
+
- `src/lib/settings.ts`: 100%
|
|
57
|
+
- `src/commands/projects.ts`: 100%
|
|
58
|
+
|
|
59
|
+
## Key Test Scenarios Covered
|
|
60
|
+
|
|
61
|
+
### Settings Module
|
|
62
|
+
✓ Load settings from YAML file
|
|
63
|
+
✓ Default to "all" when file doesn't exist
|
|
64
|
+
✓ Handle null/undefined values
|
|
65
|
+
✓ Exit on invalid YAML
|
|
66
|
+
✓ Project allow/deny filtering
|
|
67
|
+
✓ Command allow/deny filtering
|
|
68
|
+
✓ Case-sensitive project matching
|
|
69
|
+
✓ Settings caching
|
|
70
|
+
|
|
71
|
+
### Projects Command
|
|
72
|
+
✓ Show all projects when "all" allowed
|
|
73
|
+
✓ Filter to specific projects (BP, PM, PS)
|
|
74
|
+
✓ Show warning for no matches
|
|
75
|
+
✓ Handle API errors gracefully
|
|
76
|
+
|
|
77
|
+
### CLI Permissions
|
|
78
|
+
✓ Allow/deny command execution
|
|
79
|
+
✓ Display helpful error messages
|
|
80
|
+
✓ Support "all" wildcard
|
|
81
|
+
✓ Pass arguments to allowed commands
|
|
82
|
+
|
|
83
|
+
## Mock Strategy
|
|
84
|
+
|
|
85
|
+
The test suite uses Jest mocks for:
|
|
86
|
+
- **fs module**: Mocked to test file I/O without actual files
|
|
87
|
+
- **jira.js**: Mocked to avoid ES module issues
|
|
88
|
+
- **ora, chalk**: Mocked to test CLI output
|
|
89
|
+
- **console**: Spied to verify error/warning messages
|
|
90
|
+
|
|
91
|
+
## Future Test Coverage
|
|
92
|
+
|
|
93
|
+
Potential areas to expand testing:
|
|
94
|
+
- Other command modules (me, task-with-details, project-statuses)
|
|
95
|
+
- Formatters
|
|
96
|
+
- Jira client integration tests
|
|
97
|
+
- End-to-end CLI tests
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const toMarkdown = jest.fn((tree: any) => 'Mocked markdown content');
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { addCommentCommand } from '../src/commands/add-comment';
|
|
2
|
+
import * as jiraClient from '../src/lib/jira-client';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import mdToAdf from 'md-to-adf';
|
|
6
|
+
|
|
7
|
+
// Mock dependencies
|
|
8
|
+
jest.mock('fs');
|
|
9
|
+
jest.mock('md-to-adf');
|
|
10
|
+
jest.mock('../src/lib/jira-client');
|
|
11
|
+
jest.mock('../src/lib/utils');
|
|
12
|
+
jest.mock('ora', () => {
|
|
13
|
+
return jest.fn(() => ({
|
|
14
|
+
start: jest.fn().mockReturnThis(),
|
|
15
|
+
succeed: jest.fn().mockReturnThis(),
|
|
16
|
+
fail: jest.fn().mockReturnThis()
|
|
17
|
+
}));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const mockJiraClient = jiraClient as jest.Mocked<typeof jiraClient>;
|
|
21
|
+
const mockFs = fs as jest.Mocked<typeof fs>;
|
|
22
|
+
const mockMdToAdf = mdToAdf as jest.MockedFunction<typeof mdToAdf>;
|
|
23
|
+
|
|
24
|
+
describe('Add Comment Command', () => {
|
|
25
|
+
const mockIssueKey = 'TEST-123';
|
|
26
|
+
const mockFilePath = '/path/to/comment.md';
|
|
27
|
+
const mockMarkdownContent = '# Test Comment\n\nThis is a test comment.';
|
|
28
|
+
const mockAdfContent = {
|
|
29
|
+
version: 1,
|
|
30
|
+
type: 'doc',
|
|
31
|
+
content: [
|
|
32
|
+
{
|
|
33
|
+
type: 'heading',
|
|
34
|
+
attrs: { level: 1 },
|
|
35
|
+
content: [{ type: 'text', text: 'Test Comment' }]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
jest.clearAllMocks();
|
|
42
|
+
console.log = jest.fn();
|
|
43
|
+
console.error = jest.fn();
|
|
44
|
+
|
|
45
|
+
// Setup default mocks
|
|
46
|
+
jest.spyOn(mockFs, 'existsSync').mockReturnValue(true);
|
|
47
|
+
jest.spyOn(mockFs, 'readFileSync').mockReturnValue(mockMarkdownContent);
|
|
48
|
+
mockMdToAdf.mockReturnValue(mockAdfContent);
|
|
49
|
+
mockJiraClient.addIssueComment = jest.fn().mockResolvedValue(undefined);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should successfully add a comment to a Jira issue', async () => {
|
|
53
|
+
await addCommentCommand({ filePath: mockFilePath, issueKey: mockIssueKey });
|
|
54
|
+
|
|
55
|
+
expect(mockFs.existsSync).toHaveBeenCalledWith(path.resolve(mockFilePath));
|
|
56
|
+
expect(mockFs.readFileSync).toHaveBeenCalledWith(path.resolve(mockFilePath), 'utf-8');
|
|
57
|
+
expect(mockMdToAdf).toHaveBeenCalledWith(mockMarkdownContent);
|
|
58
|
+
expect(mockJiraClient.addIssueComment).toHaveBeenCalledWith(
|
|
59
|
+
mockIssueKey,
|
|
60
|
+
mockAdfContent
|
|
61
|
+
);
|
|
62
|
+
expect(console.log).toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should exit with error when issue key is empty', async () => {
|
|
66
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
67
|
+
throw new Error('Process exit');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await expect(addCommentCommand({ filePath: mockFilePath, issueKey: '' })).rejects.toThrow(
|
|
71
|
+
'Process exit'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
75
|
+
expect.stringContaining('Issue key is required')
|
|
76
|
+
);
|
|
77
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
78
|
+
|
|
79
|
+
processExitSpy.mockRestore();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should exit with error when file path is empty', async () => {
|
|
83
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
84
|
+
throw new Error('Process exit');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await expect(addCommentCommand({ filePath: '', issueKey: mockIssueKey })).rejects.toThrow(
|
|
88
|
+
'Process exit'
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
92
|
+
expect.stringContaining('File path is required')
|
|
93
|
+
);
|
|
94
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
95
|
+
|
|
96
|
+
processExitSpy.mockRestore();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should exit with error when file does not exist', async () => {
|
|
100
|
+
jest.spyOn(mockFs, 'existsSync').mockReturnValue(false);
|
|
101
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
102
|
+
throw new Error('Process exit');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await expect(
|
|
106
|
+
addCommentCommand({ filePath: mockFilePath, issueKey: mockIssueKey })
|
|
107
|
+
).rejects.toThrow('Process exit');
|
|
108
|
+
|
|
109
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
110
|
+
expect.stringContaining('File not found')
|
|
111
|
+
);
|
|
112
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
113
|
+
|
|
114
|
+
processExitSpy.mockRestore();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should exit with error when file read fails', async () => {
|
|
118
|
+
const readError = new Error('Permission denied');
|
|
119
|
+
jest.spyOn(mockFs, 'readFileSync').mockImplementation(() => {
|
|
120
|
+
throw readError;
|
|
121
|
+
});
|
|
122
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
123
|
+
throw new Error('Process exit');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await expect(
|
|
127
|
+
addCommentCommand({ filePath: mockFilePath, issueKey: mockIssueKey })
|
|
128
|
+
).rejects.toThrow('Process exit');
|
|
129
|
+
|
|
130
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
131
|
+
expect.stringContaining('Error reading file')
|
|
132
|
+
);
|
|
133
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
134
|
+
expect.stringContaining('Permission denied')
|
|
135
|
+
);
|
|
136
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
137
|
+
|
|
138
|
+
processExitSpy.mockRestore();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should exit with error when file is empty', async () => {
|
|
142
|
+
jest.spyOn(mockFs, 'readFileSync').mockReturnValue(' \n \t ');
|
|
143
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
144
|
+
throw new Error('Process exit');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await expect(
|
|
148
|
+
addCommentCommand({ filePath: mockFilePath, issueKey: mockIssueKey })
|
|
149
|
+
).rejects.toThrow('Process exit');
|
|
150
|
+
|
|
151
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
152
|
+
expect.stringContaining('File is empty')
|
|
153
|
+
);
|
|
154
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
155
|
+
|
|
156
|
+
processExitSpy.mockRestore();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should exit with error when markdown conversion fails', async () => {
|
|
160
|
+
const conversionError = new Error('Invalid markdown syntax');
|
|
161
|
+
mockMdToAdf.mockImplementation(() => {
|
|
162
|
+
throw conversionError;
|
|
163
|
+
});
|
|
164
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
165
|
+
throw new Error('Process exit');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
addCommentCommand({ filePath: mockFilePath, issueKey: mockIssueKey })
|
|
170
|
+
).rejects.toThrow('Process exit');
|
|
171
|
+
|
|
172
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
173
|
+
expect.stringContaining('Error converting Markdown to ADF')
|
|
174
|
+
);
|
|
175
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
176
|
+
expect.stringContaining('Invalid markdown syntax')
|
|
177
|
+
);
|
|
178
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
179
|
+
|
|
180
|
+
processExitSpy.mockRestore();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should exit with error and hint when issue not found (404)', async () => {
|
|
184
|
+
const apiError = new Error('Issue not found (404)');
|
|
185
|
+
mockJiraClient.addIssueComment = jest.fn().mockRejectedValue(apiError);
|
|
186
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
187
|
+
throw new Error('Process exit');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
addCommentCommand({ filePath: mockFilePath, issueKey: mockIssueKey })
|
|
192
|
+
).rejects.toThrow('Process exit');
|
|
193
|
+
|
|
194
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
195
|
+
expect.stringContaining('Issue not found (404)')
|
|
196
|
+
);
|
|
197
|
+
expect(console.log).toHaveBeenCalledWith(
|
|
198
|
+
expect.stringContaining('Check that the issue key is correct')
|
|
199
|
+
);
|
|
200
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
201
|
+
|
|
202
|
+
processExitSpy.mockRestore();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should exit with error and hint when permission denied (403)', async () => {
|
|
206
|
+
const apiError = new Error('Permission denied (403)');
|
|
207
|
+
mockJiraClient.addIssueComment = jest.fn().mockRejectedValue(apiError);
|
|
208
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
209
|
+
throw new Error('Process exit');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await expect(
|
|
213
|
+
addCommentCommand({ filePath: mockFilePath, issueKey: mockIssueKey })
|
|
214
|
+
).rejects.toThrow('Process exit');
|
|
215
|
+
|
|
216
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
217
|
+
expect.stringContaining('Permission denied (403)')
|
|
218
|
+
);
|
|
219
|
+
expect(console.log).toHaveBeenCalledWith(
|
|
220
|
+
expect.stringContaining('You may not have permission to comment on this issue')
|
|
221
|
+
);
|
|
222
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
223
|
+
|
|
224
|
+
processExitSpy.mockRestore();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as settings from '../src/lib/settings';
|
|
2
|
+
|
|
3
|
+
// Mock all dependencies
|
|
4
|
+
jest.mock('../src/lib/settings');
|
|
5
|
+
jest.mock('../src/commands/me');
|
|
6
|
+
jest.mock('../src/commands/projects');
|
|
7
|
+
jest.mock('../src/commands/task-with-details');
|
|
8
|
+
jest.mock('../src/commands/project-statuses');
|
|
9
|
+
jest.mock('../src/commands/about');
|
|
10
|
+
jest.mock('../src/lib/utils', () => ({
|
|
11
|
+
validateEnvVars: jest.fn()
|
|
12
|
+
}));
|
|
13
|
+
jest.mock('dotenv', () => ({
|
|
14
|
+
config: jest.fn()
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const mockSettings = settings as jest.Mocked<typeof settings>;
|
|
18
|
+
|
|
19
|
+
describe('CLI Command Permissions', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
console.log = jest.fn();
|
|
23
|
+
console.error = jest.fn();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('Command permission checks', () => {
|
|
27
|
+
it('should allow execution when command is in allowed list', () => {
|
|
28
|
+
mockSettings.isCommandAllowed.mockReturnValue(true);
|
|
29
|
+
mockSettings.getAllowedCommands.mockReturnValue(['me', 'projects']);
|
|
30
|
+
|
|
31
|
+
expect(mockSettings.isCommandAllowed('me')).toBe(true);
|
|
32
|
+
expect(mockSettings.isCommandAllowed('projects')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should deny execution when command is not in allowed list', () => {
|
|
36
|
+
mockSettings.isCommandAllowed.mockImplementation((cmd: string) =>
|
|
37
|
+
['me', 'projects'].includes(cmd)
|
|
38
|
+
);
|
|
39
|
+
mockSettings.getAllowedCommands.mockReturnValue(['me', 'projects']);
|
|
40
|
+
|
|
41
|
+
expect(mockSettings.isCommandAllowed('task-with-details')).toBe(false);
|
|
42
|
+
expect(mockSettings.isCommandAllowed('project-statuses')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should allow all commands when "all" is specified', () => {
|
|
46
|
+
mockSettings.isCommandAllowed.mockReturnValue(true);
|
|
47
|
+
mockSettings.getAllowedCommands.mockReturnValue(['all']);
|
|
48
|
+
|
|
49
|
+
expect(mockSettings.isCommandAllowed('me')).toBe(true);
|
|
50
|
+
expect(mockSettings.isCommandAllowed('projects')).toBe(true);
|
|
51
|
+
expect(mockSettings.isCommandAllowed('task-with-details')).toBe(true);
|
|
52
|
+
expect(mockSettings.isCommandAllowed('project-statuses')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('Permission wrapper behavior', () => {
|
|
57
|
+
it('should execute command when permission is granted', async () => {
|
|
58
|
+
mockSettings.isCommandAllowed.mockReturnValue(true);
|
|
59
|
+
|
|
60
|
+
const mockCommand = jest.fn().mockResolvedValue(undefined);
|
|
61
|
+
const wrappedCommand = async (...args: any[]) => {
|
|
62
|
+
if (!mockSettings.isCommandAllowed('me')) {
|
|
63
|
+
console.error('Command not allowed');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
return mockCommand(...args);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
await wrappedCommand('arg1', 'arg2');
|
|
70
|
+
|
|
71
|
+
expect(mockCommand).toHaveBeenCalledWith('arg1', 'arg2');
|
|
72
|
+
expect(console.error).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should block command execution when permission is denied', async () => {
|
|
76
|
+
mockSettings.isCommandAllowed.mockReturnValue(false);
|
|
77
|
+
mockSettings.getAllowedCommands.mockReturnValue(['me', 'projects']);
|
|
78
|
+
|
|
79
|
+
const mockCommand = jest.fn().mockResolvedValue(undefined);
|
|
80
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
81
|
+
throw new Error('Process exit');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const wrappedCommand = async (...args: any[]) => {
|
|
85
|
+
if (!mockSettings.isCommandAllowed('task-with-details')) {
|
|
86
|
+
console.error("Command 'task-with-details' is not allowed.");
|
|
87
|
+
console.log('Allowed commands: ' + mockSettings.getAllowedCommands().join(', '));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
return mockCommand(...args);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
await expect(wrappedCommand()).rejects.toThrow('Process exit');
|
|
94
|
+
|
|
95
|
+
expect(mockCommand).not.toHaveBeenCalled();
|
|
96
|
+
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('not allowed'));
|
|
97
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('me, projects'));
|
|
98
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
99
|
+
|
|
100
|
+
processExitSpy.mockRestore();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should pass arguments correctly to allowed commands', async () => {
|
|
104
|
+
mockSettings.isCommandAllowed.mockReturnValue(true);
|
|
105
|
+
|
|
106
|
+
const mockCommand = jest.fn().mockResolvedValue(undefined);
|
|
107
|
+
const wrappedCommand = async (...args: any[]) => {
|
|
108
|
+
if (!mockSettings.isCommandAllowed('task-with-details')) {
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
return mockCommand(...args);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
await wrappedCommand('BP-123');
|
|
115
|
+
|
|
116
|
+
expect(mockCommand).toHaveBeenCalledWith('BP-123');
|
|
117
|
+
expect(mockCommand).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('Integration scenarios', () => {
|
|
122
|
+
it('should handle scenario: only me and projects allowed', () => {
|
|
123
|
+
mockSettings.isCommandAllowed.mockImplementation((cmd: string) =>
|
|
124
|
+
['me', 'projects'].includes(cmd)
|
|
125
|
+
);
|
|
126
|
+
mockSettings.getAllowedCommands.mockReturnValue(['me', 'projects']);
|
|
127
|
+
|
|
128
|
+
expect(mockSettings.isCommandAllowed('me')).toBe(true);
|
|
129
|
+
expect(mockSettings.isCommandAllowed('projects')).toBe(true);
|
|
130
|
+
expect(mockSettings.isCommandAllowed('task-with-details')).toBe(false);
|
|
131
|
+
expect(mockSettings.isCommandAllowed('project-statuses')).toBe(false);
|
|
132
|
+
expect(mockSettings.isCommandAllowed('about')).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle scenario: all commands allowed', () => {
|
|
136
|
+
mockSettings.isCommandAllowed.mockReturnValue(true);
|
|
137
|
+
mockSettings.getAllowedCommands.mockReturnValue(['all']);
|
|
138
|
+
|
|
139
|
+
const commands = ['me', 'projects', 'task-with-details', 'project-statuses', 'about'];
|
|
140
|
+
commands.forEach(cmd => {
|
|
141
|
+
expect(mockSettings.isCommandAllowed(cmd)).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should handle scenario: no commands allowed except about', () => {
|
|
146
|
+
mockSettings.isCommandAllowed.mockImplementation((cmd: string) =>
|
|
147
|
+
cmd === 'about'
|
|
148
|
+
);
|
|
149
|
+
mockSettings.getAllowedCommands.mockReturnValue([]);
|
|
150
|
+
|
|
151
|
+
expect(mockSettings.isCommandAllowed('me')).toBe(false);
|
|
152
|
+
expect(mockSettings.isCommandAllowed('projects')).toBe(false);
|
|
153
|
+
expect(mockSettings.isCommandAllowed('about')).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|