@google-cloud/cloud-run-mcp 1.9.0 → 1.11.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 +24 -0
- package/SECURITY.md +7 -0
- package/cloudbuild.yaml +37 -0
- package/example-sources-to-deploy/java/pom.xml +41 -0
- package/example-sources-to-deploy/java/src/main/java/com/example/helloworld/HelloworldApplication.java +21 -0
- package/example-sources-to-deploy/java/src/main/resources/application.properties +1 -0
- package/example-sources-to-deploy/nodejs/index.js +15 -0
- package/example-sources-to-deploy/nodejs/package.json +12 -0
- package/example-sources-to-deploy/python/pip-project/main.py +11 -0
- package/example-sources-to-deploy/python/pip-project/requirements.txt +2 -0
- package/example-sources-to-deploy/python/pyproject-project/main.py +11 -0
- package/example-sources-to-deploy/python/pyproject-project/pyproject.toml +10 -0
- package/gemini-extension.json +12 -0
- package/lib/clients.js +104 -3
- package/lib/cloud-api/build.js +192 -28
- package/lib/cloud-api/run.js +62 -0
- package/lib/cloud-api/secrets.js +196 -0
- package/lib/cloud-api/storage.js +164 -16
- package/lib/deployment/compose.js +517 -0
- package/lib/deployment/constants.js +11 -0
- package/lib/deployment/deployer.js +334 -30
- package/lib/deployment/helpers.js +99 -49
- package/lib/deployment/source-processor.js +0 -48
- package/lib/deployment/universal-maker.js +147 -0
- package/lib/util/archive.js +3 -3
- package/lib/util/artifacts.js +189 -0
- package/lib/util/helpers.js +109 -0
- package/mcp-server.js +4 -0
- package/package.json +6 -3
- package/skills/cloud-run/SKILL.md +109 -0
- package/test/local/archive.test.js +88 -0
- package/test/local/clients.test.js +224 -2
- package/test/local/cloud-api/build.test.js +330 -25
- package/test/local/cloud-api/run.test.js +129 -0
- package/test/local/cloud-api/secrets.test.js +291 -0
- package/test/local/cloud-api/storage.test.js +405 -0
- package/test/local/compose.test.js +618 -0
- package/test/local/deploy.test.js +408 -0
- package/test/local/deployment-helpers.test.js +126 -79
- package/test/local/source-processor.test.js +0 -165
- package/test/local/test-utils.js +2 -18
- package/test/local/tools.test.js +43 -1
- package/test/local/universal-maker.test.js +157 -0
- package/test/local/util.test.js +361 -0
- package/test/need-gcp/deployer.test.js +1 -1
- package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
- package/tools/register-tools.js +19 -3
- /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
- /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
- /package/example-sources-to-deploy/{main.go → golang/main.go} +0 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Google LLC
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { describe, it, mock, beforeEach, afterEach } from 'node:test';
|
|
19
|
+
import esmock from 'esmock';
|
|
20
|
+
|
|
21
|
+
describe('secrets.js', () => {
|
|
22
|
+
let mockSecretManagerClient;
|
|
23
|
+
let getSecretMock, getSecretImpl;
|
|
24
|
+
let createSecretMock, createSecretImpl;
|
|
25
|
+
let addSecretVersionMock, addSecretVersionImpl;
|
|
26
|
+
let getIamPolicyMock, getIamPolicyImpl;
|
|
27
|
+
let setIamPolicyMock, setIamPolicyImpl;
|
|
28
|
+
let logAndProgressMock;
|
|
29
|
+
let secrets;
|
|
30
|
+
|
|
31
|
+
const projectId = 'test-project';
|
|
32
|
+
const secretName = 'test-secret';
|
|
33
|
+
const accessToken = 'test-token';
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
getSecretImpl = () => Promise.reject({ code: 5 });
|
|
37
|
+
getSecretMock = mock.fn((...args) => getSecretImpl(...args));
|
|
38
|
+
|
|
39
|
+
createSecretImpl = () => Promise.resolve([{}]);
|
|
40
|
+
createSecretMock = mock.fn((...args) => createSecretImpl(...args));
|
|
41
|
+
|
|
42
|
+
addSecretVersionImpl = () => Promise.resolve([{}]);
|
|
43
|
+
addSecretVersionMock = mock.fn((...args) => addSecretVersionImpl(...args));
|
|
44
|
+
|
|
45
|
+
getIamPolicyImpl = () => Promise.resolve([{}]);
|
|
46
|
+
getIamPolicyMock = mock.fn((...args) => getIamPolicyImpl(...args));
|
|
47
|
+
|
|
48
|
+
setIamPolicyImpl = () => Promise.resolve([{}]);
|
|
49
|
+
setIamPolicyMock = mock.fn((...args) => setIamPolicyImpl(...args));
|
|
50
|
+
|
|
51
|
+
logAndProgressMock = mock.fn();
|
|
52
|
+
|
|
53
|
+
mockSecretManagerClient = {
|
|
54
|
+
getSecret: getSecretMock,
|
|
55
|
+
createSecret: createSecretMock,
|
|
56
|
+
addSecretVersion: addSecretVersionMock,
|
|
57
|
+
getIamPolicy: getIamPolicyMock,
|
|
58
|
+
setIamPolicy: setIamPolicyMock,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
secrets = await esmock('../../../lib/cloud-api/secrets.js', {
|
|
62
|
+
'../../../lib/clients.js': {
|
|
63
|
+
getSecretManagerClient: () => Promise.resolve(mockSecretManagerClient),
|
|
64
|
+
},
|
|
65
|
+
'../../../lib/util/helpers.js': {
|
|
66
|
+
logAndProgress: logAndProgressMock,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
mock.restoreAll();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('getSecret', () => {
|
|
76
|
+
it('should return secret metadata if it exists', async () => {
|
|
77
|
+
const mockSecret = {
|
|
78
|
+
name: `projects/${projectId}/secrets/${secretName}`,
|
|
79
|
+
};
|
|
80
|
+
getSecretImpl = () => Promise.resolve([mockSecret]);
|
|
81
|
+
|
|
82
|
+
const result = await secrets.getSecret(
|
|
83
|
+
projectId,
|
|
84
|
+
secretName,
|
|
85
|
+
accessToken
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(result, mockSecret);
|
|
89
|
+
assert.equal(getSecretMock.mock.callCount(), 1);
|
|
90
|
+
assert.deepEqual(getSecretMock.mock.calls[0].arguments[0], {
|
|
91
|
+
name: `projects/${projectId}/secrets/${secretName}`,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should return null if secret does not exist (error code 5)', async () => {
|
|
96
|
+
getSecretImpl = () => Promise.reject({ code: 5 });
|
|
97
|
+
|
|
98
|
+
const result = await secrets.getSecret(
|
|
99
|
+
projectId,
|
|
100
|
+
secretName,
|
|
101
|
+
accessToken
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
assert.strictEqual(result, null);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should throw error for other error codes', async () => {
|
|
108
|
+
getSecretImpl = () => Promise.reject({ code: 3 });
|
|
109
|
+
|
|
110
|
+
await assert.rejects(
|
|
111
|
+
() => secrets.getSecret(projectId, secretName, accessToken),
|
|
112
|
+
{ code: 3 }
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('createSecret', () => {
|
|
118
|
+
it('should create a secret successfully', async () => {
|
|
119
|
+
const mockSecret = {
|
|
120
|
+
name: `projects/${projectId}/secrets/${secretName}`,
|
|
121
|
+
};
|
|
122
|
+
createSecretImpl = () => Promise.resolve([mockSecret]);
|
|
123
|
+
|
|
124
|
+
const result = await secrets.createSecret(
|
|
125
|
+
projectId,
|
|
126
|
+
secretName,
|
|
127
|
+
accessToken,
|
|
128
|
+
undefined,
|
|
129
|
+
() => {}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
assert.deepEqual(result, mockSecret);
|
|
133
|
+
assert.equal(createSecretMock.mock.callCount(), 1);
|
|
134
|
+
assert.deepEqual(createSecretMock.mock.calls[0].arguments[0], {
|
|
135
|
+
parent: `projects/${projectId}`,
|
|
136
|
+
secretId: secretName,
|
|
137
|
+
secret: {
|
|
138
|
+
replication: {
|
|
139
|
+
automatic: {},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should create a secret with labels successfully', async () => {
|
|
146
|
+
const mockSecret = {
|
|
147
|
+
name: `projects/${projectId}/secrets/${secretName}`,
|
|
148
|
+
};
|
|
149
|
+
createSecretImpl = () => Promise.resolve([mockSecret]);
|
|
150
|
+
const labels = { key: 'value' };
|
|
151
|
+
|
|
152
|
+
const result = await secrets.createSecret(
|
|
153
|
+
projectId,
|
|
154
|
+
secretName,
|
|
155
|
+
accessToken,
|
|
156
|
+
labels,
|
|
157
|
+
() => {}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
assert.deepEqual(result, mockSecret);
|
|
161
|
+
assert.equal(createSecretMock.mock.callCount(), 1);
|
|
162
|
+
assert.deepEqual(createSecretMock.mock.calls[0].arguments[0], {
|
|
163
|
+
parent: `projects/${projectId}`,
|
|
164
|
+
secretId: secretName,
|
|
165
|
+
secret: {
|
|
166
|
+
replication: {
|
|
167
|
+
automatic: {},
|
|
168
|
+
},
|
|
169
|
+
labels: labels,
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('addSecretVersion', () => {
|
|
176
|
+
it('should add a secret version successfully', async () => {
|
|
177
|
+
const mockVersion = {
|
|
178
|
+
name: `projects/${projectId}/secrets/${secretName}/versions/1`,
|
|
179
|
+
};
|
|
180
|
+
addSecretVersionImpl = () => Promise.resolve([mockVersion]);
|
|
181
|
+
const content = 'secret-content';
|
|
182
|
+
|
|
183
|
+
const result = await secrets.addSecretVersion(
|
|
184
|
+
projectId,
|
|
185
|
+
secretName,
|
|
186
|
+
content,
|
|
187
|
+
accessToken,
|
|
188
|
+
() => {}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
assert.deepEqual(result, mockVersion);
|
|
192
|
+
assert.equal(addSecretVersionMock.mock.callCount(), 1);
|
|
193
|
+
assert.deepEqual(addSecretVersionMock.mock.calls[0].arguments[0], {
|
|
194
|
+
parent: `projects/${projectId}/secrets/${secretName}`,
|
|
195
|
+
payload: {
|
|
196
|
+
data: Buffer.from(content),
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('getSecretIamPolicy', () => {
|
|
203
|
+
it('should return IAM policy successfully', async () => {
|
|
204
|
+
const mockPolicy = { bindings: [] };
|
|
205
|
+
getIamPolicyImpl = () => Promise.resolve([mockPolicy]);
|
|
206
|
+
|
|
207
|
+
const result = await secrets.getSecretIamPolicy(
|
|
208
|
+
projectId,
|
|
209
|
+
secretName,
|
|
210
|
+
accessToken
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
assert.deepEqual(result, mockPolicy);
|
|
214
|
+
assert.equal(getIamPolicyMock.mock.callCount(), 1);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('setSecretIamPolicy', () => {
|
|
219
|
+
it('should set IAM policy successfully', async () => {
|
|
220
|
+
const mockPolicy = { bindings: [] };
|
|
221
|
+
setIamPolicyImpl = () => Promise.resolve([mockPolicy]);
|
|
222
|
+
|
|
223
|
+
const result = await secrets.setSecretIamPolicy(
|
|
224
|
+
projectId,
|
|
225
|
+
secretName,
|
|
226
|
+
mockPolicy,
|
|
227
|
+
accessToken,
|
|
228
|
+
() => {}
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
assert.deepEqual(result, mockPolicy);
|
|
232
|
+
assert.equal(setIamPolicyMock.mock.callCount(), 1);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('addSecretAccessorBinding', () => {
|
|
237
|
+
it('should add binding if it does not exist', async () => {
|
|
238
|
+
const initialPolicy = { bindings: [] };
|
|
239
|
+
const updatedPolicy = {
|
|
240
|
+
bindings: [
|
|
241
|
+
{
|
|
242
|
+
role: 'roles/secretmanager.secretAccessor',
|
|
243
|
+
members: ['serviceAccount:test-sa'],
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
getIamPolicyImpl = () => Promise.resolve([initialPolicy]);
|
|
249
|
+
setIamPolicyImpl = () => Promise.resolve([updatedPolicy]);
|
|
250
|
+
|
|
251
|
+
const result = await secrets.addSecretAccessorBinding(
|
|
252
|
+
projectId,
|
|
253
|
+
secretName,
|
|
254
|
+
'serviceAccount:test-sa',
|
|
255
|
+
accessToken,
|
|
256
|
+
() => {}
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
assert.deepEqual(result, updatedPolicy);
|
|
260
|
+
assert.equal(setIamPolicyMock.mock.callCount(), 1);
|
|
261
|
+
assert.deepEqual(
|
|
262
|
+
setIamPolicyMock.mock.calls[0].arguments[0].policy,
|
|
263
|
+
updatedPolicy
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should not update if binding already exists', async () => {
|
|
268
|
+
const existingPolicy = {
|
|
269
|
+
bindings: [
|
|
270
|
+
{
|
|
271
|
+
role: 'roles/secretmanager.secretAccessor',
|
|
272
|
+
members: ['serviceAccount:test-sa'],
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
getIamPolicyImpl = () => Promise.resolve([existingPolicy]);
|
|
278
|
+
|
|
279
|
+
const result = await secrets.addSecretAccessorBinding(
|
|
280
|
+
projectId,
|
|
281
|
+
secretName,
|
|
282
|
+
'serviceAccount:test-sa',
|
|
283
|
+
accessToken,
|
|
284
|
+
() => {}
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
assert.deepEqual(result, existingPolicy);
|
|
288
|
+
assert.equal(setIamPolicyMock.mock.callCount(), 0);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it, mock, beforeEach, afterEach } from 'node:test';
|
|
3
|
+
import esmock from 'esmock';
|
|
4
|
+
|
|
5
|
+
describe('Storage API', () => {
|
|
6
|
+
let storageApi;
|
|
7
|
+
let logAndProgressMock;
|
|
8
|
+
let callWithRetryMock;
|
|
9
|
+
let getStorageClientMock;
|
|
10
|
+
let getProjectNumberMock;
|
|
11
|
+
let mockStorage;
|
|
12
|
+
let mockBucket;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
logAndProgressMock = mock.fn();
|
|
16
|
+
callWithRetryMock = mock.fn((fn) => fn());
|
|
17
|
+
getProjectNumberMock = mock.fn(() => Promise.resolve('123456'));
|
|
18
|
+
|
|
19
|
+
mockBucket = {
|
|
20
|
+
name: 'test-bucket',
|
|
21
|
+
exists: mock.fn(() => Promise.resolve([false])),
|
|
22
|
+
getMetadata: mock.fn(() =>
|
|
23
|
+
Promise.resolve([{ projectNumber: '123456' }])
|
|
24
|
+
),
|
|
25
|
+
file: mock.fn((blobName) => ({
|
|
26
|
+
name: blobName,
|
|
27
|
+
save: mock.fn(() => Promise.resolve()),
|
|
28
|
+
})),
|
|
29
|
+
iam: {
|
|
30
|
+
getPolicy: mock.fn(() => Promise.resolve([{ bindings: [] }])),
|
|
31
|
+
setPolicy: mock.fn(() => Promise.resolve()),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
mockStorage = {
|
|
36
|
+
bucket: mock.fn(() => mockBucket),
|
|
37
|
+
createBucket: mock.fn(() => Promise.resolve([mockBucket])),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
getStorageClientMock = mock.fn(() => Promise.resolve(mockStorage));
|
|
41
|
+
|
|
42
|
+
storageApi = await esmock('../../../lib/cloud-api/storage.js', {
|
|
43
|
+
'../../../lib/util/helpers.js': {
|
|
44
|
+
logAndProgress: logAndProgressMock,
|
|
45
|
+
getProjectNumber: getProjectNumberMock,
|
|
46
|
+
},
|
|
47
|
+
'../../../lib/cloud-api/helpers.js': {
|
|
48
|
+
callWithRetry: callWithRetryMock,
|
|
49
|
+
},
|
|
50
|
+
'../../../lib/clients.js': {
|
|
51
|
+
getStorageClient: getStorageClientMock,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
mock.restoreAll();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('grantBucketAccess', () => {
|
|
61
|
+
it('should grant access if member does not have role', async () => {
|
|
62
|
+
const testBucket = {
|
|
63
|
+
name: 'test-bucket',
|
|
64
|
+
iam: {
|
|
65
|
+
getPolicy: mock.fn(() => Promise.resolve([{ bindings: [] }])),
|
|
66
|
+
setPolicy: mock.fn(() => Promise.resolve()),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
await storageApi.grantBucketAccess(
|
|
70
|
+
testBucket,
|
|
71
|
+
'roles/storage.objectAdmin',
|
|
72
|
+
'serviceAccount:test@example.com'
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
assert.strictEqual(testBucket.iam.getPolicy.mock.callCount(), 1);
|
|
76
|
+
assert.strictEqual(testBucket.iam.setPolicy.mock.callCount(), 1);
|
|
77
|
+
|
|
78
|
+
const setPolicyCall = testBucket.iam.setPolicy.mock.calls[0];
|
|
79
|
+
const updatedPolicy = setPolicyCall.arguments[0];
|
|
80
|
+
assert.deepStrictEqual(updatedPolicy.bindings, [
|
|
81
|
+
{
|
|
82
|
+
role: 'roles/storage.objectAdmin',
|
|
83
|
+
members: ['serviceAccount:test@example.com'],
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should add to existing role bindings if role exists but member is missing', async () => {
|
|
89
|
+
const testBucket = {
|
|
90
|
+
name: 'test-bucket',
|
|
91
|
+
iam: {
|
|
92
|
+
getPolicy: mock.fn(() =>
|
|
93
|
+
Promise.resolve([
|
|
94
|
+
{
|
|
95
|
+
bindings: [
|
|
96
|
+
{
|
|
97
|
+
role: 'roles/storage.objectAdmin',
|
|
98
|
+
members: ['serviceAccount:existing@example.com'],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
])
|
|
103
|
+
),
|
|
104
|
+
setPolicy: mock.fn(() => Promise.resolve()),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
await storageApi.grantBucketAccess(
|
|
109
|
+
testBucket,
|
|
110
|
+
'roles/storage.objectAdmin',
|
|
111
|
+
'serviceAccount:test@example.com'
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
assert.strictEqual(testBucket.iam.setPolicy.mock.callCount(), 1);
|
|
115
|
+
const updatedPolicy = testBucket.iam.setPolicy.mock.calls[0].arguments[0];
|
|
116
|
+
const binding = updatedPolicy.bindings.find(
|
|
117
|
+
(b) => b.role === 'roles/storage.objectAdmin'
|
|
118
|
+
);
|
|
119
|
+
assert.ok(binding.members.includes('serviceAccount:test@example.com'));
|
|
120
|
+
assert.ok(
|
|
121
|
+
binding.members.includes('serviceAccount:existing@example.com')
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should not call setPolicy if member already has role', async () => {
|
|
126
|
+
const testBucket = {
|
|
127
|
+
name: 'test-bucket',
|
|
128
|
+
iam: {
|
|
129
|
+
getPolicy: mock.fn(() =>
|
|
130
|
+
Promise.resolve([
|
|
131
|
+
{
|
|
132
|
+
bindings: [
|
|
133
|
+
{
|
|
134
|
+
role: 'roles/storage.objectAdmin',
|
|
135
|
+
members: ['serviceAccount:test@example.com'],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
])
|
|
140
|
+
),
|
|
141
|
+
setPolicy: mock.fn(() => Promise.resolve()),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
await storageApi.grantBucketAccess(
|
|
146
|
+
testBucket,
|
|
147
|
+
'roles/storage.objectAdmin',
|
|
148
|
+
'serviceAccount:test@example.com'
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
assert.strictEqual(testBucket.iam.getPolicy.mock.callCount(), 1);
|
|
152
|
+
assert.strictEqual(testBucket.iam.setPolicy.mock.callCount(), 0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should handle errors gracefully by logging a warning instead of throwing', async () => {
|
|
156
|
+
const testBucket = {
|
|
157
|
+
name: 'test-bucket',
|
|
158
|
+
iam: {
|
|
159
|
+
getPolicy: mock.fn(() =>
|
|
160
|
+
Promise.reject(new Error('IAM Permission Denied'))
|
|
161
|
+
),
|
|
162
|
+
setPolicy: mock.fn(),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
await storageApi.grantBucketAccess(
|
|
167
|
+
testBucket,
|
|
168
|
+
'roles/storage.objectAdmin',
|
|
169
|
+
'serviceAccount:test@example.com'
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const errorLogs = logAndProgressMock.mock.calls.filter(
|
|
173
|
+
(c) => c.arguments[2] === 'warn'
|
|
174
|
+
);
|
|
175
|
+
assert.ok(errorLogs.length > 0);
|
|
176
|
+
assert.ok(errorLogs[0].arguments[0].includes('IAM Permission Denied'));
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('ensureStorageBucketExists', () => {
|
|
181
|
+
it('should return bucket if it exists and ownership matches', async () => {
|
|
182
|
+
mockBucket.exists.mock.mockImplementation(() => Promise.resolve([true]));
|
|
183
|
+
mockBucket.getMetadata.mock.mockImplementation(() =>
|
|
184
|
+
Promise.resolve([{ projectNumber: '123456' }])
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const result = await storageApi.ensureStorageBucketExists(
|
|
188
|
+
'test-project',
|
|
189
|
+
'test-bucket',
|
|
190
|
+
'us-central1',
|
|
191
|
+
'token'
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
assert.strictEqual(result, mockBucket);
|
|
195
|
+
assert.strictEqual(mockStorage.createBucket.mock.callCount(), 0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should create bucket without labels if none provided', async () => {
|
|
199
|
+
mockBucket.exists.mock.mockImplementation(() => Promise.resolve([false]));
|
|
200
|
+
mockStorage.createBucket.mock.mockImplementation((name, options) =>
|
|
201
|
+
Promise.resolve([mockBucket])
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const result = await storageApi.ensureStorageBucketExists(
|
|
205
|
+
'test-project',
|
|
206
|
+
'test-bucket',
|
|
207
|
+
'us-central1',
|
|
208
|
+
'token'
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
assert.strictEqual(result, mockBucket);
|
|
212
|
+
assert.strictEqual(mockStorage.createBucket.mock.callCount(), 1);
|
|
213
|
+
const call = mockStorage.createBucket.mock.calls[0];
|
|
214
|
+
assert.strictEqual(call.arguments[0], 'test-bucket');
|
|
215
|
+
assert.deepStrictEqual(call.arguments[1], { location: 'us-central1' });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should create bucket with labels if provided', async () => {
|
|
219
|
+
mockBucket.exists.mock.mockImplementation(() => Promise.resolve([false]));
|
|
220
|
+
mockStorage.createBucket.mock.mockImplementation((name, options) =>
|
|
221
|
+
Promise.resolve([mockBucket])
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const labels = { key: 'value' };
|
|
225
|
+
const result = await storageApi.ensureStorageBucketExists(
|
|
226
|
+
'test-project',
|
|
227
|
+
'test-bucket',
|
|
228
|
+
'us-central1',
|
|
229
|
+
'token',
|
|
230
|
+
labels,
|
|
231
|
+
false
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
assert.strictEqual(result, mockBucket);
|
|
235
|
+
assert.strictEqual(mockStorage.createBucket.mock.callCount(), 1);
|
|
236
|
+
const call = mockStorage.createBucket.mock.calls[0];
|
|
237
|
+
assert.strictEqual(call.arguments[0], 'test-bucket');
|
|
238
|
+
assert.deepStrictEqual(call.arguments[1], {
|
|
239
|
+
location: 'us-central1',
|
|
240
|
+
metadata: { labels: labels },
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should fall back to randomized bucket if primary bucket belongs to a different project', async () => {
|
|
245
|
+
let callCount = 0;
|
|
246
|
+
mockBucket.exists.mock.mockImplementation(() => {
|
|
247
|
+
callCount++;
|
|
248
|
+
return Promise.resolve([callCount === 1]);
|
|
249
|
+
});
|
|
250
|
+
mockBucket.getMetadata.mock.mockImplementation(() =>
|
|
251
|
+
Promise.resolve([{ projectNumber: '999999' }])
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
mockStorage.createBucket.mock.mockImplementation((name) =>
|
|
255
|
+
Promise.resolve([{ name }])
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const result = await storageApi.ensureStorageBucketExists(
|
|
259
|
+
'test-project',
|
|
260
|
+
'test-bucket',
|
|
261
|
+
'us-central1',
|
|
262
|
+
'token'
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
assert.ok(result.name.startsWith('test-bucket-'));
|
|
266
|
+
assert.notStrictEqual(result.name, 'test-bucket');
|
|
267
|
+
assert.strictEqual(mockStorage.createBucket.mock.callCount(), 1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should fall back to randomized bucket and preserve labels if exists check throws 403 (bucket squatting)', async () => {
|
|
271
|
+
let callCount = 0;
|
|
272
|
+
mockBucket.exists.mock.mockImplementation(() => {
|
|
273
|
+
callCount++;
|
|
274
|
+
if (callCount === 1) {
|
|
275
|
+
return Promise.reject({ code: 403 });
|
|
276
|
+
}
|
|
277
|
+
return Promise.resolve([false]);
|
|
278
|
+
});
|
|
279
|
+
mockBucket.getMetadata.mock.mockImplementation(() =>
|
|
280
|
+
Promise.reject({ code: 403 })
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
mockStorage.createBucket.mock.mockImplementation((name, options) =>
|
|
284
|
+
Promise.resolve([{ name, options }])
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const labels = { app: 'my-app' };
|
|
288
|
+
const result = await storageApi.ensureStorageBucketExists(
|
|
289
|
+
'test-project',
|
|
290
|
+
'test-bucket',
|
|
291
|
+
'us-central1',
|
|
292
|
+
'token',
|
|
293
|
+
labels
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
assert.ok(result.name.startsWith('test-bucket-'));
|
|
297
|
+
assert.notStrictEqual(result.name, 'test-bucket');
|
|
298
|
+
assert.strictEqual(mockStorage.createBucket.mock.callCount(), 1);
|
|
299
|
+
const call = mockStorage.createBucket.mock.calls[0];
|
|
300
|
+
assert.deepStrictEqual(call.arguments[1], {
|
|
301
|
+
location: 'us-central1',
|
|
302
|
+
metadata: { labels: labels },
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should preserve labels when falling back to randomized bucket due to ownership mismatch', async () => {
|
|
307
|
+
let callCount = 0;
|
|
308
|
+
mockBucket.exists.mock.mockImplementation(() => {
|
|
309
|
+
callCount++;
|
|
310
|
+
return Promise.resolve([callCount === 1]);
|
|
311
|
+
});
|
|
312
|
+
mockBucket.getMetadata.mock.mockImplementation(() =>
|
|
313
|
+
Promise.resolve([{ projectNumber: '999999' }])
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
mockStorage.createBucket.mock.mockImplementation((name, options) =>
|
|
317
|
+
Promise.resolve([{ name, options }])
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const labels = { app: 'my-app' };
|
|
321
|
+
const result = await storageApi.ensureStorageBucketExists(
|
|
322
|
+
'test-project',
|
|
323
|
+
'test-bucket',
|
|
324
|
+
'us-central1',
|
|
325
|
+
'token',
|
|
326
|
+
labels
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
assert.ok(result.name.startsWith('test-bucket-'));
|
|
330
|
+
assert.notStrictEqual(result.name, 'test-bucket');
|
|
331
|
+
assert.strictEqual(mockStorage.createBucket.mock.callCount(), 1);
|
|
332
|
+
const call = mockStorage.createBucket.mock.calls[0];
|
|
333
|
+
assert.deepStrictEqual(call.arguments[1], {
|
|
334
|
+
location: 'us-central1',
|
|
335
|
+
metadata: { labels: labels },
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should preserve labels when falling back to randomized bucket due to createBucket already exists error', async () => {
|
|
340
|
+
mockBucket.exists.mock.mockImplementation(() => Promise.resolve([false]));
|
|
341
|
+
|
|
342
|
+
let createCallCount = 0;
|
|
343
|
+
mockStorage.createBucket.mock.mockImplementation((name, options) => {
|
|
344
|
+
createCallCount++;
|
|
345
|
+
if (createCallCount === 1) {
|
|
346
|
+
return Promise.reject(
|
|
347
|
+
new Error('The requested bucket name already exists')
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return Promise.resolve([{ name, options }]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const labels = { tier: 'frontend' };
|
|
354
|
+
const result = await storageApi.ensureStorageBucketExists(
|
|
355
|
+
'test-project',
|
|
356
|
+
'test-bucket',
|
|
357
|
+
'us-central1',
|
|
358
|
+
'token',
|
|
359
|
+
labels
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
assert.ok(result.name.startsWith('test-bucket-'));
|
|
363
|
+
assert.notStrictEqual(result.name, 'test-bucket');
|
|
364
|
+
assert.strictEqual(mockStorage.createBucket.mock.callCount(), 2);
|
|
365
|
+
const secondCall = mockStorage.createBucket.mock.calls[1];
|
|
366
|
+
assert.deepStrictEqual(secondCall.arguments[1], {
|
|
367
|
+
location: 'us-central1',
|
|
368
|
+
metadata: { labels: labels },
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should rethrow error if exists check throws non-403 error', async () => {
|
|
373
|
+
mockBucket.exists.mock.mockImplementation(() =>
|
|
374
|
+
Promise.reject(new Error('Network failure'))
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
await assert.rejects(async () => {
|
|
378
|
+
await storageApi.ensureStorageBucketExists(
|
|
379
|
+
'test-project',
|
|
380
|
+
'test-bucket',
|
|
381
|
+
'us-central1',
|
|
382
|
+
'token'
|
|
383
|
+
);
|
|
384
|
+
}, /Network failure/);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should throw security error if fallback bucket also fails project ownership check', async () => {
|
|
388
|
+
mockBucket.exists.mock.mockImplementation(() => Promise.resolve([true]));
|
|
389
|
+
mockBucket.getMetadata.mock.mockImplementation(() =>
|
|
390
|
+
Promise.resolve([{ projectNumber: '999999' }])
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
await assert.rejects(async () => {
|
|
394
|
+
await storageApi.ensureStorageBucketExists(
|
|
395
|
+
'test-project',
|
|
396
|
+
'test-bucket',
|
|
397
|
+
'us-central1',
|
|
398
|
+
'token',
|
|
399
|
+
undefined,
|
|
400
|
+
true
|
|
401
|
+
);
|
|
402
|
+
}, /Security Error: Fallback bucket test-bucket belongs to project number 999999/);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
});
|