@pagopa/dx-cli 0.15.2 → 0.15.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.
- package/bin/index.js +8 -1280
- package/dist/adapters/azure/__tests__/cloud-account-repository.test.d.ts +1 -0
- package/dist/adapters/azure/__tests__/cloud-account-repository.test.js +95 -0
- package/dist/adapters/azure/__tests__/cloud-account-service.test.d.ts +1 -0
- package/dist/adapters/azure/__tests__/cloud-account-service.test.js +95 -0
- package/dist/adapters/azure/cloud-account-repository.d.ts +12 -0
- package/dist/adapters/azure/cloud-account-repository.js +23 -0
- package/dist/adapters/azure/cloud-account-service.d.ts +22 -0
- package/dist/adapters/azure/cloud-account-service.js +255 -0
- package/dist/adapters/azure/locations.d.ts +7 -0
- package/dist/adapters/azure/locations.js +21 -0
- package/dist/adapters/codemods/__tests__/registry.test.d.ts +1 -0
- package/dist/adapters/codemods/__tests__/registry.test.js +59 -0
- package/dist/adapters/codemods/__tests__/use-azure-appsvc.test.d.ts +1 -0
- package/dist/adapters/codemods/__tests__/use-azure-appsvc.test.js +77 -0
- package/dist/adapters/codemods/__tests__/use-pnpm.test.d.ts +1 -0
- package/dist/adapters/codemods/__tests__/use-pnpm.test.js +148 -0
- package/dist/adapters/codemods/git.d.ts +2 -0
- package/dist/adapters/codemods/git.js +18 -0
- package/dist/adapters/codemods/index.d.ts +3 -0
- package/dist/adapters/codemods/index.js +9 -0
- package/dist/adapters/codemods/registry.d.ts +8 -0
- package/dist/adapters/codemods/registry.js +16 -0
- package/dist/adapters/codemods/update-code-review.d.ts +3 -0
- package/dist/adapters/codemods/update-code-review.js +60 -0
- package/dist/adapters/codemods/use-azure-appsvc.d.ts +3 -0
- package/dist/adapters/codemods/use-azure-appsvc.js +84 -0
- package/dist/adapters/codemods/use-pnpm.d.ts +22 -0
- package/dist/adapters/codemods/use-pnpm.js +214 -0
- package/dist/adapters/codemods/yaml.d.ts +2 -0
- package/dist/adapters/codemods/yaml.js +8 -0
- package/dist/adapters/commander/commands/codemod.d.ts +8 -0
- package/dist/adapters/commander/commands/codemod.js +22 -0
- package/dist/adapters/commander/commands/doctor.d.ts +4 -0
- package/dist/adapters/commander/commands/doctor.js +12 -0
- package/dist/adapters/commander/commands/info.d.ts +3 -0
- package/dist/adapters/commander/commands/info.js +9 -0
- package/dist/adapters/commander/commands/init.d.ts +7 -0
- package/dist/adapters/commander/commands/init.js +126 -0
- package/dist/adapters/commander/commands/savemoney.d.ts +2 -0
- package/dist/adapters/commander/commands/savemoney.js +26 -0
- package/dist/adapters/commander/index.d.ts +7 -0
- package/dist/adapters/commander/index.js +19 -0
- package/dist/adapters/execa/terraform.d.ts +27 -0
- package/dist/adapters/execa/terraform.js +28 -0
- package/dist/adapters/github/__tests__/github-repo.spec.d.ts +1 -0
- package/dist/adapters/github/__tests__/github-repo.spec.js +67 -0
- package/dist/adapters/github/github-repo.d.ts +2 -0
- package/dist/adapters/github/github-repo.js +31 -0
- package/dist/adapters/logtape/validation-reporter.d.ts +2 -0
- package/dist/adapters/logtape/validation-reporter.js +14 -0
- package/dist/adapters/node/__tests__/data.d.ts +18 -0
- package/dist/adapters/node/__tests__/data.js +22 -0
- package/dist/adapters/node/__tests__/package-json.test.d.ts +1 -0
- package/dist/adapters/node/__tests__/package-json.test.js +86 -0
- package/dist/adapters/node/__tests__/repository.test.d.ts +1 -0
- package/dist/adapters/node/__tests__/repository.test.js +77 -0
- package/dist/adapters/node/fs/__tests__/file-reader.test.d.ts +1 -0
- package/dist/adapters/node/fs/__tests__/file-reader.test.js +80 -0
- package/dist/adapters/node/fs/file-reader.d.ts +24 -0
- package/dist/adapters/node/fs/file-reader.js +26 -0
- package/dist/adapters/node/json/__tests__/index.test.d.ts +1 -0
- package/dist/adapters/node/json/__tests__/index.test.js +14 -0
- package/dist/adapters/node/json/index.d.ts +2 -0
- package/dist/adapters/node/json/index.js +2 -0
- package/dist/adapters/node/package-json.d.ts +2 -0
- package/dist/adapters/node/package-json.js +22 -0
- package/dist/adapters/node/release.d.ts +2 -0
- package/dist/adapters/node/release.js +33 -0
- package/dist/adapters/node/repository.d.ts +2 -0
- package/dist/adapters/node/repository.js +47 -0
- package/dist/adapters/octokit/__tests__/index.test.d.ts +1 -0
- package/dist/adapters/octokit/__tests__/index.test.js +197 -0
- package/dist/adapters/octokit/index.d.ts +24 -0
- package/dist/adapters/octokit/index.js +65 -0
- package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.d.ts +1 -0
- package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +115 -0
- package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.d.ts +1 -0
- package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.js +116 -0
- package/dist/adapters/plop/actions/fetch-github-release.d.ts +12 -0
- package/dist/adapters/plop/actions/fetch-github-release.js +20 -0
- package/dist/adapters/plop/actions/get-node-version.d.ts +2 -0
- package/dist/adapters/plop/actions/get-node-version.js +9 -0
- package/dist/adapters/plop/actions/get-terraform-backend.d.ts +3 -0
- package/dist/adapters/plop/actions/get-terraform-backend.js +17 -0
- package/dist/adapters/plop/actions/init-cloud-accounts.d.ts +5 -0
- package/dist/adapters/plop/actions/init-cloud-accounts.js +13 -0
- package/dist/adapters/plop/actions/provision-terraform-backend.d.ts +10 -0
- package/dist/adapters/plop/actions/provision-terraform-backend.js +16 -0
- package/dist/adapters/plop/actions/semver.d.ts +19 -0
- package/dist/adapters/plop/actions/semver.js +27 -0
- package/dist/adapters/plop/actions/setup-pnpm.d.ts +2 -0
- package/dist/adapters/plop/actions/setup-pnpm.js +26 -0
- package/dist/adapters/plop/generators/environment/__tests__/actions.test.d.ts +2 -0
- package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +55 -0
- package/dist/adapters/plop/generators/environment/actions.d.ts +2 -0
- package/dist/adapters/plop/generators/environment/actions.js +54 -0
- package/dist/adapters/plop/generators/environment/index.d.ts +3 -0
- package/dist/adapters/plop/generators/environment/index.js +19 -0
- package/dist/adapters/plop/generators/environment/prompts.d.ts +66 -0
- package/dist/adapters/plop/generators/environment/prompts.js +166 -0
- package/dist/adapters/plop/generators/monorepo/actions.d.ts +51 -0
- package/dist/adapters/plop/generators/monorepo/actions.js +35 -0
- package/dist/adapters/plop/generators/monorepo/index.d.ts +6 -0
- package/dist/adapters/plop/generators/monorepo/index.js +17 -0
- package/dist/adapters/plop/generators/monorepo/prompts.d.ts +10 -0
- package/dist/adapters/plop/generators/monorepo/prompts.js +31 -0
- package/dist/adapters/plop/helpers/__tests__/resource-prefix.test.d.ts +1 -0
- package/dist/adapters/plop/helpers/__tests__/resource-prefix.test.js +113 -0
- package/dist/adapters/plop/helpers/env-short.d.ts +3 -0
- package/dist/adapters/plop/helpers/env-short.js +9 -0
- package/dist/adapters/plop/helpers/resource-prefix.d.ts +5 -0
- package/dist/adapters/plop/helpers/resource-prefix.js +18 -0
- package/dist/adapters/plop/index.d.ts +8 -0
- package/dist/adapters/plop/index.js +24 -0
- package/dist/adapters/terraform/fmt.d.ts +1 -0
- package/dist/adapters/terraform/fmt.js +17 -0
- package/dist/adapters/yaml/__tests__/index.test.d.ts +1 -0
- package/dist/adapters/yaml/__tests__/index.test.js +53 -0
- package/dist/adapters/yaml/index.d.ts +8 -0
- package/dist/adapters/yaml/index.js +9 -0
- package/dist/adapters/zod/index.d.ts +23 -0
- package/dist/adapters/zod/index.js +22 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +5 -0
- package/dist/domain/__tests__/data.d.ts +17 -0
- package/dist/domain/__tests__/data.js +27 -0
- package/dist/domain/__tests__/environment.test.d.ts +1 -0
- package/dist/domain/__tests__/environment.test.js +282 -0
- package/dist/domain/__tests__/info.test.d.ts +1 -0
- package/dist/domain/__tests__/info.test.js +77 -0
- package/dist/domain/__tests__/package-json.test.d.ts +1 -0
- package/dist/domain/__tests__/package-json.test.js +39 -0
- package/dist/domain/__tests__/repository.test.d.ts +1 -0
- package/dist/domain/__tests__/repository.test.js +101 -0
- package/dist/domain/__tests__/workspace.test.d.ts +1 -0
- package/dist/domain/__tests__/workspace.test.js +57 -0
- package/dist/domain/cloud-account.d.ts +28 -0
- package/dist/domain/cloud-account.js +12 -0
- package/dist/domain/codemod.d.ts +11 -0
- package/dist/domain/codemod.js +1 -0
- package/dist/domain/dependencies.d.ts +10 -0
- package/dist/domain/dependencies.js +1 -0
- package/dist/domain/doctor.d.ts +10 -0
- package/dist/domain/doctor.js +50 -0
- package/dist/domain/environment.d.ts +40 -0
- package/dist/domain/environment.js +57 -0
- package/dist/domain/github-repo.d.ts +6 -0
- package/dist/domain/github-repo.js +8 -0
- package/dist/domain/github.d.ts +37 -0
- package/dist/domain/github.js +29 -0
- package/dist/domain/info.d.ts +11 -0
- package/dist/domain/info.js +52 -0
- package/dist/domain/package-json.d.ts +42 -0
- package/dist/domain/package-json.js +69 -0
- package/dist/domain/remote-backend.d.ts +8 -0
- package/dist/domain/remote-backend.js +9 -0
- package/dist/domain/repository.d.ts +13 -0
- package/dist/domain/repository.js +72 -0
- package/dist/domain/validation.d.ts +16 -0
- package/dist/domain/validation.js +1 -0
- package/dist/domain/workspace.d.ts +9 -0
- package/dist/domain/workspace.js +32 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +35 -0
- package/dist/use-cases/__tests__/apply-codemod.test.d.ts +1 -0
- package/dist/use-cases/__tests__/apply-codemod.test.js +73 -0
- package/dist/use-cases/__tests__/list-codemods.test.d.ts +1 -0
- package/dist/use-cases/__tests__/list-codemods.test.js +38 -0
- package/dist/use-cases/apply-codemod.d.ts +5 -0
- package/dist/use-cases/apply-codemod.js +14 -0
- package/dist/use-cases/list-codemods.d.ts +4 -0
- package/dist/use-cases/list-codemods.js +1 -0
- package/package.json +18 -7
- package/templates/environment/bootstrapper/{{env.name}}/data.tf.hbs +13 -0
- package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +70 -0
- package/templates/environment/bootstrapper/{{env.name}}/providers.tf.hbs +26 -0
- package/templates/environment/core/{{env.name}}/main.tf.hbs +21 -0
- package/templates/environment/core/{{env.name}}/outputs.tf.hbs +8 -0
- package/templates/environment/core/{{env.name}}/providers.tf.hbs +21 -0
- package/templates/environment/shared/backend.tf.hbs +14 -0
- package/templates/environment/shared/locals.tf.hbs +26 -0
- package/templates/monorepo/.editorconfig +8 -0
- package/templates/monorepo/.node-version.hbs +1 -0
- package/templates/monorepo/.pre-commit-config.yaml.hbs +34 -0
- package/templates/monorepo/.prettierignore +5 -0
- package/templates/monorepo/.terraform-version.hbs +1 -0
- package/templates/monorepo/.trivyignore +16 -0
- package/templates/monorepo/README.md.hbs +163 -0
- package/templates/monorepo/infra/repository/main.tf.hbs +11 -0
- package/templates/monorepo/infra/repository/outputs.tf.hbs +11 -0
- package/templates/monorepo/infra/repository/providers.tf.hbs +13 -0
- package/templates/monorepo/package.json.hbs +7 -0
- package/templates/monorepo/pnpm-workspace.yaml +7 -0
- package/templates/monorepo/turbo.json +29 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AzureSubscriptionRepository } from "../cloud-account-repository.js";
|
|
3
|
+
const createMockSubscription = (overrides = {}) => ({
|
|
4
|
+
displayName: "Test Subscription",
|
|
5
|
+
state: "Enabled",
|
|
6
|
+
subscriptionId: "00000000-0000-0000-0000-000000000001",
|
|
7
|
+
...overrides,
|
|
8
|
+
});
|
|
9
|
+
const createMockSubscriptionClient = (subscriptions) => {
|
|
10
|
+
const listIterator = async function* () {
|
|
11
|
+
for (const sub of subscriptions) {
|
|
12
|
+
yield sub;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
subscriptions: {
|
|
17
|
+
list: listIterator,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
vi.mock("@azure/arm-resources-subscriptions", () => ({
|
|
22
|
+
SubscriptionClient: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("@azure/identity", () => ({
|
|
25
|
+
DefaultAzureCredential: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
import { SubscriptionClient } from "@azure/arm-resources-subscriptions";
|
|
28
|
+
import { DefaultAzureCredential } from "@azure/identity";
|
|
29
|
+
const MockedSubscriptionClient = SubscriptionClient;
|
|
30
|
+
describe("AzureSubscriptionRepository", () => {
|
|
31
|
+
it("should return a list of enabled subscriptions", async () => {
|
|
32
|
+
const subscriptions = [
|
|
33
|
+
createMockSubscription({
|
|
34
|
+
displayName: "Sub 1",
|
|
35
|
+
subscriptionId: "sub-1",
|
|
36
|
+
}),
|
|
37
|
+
createMockSubscription({
|
|
38
|
+
displayName: "Sub 2",
|
|
39
|
+
subscriptionId: "sub-2",
|
|
40
|
+
}),
|
|
41
|
+
];
|
|
42
|
+
MockedSubscriptionClient.mockImplementation(() => createMockSubscriptionClient(subscriptions));
|
|
43
|
+
const repository = new AzureSubscriptionRepository(new DefaultAzureCredential());
|
|
44
|
+
const result = await repository.list();
|
|
45
|
+
expect(result).toHaveLength(2);
|
|
46
|
+
expect(result[0]).toMatchObject({
|
|
47
|
+
displayName: "Sub 1",
|
|
48
|
+
id: "sub-1",
|
|
49
|
+
});
|
|
50
|
+
expect(result[1]).toMatchObject({
|
|
51
|
+
displayName: "Sub 2",
|
|
52
|
+
id: "sub-2",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
it("should filter out disabled subscriptions", async () => {
|
|
56
|
+
const subscriptions = [
|
|
57
|
+
createMockSubscription({
|
|
58
|
+
displayName: "Enabled Sub",
|
|
59
|
+
state: "Enabled",
|
|
60
|
+
subscriptionId: "enabled-sub",
|
|
61
|
+
}),
|
|
62
|
+
createMockSubscription({
|
|
63
|
+
displayName: "Disabled Sub",
|
|
64
|
+
state: "Disabled",
|
|
65
|
+
subscriptionId: "disabled-sub",
|
|
66
|
+
}),
|
|
67
|
+
];
|
|
68
|
+
MockedSubscriptionClient.mockImplementation(() => createMockSubscriptionClient(subscriptions));
|
|
69
|
+
const repository = new AzureSubscriptionRepository(new DefaultAzureCredential());
|
|
70
|
+
const result = await repository.list();
|
|
71
|
+
expect(result).toHaveLength(1);
|
|
72
|
+
expect(result[0]).toMatchObject({
|
|
73
|
+
displayName: "Enabled Sub",
|
|
74
|
+
id: "enabled-sub",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
it("should return an empty array when no subscriptions exist", async () => {
|
|
78
|
+
MockedSubscriptionClient.mockImplementation(() => createMockSubscriptionClient([]));
|
|
79
|
+
const repository = new AzureSubscriptionRepository(new DefaultAzureCredential());
|
|
80
|
+
const result = await repository.list();
|
|
81
|
+
expect(result).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
it("should filter out subscriptions with other states", async () => {
|
|
84
|
+
const subscriptions = [
|
|
85
|
+
createMockSubscription({ state: "Enabled" }),
|
|
86
|
+
createMockSubscription({ state: "Warned" }),
|
|
87
|
+
createMockSubscription({ state: "PastDue" }),
|
|
88
|
+
createMockSubscription({ state: "Deleted" }),
|
|
89
|
+
];
|
|
90
|
+
MockedSubscriptionClient.mockImplementation(() => createMockSubscriptionClient(subscriptions));
|
|
91
|
+
const repository = new AzureSubscriptionRepository(new DefaultAzureCredential());
|
|
92
|
+
const result = await repository.list();
|
|
93
|
+
expect(result).toHaveLength(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DefaultAzureCredential } from "@azure/identity";
|
|
2
|
+
import { test as baseTest, describe, expect, vi } from "vitest";
|
|
3
|
+
import { AzureCloudAccountService } from "../cloud-account-service.js";
|
|
4
|
+
const { queryResources } = vi.hoisted(() => ({
|
|
5
|
+
queryResources: vi.fn().mockRejectedValue(new Error("Not implemented")),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("@azure/identity", () => ({
|
|
8
|
+
DefaultAzureCredential: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("@azure/arm-resourcegraph", () => ({
|
|
11
|
+
ResourceGraphClient: class {
|
|
12
|
+
resources = queryResources;
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
const test = baseTest.extend({
|
|
16
|
+
// the empty pattern is required by vitest!!!
|
|
17
|
+
// eslint-disable-next-line no-empty-pattern
|
|
18
|
+
cloudAccountService: async ({}, use) => {
|
|
19
|
+
const cloudAccountService = new AzureCloudAccountService(new DefaultAzureCredential());
|
|
20
|
+
await use(cloudAccountService);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
describe("getTerraformBackend", () => {
|
|
24
|
+
test("returns undefined when no matching storage account is found", async ({ cloudAccountService, }) => {
|
|
25
|
+
queryResources.mockResolvedValueOnce({
|
|
26
|
+
data: [],
|
|
27
|
+
totalRecords: 0,
|
|
28
|
+
});
|
|
29
|
+
const result = await cloudAccountService.getTerraformBackend("sub-1", {
|
|
30
|
+
name: "dev",
|
|
31
|
+
prefix: "dx",
|
|
32
|
+
});
|
|
33
|
+
expect(result).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
test("return the only matching storage account", async ({ cloudAccountService, }) => {
|
|
36
|
+
queryResources.mockResolvedValueOnce({
|
|
37
|
+
data: [
|
|
38
|
+
{
|
|
39
|
+
location: "italynorth",
|
|
40
|
+
name: "dxditntfstatest01",
|
|
41
|
+
resourceGroup: "dx-d-itn-tfstate-rg-01",
|
|
42
|
+
subscriptionId: "sub-1",
|
|
43
|
+
type: "microsoft.storage/storageaccounts",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
totalRecords: 1,
|
|
47
|
+
});
|
|
48
|
+
const result = await cloudAccountService.getTerraformBackend("sub-1", {
|
|
49
|
+
name: "dev",
|
|
50
|
+
prefix: "dx",
|
|
51
|
+
});
|
|
52
|
+
expect(result).toEqual(expect.objectContaining({
|
|
53
|
+
resourceGroupName: "dx-d-itn-tfstate-rg-01",
|
|
54
|
+
storageAccountName: "dxditntfstatest01",
|
|
55
|
+
type: "azurerm",
|
|
56
|
+
}));
|
|
57
|
+
});
|
|
58
|
+
test("returns the best matching storage account among multiple", async ({ cloudAccountService, }) => {
|
|
59
|
+
queryResources.mockResolvedValueOnce({
|
|
60
|
+
data: [
|
|
61
|
+
{
|
|
62
|
+
location: "italynorth",
|
|
63
|
+
name: "dxditntfstatest01",
|
|
64
|
+
resourceGroup: "dx-d-itn-tfstate-rg-01",
|
|
65
|
+
subscriptionId: "sub-1",
|
|
66
|
+
type: "microsoft.storage/storageaccounts",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
location: "italynorth",
|
|
70
|
+
name: "dxditntfstatest02",
|
|
71
|
+
resourceGroup: "dx-d-itn-tfstate-rg-01",
|
|
72
|
+
subscriptionId: "sub-1",
|
|
73
|
+
type: "microsoft.storage/storageaccounts",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
location: "westeurope",
|
|
77
|
+
name: "dxdweutfstatest01",
|
|
78
|
+
resourceGroup: "dx-d-weu-tfstate-rg-01",
|
|
79
|
+
subscriptionId: "sub-1",
|
|
80
|
+
type: "microsoft.storage/storageaccounts",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
totalRecords: 3,
|
|
84
|
+
});
|
|
85
|
+
const result = await cloudAccountService.getTerraformBackend("sub-1", {
|
|
86
|
+
name: "dev",
|
|
87
|
+
prefix: "dx",
|
|
88
|
+
});
|
|
89
|
+
expect(result).toEqual(expect.objectContaining({
|
|
90
|
+
resourceGroupName: "dx-d-itn-tfstate-rg-01",
|
|
91
|
+
storageAccountName: "dxditntfstatest02",
|
|
92
|
+
type: "azurerm",
|
|
93
|
+
}));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TokenCredential } from "@azure/identity";
|
|
2
|
+
import { type CloudAccountRepository } from "../../domain/cloud-account.js";
|
|
3
|
+
export declare class AzureSubscriptionRepository implements CloudAccountRepository {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(credential: TokenCredential);
|
|
6
|
+
list(): Promise<{
|
|
7
|
+
csp: "azure";
|
|
8
|
+
defaultLocation: string;
|
|
9
|
+
displayName: string;
|
|
10
|
+
id: string;
|
|
11
|
+
}[]>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { SubscriptionClient } from "@azure/arm-resources-subscriptions";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { cloudAccountSchema, } from "../../domain/cloud-account.js";
|
|
4
|
+
import { defaultLocation } from "./locations.js";
|
|
5
|
+
export class AzureSubscriptionRepository {
|
|
6
|
+
#subscriptionClient;
|
|
7
|
+
constructor(credential) {
|
|
8
|
+
this.#subscriptionClient = new SubscriptionClient(credential);
|
|
9
|
+
}
|
|
10
|
+
async list() {
|
|
11
|
+
const subscriptions = [];
|
|
12
|
+
for await (const subscription of this.#subscriptionClient.subscriptions.list()) {
|
|
13
|
+
if (subscription.state === "Enabled") {
|
|
14
|
+
subscriptions.push({
|
|
15
|
+
defaultLocation,
|
|
16
|
+
displayName: subscription.displayName,
|
|
17
|
+
id: subscription.subscriptionId,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return z.array(cloudAccountSchema).parse(subscriptions);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TokenCredential } from "@azure/identity";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { CloudAccount, type CloudAccountService } from "../../domain/cloud-account.js";
|
|
4
|
+
import { type EnvironmentId } from "../../domain/environment.js";
|
|
5
|
+
import { type TerraformBackend } from "../../domain/remote-backend.js";
|
|
6
|
+
export declare const resourceGraphDataSchema: z.ZodObject<{
|
|
7
|
+
location: z.ZodEnum<{
|
|
8
|
+
italynorth: "italynorth";
|
|
9
|
+
westeurope: "westeurope";
|
|
10
|
+
}>;
|
|
11
|
+
name: z.ZodString;
|
|
12
|
+
resourceGroup: z.ZodString;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
export declare class AzureCloudAccountService implements CloudAccountService {
|
|
15
|
+
#private;
|
|
16
|
+
constructor(credential: TokenCredential);
|
|
17
|
+
getTerraformBackend(cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId): Promise<TerraformBackend | undefined>;
|
|
18
|
+
hasUserPermissionToInitialize(cloudAccountId: CloudAccount["id"]): Promise<boolean>;
|
|
19
|
+
initialize(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, tags?: Record<string, string>): Promise<void>;
|
|
20
|
+
isInitialized(cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId): Promise<boolean>;
|
|
21
|
+
provisionTerraformBackend(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, tags?: Record<string, string>): Promise<TerraformBackend>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { AuthorizationManagementClient } from "@azure/arm-authorization";
|
|
2
|
+
import { ManagedServiceIdentityClient } from "@azure/arm-msi";
|
|
3
|
+
import { ResourceGraphClient } from "@azure/arm-resourcegraph";
|
|
4
|
+
import { ResourceManagementClient } from "@azure/arm-resources";
|
|
5
|
+
import { StorageManagementClient } from "@azure/arm-storage";
|
|
6
|
+
import { BlobServiceClient } from "@azure/storage-blob";
|
|
7
|
+
import { getLogger } from "@logtape/logtape";
|
|
8
|
+
import { Client } from "@microsoft/microsoft-graph-client";
|
|
9
|
+
import * as assert from "node:assert/strict";
|
|
10
|
+
import { z } from "zod/v4";
|
|
11
|
+
import { environmentShort, } from "../../domain/environment.js";
|
|
12
|
+
import { terraformBackendSchema, } from "../../domain/remote-backend.js";
|
|
13
|
+
import { isAzureLocation, locations, locationShort } from "./locations.js";
|
|
14
|
+
// We are only interested in these properties for now;
|
|
15
|
+
// the actual result structure contains the full cloud resource object
|
|
16
|
+
export const resourceGraphDataSchema = z.object({
|
|
17
|
+
location: z.enum(locations),
|
|
18
|
+
name: z.string(),
|
|
19
|
+
resourceGroup: z.string(),
|
|
20
|
+
});
|
|
21
|
+
const graphUserResponseSchema = z.object({
|
|
22
|
+
id: z.string(),
|
|
23
|
+
});
|
|
24
|
+
const graphGroupMembershipItemSchema = z.object({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
});
|
|
27
|
+
const graphGroupMembershipResponseSchema = z.object({
|
|
28
|
+
"@odata.nextLink": z.string().optional(),
|
|
29
|
+
value: z.array(graphGroupMembershipItemSchema),
|
|
30
|
+
});
|
|
31
|
+
export class AzureCloudAccountService {
|
|
32
|
+
#credential;
|
|
33
|
+
#resourceGraphClient;
|
|
34
|
+
constructor(credential) {
|
|
35
|
+
this.#resourceGraphClient = new ResourceGraphClient(credential);
|
|
36
|
+
this.#credential = credential;
|
|
37
|
+
}
|
|
38
|
+
async getTerraformBackend(cloudAccountId, { name, prefix }) {
|
|
39
|
+
const allLocations = Object.values(locationShort).join("|");
|
|
40
|
+
const shortEnv = environmentShort[name];
|
|
41
|
+
// Check if a storage account with the expected name exists
|
|
42
|
+
// $prefix + environment short + location + "tfstatest" + suffix (e.g., "dxpitntfstatest01")
|
|
43
|
+
// it can return multiple results (e.g. for different location or instance number)
|
|
44
|
+
const resourceName = `${prefix}${shortEnv}(${allLocations})tfstatest(0[1-9]|[1-9]\\d)`;
|
|
45
|
+
const query = `resources
|
|
46
|
+
| where type == 'microsoft.storage/storageaccounts'
|
|
47
|
+
| where name matches regex @'${resourceName}'
|
|
48
|
+
`;
|
|
49
|
+
const result = await this.#resourceGraphClient.resources({
|
|
50
|
+
query,
|
|
51
|
+
subscriptions: [cloudAccountId],
|
|
52
|
+
});
|
|
53
|
+
if (result.totalRecords === 0) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const storageAccounts = z.array(resourceGraphDataSchema).parse(result.data);
|
|
57
|
+
// on multiple results, rank storage accounts by location priority and instance number
|
|
58
|
+
if (storageAccounts.length > 0) {
|
|
59
|
+
storageAccounts.sort((a, b) => {
|
|
60
|
+
// compare locations priority
|
|
61
|
+
const locationComparison = locations.indexOf(a.location) - locations.indexOf(b.location);
|
|
62
|
+
if (locationComparison === 0) {
|
|
63
|
+
// same location, compare by name (to get the highest instance number)
|
|
64
|
+
return b.name.localeCompare(a.name);
|
|
65
|
+
}
|
|
66
|
+
return locationComparison;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return terraformBackendSchema.parse({
|
|
70
|
+
resourceGroupName: storageAccounts[0].resourceGroup,
|
|
71
|
+
storageAccountName: storageAccounts[0].name,
|
|
72
|
+
subscriptionId: cloudAccountId,
|
|
73
|
+
type: "azurerm",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async hasUserPermissionToInitialize(cloudAccountId) {
|
|
77
|
+
try {
|
|
78
|
+
// All principal IDs to check (user + all groups)
|
|
79
|
+
const allPrincipalIds = await this.#getCurrentPrincipalIds();
|
|
80
|
+
// Get role assignments for the subscription
|
|
81
|
+
const authClient = new AuthorizationManagementClient(this.#credential, cloudAccountId);
|
|
82
|
+
const requiredRoles = [
|
|
83
|
+
"8e3af657-a8ff-443c-a75c-2fe8c4bcb635", // Owner
|
|
84
|
+
"ba92f5b4-2d11-453d-a403-e96b0029c9fe", // Storage Blob Data Contributor
|
|
85
|
+
];
|
|
86
|
+
const scope = `/subscriptions/${cloudAccountId}`;
|
|
87
|
+
// Collect all role definition IDs assigned to the user or their groups
|
|
88
|
+
const assignedRoleDefinitionIds = new Set();
|
|
89
|
+
for await (const assignment of authClient.roleAssignments.listForScope(scope)) {
|
|
90
|
+
// Check if this assignment is for the user or any of their groups
|
|
91
|
+
if (assignment.principalId &&
|
|
92
|
+
allPrincipalIds.has(assignment.principalId)) {
|
|
93
|
+
// Extract role definition ID from the full resource ID
|
|
94
|
+
// Format: /subscriptions/{sub}/providers/Microsoft.Authorization/roleDefinitions/{roleId}
|
|
95
|
+
const roleDefId = assignment.roleDefinitionId?.split("/").pop();
|
|
96
|
+
if (roleDefId) {
|
|
97
|
+
assignedRoleDefinitionIds.add(roleDefId);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Short-circuit: stop if all required roles have been found
|
|
101
|
+
if (requiredRoles.every((requiredRole) => assignedRoleDefinitionIds.has(requiredRole))) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Check if all required roles are present
|
|
106
|
+
const hasAllRoles = requiredRoles.every((requiredRole) => assignedRoleDefinitionIds.has(requiredRole));
|
|
107
|
+
return hasAllRoles;
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
// Handle authorization errors (403 Forbidden) - user lacks permissions
|
|
111
|
+
if (error &&
|
|
112
|
+
typeof error === "object" &&
|
|
113
|
+
"statusCode" in error &&
|
|
114
|
+
error.statusCode === 403) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
// Re-throw other errors
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async initialize(cloudAccount, { name, prefix }, tags = {}) {
|
|
122
|
+
assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure");
|
|
123
|
+
assert.ok(isAzureLocation(cloudAccount.defaultLocation), "The default location of the cloud account is not a valid Azure location");
|
|
124
|
+
const logger = getLogger(["gen", "env"]);
|
|
125
|
+
const resourceManagementClient = new ResourceManagementClient(this.#credential, cloudAccount.id);
|
|
126
|
+
const short = {
|
|
127
|
+
env: environmentShort[name],
|
|
128
|
+
location: locationShort[cloudAccount.defaultLocation],
|
|
129
|
+
};
|
|
130
|
+
const resourceGroupName = `${prefix}-${short.env}-${short.location}-bootstrap-rg-01`;
|
|
131
|
+
const parameters = {
|
|
132
|
+
location: cloudAccount.defaultLocation,
|
|
133
|
+
tags: {
|
|
134
|
+
Environment: name,
|
|
135
|
+
...tags,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
await resourceManagementClient.resourceGroups.createOrUpdate(resourceGroupName, parameters);
|
|
139
|
+
logger.debug("Created resource group {resourceGroupName} in subscription {subscriptionId}", { resourceGroupName, subscriptionId: cloudAccount.id });
|
|
140
|
+
const msiClient = new ManagedServiceIdentityClient(this.#credential, cloudAccount.id);
|
|
141
|
+
const identityName = `${prefix}-${short.env}-${short.location}-bootstrap-id-01`;
|
|
142
|
+
await msiClient.userAssignedIdentities.createOrUpdate(resourceGroupName, identityName, parameters);
|
|
143
|
+
logger.debug("Created identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
|
|
144
|
+
}
|
|
145
|
+
async isInitialized(cloudAccountId, { name, prefix }) {
|
|
146
|
+
const allLocations = Object.values(locationShort).join("|");
|
|
147
|
+
const shortEnv = environmentShort[name];
|
|
148
|
+
const resourceName = `${prefix}-${shortEnv}-(${allLocations})-bootstrap-id-(0[1-9]|[1-9]\\d)`;
|
|
149
|
+
const query = `resources
|
|
150
|
+
| where type == 'microsoft.managedidentity/userassignedidentities'
|
|
151
|
+
| where name matches regex @'${resourceName}'
|
|
152
|
+
`;
|
|
153
|
+
const result = await this.#resourceGraphClient.resources({
|
|
154
|
+
query,
|
|
155
|
+
subscriptions: [cloudAccountId],
|
|
156
|
+
});
|
|
157
|
+
const initialized = result.totalRecords > 0;
|
|
158
|
+
const logger = getLogger(["gen", "env"]);
|
|
159
|
+
logger.debug("subscription {subscriptionId} initialized: {initialized}", {
|
|
160
|
+
initialized,
|
|
161
|
+
subscriptionId: cloudAccountId,
|
|
162
|
+
});
|
|
163
|
+
return initialized;
|
|
164
|
+
}
|
|
165
|
+
async provisionTerraformBackend(cloudAccount, { name, prefix }, tags = {}) {
|
|
166
|
+
assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure");
|
|
167
|
+
assert.ok(isAzureLocation(cloudAccount.defaultLocation), "The default location of the cloud account is not a valid Azure location");
|
|
168
|
+
const logger = getLogger(["gen", "env"]);
|
|
169
|
+
const resourceManagementClient = new ResourceManagementClient(this.#credential, cloudAccount.id);
|
|
170
|
+
const short = {
|
|
171
|
+
env: environmentShort[name],
|
|
172
|
+
location: locationShort[cloudAccount.defaultLocation],
|
|
173
|
+
};
|
|
174
|
+
const parameters = {
|
|
175
|
+
location: cloudAccount.defaultLocation,
|
|
176
|
+
tags: {
|
|
177
|
+
Environment: name,
|
|
178
|
+
...tags,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
const resourceGroupName = `${prefix}-${short.env}-${short.location}-tfstate-rg-01`;
|
|
182
|
+
await resourceManagementClient.resourceGroups.createOrUpdate(resourceGroupName, parameters);
|
|
183
|
+
logger.debug("Created resource group {resourceGroupName} in subscription {subscriptionId}", { resourceGroupName, subscriptionId: cloudAccount.id });
|
|
184
|
+
const storageManagementClient = new StorageManagementClient(this.#credential, cloudAccount.id);
|
|
185
|
+
const storageAccount = await storageManagementClient.storageAccounts.beginCreateAndWait(resourceGroupName, `${prefix}${short.env}${short.location}tfstatest01`, {
|
|
186
|
+
kind: "StorageV2",
|
|
187
|
+
sku: {
|
|
188
|
+
name: "Standard_LRS",
|
|
189
|
+
tier: "Standard",
|
|
190
|
+
},
|
|
191
|
+
...parameters,
|
|
192
|
+
});
|
|
193
|
+
assert.ok(storageAccount.primaryEndpoints?.blob, "Storage account blob endpoint is undefined");
|
|
194
|
+
assert.ok(storageAccount.name, "Storage account name is undefined");
|
|
195
|
+
logger.debug("Created storage account {storageAccountName} in subscription {subscriptionId}", {
|
|
196
|
+
storageAccountName: storageAccount.name,
|
|
197
|
+
subscriptionId: cloudAccount.id,
|
|
198
|
+
});
|
|
199
|
+
const blobServiceClient = new BlobServiceClient(storageAccount.primaryEndpoints?.blob, this.#credential);
|
|
200
|
+
const containerClient = blobServiceClient.getContainerClient("terraform-state");
|
|
201
|
+
try {
|
|
202
|
+
await containerClient.create();
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
// Cleanup resource group if blob container creation fails
|
|
206
|
+
// resource group deletion also deletes all contained resources
|
|
207
|
+
await resourceManagementClient.resourceGroups.beginDeleteAndWait(resourceGroupName);
|
|
208
|
+
throw new Error(`Error during the creation of the blob container`, {
|
|
209
|
+
cause: e,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return terraformBackendSchema.parse({
|
|
213
|
+
resourceGroupName,
|
|
214
|
+
storageAccountName: storageAccount.name,
|
|
215
|
+
subscriptionId: cloudAccount.id,
|
|
216
|
+
type: "azurerm",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
async #getCurrentPrincipalIds() {
|
|
220
|
+
// Create Graph client with custom auth provider that fetches fresh tokens
|
|
221
|
+
const graphClient = Client.init({
|
|
222
|
+
authProvider: async (done) => {
|
|
223
|
+
try {
|
|
224
|
+
const tokenResponse = await this.#credential.getToken("https://graph.microsoft.com/.default");
|
|
225
|
+
if (!tokenResponse) {
|
|
226
|
+
done(new Error("Failed to acquire token for Microsoft Graph"), null);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
done(null, tokenResponse.token);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
done(error, null);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
// Get current user's info
|
|
237
|
+
const meResponse = await graphClient.api("/me").get();
|
|
238
|
+
const me = graphUserResponseSchema.parse(meResponse);
|
|
239
|
+
const userObjectId = me.id;
|
|
240
|
+
// Get all group memberships (transitive - includes nested groups)
|
|
241
|
+
const groupIds = [];
|
|
242
|
+
let nextLink = "/me/transitiveMemberOf?$select=id";
|
|
243
|
+
while (nextLink) {
|
|
244
|
+
const rawResponse = await graphClient.api(nextLink).get();
|
|
245
|
+
const response = graphGroupMembershipResponseSchema.parse(rawResponse);
|
|
246
|
+
for (const item of response.value) {
|
|
247
|
+
groupIds.push(item.id);
|
|
248
|
+
}
|
|
249
|
+
nextLink = response["@odata.nextLink"];
|
|
250
|
+
}
|
|
251
|
+
// All principal IDs to check (user + all groups)
|
|
252
|
+
const allPrincipalIds = new Set([userObjectId, ...groupIds]);
|
|
253
|
+
return allPrincipalIds;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CloudRegion } from "../../domain/cloud-account.js";
|
|
2
|
+
export declare const locations: readonly ["italynorth", "westeurope"];
|
|
3
|
+
export type AzureLocation = (typeof locations)[number];
|
|
4
|
+
export declare function isAzureLocation(location: string): location is AzureLocation;
|
|
5
|
+
export declare const defaultLocation: "italynorth";
|
|
6
|
+
export declare const locationShort: Record<AzureLocation, string>;
|
|
7
|
+
export declare const cloudRegions: CloudRegion[];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// These are the only regions we currently support for DX
|
|
2
|
+
// Order matters! they go from the preferred to the less preferred ones
|
|
3
|
+
export const locations = ["italynorth", "westeurope"];
|
|
4
|
+
export function isAzureLocation(location) {
|
|
5
|
+
return locations.includes(location);
|
|
6
|
+
}
|
|
7
|
+
// The default location is the first one
|
|
8
|
+
export const defaultLocation = locations[0];
|
|
9
|
+
export const locationShort = {
|
|
10
|
+
italynorth: "itn",
|
|
11
|
+
westeurope: "weu",
|
|
12
|
+
};
|
|
13
|
+
const displayName = {
|
|
14
|
+
italynorth: "Italy North",
|
|
15
|
+
westeurope: "West Europe",
|
|
16
|
+
};
|
|
17
|
+
export const cloudRegions = locations.map((l) => ({
|
|
18
|
+
displayName: displayName[l],
|
|
19
|
+
name: l,
|
|
20
|
+
short: locationShort[l],
|
|
21
|
+
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ok } from "neverthrow";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { LocalCodemodRegistry } from "../registry.js";
|
|
4
|
+
describe("LocalCodemodRegistry", () => {
|
|
5
|
+
const makeCodemod = (id, description = id) => ({
|
|
6
|
+
apply: vi.fn().mockResolvedValue(undefined),
|
|
7
|
+
description,
|
|
8
|
+
id,
|
|
9
|
+
});
|
|
10
|
+
it("returns empty list when no codemods are registered", async () => {
|
|
11
|
+
const registry = new LocalCodemodRegistry();
|
|
12
|
+
const result = await registry.getAll();
|
|
13
|
+
expect(result).toStrictEqual(ok([]));
|
|
14
|
+
});
|
|
15
|
+
it("adds codemods and lists them via getAll", async () => {
|
|
16
|
+
const registry = new LocalCodemodRegistry();
|
|
17
|
+
const a = makeCodemod("a", "A");
|
|
18
|
+
const b = makeCodemod("b", "B");
|
|
19
|
+
registry.add(a);
|
|
20
|
+
registry.add(b);
|
|
21
|
+
const result = await registry.getAll();
|
|
22
|
+
expect(result.isOk()).toBe(true);
|
|
23
|
+
if (result.isOk()) {
|
|
24
|
+
expect(result.value).toHaveLength(2);
|
|
25
|
+
expect(result.value).toEqual(expect.arrayContaining([
|
|
26
|
+
expect.objectContaining({ id: "a" }),
|
|
27
|
+
expect.objectContaining({ id: "b" }),
|
|
28
|
+
]));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
it("retrieves a codemod by id and returns undefined when missing", async () => {
|
|
32
|
+
const registry = new LocalCodemodRegistry();
|
|
33
|
+
const a = makeCodemod("a", "A");
|
|
34
|
+
registry.add(a);
|
|
35
|
+
const found = await registry.getById("a");
|
|
36
|
+
expect(found.isOk()).toBe(true);
|
|
37
|
+
if (found.isOk()) {
|
|
38
|
+
expect(found.value).toBe(a);
|
|
39
|
+
}
|
|
40
|
+
const missing = await registry.getById("nope");
|
|
41
|
+
expect(missing.isOk()).toBe(true);
|
|
42
|
+
if (missing.isOk()) {
|
|
43
|
+
expect(missing.value).toBeUndefined();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
it("overwrites an existing codemod when adding with the same id", async () => {
|
|
47
|
+
const registry = new LocalCodemodRegistry();
|
|
48
|
+
const first = makeCodemod("a", "first");
|
|
49
|
+
const second = makeCodemod("a", "second");
|
|
50
|
+
registry.add(first);
|
|
51
|
+
registry.add(second);
|
|
52
|
+
const byId = await registry.getById("a");
|
|
53
|
+
expect(byId.isOk()).toBe(true);
|
|
54
|
+
if (byId.isOk()) {
|
|
55
|
+
expect(byId.value).toBe(second);
|
|
56
|
+
expect(byId.value).not.toBe(first);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|