@pagopa/dx-cli 0.21.2 → 0.21.4

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.
Files changed (27) hide show
  1. package/dist/adapters/azure/__tests__/cloud-account-service.test.js +9 -1
  2. package/dist/adapters/azure/cloud-account-service.js +7 -0
  3. package/dist/adapters/commander/commands/__tests__/add.test.js +12 -4
  4. package/dist/adapters/commander/commands/add.js +5 -2
  5. package/dist/adapters/pagopa-technology/__tests__/authorization.test.js +349 -31
  6. package/dist/adapters/pagopa-technology/azure-authorization-config.d.ts +13 -0
  7. package/dist/adapters/pagopa-technology/azure-authorization-config.js +43 -0
  8. package/dist/adapters/pagopa-technology/{authorization.d.ts → azure-authorization.d.ts} +2 -2
  9. package/dist/adapters/pagopa-technology/azure-authorization.js +239 -0
  10. package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +7 -0
  11. package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.js +3 -0
  12. package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +1 -0
  13. package/dist/adapters/plop/generators/environment/__tests__/prompts.test.js +96 -2
  14. package/dist/adapters/plop/generators/environment/prompts.d.ts +1 -0
  15. package/dist/adapters/plop/generators/environment/prompts.js +6 -0
  16. package/dist/domain/authorization.d.ts +6 -9
  17. package/dist/domain/authorization.js +27 -10
  18. package/dist/domain/github.d.ts +1 -0
  19. package/dist/domain/github.js +1 -0
  20. package/dist/index.js +2 -2
  21. package/dist/use-cases/__tests__/request-authorization.test.js +5 -3
  22. package/dist/use-cases/request-authorization.d.ts +2 -2
  23. package/dist/use-cases/request-authorization.js +2 -2
  24. package/package.json +1 -1
  25. package/templates/environment/bootstrapper/{{env.name}}/data.tf.hbs +0 -16
  26. package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +2 -14
  27. package/dist/adapters/pagopa-technology/authorization.js +0 -124
@@ -254,6 +254,7 @@ describe("initialize", () => {
254
254
  name: "dev",
255
255
  prefix: "dx",
256
256
  }, {
257
+ clientId: "app-client-id",
257
258
  id: "app-id",
258
259
  installationId: "installation-id",
259
260
  key: "private-key\n",
@@ -289,7 +290,7 @@ describe("initialize", () => {
289
290
  issuer: "https://token.actions.githubusercontent.com",
290
291
  subject: "repo:pagopa/dx:environment:bootstrapper-dev-cd",
291
292
  });
292
- expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledTimes(6);
293
+ expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledTimes(7);
293
294
  expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
294
295
  environmentName: "bootstrapper-dev-cd",
295
296
  owner: "pagopa",
@@ -318,6 +319,13 @@ describe("initialize", () => {
318
319
  secretName: "GH_APP_ID",
319
320
  secretValue: "app-id",
320
321
  });
322
+ expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
323
+ environmentName: "bootstrapper-dev-cd",
324
+ owner: "pagopa",
325
+ repo: "dx",
326
+ secretName: "GH_APP_CLIENT_ID",
327
+ secretValue: "app-client-id",
328
+ });
321
329
  expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
322
330
  environmentName: "bootstrapper-dev-cd",
323
331
  owner: "pagopa",
@@ -460,6 +460,13 @@ export class AzureCloudAccountService {
460
460
  secretName: "GH_APP_ID",
461
461
  secretValue: runnerAppCredentials.id,
462
462
  }),
463
+ gitHubService.createOrUpdateEnvironmentSecret({
464
+ environmentName: githubEnvironmentName,
465
+ owner: github.owner,
466
+ repo: github.repo,
467
+ secretName: "GH_APP_CLIENT_ID",
468
+ secretValue: runnerAppCredentials.clientId,
469
+ }),
463
470
  gitHubService.createOrUpdateEnvironmentSecret({
464
471
  environmentName: githubEnvironmentName,
465
472
  owner: github.owner,
@@ -4,7 +4,7 @@
4
4
  import { errAsync, okAsync } from "neverthrow";
5
5
  import { describe, expect, it } from "vitest";
6
6
  import { mock } from "vitest-mock-extended";
7
- import { AuthorizationError, AuthorizationResult, IdentityAlreadyExistsError, } from "../../../../domain/authorization.js";
7
+ import { AuthorizationError, AuthorizationResult, } from "../../../../domain/authorization.js";
8
8
  import { authorizeCloudAccounts } from "../add.js";
9
9
  const makeEnvPayload = (overrides = {}) => ({
10
10
  env: {
@@ -65,6 +65,8 @@ describe("authorizeCloudAccounts", () => {
65
65
  expect(result._unsafeUnwrap()).toEqual([expectedPr]);
66
66
  expect(authService.requestAuthorization).toHaveBeenCalledWith(expect.objectContaining({
67
67
  bootstrapIdentityId: "dx-d-itn-bootstrap-id-01",
68
+ envShort: "d",
69
+ prefix: "dx",
68
70
  subscriptionName: "DEV-FooBar",
69
71
  }));
70
72
  });
@@ -89,6 +91,8 @@ describe("authorizeCloudAccounts", () => {
89
91
  expect(result.isOk()).toBe(true);
90
92
  expect(authService.requestAuthorization).toHaveBeenCalledWith(expect.objectContaining({
91
93
  bootstrapIdentityId: "io-p-weu-bootstrap-id-01",
94
+ envShort: "p",
95
+ prefix: "io",
92
96
  subscriptionName: "PROD-Bar",
93
97
  }));
94
98
  });
@@ -140,7 +144,7 @@ describe("authorizeCloudAccounts", () => {
140
144
  expect(prs).toHaveLength(1);
141
145
  expect(prs[0]).toEqual(expectedPr);
142
146
  });
143
- it("handles IdentityAlreadyExistsError gracefully", async () => {
147
+ it("returns a no-op authorization result when nothing changed", async () => {
144
148
  const authService = mock();
145
149
  const account = {
146
150
  csp: "azure",
@@ -148,12 +152,16 @@ describe("authorizeCloudAccounts", () => {
148
152
  displayName: "DEV-Existing",
149
153
  id: "sub-exists",
150
154
  };
151
- authService.requestAuthorization.mockReturnValue(errAsync(new IdentityAlreadyExistsError("dx-d-itn-bootstrap-id-01")));
155
+ // No-op result: Ok with no URL (identity + groups already configured)
156
+ authService.requestAuthorization.mockReturnValue(okAsync(new AuthorizationResult()));
152
157
  const envPayload = makeEnvPayload({
153
158
  init: { cloudAccountsToInitialize: [account] },
154
159
  });
155
160
  const result = await authorizeCloudAccounts(authService)(envPayload);
156
161
  expect(result.isOk()).toBe(true);
157
- expect(result._unsafeUnwrap()).toEqual([]);
162
+ // The no-op result is still collected but has no URL
163
+ const prs = result._unsafeUnwrap();
164
+ expect(prs).toHaveLength(1);
165
+ expect(prs[0].url).toBeUndefined();
158
166
  });
159
167
  });
@@ -38,6 +38,8 @@ export const authorizeCloudAccounts = (authorizationService) => (envPayload) =>
38
38
  const locShort = locationShort[account.defaultLocation];
39
39
  const input = requestAuthorizationInputSchema.safeParse({
40
40
  bootstrapIdentityId: `${prefix}-${envShort}-${locShort}-bootstrap-id-01`,
41
+ envShort,
42
+ prefix,
41
43
  repoName: envPayload.github.repo,
42
44
  subscriptionName: account.displayName,
43
45
  });
@@ -62,9 +64,10 @@ const displaySummary = (result) => {
62
64
  console.log(chalk.green.bold("\nCloud environment created successfully!"));
63
65
  let step = 1;
64
66
  console.log(chalk.green.bold("\nNext Steps:"));
65
- if (authorizationPrs.length > 0) {
67
+ const prsWithUrl = authorizationPrs.filter((pr) => pr.url != null);
68
+ if (prsWithUrl.length > 0) {
66
69
  console.log(`${step++}. Review the Azure authorization Pull Request(s):`);
67
- for (const authPr of authorizationPrs) {
70
+ for (const authPr of prsWithUrl) {
68
71
  console.log(` - ${chalk.underline(authPr.url)}`);
69
72
  }
70
73
  }
@@ -3,9 +3,10 @@
3
3
  */
4
4
  import { describe, expect, it } from "vitest";
5
5
  import { mock } from "vitest-mock-extended";
6
- import { AuthorizationResult, IdentityAlreadyExistsError, InvalidAuthorizationFileFormatError, requestAuthorizationInputSchema, } from "../../../domain/authorization.js";
6
+ import { AuthorizationResult, InvalidAuthorizationFileFormatError, requestAuthorizationInputSchema, } from "../../../domain/authorization.js";
7
7
  import { FileNotFoundError, PullRequest, } from "../../../domain/github.js";
8
- import { makeAuthorizationService } from "../authorization.js";
8
+ import { DEFAULT_GROUP_SPECS, makeAzureAdGroupName as makeGroupName, } from "../azure-authorization-config.js";
9
+ import { makeAzureAuthorizationService as makeAuthorizationService } from "../azure-authorization.js";
9
10
  const makeEnv = () => {
10
11
  const gitHubService = mock();
11
12
  const authorizationService = makeAuthorizationService(gitHubService);
@@ -16,12 +17,15 @@ const makeEnv = () => {
16
17
  };
17
18
  const makeSampleInput = () => requestAuthorizationInputSchema.parse({
18
19
  bootstrapIdentityId: "test-bootstrap-identity-id",
20
+ envShort: "d",
21
+ prefix: "test",
19
22
  repoName: "test-repo",
20
23
  subscriptionName: "test-subscription",
21
24
  });
22
25
  const FILE_PATH = "src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars.json";
23
26
  // eslint-disable-next-line max-lines-per-function
24
27
  describe("PagoPA AuthorizationService", () => {
28
+ // eslint-disable-next-line max-lines-per-function
25
29
  describe("happy path", () => {
26
30
  it("should create a pull request when all steps succeed", async () => {
27
31
  const { authorizationService, gitHubService } = makeEnv();
@@ -39,21 +43,21 @@ describe("PagoPA AuthorizationService", () => {
39
43
  const authResult = result._unsafeUnwrap();
40
44
  expect(authResult).toBeInstanceOf(AuthorizationResult);
41
45
  expect(authResult.url).toBe("https://github.com/pagopa/eng-azure-authorization/pull/42");
42
- expect(gitHubService.createBranch).toHaveBeenCalledWith({
43
- branchName: "feats/add-test-repo-test-subscription-bootstrap-identity",
44
- fromRef: "main",
46
+ expect(gitHubService.getFileContent).toHaveBeenCalledWith({
45
47
  owner: "pagopa",
48
+ path: FILE_PATH,
49
+ ref: "main",
46
50
  repo: "eng-azure-authorization",
47
51
  });
48
- expect(gitHubService.getFileContent).toHaveBeenCalledWith({
52
+ expect(gitHubService.createBranch).toHaveBeenCalledWith({
53
+ branchName: "feats/add-test-repo-test-subscription-bootstrap-identity",
54
+ fromRef: "main",
49
55
  owner: "pagopa",
50
- path: FILE_PATH,
51
- ref: "feats/add-test-repo-test-subscription-bootstrap-identity",
52
56
  repo: "eng-azure-authorization",
53
57
  });
54
58
  expect(gitHubService.updateFile).toHaveBeenCalledWith(expect.objectContaining({
55
59
  branch: "feats/add-test-repo-test-subscription-bootstrap-identity",
56
- message: "Add directory reader for test-subscription",
60
+ message: "Add bootstrap identity and AD groups for test-subscription",
57
61
  owner: "pagopa",
58
62
  path: FILE_PATH,
59
63
  repo: "eng-azure-authorization",
@@ -61,12 +65,128 @@ describe("PagoPA AuthorizationService", () => {
61
65
  }));
62
66
  expect(gitHubService.createPullRequest).toHaveBeenCalledWith({
63
67
  base: "main",
64
- body: "This PR adds the bootstrap identity `test-bootstrap-identity-id` to the directory readers for subscription `test-subscription`.",
68
+ body: "This PR adds the bootstrap identity `test-bootstrap-identity-id` to the directory readers and configures AD groups for subscription `test-subscription`.",
65
69
  head: "feats/add-test-repo-test-subscription-bootstrap-identity",
66
70
  owner: "pagopa",
67
71
  repo: "eng-azure-authorization",
68
- title: "Add directory reader for test-subscription",
72
+ title: "Add bootstrap identity and AD groups for test-subscription",
73
+ });
74
+ });
75
+ it("should add all default AD groups when none exist", async () => {
76
+ const { authorizationService, gitHubService } = makeEnv();
77
+ const input = makeSampleInput();
78
+ const originalContent = JSON.stringify({ directory_readers: { service_principals_name: [] } }, null, 2);
79
+ gitHubService.createBranch.mockResolvedValue(undefined);
80
+ gitHubService.getFileContent.mockResolvedValue({
81
+ content: originalContent,
82
+ sha: "sha-groups-1",
69
83
  });
84
+ gitHubService.updateFile.mockResolvedValue(undefined);
85
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/50"));
86
+ const result = await authorizationService.requestAuthorization(input);
87
+ expect(result.isOk()).toBe(true);
88
+ const updateCall = gitHubService.updateFile.mock.calls[0][0];
89
+ const updatedParsed = JSON.parse(updateCall.content);
90
+ expect(updatedParsed.groups).toHaveLength(DEFAULT_GROUP_SPECS.length);
91
+ for (const spec of DEFAULT_GROUP_SPECS) {
92
+ const groupName = makeGroupName("test", "d", spec.groupName);
93
+ const found = updatedParsed.groups.find((g) => g.name === groupName);
94
+ expect(found).toBeDefined();
95
+ expect(found.roles).toEqual(spec.roles);
96
+ expect(found.members).toEqual([]);
97
+ }
98
+ });
99
+ it("should preserve existing members and add missing groups", async () => {
100
+ const { authorizationService, gitHubService } = makeEnv();
101
+ const input = makeSampleInput();
102
+ const originalContent = JSON.stringify({
103
+ directory_readers: { service_principals_name: [] },
104
+ groups: [
105
+ {
106
+ members: ["alice@pagopa.it"],
107
+ name: "test-d-adgroup-admin",
108
+ roles: ["Owner"],
109
+ },
110
+ ],
111
+ }, null, 2);
112
+ gitHubService.createBranch.mockResolvedValue(undefined);
113
+ gitHubService.getFileContent.mockResolvedValue({
114
+ content: originalContent,
115
+ sha: "sha-groups-2",
116
+ });
117
+ gitHubService.updateFile.mockResolvedValue(undefined);
118
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/51"));
119
+ const result = await authorizationService.requestAuthorization(input);
120
+ expect(result.isOk()).toBe(true);
121
+ const updateCall = gitHubService.updateFile.mock.calls[0][0];
122
+ const updatedParsed = JSON.parse(updateCall.content);
123
+ // All default groups should be present
124
+ expect(updatedParsed.groups).toHaveLength(DEFAULT_GROUP_SPECS.length);
125
+ // Admin group should preserve existing member
126
+ const adminGroup = updatedParsed.groups.find((g) => g.name === "test-d-adgroup-admin");
127
+ expect(adminGroup.members).toContain("alice@pagopa.it");
128
+ });
129
+ it("should update roles on existing group while preserving members", async () => {
130
+ const { authorizationService, gitHubService } = makeEnv();
131
+ const input = makeSampleInput();
132
+ const originalContent = JSON.stringify({
133
+ directory_readers: { service_principals_name: [] },
134
+ groups: [
135
+ {
136
+ // externals normally gets "Owner" but file has "Reader"
137
+ members: ["bob@pagopa.it"],
138
+ name: "test-d-adgroup-externals",
139
+ roles: ["Reader"],
140
+ },
141
+ ],
142
+ }, null, 2);
143
+ gitHubService.createBranch.mockResolvedValue(undefined);
144
+ gitHubService.getFileContent.mockResolvedValue({
145
+ content: originalContent,
146
+ sha: "sha-groups-3",
147
+ });
148
+ gitHubService.updateFile.mockResolvedValue(undefined);
149
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/52"));
150
+ const result = await authorizationService.requestAuthorization(input);
151
+ expect(result.isOk()).toBe(true);
152
+ const updateCall = gitHubService.updateFile.mock.calls[0][0];
153
+ const updatedParsed = JSON.parse(updateCall.content);
154
+ const externalsGroup = updatedParsed.groups.find((g) => g.name === "test-d-adgroup-externals");
155
+ // Roles updated to default
156
+ expect(externalsGroup.roles).toEqual(["Owner"]);
157
+ // Members preserved
158
+ expect(externalsGroup.members).toContain("bob@pagopa.it");
159
+ });
160
+ it("should preserve custom (non-default) groups", async () => {
161
+ const { authorizationService, gitHubService } = makeEnv();
162
+ const input = makeSampleInput();
163
+ const originalContent = JSON.stringify({
164
+ directory_readers: { service_principals_name: [] },
165
+ groups: [
166
+ {
167
+ members: ["carol@pagopa.it"],
168
+ name: "test-d-adgroup-custom-team",
169
+ roles: ["Contributor"],
170
+ },
171
+ ],
172
+ }, null, 2);
173
+ gitHubService.createBranch.mockResolvedValue(undefined);
174
+ gitHubService.getFileContent.mockResolvedValue({
175
+ content: originalContent,
176
+ sha: "sha-groups-4",
177
+ });
178
+ gitHubService.updateFile.mockResolvedValue(undefined);
179
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/53"));
180
+ const result = await authorizationService.requestAuthorization(input);
181
+ expect(result.isOk()).toBe(true);
182
+ const updateCall = gitHubService.updateFile.mock.calls[0][0];
183
+ const updatedParsed = JSON.parse(updateCall.content);
184
+ // Custom group preserved
185
+ const customGroup = updatedParsed.groups.find((g) => g.name === "test-d-adgroup-custom-team");
186
+ expect(customGroup).toBeDefined();
187
+ expect(customGroup.members).toContain("carol@pagopa.it");
188
+ // All defaults also present
189
+ expect(updatedParsed.groups).toHaveLength(DEFAULT_GROUP_SPECS.length + 1);
70
190
  });
71
191
  it("should preserve existing fields in the JSON file", async () => {
72
192
  const { authorizationService, gitHubService } = makeEnv();
@@ -92,19 +212,14 @@ describe("PagoPA AuthorizationService", () => {
92
212
  expect(result.isOk()).toBe(true);
93
213
  const updateCall = gitHubService.updateFile.mock.calls[0][0];
94
214
  const updatedParsed = JSON.parse(updateCall.content);
95
- expect(updatedParsed).toStrictEqual({
96
- directory_readers: {
97
- service_principals_name: [
98
- "existing-identity",
99
- "test-bootstrap-identity-id",
100
- ],
101
- some_other_field: "keep-me",
102
- },
103
- entra_groups: {
104
- readers: ["reader-group"],
105
- },
106
- other_top_level: true,
107
- });
215
+ // Identity added
216
+ expect(updatedParsed.directory_readers.service_principals_name).toContain("test-bootstrap-identity-id");
217
+ // Extra fields preserved
218
+ expect(updatedParsed.directory_readers.some_other_field).toBe("keep-me");
219
+ expect(updatedParsed.entra_groups).toEqual({ readers: ["reader-group"] });
220
+ expect(updatedParsed.other_top_level).toBe(true);
221
+ // All default groups added
222
+ expect(updatedParsed.groups).toHaveLength(DEFAULT_GROUP_SPECS.length);
108
223
  });
109
224
  it("should append identity to an existing non-empty list", async () => {
110
225
  const { authorizationService, gitHubService } = makeEnv();
@@ -128,23 +243,150 @@ describe("PagoPA AuthorizationService", () => {
128
243
  expect(updatedParsed.directory_readers.service_principals_name).toContain("test-bootstrap-identity-id");
129
244
  expect(updatedParsed.directory_readers.service_principals_name).toContain("existing-identity");
130
245
  });
246
+ it("should use identity-only messages when groups are already correct", async () => {
247
+ const { authorizationService, gitHubService } = makeEnv();
248
+ const input = makeSampleInput();
249
+ // Groups already correct, but identity is missing
250
+ const allGroups = DEFAULT_GROUP_SPECS.map((spec) => ({
251
+ members: [],
252
+ name: makeGroupName("test", "d", spec.groupName),
253
+ roles: [...spec.roles],
254
+ }));
255
+ const originalContent = JSON.stringify({
256
+ directory_readers: { service_principals_name: [] },
257
+ groups: allGroups,
258
+ }, null, 2);
259
+ gitHubService.getFileContent.mockResolvedValue({
260
+ content: originalContent,
261
+ sha: "sha-identity-only",
262
+ });
263
+ gitHubService.createBranch.mockResolvedValue(undefined);
264
+ gitHubService.updateFile.mockResolvedValue(undefined);
265
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/60"));
266
+ const result = await authorizationService.requestAuthorization(input);
267
+ expect(result.isOk()).toBe(true);
268
+ expect(gitHubService.updateFile).toHaveBeenCalledWith(expect.objectContaining({
269
+ message: "Add bootstrap identity for test-subscription",
270
+ }));
271
+ expect(gitHubService.createPullRequest).toHaveBeenCalledWith(expect.objectContaining({
272
+ body: "This PR adds the bootstrap identity `test-bootstrap-identity-id` to the directory readers for subscription `test-subscription`.",
273
+ title: "Add bootstrap identity for test-subscription",
274
+ }));
275
+ });
276
+ it("should use groups-only messages when identity already exists", async () => {
277
+ const { authorizationService, gitHubService } = makeEnv();
278
+ const input = makeSampleInput();
279
+ // Identity present, no groups yet
280
+ const originalContent = JSON.stringify({
281
+ directory_readers: {
282
+ service_principals_name: ["test-bootstrap-identity-id"],
283
+ },
284
+ }, null, 2);
285
+ gitHubService.getFileContent.mockResolvedValue({
286
+ content: originalContent,
287
+ sha: "sha-groups-only",
288
+ });
289
+ gitHubService.createBranch.mockResolvedValue(undefined);
290
+ gitHubService.updateFile.mockResolvedValue(undefined);
291
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/61"));
292
+ const result = await authorizationService.requestAuthorization(input);
293
+ expect(result.isOk()).toBe(true);
294
+ expect(gitHubService.updateFile).toHaveBeenCalledWith(expect.objectContaining({
295
+ message: "Configure AD groups for test-subscription",
296
+ }));
297
+ expect(gitHubService.createPullRequest).toHaveBeenCalledWith(expect.objectContaining({
298
+ body: "This PR configures AD groups for subscription `test-subscription`.",
299
+ title: "Configure AD groups for test-subscription",
300
+ }));
301
+ });
302
+ it("should preserve original group order and append missing defaults at end", async () => {
303
+ const { authorizationService, gitHubService } = makeEnv();
304
+ const input = makeSampleInput();
305
+ // Start with custom group + one default group (externals) in deliberate order
306
+ const originalContent = JSON.stringify({
307
+ directory_readers: { service_principals_name: [] },
308
+ groups: [
309
+ {
310
+ members: ["carol@pagopa.it"],
311
+ name: "test-d-adgroup-custom-team",
312
+ roles: ["Contributor"],
313
+ },
314
+ {
315
+ members: ["alice@pagopa.it"],
316
+ name: "test-d-adgroup-externals",
317
+ roles: ["Owner"],
318
+ },
319
+ ],
320
+ }, null, 2);
321
+ gitHubService.getFileContent.mockResolvedValue({
322
+ content: originalContent,
323
+ sha: "sha-order",
324
+ });
325
+ gitHubService.createBranch.mockResolvedValue(undefined);
326
+ gitHubService.updateFile.mockResolvedValue(undefined);
327
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/62"));
328
+ const result = await authorizationService.requestAuthorization(input);
329
+ expect(result.isOk()).toBe(true);
330
+ const updateCall = gitHubService.updateFile.mock.calls[0][0];
331
+ const updatedParsed = JSON.parse(updateCall.content);
332
+ const groupNames = updatedParsed.groups.map((g) => g.name);
333
+ // Original groups preserve their order
334
+ expect(groupNames[0]).toBe("test-d-adgroup-custom-team");
335
+ expect(groupNames[1]).toBe("test-d-adgroup-externals");
336
+ // Missing defaults appended after existing groups
337
+ expect(groupNames.length).toBe(DEFAULT_GROUP_SPECS.length + 1);
338
+ });
339
+ it("should preserve extra fields on group entries through round-trip", async () => {
340
+ const { authorizationService, gitHubService } = makeEnv();
341
+ const input = makeSampleInput();
342
+ // Group with extra metadata field
343
+ const originalContent = JSON.stringify({
344
+ directory_readers: { service_principals_name: [] },
345
+ groups: [
346
+ {
347
+ members: ["alice@pagopa.it"],
348
+ metadata: { created_by: "automation" },
349
+ name: "test-d-adgroup-admin",
350
+ roles: ["Owner"],
351
+ },
352
+ ],
353
+ }, null, 2);
354
+ gitHubService.getFileContent.mockResolvedValue({
355
+ content: originalContent,
356
+ sha: "sha-extra-fields",
357
+ });
358
+ gitHubService.createBranch.mockResolvedValue(undefined);
359
+ gitHubService.updateFile.mockResolvedValue(undefined);
360
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/63"));
361
+ const result = await authorizationService.requestAuthorization(input);
362
+ expect(result.isOk()).toBe(true);
363
+ const updateCall = gitHubService.updateFile.mock.calls[0][0];
364
+ const updatedParsed = JSON.parse(updateCall.content);
365
+ const adminGroup = updatedParsed.groups.find((g) => g.name === "test-d-adgroup-admin");
366
+ // Extra field preserved
367
+ expect(adminGroup.metadata).toEqual({ created_by: "automation" });
368
+ // Members preserved
369
+ expect(adminGroup.members).toContain("alice@pagopa.it");
370
+ });
131
371
  });
372
+ // eslint-disable-next-line max-lines-per-function
132
373
  describe("error handling", () => {
133
374
  it("should return error when file is not found", async () => {
134
375
  const { authorizationService, gitHubService } = makeEnv();
135
376
  const input = makeSampleInput();
136
- gitHubService.createBranch.mockResolvedValue(undefined);
137
377
  gitHubService.getFileContent.mockRejectedValue(new FileNotFoundError(FILE_PATH));
138
378
  const result = await authorizationService.requestAuthorization(input);
139
379
  expect(result.isErr()).toBe(true);
140
380
  const error = result._unsafeUnwrapErr();
141
381
  expect(error.message).toContain("Unable to get");
142
382
  expect(error.message).toContain("terraform.tfvars.json");
383
+ expect(gitHubService.createBranch).not.toHaveBeenCalled();
143
384
  expect(gitHubService.updateFile).not.toHaveBeenCalled();
144
385
  });
145
- it("should return error when identity already exists", async () => {
386
+ it("should upsert groups and create PR when identity already exists", async () => {
146
387
  const { authorizationService, gitHubService } = makeEnv();
147
388
  const input = makeSampleInput();
389
+ // Identity already present, no groups yet
148
390
  const content = JSON.stringify({
149
391
  directory_readers: {
150
392
  service_principals_name: ["test-bootstrap-identity-id"],
@@ -155,15 +397,86 @@ describe("PagoPA AuthorizationService", () => {
155
397
  content,
156
398
  sha: "sha-123",
157
399
  });
400
+ gitHubService.updateFile.mockResolvedValue(undefined);
401
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/55"));
158
402
  const result = await authorizationService.requestAuthorization(input);
159
- expect(result.isErr()).toBe(true);
160
- expect(result._unsafeUnwrapErr()).toBeInstanceOf(IdentityAlreadyExistsError);
403
+ expect(result.isOk()).toBe(true);
404
+ const authResult = result._unsafeUnwrap();
405
+ expect(authResult.url).toBe("https://github.com/pagopa/eng-azure-authorization/pull/55");
406
+ // Identity must NOT be duplicated
407
+ const updateCall = gitHubService.updateFile.mock.calls[0][0];
408
+ const updatedParsed = JSON.parse(updateCall.content);
409
+ expect(updatedParsed.directory_readers.service_principals_name).toHaveLength(1);
410
+ expect(updatedParsed.directory_readers.service_principals_name).toContain("test-bootstrap-identity-id");
411
+ // All default groups must be created
412
+ expect(updatedParsed.groups).toHaveLength(DEFAULT_GROUP_SPECS.length);
413
+ });
414
+ it("should skip update and PR when identity exists and groups are already correct", async () => {
415
+ const { authorizationService, gitHubService } = makeEnv();
416
+ const input = makeSampleInput();
417
+ // Build a file where the identity is present and all groups are already correct
418
+ const allGroups = DEFAULT_GROUP_SPECS.map((spec) => ({
419
+ members: [],
420
+ name: makeGroupName("test", "d", spec.groupName),
421
+ roles: [...spec.roles],
422
+ }));
423
+ const content = JSON.stringify({
424
+ directory_readers: {
425
+ service_principals_name: ["test-bootstrap-identity-id"],
426
+ },
427
+ groups: allGroups,
428
+ }, null, 2);
429
+ gitHubService.getFileContent.mockResolvedValue({
430
+ content,
431
+ sha: "sha-noop",
432
+ });
433
+ const result = await authorizationService.requestAuthorization(input);
434
+ // Should succeed as a no-op (no PR created)
435
+ expect(result.isOk()).toBe(true);
436
+ expect(result._unsafeUnwrap().url).toBeUndefined();
437
+ // Branch, file update, and PR must not be touched
438
+ expect(gitHubService.createBranch).not.toHaveBeenCalled();
161
439
  expect(gitHubService.updateFile).not.toHaveBeenCalled();
440
+ expect(gitHubService.createPullRequest).not.toHaveBeenCalled();
162
441
  });
163
- it("should return error when file content is not valid JSON", async () => {
442
+ it("should update group roles and create PR when identity exists with wrong roles", async () => {
164
443
  const { authorizationService, gitHubService } = makeEnv();
165
444
  const input = makeSampleInput();
445
+ // Identity present, but externals group has wrong roles
446
+ const content = JSON.stringify({
447
+ directory_readers: {
448
+ service_principals_name: ["test-bootstrap-identity-id"],
449
+ },
450
+ groups: [
451
+ {
452
+ members: ["bob@pagopa.it"],
453
+ name: "test-d-adgroup-externals",
454
+ roles: ["Reader"],
455
+ },
456
+ ],
457
+ }, null, 2);
166
458
  gitHubService.createBranch.mockResolvedValue(undefined);
459
+ gitHubService.getFileContent.mockResolvedValue({
460
+ content,
461
+ sha: "sha-roles",
462
+ });
463
+ gitHubService.updateFile.mockResolvedValue(undefined);
464
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/56"));
465
+ const result = await authorizationService.requestAuthorization(input);
466
+ expect(result.isOk()).toBe(true);
467
+ expect(result._unsafeUnwrap().url).toBe("https://github.com/pagopa/eng-azure-authorization/pull/56");
468
+ const updateCall = gitHubService.updateFile.mock.calls[0][0];
469
+ const updatedParsed = JSON.parse(updateCall.content);
470
+ const externalsGroup = updatedParsed.groups.find((g) => g.name === "test-d-adgroup-externals");
471
+ expect(externalsGroup.roles).toEqual(["Owner"]);
472
+ // Member preserved
473
+ expect(externalsGroup.members).toContain("bob@pagopa.it");
474
+ // Identity not duplicated
475
+ expect(updatedParsed.directory_readers.service_principals_name).toHaveLength(1);
476
+ });
477
+ it("should return error when file content is not valid JSON", async () => {
478
+ const { authorizationService, gitHubService } = makeEnv();
479
+ const input = makeSampleInput();
167
480
  gitHubService.getFileContent.mockResolvedValue({
168
481
  content: "not valid json {{",
169
482
  sha: "sha-123",
@@ -171,12 +484,12 @@ describe("PagoPA AuthorizationService", () => {
171
484
  const result = await authorizationService.requestAuthorization(input);
172
485
  expect(result.isErr()).toBe(true);
173
486
  expect(result._unsafeUnwrapErr()).toBeInstanceOf(InvalidAuthorizationFileFormatError);
487
+ expect(gitHubService.createBranch).not.toHaveBeenCalled();
174
488
  expect(gitHubService.updateFile).not.toHaveBeenCalled();
175
489
  });
176
490
  it("should return error when JSON is missing expected keys", async () => {
177
491
  const { authorizationService, gitHubService } = makeEnv();
178
492
  const input = makeSampleInput();
179
- gitHubService.createBranch.mockResolvedValue(undefined);
180
493
  gitHubService.getFileContent.mockResolvedValue({
181
494
  content: JSON.stringify({ unexpected_key: {} }),
182
495
  sha: "sha-123",
@@ -184,16 +497,21 @@ describe("PagoPA AuthorizationService", () => {
184
497
  const result = await authorizationService.requestAuthorization(input);
185
498
  expect(result.isErr()).toBe(true);
186
499
  expect(result._unsafeUnwrapErr()).toBeInstanceOf(InvalidAuthorizationFileFormatError);
500
+ expect(gitHubService.createBranch).not.toHaveBeenCalled();
187
501
  expect(gitHubService.updateFile).not.toHaveBeenCalled();
188
502
  });
189
503
  it("should return error when branch creation fails", async () => {
190
504
  const { authorizationService, gitHubService } = makeEnv();
191
505
  const input = makeSampleInput();
506
+ const content = JSON.stringify({ directory_readers: { service_principals_name: [] } }, null, 2);
507
+ gitHubService.getFileContent.mockResolvedValue({
508
+ content,
509
+ sha: "sha-123",
510
+ });
192
511
  gitHubService.createBranch.mockRejectedValue(new Error("Failed to create branch: branch already exists"));
193
512
  const result = await authorizationService.requestAuthorization(input);
194
513
  expect(result.isErr()).toBe(true);
195
514
  expect(result._unsafeUnwrapErr().message).toContain("Unable to create branch");
196
- expect(gitHubService.getFileContent).not.toHaveBeenCalled();
197
515
  expect(gitHubService.updateFile).not.toHaveBeenCalled();
198
516
  });
199
517
  it("should return error when file update fails", async () => {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Azure authorization group configuration
3
+ *
4
+ * Defines the default Azure AD groups that the PagoPA Technology adapter
5
+ * keeps aligned inside the subscription authorization repository.
6
+ */
7
+ type AzureAdGroupSpec = {
8
+ readonly groupName: string;
9
+ readonly roles: readonly string[];
10
+ };
11
+ export declare const DEFAULT_GROUP_SPECS: readonly AzureAdGroupSpec[];
12
+ export declare const makeAzureAdGroupName: (prefix: string, envShort: string, groupName: string) => string;
13
+ export {};