@friggframework/admin-scripts 2.0.0--canary.517.a37d697.0 → 2.0.0--canary.522.cbd3d5a.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/index.js +2 -2
- package/package.json +6 -6
- package/src/application/__tests__/admin-frigg-commands.test.js +18 -18
- package/src/application/__tests__/script-runner.test.js +12 -12
- package/src/application/admin-frigg-commands.js +7 -7
- package/src/application/admin-script-base.js +4 -2
- package/src/application/script-runner.js +4 -4
- package/src/infrastructure/__tests__/admin-auth-middleware.test.js +95 -32
- package/src/infrastructure/__tests__/admin-script-router.test.js +25 -24
- package/src/infrastructure/admin-auth-middleware.js +43 -5
- package/src/infrastructure/admin-script-router.js +16 -14
- package/src/infrastructure/script-executor-handler.js +2 -2
package/index.js
CHANGED
|
@@ -12,7 +12,7 @@ const { AdminFriggCommands, createAdminFriggCommands } = require('./src/applicat
|
|
|
12
12
|
const { ScriptRunner, createScriptRunner } = require('./src/application/script-runner');
|
|
13
13
|
|
|
14
14
|
// Infrastructure
|
|
15
|
-
const {
|
|
15
|
+
const { adminAuthMiddleware } = require('./src/infrastructure/admin-auth-middleware');
|
|
16
16
|
const { router, app, handler: routerHandler } = require('./src/infrastructure/admin-script-router');
|
|
17
17
|
const { handler: executorHandler } = require('./src/infrastructure/script-executor-handler');
|
|
18
18
|
|
|
@@ -45,7 +45,7 @@ module.exports = {
|
|
|
45
45
|
createScriptRunner,
|
|
46
46
|
|
|
47
47
|
// Infrastructure layer
|
|
48
|
-
|
|
48
|
+
adminAuthMiddleware,
|
|
49
49
|
router,
|
|
50
50
|
app,
|
|
51
51
|
routerHandler,
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/admin-scripts",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.
|
|
4
|
+
"version": "2.0.0--canary.522.cbd3d5a.0",
|
|
5
5
|
"description": "Admin Script Runner for Frigg - Execute maintenance and operational scripts in hosted environments",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@aws-sdk/client-scheduler": "^3.588.0",
|
|
8
|
-
"@friggframework/core": "2.0.0--canary.
|
|
8
|
+
"@friggframework/core": "2.0.0--canary.522.cbd3d5a.0",
|
|
9
9
|
"bcryptjs": "^2.4.3",
|
|
10
10
|
"express": "^4.18.2",
|
|
11
11
|
"lodash": "4.17.21",
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"uuid": "^9.0.1"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
18
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
19
|
-
"@friggframework/test": "2.0.0--canary.
|
|
17
|
+
"@friggframework/eslint-config": "2.0.0--canary.522.cbd3d5a.0",
|
|
18
|
+
"@friggframework/prettier-config": "2.0.0--canary.522.cbd3d5a.0",
|
|
19
|
+
"@friggframework/test": "2.0.0--canary.522.cbd3d5a.0",
|
|
20
20
|
"chai": "^4.3.6",
|
|
21
21
|
"eslint": "^8.22.0",
|
|
22
22
|
"jest": "^29.7.0",
|
|
@@ -49,5 +49,5 @@
|
|
|
49
49
|
"maintenance",
|
|
50
50
|
"operations"
|
|
51
51
|
],
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "cbd3d5a81315519842f308fc0bd786a3f8786b79"
|
|
53
53
|
}
|
|
@@ -5,7 +5,7 @@ jest.mock('@friggframework/core/integrations/repositories/integration-repository
|
|
|
5
5
|
jest.mock('@friggframework/core/user/repositories/user-repository-factory');
|
|
6
6
|
jest.mock('@friggframework/core/modules/repositories/module-repository-factory');
|
|
7
7
|
jest.mock('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
8
|
-
jest.mock('@friggframework/core/admin-scripts/repositories/
|
|
8
|
+
jest.mock('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory');
|
|
9
9
|
jest.mock('@friggframework/core/queues');
|
|
10
10
|
|
|
11
11
|
describe('AdminFriggCommands', () => {
|
|
@@ -13,7 +13,7 @@ describe('AdminFriggCommands', () => {
|
|
|
13
13
|
let mockUserRepo;
|
|
14
14
|
let mockModuleRepo;
|
|
15
15
|
let mockCredentialRepo;
|
|
16
|
-
let
|
|
16
|
+
let mockScriptExecutionRepo;
|
|
17
17
|
let mockQueuerUtil;
|
|
18
18
|
|
|
19
19
|
beforeEach(() => {
|
|
@@ -46,8 +46,8 @@ describe('AdminFriggCommands', () => {
|
|
|
46
46
|
updateCredential: jest.fn(),
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
mockScriptExecutionRepo = {
|
|
50
|
+
appendExecutionLog: jest.fn().mockResolvedValue(undefined),
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
mockQueuerUtil = {
|
|
@@ -60,14 +60,14 @@ describe('AdminFriggCommands', () => {
|
|
|
60
60
|
const { createUserRepository } = require('@friggframework/core/user/repositories/user-repository-factory');
|
|
61
61
|
const { createModuleRepository } = require('@friggframework/core/modules/repositories/module-repository-factory');
|
|
62
62
|
const { createCredentialRepository } = require('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
63
|
-
const {
|
|
63
|
+
const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory');
|
|
64
64
|
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
65
65
|
|
|
66
66
|
createIntegrationRepository.mockReturnValue(mockIntegrationRepo);
|
|
67
67
|
createUserRepository.mockReturnValue(mockUserRepo);
|
|
68
68
|
createModuleRepository.mockReturnValue(mockModuleRepo);
|
|
69
69
|
createCredentialRepository.mockReturnValue(mockCredentialRepo);
|
|
70
|
-
|
|
70
|
+
createScriptExecutionRepository.mockReturnValue(mockScriptExecutionRepo);
|
|
71
71
|
|
|
72
72
|
// Mock QueuerUtil methods
|
|
73
73
|
QueuerUtil.send = mockQueuerUtil.send;
|
|
@@ -158,16 +158,16 @@ describe('AdminFriggCommands', () => {
|
|
|
158
158
|
expect(repo).toBe(mockCredentialRepo);
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
it('creates
|
|
161
|
+
it('creates scriptExecutionRepository on first access', () => {
|
|
162
162
|
const commands = new AdminFriggCommands();
|
|
163
|
-
const {
|
|
163
|
+
const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory');
|
|
164
164
|
|
|
165
|
-
expect(
|
|
165
|
+
expect(createScriptExecutionRepository).not.toHaveBeenCalled();
|
|
166
166
|
|
|
167
|
-
const repo = commands.
|
|
167
|
+
const repo = commands.scriptExecutionRepository;
|
|
168
168
|
|
|
169
|
-
expect(
|
|
170
|
-
expect(repo).toBe(
|
|
169
|
+
expect(createScriptExecutionRepository).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(repo).toBe(mockScriptExecutionRepo);
|
|
171
171
|
});
|
|
172
172
|
});
|
|
173
173
|
|
|
@@ -551,15 +551,15 @@ describe('AdminFriggCommands', () => {
|
|
|
551
551
|
it('log() persists if executionId set', async () => {
|
|
552
552
|
const commands = new AdminFriggCommands({ executionId: 'exec_123' });
|
|
553
553
|
// Force repository creation
|
|
554
|
-
commands.
|
|
554
|
+
commands.scriptExecutionRepository;
|
|
555
555
|
|
|
556
556
|
commands.log('warn', 'Warning message', { detail: 'xyz' });
|
|
557
557
|
|
|
558
558
|
// Give async operation a chance to execute
|
|
559
559
|
await new Promise(resolve => setImmediate(resolve));
|
|
560
560
|
|
|
561
|
-
expect(
|
|
562
|
-
const callArgs =
|
|
561
|
+
expect(mockScriptExecutionRepo.appendExecutionLog).toHaveBeenCalled();
|
|
562
|
+
const callArgs = mockScriptExecutionRepo.appendExecutionLog.mock.calls[0];
|
|
563
563
|
expect(callArgs[0]).toBe('exec_123');
|
|
564
564
|
expect(callArgs[1].level).toBe('warn');
|
|
565
565
|
expect(callArgs[1].message).toBe('Warning message');
|
|
@@ -572,14 +572,14 @@ describe('AdminFriggCommands', () => {
|
|
|
572
572
|
|
|
573
573
|
await new Promise(resolve => setImmediate(resolve));
|
|
574
574
|
|
|
575
|
-
expect(
|
|
575
|
+
expect(mockScriptExecutionRepo.appendExecutionLog).not.toHaveBeenCalled();
|
|
576
576
|
});
|
|
577
577
|
|
|
578
578
|
it('log() handles persistence failure gracefully', async () => {
|
|
579
579
|
const commands = new AdminFriggCommands({ executionId: 'exec_123' });
|
|
580
580
|
// Force repository creation
|
|
581
|
-
commands.
|
|
582
|
-
|
|
581
|
+
commands.scriptExecutionRepository;
|
|
582
|
+
mockScriptExecutionRepo.appendExecutionLog.mockRejectedValue(new Error('DB Error'));
|
|
583
583
|
|
|
584
584
|
// Should not throw
|
|
585
585
|
expect(() => commands.log('error', 'Test error')).not.toThrow();
|
|
@@ -36,9 +36,9 @@ describe('ScriptRunner', () => {
|
|
|
36
36
|
scriptFactory = new ScriptFactory([TestScript]);
|
|
37
37
|
|
|
38
38
|
mockCommands = {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
createScriptExecution: jest.fn(),
|
|
40
|
+
updateScriptExecutionStatus: jest.fn(),
|
|
41
|
+
completeScriptExecution: jest.fn(),
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
mockFrigg = {
|
|
@@ -49,11 +49,11 @@ describe('ScriptRunner', () => {
|
|
|
49
49
|
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
50
50
|
createAdminFriggCommands.mockReturnValue(mockFrigg);
|
|
51
51
|
|
|
52
|
-
mockCommands.
|
|
52
|
+
mockCommands.createScriptExecution.mockResolvedValue({
|
|
53
53
|
id: 'exec-123',
|
|
54
54
|
});
|
|
55
|
-
mockCommands.
|
|
56
|
-
mockCommands.
|
|
55
|
+
mockCommands.updateScriptExecutionStatus.mockResolvedValue({});
|
|
56
|
+
mockCommands.completeScriptExecution.mockResolvedValue({ success: true });
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
afterEach(() => {
|
|
@@ -76,7 +76,7 @@ describe('ScriptRunner', () => {
|
|
|
76
76
|
expect(result.executionId).toBe('exec-123');
|
|
77
77
|
expect(result.metrics.durationMs).toBeGreaterThanOrEqual(0);
|
|
78
78
|
|
|
79
|
-
expect(mockCommands.
|
|
79
|
+
expect(mockCommands.createScriptExecution).toHaveBeenCalledWith({
|
|
80
80
|
scriptName: 'test-script',
|
|
81
81
|
scriptVersion: '1.0.0',
|
|
82
82
|
trigger: 'MANUAL',
|
|
@@ -85,12 +85,12 @@ describe('ScriptRunner', () => {
|
|
|
85
85
|
audit: { apiKeyName: 'test-key' },
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
expect(mockCommands.
|
|
88
|
+
expect(mockCommands.updateScriptExecutionStatus).toHaveBeenCalledWith(
|
|
89
89
|
'exec-123',
|
|
90
90
|
'RUNNING'
|
|
91
91
|
);
|
|
92
92
|
|
|
93
|
-
expect(mockCommands.
|
|
93
|
+
expect(mockCommands.completeScriptExecution).toHaveBeenCalledWith(
|
|
94
94
|
'exec-123',
|
|
95
95
|
expect.objectContaining({
|
|
96
96
|
status: 'COMPLETED',
|
|
@@ -128,7 +128,7 @@ describe('ScriptRunner', () => {
|
|
|
128
128
|
expect(result.scriptName).toBe('failing-script');
|
|
129
129
|
expect(result.error.message).toBe('Script failed');
|
|
130
130
|
|
|
131
|
-
expect(mockCommands.
|
|
131
|
+
expect(mockCommands.completeScriptExecution).toHaveBeenCalledWith(
|
|
132
132
|
'exec-123',
|
|
133
133
|
expect.objectContaining({
|
|
134
134
|
status: 'FAILED',
|
|
@@ -178,8 +178,8 @@ describe('ScriptRunner', () => {
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
expect(result.executionId).toBe('existing-exec-456');
|
|
181
|
-
expect(mockCommands.
|
|
182
|
-
expect(mockCommands.
|
|
181
|
+
expect(mockCommands.createScriptExecution).not.toHaveBeenCalled();
|
|
182
|
+
expect(mockCommands.updateScriptExecutionStatus).toHaveBeenCalledWith(
|
|
183
183
|
'existing-exec-456',
|
|
184
184
|
'RUNNING'
|
|
185
185
|
);
|
|
@@ -25,7 +25,7 @@ class AdminFriggCommands {
|
|
|
25
25
|
this._userRepository = null;
|
|
26
26
|
this._moduleRepository = null;
|
|
27
27
|
this._credentialRepository = null;
|
|
28
|
-
this.
|
|
28
|
+
this._scriptExecutionRepository = null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
// ==================== LAZY-LOADED REPOSITORIES ====================
|
|
@@ -62,12 +62,12 @@ class AdminFriggCommands {
|
|
|
62
62
|
return this._credentialRepository;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
get
|
|
66
|
-
if (!this.
|
|
67
|
-
const {
|
|
68
|
-
this.
|
|
65
|
+
get scriptExecutionRepository() {
|
|
66
|
+
if (!this._scriptExecutionRepository) {
|
|
67
|
+
const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory');
|
|
68
|
+
this._scriptExecutionRepository = createScriptExecutionRepository();
|
|
69
69
|
}
|
|
70
|
-
return this.
|
|
70
|
+
return this._scriptExecutionRepository;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// ==================== INTEGRATION QUERIES ====================
|
|
@@ -209,7 +209,7 @@ class AdminFriggCommands {
|
|
|
209
209
|
|
|
210
210
|
// Persist to execution record if we have an executionId
|
|
211
211
|
if (this.executionId) {
|
|
212
|
-
this.
|
|
212
|
+
this.scriptExecutionRepository.appendExecutionLog(this.executionId, entry)
|
|
213
213
|
.catch(err => console.error('Failed to persist log:', err));
|
|
214
214
|
}
|
|
215
215
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory');
|
|
2
|
+
const { createAdminApiKeyRepository } = require('@friggframework/core/admin-scripts/repositories/admin-api-key-repository-factory');
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Admin Script Base Class
|
|
@@ -86,7 +87,8 @@ class AdminScriptBase {
|
|
|
86
87
|
this.integrationFactory = params.integrationFactory || null;
|
|
87
88
|
|
|
88
89
|
// OPTIONAL: Injected repositories (for testing or custom implementations)
|
|
89
|
-
this.
|
|
90
|
+
this.scriptExecutionRepository = params.scriptExecutionRepository || null;
|
|
91
|
+
this.adminApiKeyRepository = params.adminApiKeyRepository || null;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
/**
|
|
@@ -50,7 +50,7 @@ class ScriptRunner {
|
|
|
50
50
|
|
|
51
51
|
// Create execution record if not provided
|
|
52
52
|
if (!executionId) {
|
|
53
|
-
const execution = await this.commands.
|
|
53
|
+
const execution = await this.commands.createScriptExecution({
|
|
54
54
|
scriptName,
|
|
55
55
|
scriptVersion: definition.version,
|
|
56
56
|
trigger,
|
|
@@ -66,7 +66,7 @@ class ScriptRunner {
|
|
|
66
66
|
try {
|
|
67
67
|
// Update status to RUNNING (skip in dry-run)
|
|
68
68
|
if (!dryRun) {
|
|
69
|
-
await this.commands.
|
|
69
|
+
await this.commands.updateScriptExecutionStatus(executionId, 'RUNNING');
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// Create frigg commands for the script
|
|
@@ -99,7 +99,7 @@ class ScriptRunner {
|
|
|
99
99
|
|
|
100
100
|
// Complete execution (skip in dry-run)
|
|
101
101
|
if (!dryRun) {
|
|
102
|
-
await this.commands.
|
|
102
|
+
await this.commands.completeScriptExecution(executionId, {
|
|
103
103
|
status: 'COMPLETED',
|
|
104
104
|
output,
|
|
105
105
|
metrics: {
|
|
@@ -140,7 +140,7 @@ class ScriptRunner {
|
|
|
140
140
|
|
|
141
141
|
// Record failure (skip in dry-run)
|
|
142
142
|
if (!dryRun) {
|
|
143
|
-
await this.commands.
|
|
143
|
+
await this.commands.completeScriptExecution(executionId, {
|
|
144
144
|
status: 'FAILED',
|
|
145
145
|
error: {
|
|
146
146
|
name: error.name,
|
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { adminAuthMiddleware } = require('../admin-auth-middleware');
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Mock the admin script commands
|
|
4
|
+
jest.mock('@friggframework/core/application/commands/admin-script-commands', () => ({
|
|
5
|
+
createAdminScriptCommands: jest.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands');
|
|
9
|
+
|
|
10
|
+
describe('adminAuthMiddleware', () => {
|
|
4
11
|
let mockReq;
|
|
5
12
|
let mockRes;
|
|
6
13
|
let mockNext;
|
|
7
|
-
let
|
|
14
|
+
let mockCommands;
|
|
8
15
|
|
|
9
16
|
beforeEach(() => {
|
|
10
|
-
originalEnv = process.env.ADMIN_API_KEY;
|
|
11
|
-
process.env.ADMIN_API_KEY = 'test-admin-key-123';
|
|
12
|
-
|
|
13
17
|
mockReq = {
|
|
14
18
|
headers: {},
|
|
19
|
+
ip: '127.0.0.1',
|
|
15
20
|
};
|
|
16
21
|
|
|
17
22
|
mockRes = {
|
|
@@ -20,66 +25,124 @@ describe('validateAdminApiKey', () => {
|
|
|
20
25
|
};
|
|
21
26
|
|
|
22
27
|
mockNext = jest.fn();
|
|
28
|
+
|
|
29
|
+
mockCommands = {
|
|
30
|
+
validateAdminApiKey: jest.fn(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
23
34
|
});
|
|
24
35
|
|
|
25
36
|
afterEach(() => {
|
|
26
|
-
if (originalEnv) {
|
|
27
|
-
process.env.ADMIN_API_KEY = originalEnv;
|
|
28
|
-
} else {
|
|
29
|
-
delete process.env.ADMIN_API_KEY;
|
|
30
|
-
}
|
|
31
37
|
jest.clearAllMocks();
|
|
32
38
|
});
|
|
33
39
|
|
|
34
|
-
describe('
|
|
35
|
-
it('should reject
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
validateAdminApiKey(mockReq, mockRes, mockNext);
|
|
40
|
+
describe('Authorization header validation', () => {
|
|
41
|
+
it('should reject request without Authorization header', async () => {
|
|
42
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
39
43
|
|
|
40
44
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
|
41
45
|
expect(mockRes.json).toHaveBeenCalledWith({
|
|
42
|
-
error: '
|
|
43
|
-
|
|
46
|
+
error: 'Missing or invalid Authorization header',
|
|
47
|
+
code: 'MISSING_AUTH',
|
|
44
48
|
});
|
|
45
49
|
expect(mockNext).not.toHaveBeenCalled();
|
|
46
50
|
});
|
|
47
|
-
});
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
it('should reject request with invalid Authorization format', async () => {
|
|
53
|
+
mockReq.headers.authorization = 'InvalidFormat key123';
|
|
54
|
+
|
|
55
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
52
56
|
|
|
53
57
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
|
54
58
|
expect(mockRes.json).toHaveBeenCalledWith({
|
|
55
|
-
error: '
|
|
56
|
-
|
|
59
|
+
error: 'Missing or invalid Authorization header',
|
|
60
|
+
code: 'MISSING_AUTH',
|
|
57
61
|
});
|
|
58
62
|
expect(mockNext).not.toHaveBeenCalled();
|
|
59
63
|
});
|
|
60
64
|
});
|
|
61
65
|
|
|
62
66
|
describe('API key validation', () => {
|
|
63
|
-
it('should reject request with invalid API key', () => {
|
|
64
|
-
mockReq.headers
|
|
67
|
+
it('should reject request with invalid API key', async () => {
|
|
68
|
+
mockReq.headers.authorization = 'Bearer invalid-key';
|
|
69
|
+
mockCommands.validateAdminApiKey.mockResolvedValue({
|
|
70
|
+
error: 401,
|
|
71
|
+
reason: 'Invalid API key',
|
|
72
|
+
code: 'INVALID_API_KEY',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
76
|
+
|
|
77
|
+
expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith('invalid-key');
|
|
78
|
+
expect(mockRes.status).toHaveBeenCalledWith(401);
|
|
79
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
80
|
+
error: 'Invalid API key',
|
|
81
|
+
code: 'INVALID_API_KEY',
|
|
82
|
+
});
|
|
83
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
65
85
|
|
|
66
|
-
|
|
86
|
+
it('should reject request with expired API key', async () => {
|
|
87
|
+
mockReq.headers.authorization = 'Bearer expired-key';
|
|
88
|
+
mockCommands.validateAdminApiKey.mockResolvedValue({
|
|
89
|
+
error: 401,
|
|
90
|
+
reason: 'API key has expired',
|
|
91
|
+
code: 'EXPIRED_API_KEY',
|
|
92
|
+
});
|
|
67
93
|
|
|
94
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
95
|
+
|
|
96
|
+
expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith('expired-key');
|
|
68
97
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
|
69
98
|
expect(mockRes.json).toHaveBeenCalledWith({
|
|
70
|
-
error: '
|
|
71
|
-
|
|
99
|
+
error: 'API key has expired',
|
|
100
|
+
code: 'EXPIRED_API_KEY',
|
|
72
101
|
});
|
|
73
102
|
expect(mockNext).not.toHaveBeenCalled();
|
|
74
103
|
});
|
|
75
104
|
|
|
76
|
-
it('should accept request with valid API key', () => {
|
|
77
|
-
|
|
105
|
+
it('should accept request with valid API key', async () => {
|
|
106
|
+
const validKey = 'valid-api-key-123';
|
|
107
|
+
mockReq.headers.authorization = `Bearer ${validKey}`;
|
|
108
|
+
mockCommands.validateAdminApiKey.mockResolvedValue({
|
|
109
|
+
valid: true,
|
|
110
|
+
apiKey: {
|
|
111
|
+
id: 'key-id-1',
|
|
112
|
+
name: 'test-key',
|
|
113
|
+
keyLast4: 'e123',
|
|
114
|
+
},
|
|
115
|
+
});
|
|
78
116
|
|
|
79
|
-
|
|
117
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
80
118
|
|
|
119
|
+
expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith(validKey);
|
|
120
|
+
expect(mockReq.adminApiKey).toBeDefined();
|
|
121
|
+
expect(mockReq.adminApiKey.name).toBe('test-key');
|
|
122
|
+
expect(mockReq.adminAudit).toBeDefined();
|
|
123
|
+
expect(mockReq.adminAudit.apiKeyName).toBe('test-key');
|
|
124
|
+
expect(mockReq.adminAudit.apiKeyLast4).toBe('e123');
|
|
125
|
+
expect(mockReq.adminAudit.ipAddress).toBe('127.0.0.1');
|
|
81
126
|
expect(mockNext).toHaveBeenCalled();
|
|
82
127
|
expect(mockRes.status).not.toHaveBeenCalled();
|
|
83
128
|
});
|
|
84
129
|
});
|
|
130
|
+
|
|
131
|
+
describe('Error handling', () => {
|
|
132
|
+
it('should handle validation errors gracefully', async () => {
|
|
133
|
+
mockReq.headers.authorization = 'Bearer some-key';
|
|
134
|
+
mockCommands.validateAdminApiKey.mockRejectedValue(
|
|
135
|
+
new Error('Database error')
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await adminAuthMiddleware(mockReq, mockRes, mockNext);
|
|
139
|
+
|
|
140
|
+
expect(mockRes.status).toHaveBeenCalledWith(500);
|
|
141
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
142
|
+
error: 'Authentication failed',
|
|
143
|
+
code: 'AUTH_ERROR',
|
|
144
|
+
});
|
|
145
|
+
expect(mockNext).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
85
148
|
});
|
|
@@ -4,8 +4,13 @@ const { AdminScriptBase } = require('../../application/admin-script-base');
|
|
|
4
4
|
|
|
5
5
|
// Mock dependencies
|
|
6
6
|
jest.mock('../admin-auth-middleware', () => ({
|
|
7
|
-
|
|
8
|
-
// Mock auth -
|
|
7
|
+
adminAuthMiddleware: (req, res, next) => {
|
|
8
|
+
// Mock auth - attach admin audit info
|
|
9
|
+
req.adminAudit = {
|
|
10
|
+
apiKeyName: 'test-key',
|
|
11
|
+
apiKeyLast4: '1234',
|
|
12
|
+
ipAddress: '127.0.0.1',
|
|
13
|
+
};
|
|
9
14
|
next();
|
|
10
15
|
},
|
|
11
16
|
}));
|
|
@@ -54,8 +59,8 @@ describe('Admin Script Router', () => {
|
|
|
54
59
|
};
|
|
55
60
|
|
|
56
61
|
mockCommands = {
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
createScriptExecution: jest.fn(),
|
|
63
|
+
findScriptExecutionById: jest.fn(),
|
|
59
64
|
findRecentExecutions: jest.fn(),
|
|
60
65
|
};
|
|
61
66
|
|
|
@@ -138,7 +143,7 @@ describe('Admin Script Router', () => {
|
|
|
138
143
|
});
|
|
139
144
|
});
|
|
140
145
|
|
|
141
|
-
describe('POST /admin/scripts/:scriptName', () => {
|
|
146
|
+
describe('POST /admin/scripts/:scriptName/execute', () => {
|
|
142
147
|
it('should execute script synchronously', async () => {
|
|
143
148
|
mockRunner.execute.mockResolvedValue({
|
|
144
149
|
executionId: 'exec-123',
|
|
@@ -149,7 +154,7 @@ describe('Admin Script Router', () => {
|
|
|
149
154
|
});
|
|
150
155
|
|
|
151
156
|
const response = await request(app)
|
|
152
|
-
.post('/admin/scripts/test-script')
|
|
157
|
+
.post('/admin/scripts/test-script/execute')
|
|
153
158
|
.send({
|
|
154
159
|
params: { foo: 'bar' },
|
|
155
160
|
mode: 'sync',
|
|
@@ -169,12 +174,12 @@ describe('Admin Script Router', () => {
|
|
|
169
174
|
});
|
|
170
175
|
|
|
171
176
|
it('should queue script for async execution', async () => {
|
|
172
|
-
mockCommands.
|
|
177
|
+
mockCommands.createScriptExecution.mockResolvedValue({
|
|
173
178
|
id: 'exec-456',
|
|
174
179
|
});
|
|
175
180
|
|
|
176
181
|
const response = await request(app)
|
|
177
|
-
.post('/admin/scripts/test-script')
|
|
182
|
+
.post('/admin/scripts/test-script/execute')
|
|
178
183
|
.send({
|
|
179
184
|
params: { foo: 'bar' },
|
|
180
185
|
mode: 'async',
|
|
@@ -193,12 +198,12 @@ describe('Admin Script Router', () => {
|
|
|
193
198
|
});
|
|
194
199
|
|
|
195
200
|
it('should default to async mode', async () => {
|
|
196
|
-
mockCommands.
|
|
201
|
+
mockCommands.createScriptExecution.mockResolvedValue({
|
|
197
202
|
id: 'exec-789',
|
|
198
203
|
});
|
|
199
204
|
|
|
200
205
|
const response = await request(app)
|
|
201
|
-
.post('/admin/scripts/test-script')
|
|
206
|
+
.post('/admin/scripts/test-script/execute')
|
|
202
207
|
.send({
|
|
203
208
|
params: { foo: 'bar' },
|
|
204
209
|
});
|
|
@@ -211,7 +216,7 @@ describe('Admin Script Router', () => {
|
|
|
211
216
|
mockFactory.has.mockReturnValue(false);
|
|
212
217
|
|
|
213
218
|
const response = await request(app)
|
|
214
|
-
.post('/admin/scripts/non-existent')
|
|
219
|
+
.post('/admin/scripts/non-existent/execute')
|
|
215
220
|
.send({
|
|
216
221
|
params: {},
|
|
217
222
|
});
|
|
@@ -221,15 +226,15 @@ describe('Admin Script Router', () => {
|
|
|
221
226
|
});
|
|
222
227
|
});
|
|
223
228
|
|
|
224
|
-
describe('GET /admin/
|
|
229
|
+
describe('GET /admin/executions/:executionId', () => {
|
|
225
230
|
it('should return execution details', async () => {
|
|
226
|
-
mockCommands.
|
|
231
|
+
mockCommands.findScriptExecutionById.mockResolvedValue({
|
|
227
232
|
id: 'exec-123',
|
|
228
233
|
scriptName: 'test-script',
|
|
229
234
|
status: 'COMPLETED',
|
|
230
235
|
});
|
|
231
236
|
|
|
232
|
-
const response = await request(app).get('/admin/
|
|
237
|
+
const response = await request(app).get('/admin/executions/exec-123');
|
|
233
238
|
|
|
234
239
|
expect(response.status).toBe(200);
|
|
235
240
|
expect(response.body.id).toBe('exec-123');
|
|
@@ -237,14 +242,14 @@ describe('Admin Script Router', () => {
|
|
|
237
242
|
});
|
|
238
243
|
|
|
239
244
|
it('should return 404 for non-existent execution', async () => {
|
|
240
|
-
mockCommands.
|
|
245
|
+
mockCommands.findScriptExecutionById.mockResolvedValue({
|
|
241
246
|
error: 404,
|
|
242
247
|
reason: 'Execution not found',
|
|
243
248
|
code: 'EXECUTION_NOT_FOUND',
|
|
244
249
|
});
|
|
245
250
|
|
|
246
251
|
const response = await request(app).get(
|
|
247
|
-
'/admin/
|
|
252
|
+
'/admin/executions/non-existent'
|
|
248
253
|
);
|
|
249
254
|
|
|
250
255
|
expect(response.status).toBe(404);
|
|
@@ -252,28 +257,24 @@ describe('Admin Script Router', () => {
|
|
|
252
257
|
});
|
|
253
258
|
});
|
|
254
259
|
|
|
255
|
-
describe('GET /admin/
|
|
256
|
-
it('should list executions
|
|
260
|
+
describe('GET /admin/executions', () => {
|
|
261
|
+
it('should list recent executions', async () => {
|
|
257
262
|
mockCommands.findRecentExecutions.mockResolvedValue([
|
|
258
263
|
{ id: 'exec-1', scriptName: 'test-script', status: 'COMPLETED' },
|
|
259
264
|
{ id: 'exec-2', scriptName: 'test-script', status: 'RUNNING' },
|
|
260
265
|
]);
|
|
261
266
|
|
|
262
|
-
const response = await request(app).get('/admin/
|
|
267
|
+
const response = await request(app).get('/admin/executions');
|
|
263
268
|
|
|
264
269
|
expect(response.status).toBe(200);
|
|
265
270
|
expect(response.body.executions).toHaveLength(2);
|
|
266
|
-
expect(mockCommands.findRecentExecutions).toHaveBeenCalledWith({
|
|
267
|
-
scriptName: 'test-script',
|
|
268
|
-
limit: 50,
|
|
269
|
-
});
|
|
270
271
|
});
|
|
271
272
|
|
|
272
273
|
it('should accept query parameters', async () => {
|
|
273
274
|
mockCommands.findRecentExecutions.mockResolvedValue([]);
|
|
274
275
|
|
|
275
276
|
await request(app).get(
|
|
276
|
-
'/admin/
|
|
277
|
+
'/admin/executions?scriptName=test-script&status=COMPLETED&limit=10'
|
|
277
278
|
);
|
|
278
279
|
|
|
279
280
|
expect(mockCommands.findRecentExecutions).toHaveBeenCalledWith({
|
|
@@ -1,11 +1,49 @@
|
|
|
1
|
+
const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Admin API Key Authentication Middleware
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Expects: x-frigg-admin-api-key header
|
|
6
|
+
* Validates admin API keys for script endpoints.
|
|
7
|
+
* Expects: Authorization: Bearer <api-key>
|
|
7
8
|
*/
|
|
9
|
+
async function adminAuthMiddleware(req, res, next) {
|
|
10
|
+
try {
|
|
11
|
+
const authHeader = req.headers.authorization;
|
|
12
|
+
|
|
13
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
14
|
+
return res.status(401).json({
|
|
15
|
+
error: 'Missing or invalid Authorization header',
|
|
16
|
+
code: 'MISSING_AUTH'
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const apiKey = authHeader.substring(7); // Remove 'Bearer '
|
|
21
|
+
const commands = createAdminScriptCommands();
|
|
22
|
+
const result = await commands.validateAdminApiKey(apiKey);
|
|
23
|
+
|
|
24
|
+
if (result.error) {
|
|
25
|
+
return res.status(result.error).json({
|
|
26
|
+
error: result.reason,
|
|
27
|
+
code: result.code
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Attach validated key info to request for audit trail
|
|
32
|
+
req.adminApiKey = result.apiKey;
|
|
33
|
+
req.adminAudit = {
|
|
34
|
+
apiKeyName: result.apiKey.name,
|
|
35
|
+
apiKeyLast4: result.apiKey.keyLast4,
|
|
36
|
+
ipAddress: req.ip || req.connection?.remoteAddress || 'unknown'
|
|
37
|
+
};
|
|
8
38
|
|
|
9
|
-
|
|
39
|
+
next();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('Admin auth middleware error:', error);
|
|
42
|
+
res.status(500).json({
|
|
43
|
+
error: 'Authentication failed',
|
|
44
|
+
code: 'AUTH_ERROR'
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
10
48
|
|
|
11
|
-
module.exports = {
|
|
49
|
+
module.exports = { adminAuthMiddleware };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const serverless = require('serverless-http');
|
|
3
|
-
const {
|
|
3
|
+
const { adminAuthMiddleware } = require('./admin-auth-middleware');
|
|
4
4
|
const { getScriptFactory } = require('../application/script-factory');
|
|
5
5
|
const { createScriptRunner } = require('../application/script-runner');
|
|
6
6
|
const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands');
|
|
@@ -11,7 +11,7 @@ const { ScheduleManagementUseCase } = require('../application/schedule-managemen
|
|
|
11
11
|
const router = express.Router();
|
|
12
12
|
|
|
13
13
|
// Apply auth middleware to all admin routes
|
|
14
|
-
router.use(
|
|
14
|
+
router.use(adminAuthMiddleware);
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Create ScheduleManagementUseCase instance
|
|
@@ -87,10 +87,10 @@ router.get('/scripts/:scriptName', async (req, res) => {
|
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* POST /admin/scripts/:scriptName
|
|
90
|
+
* POST /admin/scripts/:scriptName/execute
|
|
91
91
|
* Execute a script (sync, async, or dry-run)
|
|
92
92
|
*/
|
|
93
|
-
router.post('/scripts/:scriptName', async (req, res) => {
|
|
93
|
+
router.post('/scripts/:scriptName/execute', async (req, res) => {
|
|
94
94
|
try {
|
|
95
95
|
const { scriptName } = req.params;
|
|
96
96
|
const { params = {}, mode = 'async', dryRun = false } = req.body;
|
|
@@ -110,6 +110,7 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
110
110
|
trigger: 'MANUAL',
|
|
111
111
|
mode: 'sync',
|
|
112
112
|
dryRun: true,
|
|
113
|
+
audit: req.adminAudit,
|
|
113
114
|
});
|
|
114
115
|
return res.json(result);
|
|
115
116
|
}
|
|
@@ -120,18 +121,20 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
120
121
|
const result = await runner.execute(scriptName, params, {
|
|
121
122
|
trigger: 'MANUAL',
|
|
122
123
|
mode: 'sync',
|
|
124
|
+
audit: req.adminAudit,
|
|
123
125
|
});
|
|
124
126
|
return res.json(result);
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
// Async execution - queue and return immediately
|
|
128
130
|
const commands = createAdminScriptCommands();
|
|
129
|
-
const execution = await commands.
|
|
131
|
+
const execution = await commands.createScriptExecution({
|
|
130
132
|
scriptName,
|
|
131
133
|
scriptVersion: factory.get(scriptName).Definition.version,
|
|
132
134
|
trigger: 'MANUAL',
|
|
133
135
|
mode: 'async',
|
|
134
136
|
input: params,
|
|
137
|
+
audit: req.adminAudit,
|
|
135
138
|
});
|
|
136
139
|
|
|
137
140
|
// Queue the execution
|
|
@@ -158,14 +161,14 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
158
161
|
});
|
|
159
162
|
|
|
160
163
|
/**
|
|
161
|
-
* GET /admin/
|
|
162
|
-
* Get execution status
|
|
164
|
+
* GET /admin/executions/:executionId
|
|
165
|
+
* Get execution status
|
|
163
166
|
*/
|
|
164
|
-
router.get('/
|
|
167
|
+
router.get('/executions/:executionId', async (req, res) => {
|
|
165
168
|
try {
|
|
166
169
|
const { executionId } = req.params;
|
|
167
170
|
const commands = createAdminScriptCommands();
|
|
168
|
-
const execution = await commands.
|
|
171
|
+
const execution = await commands.findScriptExecutionById(executionId);
|
|
169
172
|
|
|
170
173
|
if (execution.error) {
|
|
171
174
|
return res.status(execution.error).json({
|
|
@@ -182,13 +185,12 @@ router.get('/scripts/:scriptName/executions/:executionId', async (req, res) => {
|
|
|
182
185
|
});
|
|
183
186
|
|
|
184
187
|
/**
|
|
185
|
-
* GET /admin/
|
|
186
|
-
* List recent executions
|
|
188
|
+
* GET /admin/executions
|
|
189
|
+
* List recent executions
|
|
187
190
|
*/
|
|
188
|
-
router.get('/
|
|
191
|
+
router.get('/executions', async (req, res) => {
|
|
189
192
|
try {
|
|
190
|
-
const { scriptName } = req.
|
|
191
|
-
const { status, limit = 50 } = req.query;
|
|
193
|
+
const { scriptName, status, limit = 50 } = req.query;
|
|
192
194
|
const commands = createAdminScriptCommands();
|
|
193
195
|
|
|
194
196
|
const executions = await commands.findRecentExecutions({
|
|
@@ -21,7 +21,7 @@ async function handler(event) {
|
|
|
21
21
|
|
|
22
22
|
// If executionId provided (async from API), update existing record
|
|
23
23
|
if (executionId) {
|
|
24
|
-
await commands.
|
|
24
|
+
await commands.updateScriptExecutionStatus(executionId, 'RUNNING');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const result = await runner.execute(scriptName, params, {
|
|
@@ -45,7 +45,7 @@ async function handler(event) {
|
|
|
45
45
|
if (executionId) {
|
|
46
46
|
const commands = createAdminScriptCommands();
|
|
47
47
|
await commands
|
|
48
|
-
.
|
|
48
|
+
.completeScriptExecution(executionId, {
|
|
49
49
|
status: 'FAILED',
|
|
50
50
|
error: {
|
|
51
51
|
name: error.name,
|