opticedge-cloud-utils 1.0.4 → 1.0.6
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/.eslintignore +2 -0
- package/.eslintrc.js +9 -8
- package/.prettierignore +4 -0
- package/.prettierrc +8 -0
- package/dist/auth/verify.js +1 -1
- package/dist/auth/verify.test.js +17 -17
- package/dist/db/mongo.test.js +3 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/utils/secrets.js +1 -1
- package/dist/utils/secrets.test.js +4 -4
- package/dist/utils/task.d.ts +1 -0
- package/dist/utils/task.js +30 -0
- package/dist/utils/task.test.d.ts +1 -0
- package/dist/utils/task.test.js +54 -0
- package/jest.config.js +6 -6
- package/package.json +8 -2
- package/src/auth/verify.test.ts +40 -40
- package/src/auth/verify.ts +14 -17
- package/src/db/mongo.test.ts +29 -28
- package/src/db/mongo.ts +11 -11
- package/src/index.ts +4 -3
- package/src/utils/secrets.test.ts +26 -26
- package/src/utils/secrets.ts +8 -11
- package/src/utils/task.test.ts +94 -0
- package/src/utils/task.ts +39 -0
package/.eslintignore
ADDED
package/.eslintrc.js
CHANGED
|
@@ -2,28 +2,29 @@ module.exports = {
|
|
|
2
2
|
root: true,
|
|
3
3
|
parser: '@typescript-eslint/parser',
|
|
4
4
|
parserOptions: {
|
|
5
|
-
ecmaVersion: 2020,
|
|
6
|
-
sourceType: 'module'
|
|
5
|
+
ecmaVersion: 2020, // modern JS features
|
|
6
|
+
sourceType: 'module'
|
|
7
7
|
},
|
|
8
8
|
env: {
|
|
9
9
|
node: true,
|
|
10
10
|
jest: true,
|
|
11
|
-
es2020: true
|
|
11
|
+
es2020: true
|
|
12
12
|
},
|
|
13
13
|
plugins: ['@typescript-eslint', 'jest'],
|
|
14
14
|
extends: [
|
|
15
15
|
'eslint:recommended',
|
|
16
16
|
'plugin:@typescript-eslint/recommended',
|
|
17
17
|
'plugin:jest/recommended',
|
|
18
|
+
'plugin:prettier/recommended'
|
|
18
19
|
],
|
|
19
20
|
rules: {
|
|
20
21
|
// Your custom rules here, for example:
|
|
21
22
|
'no-console': 'warn',
|
|
22
|
-
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
|
|
23
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
|
|
23
24
|
},
|
|
24
25
|
settings: {
|
|
25
26
|
jest: {
|
|
26
|
-
version: 29
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
27
|
+
version: 29 // or your jest version
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/.prettierignore
ADDED
package/.prettierrc
ADDED
package/dist/auth/verify.js
CHANGED
|
@@ -12,7 +12,7 @@ async function verifyRequest(req, options) {
|
|
|
12
12
|
try {
|
|
13
13
|
const ticket = await client.verifyIdToken({
|
|
14
14
|
idToken: token,
|
|
15
|
-
audience: options.allowedAudience
|
|
15
|
+
audience: options.allowedAudience
|
|
16
16
|
});
|
|
17
17
|
const payload = ticket.getPayload();
|
|
18
18
|
return payload?.email === options.allowedServiceAccount;
|
package/dist/auth/verify.test.js
CHANGED
|
@@ -9,70 +9,70 @@ jest.mock('google-auth-library', () => {
|
|
|
9
9
|
if (idToken === 'valid-token' && audience === 'audience') {
|
|
10
10
|
return {
|
|
11
11
|
getPayload: () => ({
|
|
12
|
-
email: 'allowed@example.com'
|
|
13
|
-
})
|
|
12
|
+
email: 'allowed@example.com'
|
|
13
|
+
})
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
16
|
else if (idToken === 'wrong-email-token' && audience === 'audience') {
|
|
17
17
|
return {
|
|
18
18
|
getPayload: () => ({
|
|
19
|
-
email: 'unauthorized@example.com'
|
|
20
|
-
})
|
|
19
|
+
email: 'unauthorized@example.com'
|
|
20
|
+
})
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
throw new Error('Invalid token');
|
|
24
|
-
})
|
|
25
|
-
}))
|
|
24
|
+
})
|
|
25
|
+
}))
|
|
26
26
|
};
|
|
27
27
|
});
|
|
28
28
|
describe('verifyRequest', () => {
|
|
29
29
|
it('returns true for valid token and matching email', async () => {
|
|
30
30
|
const mockReq = {
|
|
31
31
|
headers: {
|
|
32
|
-
authorization: 'Bearer valid-token'
|
|
33
|
-
}
|
|
32
|
+
authorization: 'Bearer valid-token'
|
|
33
|
+
}
|
|
34
34
|
};
|
|
35
35
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
36
|
const result = await (0, verify_1.verifyRequest)(mockReq, {
|
|
37
37
|
allowedAudience: 'audience',
|
|
38
|
-
allowedServiceAccount: 'allowed@example.com'
|
|
38
|
+
allowedServiceAccount: 'allowed@example.com'
|
|
39
39
|
});
|
|
40
40
|
expect(result).toBe(true);
|
|
41
41
|
});
|
|
42
42
|
it('returns false for invalid token', async () => {
|
|
43
43
|
const mockReq = {
|
|
44
44
|
headers: {
|
|
45
|
-
authorization: 'Bearer invalid-token'
|
|
46
|
-
}
|
|
45
|
+
authorization: 'Bearer invalid-token'
|
|
46
|
+
}
|
|
47
47
|
};
|
|
48
48
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
49
|
const result = await (0, verify_1.verifyRequest)(mockReq, {
|
|
50
50
|
allowedAudience: 'audience',
|
|
51
|
-
allowedServiceAccount: 'allowed@example.com'
|
|
51
|
+
allowedServiceAccount: 'allowed@example.com'
|
|
52
52
|
});
|
|
53
53
|
expect(result).toBe(false);
|
|
54
54
|
});
|
|
55
55
|
it('returns false for missing authorization header', async () => {
|
|
56
56
|
const mockReq = {
|
|
57
|
-
headers: {}
|
|
57
|
+
headers: {}
|
|
58
58
|
};
|
|
59
59
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
60
|
const result = await (0, verify_1.verifyRequest)(mockReq, {
|
|
61
61
|
allowedAudience: 'audience',
|
|
62
|
-
allowedServiceAccount: 'allowed@example.com'
|
|
62
|
+
allowedServiceAccount: 'allowed@example.com'
|
|
63
63
|
});
|
|
64
64
|
expect(result).toBe(false);
|
|
65
65
|
});
|
|
66
66
|
it('returns false for incorrect email', async () => {
|
|
67
67
|
const mockReq = {
|
|
68
68
|
headers: {
|
|
69
|
-
authorization: 'Bearer wrong-email-token'
|
|
70
|
-
}
|
|
69
|
+
authorization: 'Bearer wrong-email-token'
|
|
70
|
+
}
|
|
71
71
|
};
|
|
72
72
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
73
|
const result = await (0, verify_1.verifyRequest)(mockReq, {
|
|
74
74
|
allowedAudience: 'audience',
|
|
75
|
-
allowedServiceAccount: 'allowed@example.com'
|
|
75
|
+
allowedServiceAccount: 'allowed@example.com'
|
|
76
76
|
});
|
|
77
77
|
expect(result).toBe(false);
|
|
78
78
|
});
|
package/dist/db/mongo.test.js
CHANGED
|
@@ -39,12 +39,12 @@ const secretsModule = __importStar(require("../utils/secrets"));
|
|
|
39
39
|
jest.mock('mongodb');
|
|
40
40
|
jest.mock('../utils/secrets');
|
|
41
41
|
const mockDb = {
|
|
42
|
-
collection: jest.fn()
|
|
42
|
+
collection: jest.fn()
|
|
43
43
|
};
|
|
44
44
|
const mockConnect = jest.fn();
|
|
45
45
|
const mockClient = {
|
|
46
46
|
connect: mockConnect,
|
|
47
|
-
db: jest.fn().mockReturnValue(mockDb)
|
|
47
|
+
db: jest.fn().mockReturnValue(mockDb)
|
|
48
48
|
};
|
|
49
49
|
beforeEach(() => {
|
|
50
50
|
jest.clearAllMocks();
|
|
@@ -65,6 +65,7 @@ describe('Mongo Utils', () => {
|
|
|
65
65
|
});
|
|
66
66
|
it('should throw error if db is not initialized', () => {
|
|
67
67
|
jest.isolateModules(() => {
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
68
69
|
const { getCollection } = require('./mongo');
|
|
69
70
|
expect(() => getCollection('users')).toThrow('Mongo not initialized');
|
|
70
71
|
});
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -15,5 +15,6 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./auth/verify"), exports);
|
|
18
|
-
__exportStar(require("./utils/secrets"), exports);
|
|
19
18
|
__exportStar(require("./db/mongo"), exports);
|
|
19
|
+
__exportStar(require("./utils/secrets"), exports);
|
|
20
|
+
__exportStar(require("./utils/task"), exports);
|
package/dist/utils/secrets.js
CHANGED
|
@@ -16,7 +16,7 @@ async function getSecret(projectId, secretName) {
|
|
|
16
16
|
throw new Error('secretName is required');
|
|
17
17
|
const secretClient = new secret_manager_1.SecretManagerServiceClient();
|
|
18
18
|
const [version] = await secretClient.accessSecretVersion({
|
|
19
|
-
name: `projects/${projectId}/secrets/${secretName}/versions/latest
|
|
19
|
+
name: `projects/${projectId}/secrets/${secretName}/versions/latest`
|
|
20
20
|
});
|
|
21
21
|
return version.payload?.data?.toString('utf-8') ?? '';
|
|
22
22
|
}
|
|
@@ -6,7 +6,7 @@ jest.mock('@google-cloud/secret-manager');
|
|
|
6
6
|
// Mock implementation
|
|
7
7
|
const mockAccessSecretVersion = jest.fn();
|
|
8
8
|
secret_manager_1.SecretManagerServiceClient.mockImplementation(() => ({
|
|
9
|
-
accessSecretVersion: mockAccessSecretVersion
|
|
9
|
+
accessSecretVersion: mockAccessSecretVersion
|
|
10
10
|
}));
|
|
11
11
|
describe('getSecret', () => {
|
|
12
12
|
beforeEach(() => {
|
|
@@ -15,13 +15,13 @@ describe('getSecret', () => {
|
|
|
15
15
|
it('returns secret value as string', async () => {
|
|
16
16
|
mockAccessSecretVersion.mockResolvedValue([
|
|
17
17
|
{
|
|
18
|
-
payload: { data: Buffer.from('super-secret-value') }
|
|
19
|
-
}
|
|
18
|
+
payload: { data: Buffer.from('super-secret-value') }
|
|
19
|
+
}
|
|
20
20
|
]);
|
|
21
21
|
const result = await (0, secrets_1.getSecret)('test-project', 'test-secret');
|
|
22
22
|
expect(result).toBe('super-secret-value');
|
|
23
23
|
expect(mockAccessSecretVersion).toHaveBeenCalledWith({
|
|
24
|
-
name: 'projects/test-project/secrets/test-secret/versions/latest'
|
|
24
|
+
name: 'projects/test-project/secrets/test-secret/versions/latest'
|
|
25
25
|
});
|
|
26
26
|
});
|
|
27
27
|
it('throws if projectId is missing', async () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function createTask(projectId: string, region: string, queueId: string, data: unknown, serviceAccount: string, audience: string): Promise<string>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createTask = createTask;
|
|
4
|
+
const tasks_1 = require("@google-cloud/tasks");
|
|
5
|
+
const tasksClient = new tasks_1.CloudTasksClient();
|
|
6
|
+
async function createTask(projectId, region, queueId, data, serviceAccount, audience) {
|
|
7
|
+
if (!projectId || !region || !queueId || !serviceAccount || !audience) {
|
|
8
|
+
throw new Error('Missing required parameters for Cloud Tasks setup');
|
|
9
|
+
}
|
|
10
|
+
const parent = tasksClient.queuePath(projectId, region, queueId);
|
|
11
|
+
const task = {
|
|
12
|
+
httpRequest: {
|
|
13
|
+
httpMethod: tasks_1.protos.google.cloud.tasks.v2.HttpMethod.POST,
|
|
14
|
+
url: audience,
|
|
15
|
+
headers: {
|
|
16
|
+
'Content-Type': 'application/json'
|
|
17
|
+
},
|
|
18
|
+
body: Buffer.from(JSON.stringify(data)).toString('base64'),
|
|
19
|
+
oidcToken: {
|
|
20
|
+
serviceAccountEmail: serviceAccount,
|
|
21
|
+
audience
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const [response] = await tasksClient.createTask({ parent, task });
|
|
26
|
+
if (!response.name) {
|
|
27
|
+
throw new Error('Failed to create task: no name returned');
|
|
28
|
+
}
|
|
29
|
+
return response.name;
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const mockCreateTask = jest.fn();
|
|
4
|
+
const mockQueuePath = jest.fn((projectId, region, queueId) => `projects/${projectId}/locations/${region}/queues/${queueId}`);
|
|
5
|
+
jest.mock('@google-cloud/tasks', () => {
|
|
6
|
+
return {
|
|
7
|
+
CloudTasksClient: jest.fn(() => ({
|
|
8
|
+
createTask: mockCreateTask,
|
|
9
|
+
queuePath: mockQueuePath
|
|
10
|
+
})),
|
|
11
|
+
protos: {
|
|
12
|
+
google: {
|
|
13
|
+
cloud: {
|
|
14
|
+
tasks: {
|
|
15
|
+
v2: {
|
|
16
|
+
HttpMethod: {
|
|
17
|
+
POST: 'POST'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
const task_1 = require("./task");
|
|
27
|
+
describe('createTask', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
mockCreateTask.mockReset();
|
|
30
|
+
});
|
|
31
|
+
it('throws error if any required parameter is missing', async () => {
|
|
32
|
+
// Missing projectId
|
|
33
|
+
await expect((0, task_1.createTask)('', 'region', 'queue', {}, 'serviceAccount', 'audience')).rejects.toThrow('Missing required parameters for Cloud Tasks setup');
|
|
34
|
+
// Missing region
|
|
35
|
+
await expect((0, task_1.createTask)('project', '', 'queue', {}, 'serviceAccount', 'audience')).rejects.toThrow('Missing required parameters for Cloud Tasks setup');
|
|
36
|
+
// Missing queueId
|
|
37
|
+
await expect((0, task_1.createTask)('project', 'region', '', {}, 'serviceAccount', 'audience')).rejects.toThrow('Missing required parameters for Cloud Tasks setup');
|
|
38
|
+
// Missing serviceAccount
|
|
39
|
+
await expect((0, task_1.createTask)('project', 'region', 'queue', {}, '', 'audience')).rejects.toThrow('Missing required parameters for Cloud Tasks setup');
|
|
40
|
+
// Missing audience
|
|
41
|
+
await expect((0, task_1.createTask)('project', 'region', 'queue', {}, 'serviceAccount', '')).rejects.toThrow('Missing required parameters for Cloud Tasks setup');
|
|
42
|
+
});
|
|
43
|
+
it('should create a task and return task name', async () => {
|
|
44
|
+
const mockTaskName = 'projects/test-project/locations/us-central1/queues/test-queue/tasks/task-123';
|
|
45
|
+
mockCreateTask.mockResolvedValue([{ name: mockTaskName }]);
|
|
46
|
+
const result = await (0, task_1.createTask)('test-project', 'us-central1', 'test-queue', { test: 'data' }, 'test-sa@test.iam.gserviceaccount.com', 'https://run-url');
|
|
47
|
+
expect(result).toBe(mockTaskName);
|
|
48
|
+
expect(mockCreateTask).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
it('should throw error if task name is missing', async () => {
|
|
51
|
+
mockCreateTask.mockResolvedValue([{}]); // Simulate missing name
|
|
52
|
+
await expect((0, task_1.createTask)('test-project', 'us-central1', 'test-queue', { foo: 'bar' }, 'test@project.iam.gserviceaccount.com', 'https://example.com')).rejects.toThrow('Failed to create task: no name returned');
|
|
53
|
+
});
|
|
54
|
+
});
|
package/jest.config.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
const { createDefaultPreset } = require(
|
|
1
|
+
const { createDefaultPreset } = require('ts-jest')
|
|
2
2
|
|
|
3
|
-
const tsJestTransformCfg = createDefaultPreset().transform
|
|
3
|
+
const tsJestTransformCfg = createDefaultPreset().transform
|
|
4
4
|
|
|
5
5
|
/** @type {import("jest").Config} **/
|
|
6
6
|
module.exports = {
|
|
7
|
-
testEnvironment:
|
|
7
|
+
testEnvironment: 'node',
|
|
8
8
|
transform: {
|
|
9
|
-
...tsJestTransformCfg
|
|
9
|
+
...tsJestTransformCfg
|
|
10
10
|
},
|
|
11
|
-
testPathIgnorePatterns: ['/node_modules/', '/dist/']
|
|
12
|
-
}
|
|
11
|
+
testPathIgnorePatterns: ['/node_modules/', '/dist/']
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opticedge-cloud-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Common utilities for cloud functions",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "tsc",
|
|
9
9
|
"test": "jest --coverage",
|
|
10
|
-
"lint": "eslint . --ext .ts",
|
|
10
|
+
"lint": "eslint . --ext .ts --fix",
|
|
11
|
+
"format": "prettier --write .",
|
|
12
|
+
"fix": "npm run lint && npm run format",
|
|
11
13
|
"prepare": "npm run build"
|
|
12
14
|
},
|
|
13
15
|
"author": "Evans Musonda",
|
|
14
16
|
"license": "MIT",
|
|
15
17
|
"dependencies": {
|
|
16
18
|
"@google-cloud/secret-manager": "^6.0.1",
|
|
19
|
+
"@google-cloud/tasks": "^6.1.0",
|
|
17
20
|
"google-auth-library": "^9.15.1",
|
|
18
21
|
"mongodb": "^6.16.0"
|
|
19
22
|
},
|
|
@@ -24,8 +27,11 @@
|
|
|
24
27
|
"@typescript-eslint/eslint-plugin": "^8.33.0",
|
|
25
28
|
"@typescript-eslint/parser": "^8.33.0",
|
|
26
29
|
"eslint": "^8.57.1",
|
|
30
|
+
"eslint-config-prettier": "^10.1.5",
|
|
27
31
|
"eslint-plugin-jest": "^28.11.1",
|
|
32
|
+
"eslint-plugin-prettier": "^5.4.0",
|
|
28
33
|
"jest": "^29.7.0",
|
|
34
|
+
"prettier": "^3.5.3",
|
|
29
35
|
"ts-jest": "^29.3.4",
|
|
30
36
|
"typescript": "^5.8.3"
|
|
31
37
|
}
|
package/src/auth/verify.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { verifyRequest } from './verify'
|
|
1
|
+
import { verifyRequest } from './verify'
|
|
2
2
|
|
|
3
3
|
// Mock the google-auth-library
|
|
4
4
|
jest.mock('google-auth-library', () => {
|
|
@@ -8,82 +8,82 @@ jest.mock('google-auth-library', () => {
|
|
|
8
8
|
if (idToken === 'valid-token' && audience === 'audience') {
|
|
9
9
|
return {
|
|
10
10
|
getPayload: () => ({
|
|
11
|
-
email: 'allowed@example.com'
|
|
12
|
-
})
|
|
13
|
-
}
|
|
11
|
+
email: 'allowed@example.com'
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
14
|
} else if (idToken === 'wrong-email-token' && audience === 'audience') {
|
|
15
15
|
return {
|
|
16
16
|
getPayload: () => ({
|
|
17
|
-
email: 'unauthorized@example.com'
|
|
18
|
-
})
|
|
19
|
-
}
|
|
17
|
+
email: 'unauthorized@example.com'
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
20
|
}
|
|
21
|
-
throw new Error('Invalid token')
|
|
22
|
-
})
|
|
23
|
-
}))
|
|
24
|
-
}
|
|
25
|
-
})
|
|
21
|
+
throw new Error('Invalid token')
|
|
22
|
+
})
|
|
23
|
+
}))
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
26
|
|
|
27
27
|
describe('verifyRequest', () => {
|
|
28
28
|
it('returns true for valid token and matching email', async () => {
|
|
29
29
|
const mockReq = {
|
|
30
30
|
headers: {
|
|
31
|
-
authorization: 'Bearer valid-token'
|
|
32
|
-
}
|
|
33
|
-
}
|
|
31
|
+
authorization: 'Bearer valid-token'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
34
|
|
|
35
35
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
36
|
const result = await verifyRequest(mockReq as any, {
|
|
37
37
|
allowedAudience: 'audience',
|
|
38
|
-
allowedServiceAccount: 'allowed@example.com'
|
|
39
|
-
})
|
|
38
|
+
allowedServiceAccount: 'allowed@example.com'
|
|
39
|
+
})
|
|
40
40
|
|
|
41
|
-
expect(result).toBe(true)
|
|
42
|
-
})
|
|
41
|
+
expect(result).toBe(true)
|
|
42
|
+
})
|
|
43
43
|
|
|
44
44
|
it('returns false for invalid token', async () => {
|
|
45
45
|
const mockReq = {
|
|
46
46
|
headers: {
|
|
47
|
-
authorization: 'Bearer invalid-token'
|
|
48
|
-
}
|
|
49
|
-
}
|
|
47
|
+
authorization: 'Bearer invalid-token'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
50
|
|
|
51
51
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
52
|
const result = await verifyRequest(mockReq as any, {
|
|
53
53
|
allowedAudience: 'audience',
|
|
54
|
-
allowedServiceAccount: 'allowed@example.com'
|
|
55
|
-
})
|
|
54
|
+
allowedServiceAccount: 'allowed@example.com'
|
|
55
|
+
})
|
|
56
56
|
|
|
57
|
-
expect(result).toBe(false)
|
|
58
|
-
})
|
|
57
|
+
expect(result).toBe(false)
|
|
58
|
+
})
|
|
59
59
|
|
|
60
60
|
it('returns false for missing authorization header', async () => {
|
|
61
61
|
const mockReq = {
|
|
62
|
-
headers: {}
|
|
63
|
-
}
|
|
62
|
+
headers: {}
|
|
63
|
+
}
|
|
64
64
|
|
|
65
65
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
66
66
|
const result = await verifyRequest(mockReq as any, {
|
|
67
67
|
allowedAudience: 'audience',
|
|
68
|
-
allowedServiceAccount: 'allowed@example.com'
|
|
69
|
-
})
|
|
68
|
+
allowedServiceAccount: 'allowed@example.com'
|
|
69
|
+
})
|
|
70
70
|
|
|
71
|
-
expect(result).toBe(false)
|
|
72
|
-
})
|
|
71
|
+
expect(result).toBe(false)
|
|
72
|
+
})
|
|
73
73
|
|
|
74
74
|
it('returns false for incorrect email', async () => {
|
|
75
75
|
const mockReq = {
|
|
76
76
|
headers: {
|
|
77
|
-
authorization: 'Bearer wrong-email-token'
|
|
78
|
-
}
|
|
79
|
-
}
|
|
77
|
+
authorization: 'Bearer wrong-email-token'
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
80
|
|
|
81
81
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
82
|
const result = await verifyRequest(mockReq as any, {
|
|
83
83
|
allowedAudience: 'audience',
|
|
84
|
-
allowedServiceAccount: 'allowed@example.com'
|
|
85
|
-
})
|
|
84
|
+
allowedServiceAccount: 'allowed@example.com'
|
|
85
|
+
})
|
|
86
86
|
|
|
87
|
-
expect(result).toBe(false)
|
|
88
|
-
})
|
|
89
|
-
})
|
|
87
|
+
expect(result).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
})
|
package/src/auth/verify.ts
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
|
-
import { OAuth2Client } from 'google-auth-library'
|
|
2
|
-
import { Request } from '@google-cloud/functions-framework'
|
|
1
|
+
import { OAuth2Client } from 'google-auth-library'
|
|
2
|
+
import { Request } from '@google-cloud/functions-framework'
|
|
3
3
|
|
|
4
4
|
interface VerifyOptions {
|
|
5
|
-
allowedAudience: string
|
|
6
|
-
allowedServiceAccount: string
|
|
5
|
+
allowedAudience: string
|
|
6
|
+
allowedServiceAccount: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export async function verifyRequest(
|
|
10
|
-
req
|
|
11
|
-
options: VerifyOptions
|
|
12
|
-
): Promise<boolean> {
|
|
13
|
-
const authHeader = req.headers['authorization'];
|
|
9
|
+
export async function verifyRequest(req: Request, options: VerifyOptions): Promise<boolean> {
|
|
10
|
+
const authHeader = req.headers['authorization']
|
|
14
11
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
15
|
-
return false
|
|
12
|
+
return false
|
|
16
13
|
}
|
|
17
14
|
|
|
18
|
-
const token = authHeader.split(' ')[1]
|
|
19
|
-
const client = new OAuth2Client()
|
|
15
|
+
const token = authHeader.split(' ')[1]
|
|
16
|
+
const client = new OAuth2Client()
|
|
20
17
|
|
|
21
18
|
try {
|
|
22
19
|
const ticket = await client.verifyIdToken({
|
|
23
20
|
idToken: token,
|
|
24
|
-
audience: options.allowedAudience
|
|
25
|
-
})
|
|
21
|
+
audience: options.allowedAudience
|
|
22
|
+
})
|
|
26
23
|
|
|
27
|
-
const payload = ticket.getPayload()
|
|
28
|
-
return payload?.email === options.allowedServiceAccount
|
|
24
|
+
const payload = ticket.getPayload()
|
|
25
|
+
return payload?.email === options.allowedServiceAccount
|
|
29
26
|
} catch {
|
|
30
|
-
return false
|
|
27
|
+
return false
|
|
31
28
|
}
|
|
32
29
|
}
|
package/src/db/mongo.test.ts
CHANGED
|
@@ -1,46 +1,47 @@
|
|
|
1
|
-
import { connectToMongo, getCollection } from './mongo'
|
|
2
|
-
import { MongoClient, Db
|
|
3
|
-
import * as secretsModule from '../utils/secrets'
|
|
1
|
+
import { connectToMongo, getCollection } from './mongo'
|
|
2
|
+
import { MongoClient, Db } from 'mongodb'
|
|
3
|
+
import * as secretsModule from '../utils/secrets'
|
|
4
4
|
|
|
5
|
-
jest.mock('mongodb')
|
|
6
|
-
jest.mock('../utils/secrets')
|
|
5
|
+
jest.mock('mongodb')
|
|
6
|
+
jest.mock('../utils/secrets')
|
|
7
7
|
|
|
8
8
|
const mockDb = {
|
|
9
|
-
collection: jest.fn()
|
|
10
|
-
} as unknown as Db
|
|
9
|
+
collection: jest.fn()
|
|
10
|
+
} as unknown as Db
|
|
11
11
|
|
|
12
|
-
const mockConnect = jest.fn()
|
|
12
|
+
const mockConnect = jest.fn()
|
|
13
13
|
const mockClient = {
|
|
14
14
|
connect: mockConnect,
|
|
15
|
-
db: jest.fn().mockReturnValue(mockDb)
|
|
16
|
-
} as unknown as MongoClient
|
|
15
|
+
db: jest.fn().mockReturnValue(mockDb)
|
|
16
|
+
} as unknown as MongoClient
|
|
17
17
|
|
|
18
18
|
beforeEach(() => {
|
|
19
|
-
jest.clearAllMocks()
|
|
20
|
-
(secretsModule.getSecret as jest.Mock).mockResolvedValue('mongodb://mock-uri')
|
|
21
|
-
(MongoClient as unknown as jest.Mock).mockImplementation(() => mockClient)
|
|
22
|
-
})
|
|
19
|
+
jest.clearAllMocks()
|
|
20
|
+
;(secretsModule.getSecret as jest.Mock).mockResolvedValue('mongodb://mock-uri')
|
|
21
|
+
;(MongoClient as unknown as jest.Mock).mockImplementation(() => mockClient)
|
|
22
|
+
})
|
|
23
23
|
|
|
24
24
|
describe('Mongo Utils', () => {
|
|
25
25
|
it('should connect to MongoDB and store client/db', async () => {
|
|
26
|
-
await connectToMongo('test-project', 'mongo-uri-secret', 'test-db')
|
|
26
|
+
await connectToMongo('test-project', 'mongo-uri-secret', 'test-db')
|
|
27
27
|
|
|
28
|
-
expect(secretsModule.getSecret).toHaveBeenCalledWith('test-project', 'mongo-uri-secret')
|
|
29
|
-
expect(mockConnect).toHaveBeenCalled()
|
|
30
|
-
expect(mockClient.db).toHaveBeenCalledWith('test-db')
|
|
31
|
-
})
|
|
28
|
+
expect(secretsModule.getSecret).toHaveBeenCalledWith('test-project', 'mongo-uri-secret')
|
|
29
|
+
expect(mockConnect).toHaveBeenCalled()
|
|
30
|
+
expect(mockClient.db).toHaveBeenCalledWith('test-db')
|
|
31
|
+
})
|
|
32
32
|
|
|
33
33
|
it('should return a collection when db is initialized', async () => {
|
|
34
|
-
await connectToMongo('test-project', 'mongo-uri-secret', 'test-db')
|
|
35
|
-
getCollection('users')
|
|
34
|
+
await connectToMongo('test-project', 'mongo-uri-secret', 'test-db')
|
|
35
|
+
getCollection('users')
|
|
36
36
|
|
|
37
|
-
expect(mockDb.collection).toHaveBeenCalledWith('users')
|
|
38
|
-
})
|
|
37
|
+
expect(mockDb.collection).toHaveBeenCalledWith('users')
|
|
38
|
+
})
|
|
39
39
|
|
|
40
40
|
it('should throw error if db is not initialized', () => {
|
|
41
41
|
jest.isolateModules(() => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
})
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
43
|
+
const { getCollection } = require('./mongo')
|
|
44
|
+
expect(() => getCollection('users')).toThrow('Mongo not initialized')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
})
|
package/src/db/mongo.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import { MongoClient, Db, Collection, Document } from 'mongodb'
|
|
2
|
-
import { getSecret } from '../utils/secrets'
|
|
1
|
+
import { MongoClient, Db, Collection, Document } from 'mongodb'
|
|
2
|
+
import { getSecret } from '../utils/secrets' // reuse your secret manager util
|
|
3
3
|
|
|
4
|
-
let client: MongoClient
|
|
5
|
-
let db: Db
|
|
4
|
+
let client: MongoClient
|
|
5
|
+
let db: Db
|
|
6
6
|
|
|
7
7
|
export async function connectToMongo(projectId: string, uriSecret: string, dbName: string) {
|
|
8
|
-
if (client) return
|
|
8
|
+
if (client) return
|
|
9
9
|
|
|
10
|
-
const uri = await getSecret(projectId, uriSecret)
|
|
11
|
-
client = new MongoClient(uri)
|
|
12
|
-
await client.connect()
|
|
13
|
-
db = client.db(dbName)
|
|
10
|
+
const uri = await getSecret(projectId, uriSecret)
|
|
11
|
+
client = new MongoClient(uri)
|
|
12
|
+
await client.connect()
|
|
13
|
+
db = client.db(dbName) // or pass DB name if needed
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function getCollection<T extends Document = Document>(name: string): Collection<T> {
|
|
17
|
-
if (!db) throw new Error('Mongo not initialized')
|
|
18
|
-
return db.collection<T>(name)
|
|
17
|
+
if (!db) throw new Error('Mongo not initialized')
|
|
18
|
+
return db.collection<T>(name)
|
|
19
19
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export * from './auth/verify'
|
|
2
|
-
export * from './
|
|
3
|
-
export * from './
|
|
1
|
+
export * from './auth/verify'
|
|
2
|
+
export * from './db/mongo'
|
|
3
|
+
export * from './utils/secrets'
|
|
4
|
+
export * from './utils/task'
|
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
import { getSecret } from './secrets'
|
|
2
|
-
import { SecretManagerServiceClient } from '@google-cloud/secret-manager'
|
|
1
|
+
import { getSecret } from './secrets'
|
|
2
|
+
import { SecretManagerServiceClient } from '@google-cloud/secret-manager'
|
|
3
3
|
|
|
4
|
-
jest.mock('@google-cloud/secret-manager')
|
|
4
|
+
jest.mock('@google-cloud/secret-manager')
|
|
5
5
|
|
|
6
6
|
// Mock implementation
|
|
7
|
-
const mockAccessSecretVersion = jest.fn()
|
|
8
|
-
(SecretManagerServiceClient as unknown as jest.Mock).mockImplementation(() => ({
|
|
9
|
-
accessSecretVersion: mockAccessSecretVersion
|
|
10
|
-
}))
|
|
7
|
+
const mockAccessSecretVersion = jest.fn()
|
|
8
|
+
;(SecretManagerServiceClient as unknown as jest.Mock).mockImplementation(() => ({
|
|
9
|
+
accessSecretVersion: mockAccessSecretVersion
|
|
10
|
+
}))
|
|
11
11
|
|
|
12
12
|
describe('getSecret', () => {
|
|
13
13
|
beforeEach(() => {
|
|
14
|
-
jest.clearAllMocks()
|
|
15
|
-
})
|
|
14
|
+
jest.clearAllMocks()
|
|
15
|
+
})
|
|
16
16
|
|
|
17
17
|
it('returns secret value as string', async () => {
|
|
18
18
|
mockAccessSecretVersion.mockResolvedValue([
|
|
19
19
|
{
|
|
20
|
-
payload: { data: Buffer.from('super-secret-value') }
|
|
21
|
-
}
|
|
22
|
-
])
|
|
20
|
+
payload: { data: Buffer.from('super-secret-value') }
|
|
21
|
+
}
|
|
22
|
+
])
|
|
23
23
|
|
|
24
|
-
const result = await getSecret('test-project', 'test-secret')
|
|
25
|
-
expect(result).toBe('super-secret-value')
|
|
24
|
+
const result = await getSecret('test-project', 'test-secret')
|
|
25
|
+
expect(result).toBe('super-secret-value')
|
|
26
26
|
expect(mockAccessSecretVersion).toHaveBeenCalledWith({
|
|
27
|
-
name: 'projects/test-project/secrets/test-secret/versions/latest'
|
|
28
|
-
})
|
|
29
|
-
})
|
|
27
|
+
name: 'projects/test-project/secrets/test-secret/versions/latest'
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
30
|
|
|
31
31
|
it('throws if projectId is missing', async () => {
|
|
32
|
-
await expect(getSecret('', 'secret')).rejects.toThrow('projectId is required')
|
|
33
|
-
})
|
|
32
|
+
await expect(getSecret('', 'secret')).rejects.toThrow('projectId is required')
|
|
33
|
+
})
|
|
34
34
|
|
|
35
35
|
it('throws if secretName is missing', async () => {
|
|
36
|
-
await expect(getSecret('project', '')).rejects.toThrow('secretName is required')
|
|
37
|
-
})
|
|
36
|
+
await expect(getSecret('project', '')).rejects.toThrow('secretName is required')
|
|
37
|
+
})
|
|
38
38
|
|
|
39
39
|
it('returns empty string if payload or data is missing', async () => {
|
|
40
|
-
mockAccessSecretVersion.mockResolvedValue([{}])
|
|
41
|
-
const result = await getSecret('project', 'secret')
|
|
42
|
-
expect(result).toBe('')
|
|
43
|
-
})
|
|
44
|
-
})
|
|
40
|
+
mockAccessSecretVersion.mockResolvedValue([{}]) // no payload
|
|
41
|
+
const result = await getSecret('project', 'secret')
|
|
42
|
+
expect(result).toBe('')
|
|
43
|
+
})
|
|
44
|
+
})
|
package/src/utils/secrets.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SecretManagerServiceClient } from '@google-cloud/secret-manager'
|
|
1
|
+
import { SecretManagerServiceClient } from '@google-cloud/secret-manager'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Returns the latest value of a Secret Manager secret.
|
|
@@ -7,18 +7,15 @@ import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
|
|
|
7
7
|
* @param secretName – Secret name (without version).
|
|
8
8
|
* @returns – UTF-8 string value.
|
|
9
9
|
*/
|
|
10
|
-
export async function getSecret(
|
|
11
|
-
projectId
|
|
12
|
-
secretName
|
|
13
|
-
): Promise<string> {
|
|
14
|
-
if (!projectId) throw new Error('projectId is required');
|
|
15
|
-
if (!secretName) throw new Error('secretName is required');
|
|
10
|
+
export async function getSecret(projectId: string, secretName: string): Promise<string> {
|
|
11
|
+
if (!projectId) throw new Error('projectId is required')
|
|
12
|
+
if (!secretName) throw new Error('secretName is required')
|
|
16
13
|
|
|
17
|
-
const secretClient = new SecretManagerServiceClient()
|
|
14
|
+
const secretClient = new SecretManagerServiceClient()
|
|
18
15
|
|
|
19
16
|
const [version] = await secretClient.accessSecretVersion({
|
|
20
|
-
name: `projects/${projectId}/secrets/${secretName}/versions/latest
|
|
21
|
-
})
|
|
17
|
+
name: `projects/${projectId}/secrets/${secretName}/versions/latest`
|
|
18
|
+
})
|
|
22
19
|
|
|
23
|
-
return (version.payload?.data as Buffer)?.toString('utf-8') ?? ''
|
|
20
|
+
return (version.payload?.data as Buffer)?.toString('utf-8') ?? ''
|
|
24
21
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const mockCreateTask = jest.fn()
|
|
2
|
+
const mockQueuePath = jest.fn(
|
|
3
|
+
(projectId, region, queueId) => `projects/${projectId}/locations/${region}/queues/${queueId}`
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
jest.mock('@google-cloud/tasks', () => {
|
|
7
|
+
return {
|
|
8
|
+
CloudTasksClient: jest.fn(() => ({
|
|
9
|
+
createTask: mockCreateTask,
|
|
10
|
+
queuePath: mockQueuePath
|
|
11
|
+
})),
|
|
12
|
+
protos: {
|
|
13
|
+
google: {
|
|
14
|
+
cloud: {
|
|
15
|
+
tasks: {
|
|
16
|
+
v2: {
|
|
17
|
+
HttpMethod: {
|
|
18
|
+
POST: 'POST'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
import { createTask } from './task'
|
|
29
|
+
|
|
30
|
+
describe('createTask', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
mockCreateTask.mockReset()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('throws error if any required parameter is missing', async () => {
|
|
36
|
+
// Missing projectId
|
|
37
|
+
await expect(
|
|
38
|
+
createTask('', 'region', 'queue', {}, 'serviceAccount', 'audience')
|
|
39
|
+
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
40
|
+
|
|
41
|
+
// Missing region
|
|
42
|
+
await expect(
|
|
43
|
+
createTask('project', '', 'queue', {}, 'serviceAccount', 'audience')
|
|
44
|
+
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
45
|
+
|
|
46
|
+
// Missing queueId
|
|
47
|
+
await expect(
|
|
48
|
+
createTask('project', 'region', '', {}, 'serviceAccount', 'audience')
|
|
49
|
+
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
50
|
+
|
|
51
|
+
// Missing serviceAccount
|
|
52
|
+
await expect(createTask('project', 'region', 'queue', {}, '', 'audience')).rejects.toThrow(
|
|
53
|
+
'Missing required parameters for Cloud Tasks setup'
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Missing audience
|
|
57
|
+
await expect(
|
|
58
|
+
createTask('project', 'region', 'queue', {}, 'serviceAccount', '')
|
|
59
|
+
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should create a task and return task name', async () => {
|
|
63
|
+
const mockTaskName =
|
|
64
|
+
'projects/test-project/locations/us-central1/queues/test-queue/tasks/task-123'
|
|
65
|
+
mockCreateTask.mockResolvedValue([{ name: mockTaskName }])
|
|
66
|
+
|
|
67
|
+
const result = await createTask(
|
|
68
|
+
'test-project',
|
|
69
|
+
'us-central1',
|
|
70
|
+
'test-queue',
|
|
71
|
+
{ test: 'data' },
|
|
72
|
+
'test-sa@test.iam.gserviceaccount.com',
|
|
73
|
+
'https://run-url'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(result).toBe(mockTaskName)
|
|
77
|
+
expect(mockCreateTask).toHaveBeenCalledTimes(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should throw error if task name is missing', async () => {
|
|
81
|
+
mockCreateTask.mockResolvedValue([{}]) // Simulate missing name
|
|
82
|
+
|
|
83
|
+
await expect(
|
|
84
|
+
createTask(
|
|
85
|
+
'test-project',
|
|
86
|
+
'us-central1',
|
|
87
|
+
'test-queue',
|
|
88
|
+
{ foo: 'bar' },
|
|
89
|
+
'test@project.iam.gserviceaccount.com',
|
|
90
|
+
'https://example.com'
|
|
91
|
+
)
|
|
92
|
+
).rejects.toThrow('Failed to create task: no name returned')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CloudTasksClient, protos } from '@google-cloud/tasks'
|
|
2
|
+
|
|
3
|
+
const tasksClient = new CloudTasksClient()
|
|
4
|
+
|
|
5
|
+
export async function createTask(
|
|
6
|
+
projectId: string,
|
|
7
|
+
region: string,
|
|
8
|
+
queueId: string,
|
|
9
|
+
data: unknown,
|
|
10
|
+
serviceAccount: string,
|
|
11
|
+
audience: string
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
if (!projectId || !region || !queueId || !serviceAccount || !audience) {
|
|
14
|
+
throw new Error('Missing required parameters for Cloud Tasks setup')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const parent = tasksClient.queuePath(projectId, region, queueId)
|
|
18
|
+
|
|
19
|
+
const task: protos.google.cloud.tasks.v2.ITask = {
|
|
20
|
+
httpRequest: {
|
|
21
|
+
httpMethod: protos.google.cloud.tasks.v2.HttpMethod.POST,
|
|
22
|
+
url: audience,
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'application/json'
|
|
25
|
+
},
|
|
26
|
+
body: Buffer.from(JSON.stringify(data)).toString('base64'),
|
|
27
|
+
oidcToken: {
|
|
28
|
+
serviceAccountEmail: serviceAccount,
|
|
29
|
+
audience
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const [response] = await tasksClient.createTask({ parent, task })
|
|
35
|
+
if (!response.name) {
|
|
36
|
+
throw new Error('Failed to create task: no name returned')
|
|
37
|
+
}
|
|
38
|
+
return response.name
|
|
39
|
+
}
|