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>
697 lines
19 KiB
TypeScript
697 lines
19 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
REGISTRY_ENDPOINT,
|
|
REGISTRY_FAILURE,
|
|
REGISTRY_SUBMISSION,
|
|
} from "@/types/registry";
|
|
|
|
import {
|
|
adaptRegistryCredentialStatus,
|
|
adaptRegistryTenantArtifacts,
|
|
classifyRegistryFailure,
|
|
collectCompleteRegistryCatalog,
|
|
parseRegistryArtifactSubmission,
|
|
} from "./registry.adapter";
|
|
|
|
const credentialPayload = {
|
|
data: {
|
|
attributes: {
|
|
configured: true,
|
|
is_valid: true,
|
|
scopes: ["catalog:read"],
|
|
last_validated_at: "2026-03-20T12:00:00Z",
|
|
validation_status: "valid",
|
|
validation_pending: false,
|
|
key: "registry-secret-value",
|
|
masked_key: "reg_***",
|
|
pending_key: "queued-secret",
|
|
arbitrary_backend_detail: "do not expose",
|
|
},
|
|
},
|
|
};
|
|
|
|
const activeCredential = adaptRegistryCredentialStatus(credentialPayload);
|
|
const jsonError = (status: number, code: string) =>
|
|
new Response(
|
|
JSON.stringify({ errors: [{ code, detail: "private detail" }] }),
|
|
{
|
|
status,
|
|
},
|
|
);
|
|
|
|
describe("Registry adapter", () => {
|
|
it("reads the resolved installed version separately from the requested spec", () => {
|
|
// Given / When
|
|
const artifacts = adaptRegistryTenantArtifacts({
|
|
data: [
|
|
{
|
|
type: "registry-artifacts",
|
|
id: "template",
|
|
attributes: {
|
|
version_spec: "latest",
|
|
resolved_version: " 1.0.0 ",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
// Then
|
|
expect(artifacts).toEqual([
|
|
expect.objectContaining({
|
|
normalizedName: "template",
|
|
versionSpec: "latest",
|
|
resolvedVersion: "1.0.0",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it.each([undefined, null, "", " "])(
|
|
"accepts an unknown resolved version %j",
|
|
(resolvedVersion) => {
|
|
// Given / When
|
|
const artifacts = adaptRegistryTenantArtifacts({
|
|
data: [
|
|
{
|
|
type: "registry-artifacts",
|
|
id: "template",
|
|
attributes: {
|
|
version_spec: "latest",
|
|
resolved_version: resolvedVersion,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
// Then
|
|
expect(artifacts).toMatchObject([{ resolvedVersion: undefined }]);
|
|
},
|
|
);
|
|
|
|
it("maps only documented non-secret credential status fields", () => {
|
|
// Given
|
|
const malformedPayload = { data: { attributes: { configured: true } } };
|
|
|
|
// When
|
|
const status = adaptRegistryCredentialStatus(credentialPayload);
|
|
|
|
// Then
|
|
expect(status).toEqual({
|
|
configured: true,
|
|
isValid: true,
|
|
scopes: ["catalog:read"],
|
|
lastValidatedAt: "2026-03-20T12:00:00Z",
|
|
validationStatus: "valid",
|
|
validationPending: false,
|
|
});
|
|
expect(adaptRegistryCredentialStatus(malformedPayload)).toBeNull();
|
|
});
|
|
|
|
it("normalizes an absent credential status with nullable validation fields", () => {
|
|
// Given
|
|
const absentCredentialPayload = {
|
|
data: {
|
|
attributes: {
|
|
configured: false,
|
|
is_valid: false,
|
|
scopes: [],
|
|
last_validated_at: null,
|
|
validation_status: null,
|
|
validation_pending: false,
|
|
},
|
|
},
|
|
};
|
|
|
|
// When
|
|
const status = adaptRegistryCredentialStatus(absentCredentialPayload);
|
|
|
|
// Then
|
|
expect(status).toEqual({
|
|
configured: false,
|
|
isValid: false,
|
|
scopes: [],
|
|
lastValidatedAt: undefined,
|
|
validationStatus: undefined,
|
|
validationPending: false,
|
|
});
|
|
});
|
|
|
|
it("accepts only a matching artifact 202 task and fixed Content-Location path", async () => {
|
|
// Given
|
|
const response = new Response(
|
|
JSON.stringify({ data: { type: "tasks", id: "task-123" } }),
|
|
{
|
|
status: 202,
|
|
headers: { "Content-Location": "/api/v1/tasks/task-123" },
|
|
},
|
|
);
|
|
|
|
// When
|
|
const result = await parseRegistryArtifactSubmission(response);
|
|
|
|
// Then
|
|
expect(result).toEqual({
|
|
status: REGISTRY_SUBMISSION.PENDING,
|
|
taskId: "task-123",
|
|
});
|
|
});
|
|
|
|
it("rejects a non-202 response or a mismatched task location", async () => {
|
|
// Given
|
|
const task = JSON.stringify({ data: { type: "tasks", id: "task-123" } });
|
|
const wrongStatus = new Response(task, { status: 201 });
|
|
const wrongLocation = new Response(task, {
|
|
status: 202,
|
|
headers: { "Content-Location": "/api/v1/tasks/other" },
|
|
});
|
|
|
|
// When
|
|
const results = await Promise.all([
|
|
parseRegistryArtifactSubmission(wrongStatus),
|
|
parseRegistryArtifactSubmission(wrongLocation),
|
|
]);
|
|
|
|
// Then
|
|
expect(results).toEqual([
|
|
{ status: REGISTRY_SUBMISSION.ERROR },
|
|
{ status: REGISTRY_SUBMISSION.ERROR },
|
|
]);
|
|
});
|
|
|
|
it("classifies every Registry 401 or 403 as access denied first", async () => {
|
|
// Given
|
|
const responses = [
|
|
[401, REGISTRY_ENDPOINT.CREDENTIAL],
|
|
[403, REGISTRY_ENDPOINT.MUTATION],
|
|
[403, REGISTRY_ENDPOINT.PROVIDERS],
|
|
] as const;
|
|
|
|
// When
|
|
const results = await Promise.all(
|
|
responses.map(([status, endpoint]) =>
|
|
classifyRegistryFailure(
|
|
jsonError(status, "registry_key_rejected"),
|
|
endpoint,
|
|
activeCredential,
|
|
),
|
|
),
|
|
);
|
|
|
|
// Then
|
|
expect(results).toEqual([
|
|
{ status: REGISTRY_FAILURE.ACCESS_DENIED },
|
|
{ status: REGISTRY_FAILURE.ACCESS_DENIED },
|
|
{ status: REGISTRY_FAILURE.ACCESS_DENIED },
|
|
]);
|
|
});
|
|
|
|
it("maps only a 409 with an authoritative no-active credential to onboarding", async () => {
|
|
// Given
|
|
const noCredential = adaptRegistryCredentialStatus({
|
|
data: {
|
|
attributes: {
|
|
configured: false,
|
|
is_valid: false,
|
|
scopes: [],
|
|
validation_pending: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
// When
|
|
const results = await Promise.all(
|
|
[noCredential, null].map((credential) =>
|
|
classifyRegistryFailure(
|
|
new Response(null, { status: 409 }),
|
|
REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS,
|
|
credential,
|
|
),
|
|
),
|
|
);
|
|
|
|
// Then
|
|
expect(results).toEqual([
|
|
{ status: REGISTRY_FAILURE.ONBOARDING },
|
|
{ status: REGISTRY_FAILURE.ERROR },
|
|
]);
|
|
});
|
|
|
|
it("maps only exact documented 502 and 503 status-code pairs", async () => {
|
|
// Given
|
|
const rejected = jsonError(502, "registry_key_rejected");
|
|
const unavailable = jsonError(503, "registry_unavailable");
|
|
|
|
// When
|
|
const results = await Promise.all([
|
|
classifyRegistryFailure(
|
|
rejected,
|
|
REGISTRY_ENDPOINT.PROVIDERS,
|
|
activeCredential,
|
|
),
|
|
classifyRegistryFailure(
|
|
unavailable,
|
|
REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS,
|
|
activeCredential,
|
|
),
|
|
]);
|
|
|
|
// Then
|
|
expect(results).toEqual([
|
|
{ status: REGISTRY_FAILURE.RECONNECT },
|
|
{ status: REGISTRY_FAILURE.UNAVAILABLE },
|
|
]);
|
|
});
|
|
|
|
it("keeps wrong, malformed, and unrelated failures generic", async () => {
|
|
// Given
|
|
const malformed = new Response("<html>key=private</html>", { status: 503 });
|
|
|
|
// When
|
|
const results = await Promise.all([
|
|
classifyRegistryFailure(
|
|
jsonError(502, "other_error"),
|
|
REGISTRY_ENDPOINT.PROVIDERS,
|
|
activeCredential,
|
|
),
|
|
classifyRegistryFailure(
|
|
jsonError(502, "registry_unavailable"),
|
|
REGISTRY_ENDPOINT.PROVIDERS,
|
|
activeCredential,
|
|
),
|
|
classifyRegistryFailure(
|
|
malformed,
|
|
REGISTRY_ENDPOINT.PROVIDERS,
|
|
activeCredential,
|
|
),
|
|
]);
|
|
|
|
// Then
|
|
expect(results).toEqual([
|
|
{ status: REGISTRY_FAILURE.ERROR },
|
|
{ status: REGISTRY_FAILURE.ERROR },
|
|
{ status: REGISTRY_FAILURE.ERROR },
|
|
]);
|
|
});
|
|
|
|
it("degrades a non-terminal empty first catalog page", async () => {
|
|
// Given
|
|
const document = (page: number) => ({
|
|
data: [],
|
|
meta: { pagination: { page, pages: 2, count: 0 } },
|
|
});
|
|
|
|
// When
|
|
const result = await collectCompleteRegistryCatalog(async (page) =>
|
|
document(page),
|
|
);
|
|
|
|
// Then
|
|
expect(result).toEqual({
|
|
status: "incomplete",
|
|
reason: "invalid_page",
|
|
collectedCount: 0,
|
|
});
|
|
});
|
|
|
|
it("accepts a terminal empty first catalog page", async () => {
|
|
// Given
|
|
const document = {
|
|
data: [],
|
|
meta: { pagination: { page: 1, pages: 1, count: 0 } },
|
|
};
|
|
|
|
// When
|
|
const result = await collectCompleteRegistryCatalog(async () => document);
|
|
|
|
// Then
|
|
expect(result).toEqual({ status: "complete", artifacts: [] });
|
|
});
|
|
|
|
it("maps the flat owner attributes tolerantly", async () => {
|
|
// Given
|
|
const document = {
|
|
data: [
|
|
{
|
|
type: "registry-artifacts",
|
|
id: "core",
|
|
attributes: {
|
|
owner_name: "Prowler",
|
|
owner_slug: "prowler",
|
|
owner_type: "organization",
|
|
owner_logo_url: "https://cdn.example/prowler.png",
|
|
},
|
|
},
|
|
{
|
|
type: "registry-artifacts",
|
|
id: "plain-owner",
|
|
attributes: {
|
|
owner_name: "Ada",
|
|
owner_slug: "ada",
|
|
owner_type: "user",
|
|
owner_logo_url: null,
|
|
},
|
|
},
|
|
{
|
|
type: "registry-artifacts",
|
|
id: "ownerless",
|
|
attributes: { owner_name: " ", owner_logo_url: " " },
|
|
},
|
|
],
|
|
meta: { pagination: { page: 1, pages: 1, count: 3 } },
|
|
};
|
|
|
|
// When
|
|
const result = await collectCompleteRegistryCatalog(async () => document);
|
|
|
|
// Then
|
|
expect(result).toMatchObject({
|
|
status: "complete",
|
|
artifacts: [
|
|
{
|
|
normalizedName: "core",
|
|
owners: [
|
|
{
|
|
type: "organization",
|
|
name: "Prowler",
|
|
logoUrl: "https://cdn.example/prowler.png",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
normalizedName: "ownerless",
|
|
owners: [],
|
|
},
|
|
{
|
|
normalizedName: "plain-owner",
|
|
owners: [{ type: "user", name: "Ada", logoUrl: undefined }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("defaults omitted built-in status and maps explicit built-ins", async () => {
|
|
// Given
|
|
const document = {
|
|
data: [
|
|
{ type: "registry-artifacts", id: "installable", attributes: {} },
|
|
{
|
|
type: "registry-artifacts",
|
|
id: "built-in",
|
|
attributes: { is_builtin: true },
|
|
},
|
|
],
|
|
meta: { pagination: { page: 1, pages: 1, count: 2 } },
|
|
};
|
|
|
|
// When
|
|
const result = await collectCompleteRegistryCatalog(async () => document);
|
|
|
|
// Then
|
|
expect(result).toMatchObject({
|
|
status: "complete",
|
|
artifacts: [
|
|
{ normalizedName: "built-in", isBuiltin: true },
|
|
{ normalizedName: "installable", isBuiltin: false },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("rejects malformed built-in values and preserves built-in duplicates", async () => {
|
|
// Given
|
|
const document = (data: unknown[]) => ({
|
|
data,
|
|
meta: { pagination: { page: 1, pages: 1, count: data.length } },
|
|
});
|
|
const resource = (id: string, isBuiltin: unknown) => ({
|
|
type: "registry-artifacts",
|
|
id,
|
|
attributes: { is_builtin: isBuiltin },
|
|
});
|
|
|
|
// When
|
|
const explicitFalse = await collectCompleteRegistryCatalog(async () =>
|
|
document([resource("installable", false)]),
|
|
);
|
|
const malformed = await Promise.all(
|
|
[null, "true", 1].map((isBuiltin) =>
|
|
collectCompleteRegistryCatalog(async () =>
|
|
document([resource("malformed", isBuiltin)]),
|
|
),
|
|
),
|
|
);
|
|
const duplicate = await collectCompleteRegistryCatalog(async (page) => ({
|
|
data: [resource("built-in", page === 2)],
|
|
meta: { pagination: { page, pages: 2, count: 2 } },
|
|
}));
|
|
|
|
// Then
|
|
expect(explicitFalse).toMatchObject({
|
|
status: "complete",
|
|
artifacts: [{ normalizedName: "installable", isBuiltin: false }],
|
|
});
|
|
expect(malformed).toEqual([
|
|
{ status: "incomplete", reason: "invalid_resource", collectedCount: 1 },
|
|
{ status: "incomplete", reason: "invalid_resource", collectedCount: 1 },
|
|
{ status: "incomplete", reason: "invalid_resource", collectedCount: 1 },
|
|
]);
|
|
expect(duplicate).toMatchObject({
|
|
status: "complete",
|
|
artifacts: [{ normalizedName: "built-in", isBuiltin: true }],
|
|
});
|
|
});
|
|
|
|
it("preserves artifact counts, including zero, without inventing missing counts", async () => {
|
|
// Given
|
|
const resources = [
|
|
{ id: "aws", attributes: { check_count: 645, compliance_count: 45 } },
|
|
{ id: "openai", attributes: { check_count: 2, compliance_count: 0 } },
|
|
{ id: "missing", attributes: {} },
|
|
{
|
|
id: "unknown",
|
|
attributes: { check_count: null, compliance_count: null },
|
|
},
|
|
{ id: "aws", attributes: { check_count: 645 } },
|
|
].map((resource) => ({
|
|
type: "registry-available-artifacts",
|
|
...resource,
|
|
}));
|
|
|
|
// When
|
|
const result = await collectCompleteRegistryCatalog(async () => ({
|
|
data: resources,
|
|
meta: { pagination: { page: 1, pages: 1, count: resources.length } },
|
|
}));
|
|
|
|
// Then
|
|
expect(result).toMatchObject({
|
|
status: "complete",
|
|
artifacts: [
|
|
{ normalizedName: "aws", checkCount: 645, complianceCount: 45 },
|
|
{
|
|
normalizedName: "missing",
|
|
checkCount: undefined,
|
|
complianceCount: undefined,
|
|
},
|
|
{ normalizedName: "openai", checkCount: 2, complianceCount: 0 },
|
|
{
|
|
normalizedName: "unknown",
|
|
checkCount: undefined,
|
|
complianceCount: undefined,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("preserves the declared provider when merging complementary catalog entries", async () => {
|
|
// Given
|
|
const fetchPage = async (page: number) => ({
|
|
data: [
|
|
{
|
|
type: "registry-artifacts",
|
|
id: "external-package",
|
|
attributes:
|
|
page === 1
|
|
? { providers: ["aaa"], has_checks: true }
|
|
: { providers: ["zzz"], has_provider: true },
|
|
},
|
|
],
|
|
meta: { pagination: { page, pages: 2, count: 2 } },
|
|
});
|
|
|
|
// When
|
|
const result = await collectCompleteRegistryCatalog(fetchPage);
|
|
|
|
// Then
|
|
expect(result).toMatchObject({
|
|
status: "complete",
|
|
artifacts: [
|
|
{ hasProvider: true, providerSlug: "zzz", providers: ["aaa", "zzz"] },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("rejects duplicate catalog entries with conflicting declared providers", async () => {
|
|
// Given
|
|
const fetchPage = async (page: number) => ({
|
|
data: [
|
|
{
|
|
type: "registry-artifacts",
|
|
id: "external-package",
|
|
attributes: {
|
|
has_provider: true,
|
|
providers: [page === 1 ? "aaa" : "zzz"],
|
|
},
|
|
},
|
|
],
|
|
meta: { pagination: { page, pages: 2, count: 2 } },
|
|
});
|
|
|
|
// When / Then
|
|
await expect(
|
|
collectCompleteRegistryCatalog(fetchPage),
|
|
).resolves.toMatchObject({
|
|
status: "incomplete",
|
|
reason: "conflicting_duplicate",
|
|
});
|
|
});
|
|
|
|
it("traverses, merges, and degrades unsafe catalog data", async () => {
|
|
// Given
|
|
|
|
const resource = (
|
|
id: string,
|
|
attributes: Record<string, unknown> = {},
|
|
) => ({ type: "registry-artifacts", id, attributes });
|
|
|
|
const document = (
|
|
page: number,
|
|
pages: number,
|
|
count: number,
|
|
data: unknown[],
|
|
) => ({ data, meta: { pagination: { page, pages, count } } });
|
|
const requests: Array<[number, string | null, string | null]> = [];
|
|
|
|
// When
|
|
|
|
const complete = await collectCompleteRegistryCatalog(
|
|
async (page, query) => {
|
|
requests.push([
|
|
page,
|
|
query.get("page[number]"),
|
|
query.get("page[size]"),
|
|
]);
|
|
return page === 1
|
|
? document(1, 2, 3, [
|
|
resource("core", {
|
|
name: "Core",
|
|
providers: ["AWS"],
|
|
is_verified: true,
|
|
version_count: 1,
|
|
total_downloads: 2,
|
|
owner_name: "Prowler",
|
|
owner_type: "organization",
|
|
}),
|
|
resource("zeta"),
|
|
])
|
|
: document(2, 2, 3, [
|
|
resource("core", {
|
|
description: "Registry core",
|
|
latest_version: "2.0.0",
|
|
providers: ["gcp"],
|
|
is_official: true,
|
|
has_checks: true,
|
|
version_count: 3,
|
|
total_downloads: 8,
|
|
}),
|
|
]);
|
|
},
|
|
);
|
|
|
|
const limits = await Promise.all(
|
|
[999, 1000, 1001].map(async (pages) => {
|
|
let requests = 0;
|
|
const result = await collectCompleteRegistryCatalog(async (page) => {
|
|
requests += 1;
|
|
return document(page, pages, pages, [resource(`item-${page}`)]);
|
|
});
|
|
return [pages, requests, result] as const;
|
|
}),
|
|
);
|
|
|
|
const failures = await Promise.all([
|
|
collectCompleteRegistryCatalog(async () => ({ data: {}, meta: {} })),
|
|
collectCompleteRegistryCatalog(async () =>
|
|
document(1, 1, 2, [resource("one")]),
|
|
),
|
|
collectCompleteRegistryCatalog(async (page) =>
|
|
document(page === 1 ? 1 : 1, 2, 2, [resource(`item-${page}`)]),
|
|
),
|
|
collectCompleteRegistryCatalog(async (page) =>
|
|
document(page, page === 1 ? 2 : 3, 2, [resource(`item-${page}`)]),
|
|
),
|
|
collectCompleteRegistryCatalog(async () =>
|
|
document(1, 1, 1, [resource("")]),
|
|
),
|
|
collectCompleteRegistryCatalog(async (page) =>
|
|
document(page, 2, 2, [
|
|
resource("duplicate", { name: page === 1 ? "One" : "Two" }),
|
|
]),
|
|
),
|
|
collectCompleteRegistryCatalog(async (page) => {
|
|
if (page === 2) throw new Error("offline");
|
|
return document(1, 2, 2, [resource("first")]);
|
|
}),
|
|
]);
|
|
|
|
// Then
|
|
expect(requests).toEqual([
|
|
[1, "1", "100"],
|
|
[2, "2", "100"],
|
|
]);
|
|
|
|
expect(complete).toMatchObject({
|
|
status: "complete",
|
|
artifacts: [
|
|
{
|
|
normalizedName: "core",
|
|
name: "Core",
|
|
description: "Registry core",
|
|
latestVersion: "2.0.0",
|
|
providers: ["aws", "gcp"],
|
|
isVerified: true,
|
|
isOfficial: true,
|
|
hasChecks: true,
|
|
versionCount: 3,
|
|
totalDownloads: 8,
|
|
owners: [{ type: "organization", name: "Prowler" }],
|
|
},
|
|
{ normalizedName: "zeta" },
|
|
],
|
|
});
|
|
expect(limits.map(([pages, requests]) => [pages, requests])).toEqual([
|
|
[999, 999],
|
|
[1000, 1000],
|
|
[1001, 1],
|
|
]);
|
|
expect(limits[2]?.[2]).toEqual({
|
|
status: "incomplete",
|
|
reason: "guard_exhausted",
|
|
collectedCount: 1,
|
|
});
|
|
expect(
|
|
failures.map((result) =>
|
|
result.status === "incomplete" ? result.reason : undefined,
|
|
),
|
|
).toEqual([
|
|
"invalid_page",
|
|
"count_mismatch",
|
|
"invalid_page",
|
|
"invalid_page",
|
|
"invalid_resource",
|
|
"conflicting_duplicate",
|
|
"page_failed",
|
|
]);
|
|
failures.forEach((result) =>
|
|
expect(result).not.toHaveProperty("artifacts"),
|
|
);
|
|
});
|
|
});
|