Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 92 additions & 24 deletions ui/actions/registry/registry.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
REGISTRY_CATALOG_INCOMPLETE_REASON,
REGISTRY_ENDPOINT,
REGISTRY_FAILURE,
REGISTRY_MUTATION,
REGISTRY_SUBMISSION,
type RegistryCatalogArtifact,
type RegistryCatalogResult,
type RegistryCredentialStatus,
type RegistryCredentialSubmissionResult,
type RegistryEndpoint,
type RegistryFailureResult,
type RegistryMutationResult,
type RegistryTenantArtifact,
} from "@/types/registry";

Expand All @@ -20,6 +22,14 @@ const REGISTRY_ERROR_CODE = {
KEY_REJECTED: "registry_key_rejected",
UNAVAILABLE: "registry_unavailable",
} as const;
const REGISTRY_MUTATION_REFUSAL_COPY = {
no_installable_version: "No available version can be added.",
registry_artifact_not_found: "This artifact is no longer available.",
version_not_found: "This version is not available.",
version_not_processed: "This version is not ready to add yet.",
version_not_verified: "This version is not verified and cannot be added.",
version_yanked: "This version is no longer available.",
} as const;
const registryDiscoveryEndpoints = new Set<RegistryEndpoint>([
REGISTRY_ENDPOINT.PROVIDERS,
REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS,
Expand Down Expand Up @@ -125,6 +135,18 @@ export async function parseRegistryCredentialSubmission(
return { status: REGISTRY_SUBMISSION.PENDING, taskId };
}

export async function classifyRegistryMutationRefusal(
response: Response,
): Promise<Extract<RegistryMutationResult, { status: "refused" }> | null> {
const code = await getRegistryErrorCode(response);
const message = code
? REGISTRY_MUTATION_REFUSAL_COPY[
code as keyof typeof REGISTRY_MUTATION_REFUSAL_COPY
]
: undefined;
return message ? { status: REGISTRY_MUTATION.REFUSED, message } : null;
}

export async function classifyRegistryFailure(
response: Response,
endpoint: RegistryEndpoint,
Expand Down Expand Up @@ -184,19 +206,37 @@ async function getRegistryErrorCode(response: Response) {
const REGISTRY_CATALOG_PAGE_SIZE = 100;
const REGISTRY_CATALOG_MAX_PAGES = 1000;
const safeInteger = z.number().int().nonnegative().safe();
// prettier-ignore
const catalogPageSchema = z.object({
data: z.array(z.unknown()),
meta: z.object({ pagination: z.object({ page: safeInteger, pages: safeInteger, count: safeInteger }) }),
meta: z.object({
pagination: z.object({
page: safeInteger,
pages: safeInteger,
count: safeInteger,
}),
}),
});
// prettier-ignore
const catalogAttributesSchema = z.object({
name: z.string().optional(), description: z.string().optional(), latest_version: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
latest_version: z.string().optional(),
providers: z.array(z.string().trim().min(1)).optional(),
owners: z.array(z.object({ name: z.string().trim().min(1), type: z.string().trim().min(1) })).optional(),
is_verified: z.boolean().optional(), is_official: z.boolean().optional(), is_meta: z.boolean().optional(),
has_provider: z.boolean().optional(), has_checks: z.boolean().optional(), has_compliance: z.boolean().optional(),
version_count: safeInteger.optional(), total_downloads: safeInteger.optional(),
owners: z
.array(
z.object({
name: z.string().trim().min(1),
type: z.string().trim().min(1),
}),
)
.optional(),
is_verified: z.boolean().optional(),
is_official: z.boolean().optional(),
is_meta: z.boolean().optional(),
has_provider: z.boolean().optional(),
has_checks: z.boolean().optional(),
has_compliance: z.boolean().optional(),
version_count: safeInteger.optional(),
total_downloads: safeInteger.optional(),
});
const catalogResourceSchema = z.object({
type: z.string().trim().min(1),
Expand Down Expand Up @@ -276,30 +316,58 @@ function mergeCatalogResources(resources: unknown[]): RegistryCatalogResult {
};
}

// prettier-ignore
function adaptCatalogArtifact(resource: unknown): RegistryCatalogArtifact | null {
function adaptCatalogArtifact(
resource: unknown,
): RegistryCatalogArtifact | null {
const parsed = catalogResourceSchema.safeParse(resource);
if (!parsed.success) return null;
const { attributes: a, id } = parsed.data;
return {
normalizedName: id, name: text(a.name), description: text(a.description), latestVersion: text(a.latest_version),
providers: unique(a.providers?.map((provider) => provider.toLowerCase()) ?? []), owners: uniqueOwners(a.owners ?? []),
isVerified: a.is_verified ?? false, isOfficial: a.is_official ?? false, isMeta: a.is_meta ?? false,
hasProvider: a.has_provider ?? false, hasChecks: a.has_checks ?? false, hasCompliance: a.has_compliance ?? false,
versionCount: a.version_count ?? 0, totalDownloads: a.total_downloads ?? 0,
normalizedName: id,
name: text(a.name),
description: text(a.description),
latestVersion: text(a.latest_version),
providers: unique(
a.providers?.map((provider) => provider.toLowerCase()) ?? [],
),
owners: uniqueOwners(a.owners ?? []),
isVerified: a.is_verified ?? false,
isOfficial: a.is_official ?? false,
isMeta: a.is_meta ?? false,
hasProvider: a.has_provider ?? false,
hasChecks: a.has_checks ?? false,
hasCompliance: a.has_compliance ?? false,
versionCount: a.version_count ?? 0,
totalDownloads: a.total_downloads ?? 0,
};
}

// prettier-ignore
function mergeArtifacts(left: RegistryCatalogArtifact, right: RegistryCatalogArtifact): RegistryCatalogArtifact | null {
const [name, description, latestVersion] = [mergeText(left.name, right.name), mergeText(left.description, right.description), mergeText(left.latestVersion, right.latestVersion)];
if ([name, description, latestVersion].some((value) => value === null)) return null;
function mergeArtifacts(
left: RegistryCatalogArtifact,
right: RegistryCatalogArtifact,
): RegistryCatalogArtifact | null {
const [name, description, latestVersion] = [
mergeText(left.name, right.name),
mergeText(left.description, right.description),
mergeText(left.latestVersion, right.latestVersion),
];
if ([name, description, latestVersion].some((value) => value === null))
return null;
return {
...left, name: name ?? undefined, description: description ?? undefined, latestVersion: latestVersion ?? undefined,
providers: unique([...left.providers, ...right.providers]), owners: uniqueOwners([...left.owners, ...right.owners]),
isVerified: left.isVerified || right.isVerified, isOfficial: left.isOfficial || right.isOfficial, isMeta: left.isMeta || right.isMeta,
hasProvider: left.hasProvider || right.hasProvider, hasChecks: left.hasChecks || right.hasChecks, hasCompliance: left.hasCompliance || right.hasCompliance,
versionCount: Math.max(left.versionCount, right.versionCount), totalDownloads: Math.max(left.totalDownloads, right.totalDownloads),
...left,
name: name ?? undefined,
description: description ?? undefined,
latestVersion: latestVersion ?? undefined,
providers: unique([...left.providers, ...right.providers]),
owners: uniqueOwners([...left.owners, ...right.owners]),
isVerified: left.isVerified || right.isVerified,
isOfficial: left.isOfficial || right.isOfficial,
isMeta: left.isMeta || right.isMeta,
hasProvider: left.hasProvider || right.hasProvider,
hasChecks: left.hasChecks || right.hasChecks,
hasCompliance: left.hasCompliance || right.hasCompliance,
versionCount: Math.max(left.versionCount, right.versionCount),
totalDownloads: Math.max(left.totalDownloads, right.totalDownloads),
};
}

Expand Down
167 changes: 167 additions & 0 deletions ui/actions/registry/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ vi.mock("@/lib/registry/access.server", () => ({
}));

import {
addRegistryArtifact,
disconnectRegistryCredential,
getRegistryBootstrap,
refreshRegistryCollections,
removeRegistryArtifact,
refreshRegistryCredential,
refreshRegistryEligibility,
submitRegistryCredential,
Expand Down Expand Up @@ -673,4 +675,169 @@ describe("Registry guarded reads", () => {
expect(disconnected).toEqual({ status: "access_denied" });
expect(fetchMock).toHaveBeenCalledTimes(6);
});

it("confirms an exact Add only after My artifacts reports the artifact", async () => {
// Given
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 201 }))
.mockResolvedValueOnce(
jsonResponse({
data: [
{
type: "registry-tenant-artifacts",
id: "later-guard",
attributes: { version_spec: "2.0.0" },
},
],
}),
);

// When
const result = await addRegistryArtifact({
normalizedName: "later-guard",
versionSpec: " 2.0.0 ",
});

// Then
expect(result).toEqual({
status: "confirmed",
tenantArtifacts: [
{ normalizedName: "later-guard", versionSpec: "2.0.0" },
],
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.test/api/v1/registry/my-artifacts",
expect.objectContaining({
body: JSON.stringify({
data: {
type: "registry-tenant-artifacts",
id: "later-guard",
attributes: { version_spec: "2.0.0" },
},
}),
cache: "no-store",
method: "POST",
}),
);
});

it("defaults Add to latest", async () => {
// Given
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 201 }))
.mockResolvedValueOnce(
jsonResponse({
data: [
{
type: "registry-tenant-artifacts",
id: "later-guard",
attributes: { version_spec: "latest" },
},
],
}),
);

// When
await addRegistryArtifact({ normalizedName: "later-guard" });

// Then
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.test/api/v1/registry/my-artifacts",
expect.objectContaining({
body: JSON.stringify({
data: {
type: "registry-tenant-artifacts",
id: "later-guard",
attributes: { version_spec: "latest" },
},
}),
}),
);
});

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("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/my-artifacts/guard%2Fwith%20space",
expect.objectContaining({ cache: "no-store", method: "DELETE" }),
);
});

it.each([
[
"Add",
() => addRegistryArtifact({ normalizedName: "later-guard" }),
{ data: [] },
],
[
"Remove",
() => removeRegistryArtifact("later-guard"),
{
data: [
{
type: "registry-tenant-artifacts",
id: "later-guard",
attributes: { version_spec: "latest" },
},
],
},
],
])(
"keeps membership unchanged when %s refresh contradicts acceptance",
async (_name, mutate, refreshedArtifacts) => {
// Given
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(jsonResponse(refreshedArtifacts));

// When
const result = await mutate();

// Then
expect(result).toEqual({ status: "refresh_failed" });
expect(fetchMock).toHaveBeenCalledTimes(2);
},
);
});
Loading