mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
747 lines
24 KiB
TypeScript
747 lines
24 KiB
TypeScript
/**
|
|
* MSW handlers for the organization onboarding flow.
|
|
*
|
|
* These serve BOTH the deprecated `/organizational-units` routes and the
|
|
* canonical `/organization-nodes` routes over the same fixture data. AWS
|
|
* bodies carry canonical fields plus the deprecated aliases, mirroring an API
|
|
* that still accepts both. Set the fixture flag `serveDeprecatedRoutes` to
|
|
* `false` to drop the alias routes — used to assert no UI code still calls
|
|
* them.
|
|
*
|
|
* Wire the handlers per test via `worker.use(...handlersForOrganizations(fx))`.
|
|
* The module also doubles as the no-backend dev harness.
|
|
*/
|
|
|
|
import { http, HttpResponse } from "msw";
|
|
|
|
import { NODE_KIND } from "./organizations.fixtures";
|
|
import type {
|
|
FixtureNode,
|
|
FixtureOrganization,
|
|
FixtureProvider,
|
|
OrgFixture,
|
|
} from "./organizations.fixtures";
|
|
|
|
const API = process.env.UI_API_BASE_URL;
|
|
const TS = "2026-07-01T10:00:00Z";
|
|
|
|
type JsonApiError = { errors: Array<{ detail: string; status: string }> };
|
|
|
|
const errorBody = (detail: string, status: number): JsonApiError => ({
|
|
errors: [{ detail, status: String(status) }],
|
|
});
|
|
|
|
const providerRefs = (ids: string[]) =>
|
|
ids.map((id) => ({ type: "providers", id }));
|
|
|
|
interface OrgResourceOptions {
|
|
/** Emit the deprecated `organizational_units` alias alongside canonical. */
|
|
includeAliases: boolean;
|
|
/**
|
|
* Node ids to surface under the deprecated `organizational_units` alias.
|
|
* Only organizational-unit-kind nodes belong here — the deprecated route
|
|
* never surfaced GCP folders.
|
|
*/
|
|
unitNodeIds: string[];
|
|
}
|
|
|
|
const organizationResource = (
|
|
org: FixtureOrganization,
|
|
{ includeAliases, unitNodeIds }: OrgResourceOptions,
|
|
) => ({
|
|
id: org.id,
|
|
type: "organizations",
|
|
attributes: {
|
|
name: org.name,
|
|
org_type: org.orgType,
|
|
external_id: org.externalId,
|
|
metadata: {},
|
|
root_external_id: org.rootExternalId,
|
|
inserted_at: TS,
|
|
updated_at: TS,
|
|
},
|
|
relationships: {
|
|
providers: { data: providerRefs(org.providerIds) },
|
|
organization_nodes: {
|
|
data: org.nodeIds.map((id) => ({ type: "organization-nodes", id })),
|
|
},
|
|
// Deprecated alias, gated on `includeAwsAliases`.
|
|
...(includeAliases && {
|
|
organizational_units: {
|
|
data: unitNodeIds.map((id) => ({ type: "organizational-units", id })),
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Canonical `organization-nodes` resource (carries `kind`).
|
|
*
|
|
* The parent is a relationship, not an attribute, and DJA always emits the key —
|
|
* `data: null` for a top-level node, since neither the AWS root nor a GCP
|
|
* organization is itself a node. Fixtures still express structure as
|
|
* `parentExternalId`, resolved to a node ref here.
|
|
*/
|
|
const organizationNodeResource = (node: FixtureNode, all: FixtureNode[]) => {
|
|
const parent = all.find(
|
|
(candidate) =>
|
|
candidate.organizationId === node.organizationId &&
|
|
candidate.externalId === node.parentExternalId,
|
|
);
|
|
|
|
return {
|
|
id: node.id,
|
|
type: "organization-nodes",
|
|
attributes: {
|
|
name: node.name,
|
|
kind: node.kind,
|
|
external_id: node.externalId,
|
|
metadata: {},
|
|
inserted_at: TS,
|
|
updated_at: TS,
|
|
},
|
|
relationships: {
|
|
organization: {
|
|
data: { type: "organizations", id: node.organizationId },
|
|
},
|
|
parent: {
|
|
data: parent ? { type: "organization-nodes", id: parent.id } : null,
|
|
},
|
|
providers: { data: providerRefs(node.providerIds) },
|
|
},
|
|
};
|
|
};
|
|
|
|
/** Deprecated AWS-only `organizational-units` resource (no `kind`). */
|
|
const organizationalUnitResource = (node: FixtureNode) => ({
|
|
id: node.id,
|
|
type: "organizational-units",
|
|
attributes: {
|
|
name: node.name,
|
|
external_id: node.externalId,
|
|
parent_external_id: node.parentExternalId,
|
|
metadata: {},
|
|
inserted_at: TS,
|
|
updated_at: TS,
|
|
},
|
|
relationships: {
|
|
organization: {
|
|
data: { type: "organizations", id: node.organizationId },
|
|
},
|
|
providers: { data: providerRefs(node.providerIds) },
|
|
},
|
|
});
|
|
|
|
/** Single-page collection meta, enough for the paginating list actions to stop. */
|
|
const collectionMeta = (count: number) => ({
|
|
pagination: { page: 1, pages: 1, count },
|
|
version: "v1",
|
|
});
|
|
|
|
/**
|
|
* Full `providers` list resource, as the providers-page loader consumes it.
|
|
* Deliberately carries no `scan_*` attributes: the API omits them unless a
|
|
* schedule is configured, and their absence is what makes the loader fall back
|
|
* to `/schedules`.
|
|
*/
|
|
const providerResource = (provider: FixtureProvider) => ({
|
|
id: provider.id,
|
|
type: "providers",
|
|
attributes: {
|
|
provider: provider.provider,
|
|
is_dynamic: false,
|
|
uid: provider.uid,
|
|
alias: provider.alias,
|
|
status: "completed",
|
|
resources: 0,
|
|
connection: {
|
|
connected: provider.connected ?? false,
|
|
last_checked_at: TS,
|
|
},
|
|
scanner_args: {
|
|
only_logs: false,
|
|
excluded_checks: [],
|
|
aws_retries_max_attempts: 3,
|
|
},
|
|
inserted_at: TS,
|
|
updated_at: TS,
|
|
created_by: { object: "user", id: "user-1" },
|
|
},
|
|
relationships: {
|
|
secret: { data: { type: "secrets", id: `secret-${provider.id}` } },
|
|
provider_groups: { meta: { count: 0 }, data: [] },
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Serves a collection the way the paginated API does: honours
|
|
* `page[number]`/`page[size]` and reports `meta.pagination.pages`, so a caller
|
|
* that stops after the first page visibly loses the rest.
|
|
*/
|
|
const paginatedCollection = <T>(items: T[], request: Request) => {
|
|
const params = new URL(request.url).searchParams;
|
|
const size = Number(params.get("page[size]")) || items.length || 1;
|
|
const page = Number(params.get("page[number]")) || 1;
|
|
const start = (page - 1) * size;
|
|
|
|
return {
|
|
data: items.slice(start, start + size),
|
|
meta: {
|
|
version: "v1",
|
|
pagination: {
|
|
page,
|
|
pages: Math.max(1, Math.ceil(items.length / size)),
|
|
count: items.length,
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Map a created-provider id back to its uid (AWS account id / GCP project id).
|
|
* `apply.candidateProviderIds` is a fixture-side mapping, not a wire field.
|
|
*/
|
|
const uidForProviderId = (
|
|
fx: OrgFixture,
|
|
providerId: string,
|
|
): string | null => {
|
|
const mapping = fx.apply.candidateProviderIds.find(
|
|
(m) => m.providerId === providerId,
|
|
);
|
|
if (mapping) return mapping.candidateId;
|
|
const provider = fx.providers.find((p) => p.id === providerId);
|
|
return provider?.uid ?? null;
|
|
};
|
|
|
|
/**
|
|
* The provider behind an id, seeded or apply-created. A created provider exists
|
|
* only as an id plus its candidate mapping, so the rest is synthesized as
|
|
* `/providers/:id` does; only `id` and `uid` are ever read back.
|
|
*/
|
|
const providerForId = (fx: OrgFixture, id: string): FixtureProvider => {
|
|
const seeded = fx.providers.find((provider) => provider.id === id);
|
|
if (seeded) return seeded;
|
|
|
|
const uid = uidForProviderId(fx, id) ?? id;
|
|
return { id, provider: "aws", uid, alias: uid, connected: true };
|
|
};
|
|
|
|
const applyResultResponse = (fx: OrgFixture) => ({
|
|
data: {
|
|
id: "apply-result-1",
|
|
type: "organization-discovery-apply-results",
|
|
attributes: {
|
|
providers_created_count: fx.apply.providersCreatedCount,
|
|
providers_linked_count: fx.apply.providersLinkedCount,
|
|
providers_applied_count:
|
|
fx.apply.providersCreatedCount + fx.apply.providersLinkedCount,
|
|
organization_nodes_created_count: fx.apply.nodesCreatedCount,
|
|
// Deprecated counter alias, gated on `includeAwsAliases`.
|
|
...(fx.includeAwsAliases && {
|
|
organizational_units_created_count: fx.apply.nodesCreatedCount,
|
|
}),
|
|
},
|
|
relationships: {
|
|
providers: {
|
|
data: providerRefs(fx.apply.createdProviderIds),
|
|
meta: { count: fx.apply.createdProviderIds.length },
|
|
},
|
|
organization_nodes: {
|
|
data: [],
|
|
meta: { count: fx.apply.nodesCreatedCount },
|
|
},
|
|
// Deprecated relationship alias, gated on `includeAwsAliases`.
|
|
...(fx.includeAwsAliases && {
|
|
organizational_units: {
|
|
data: [],
|
|
meta: { count: fx.apply.nodesCreatedCount },
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
// No `included`: the apply view serves provider ids only and rejects `include`,
|
|
// so the created providers' uids are read from `/providers` afterwards.
|
|
});
|
|
|
|
const taskResource = (id: string, state: string, result: unknown) => ({
|
|
data: { id, type: "tasks", attributes: { state, result } },
|
|
});
|
|
|
|
const CONNECTION_TASK_PREFIX = "conn-task-";
|
|
const DELETION_TASK_PREFIX = "del-task-";
|
|
|
|
interface HandlerOptions {
|
|
/**
|
|
* Which hierarchy read 500s. The `…Safe` actions turn that into their
|
|
* degraded flag and the page derives `hierarchyStatus` — never an injected
|
|
* prop.
|
|
*/
|
|
hierarchyFailure?: HierarchyReadFailure;
|
|
}
|
|
|
|
export const HIERARCHY_READ_FAILURE = {
|
|
NONE: "none",
|
|
/** Both `/organizations` and `/organization-nodes` fail. */
|
|
ALL: "all",
|
|
/** Only `/organization-nodes` fails. */
|
|
NODES: "nodes",
|
|
} as const;
|
|
|
|
export type HierarchyReadFailure =
|
|
(typeof HIERARCHY_READ_FAILURE)[keyof typeof HIERARCHY_READ_FAILURE];
|
|
|
|
export const handlersForOrganizations = (
|
|
fx: OrgFixture,
|
|
{ hierarchyFailure = HIERARCHY_READ_FAILURE.NONE }: HandlerOptions = {},
|
|
) => {
|
|
const organizationReadFails = hierarchyFailure === HIERARCHY_READ_FAILURE.ALL;
|
|
const nodeReadFails = hierarchyFailure !== HIERARCHY_READ_FAILURE.NONE;
|
|
// Mutable working copy for resources created during the test lifecycle.
|
|
const organizations = [...fx.organizations];
|
|
const createdSecretIds = new Set(
|
|
organizations.map((o) => o.secretId).filter((id): id is string => !!id),
|
|
);
|
|
let orgSeq = 0;
|
|
let providerSeq = 0;
|
|
let secretSeq = 0;
|
|
/** Reads per connection task, so `executingPolls` can hold one task running. */
|
|
const connectionTaskReads = new Map<string, number>();
|
|
|
|
const unitNodeIds = (org: FixtureOrganization): string[] =>
|
|
org.nodeIds.filter((id) =>
|
|
fx.nodes.some(
|
|
(n) => n.id === id && n.kind === NODE_KIND.ORGANIZATIONAL_UNIT,
|
|
),
|
|
);
|
|
const orgResource = (org: FixtureOrganization) =>
|
|
organizationResource(org, {
|
|
includeAliases: fx.includeAwsAliases,
|
|
unitNodeIds: unitNodeIds(org),
|
|
});
|
|
|
|
const handlers = [
|
|
// --- organizations CRUD + filters ------------------------------------
|
|
http.get(`${API}/organizations`, ({ request }) => {
|
|
if (organizationReadFails) {
|
|
return HttpResponse.json(errorBody("Hierarchy unavailable", 500), {
|
|
status: 500,
|
|
});
|
|
}
|
|
const url = new URL(request.url);
|
|
const externalId = url.searchParams.get("filter[external_id]");
|
|
const orgType = url.searchParams.get("filter[org_type]");
|
|
const matches = organizations
|
|
.filter((o) => (externalId ? o.externalId === externalId : true))
|
|
.filter((o) => (orgType ? o.orgType === orgType : true))
|
|
.map(orgResource);
|
|
return HttpResponse.json(paginatedCollection(matches, request));
|
|
}),
|
|
|
|
http.post(`${API}/organizations`, async ({ request }) => {
|
|
const body = (await request.json()) as {
|
|
data?: { attributes?: Record<string, unknown> };
|
|
};
|
|
const attrs = body?.data?.attributes ?? {};
|
|
orgSeq += 1;
|
|
const created: FixtureOrganization = {
|
|
id: `org-created-${orgSeq}`,
|
|
orgType: String(attrs.org_type ?? "aws"),
|
|
name: String(attrs.name ?? ""),
|
|
externalId: String(attrs.external_id ?? ""),
|
|
// Writable on POST for Azure (the target Management Group); AWS and GCP
|
|
// leave it to discovery and send nothing.
|
|
rootExternalId: attrs.root_external_id
|
|
? String(attrs.root_external_id)
|
|
: null,
|
|
providerIds: [],
|
|
nodeIds: [],
|
|
secretId: null,
|
|
};
|
|
organizations.push(created);
|
|
return HttpResponse.json({ data: orgResource(created) }, { status: 201 });
|
|
}),
|
|
|
|
http.patch<{ id: string }>(
|
|
`${API}/organizations/:id`,
|
|
async ({ params, request }) => {
|
|
const body = (await request.json()) as {
|
|
data?: { attributes?: { name?: string } };
|
|
};
|
|
const org = organizations.find((o) => o.id === params.id);
|
|
if (!org) {
|
|
return HttpResponse.json(errorBody("Not found", 404), {
|
|
status: 404,
|
|
});
|
|
}
|
|
org.name = body?.data?.attributes?.name ?? org.name;
|
|
return HttpResponse.json({ data: orgResource(org) });
|
|
},
|
|
),
|
|
|
|
http.delete<{ id: string }>(`${API}/organizations/:id`, ({ params }) =>
|
|
HttpResponse.json(
|
|
taskResource(`${DELETION_TASK_PREFIX}${params.id}`, "executing", null),
|
|
{ status: 202 },
|
|
),
|
|
),
|
|
|
|
// --- organization-secrets --------------------------------------------
|
|
http.get(`${API}/organization-secrets`, ({ request }) => {
|
|
const url = new URL(request.url);
|
|
const orgId = url.searchParams.get("filter[organization_id]");
|
|
const org = organizations.find((o) => o.id === orgId);
|
|
const data = org?.secretId
|
|
? [
|
|
{
|
|
id: org.secretId,
|
|
type: "organization-secrets",
|
|
attributes: { secret_type: "role" },
|
|
},
|
|
]
|
|
: [];
|
|
return HttpResponse.json({ data });
|
|
}),
|
|
|
|
http.post(`${API}/organization-secrets`, async ({ request }) => {
|
|
const body = (await request.json()) as {
|
|
data?: {
|
|
attributes?: { secret_type?: string };
|
|
relationships?: {
|
|
organization?: { data?: { id?: string } };
|
|
};
|
|
};
|
|
};
|
|
const orgId = body?.data?.relationships?.organization?.data?.id;
|
|
const org = organizations.find((o) => o.id === orgId);
|
|
if (fx.duplicateSecret || org?.secretId) {
|
|
return HttpResponse.json(
|
|
errorBody("A secret for this organization already exists.", 409),
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
secretSeq += 1;
|
|
const secretId = `secret-created-${secretSeq}`;
|
|
createdSecretIds.add(secretId);
|
|
if (org) org.secretId = secretId;
|
|
return HttpResponse.json(
|
|
{
|
|
data: {
|
|
id: secretId,
|
|
type: "organization-secrets",
|
|
attributes: {
|
|
secret_type: body?.data?.attributes?.secret_type ?? "role",
|
|
},
|
|
},
|
|
},
|
|
{ status: 201 },
|
|
);
|
|
}),
|
|
|
|
http.patch<{ id: string }>(
|
|
`${API}/organization-secrets/:id`,
|
|
({ params }) =>
|
|
HttpResponse.json({
|
|
data: { id: params.id, type: "organization-secrets" },
|
|
}),
|
|
),
|
|
|
|
// --- canonical organization-nodes ------------------------------------
|
|
http.get(`${API}/organization-nodes`, ({ request }) =>
|
|
nodeReadFails
|
|
? HttpResponse.json(errorBody("Hierarchy unavailable", 500), {
|
|
status: 500,
|
|
})
|
|
: HttpResponse.json(
|
|
paginatedCollection(
|
|
fx.nodes.map((node) => organizationNodeResource(node, fx.nodes)),
|
|
request,
|
|
),
|
|
),
|
|
),
|
|
|
|
http.delete<{ id: string }>(`${API}/organization-nodes/:id`, ({ params }) =>
|
|
HttpResponse.json(
|
|
taskResource(`${DELETION_TASK_PREFIX}${params.id}`, "executing", null),
|
|
{ status: 202 },
|
|
),
|
|
),
|
|
|
|
// --- discovery -------------------------------------------------------
|
|
http.post<{ orgId: string }>(`${API}/organizations/:orgId/discover`, () => {
|
|
if (!fx.discovery) {
|
|
return HttpResponse.json(errorBody("Discovery unavailable", 409), {
|
|
status: 409,
|
|
});
|
|
}
|
|
return HttpResponse.json(
|
|
{
|
|
data: {
|
|
id: fx.discovery.id,
|
|
type: "organization-discoveries",
|
|
attributes: {
|
|
status: "pending",
|
|
result: {},
|
|
error: null,
|
|
inserted_at: TS,
|
|
updated_at: TS,
|
|
},
|
|
},
|
|
},
|
|
{ status: 202 },
|
|
);
|
|
}),
|
|
|
|
http.get<{ orgId: string; discoveryId: string }>(
|
|
`${API}/organizations/:orgId/discoveries/:discoveryId`,
|
|
({ params }) => {
|
|
if (!fx.discovery || fx.discovery.id !== params.discoveryId) {
|
|
return HttpResponse.json(errorBody("Discovery not found", 404), {
|
|
status: 404,
|
|
});
|
|
}
|
|
return HttpResponse.json({
|
|
data: {
|
|
id: fx.discovery.id,
|
|
type: "organization-discoveries",
|
|
attributes: {
|
|
status: fx.discovery.status,
|
|
result:
|
|
fx.discovery.status === "succeeded" ? fx.discovery.result : {},
|
|
error: fx.discovery.error,
|
|
// Machine code and human message are separate fields on the wire.
|
|
error_message: fx.discovery.errorMessage ?? null,
|
|
inserted_at: TS,
|
|
updated_at: TS,
|
|
},
|
|
},
|
|
});
|
|
},
|
|
),
|
|
|
|
http.post<{ orgId: string; discoveryId: string }>(
|
|
`${API}/organizations/:orgId/discoveries/:discoveryId/apply`,
|
|
() => {
|
|
if (fx.apply.error) {
|
|
return HttpResponse.json(
|
|
errorBody(fx.apply.error.detail, fx.apply.error.status),
|
|
{ status: fx.apply.error.status },
|
|
);
|
|
}
|
|
return HttpResponse.json(applyResultResponse(fx));
|
|
},
|
|
),
|
|
|
|
// --- providers-page loader (providers list, groups, schedules) --------
|
|
http.get(`${API}/providers`, ({ request }) => {
|
|
// `filter[id__in]` is how the apply flow resolves the uids of the providers
|
|
// it just created. Those are not seeded in `fx.providers`, so they are
|
|
// synthesized here as `/providers/:id` does.
|
|
const idFilter = new URL(request.url).searchParams.get("filter[id__in]");
|
|
const data = idFilter
|
|
? idFilter
|
|
.split(",")
|
|
.filter(Boolean)
|
|
.map((id) => providerResource(providerForId(fx, id)))
|
|
: fx.providers.map(providerResource);
|
|
|
|
return HttpResponse.json({
|
|
data,
|
|
included: [],
|
|
meta: collectionMeta(data.length),
|
|
});
|
|
}),
|
|
|
|
http.get(`${API}/provider-groups`, () =>
|
|
HttpResponse.json({ data: [], meta: collectionMeta(0) }),
|
|
),
|
|
|
|
// Schedules are a best-effort fallback for providers without `scan_*`
|
|
// attributes; none of the fixtures seed one.
|
|
http.get(`${API}/schedules`, () =>
|
|
HttpResponse.json({ data: [], meta: collectionMeta(0) }),
|
|
),
|
|
|
|
// Cloud-only scan configurations, fetched alongside the view data.
|
|
http.get(`${API}/scan-configurations`, () =>
|
|
HttpResponse.json({ data: [], meta: collectionMeta(0) }),
|
|
),
|
|
|
|
// --- single-account connect (AWS one-step form) -----------------------
|
|
http.post(`${API}/providers`, async ({ request }) => {
|
|
const body = (await request.json()) as {
|
|
data: { attributes: { provider: string; uid: string; alias?: string } };
|
|
};
|
|
providerSeq += 1;
|
|
return HttpResponse.json(
|
|
{
|
|
data: {
|
|
id: `provider-created-${providerSeq}`,
|
|
type: "providers",
|
|
attributes: {
|
|
...body.data.attributes,
|
|
connection: { connected: false, last_checked_at: null },
|
|
},
|
|
},
|
|
},
|
|
{ status: 201 },
|
|
);
|
|
}),
|
|
|
|
http.post(`${API}/providers/secrets`, () => {
|
|
secretSeq += 1;
|
|
return HttpResponse.json(
|
|
{
|
|
data: { id: `secret-created-${secretSeq}`, type: "provider-secrets" },
|
|
},
|
|
{ status: 201 },
|
|
);
|
|
}),
|
|
|
|
// --- providers (uid resolution) + connection testing -----------------
|
|
http.get<{ id: string }>(`${API}/providers/:id`, ({ params }) => {
|
|
const provider = fx.providers.find((p) => p.id === params.id);
|
|
const uid = provider?.uid ?? uidForProviderId(fx, params.id) ?? params.id;
|
|
return HttpResponse.json({
|
|
data: {
|
|
id: params.id,
|
|
type: "providers",
|
|
attributes: {
|
|
provider: provider?.provider ?? "aws",
|
|
uid,
|
|
alias: provider?.alias ?? uid,
|
|
connection: {
|
|
connected: provider?.connected ?? true,
|
|
last_checked_at: TS,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.post<{ id: string }>(`${API}/providers/:id/connection`, ({ params }) =>
|
|
HttpResponse.json(
|
|
{
|
|
data: {
|
|
id: `${CONNECTION_TASK_PREFIX}${params.id}`,
|
|
type: "tasks",
|
|
attributes: { state: "executing" },
|
|
},
|
|
},
|
|
{ status: 202 },
|
|
),
|
|
),
|
|
|
|
// --- task polling (deletion + connection) ----------------------------
|
|
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
|
|
const { taskId } = params;
|
|
if (taskId.startsWith(CONNECTION_TASK_PREFIX)) {
|
|
const providerId = taskId.slice(CONNECTION_TASK_PREFIX.length);
|
|
const uid = uidForProviderId(fx, providerId);
|
|
const outcome = uid ? fx.connectionByUid[uid] : undefined;
|
|
const reads = (connectionTaskReads.get(taskId) ?? 0) + 1;
|
|
connectionTaskReads.set(taskId, reads);
|
|
|
|
if (outcome?.executingPolls && reads <= outcome.executingPolls) {
|
|
return HttpResponse.json(taskResource(taskId, "executing", {}));
|
|
}
|
|
|
|
const connected = outcome?.connected ?? true;
|
|
return HttpResponse.json(
|
|
taskResource(taskId, "completed", {
|
|
connected,
|
|
error: connected ? undefined : outcome?.error,
|
|
}),
|
|
);
|
|
}
|
|
if (taskId.startsWith(DELETION_TASK_PREFIX)) {
|
|
return HttpResponse.json(
|
|
taskResource(taskId, fx.deletionTaskState, {}),
|
|
);
|
|
}
|
|
return HttpResponse.json(taskResource(taskId, "completed", {}));
|
|
}),
|
|
|
|
// --- launch (scans + schedules) --------------------------------------
|
|
http.post(`${API}/scans/bulk`, () =>
|
|
HttpResponse.json(
|
|
{
|
|
data: fx.apply.createdProviderIds.map((providerId, index) => ({
|
|
id: `scan-${index + 1}`,
|
|
type: "scans",
|
|
relationships: {
|
|
provider: {
|
|
data: { id: providerId, type: "providers" },
|
|
},
|
|
task: {
|
|
data: { id: `scan-task-${index + 1}`, type: "tasks" },
|
|
},
|
|
},
|
|
})),
|
|
},
|
|
{ status: 202 },
|
|
),
|
|
),
|
|
http.post(`${API}/schedules`, () =>
|
|
HttpResponse.json(
|
|
{ data: { id: "schedule-1", type: "schedules", attributes: {} } },
|
|
{ status: 202 },
|
|
),
|
|
),
|
|
http.post(`${API}/schedules/bulk`, async ({ request }) => {
|
|
const body = (await request.json()) as {
|
|
data?: { attributes?: { provider_ids?: string[] } };
|
|
};
|
|
const requestedIds = body?.data?.attributes?.provider_ids ?? [];
|
|
const { failed, shape } = fx.scheduleBulk;
|
|
const failedIds = new Set(failed.map((failure) => failure.id));
|
|
// `updated` defaults to the requested ids minus the failures: each provider
|
|
// commits in its own transaction, so the API's list already excludes them.
|
|
const updated =
|
|
fx.scheduleBulk.updated ??
|
|
requestedIds.filter((id) => !failedIds.has(id));
|
|
|
|
// The real endpoint returns a plain dict the JSON:API renderer wraps in
|
|
// `data`, with no `attributes` level; the other shapes exercise the client's
|
|
// tolerance of a serializer-rendered body and of one carrying no lists.
|
|
if (shape === "attributes") {
|
|
return HttpResponse.json({
|
|
data: { type: "schedules-bulk", attributes: { updated, failed } },
|
|
});
|
|
}
|
|
if (shape === "bare") {
|
|
return HttpResponse.json({ data: {} });
|
|
}
|
|
return HttpResponse.json({ data: { updated, failed } });
|
|
}),
|
|
];
|
|
|
|
// Deprecated AWS-only routes, served alongside the canonical ones unless
|
|
// `serveDeprecatedRoutes` is off.
|
|
const deprecatedHandlers = [
|
|
http.get(`${API}/organizational-units`, () =>
|
|
HttpResponse.json({
|
|
data: fx.nodes
|
|
.filter((n) => n.kind === NODE_KIND.ORGANIZATIONAL_UNIT)
|
|
.map(organizationalUnitResource),
|
|
meta: { version: "v1" },
|
|
}),
|
|
),
|
|
http.delete<{ id: string }>(
|
|
`${API}/organizational-units/:id`,
|
|
({ params }) =>
|
|
HttpResponse.json(
|
|
taskResource(
|
|
`${DELETION_TASK_PREFIX}${params.id}`,
|
|
"executing",
|
|
null,
|
|
),
|
|
{ status: 202 },
|
|
),
|
|
),
|
|
];
|
|
|
|
return fx.serveDeprecatedRoutes
|
|
? [...handlers, ...deprecatedHandlers]
|
|
: handlers;
|
|
};
|