StorageAccountService using authenticated requests

This commit is contained in:
Shiran Pasternak 2022-11-01 17:30:24 -04:00 коммит произвёл Shiran Pasternak
Родитель 55e0746f8e
Коммит 66dd10d76e
20 изменённых файлов: 433 добавлений и 20 удалений

Просмотреть файл

@ -12,6 +12,7 @@ import { DependencyName } from "@batch/ui-common/lib/environment";
import { DefaultFormLayoutProvider, DefaultParameterTypeResolver } from "@batch/ui-react/lib/components/form";
import { ConsoleLogger } from "@batch/ui-common/lib/logging";
import { BrowserDependencyName } from "@batch/ui-react";
import { StorageAccountServiceImpl } from "@batch/ui-service";
import { registerIcons } from "app/config";
import {
AuthorizationHttpService,
@ -75,6 +76,8 @@ export class AppComponent implements OnInit, OnDestroy {
[DependencyName.Localizer]: () => new StandardLocalizer(),
[DependencyName.HttpClient]:
() => new BatchExplorerHttpClient(authService),
[BrowserDependencyName.StorageAccountService]:
() => new StorageAccountServiceImpl(),
[BrowserDependencyName.ParameterTypeResolver]:
() => new DefaultParameterTypeResolver(),
[BrowserDependencyName.FormLayoutProvider]:

Просмотреть файл

@ -1,7 +1,6 @@
export {
getEnvironment,
initEnvironment,
initMockEnvironment,
destroyEnvironment,
} from "./environment/environment-util";
export {

38
packages/react/package-lock.json сгенерированный
Просмотреть файл

@ -20,6 +20,7 @@
"jest": "^27.1.0",
"jest-axe": "^5.0.1",
"jest-junit": "^12.2.0",
"jest-mock-extended": "^2.0.6",
"ts-jest": "^27.0.5"
},
"peerDependencies": {
@ -3102,6 +3103,19 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/jest-mock-extended": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-2.0.6.tgz",
"integrity": "sha512-KoDdjqwIp2phaOWB0hr4O+9HF7hIJx7O+Reefi3iGrNhUpzVkod9UozYTSanvbNvjFYIEH6noA2tIjc8IDpadw==",
"dev": true,
"dependencies": {
"ts-essentials": "^7.0.3"
},
"peerDependencies": {
"jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0",
"typescript": "^3.0.0 || ^4.0.0"
}
},
"node_modules/jest-pnp-resolver": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz",
@ -4454,6 +4468,15 @@
"node": ">=8"
}
},
"node_modules/ts-essentials": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz",
"integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==",
"dev": true,
"peerDependencies": {
"typescript": ">=3.7.0"
}
},
"node_modules/ts-jest": {
"version": "27.1.4",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.1.4.tgz",
@ -7222,6 +7245,15 @@
"@types/node": "*"
}
},
"jest-mock-extended": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-2.0.6.tgz",
"integrity": "sha512-KoDdjqwIp2phaOWB0hr4O+9HF7hIJx7O+Reefi3iGrNhUpzVkod9UozYTSanvbNvjFYIEH6noA2tIjc8IDpadw==",
"dev": true,
"requires": {
"ts-essentials": "^7.0.3"
}
},
"jest-pnp-resolver": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz",
@ -8257,6 +8289,12 @@
"punycode": "^2.1.1"
}
},
"ts-essentials": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz",
"integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==",
"dev": true
},
"ts-jest": {
"version": "27.1.4",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.1.4.tgz",

Просмотреть файл

@ -68,7 +68,6 @@
"react-dom": ">=16.13.1 <17.0.0",
"tslib": "~2.3.1"
},
"dependencies": {},
"devDependencies": {
"@batch/common-config": "^1.0.0",
"@batch/ui-common": "^1.0.0",
@ -84,6 +83,7 @@
"jest": "^27.1.0",
"jest-axe": "^5.0.1",
"jest-junit": "^12.2.0",
"jest-mock-extended": "^2.0.6",
"ts-jest": "^27.0.5"
},
"files": [

Просмотреть файл

@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from "react";
import { initMockBrowserEnvironment } from "../../../environment";
import { runAxe } from "../../../test-util/a11y";
@ -23,6 +24,20 @@ describe("Dropdown form control", () => {
const ddEl = screen.getByRole("combobox");
expect(ddEl).toBeDefined();
const user = userEvent.setup();
user.click(ddEl);
await waitFor(() =>
expect(ddEl.getAttribute("aria-expanded")).toBe("true")
);
const options = screen.getAllByRole("option");
expect(options.length).toEqual(3);
expect(options.map((option) => option.textContent)).toEqual([
"ace",
"king",
"queen",
]);
expect(
await runAxe(container, {
rules: {

Просмотреть файл

@ -0,0 +1,118 @@
import { Parameter } from "@batch/ui-common";
import { createForm, Form } from "@batch/ui-common/lib/form";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserEvent } from "@testing-library/user-event/dist/types/setup";
import * as React from "react";
import { initMockBrowserEnvironment } from "../../../environment";
import { runAxe } from "../../../test-util/a11y";
import {
StorageAccountDropdown,
} from "../parameter-type";
/* KLUDGE: the parameter has to be called "subscriptionId" until we can specify
* dependencies in parameter types
*/
type FakeFormValues = {
subscriptionId?: string;
storageAccountId?: string;
};
describe("Parameter type tests", () => {
let user: UserEvent;
let form: Form<FakeFormValues>;
let subParam: Parameter<FakeFormValues, "subscriptionId">;
beforeEach(() => {
initMockBrowserEnvironment();
user = userEvent.setup();
form = createForm<FakeFormValues>({ values: {} });
subParam = form.param("subscriptionId", "string");
});
describe("StorageAccountDropdown", () => {
let storageParam: Parameter<FakeFormValues, "storageAccountId">;
beforeEach(() => {
storageParam = form.param("storageAccountId", "string");
});
test("simple dropdown", async () => {
render(<StorageAccountDropdown param={storageParam} />);
const element = screen.getByRole("combobox");
await user.click(element);
await waitFor(() => expectElementEnabled(element));
expect(screen.queryAllByRole("option")).toEqual([]);
});
test("dropdown with subscription", async () => {
render(
<>
<SubscriptionDropdown param={subParam} />
<StorageAccountDropdown param={storageParam} />
</>
);
const subDropdown = screen.getByRole("combobox", {
name: /subscriptionId/,
});
const storageDropdown = screen.getByRole("combobox", {
name: /storageAccountId/,
});
await user.click(subDropdown);
await waitFor(() => {
expectElementEnabled(subDropdown);
expectElementEnabled(storageDropdown);
});
selectOption(0);
await user.click(storageDropdown);
let storageAccounts = await screen.findAllByRole("option");
// Data served by FakeStorageAccountService
expect(storageAccounts.length).toEqual(3);
expect(storageAccounts[0].textContent).toEqual("Storage A");
await user.click(subDropdown); // Reopen sub dropdown
selectOption(2);
await user.click(storageDropdown);
storageAccounts = await screen.findAllByRole("option");
expect(storageAccounts.length).toEqual(4);
expect(storageAccounts[0].textContent).toEqual("Storage F");
});
test("bad subscription shows error", async () => {
render(
<>
<SubscriptionDropdown param={subParam} />
<StorageAccountDropdown param={storageParam} />
</>
);
const subDropdown = screen.getByRole("combobox", {
name: /subscriptionId/,
});
const storageDropdown = screen.getByRole("combobox", {
name: /storageAccountId/,
});
await user.click(subDropdown);
await waitFor(() => {
expectElementEnabled(subDropdown);
expectElementEnabled(storageDropdown);
});
selectOption(4); // Bad subscription
expect(screen.getByText("Bad Subscription")).toBeDefined();
await user.click(storageDropdown);
expect(await screen.queryAllByRole("option")).toEqual([]);
expect(
screen.getByText("Error: No storage accounts in subscription.")
).toBeDefined();
});
});
});
const expectElementEnabled = (element: HTMLElement) =>
expect(element.className).not.toContain("is-disabled");
const selectOption = (index: number) =>
fireEvent.click(screen.getAllByRole("option")[index]);

Просмотреть файл

@ -1,10 +1,16 @@
import { ParameterType as CommonParameterType } from "@batch/ui-common";
import {
Parameter,
ParameterType as CommonParameterType,
} from "@batch/ui-common";
import { inject } from "@batch/ui-common/lib/environment";
import { FormValues, ValidationStatus } from "@batch/ui-common/lib/form";
import { StorageAccount, StorageAccountService } from "@batch/ui-service";
import * as React from "react";
import { Parameter } from "@batch/ui-common";
import { TextField } from "./text-field";
import { Dropdown } from "./dropdown";
import { useEffect, useState } from "react";
import { BrowserDependencyName } from "../..";
import { useAsyncEffect, useUniqueId } from "../../hooks";
import { FormValues } from "@batch/ui-common/lib/form";
import { Dropdown } from "./dropdown";
import { TextField } from "./text-field";
enum ExtendedParameterType {
BatchAccountName = "BatchAccountName",
@ -99,7 +105,7 @@ export class DefaultParameterTypeResolver implements ParameterTypeResolver {
);
case ParameterType.StorageAccountId:
return (
<StringParamTextField
<StorageAccountDropdown
id={id}
key={param.name}
param={param}
@ -107,7 +113,7 @@ export class DefaultParameterTypeResolver implements ParameterTypeResolver {
);
case ParameterType.SubscriptionId:
return (
<SubscriptionIdParamDropdown
<SubscriptionDropdown
id={id}
key={param.name}
param={param}
@ -157,6 +163,68 @@ export function StringParamTextField<
);
}
export function StorageAccountDropdown<
V extends FormValues,
K extends Extract<keyof V, string>
>(props: ParamControlProps<V, K>): JSX.Element {
const { param } = props;
const value = param.value == null ? undefined : String(param.value);
const [loading, setLoading] = useState<boolean>(true);
const [storageAccounts, setStorageAccounts] = useState<StorageAccount[]>(
[]
);
const id = useUniqueId("form-control", props.id);
const service: StorageAccountService = inject(
BrowserDependencyName.StorageAccountService
);
const form = param.parentForm;
const [subscriptionId, setSubscriptionId] = useState<string>(
form.values.subscriptionId as string
);
const [validationStatus, setValidationStatus] =
useState<ValidationStatus | null>();
useAsyncEffect(async () => {
let accounts: StorageAccount[] = [];
try {
if (subscriptionId) {
accounts = await service.list(subscriptionId);
}
setValidationStatus(null);
} catch (error) {
setValidationStatus(new ValidationStatus("error", error + ""));
} finally {
setStorageAccounts(accounts);
setLoading(false);
}
}, [subscriptionId]);
useEffect(() => {
const handler = form.onChange((values: FormValues) =>
setSubscriptionId(values.subscriptionId as string)
);
return () => form.removeOnChange(handler);
});
const options = storageAccounts.map((sub) => {
return { value: sub.id, label: sub.name };
});
return (
<Dropdown
id={id}
label={param.label}
disabled={loading || param.disabled}
options={options}
placeholder={param.placeholder}
value={value}
validationStatus={validationStatus ?? param.validationStatus}
onChange={(value: string) => (param.value = value as V[K])}
/>
);
}
export function SubscriptionIdParamDropdown<
V extends FormValues,
K extends Extract<keyof V, string>
@ -183,7 +251,7 @@ export function SubscriptionIdParamDropdown<
resolve();
}, 1000);
});
});
}, []);
const options = subscriptions.map((sub) => {
return { value: sub.id, label: sub.displayName };
@ -197,9 +265,7 @@ export function SubscriptionIdParamDropdown<
options={options}
placeholder={param.placeholder}
value={value}
onChange={(newValue: string) => {
param.value = newValue as V[K];
}}
onChange={(newValue: string) => (param.value = newValue as V[K])}
/>
);
}

Просмотреть файл

@ -8,6 +8,7 @@ import {
getEnvironment,
} from "@batch/ui-common/lib/environment";
import { FormValues } from "@batch/ui-common/lib/form";
import { StorageAccountService } from "@batch/ui-service";
import {
FormLayout,
FormLayoutProvider,
@ -23,6 +24,7 @@ import { MockBrowserEnvironment } from "./mock-browser-environment";
export enum BrowserDependencyName {
ParameterTypeResolver = "parameterTypeResolver",
FormLayoutProvider = "formLayoutProvider",
StorageAccountService = "storageAccount",
}
export interface BrowserEnvironment
@ -42,6 +44,7 @@ export interface BrowserEnvironmentConfig extends EnvironmentConfig {
export interface BrowserDependencyFactories extends DependencyFactories {
[BrowserDependencyName.ParameterTypeResolver]: () => ParameterTypeResolver;
[BrowserDependencyName.FormLayoutProvider]: () => FormLayoutProvider;
[BrowserDependencyName.StorageAccountService]: () => StorageAccountService;
}
/**

Просмотреть файл

@ -5,6 +5,7 @@ import {
mockDependencyFactories,
mockEnvironmentConfig,
} from "@batch/ui-common/lib/environment";
import { FakeStorageAccountService } from "@batch/ui-service";
import { initializeIcons } from "@fluentui/react/lib/Icons";
import { BrowserEnvironmentConfig } from ".";
import {
@ -17,12 +18,9 @@ import { MockBrowserEnvironment } from "./mock-browser-environment";
let _fluentIconsInitialized = false;
export const mockBrowserDepFactories: Partial<BrowserDependencyFactories> = {
parameterTypeResolver: () => {
return new DefaultParameterTypeResolver();
},
formLayoutProvider: () => {
return new DefaultFormLayoutProvider();
},
parameterTypeResolver: () => new DefaultParameterTypeResolver(),
formLayoutProvider: () => new DefaultFormLayoutProvider(),
storageAccount: () => new FakeStorageAccountService(),
};
/**

Просмотреть файл

@ -0,0 +1,4 @@
export interface ArmResourceListResponse<T> {
value: T[];
nextLink?: string;
}

Просмотреть файл

@ -0,0 +1,15 @@
export const Endpoints = {
arm: `https://management.azure.com`,
};
export const ApiVersion = {
arm: `2022-05-01`,
batch: {
arm: `2022-06-01`,
data: `2022-06-01`,
},
storage: {
arm: `2022-05-01`,
data: `2022-05-01`,
},
};

Просмотреть файл

@ -1,3 +1,5 @@
export * from "./http-service";
export * from "./view";
export * from "./certificate";
export * from "./storage";
export * from "./subscription";

Просмотреть файл

@ -0,0 +1,46 @@
import {
initMockEnvironment,
getMockEnvironment,
} from "@batch/ui-common/lib/environment";
import { MockHttpClient, MockHttpResponse } from "@batch/ui-common/lib/http";
import { StorageAccountServiceImpl } from "..";
import { ApiVersion, Endpoints } from "../../constants";
describe("StorageAccountService", () => {
let httpClient: MockHttpClient;
beforeEach(() => {
initMockEnvironment();
httpClient = getMockEnvironment().getHttpClient();
});
afterEach(() => {
const assertions = httpClient.remainingAssertions();
if (assertions.length > 0) {
throw new Error(
`HTTP client has untested assertions (${assertions.join(", ")})`
);
}
});
test("list()", async () => {
const service = new StorageAccountServiceImpl();
httpClient.addExpected(
new MockHttpResponse(
`${Endpoints.arm}/subscriptions//fake/sub1/providers/Microsoft.Storage/storageAccounts?api-version=${ApiVersion.storage.arm}`,
200,
JSON.stringify({
value: [
{ id: "1", name: "One" },
{ id: "2", name: "Two" },
{ id: "3", name: "Three" },
],
})
)
);
const accounts = await service.list("/fake/sub1");
expect(accounts.length).toEqual(3);
});
});

Просмотреть файл

@ -0,0 +1,47 @@
import { StorageAccount } from "./storage-account-models";
import { StorageAccountService } from "./storage-account-service";
const subscriptionAccounts: { [key: string]: StorageAccount[] } = {
"/fake/sub1": [
{ id: "/fake/storageA", name: "Storage A" },
{ id: "/fake/storageB", name: "Storage B" },
{ id: "/fake/storageC", name: "Storage C" },
],
"/fake/sub2": [
{ id: "/fake/storageD", name: "Storage D" },
{ id: "/fake/storageE", name: "Storage E" },
],
"/fake/sub3": [
{ id: "/fake/storageF", name: "Storage F" },
{ id: "/fake/storageG", name: "Storage G" },
{ id: "/fake/storageH", name: "Storage H" },
{ id: "/fake/storageI", name: "Storage I" },
],
};
export class FakeStorageAccountService implements StorageAccountService {
public async list(subscriptionId: string): Promise<StorageAccount[]> {
if (subscriptionId in subscriptionAccounts) {
return subscriptionAccounts[subscriptionId];
} else if (subscriptionId === "/fake/badsub") {
// Simulates a network error.
throw new Error("No storage accounts in subscription.");
} else {
return [];
}
}
public async get(): Promise<StorageAccount | null> {
return null;
}
public async create(): Promise<void> {
return;
}
public async remove(): Promise<void> {
return;
}
public async update(): Promise<void> {
return;
}
}

Просмотреть файл

@ -0,0 +1,3 @@
export * from "./storage-account-service";
export * from "./storage-account-models";
export * from "./fake-storage-account-service";

Просмотреть файл

@ -0,0 +1,4 @@
export interface StorageAccount {
id: string;
name: string;
}

Просмотреть файл

@ -0,0 +1,41 @@
import { ApiVersion, Endpoints } from "../constants";
import { AbstractHttpService } from "../http-service";
import { ArmResourceListResponse } from "../arm";
import { StorageAccount } from "./storage-account-models";
export interface StorageAccountService {
list(subscriptionId: string): Promise<StorageAccount[]>;
get(id: string): Promise<StorageAccount | null>;
create(account: StorageAccount): Promise<void>;
remove(account: StorageAccount): Promise<void>;
update(account: StorageAccount): Promise<void>;
}
export class StorageAccountServiceImpl
extends AbstractHttpService
implements StorageAccountService
{
public async list(subscriptionId: string): Promise<StorageAccount[]> {
const response = await this.httpClient.get(
`${Endpoints.arm}/subscriptions/${subscriptionId}/providers/Microsoft.Storage/storageAccounts?api-version=${ApiVersion.storage.arm}`,
{}
);
const json =
(await response.json()) as ArmResourceListResponse<StorageAccount>;
return json.value;
}
public async get(): Promise<StorageAccount | null> {
return null;
}
public async create(): Promise<void> {
return;
}
public async remove(): Promise<void> {
return;
}
public async update(): Promise<void> {
return;
}
}

Просмотреть файл

@ -0,0 +1 @@
export * from "./subscription-models";

Просмотреть файл

@ -0,0 +1,7 @@
export interface Subscription {
id: string;
subscriptionId: string;
tenantId: string;
displayName: string;
state: string;
}

Просмотреть файл

@ -14,6 +14,7 @@ import {
DefaultParameterTypeResolver,
} from "@batch/ui-react/lib/components/form";
import { StandardLocalizer } from "@batch/ui-common/lib/localization";
import { FakeStorageAccountService } from "@batch/ui-service";
// Defined by webpack
declare const ENV: {
@ -30,6 +31,8 @@ export function init(rootEl: HTMLElement): void {
[DependencyName.Logger]: () => new ConsoleLogger(),
[DependencyName.Localizer]: () => new StandardLocalizer(),
[DependencyName.HttpClient]: () => new MockHttpClient(),
[BrowserDependencyName.StorageAccountService]: () =>
new FakeStorageAccountService(),
[BrowserDependencyName.ParameterTypeResolver]: () =>
new DefaultParameterTypeResolver(),
[BrowserDependencyName.FormLayoutProvider]: () =>