Files
prowler/ui/actions/registry/registry.test.ts
T

1265 lines
36 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const {
authMock,
evaluateAccessMock,
evaluateProviderAccessMock,
fetchMock,
pollTaskUntilSettledMock,
} = vi.hoisted(() => ({
authMock: vi.fn(),
evaluateAccessMock: vi.fn(),
evaluateProviderAccessMock: vi.fn(),
fetchMock: vi.fn(),
pollTaskUntilSettledMock: vi.fn(),
}));
vi.mock("@/auth.config", () => ({ auth: authMock }));
vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.test/api/v1" }));
vi.mock("@/actions/task/poll", () => ({
pollTaskUntilSettled: pollTaskUntilSettledMock,
}));
vi.mock("@/lib/registry/access.server", () => ({
evaluateRegistryAccess: evaluateAccessMock,
evaluateRegistryProviderAccess: evaluateProviderAccessMock,
}));
import {
addRegistryArtifact,
confirmRegistryArtifactAddition,
disconnectRegistryCredential,
getRegistryBootstrap,
getInstalledRegistryProviderOptions,
refreshRegistryCollections,
removeRegistryArtifact,
refreshRegistryCredential,
submitRegistryCredential,
} from "./registry";
const activeCredential = {
configured: true,
isValid: true,
scopes: ["catalog:read"],
validationPending: false,
};
const noCredential = {
configured: false,
isValid: false,
scopes: [],
validationPending: false,
};
const pendingCredential = {
configured: true,
isValid: false,
scopes: [],
validationPending: true,
};
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/vnd.api+json" },
});
const credentialResponse = (credential = activeCredential) =>
jsonResponse({
data: {
attributes: {
configured: credential.configured,
is_valid: credential.isValid,
scopes: credential.scopes,
validation_pending: credential.validationPending,
},
},
});
const tenantArtifactsResponse = () =>
jsonResponse({
data: [
{
type: "registry-artifacts",
id: "prowler-aws",
attributes: {
version_spec: "latest",
inserted_at: "2026-03-20T12:00:00Z",
},
},
],
});
const catalogResponse = () =>
jsonResponse({
data: [
{
type: "registry-artifacts",
id: "prowler-aws",
attributes: { name: "Prowler AWS", providers: ["aws"] },
},
],
meta: { pagination: { page: 1, pages: 1, count: 1 } },
});
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
authMock.mockResolvedValue({ accessToken: "access-token" });
evaluateAccessMock.mockResolvedValue({ status: "eligible" });
evaluateProviderAccessMock.mockResolvedValue({ status: "eligible" });
fetchMock.mockReset();
pollTaskUntilSettledMock.mockReset();
});
function mockRequestDeadlines() {
// Native AbortSignal.timeout uses real timers; drive it with the test clock.
vi.spyOn(AbortSignal, "timeout").mockImplementation((milliseconds) => {
const controller = new AbortController();
setTimeout(() => controller.abort(), milliseconds);
return controller.signal;
});
}
describe("installed Registry provider discovery", () => {
function mockDiscovery({
emptyMetadata = false,
failedEndpoint,
failureStatus = 500,
failureBody = {},
}: {
emptyMetadata?: boolean;
failedEndpoint?: string;
failureStatus?: number;
failureBody?: unknown;
} = {}) {
fetchMock.mockImplementation((url: string) => {
const endpoint = new URL(url).pathname.split("/").pop();
if (endpoint === failedEndpoint)
return jsonResponse(failureBody, failureStatus);
if (endpoint === "available-artifacts")
return jsonResponse({
data: [
{
type: "registry-artifacts",
id: "acme-package",
attributes: {
name: "Acme package",
providers: ["acme"],
has_provider: true,
},
},
],
meta: { pagination: { page: 1, pages: 1, count: 1 } },
});
if (endpoint === "artifacts")
return jsonResponse({
data: [
{
type: "registry-artifacts",
id: "acme-package",
attributes: { version_spec: "latest" },
},
],
});
if (endpoint === "providers")
return jsonResponse({
data: emptyMetadata
? []
: [
{
id: "acme",
attributes: {
name: "Acme Cloud",
logo_url: "https://media.registry.test/acme.svg",
},
},
],
});
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
}
it("joins catalog declarations, installed membership and provider metadata", async () => {
mockDiscovery();
expect(await getInstalledRegistryProviderOptions()).toEqual({
status: "ready",
options: [
{
type: "acme",
label: "Acme Cloud",
logoUrl: "https://media.registry.test/acme.svg",
},
],
});
});
it("allows installed-provider discovery without Registry management access", async () => {
// Given
mockDiscovery({ emptyMetadata: true });
evaluateAccessMock.mockResolvedValue({ status: "ineligible" });
// When / Then
expect(await getInstalledRegistryProviderOptions()).toEqual({
status: "ready",
options: [{ type: "acme", label: "Acme package" }],
});
expect(evaluateProviderAccessMock).toHaveBeenCalledWith("access-token");
expect(evaluateAccessMock).not.toHaveBeenCalled();
});
it.each([
["ineligible", "access_denied"],
["unknown", "unknown"],
] as const)(
"maps installed-provider access %s to %s",
async (status, expectedStatus) => {
// Given
evaluateProviderAccessMock.mockResolvedValue({ status });
// When
const result = await getInstalledRegistryProviderOptions();
// Then
expect(result).toEqual({ status: expectedStatus });
expect(fetchMock).not.toHaveBeenCalled();
},
);
it("uses the declared provider and artifact name when metadata is empty", async () => {
mockDiscovery({ emptyMetadata: true });
expect(await getInstalledRegistryProviderOptions()).toEqual({
status: "ready",
options: [{ type: "acme", label: "Acme package" }],
});
});
it.each(["available-artifacts", "artifacts", "providers"])(
"returns an error when the %s read fails",
async (failedEndpoint) => {
mockDiscovery({ failedEndpoint });
expect(await getInstalledRegistryProviderOptions()).toEqual({
status: "error",
});
},
);
it.each(["available-artifacts", "artifacts", "providers"])(
"preserves access denial from the %s read",
async (failedEndpoint) => {
mockDiscovery({ failedEndpoint, failureStatus: 403 });
expect(await getInstalledRegistryProviderOptions()).toEqual({
status: "access_denied",
});
},
);
it.each(["available-artifacts", "artifacts", "providers"])(
"hides Registry when the backend has it disabled and %s answers 404",
async (failedEndpoint) => {
mockDiscovery({ failedEndpoint, failureStatus: 404 });
expect(await getInstalledRegistryProviderOptions()).toEqual({
status: "access_denied",
});
},
);
it.each(["available-artifacts", "providers"])(
"keeps Registry visible when an enabled backend answers 404 for a missing %s page",
async (failedEndpoint) => {
mockDiscovery({
failedEndpoint,
failureStatus: 404,
failureBody: {
errors: [{ status: "404", code: "registry_page_not_found" }],
},
});
expect(await getInstalledRegistryProviderOptions()).toEqual({
status: "error",
});
},
);
it("keeps Registry visible when the missing-page code is not the first error", async () => {
mockDiscovery({
failedEndpoint: "available-artifacts",
failureStatus: 404,
failureBody: {
errors: [{ code: "not_found" }, { code: "registry_page_not_found" }],
},
});
expect(await getInstalledRegistryProviderOptions()).toEqual({
status: "error",
});
});
});
describe("Registry guarded reads", () => {
it("recovers when a Registry read stalls", async () => {
// Given: the upstream responds only when its request is aborted.
vi.useFakeTimers();
try {
mockRequestDeadlines();
fetchMock.mockImplementation(
(_url, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(init.signal?.reason),
);
}),
);
const settled = vi.fn();
// When
void refreshRegistryCredential().then(settled);
await vi.advanceTimersByTimeAsync(30_000);
// Then
expect(settled).toHaveBeenCalledWith({ status: "error" });
} finally {
vi.useRealTimers();
}
});
it.each([
[
"credential submission",
() => submitRegistryCredential("registry-test-key"),
],
["credential disconnection", disconnectRegistryCredential],
[
"artifact addition",
() => addRegistryArtifact({ normalizedName: "external-package" }),
],
["artifact removal", () => removeRegistryArtifact("external-package")],
])("recovers when %s stalls", async (_name, action) => {
// Given: prerequisite reads succeed, but the mutation never responds.
vi.useFakeTimers();
try {
mockRequestDeadlines();
fetchMock.mockImplementation((url: string, init?: RequestInit) => {
if (init?.method === "POST" || init?.method === "DELETE") {
return new Promise((_resolve, reject) => {
init.signal?.addEventListener("abort", () =>
reject(init.signal?.reason),
);
});
}
if (url.includes("available-artifacts")) {
return Promise.resolve(
jsonResponse({
data: [
{
type: "registry-artifacts",
id: "external-package",
attributes: {
has_provider: true,
is_builtin: false,
is_installable: true,
},
},
],
meta: { pagination: { page: 1, pages: 1, count: 1 } },
}),
);
}
return Promise.resolve(credentialResponse(noCredential));
});
const settled = vi.fn();
// When
void action().then(settled);
await vi.advanceTimersByTimeAsync(30_000);
// Then
expect(settled).toHaveBeenCalledWith({ status: "error" });
} finally {
vi.useRealTimers();
}
});
it("denies Registry management actions before any Registry endpoint call", async () => {
// Given
evaluateAccessMock.mockResolvedValue({ status: "ineligible" });
const actions = [
getRegistryBootstrap,
refreshRegistryCredential,
refreshRegistryCollections,
() => submitRegistryCredential("registry-test-key"),
disconnectRegistryCredential,
];
// When
const results = await Promise.all(actions.map((action) => action()));
// Then
expect(results).toEqual(actions.map(() => ({ status: "access_denied" })));
expect(evaluateAccessMock).toHaveBeenCalledTimes(actions.length);
expect(fetchMock).not.toHaveBeenCalled();
});
it.each(["unknown", "ineligible"])(
"does not fetch Registry collections when access is %s",
async (status) => {
// Given
evaluateAccessMock.mockResolvedValue({ status });
// When
const results = await Promise.all([
getRegistryBootstrap(),
refreshRegistryCredential(),
refreshRegistryCollections(),
]);
// Then
expect(results).toEqual([
{ status: "access_denied" },
{ status: "access_denied" },
{ status: status === "unknown" ? "error" : "access_denied" },
]);
expect(fetchMock).not.toHaveBeenCalled();
},
);
it("bootstraps in credential, tenant-artifact, then complete-catalog order", async () => {
// Given
fetchMock
.mockResolvedValueOnce(credentialResponse())
.mockResolvedValueOnce(tenantArtifactsResponse())
.mockResolvedValueOnce(catalogResponse());
// When
const result = await getRegistryBootstrap();
// Then
expect(result).toEqual({
status: "ready",
state: {
status: "ready",
credential: activeCredential,
catalog: {
status: "complete",
artifacts: [
expect.objectContaining({ normalizedName: "prowler-aws" }),
],
},
tenantArtifacts: [
{
normalizedName: "prowler-aws",
versionSpec: "latest",
extendsProviderSlugs: [],
insertedAt: "2026-03-20T12:00:00Z",
},
],
},
});
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"https://api.test/api/v1/registry/credential",
"https://api.test/api/v1/registry/artifacts",
"https://api.test/api/v1/registry/available-artifacts?page%5Bnumber%5D=1&page%5Bsize%5D=100",
]);
fetchMock.mock.calls.forEach(([, options]) => {
expect(options).toMatchObject({
cache: "no-store",
headers: {
Accept: "application/vnd.api+json",
Authorization: "Bearer access-token",
},
});
});
});
it.each([
[noCredential, "onboarding"],
[pendingCredential, "validation_pending"],
] as const)(
"blocks catalog bootstrap as %s credential is authoritative",
async (credential, expectedStatus) => {
// Given
fetchMock
.mockResolvedValueOnce(credentialResponse(credential))
.mockResolvedValueOnce(tenantArtifactsResponse());
// When
const result = await getRegistryBootstrap();
// Then
expect(result).toEqual({
status: "ready",
state: {
status: expectedStatus,
credential,
tenantArtifacts: [
{
normalizedName: "prowler-aws",
versionSpec: "latest",
extendsProviderSlugs: [],
insertedAt: "2026-03-20T12:00:00Z",
},
],
},
});
expect(fetchMock).toHaveBeenCalledTimes(2);
},
);
it("returns only a non-secret status read after a fresh guard", async () => {
// Given
fetchMock.mockResolvedValueOnce(credentialResponse());
// When
const result = await refreshRegistryCredential();
// Then
expect(result).toEqual({ status: "status", credential: activeCredential });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.test/api/v1/registry/credential",
expect.objectContaining({ cache: "no-store" }),
);
});
it("returns fresh complete collections", async () => {
// Given
fetchMock
.mockResolvedValueOnce(catalogResponse())
.mockResolvedValueOnce(tenantArtifactsResponse());
// When
const result = await refreshRegistryCollections();
// Then
expect(result).toEqual({
status: "complete",
catalog: {
status: "complete",
artifacts: [expect.objectContaining({ normalizedName: "prowler-aws" })],
},
tenantArtifacts: [
{
normalizedName: "prowler-aws",
versionSpec: "latest",
extendsProviderSlugs: [],
insertedAt: "2026-03-20T12:00:00Z",
},
],
});
expect(evaluateAccessMock).toHaveBeenCalledWith("access-token");
});
it("maps a discovery 409 to onboarding after an authoritative no-credential read", async () => {
// Given
fetchMock
.mockResolvedValueOnce(jsonResponse({ errors: [] }, 409))
.mockResolvedValueOnce(credentialResponse(noCredential));
// When
const result = await refreshRegistryCollections();
// Then
expect(result).toEqual({ status: "onboarding" });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("maps documented read recovery without exposing retained or partial catalog data", async () => {
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({ errors: [{ code: "registry_key_rejected" }] }, 502),
);
// When
const reconnect = await refreshRegistryCollections();
// Then
expect(reconnect).toEqual({ status: "reconnect" });
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({ errors: [{ code: "registry_unavailable" }] }, 503),
);
// When
const unavailable = await refreshRegistryCollections();
// Then
expect(unavailable).toEqual({ status: "unavailable" });
expect(unavailable).not.toHaveProperty("catalog");
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({ errors: [{ code: "other_failure" }] }, 502),
);
// When
const generic = await refreshRegistryCollections();
// Then
expect(generic).toEqual({ status: "error" });
});
it("keeps a transient access check failure retryable when refreshing collections", async () => {
// Given: the API cannot answer the permission check during a transient outage.
evaluateAccessMock.mockResolvedValueOnce({ status: "unknown" });
// When / Then: preserve the current page instead of treating the outage as revocation.
expect(await refreshRegistryCollections()).toEqual({ status: "error" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("maps Registry 401 and 403 to access denial before any recovery classification", async () => {
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({ errors: [{ code: "registry_key_rejected" }] }, 401),
);
// When
const credential = await refreshRegistryCredential();
// Then
expect(credential).toEqual({ status: "access_denied" });
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({ errors: [{ code: "registry_unavailable" }] }, 403),
);
// When
const collections = await refreshRegistryCollections();
// Then
expect(collections).toEqual({ status: "access_denied" });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("rechecks access between separate actions after permission revocation", async () => {
// Given
evaluateAccessMock
.mockResolvedValueOnce({ status: "eligible" })
.mockResolvedValueOnce({ status: "ineligible" });
fetchMock.mockResolvedValueOnce(credentialResponse());
// When
const first = await refreshRegistryCredential();
const second = await refreshRegistryCollections();
// Then
expect(first).toEqual({ status: "status", credential: activeCredential });
expect(second).toEqual({ status: "access_denied" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(evaluateAccessMock).toHaveBeenCalledTimes(2);
});
it("returns the accepted validation task immediately without server-side polling", async () => {
// Given
const key = " registry-test-key ";
fetchMock
.mockResolvedValueOnce(credentialResponse(noCredential))
.mockResolvedValueOnce(
new Response(
JSON.stringify({ data: { type: "tasks", id: "task-123" } }),
{
status: 202,
headers: { "Content-Location": "/api/v1/tasks/task-123" },
},
),
);
// When
const result = await submitRegistryCredential(key);
// Then
expect(result).toEqual({
status: "submitted",
taskId: "task-123",
priorConfigured: false,
});
expect(pollTaskUntilSettledMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.test/api/v1/registry/credential",
expect.objectContaining({
body: JSON.stringify({
data: {
type: "registry-credentials",
attributes: { api_key: key.trim() },
},
}),
cache: "no-store",
method: "POST",
}),
);
expect(JSON.stringify(result)).not.toContain(key);
});
it("marks an accepted replacement as superseding a configured credential", async () => {
// Given
fetchMock
.mockResolvedValueOnce(credentialResponse(activeCredential))
.mockResolvedValueOnce(
new Response(
JSON.stringify({ data: { type: "tasks", id: "task-456" } }),
{
status: 202,
headers: { "Content-Location": "/api/v1/tasks/task-456" },
},
),
);
// When
const result = await submitRegistryCredential("registry-replacement-key");
// Then
expect(result).toEqual({
status: "submitted",
taskId: "task-456",
priorConfigured: true,
});
expect(pollTaskUntilSettledMock).not.toHaveBeenCalled();
});
it("re-reads credential and preserves authoritative My artifacts after disconnect", async () => {
// Given
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(credentialResponse(noCredential))
.mockResolvedValueOnce(tenantArtifactsResponse());
// When
const result = await disconnectRegistryCredential();
// Then
expect(result).toEqual({
status: "disconnected",
credential: noCredential,
tenantArtifacts: [
{
normalizedName: "prowler-aws",
versionSpec: "latest",
extendsProviderSlugs: [],
insertedAt: "2026-03-20T12:00:00Z",
},
],
});
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"https://api.test/api/v1/registry/credential",
"https://api.test/api/v1/registry/credential",
"https://api.test/api/v1/registry/artifacts",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.test/api/v1/registry/credential",
expect.objectContaining({ cache: "no-store", method: "DELETE" }),
);
});
it("rejects a task-binding mismatch without returning the key or a task", async () => {
// Given
const key = "registry-test-key";
fetchMock
.mockResolvedValueOnce(credentialResponse(noCredential))
.mockResolvedValueOnce(
new Response(
JSON.stringify({ data: { type: "tasks", id: "task-123" } }),
{
status: 202,
headers: { "Content-Location": "/api/v1/tasks/other-task" },
},
),
);
// When
const result = await submitRegistryCredential(key);
// Then
expect(result).toEqual({ status: "error" });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pollTaskUntilSettledMock).not.toHaveBeenCalled();
expect(JSON.stringify(result)).not.toContain(key);
});
it("rejects malformed accepted task data without a task identity", async () => {
// Given
fetchMock
.mockResolvedValueOnce(credentialResponse(noCredential))
.mockResolvedValueOnce(
new Response(
JSON.stringify({ data: { type: "not-a-task", id: "task-123" } }),
{
status: 202,
headers: { "Content-Location": "/api/v1/tasks/task-123" },
},
),
);
// When
const result = await submitRegistryCredential("registry-test-key");
// Then
expect(result).toEqual({ status: "error" });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result).not.toHaveProperty("taskId");
});
it("preserves an active credential after a rejected replacement", async () => {
// Given
const key = "registry-replacement-key";
fetchMock
.mockResolvedValueOnce(credentialResponse(activeCredential))
.mockResolvedValueOnce(jsonResponse({ errors: [] }, 500));
// When
const result = await submitRegistryCredential(key);
// Then
expect(result).toEqual({
status: "replacement_failed",
credential: activeCredential,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(JSON.stringify(result)).not.toContain(key);
});
it("handles action authorization failures safely", async () => {
// Given
fetchMock
.mockResolvedValueOnce(credentialResponse(noCredential))
.mockResolvedValueOnce(jsonResponse({ errors: [] }, 401))
.mockResolvedValueOnce(jsonResponse({ errors: [] }, 403));
// When
const rejected = await submitRegistryCredential("registry-test-key");
const disconnected = await disconnectRegistryCredential();
// Then
expect(rejected).toEqual({ status: "access_denied" });
expect(disconnected).toEqual({ status: "access_denied" });
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
const installCatalogMock = vi.fn();
describe("Registry artifact mutations", () => {
beforeEach(() => {
installCatalogMock.mockImplementation(() =>
jsonResponse({
data: [
{
type: "registry-available-artifacts",
id: "later-guard",
attributes: {
has_provider: true,
is_builtin: false,
is_installable: true,
providers: ["acme"],
},
},
],
meta: { pagination: { page: 1, pages: 1, count: 1 } },
}),
);
vi.stubGlobal("fetch", (url: string, options?: RequestInit) =>
url.includes("/available-artifacts")
? installCatalogMock(url, options)
: fetchMock(url, options),
);
});
it.each([
[
{
has_checks: true,
is_installable: false,
not_installable_reason: "checks_target_is_not_builtin",
},
"Its checks are written for a provider this deployment does not ship.",
],
[
{
has_checks: true,
is_installable: false,
not_installable_reason: "a_code_from_a_newer_api",
},
"This artifact cannot be installed in this deployment.",
],
[
{ has_provider: true, is_builtin: false },
"This artifact cannot be installed in this deployment.",
],
])(
"refuses what the API says cannot be installed before POST: %j",
async (attributes, message) => {
// Given
installCatalogMock.mockImplementation(() =>
jsonResponse({
data: [
{
type: "registry-available-artifacts",
id: "later-guard",
attributes,
},
],
meta: { pagination: { page: 1, pages: 1, count: 1 } },
}),
);
// When
const result = await addRegistryArtifact({
normalizedName: "later-guard",
});
// Then
expect(result).toEqual({ status: "refused", message });
expect(fetchMock).not.toHaveBeenCalled();
},
);
it("submits a checks artifact that defines no provider once the API calls it installable", async () => {
// Given
installCatalogMock.mockImplementation(() =>
jsonResponse({
data: [
{
type: "registry-available-artifacts",
id: "later-guard",
attributes: {
has_provider: false,
has_checks: true,
is_installable: true,
providers: ["aws"],
},
},
],
meta: { pagination: { page: 1, pages: 1, count: 1 } },
}),
);
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ data: { type: "tasks", id: "task-1" } }), {
status: 202,
headers: { "Content-Location": "/api/v1/tasks/task-1" },
}),
);
// When
const result = await addRegistryArtifact({ normalizedName: "later-guard" });
// Then
expect(result).toEqual({ status: "submitted", taskId: "task-1" });
});
it("returns an accepted Add task without reading My artifacts", async () => {
// Given
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ data: { type: "tasks", id: "artifact-task" } }),
{
status: 202,
headers: { "Content-Location": "/api/v1/tasks/artifact-task" },
},
),
);
// When
const result = await addRegistryArtifact({
normalizedName: "later-guard",
versionSpec: " 2.0.0 ",
});
// Then
expect(result).toEqual({ status: "submitted", taskId: "artifact-task" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.test/api/v1/registry/artifacts",
expect.objectContaining({
body: JSON.stringify({
data: {
type: "registry-artifacts",
attributes: {
normalized_name: "later-guard",
version_spec: "2.0.0",
},
},
}),
cache: "no-store",
method: "POST",
}),
);
});
it("defaults Add to latest", async () => {
// Given
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ data: { type: "tasks", id: "artifact-task" } }),
{
status: 202,
headers: { "Content-Location": "/api/v1/tasks/artifact-task" },
},
),
);
// When
const result = await addRegistryArtifact({ normalizedName: "later-guard" });
// Then
expect(result).toEqual({ status: "submitted", taskId: "artifact-task" });
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.test/api/v1/registry/artifacts",
expect.objectContaining({
body: JSON.stringify({
data: {
type: "registry-artifacts",
attributes: {
normalized_name: "later-guard",
version_spec: "latest",
},
},
}),
}),
);
});
it("rejects invalid accepted task bindings without reading My artifacts", async () => {
// Given
const document = JSON.stringify({
data: { type: "tasks", id: "artifact-task" },
});
fetchMock
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: { type: "tasks" } }), {
status: 202,
headers: { "Content-Location": "/api/v1/tasks/artifact-task" },
}),
)
.mockResolvedValueOnce(new Response(document, { status: 202 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({ data: { type: "other", id: "artifact-task" } }),
{
status: 202,
headers: { "Content-Location": "/api/v1/tasks/artifact-task" },
},
),
)
.mockResolvedValueOnce(
new Response(document, {
status: 202,
headers: { "Content-Location": "/api/v1/tasks/other" },
}),
);
// When
const outcomes = await Promise.all(
["missing-id", "missing-location", "wrong-type", "wrong-location"].map(
() => addRegistryArtifact({ normalizedName: "later-guard" }),
),
);
// Then
expect(outcomes).toEqual(Array(4).fill({ status: "error" }));
expect(fetchMock).toHaveBeenCalledTimes(4);
});
it("keeps a missing Registry credential synchronous", async () => {
// Given
fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [] }, 409));
// When
const outcome = await addRegistryArtifact({
normalizedName: "later-guard",
});
// Then
expect(outcome).toEqual({ status: "onboarding" });
expect(fetchMock).toHaveBeenCalledOnce();
});
it.each([
["registry_artifact_not_found", "This artifact is no longer available."],
["version_yanked", "This version is no longer available."],
[
"version_not_verified",
"This version is not verified and cannot be added.",
],
["version_not_processed", "This version is not ready to add yet."],
["version_not_found", "This version is not available."],
["no_installable_version", "No available version can be added."],
])("keeps membership unchanged for %s", async (code, message) => {
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse(
{ errors: [{ code }] },
code === "registry_artifact_not_found" ? 404 : 400,
),
);
// When
const result = await addRegistryArtifact({
normalizedName: "later-guard",
versionSpec: "2.0.0",
});
// Then
expect(result).toEqual({ status: "refused", message });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it.each([
["registry_artifact_in_use", "in_use"],
["registry_artifact_busy", "busy"],
])(
"tells a Remove 409 %s apart as %s without refreshing membership",
async (code, expected) => {
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({ errors: [{ code }] }, 409),
);
// When
const result = await removeRegistryArtifact("aws-guard");
// Then
expect(result).toEqual({ status: expected });
expect(fetchMock).toHaveBeenCalledTimes(1);
},
);
it("never asks someone to delete providers over a Remove 409 it cannot identify", async () => {
// Given
fetchMock.mockResolvedValueOnce(new Response(null, { status: 409 }));
// When
const result = await removeRegistryArtifact("aws-guard");
// Then
expect(result).toEqual({ status: "error" });
});
it.each([
[401, "access_denied"],
[403, "access_denied"],
[400, "error"],
[500, "error"],
])("preserves the Remove failure for HTTP %s", async (status, expected) => {
// Given
fetchMock.mockResolvedValueOnce(new Response(null, { status }));
// When
const result = await removeRegistryArtifact("aws-guard");
// Then
expect(result).toEqual({ status: expected });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("reports a Remove network failure without refreshing membership", async () => {
// Given
fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
// When
const result = await removeRegistryArtifact("aws-guard");
// Then
expect(result).toEqual({ status: "error" });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("encodes the deletion identity and confirms Remove after an absent refresh", async () => {
// Given
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(jsonResponse({ data: [] }));
// When
const result = await removeRegistryArtifact("guard/with space");
// Then
expect(result).toEqual({ status: "confirmed", tenantArtifacts: [] });
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.test/api/v1/registry/artifacts/guard%2Fwith%20space",
expect.objectContaining({ cache: "no-store", method: "DELETE" }),
);
});
it.each(["1.0.0", null, undefined])(
"does not confirm an update when the installed version is %j",
async (resolvedVersion) => {
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({
data: [
{
type: "registry-artifacts",
id: "template",
attributes: {
version_spec: "latest",
resolved_version: resolvedVersion,
},
},
],
}),
);
// When
const result = await confirmRegistryArtifactAddition("template", "1.1.0");
// Then
expect(result).toEqual({ status: "refresh_failed" });
},
);
it("confirms an update only after reading its resolved target version", async () => {
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({
data: [
{
type: "registry-artifacts",
id: "template",
attributes: { version_spec: "latest", resolved_version: "1.1.0" },
},
],
}),
);
// When / Then
expect(
await confirmRegistryArtifactAddition("template", "1.1.0"),
).toMatchObject({
status: "confirmed",
tenantArtifacts: [
{ normalizedName: "template", resolvedVersion: "1.1.0" },
],
});
});
it.each([
[
"Add",
() => addRegistryArtifact({ normalizedName: "later-guard" }),
{ data: [] },
{ status: "error" },
1,
],
[
"Remove",
() => removeRegistryArtifact("later-guard"),
{
data: [
{
type: "registry-artifacts",
id: "later-guard",
attributes: { version_spec: "latest" },
},
],
},
{ status: "refresh_failed" },
2,
],
])(
"keeps membership unchanged when %s refresh contradicts acceptance",
async (_name, mutate, refreshedArtifacts, expected, calls) => {
// Given
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(jsonResponse(refreshedArtifacts));
// When
const result = await mutate();
// Then
expect(result).toEqual(expected);
expect(fetchMock).toHaveBeenCalledTimes(calls);
},
);
});