From 509fe6ff59c220d54d1f215642d872cd18ecaf17 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 08:13:34 +0100 Subject: [PATCH 01/10] feat(cloud-registry): add verification methods Add provider methods and types for the verification endpoints of Cloud Registry 0.4.0 and later (GET /verifications, GET, PUT and DELETE /services/{id}/verification), optional in the interface so that custom providers still conform, and helpers that tell whether a verification still matches its service, as the registry compares them. Co-Authored-By: Claude Opus 5.5 --- .../pages/docs/cloud-registry/providers.mdx | 70 ++++++ .../src/providers/cr-provider.ts | 42 ++++ .../src/providers/index.ts | 5 + .../src/providers/rest-cr-provider.ts | 102 ++++++++ .../providers/tests/rest-cr-provider.test.ts | 7 + .../src/providers/tests/verifications.test.ts | 232 ++++++++++++++++++ .../src/providers/verifications.ts | 88 +++++++ 7 files changed, 546 insertions(+) create mode 100644 packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts create mode 100644 packages/ecc-client-elixir-cloud-registry/src/providers/verifications.ts diff --git a/apps/documentation/pages/docs/cloud-registry/providers.mdx b/apps/documentation/pages/docs/cloud-registry/providers.mdx index 235fa126..1d638bc9 100644 --- a/apps/documentation/pages/docs/cloud-registry/providers.mdx +++ b/apps/documentation/pages/docs/cloud-registry/providers.mdx @@ -33,6 +33,12 @@ export interface CloudRegistryProvider extends ServiceRegistryProvider { // Service info methods (Cloud Registry specific) createOrUpdateServiceInfo?(service: ServiceRegister): Promise; + + // Verification methods (Cloud Registry 0.4.0 and later; see Verifications) + getVerifications?(): Promise; + getServiceVerification?(id: string): Promise; + verifyService?(id: string, verification?: VerificationRegister): Promise; + deleteServiceVerification?(id: string): Promise; } ``` @@ -155,6 +161,48 @@ Creates or updates the service registry's own service information. createOrUpdateServiceInfo?(service: ServiceRegister): Promise ``` +### Verifications + +Cloud Registry 0.4.0 and later let admins verify services, e.g. after checking that they accept the tokens of the deployment's identity provider. The methods are optional in `CloudRegistryProvider`, so that custom providers without them still conform; `RestCloudRegistryProvider` has them all. The [Service Verification component](/docs/cloud-registry/components/service-verification) uses them. + +```typescript +// GET /verifications: the verified services, sorted by ID +getVerifications?(): Promise + +// GET /services/{id}/verification: rejects with status 404 if not verified +getServiceVerification?(id: string): Promise + +// PUT /services/{id}/verification (admins): with the URL and API type that +// were checked, rejects with status 409 (problem `code` +// `verification-mismatch`) if the service no longer has them; without +// them, verifies the service as registered +verifyService?(id: string, verification?: VerificationRegister): Promise + +// DELETE /services/{id}/verification (admins): rejects with status 404 if +// not verified; resolves to the service ID +deleteServiceVerification?(id: string): Promise +``` + +A verification only counts while the service still has the verified URL (as written) and API type (`type.group` and `type.artifact`, trimmed and case-insensitive; not `type.version`). The registry removes verifications when these change, but a list of services and a list of verifications loaded one after the other may disagree. Two helpers compare them as the registry does: + +```typescript +import { + matchesVerification, + verifiedServices, +} from '@elixir-cloud/cloud-registry/providers'; + +const [services, verifications] = await Promise.all([ + provider.getServices(), + provider.getVerifications(), +]); + +// Map of service ID to verification, for the services that are verified +const verified = verifiedServices(services, verifications); + +// Whether one verification still matches a service (or form values) +matchesVerification(service, verification); +``` + ## Errors If the registry answers with an error status, the methods of `RestCloudRegistryProvider` reject with a `CloudRegistryError`. It extends `Error` and carries the status and the [problem details](https://www.rfc-editor.org/rfc/rfc9457) that the registry sends: @@ -257,6 +305,28 @@ interface RegistrationRules { } ``` +### Verification, ServiceVerification and VerificationRegister + +```typescript +// GET and PUT /services/{id}/verification +interface Verification { + url: string; // URL of the verified service, as registered + type: ServiceType; // API type of the verified service + verifiedAt: string; // when an admin verified it (RFC 3339, UTC) +} + +// GET /verifications +interface ServiceVerification extends Verification { + serviceId: string; +} + +// Body of PUT /services/{id}/verification: what the admin checked +interface VerificationRegister { + url: string; // exactly as registered + type: ServiceTypeRegister; // `version` isn't compared +} +``` + ## Using with Components ### Service Creation diff --git a/packages/ecc-client-elixir-cloud-registry/src/providers/cr-provider.ts b/packages/ecc-client-elixir-cloud-registry/src/providers/cr-provider.ts index 31959fc5..d99f499d 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/providers/cr-provider.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/providers/cr-provider.ts @@ -80,6 +80,38 @@ export interface RegistryServiceInfo extends Service { registrationRules?: RegistrationRules; } +/** + * URL and API type of a service that an admin checked, sent to verify it + * (`PUT /services/{serviceId}/verification`): the registry only verifies + * the service if it still has exactly this URL and API type + */ +export interface VerificationRegister { + /** URL of the service, exactly as registered */ + url: string; + /** API type of the service; `version` isn't compared */ + type: ServiceTypeRegister; +} + +/** + * Verification of a service by an admin (`GET` and `PUT + * /services/{serviceId}/verification`, since cloud-registry 0.4.0), with + * the URL and API type of the service that were verified + */ +export interface Verification { + /** URL of the verified service, as registered */ + url: string; + /** API type of the verified service */ + type: ServiceType; + /** When an admin verified the service (RFC 3339, UTC) */ + verifiedAt: string; +} + +/** Verification of a service with its ID (`GET /verifications`) */ +export interface ServiceVerification extends Verification { + /** ID of the verified service */ + serviceId: string; +} + /** * Interface defining the operations required for Cloud Registry data providers * Extends the base ServiceRegistryProvider with Cloud Registry specific creation and deletion operations @@ -104,4 +136,14 @@ export interface CloudRegistryProvider extends ServiceRegistryProvider { // Service info, with the registration rules of Cloud Registry 1.0.0rc2 and // later (optional, so providers returning a plain `Service` still conform) getServiceInfo(): Promise; + + // Verification methods (Cloud Registry 0.4.0 and later; optional, so + // that providers without them still conform) + getVerifications?(): Promise; + getServiceVerification?(id: string): Promise; + verifyService?( + id: string, + verification?: VerificationRegister + ): Promise; + deleteServiceVerification?(id: string): Promise; } diff --git a/packages/ecc-client-elixir-cloud-registry/src/providers/index.ts b/packages/ecc-client-elixir-cloud-registry/src/providers/index.ts index 7a60fd31..812ab291 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/providers/index.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/providers/index.ts @@ -18,4 +18,9 @@ export type { ExternalServiceRegister, RegistrationRules, RegistryServiceInfo, + VerificationRegister, + Verification, + ServiceVerification, } from "./cr-provider.js"; + +export { matchesVerification, verifiedServices } from "./verifications.js"; diff --git a/packages/ecc-client-elixir-cloud-registry/src/providers/rest-cr-provider.ts b/packages/ecc-client-elixir-cloud-registry/src/providers/rest-cr-provider.ts index c29d255e..8c6e7857 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/providers/rest-cr-provider.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/providers/rest-cr-provider.ts @@ -5,6 +5,9 @@ import { ExternalServiceRegister, ServiceRegister, RegistryServiceInfo, + ServiceVerification, + Verification, + VerificationRegister, } from "./cr-provider.js"; import { CloudRegistryError } from "./errors.js"; @@ -201,6 +204,105 @@ export class RestCloudRegistryProvider implements CloudRegistryProvider { return response.json(); } + /** + * List the verified services (`GET /verifications`). An entry only counts + * while the service still has its URL and API type; see + * `verifiedServices()`. + * @returns Promise resolving to the verifications, sorted by service ID + */ + async getVerifications(): Promise { + const url = `${this.baseUrl}/verifications`; + const response = await this.fetch(url); + if (!response.ok) { + throw await CloudRegistryError.fromResponse( + response, + "Failed to fetch verifications" + ); + } + return response.json(); + } + + /** + * Get the verification of a service. Rejects with status `404` if the + * service isn't verified (or unknown). + * @param id Service identifier + * @returns Promise resolving to the verification + */ + async getServiceVerification(id: string): Promise { + const encodedServiceId = encodeURIComponent(id); + const url = `${this.baseUrl}/services/${encodedServiceId}/verification`; + const response = await this.fetch(url); + if (!response.ok) { + throw await CloudRegistryError.fromResponse( + response, + "Failed to fetch verification" + ); + } + return response.json(); + } + + /** + * Verify a service (admins only). With the URL and API type that were + * checked, the registry only verifies the service if it still has them, + * else it rejects with status `409` (problem `code` + * `verification-mismatch`); without them, it verifies the service as it + * is registered. + * @param id Service identifier + * @param verification URL and API type that were checked (recommended) + * @returns Promise resolving to the verification + */ + async verifyService( + id: string, + verification?: VerificationRegister + ): Promise { + const encodedServiceId = encodeURIComponent(id); + const url = `${this.baseUrl}/services/${encodedServiceId}/verification`; + const response = await this.fetch( + url, + verification + ? { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(verification), + } + : { method: "PUT" } + ); + + if (!response.ok) { + throw await CloudRegistryError.fromResponse( + response, + "Failed to verify service" + ); + } + + return response.json(); + } + + /** + * Remove the verification of a service (admins only). Rejects with status + * `404` if the service isn't verified (or unknown). + * @param id Service identifier + * @returns Promise resolving to the service identifier + */ + async deleteServiceVerification(id: string): Promise { + const encodedServiceId = encodeURIComponent(id); + const url = `${this.baseUrl}/services/${encodedServiceId}/verification`; + const response = await this.fetch(url, { + method: "DELETE", + }); + + if (!response.ok) { + throw await CloudRegistryError.fromResponse( + response, + "Failed to remove verification" + ); + } + + return response.json(); + } + /** * Create or update the service info * @param service Service info to register diff --git a/packages/ecc-client-elixir-cloud-registry/src/providers/tests/rest-cr-provider.test.ts b/packages/ecc-client-elixir-cloud-registry/src/providers/tests/rest-cr-provider.test.ts index e153c394..c71f095a 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/providers/tests/rest-cr-provider.test.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/providers/tests/rest-cr-provider.test.ts @@ -32,6 +32,13 @@ const METHODS: [string, (provider: RestCloudRegistryProvider) => unknown][] = [ "createOrUpdateServiceInfo", (p) => p.createOrUpdateServiceInfo({ ...SERVICE, id: "registry" }), ], + ["getVerifications", (p) => p.getVerifications()], + ["getServiceVerification", (p) => p.getServiceVerification("service-1")], + ["verifyService", (p) => p.verifyService("service-1")], + [ + "deleteServiceVerification", + (p) => p.deleteServiceVerification("service-1"), + ], ]; /** The error that a call rejects with. */ diff --git a/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts b/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts new file mode 100644 index 00000000..c824fac2 --- /dev/null +++ b/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts @@ -0,0 +1,232 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect } from "@open-wc/testing"; +import sinon from "sinon"; +import { + CloudRegistryError, + ExternalService, + RestCloudRegistryProvider, + ServiceVerification, + matchesVerification, + verifiedServices, +} from "../index.js"; + +const BASE_URL = "https://registry.example.org/ga4gh/registry/v1"; + +const VERIFICATION = { + url: "https://tes.example.org/ga4gh/tes/v1", + type: { group: "org.ga4gh", artifact: "tes", version: "1.1.0" }, + verifiedAt: "2026-10-20T12:34:56.789Z", +}; + +const SERVICE: ExternalService = { + id: "service-1", + name: "My TES", + url: VERIFICATION.url, + type: { group: "org.ga4gh", artifact: "tes", version: "1.1.0" }, + organization: { name: "Example", url: "https://example.org" }, + version: "1.0.0", +}; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + +/** The error that a call rejects with. */ +const rejection = async (call: () => unknown): Promise => { + try { + await call(); + } catch (err) { + return err; + } + throw new Error("The call did not fail."); +}; + +describe("RestCloudRegistryProvider verifications", () => { + let fetchStub: sinon.SinonStub; + const provider = new RestCloudRegistryProvider(BASE_URL); + + beforeEach(() => { + fetchStub = sinon.stub(window, "fetch"); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("lists the verified services", async () => { + const list = [{ serviceId: "service-1", ...VERIFICATION }]; + fetchStub.resolves(json(list)); + + const result = await provider.getVerifications(); + + expect(result).to.deep.equal(list); + expect(fetchStub.firstCall.args[0]).to.equal(`${BASE_URL}/verifications`); + }); + + it("gets the verification of a service by its encoded ID", async () => { + fetchStub.resolves(json(VERIFICATION)); + + const result = await provider.getServiceVerification("a/b c"); + + expect(result).to.deep.equal(VERIFICATION); + expect(fetchStub.firstCall.args[0]).to.equal( + `${BASE_URL}/services/a%2Fb%20c/verification` + ); + }); + + it("rejects with status 404 for unverified services", async () => { + fetchStub.resolves(json({ status: 404, title: "Not Found" }, 404)); + + const err = await rejection(() => + provider.getServiceVerification("service-1") + ); + + expect(err).to.be.instanceOf(CloudRegistryError); + expect((err as CloudRegistryError).status).to.equal(404); + }); + + it("verifies a service with the checked URL and API type", async () => { + fetchStub.resolves(json(VERIFICATION)); + const checked = { + url: VERIFICATION.url, + type: { group: "org.ga4gh", artifact: "tes", version: "1.1.0" }, + }; + + const result = await provider.verifyService("service-1", checked); + + expect(result).to.deep.equal(VERIFICATION); + const [url, init] = fetchStub.firstCall.args; + expect(url).to.equal(`${BASE_URL}/services/service-1/verification`); + expect(init.method).to.equal("PUT"); + expect(init.headers).to.deep.equal({ "Content-Type": "application/json" }); + expect(JSON.parse(init.body)).to.deep.equal(checked); + }); + + it("verifies a service as registered without a body", async () => { + fetchStub.resolves(json(VERIFICATION)); + + await provider.verifyService("service-1"); + + const [, init] = fetchStub.firstCall.args; + expect(init.method).to.equal("PUT"); + expect(init.body).to.equal(undefined); + expect(init.headers).to.equal(undefined); + }); + + it("keeps the conflict code of a mismatch", async () => { + fetchStub.resolves( + json( + { + status: 409, + title: "Conflict", + detail: "The service has another URL.", + code: "verification-mismatch", + }, + 409 + ) + ); + + const err = (await rejection(() => + provider.verifyService("service-1", { + url: "https://other.example.org", + type: SERVICE.type, + }) + )) as CloudRegistryError; + + expect(err.status).to.equal(409); + expect(err.problem?.code).to.equal("verification-mismatch"); + expect(err.message).to.equal("The service has another URL."); + }); + + it("removes the verification of a service", async () => { + fetchStub.resolves(json("service-1")); + + const result = await provider.deleteServiceVerification("service-1"); + + expect(result).to.equal("service-1"); + const [url, init] = fetchStub.firstCall.args; + expect(url).to.equal(`${BASE_URL}/services/service-1/verification`); + expect(init.method).to.equal("DELETE"); + }); +}); + +describe("matchesVerification", () => { + it("matches the same URL and API type", () => { + expect(matchesVerification(SERVICE, VERIFICATION)).to.equal(true); + }); + + it("ignores the version of the type", () => { + const service = { + ...SERVICE, + type: { ...SERVICE.type, version: "2.0.0" }, + }; + expect(matchesVerification(service, VERIFICATION)).to.equal(true); + }); + + it("compares group and artifact trimmed and case-insensitive", () => { + const service = { + ...SERVICE, + type: { + group: " ORG.GA4GH\u00a0\u0085", + artifact: "\u001cTES", + version: "1", + }, + }; + expect(matchesVerification(service, VERIFICATION)).to.equal(true); + }); + + it("folds the case of special characters", () => { + const service = { + ...SERVICE, + type: { group: "straße", artifact: "tes", version: "1" }, + }; + const verification = { + ...VERIFICATION, + type: { group: "STRASSE", artifact: "tes", version: "1" }, + }; + expect(matchesVerification(service, verification)).to.equal(true); + }); + + it("doesn't trim a byte order mark, as the registry doesn't", () => { + const service = { + ...SERVICE, + type: { ...SERVICE.type, artifact: "tes\uFEFF" }, + }; + expect(matchesVerification(service, VERIFICATION)).to.equal(false); + }); + + it("compares the URL exactly as written", () => { + const service = { ...SERVICE, url: `${VERIFICATION.url}/` }; + expect(matchesVerification(service, VERIFICATION)).to.equal(false); + }); + + it("rejects another artifact", () => { + const service = { + ...SERVICE, + type: { ...SERVICE.type, artifact: "wes" }, + }; + expect(matchesVerification(service, VERIFICATION)).to.equal(false); + }); +}); + +describe("verifiedServices", () => { + it("keeps the verifications that match their services, by ID", () => { + const other: ExternalService = { + ...SERVICE, + id: "service-2", + url: "https://wes.example.org", + }; + const verifications: ServiceVerification[] = [ + { serviceId: "service-1", ...VERIFICATION }, + { serviceId: "service-2", ...VERIFICATION }, + { serviceId: "unknown", ...VERIFICATION }, + ]; + + const result = verifiedServices([SERVICE, other], verifications); + + expect([...result.keys()]).to.deep.equal(["service-1"]); + expect(result.get("service-1")).to.equal(verifications[0]); + }); +}); diff --git a/packages/ecc-client-elixir-cloud-registry/src/providers/verifications.ts b/packages/ecc-client-elixir-cloud-registry/src/providers/verifications.ts new file mode 100644 index 00000000..721d3337 --- /dev/null +++ b/packages/ecc-client-elixir-cloud-registry/src/providers/verifications.ts @@ -0,0 +1,88 @@ +/** + * Which services are verified. A verification (from `GET /verifications` or + * `GET /services/{serviceId}/verification`) only counts while the service + * still has the verified URL (as written) and API type (`type.group` and + * `type.artifact`, trimmed and case-insensitive; not `type.version`), as the + * registry compares them when it verifies a service. The registry removes a + * verification when they change, but a list of services and a list of + * verifications loaded one after the other may disagree. + * + * The registry compares with Python's `strip().casefold()`: trimming here + * uses the same white space (JavaScript's `\s` without U+FEFF, plus + * U+001C-U+001F and U+0085), and case folding is approximated by upper- then + * lower-casing (e.g. `ß` and `SS` match), which JavaScript lacks a built-in + * for. + */ + +import type { + ExternalService, + ServiceType, + ServiceTypeRegister, + ServiceVerification, +} from "./cr-provider.js"; + +/** Whether a character is white space for Python's `str.strip()` */ +function isSpace(char: string): boolean { + return ( + (/\s/.test(char) && char !== "\uFEFF") || + "\x1c\x1d\x1e\x1f\x85".includes(char) + ); +} + +/** The text without leading and trailing white space, as Python strips it */ +function strip(text: string): string { + let start = 0; + let end = text.length; + while (start < end && isSpace(text[start])) start += 1; + while (end > start && isSpace(text[end - 1])) end -= 1; + return text.slice(start, end); +} + +/** Whether two texts are equal, trimmed and case-insensitive */ +function sameText(a: string, b: string): boolean { + const fold = (text: string) => strip(text).toUpperCase().toLowerCase(); + return fold(a) === fold(b); +} + +/** + * Whether a verification still matches a service: the same URL (as written) + * and API type (`group` and `artifact`) + * @param service URL and API type of the service, e.g. as registered + * @param verification URL and API type that were verified + * @returns `true` if they match + */ +export function matchesVerification( + service: { url: string; type: ServiceType | ServiceTypeRegister }, + verification: { url: string; type: ServiceType | ServiceTypeRegister } +): boolean { + return ( + service.url === verification.url && + sameText(service.type.group, verification.type.group) && + sameText(service.type.artifact, verification.type.artifact) + ); +} + +/** + * The verifications that still match their services, e.g. to show which + * services of a list are verified with one request for all + * @param services Services as registered (`GET /services`) + * @param verifications Verifications (`GET /verifications`) + * @returns The matching verifications by service ID; services without one + * aren't verified + */ +export function verifiedServices( + services: ExternalService[], + verifications: ServiceVerification[] +): Map { + const servicesById = new Map( + services.map((service) => [service.id, service]) + ); + return new Map( + verifications + .filter((verification) => { + const service = servicesById.get(verification.serviceId); + return !!service && matchesVerification(service, verification); + }) + .map((verification) => [verification.serviceId, verification]) + ); +} From 1c86934325306dfa99bc341ab2a3f61e487f49f0 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 08:18:56 +0100 Subject: [PATCH 02/10] feat(cloud-registry): add service verification Add ecc-client-elixir-cloud-registry-service-verification: a badge for verified services, and "Verify" and "Remove verification" for admins. It sends the URL and API type of the service shown as the ones checked, takes a verification the app has loaded already (e.g. for a list, from getVerifications()), reacts to a provider set after the first update, and fires change and failure events with the status and conflict code. Co-Authored-By: Claude Opus 5.5 --- .../docs/cloud-registry/components/_meta.json | 3 +- .../components/service-verification.mdx | 175 +++++++ .../src/components/index.ts | 1 + .../components/service-verification/index.ts | 15 + .../service-verification.ts | 399 ++++++++++++++++ .../tests/service-verification.test.ts | 441 ++++++++++++++++++ .../ecc-service-verification-changed.ts | 14 + .../events/ecc-service-verification-failed.ts | 27 ++ .../src/events/index.ts | 2 + 9 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx create mode 100644 packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts create mode 100644 packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts create mode 100644 packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts create mode 100644 packages/ecc-client-elixir-cloud-registry/src/events/ecc-service-verification-changed.ts create mode 100644 packages/ecc-client-elixir-cloud-registry/src/events/ecc-service-verification-failed.ts diff --git a/apps/documentation/pages/docs/cloud-registry/components/_meta.json b/apps/documentation/pages/docs/cloud-registry/components/_meta.json index 79878531..b54e156f 100644 --- a/apps/documentation/pages/docs/cloud-registry/components/_meta.json +++ b/apps/documentation/pages/docs/cloud-registry/components/_meta.json @@ -1,3 +1,4 @@ { - "service-create": "Service Create" + "service-create": "Service Create", + "service-verification": "Service Verification" } \ No newline at end of file diff --git a/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx new file mode 100644 index 00000000..f9da8bf1 --- /dev/null +++ b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx @@ -0,0 +1,175 @@ +import { Callout, Tabs } from 'nextra/components' +import ComponentName from '@/components/common/ComponentName' + +# EccClientElixirCloudRegistryServiceVerification Component + + + +## Overview + +The `ecc-client-elixir-cloud-registry-service-verification` component shows whether a service in the Cloud Registry is verified, i.e. whether an admin of the registry checked it (e.g. that it accepts the tokens of the deployment's identity provider). Verified services get a "Verified" badge; its tooltip says when. For admins, the component also offers "Verify" and "Remove verification". + +It uses the verification endpoints of Cloud Registry 0.4.0 and later (see [Verifications](/docs/cloud-registry/providers#verifications)): + +- `GET /services/{serviceId}/verification` to load the verification of the service; +- `PUT /services/{serviceId}/verification` to verify it (admins only); +- `DELETE /services/{serviceId}/verification` to remove its verification (admins only). + + + The registry removes a verification when the URL or API type of the service changes. Verified doesn't mean trusted: users decide which services to use. + + +## Usage + +### One service + +The component loads the verification of the service and shows the badge if the service is verified; otherwise it shows nothing. Pass the service with `service`, as the app shows it: the badge then only shows while the verification matches the service's URL and API type, and "Verify" sends them to the registry as the ones that the admin checked, so that a service that has changed meanwhile isn't verified. + + + + ```tsx showLineNumbers copy filename="service-verification.tsx" + import React, { useMemo } from 'react'; + import { EccClientElixirCloudRegistryServiceVerification } from '@elixir-cloud/cloud-registry/react/service-verification'; + import { RestCloudRegistryProvider } from '@elixir-cloud/cloud-registry/providers'; + + export default function ServiceHeader({ service, me }) { + // created once: a new provider makes the component load again + const provider = useMemo( + () => new RestCloudRegistryProvider('https://cloud-registry.example.com/ga4gh/registry/v1'), + [] + ); + + return ( +

+ {service.name}{' '} + console.error(event.detail.error)} + /> +

+ ); + } + ``` +
+ + ```html showLineNumbers copy filename="service-verification.html" + + + + ``` + +
+ +### A list of services + +For a list, load all verifications with one request (`getVerifications()`), keep those that still match their services (`verifiedServices()`), and pass each row's verification with `verification`: the rows then send no requests of their own. `null` means that the service isn't verified. + +```tsx copy +import { verifiedServices } from '@elixir-cloud/cloud-registry/providers'; + +const [services, verifications] = await Promise.all([ + provider.getServices(), + provider.getVerifications(), +]); +const verified = verifiedServices(services, verifications); + +services.map((service) => ( + +)); +``` + + + The services list of the GA4GH Service Registry package (`ecc-client-ga4gh-service-registry-services`) doesn't show verifications itself; place the component in a row's `actions-` slot or in your own list. + + +## Properties + +| Property | Type | Default | Description | +|----------------|-------------------------|-------------|-------------| +| `baseUrl` | `string` | `""` | Base URL of the Cloud Registry API, if no `provider` is set | +| `provider` | `CloudRegistryProvider` | `undefined` | Data provider (see [Providers](/docs/cloud-registry/providers)); takes precedence over `baseUrl` | +| `serviceId` | `string` | `""` | ID of the service (attribute `service-id`); defaults to the ID of `service` | +| `service` | `ExternalService` | `undefined` | The service as the app shows it: the badge only shows while the verification matches its URL and API type, and "Verify" sends them as checked | +| `verification` | `Verification \| null` | `undefined` | The verification if the app has loaded it (`null`: not verified); if `undefined`, the component loads it | +| `admin` | `boolean` | `false` | Whether to offer "Verify" and "Remove verification" | + +### `provider` and `baseUrl` +The component loads the verification again when `provider`, `baseUrl`, `serviceId`, `verification` or the ID of `service` changes, also if the app sets them after the first render. A new object of the same service (e.g. of a reloaded list) needs no new request: the component compares the verification shown with it. In React, create the provider only when its inputs change, e.g. with `useMemo`. + +Custom providers implement the optional verification methods of `CloudRegistryProvider`; without them, the component fires `ecc-service-verification-failed`. + +### `service` +Without `service`, the component shows the registry's verification as it is, and "Verify" verifies the service as it is registered at the time of the request. With `service`, a verification of another URL (compared as written) or API type (`type.group` and `type.artifact`, trimmed and case-insensitive, as the registry compares them) doesn't count. + +### `admin` +Apps take the value from `admin` of the registry's `GET /me`. The property only changes what the component offers: the registry checks permissions itself and answers other users with `403`. For admins, unverified services show a "Not verified" badge and a "Verify" button; verified services show "Remove verification". + +## Events + +| Event Name | React Event Name | Detail Type | Description | +|------------------------------------|------------------------------------|-------------|-------------| +| `ecc-service-verification-changed` | `onEccServiceVerificationChanged` | `{ serviceId: string, verification: Verification \| null }` | Fired when an admin verified the service or removed its verification | +| `ecc-service-verification-failed` | `onEccServiceVerificationFailed` | `{ serviceId: string, action: "load" \| "verify" \| "remove", error: string, status?: number, title?: string, detail?: string, code?: string, cause?: unknown }` | Fired when loading, verifying or removing the verification fails | + +### `ecc-service-verification-changed` +Emitted after an admin verified the service (`verification`: the new verification) or removed its verification (`null`). A verification that was removed already (`404`) counts as removed. Apps that pass `verification` update it from this event, e.g. in their map of verified services. + +### `ecc-service-verification-failed` +Emitted when a request fails. Services that aren't verified (`404` when loading) are no failure. + +- `action`: what failed: `load`, `verify` or `remove`; +- `error`: what went wrong, e.g. the registry's explanation (the problem's `detail`); +- `status`: the HTTP status, e.g. `403` (not an admin) or `409`; +- `title`, `detail`: the problem details that the registry sent, if any; +- `code`: the conflict code of a `409`: `verification-mismatch` if the service has another URL or API type than the one shown (the component then loads the verification again, unless the app passes `verification`), `pending-service` if the service awaits approval; +- `cause`: the error that the provider threw. + + + + ```tsx copy + { + if (event.detail.code === 'verification-mismatch') { + alert('The service has changed. Reload it and check it again.'); + } else { + alert(event.detail.error); + } + }} + /> + ``` + + + ```javascript copy + element.addEventListener('ecc-service-verification-failed', (event) => { + console.error(event.detail.action, event.detail.status, event.detail.error); + }); + ``` + + + +## CSS parts + +| Part | Description | +|-----------------|-------------| +| `badge` | The "Verified" (for admins also "Not verified") badge | +| `verify-button` | The "Verify" button (admins) | +| `remove-button` | The "Remove verification" button (admins) | diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/index.ts b/packages/ecc-client-elixir-cloud-registry/src/components/index.ts index 42b8556d..81d865e2 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/index.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/index.ts @@ -1 +1,2 @@ import "./service-create/index.js"; +import "./service-verification/index.js"; diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts new file mode 100644 index 00000000..eca23e57 --- /dev/null +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts @@ -0,0 +1,15 @@ +import ECCClientElixirCloudRegistryServiceVerification from "./service-verification.js"; + +export * from "./service-verification.js"; +export default ECCClientElixirCloudRegistryServiceVerification; + +window.customElements.define( + "ecc-client-elixir-cloud-registry-service-verification", + ECCClientElixirCloudRegistryServiceVerification +); + +declare global { + interface HTMLElementTagNameMap { + "ecc-client-elixir-cloud-registry-service-verification": ECCClientElixirCloudRegistryServiceVerification; + } +} diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts new file mode 100644 index 00000000..fe17b35e --- /dev/null +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts @@ -0,0 +1,399 @@ +import { LitElement, html, css, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; +import { GlobalStyles } from "../../global.js"; +import { RestCloudRegistryProvider } from "../../providers/rest-cr-provider.js"; +import { + CloudRegistryProvider, + ExternalService, + Verification, +} from "../../providers/index.js"; +import { matchesVerification } from "../../providers/verifications.js"; +import { nonBlankString } from "../../providers/errors.js"; +import type { EccServiceVerificationFailedEvent } from "../../events/index.js"; +import "@elixir-cloud/design/components/badge/index.js"; +import "@elixir-cloud/design/components/button/index.js"; + +type FailureDetail = EccServiceVerificationFailedEvent["detail"]; + +/** Status of an error that a provider threw, if it has one */ +const statusOf = (err: unknown): number | undefined => { + const status = + err && typeof err === "object" + ? (err as Record).status + : undefined; + return typeof status === "number" ? status : undefined; +}; + +/** + * Detail of `ecc-service-verification-failed` for an error that a provider + * threw: its message, and the status and problem details if it has them. + * They are read from the error's fields, as `CloudRegistryError` has them + * (`code` from its `problem`), so that errors of custom providers with the + * same fields work too. + * @param serviceId ID of the service + * @param action What failed + * @param err The error + * @param fallback Message if the error has none + * @returns The event's detail + */ +const failureDetail = ( + serviceId: string, + action: FailureDetail["action"], + err: unknown, + fallback: string +): FailureDetail => { + const fields = + err && typeof err === "object" ? (err as Record) : {}; + const problem = + fields.problem && typeof fields.problem === "object" + ? (fields.problem as Record) + : {}; + const detail = nonBlankString(fields.detail); + return { + serviceId, + action, + error: + nonBlankString(fields.message) ?? + detail ?? + nonBlankString(err) ?? + fallback, + status: statusOf(err), + title: nonBlankString(fields.title), + detail, + code: nonBlankString(fields.code) ?? nonBlankString(problem.code), + cause: err, + }; +}; + +/** Date of a verification for people, e.g. "20 Oct 2026"; else as sent */ +const formatDate = (value: string): string => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +}; + +/** + * @summary Shows whether a service in Cloud Registry is verified, and lets admins verify it or remove its verification + * @since 2.0.0 + * + * @property {string} baseUrl - Base URL of the Cloud Registry instance/gateway + * @property {CloudRegistryProvider} provider - Custom data provider (optional, overrides baseUrl); replacing it loads the verification again + * @property {string} serviceId - ID of the service + * @property {ExternalService} service - The service as the app shows it (optional): a verification only counts while it matches the service's URL and API type, and "Verify" sends them as checked + * @property {Verification | null} verification - The verification, if the app has loaded it already (optional; `null`: not verified), e.g. from `getVerifications()` for a list; else the component loads it + * @property {boolean} admin - Whether to offer "Verify" and "Remove verification" (for admins of the registry); default `false` + * + * @fires ecc-service-verification-changed - Fired when an admin verified the service or removed its verification (includes the service ID and the new verification, or `null`) + * @fires ecc-service-verification-failed - Fired when loading, verifying or removing the verification fails (includes the action, the error message and, if the registry sent them, the status, problem details and conflict code) + * + * @csspart badge - The "Verified" (or, for admins, "Not verified") badge + * @csspart verify-button - The "Verify" button (admins) + * @csspart remove-button - The "Remove verification" button (admins) + */ +export class ECCClientElixirCloudRegistryServiceVerification extends LitElement { + static styles = [ + TailwindStyles, + GlobalStyles, + css` + :host { + display: inline-block; + } + `, + ]; + + @property({ type: String, reflect: true }) baseUrl = ""; + @property({ attribute: false }) provider?: CloudRegistryProvider; + + /** ID of the service; defaults to the ID of `service` */ + @property({ type: String, attribute: "service-id" }) serviceId = ""; + + /** + * The service as the app shows it. A verification only counts while it + * matches the service's URL (as written) and API type (`group`, + * `artifact`), as the registry compares them; "Verify" sends them as the + * ones that the admin checked, so that a service that changed meanwhile + * isn't verified (`409`). Without it, the component trusts the registry's + * verification and "Verify" verifies the service as it is registered. + */ + @property({ attribute: false }) service?: ExternalService; + + /** + * The verification, if the app has loaded it already: e.g. from + * `getVerifications()` (with `verifiedServices()`) for a list of + * services, so that the rows need no request each. `null` means not + * verified; `undefined` (the default) makes the component load it. + */ + @property({ attribute: false }) verification?: Verification | null; + + /** + * Whether to offer "Verify" and "Remove verification". Apps set it from + * `admin` of the registry's `GET /me`; the registry checks permissions + * itself. + */ + @property({ type: Boolean }) admin = false; + + // The verification shown: `undefined` while it loads or if loading failed + @state() private current?: Verification | null; + // Whether a verify or remove request is running + @state() private busy = false; + + /** The provider for `baseUrl`, used if `provider` isn't set. */ + private restProvider: RestCloudRegistryProvider | null = null; + + // Number of the latest load, verify or remove, so that older answers are + // ignored (e.g. of a previous service or provider) + private request = 0; + + /** ID of the service: `serviceId`, else the ID of `service`. */ + private get targetId(): string { + return this.serviceId || this.service?.id || ""; + } + + /** Whether the service is verified, as far as is known. */ + private get verified(): boolean { + return Boolean( + this.current && + (!this.service || matchesVerification(this.service, this.current)) + ); + } + + protected updated(changedProperties: Map): void { + // a new object of the same service (e.g. of a reloaded list) needs no + // new request: the verification shown is compared with it on render + const previous = changedProperties.get("service") as + | ExternalService + | undefined; + if ( + changedProperties.has("provider") || + changedProperties.has("baseUrl") || + changedProperties.has("serviceId") || + changedProperties.has("verification") || + (changedProperties.has("service") && previous?.id !== this.service?.id) + ) { + this.load(); + } + } + + /** + * The provider to use now: `provider`, else a REST provider for `baseUrl` + * (also right after an app set them, before the component updated). + */ + private currentProvider(): CloudRegistryProvider | null { + if (this.baseUrl && this.restProvider?.baseUrl !== this.baseUrl) { + this.restProvider = new RestCloudRegistryProvider(this.baseUrl); + } else if (!this.baseUrl) { + this.restProvider = null; + } + return this.provider ?? this.restProvider; + } + + private fail(detail: FailureDetail): void { + this.dispatchEvent( + new CustomEvent("ecc-service-verification-failed", { + detail, + bubbles: true, + composed: true, + }) + ); + } + + /** + * Shows `verification` if the app set it, else loads the verification of + * the service; services that aren't verified (`404`) show none. + */ + private async load(): Promise { + this.request += 1; + const { request } = this; + const id = this.targetId; + this.busy = false; + if (this.verification !== undefined) { + this.current = this.verification; + return; + } + this.current = undefined; + const provider = this.currentProvider(); + if (!provider || !id) return; + if (!provider.getServiceVerification) { + this.fail({ + serviceId: id, + action: "load", + error: "The provider doesn't support verifications.", + }); + return; + } + try { + const verification = await provider.getServiceVerification(id); + if (request !== this.request) return; + this.current = verification; + } catch (err) { + if (request !== this.request) return; + if (statusOf(err) === 404) { + this.current = null; + return; + } + this.fail( + failureDetail(id, "load", err, "Failed to load the verification") + ); + } + } + + /** + * Verifies the service (admins), with the URL and API type of `service` + * as the ones checked. If the service has changed meanwhile (`409`), the + * verification is loaded again. + */ + private async verifyService(): Promise { + const provider = this.currentProvider(); + const { targetId: id, service } = this; + if (!provider || !id || this.busy) return; + if (!provider.verifyService) { + this.fail({ + serviceId: id, + action: "verify", + error: "The provider doesn't support verifications.", + }); + return; + } + this.request += 1; + const { request } = this; + this.busy = true; + try { + const verification = await provider.verifyService( + id, + service + ? { + url: service.url, + type: { + group: service.type.group, + artifact: service.type.artifact, + version: service.type.version, + }, + } + : undefined + ); + if (request !== this.request) return; + this.busy = false; + this.current = verification; + this.changed(id, verification); + } catch (err) { + if (request !== this.request) return; + this.busy = false; + this.fail(failureDetail(id, "verify", err, "Failed to verify service")); + if (statusOf(err) === 409 && this.verification === undefined) { + this.load(); + } + } + } + + /** + * Removes the verification of the service (admins); one that is gone + * already (`404`) counts as removed. + */ + private async removeVerification(): Promise { + const provider = this.currentProvider(); + const id = this.targetId; + if (!provider || !id || this.busy) return; + if (!provider.deleteServiceVerification) { + this.fail({ + serviceId: id, + action: "remove", + error: "The provider doesn't support verifications.", + }); + return; + } + this.request += 1; + const { request } = this; + this.busy = true; + try { + await provider.deleteServiceVerification(id); + } catch (err) { + if (request !== this.request) return; + this.busy = false; + if (statusOf(err) !== 404) { + this.fail( + failureDetail(id, "remove", err, "Failed to remove verification") + ); + return; + } + } + if (request !== this.request) return; + this.busy = false; + this.current = null; + this.changed(id, null); + } + + private changed(serviceId: string, verification: Verification | null) { + this.dispatchEvent( + new CustomEvent("ecc-service-verification-changed", { + detail: { serviceId, verification }, + bubbles: true, + composed: true, + }) + ); + } + + render() { + // nothing while loading, and nothing else for unverified services + // unless the user is an admin + const { current, verified } = this; + if (current === undefined) return nothing; + if (!verified && !this.admin) return nothing; + + const badge = verified + ? html` + + Verified + ` + : html` + Not verified + `; + + if (!this.admin) return badge; + + return html` +
+ ${badge} + ${verified + ? html` + ${this.busy ? "Removing..." : "Remove verification"} + ` + : html` + ${this.busy ? "Verifying..." : "Verify"} + `} +
+ `; + } +} + +export default ECCClientElixirCloudRegistryServiceVerification; diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts new file mode 100644 index 00000000..97025f50 --- /dev/null +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts @@ -0,0 +1,441 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, oneEvent, waitUntil } from "@open-wc/testing"; +import sinon from "sinon"; +import "../index.js"; +import type ECCClientElixirCloudRegistryServiceVerification from "../service-verification.js"; +import type { + EccServiceVerificationChangedEvent, + EccServiceVerificationFailedEvent, +} from "../../../events/index.js"; +import { + CloudRegistryError, + CloudRegistryProvider, + ExternalService, + RestCloudRegistryProvider, +} from "../../../providers/index.js"; + +const BASE_URL = "https://registry.example.org/ga4gh/registry/v1"; + +const VERIFICATION = { + url: "https://tes.example.org/ga4gh/tes/v1", + type: { group: "org.ga4gh", artifact: "tes", version: "1.1.0" }, + verifiedAt: "2026-10-20T12:34:56.789Z", +}; + +const SERVICE: ExternalService = { + id: "service-1", + name: "My TES", + url: VERIFICATION.url, + type: { group: "org.ga4gh", artifact: "tes", version: "1.1.0" }, + organization: { name: "Example", url: "https://example.org" }, + version: "1.0.0", +}; + +type El = ECCClientElixirCloudRegistryServiceVerification; + +const part = (el: El, name: string) => + el.shadowRoot?.querySelector(`[part="${name}"]`) as HTMLElement | null; + +/** Clicks the native button inside a design button, as a user would. */ +const click = (button: HTMLElement | null) => { + const inner = button?.shadowRoot?.querySelector("button"); + if (!inner) throw new Error("No button to click."); + inner.click(); +}; + +/** Waits until the component has finished loading. */ +const settled = async (el: El) => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + await el.updateComplete; +}; + +const conflict = () => + new CloudRegistryError("The service has another URL.", 409, { + status: 409, + title: "Conflict", + detail: "The service has another URL.", + code: "verification-mismatch", + }); + +const notFound = () => new CloudRegistryError("Not found", 404, {}); + +/** A provider with stubbed verification methods. */ +const stubProvider = () => { + const provider = new RestCloudRegistryProvider(BASE_URL); + return { + provider, + get: sinon.stub(provider, "getServiceVerification"), + verify: sinon.stub(provider, "verifyService"), + remove: sinon.stub(provider, "deleteServiceVerification"), + }; +}; + +describe("ecc-client-elixir-cloud-registry-service-verification", () => { + afterEach(() => { + sinon.restore(); + }); + + it("shows a badge for a verified service, without actions", async () => { + const { provider, get } = stubProvider(); + get.resolves(VERIFICATION); + + const el = await fixture(html` + + `); + await settled(el); + + expect(get.calledOnceWithExactly("service-1")).to.equal(true); + const badge = part(el, "badge"); + expect(badge?.textContent?.trim()).to.equal("Verified"); + expect(badge?.getAttribute("title")).to.match(/^Verified on .*2026/); + expect(part(el, "verify-button")).to.equal(null); + expect(part(el, "remove-button")).to.equal(null); + }); + + it("loads with a base URL", async () => { + const fetchStub = sinon.stub(window, "fetch").resolves( + new Response(JSON.stringify(VERIFICATION), { + headers: { "Content-Type": "application/json" }, + }) + ); + + const el = await fixture(html` + + `); + await waitUntil(() => part(el, "badge")); + + expect(fetchStub.firstCall.args[0]).to.equal( + `${BASE_URL}/services/a%2Fb/verification` + ); + }); + + it("shows nothing for an unverified service, without an error", async () => { + const { provider, get } = stubProvider(); + get.rejects(notFound()); + const failed = sinon.spy(); + + const el = await fixture(html` + + `); + await settled(el); + + expect(part(el, "badge")).to.equal(null); + expect(failed.called).to.equal(false); + }); + + it("uses a verification that the app loaded, without a request", async () => { + const { provider, get } = stubProvider(); + + const el = await fixture(html` + + `); + await settled(el); + + expect(get.called).to.equal(false); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Verified"); + + el.verification = null; + await settled(el); + + expect(get.called).to.equal(false); + expect(part(el, "badge")).to.equal(null); + }); + + it("doesn't count a verification of another URL", async () => { + const { provider, get } = stubProvider(); + get.resolves(VERIFICATION); + + const el = await fixture(html` + + `); + await settled(el); + + expect(get.calledOnceWithExactly("service-1")).to.equal(true); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Not verified"); + expect(part(el, "verify-button")).to.not.equal(null); + }); + + it("needs no new request for a new object of the same service", async () => { + const { provider, get } = stubProvider(); + get.resolves(VERIFICATION); + const el = await fixture(html` + + `); + await settled(el); + + el.service = { ...SERVICE, url: "https://tes.example.org/v2" }; + await settled(el); + + expect(get.calledOnce).to.equal(true); + expect(part(el, "badge")).to.equal(null); + }); + + it("lets admins verify a service with its URL and API type", async () => { + const { provider, get, verify } = stubProvider(); + get.rejects(notFound()); + verify.resolves(VERIFICATION); + const el = await fixture(html` + + `); + await settled(el); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Not verified"); + + const changed = oneEvent(el, "ecc-service-verification-changed"); + click(part(el, "verify-button")); + const { detail } = (await changed) as EccServiceVerificationChangedEvent; + await el.updateComplete; + + expect( + verify.calledOnceWithExactly("service-1", { + url: SERVICE.url, + type: SERVICE.type, + }) + ).to.equal(true); + expect(detail).to.deep.equal({ + serviceId: "service-1", + verification: VERIFICATION, + }); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Verified"); + expect(part(el, "remove-button")).to.not.equal(null); + }); + + it("verifies a service as registered without `service`", async () => { + const { provider, get, verify } = stubProvider(); + get.rejects(notFound()); + verify.resolves(VERIFICATION); + const el = await fixture(html` + + `); + await settled(el); + + const changed = oneEvent(el, "ecc-service-verification-changed"); + click(part(el, "verify-button")); + await changed; + + expect(verify.calledOnceWithExactly("service-1", undefined)).to.equal(true); + }); + + it("reports a mismatch and loads the verification again", async () => { + const { provider, get, verify } = stubProvider(); + get.rejects(notFound()); + verify.rejects(conflict()); + const el = await fixture(html` + + `); + await settled(el); + + const failed = oneEvent(el, "ecc-service-verification-failed"); + click(part(el, "verify-button")); + const { detail } = (await failed) as EccServiceVerificationFailedEvent; + await settled(el); + + expect(detail.serviceId).to.equal("service-1"); + expect(detail.action).to.equal("verify"); + expect(detail.error).to.equal("The service has another URL."); + expect(detail.status).to.equal(409); + expect(detail.title).to.equal("Conflict"); + expect(detail.code).to.equal("verification-mismatch"); + expect(detail.cause).to.be.instanceOf(CloudRegistryError); + expect(get.calledTwice).to.equal(true); + }); + + it("lets admins remove a verification", async () => { + const { provider, get, remove } = stubProvider(); + get.resolves(VERIFICATION); + remove.resolves("service-1"); + const el = await fixture(html` + + `); + await settled(el); + + const changed = oneEvent(el, "ecc-service-verification-changed"); + click(part(el, "remove-button")); + const { detail } = (await changed) as EccServiceVerificationChangedEvent; + await el.updateComplete; + + expect(remove.calledOnceWithExactly("service-1")).to.equal(true); + expect(detail).to.deep.equal({ + serviceId: "service-1", + verification: null, + }); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Not verified"); + expect(part(el, "verify-button")).to.not.equal(null); + }); + + it("counts a verification that is gone already as removed", async () => { + const { provider, get, remove } = stubProvider(); + get.resolves(VERIFICATION); + remove.rejects(notFound()); + const failed = sinon.spy(); + const el = await fixture(html` + + `); + await settled(el); + + const changed = oneEvent(el, "ecc-service-verification-changed"); + click(part(el, "remove-button")); + const { detail } = (await changed) as EccServiceVerificationChangedEvent; + + expect(detail.verification).to.equal(null); + expect(failed.called).to.equal(false); + }); + + it("reports a failed removal and keeps the verification", async () => { + const { provider, get, remove } = stubProvider(); + get.resolves(VERIFICATION); + remove.rejects(new CloudRegistryError("Admins only.", 403, {})); + const el = await fixture(html` + + `); + await settled(el); + + const failed = oneEvent(el, "ecc-service-verification-failed"); + click(part(el, "remove-button")); + const { detail } = (await failed) as EccServiceVerificationFailedEvent; + await el.updateComplete; + + expect(detail.action).to.equal("remove"); + expect(detail.status).to.equal(403); + expect(detail.error).to.equal("Admins only."); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Verified"); + }); + + it("reports a failed load", async () => { + const { provider, get } = stubProvider(); + get.rejects(new CloudRegistryError("Server error", 500, {})); + const el = document.createElement( + "ecc-client-elixir-cloud-registry-service-verification" + ); + el.serviceId = "service-1"; + el.provider = provider; + + const failed = oneEvent(el, "ecc-service-verification-failed"); + document.body.appendChild(el); + try { + const { detail } = (await failed) as EccServiceVerificationFailedEvent; + + expect(detail.action).to.equal("load"); + expect(detail.status).to.equal(500); + expect(detail.error).to.equal("Server error"); + expect(part(el, "badge")).to.equal(null); + } finally { + el.remove(); + } + }); + + it("reports providers without verifications", async () => { + const provider = { + getServiceInfo: sinon.stub(), + } as unknown as CloudRegistryProvider; + const el = document.createElement( + "ecc-client-elixir-cloud-registry-service-verification" + ); + el.serviceId = "service-1"; + el.provider = provider; + + const failed = oneEvent(el, "ecc-service-verification-failed"); + document.body.appendChild(el); + try { + const { detail } = (await failed) as EccServiceVerificationFailedEvent; + + expect(detail.error).to.equal( + "The provider doesn't support verifications." + ); + } finally { + el.remove(); + } + }); + + it("loads once a provider is set after the first update", async () => { + const { provider, get } = stubProvider(); + get.resolves(VERIFICATION); + const el = await fixture(html` + + `); + await settled(el); + expect(part(el, "badge")).to.equal(null); + + el.provider = provider; + await settled(el); + + expect(get.calledOnceWithExactly("service-1")).to.equal(true); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Verified"); + }); + + it("ignores the answer for a previous service", async () => { + const { provider, get } = stubProvider(); + let answerFirst = (value: typeof VERIFICATION): void => { + throw new Error(`No request waits for ${value.url}`); + }; + get.onFirstCall().returns( + new Promise((resolve) => { + answerFirst = resolve; + }) + ); + get.onSecondCall().rejects(notFound()); + const el = await fixture(html` + + `); + + el.serviceId = "service-2"; + await settled(el); + answerFirst(VERIFICATION); + await settled(el); + + expect(get.secondCall.args[0]).to.equal("service-2"); + expect(part(el, "badge")).to.equal(null); + }); +}); diff --git a/packages/ecc-client-elixir-cloud-registry/src/events/ecc-service-verification-changed.ts b/packages/ecc-client-elixir-cloud-registry/src/events/ecc-service-verification-changed.ts new file mode 100644 index 00000000..67d3db74 --- /dev/null +++ b/packages/ecc-client-elixir-cloud-registry/src/events/ecc-service-verification-changed.ts @@ -0,0 +1,14 @@ +import { Verification } from "../providers/index.js"; + +export type EccServiceVerificationChangedEvent = CustomEvent<{ + /** ID of the service. */ + serviceId: string; + /** The new verification, or `null` if it was removed. */ + verification: Verification | null; +}>; + +declare global { + interface GlobalEventHandlersEventMap { + "ecc-service-verification-changed": EccServiceVerificationChangedEvent; + } +} diff --git a/packages/ecc-client-elixir-cloud-registry/src/events/ecc-service-verification-failed.ts b/packages/ecc-client-elixir-cloud-registry/src/events/ecc-service-verification-failed.ts new file mode 100644 index 00000000..3c5ed2e6 --- /dev/null +++ b/packages/ecc-client-elixir-cloud-registry/src/events/ecc-service-verification-failed.ts @@ -0,0 +1,27 @@ +export type EccServiceVerificationFailedEvent = CustomEvent<{ + /** ID of the service. */ + serviceId: string; + /** What failed: loading, verifying or removing the verification. */ + action: "load" | "verify" | "remove"; + /** What went wrong, e.g. the registry's explanation (problem `detail`). */ + error: string; + /** HTTP status of the failed request, e.g. `403` or `409`, if any. */ + status?: number; + /** Problem `title` of the failed request, if any. */ + title?: string; + /** Problem `detail` of the failed request, if any. */ + detail?: string; + /** + * Problem `code` of a conflict (`409`), if any, e.g. + * `verification-mismatch` or `pending-service`. + */ + code?: string; + /** The error that the provider threw, if any. */ + cause?: unknown; +}>; + +declare global { + interface GlobalEventHandlersEventMap { + "ecc-service-verification-failed": EccServiceVerificationFailedEvent; + } +} diff --git a/packages/ecc-client-elixir-cloud-registry/src/events/index.ts b/packages/ecc-client-elixir-cloud-registry/src/events/index.ts index ec6589c9..fb24aee0 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/events/index.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/events/index.ts @@ -2,3 +2,5 @@ export type { EccServiceCreatedEvent } from "./ecc-service-created.js"; export type { EccServiceCreateFailedEvent } from "./ecc-service-create-failed.js"; export type { EccServiceCreateValidationFailedEvent } from "./ecc-service-create-validation-failed.js"; export type { EccServiceFormChangedEvent } from "./ecc-service-form-changed.js"; +export type { EccServiceVerificationChangedEvent } from "./ecc-service-verification-changed.js"; +export type { EccServiceVerificationFailedEvent } from "./ecc-service-verification-failed.js"; From 98b06daeb343116fd681338b2331f922516825d5 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 09:11:05 +0100 Subject: [PATCH 03/10] fix(cloud-registry): expose the conflict code Add code to CloudRegistryError, so that apps and the verification component read the conflict code of a 409 without digging into the problem. Test the component's busy state, races and failures of verify and remove, and link it from the package introduction. Co-Authored-By: Claude Opus 5.5 --- .../docs/cloud-registry/introduction.mdx | 1 + .../pages/docs/cloud-registry/providers.mdx | 1 + .../service-verification.ts | 15 +- .../tests/service-verification.test.ts | 160 ++++++++++++++++++ .../src/providers/errors.ts | 7 + .../src/providers/tests/verifications.test.ts | 2 +- 6 files changed, 176 insertions(+), 10 deletions(-) diff --git a/apps/documentation/pages/docs/cloud-registry/introduction.mdx b/apps/documentation/pages/docs/cloud-registry/introduction.mdx index 990a5bfc..0be6e24c 100644 --- a/apps/documentation/pages/docs/cloud-registry/introduction.mdx +++ b/apps/documentation/pages/docs/cloud-registry/introduction.mdx @@ -20,6 +20,7 @@ The `Cloud Registry` package provides web components for managing and creating s ## Available Components - [``](./components/service-create): A component for creating new service registrations. +- [``](./components/service-verification): A component that shows whether a service is verified, and lets admins verify it or remove its verification. - [``](../service-registry/components/services): A component for listing existing service registrations. - [``](../service-registry/components/service): A component for displaying details of a specific service. diff --git a/apps/documentation/pages/docs/cloud-registry/providers.mdx b/apps/documentation/pages/docs/cloud-registry/providers.mdx index 1d638bc9..7ee7ca95 100644 --- a/apps/documentation/pages/docs/cloud-registry/providers.mdx +++ b/apps/documentation/pages/docs/cloud-registry/providers.mdx @@ -214,6 +214,7 @@ If the registry answers with an error status, the methods of `RestCloudRegistryP | `title` | `string \| undefined` | Short summary of the problem type, e.g. "Conflict" | | `detail` | `string \| undefined` | Explanation of this occurrence, e.g. which field is invalid | | `serviceId` | `string \| undefined` | ID of the existing service, for duplicate registrations (`409`) | +| `code` | `string \| undefined` | Why a request conflicts (`409`), e.g. `verification-mismatch`; treat unknown codes as a general conflict | | `problem` | `ProblemDetails \| undefined` | The whole response body, if it was a JSON object | ```typescript diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts index fe17b35e..69417f36 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts @@ -28,9 +28,8 @@ const statusOf = (err: unknown): number | undefined => { /** * Detail of `ecc-service-verification-failed` for an error that a provider * threw: its message, and the status and problem details if it has them. - * They are read from the error's fields, as `CloudRegistryError` has them - * (`code` from its `problem`), so that errors of custom providers with the - * same fields work too. + * They are read from the error's fields, as `CloudRegistryError` has them, + * so that errors of custom providers with the same fields work too. * @param serviceId ID of the service * @param action What failed * @param err The error @@ -45,10 +44,6 @@ const failureDetail = ( ): FailureDetail => { const fields = err && typeof err === "object" ? (err as Record) : {}; - const problem = - fields.problem && typeof fields.problem === "object" - ? (fields.problem as Record) - : {}; const detail = nonBlankString(fields.detail); return { serviceId, @@ -61,7 +56,7 @@ const failureDetail = ( status: statusOf(err), title: nonBlankString(fields.title), detail, - code: nonBlankString(fields.code) ?? nonBlankString(problem.code), + code: nonBlankString(fields.code), cause: err, }; }; @@ -348,7 +343,9 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement ? html` { expect(get.secondCall.args[0]).to.equal("service-2"); expect(part(el, "badge")).to.equal(null); }); + + it("verifies once while a request runs, and shows that it runs", async () => { + const { provider, get, verify } = stubProvider(); + get.rejects(notFound()); + let answer = (value: typeof VERIFICATION): void => { + throw new Error(`No request waits for ${value.url}`); + }; + verify.returns( + new Promise((resolve) => { + answer = resolve; + }) + ); + const el = await fixture(html` + + `); + await settled(el); + + const button = part(el, "verify-button"); + button?.click(); + await el.updateComplete; + button?.click(); + await el.updateComplete; + + expect(verify.calledOnce).to.equal(true); + expect(button?.textContent?.trim()).to.equal("Verifying..."); + expect(button?.hasAttribute("disabled")).to.equal(true); + + const changed = oneEvent(el, "ecc-service-verification-changed"); + answer(VERIFICATION); + await changed; + await el.updateComplete; + + expect(part(el, "remove-button")?.hasAttribute("disabled")).to.equal(false); + }); + + it("ignores a verify answer once the service changed", async () => { + const { provider, get, verify } = stubProvider(); + get.rejects(notFound()); + let answer = (value: typeof VERIFICATION): void => { + throw new Error(`No request waits for ${value.url}`); + }; + verify.returns( + new Promise((resolve) => { + answer = resolve; + }) + ); + const changed = sinon.spy(); + const el = await fixture(html` + + `); + await settled(el); + + click(part(el, "verify-button")); + el.serviceId = "service-2"; + await settled(el); + answer(VERIFICATION); + await settled(el); + + expect(changed.called).to.equal(false); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Not verified"); + }); + + it("reports a failed verify without loading again", async () => { + const { provider, get, verify } = stubProvider(); + get.rejects(notFound()); + verify.rejects(new CloudRegistryError("Admins only.", 403, {})); + const el = await fixture(html` + + `); + await settled(el); + + const failed = oneEvent(el, "ecc-service-verification-failed"); + click(part(el, "verify-button")); + const { detail } = (await failed) as EccServiceVerificationFailedEvent; + await settled(el); + + expect(detail.action).to.equal("verify"); + expect(detail.status).to.equal(403); + expect(detail.code).to.equal(undefined); + expect(get.calledOnce).to.equal(true); + expect(part(el, "verify-button")?.hasAttribute("disabled")).to.equal(false); + }); + + it("leaves a mismatch with a verification of the app to the app", async () => { + const { provider, get, verify } = stubProvider(); + verify.rejects(conflict()); + const el = await fixture(html` + + `); + await settled(el); + + const failed = oneEvent(el, "ecc-service-verification-failed"); + click(part(el, "verify-button")); + const { detail } = (await failed) as EccServiceVerificationFailedEvent; + await settled(el); + + expect(detail.code).to.equal("verification-mismatch"); + expect(get.called).to.equal(false); + }); + + it("shows nothing to other users for a verification of `null`", async () => { + const { provider, get } = stubProvider(); + const el = await fixture(html` + + `); + await settled(el); + + expect(get.called).to.equal(false); + expect(part(el, "badge")).to.equal(null); + expect(part(el, "verify-button")).to.equal(null); + }); + + ["verify", "remove"].forEach((action) => { + it(`reports providers that can't ${action}`, async () => { + const provider = { + getServiceVerification: sinon + .stub() + .resolves(action === "remove" ? VERIFICATION : null), + } as unknown as CloudRegistryProvider; + const el = await fixture(html` + + `); + await settled(el); + + const failed = oneEvent(el, "ecc-service-verification-failed"); + click(part(el, `${action}-button`)); + const { detail } = (await failed) as EccServiceVerificationFailedEvent; + + expect(detail.action).to.equal(action); + expect(detail.error).to.equal( + "The provider doesn't support verifications." + ); + }); + }); }); diff --git a/packages/ecc-client-elixir-cloud-registry/src/providers/errors.ts b/packages/ecc-client-elixir-cloud-registry/src/providers/errors.ts index 1b605111..84b937b3 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/providers/errors.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/providers/errors.ts @@ -97,6 +97,12 @@ export class CloudRegistryError extends Error { /** ID of the existing service, for duplicate registrations (`409`). */ readonly serviceId?: string; + /** + * Why a request conflicts (`409`), e.g. `verification-mismatch`; the + * registry may add codes, so treat unknown ones as a general conflict. + */ + readonly code?: string; + /** The response body, if it was a JSON object. */ readonly problem?: ProblemDetails; @@ -108,6 +114,7 @@ export class CloudRegistryError extends Error { this.title = nonBlankString(problem?.title); this.detail = nonBlankString(problem?.detail); this.serviceId = nonBlankString(problem?.serviceId); + this.code = nonBlankString(problem?.code); } /** diff --git a/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts b/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts index c824fac2..5f025be0 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts @@ -136,7 +136,7 @@ describe("RestCloudRegistryProvider verifications", () => { )) as CloudRegistryError; expect(err.status).to.equal(409); - expect(err.problem?.code).to.equal("verification-mismatch"); + expect(err.code).to.equal("verification-mismatch"); expect(err.message).to.equal("The service has another URL."); }); From 92e889a922211b197c8b380eef84ba3b4ee19c84 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 09:45:49 +0100 Subject: [PATCH 04/10] fix(cloud-registry): keep verify results on reload A reload while a verify or remove ran (e.g. a new provider after a token refresh) dropped the result: no change event, the old state shown, and the button enabled again. Track loads apart from verify and remove, always report a result the registry has applied, and show it until the app sets verification again. Fold capital sharp s and dotless i as Python does. Co-Authored-By: Claude Opus 5.5 --- .../components/service-verification.mdx | 23 ++++++- .../service-verification.ts | 65 ++++++++++++------- .../tests/service-verification.test.ts | 52 ++++++++++++++- .../src/providers/tests/verifications.test.ts | 26 ++++++++ .../src/providers/verifications.ts | 22 +++++-- 5 files changed, 153 insertions(+), 35 deletions(-) diff --git a/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx index f9da8bf1..adaf24ea 100644 --- a/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx +++ b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx @@ -85,18 +85,35 @@ const [services, verifications] = await Promise.all([ provider.getServices(), provider.getVerifications(), ]); -const verified = verifiedServices(services, verifications); +// e.g. in React state, so that the rows follow verify and remove +const [verified, setVerified] = useState( + () => verifiedServices(services, verifications) +); services.map((service) => ( + setVerified((current) => { + const next = new Map(current); + if (detail.verification) { + next.set(detail.serviceId, { serviceId: detail.serviceId, ...detail.verification }); + } else { + next.delete(detail.serviceId); + } + return next; + }) + } /> )); ``` +After a verify or remove, the component shows the result for that service until the app sets `verification` again, also if the provider changes meanwhile; update your verifications from `ecc-service-verification-changed`, as above. + The services list of the GA4GH Service Registry package (`ecc-client-ga4gh-service-registry-services`) doesn't show verifications itself; place the component in a row's `actions-` slot or in your own list. @@ -113,7 +130,7 @@ services.map((service) => ( | `admin` | `boolean` | `false` | Whether to offer "Verify" and "Remove verification" | ### `provider` and `baseUrl` -The component loads the verification again when `provider`, `baseUrl`, `serviceId`, `verification` or the ID of `service` changes, also if the app sets them after the first render. A new object of the same service (e.g. of a reloaded list) needs no new request: the component compares the verification shown with it. In React, create the provider only when its inputs change, e.g. with `useMemo`. +The component loads the verification again when `provider`, `baseUrl`, `serviceId` or the ID of `service` changes, also if the app sets them after the first render; if the app passes `verification`, it shows that instead (or the result of a verify or remove since, until the app sets `verification` again). A new object of the same service (e.g. of a reloaded list) needs no new request: the component compares the verification shown with it. In React, create the provider only when its inputs change, e.g. with `useMemo`. Custom providers implement the optional verification methods of `CloudRegistryProvider`; without them, the component fires `ecc-service-verification-failed`. @@ -131,7 +148,7 @@ Apps take the value from `admin` of the registry's `GET /me`. The property only | `ecc-service-verification-failed` | `onEccServiceVerificationFailed` | `{ serviceId: string, action: "load" \| "verify" \| "remove", error: string, status?: number, title?: string, detail?: string, code?: string, cause?: unknown }` | Fired when loading, verifying or removing the verification fails | ### `ecc-service-verification-changed` -Emitted after an admin verified the service (`verification`: the new verification) or removed its verification (`null`). A verification that was removed already (`404`) counts as removed. Apps that pass `verification` update it from this event, e.g. in their map of verified services. +Emitted after an admin verified the service (`verification`: the new verification) or removed its verification (`null`). A verification that was removed already (`404`) counts as removed. The event is fired whenever the registry has applied the change, also if the component shows another service by then (`serviceId` names the service). Apps that pass `verification` update it from this event, e.g. in their map of verified services. ### `ecc-service-verification-failed` Emitted when a request fails. Services that aren't verified (`404` when loading) are no failure. diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts index 69417f36..8731153b 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts @@ -140,9 +140,13 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement /** The provider for `baseUrl`, used if `provider` isn't set. */ private restProvider: RestCloudRegistryProvider | null = null; - // Number of the latest load, verify or remove, so that older answers are - // ignored (e.g. of a previous service or provider) - private request = 0; + // Number of the latest load, so that older answers are ignored (e.g. of a + // previous service or provider, or read before a verify or remove) + private loadRequest = 0; + + // Result of the last verify or remove: shown instead of `verification` + // for that service until the app sets `verification` again + private own?: { id: string; verification: Verification | null }; /** ID of the service: `serviceId`, else the ID of `service`. */ private get targetId(): string { @@ -158,6 +162,7 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement } protected updated(changedProperties: Map): void { + if (changedProperties.has("verification")) this.own = undefined; // a new object of the same service (e.g. of a reloaded list) needs no // new request: the verification shown is compared with it on render const previous = changedProperties.get("service") as @@ -198,16 +203,17 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement } /** - * Shows `verification` if the app set it, else loads the verification of - * the service; services that aren't verified (`404`) show none. + * Shows `verification` if the app set it (or the result of a verify or + * remove since), else loads the verification of the service; services + * that aren't verified (`404`) show none. */ private async load(): Promise { - this.request += 1; - const { request } = this; + this.loadRequest += 1; + const request = this.loadRequest; const id = this.targetId; - this.busy = false; if (this.verification !== undefined) { - this.current = this.verification; + this.current = + this.own?.id === id ? this.own.verification : this.verification; return; } this.current = undefined; @@ -223,10 +229,10 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement } try { const verification = await provider.getServiceVerification(id); - if (request !== this.request) return; + if (request !== this.loadRequest) return; this.current = verification; } catch (err) { - if (request !== this.request) return; + if (request !== this.loadRequest) return; if (statusOf(err) === 404) { this.current = null; return; @@ -237,6 +243,21 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement } } + /** + * Applies the result of a verify or remove: always reported, as the + * registry has applied it, but shown only if the component still shows + * that service. + */ + private applied(id: string, verification: Verification | null): void { + if (id === this.targetId) { + // a load that runs now may have read the state before the change + this.loadRequest += 1; + this.current = verification; + if (this.verification !== undefined) this.own = { id, verification }; + } + this.changed(id, verification); + } + /** * Verifies the service (admins), with the URL and API type of `service` * as the ones checked. If the service has changed meanwhile (`409`), the @@ -254,8 +275,6 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement }); return; } - this.request += 1; - const { request } = this; this.busy = true; try { const verification = await provider.verifyService( @@ -271,15 +290,16 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement } : undefined ); - if (request !== this.request) return; this.busy = false; - this.current = verification; - this.changed(id, verification); + this.applied(id, verification); } catch (err) { - if (request !== this.request) return; this.busy = false; this.fail(failureDetail(id, "verify", err, "Failed to verify service")); - if (statusOf(err) === 409 && this.verification === undefined) { + if ( + statusOf(err) === 409 && + id === this.targetId && + this.verification === undefined + ) { this.load(); } } @@ -301,25 +321,20 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement }); return; } - this.request += 1; - const { request } = this; this.busy = true; try { await provider.deleteServiceVerification(id); } catch (err) { - if (request !== this.request) return; - this.busy = false; if (statusOf(err) !== 404) { + this.busy = false; this.fail( failureDetail(id, "remove", err, "Failed to remove verification") ); return; } } - if (request !== this.request) return; this.busy = false; - this.current = null; - this.changed(id, null); + this.applied(id, null); } private changed(serviceId: string, verification: Verification | null) { diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts index d4bab002..c09568fc 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts @@ -477,7 +477,7 @@ describe("ecc-client-elixir-cloud-registry-service-verification", () => { expect(part(el, "remove-button")?.hasAttribute("disabled")).to.equal(false); }); - it("ignores a verify answer once the service changed", async () => { + it("reports a verify of a previous service, but doesn't show it", async () => { const { provider, get, verify } = stubProvider(); get.rejects(notFound()); let answer = (value: typeof VERIFICATION): void => { @@ -505,10 +505,58 @@ describe("ecc-client-elixir-cloud-registry-service-verification", () => { answer(VERIFICATION); await settled(el); - expect(changed.called).to.equal(false); + expect(changed.calledOnce).to.equal(true); + expect(changed.firstCall.args[0].detail).to.deep.equal({ + serviceId: "service-1", + verification: VERIFICATION, + }); expect(part(el, "badge")?.textContent?.trim()).to.equal("Not verified"); }); + it("keeps a verify when the provider is replaced meanwhile", async () => { + const { provider, get, verify } = stubProvider(); + let answer = (value: typeof VERIFICATION): void => { + throw new Error(`No request waits for ${value.url}`); + }; + verify.returns( + new Promise((resolve) => { + answer = resolve; + }) + ); + const changed = sinon.spy(); + const el = await fixture(html` + + `); + await settled(el); + + click(part(el, "verify-button")); + el.provider = stubProvider().provider; + await settled(el); + expect(part(el, "verify-button")?.hasAttribute("disabled")).to.equal(true); + answer(VERIFICATION); + await settled(el); + + expect(changed.calledOnce).to.equal(true); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Verified"); + + // the app hasn't updated `verification`: the result stays + el.provider = stubProvider().provider; + await settled(el); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Verified"); + + // until it sets another value (here: of an older URL) + el.verification = { ...VERIFICATION, url: "https://tes.example.org/old" }; + await settled(el); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Not verified"); + expect(get.called).to.equal(false); + }); + it("reports a failed verify without loading again", async () => { const { provider, get, verify } = stubProvider(); get.rejects(notFound()); diff --git a/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts b/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts index 5f025be0..e9528c70 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/providers/tests/verifications.test.ts @@ -177,6 +177,32 @@ describe("matchesVerification", () => { expect(matchesVerification(service, VERIFICATION)).to.equal(true); }); + it("folds capital sharp s and keeps dotless i, as Python does", () => { + const type = (group: string) => ({ group, artifact: "tes", version: "1" }); + const verification = (group: string) => ({ + ...VERIFICATION, + type: type(group), + }); + expect( + matchesVerification( + { ...SERVICE, type: type("\u1E9E") }, + verification("ss") + ) + ).to.equal(true); + expect( + matchesVerification( + { ...SERVICE, type: type("t\u0131s") }, + verification("tis") + ) + ).to.equal(false); + expect( + matchesVerification( + { ...SERVICE, type: type("T\u0131S") }, + verification("t\u0131s") + ) + ).to.equal(true); + }); + it("folds the case of special characters", () => { const service = { ...SERVICE, diff --git a/packages/ecc-client-elixir-cloud-registry/src/providers/verifications.ts b/packages/ecc-client-elixir-cloud-registry/src/providers/verifications.ts index 721d3337..317761c1 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/providers/verifications.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/providers/verifications.ts @@ -9,9 +9,8 @@ * * The registry compares with Python's `strip().casefold()`: trimming here * uses the same white space (JavaScript's `\s` without U+FEFF, plus - * U+001C-U+001F and U+0085), and case folding is approximated by upper- then - * lower-casing (e.g. `ß` and `SS` match), which JavaScript lacks a built-in - * for. + * U+001C-U+001F and U+0085), and case folding, which JavaScript lacks a + * built-in for, is approximated (see `caseFold()`; e.g. `ß` and `SS` match). */ import type { @@ -38,10 +37,23 @@ function strip(text: string): string { return text.slice(start, end); } +/** + * The text case-folded like Python's `str.casefold()`, approximated by + * upper- then lower-casing, except for the two characters where that + * differs: capital sharp s (U+1E9E) folds to `ss`, and dotless i (U+0131) + * stays as it is (characters added in later Unicode versions may differ) + */ +function caseFold(text: string): string { + return text + .replace(/\u1E9E/g, "ss") + .split("\u0131") + .map((part) => part.toUpperCase().toLowerCase()) + .join("\u0131"); +} + /** Whether two texts are equal, trimmed and case-insensitive */ function sameText(a: string, b: string): boolean { - const fold = (text: string) => strip(text).toUpperCase().toLowerCase(); - return fold(a) === fold(b); + return caseFold(strip(a)) === caseFold(strip(b)); } /** From e138c469c5f12daade8187f61d0fc5cc1549872a Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 14:01:00 +0100 Subject: [PATCH 05/10] fix(cloud-registry): scope verify to service Track running verify and remove requests by service ID, so that one for a previous service doesn't block the service shown, and show a result (also one kept over the app's verification) only while the component shows the same service of the same registry (base URL). Co-Authored-By: Claude Opus 5.5 --- .../components/service-verification.mdx | 6 +- .../service-verification.ts | 79 ++++++++++---- .../tests/service-verification.test.ts | 102 ++++++++++++++++++ 3 files changed, 167 insertions(+), 20 deletions(-) diff --git a/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx index adaf24ea..3e59a3a4 100644 --- a/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx +++ b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx @@ -85,10 +85,12 @@ const [services, verifications] = await Promise.all([ provider.getServices(), provider.getVerifications(), ]); -// e.g. in React state, so that the rows follow verify and remove +// e.g. in React state, so that the rows follow verify and remove; after +// reloading the lists, call setVerified(verifiedServices(...)) again const [verified, setVerified] = useState( () => verifiedServices(services, verifications) ); +// `me` is the response of the registry's GET /me services.map((service) => ( ( )); ``` -After a verify or remove, the component shows the result for that service until the app sets `verification` again, also if the provider changes meanwhile; update your verifications from `ecc-service-verification-changed`, as above. +After a verify or remove, the component shows the result for that service until the app sets `verification` again, also if the provider is replaced meanwhile by one for the same registry (the same base URL, e.g. with a new token); update your verifications from `ecc-service-verification-changed`, as above. Pass a stable value (e.g. the object from the map, not one built on each render): every new value counts as set again. The services list of the GA4GH Service Registry package (`ecc-client-ga4gh-service-registry-services`) doesn't show verifications itself; place the component in a row's `actions-` slot or in your own list. diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts index 8731153b..50e06285 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts @@ -134,8 +134,8 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement // The verification shown: `undefined` while it loads or if loading failed @state() private current?: Verification | null; - // Whether a verify or remove request is running - @state() private busy = false; + // IDs of the services with a verify or remove request running + @state() private pending: ReadonlySet = new Set(); /** The provider for `baseUrl`, used if `provider` isn't set. */ private restProvider: RestCloudRegistryProvider | null = null; @@ -145,14 +145,24 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement private loadRequest = 0; // Result of the last verify or remove: shown instead of `verification` - // for that service until the app sets `verification` again - private own?: { id: string; verification: Verification | null }; + // for that service of that registry until the app sets `verification` + // again + private own?: { + id: string; + verification: Verification | null; + provider: CloudRegistryProvider; + }; /** ID of the service: `serviceId`, else the ID of `service`. */ private get targetId(): string { return this.serviceId || this.service?.id || ""; } + /** Whether a verify or remove of the service shown is running. */ + private get busy(): boolean { + return this.pending.has(this.targetId); + } + /** Whether the service is verified, as far as is known. */ private get verified(): boolean { return Boolean( @@ -212,8 +222,11 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement const request = this.loadRequest; const id = this.targetId; if (this.verification !== undefined) { + const { own } = this; this.current = - this.own?.id === id ? this.own.verification : this.verification; + own && this.showsSame(own.id, own.provider) + ? own.verification + : this.verification; return; } this.current = undefined; @@ -243,17 +256,47 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement } } + /** + * Whether the component still shows the service of a request: the same ID + * and registry (the same provider, or one for the same base URL, e.g. one + * that an app replaced for a new token) + */ + private showsSame(id: string, provider: CloudRegistryProvider): boolean { + const current = this.currentProvider(); + const { baseUrl } = provider as { baseUrl?: unknown }; + return ( + id === this.targetId && + (current === provider || + (typeof baseUrl === "string" && + baseUrl === (current as { baseUrl?: unknown } | null)?.baseUrl)) + ); + } + + /** Marks a verify or remove of a service as running, or as done. */ + private setPending(id: string, running: boolean): void { + const pending = new Set(this.pending); + if (running) pending.add(id); + else pending.delete(id); + this.pending = pending; + } + /** * Applies the result of a verify or remove: always reported, as the * registry has applied it, but shown only if the component still shows - * that service. + * that service of that registry. */ - private applied(id: string, verification: Verification | null): void { - if (id === this.targetId) { + private applied( + id: string, + verification: Verification | null, + provider: CloudRegistryProvider + ): void { + if (this.showsSame(id, provider)) { // a load that runs now may have read the state before the change this.loadRequest += 1; this.current = verification; - if (this.verification !== undefined) this.own = { id, verification }; + if (this.verification !== undefined) { + this.own = { id, verification, provider }; + } } this.changed(id, verification); } @@ -275,7 +318,7 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement }); return; } - this.busy = true; + this.setPending(id, true); try { const verification = await provider.verifyService( id, @@ -290,14 +333,14 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement } : undefined ); - this.busy = false; - this.applied(id, verification); + this.setPending(id, false); + this.applied(id, verification, provider); } catch (err) { - this.busy = false; + this.setPending(id, false); this.fail(failureDetail(id, "verify", err, "Failed to verify service")); if ( statusOf(err) === 409 && - id === this.targetId && + this.showsSame(id, provider) && this.verification === undefined ) { this.load(); @@ -321,20 +364,20 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement }); return; } - this.busy = true; + this.setPending(id, true); try { await provider.deleteServiceVerification(id); } catch (err) { if (statusOf(err) !== 404) { - this.busy = false; + this.setPending(id, false); this.fail( failureDetail(id, "remove", err, "Failed to remove verification") ); return; } } - this.busy = false; - this.applied(id, null); + this.setPending(id, false); + this.applied(id, null, provider); } private changed(serviceId: string, verification: Verification | null) { diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts index c09568fc..5761ae7c 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts @@ -502,6 +502,8 @@ describe("ecc-client-elixir-cloud-registry-service-verification", () => { click(part(el, "verify-button")); el.serviceId = "service-2"; await settled(el); + // the request for service-1 doesn't block service-2 + expect(part(el, "verify-button")?.hasAttribute("disabled")).to.equal(false); answer(VERIFICATION); await settled(el); @@ -513,6 +515,42 @@ describe("ecc-client-elixir-cloud-registry-service-verification", () => { expect(part(el, "badge")?.textContent?.trim()).to.equal("Not verified"); }); + it("doesn't show a verify of another registry", async () => { + const { provider, get, verify } = stubProvider(); + get.rejects(notFound()); + let answer = (value: typeof VERIFICATION): void => { + throw new Error(`No request waits for ${value.url}`); + }; + verify.returns( + new Promise((resolve) => { + answer = resolve; + }) + ); + const changed = sinon.spy(); + const el = await fixture(html` + + `); + await settled(el); + + click(part(el, "verify-button")); + const other = new RestCloudRegistryProvider( + "https://other.example.org/ga4gh/registry/v1" + ); + sinon.stub(other, "getServiceVerification").rejects(notFound()); + el.provider = other; + await settled(el); + answer(VERIFICATION); + await settled(el); + + expect(changed.calledOnce).to.equal(true); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Not verified"); + }); + it("keeps a verify when the provider is replaced meanwhile", async () => { const { provider, get, verify } = stubProvider(); let answer = (value: typeof VERIFICATION): void => { @@ -646,4 +684,68 @@ describe("ecc-client-elixir-cloud-registry-service-verification", () => { ); }); }); + + it("reports a remove of a previous service, but doesn't show it", async () => { + const { provider, get, remove } = stubProvider(); + get.onFirstCall().resolves(VERIFICATION); + get.onSecondCall().resolves(VERIFICATION); + let answer = (value: string): void => { + throw new Error(`No request waits for ${value}`); + }; + remove.returns( + new Promise((resolve) => { + answer = resolve; + }) + ); + const changed = sinon.spy(); + const el = await fixture(html` + + `); + await settled(el); + + click(part(el, "remove-button")); + el.serviceId = "service-2"; + await settled(el); + expect(part(el, "remove-button")?.hasAttribute("disabled")).to.equal(false); + answer("service-1"); + await settled(el); + + expect(changed.firstCall.args[0].detail).to.deep.equal({ + serviceId: "service-1", + verification: null, + }); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Verified"); + }); + + it("drops a result of its own for another registry", async () => { + const { provider, verify } = stubProvider(); + verify.resolves(VERIFICATION); + const el = await fixture(html` + + `); + await settled(el); + + const changed = oneEvent(el, "ecc-service-verification-changed"); + click(part(el, "verify-button")); + await changed; + await el.updateComplete; + expect(part(el, "badge")?.textContent?.trim()).to.equal("Verified"); + + el.provider = new RestCloudRegistryProvider( + "https://other.example.org/ga4gh/registry/v1" + ); + await settled(el); + + expect(part(el, "badge")?.textContent?.trim()).to.equal("Not verified"); + }); }); From 5614d1253b9c693a6e4ca91008e2d424fb0e1c33 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 15:24:23 +0100 Subject: [PATCH 06/10] fix(cloud-registry): reload unclear verify results Address automated review: load the verification again when the URL or API type of the service changes and after a verify or remove fails without a clear outcome (no status or a server error), and key running requests by registry and service, so that a request to one registry doesn't block another. Co-Authored-By: Claude Opus 5.5 --- .../components/service-verification.mdx | 4 +- .../service-verification.ts | 78 ++++++++++++++----- .../tests/service-verification.test.ts | 74 +++++++++++++++++- 3 files changed, 134 insertions(+), 22 deletions(-) diff --git a/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx index 3e59a3a4..e05b6403 100644 --- a/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx +++ b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx @@ -132,7 +132,7 @@ After a verify or remove, the component shows the result for that service until | `admin` | `boolean` | `false` | Whether to offer "Verify" and "Remove verification" | ### `provider` and `baseUrl` -The component loads the verification again when `provider`, `baseUrl`, `serviceId` or the ID of `service` changes, also if the app sets them after the first render; if the app passes `verification`, it shows that instead (or the result of a verify or remove since, until the app sets `verification` again). A new object of the same service (e.g. of a reloaded list) needs no new request: the component compares the verification shown with it. In React, create the provider only when its inputs change, e.g. with `useMemo`. +The component loads the verification again when `provider`, `baseUrl`, `serviceId` or the ID of `service` changes, also if the app sets them after the first render; if the app passes `verification`, it shows that instead (or the result of a verify or remove since, until the app sets `verification` again). A new object of the same service (e.g. of a reloaded list) needs no new request, unless its URL or API type changed: the registry then removes the verification. In React, create the provider only when its inputs change, e.g. with `useMemo`. Custom providers implement the optional verification methods of `CloudRegistryProvider`; without them, the component fires `ecc-service-verification-failed`. @@ -159,7 +159,7 @@ Emitted when a request fails. Services that aren't verified (`404` when loading) - `error`: what went wrong, e.g. the registry's explanation (the problem's `detail`); - `status`: the HTTP status, e.g. `403` (not an admin) or `409`; - `title`, `detail`: the problem details that the registry sent, if any; -- `code`: the conflict code of a `409`: `verification-mismatch` if the service has another URL or API type than the one shown (the component then loads the verification again, unless the app passes `verification`), `pending-service` if the service awaits approval; +- `code`: the conflict code of a `409`: `verification-mismatch` if the service has another URL or API type than the one shown (the component then loads the verification again, unless the app passes `verification`), `pending-service` if the service awaits approval. After a failure without a status (e.g. a lost connection) or with a server error, the component also loads the verification again, as the registry may have applied the change; - `cause`: the error that the provider threw. diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts index 50e06285..2e0f235c 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/service-verification.ts @@ -61,6 +61,15 @@ const failureDetail = ( }; }; +/** + * Key of a running verify or remove: the service ID and the registry's base + * URL, if the provider has one + */ +const pendingKey = (id: string, provider: CloudRegistryProvider): string => { + const { baseUrl } = provider as { baseUrl?: unknown }; + return JSON.stringify([typeof baseUrl === "string" ? baseUrl : "", id]); +}; + /** Date of a verification for people, e.g. "20 Oct 2026"; else as sent */ const formatDate = (value: string): string => { const date = new Date(value); @@ -160,7 +169,10 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement /** Whether a verify or remove of the service shown is running. */ private get busy(): boolean { - return this.pending.has(this.targetId); + const provider = this.currentProvider(); + return Boolean( + provider && this.pending.has(pendingKey(this.targetId, provider)) + ); } /** Whether the service is verified, as far as is known. */ @@ -174,16 +186,24 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement protected updated(changedProperties: Map): void { if (changedProperties.has("verification")) this.own = undefined; // a new object of the same service (e.g. of a reloaded list) needs no - // new request: the verification shown is compared with it on render + // new request, unless its URL or API type changed: the registry then + // removes the verification, which must not show again if they change back const previous = changedProperties.get("service") as | ExternalService | undefined; + const { service } = this; + const serviceChanged = + changedProperties.has("service") && + (previous?.id !== service?.id || + !previous || + !service || + !matchesVerification(previous, service)); if ( changedProperties.has("provider") || changedProperties.has("baseUrl") || changedProperties.has("serviceId") || changedProperties.has("verification") || - (changedProperties.has("service") && previous?.id !== this.service?.id) + serviceChanged ) { this.load(); } @@ -273,13 +293,38 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement } /** Marks a verify or remove of a service as running, or as done. */ - private setPending(id: string, running: boolean): void { + private setPending( + id: string, + provider: CloudRegistryProvider, + running: boolean + ): void { const pending = new Set(this.pending); - if (running) pending.add(id); - else pending.delete(id); + if (running) pending.add(pendingKey(id, provider)); + else pending.delete(pendingKey(id, provider)); this.pending = pending; } + /** + * Loads the verification again after a failed verify or remove whose + * outcome is unclear (no status, e.g. a lost connection, or a server + * error) or that conflicts (`409`), if the component still shows that + * service and doesn't use the app's `verification` + */ + private reconcile( + err: unknown, + id: string, + provider: CloudRegistryProvider + ): void { + const status = statusOf(err); + if ( + (status === undefined || status === 409 || status >= 500) && + this.showsSame(id, provider) && + this.verification === undefined + ) { + this.load(); + } + } + /** * Applies the result of a verify or remove: always reported, as the * registry has applied it, but shown only if the component still shows @@ -318,7 +363,7 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement }); return; } - this.setPending(id, true); + this.setPending(id, provider, true); try { const verification = await provider.verifyService( id, @@ -333,18 +378,12 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement } : undefined ); - this.setPending(id, false); + this.setPending(id, provider, false); this.applied(id, verification, provider); } catch (err) { - this.setPending(id, false); + this.setPending(id, provider, false); this.fail(failureDetail(id, "verify", err, "Failed to verify service")); - if ( - statusOf(err) === 409 && - this.showsSame(id, provider) && - this.verification === undefined - ) { - this.load(); - } + this.reconcile(err, id, provider); } } @@ -364,19 +403,20 @@ export class ECCClientElixirCloudRegistryServiceVerification extends LitElement }); return; } - this.setPending(id, true); + this.setPending(id, provider, true); try { await provider.deleteServiceVerification(id); } catch (err) { if (statusOf(err) !== 404) { - this.setPending(id, false); + this.setPending(id, provider, false); this.fail( failureDetail(id, "remove", err, "Failed to remove verification") ); + this.reconcile(err, id, provider); return; } } - this.setPending(id, false); + this.setPending(id, provider, false); this.applied(id, null, provider); } diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts index 5761ae7c..1471df83 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/tests/service-verification.test.ts @@ -186,13 +186,83 @@ describe("ecc-client-elixir-cloud-registry-service-verification", () => { `); await settled(el); - el.service = { ...SERVICE, url: "https://tes.example.org/v2" }; + el.service = { ...SERVICE, name: "Renamed" }; await settled(el); expect(get.calledOnce).to.equal(true); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Verified"); + }); + + it("loads again when the URL of the service changes", async () => { + const { provider, get } = stubProvider(); + get.onFirstCall().resolves(VERIFICATION); + get.onSecondCall().rejects(notFound()); + get.onThirdCall().rejects(notFound()); + const el = await fixture(html` + + `); + await settled(el); + + el.service = { ...SERVICE, url: "https://tes.example.org/v2" }; + await settled(el); + // back to the verified URL: the registry removed the verification + el.service = { ...SERVICE }; + await settled(el); + + expect(get.calledThrice).to.equal(true); expect(part(el, "badge")).to.equal(null); }); + it("loads again when a verify fails without a clear outcome", async () => { + const { provider, get, verify } = stubProvider(); + get.onFirstCall().rejects(notFound()); + get.onSecondCall().resolves(VERIFICATION); + verify.rejects(new TypeError("Failed to fetch")); + const el = await fixture(html` + + `); + await settled(el); + + const failed = oneEvent(el, "ecc-service-verification-failed"); + click(part(el, "verify-button")); + const { detail } = (await failed) as EccServiceVerificationFailedEvent; + await settled(el); + + expect(detail.error).to.equal("Failed to fetch"); + expect(get.calledTwice).to.equal(true); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Verified"); + }); + + it("loads again when a remove fails with a server error", async () => { + const { provider, get, remove } = stubProvider(); + get.onFirstCall().resolves(VERIFICATION); + get.onSecondCall().rejects(notFound()); + remove.rejects(new CloudRegistryError("Bad gateway", 502, {})); + const el = await fixture(html` + + `); + await settled(el); + + const failed = oneEvent(el, "ecc-service-verification-failed"); + click(part(el, "remove-button")); + await failed; + await settled(el); + + expect(get.calledTwice).to.equal(true); + expect(part(el, "badge")?.textContent?.trim()).to.equal("Not verified"); + }); + it("lets admins verify a service with its URL and API type", async () => { const { provider, get, verify } = stubProvider(); get.rejects(notFound()); @@ -544,6 +614,8 @@ describe("ecc-client-elixir-cloud-registry-service-verification", () => { sinon.stub(other, "getServiceVerification").rejects(notFound()); el.provider = other; await settled(el); + // the request to the first registry doesn't block the other one + expect(part(el, "verify-button")?.hasAttribute("disabled")).to.equal(false); answer(VERIFICATION); await settled(el); From 544b76404f5b0a3ae767c6b5fe6102671931adf3 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 21:26:33 +0100 Subject: [PATCH 07/10] feat(cloud-registry): list services with badges Add ecc-client-elixir-cloud-registry-services, the services list of the service-registry package with a verification badge in each row. It loads all verifications with one request and follows verify and remove by admins. The elements it renders are defined only in browsers and only once, so that server-side rendering of the React wrappers keeps working. Co-Authored-By: Claude Opus 5.5 --- .../cloud-registry-services-verified.md | 5 + .changeset/service-registry-define-once.md | 5 + .../src/components/index.ts | 1 + .../components/service-verification/index.ts | 18 +- .../src/components/services/index.ts | 22 ++ .../src/components/services/services.ts | 254 ++++++++++++++ .../services/tests/services.test.ts | 330 ++++++++++++++++++ .../src/events/index.ts | 6 + .../tsconfig.json | 8 +- .../src/components/services/index.ts | 16 +- 10 files changed, 656 insertions(+), 9 deletions(-) create mode 100644 .changeset/cloud-registry-services-verified.md create mode 100644 .changeset/service-registry-define-once.md create mode 100644 packages/ecc-client-elixir-cloud-registry/src/components/services/index.ts create mode 100644 packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts create mode 100644 packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts diff --git a/.changeset/cloud-registry-services-verified.md b/.changeset/cloud-registry-services-verified.md new file mode 100644 index 00000000..684e0fce --- /dev/null +++ b/.changeset/cloud-registry-services-verified.md @@ -0,0 +1,5 @@ +--- +"@elixir-cloud/cloud-registry": minor +--- + +Add `ecc-client-elixir-cloud-registry-services`: the services list of the service-registry package with a "Verified" badge for verified services. It loads all verifications with one request (`GET /verifications`), and admins can verify services or remove their verification in each row. diff --git a/.changeset/service-registry-define-once.md b/.changeset/service-registry-define-once.md new file mode 100644 index 00000000..9333f633 --- /dev/null +++ b/.changeset/service-registry-define-once.md @@ -0,0 +1,5 @@ +--- +"@elixir-cloud/service-registry": patch +--- + +Define `ecc-client-ga4gh-service-registry-services` only in browsers and only once, so that the services list of the cloud-registry package can define it too. diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/index.ts b/packages/ecc-client-elixir-cloud-registry/src/components/index.ts index 81d865e2..aad15376 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/index.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/index.ts @@ -1,2 +1,3 @@ import "./service-create/index.js"; import "./service-verification/index.js"; +import "./services/index.js"; diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts index eca23e57..7d3b2b1a 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/service-verification/index.ts @@ -3,10 +3,20 @@ import ECCClientElixirCloudRegistryServiceVerification from "./service-verificat export * from "./service-verification.js"; export default ECCClientElixirCloudRegistryServiceVerification; -window.customElements.define( - "ecc-client-elixir-cloud-registry-service-verification", - ECCClientElixirCloudRegistryServiceVerification -); +// Only in browsers, and only once (the services list of the +// cloud-registry package may have defined it already) +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get( + "ecc-client-elixir-cloud-registry-service-verification" + ) +) { + window.customElements.define( + "ecc-client-elixir-cloud-registry-service-verification", + ECCClientElixirCloudRegistryServiceVerification + ); +} declare global { interface HTMLElementTagNameMap { diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/services/index.ts b/packages/ecc-client-elixir-cloud-registry/src/components/services/index.ts new file mode 100644 index 00000000..c0ceab0c --- /dev/null +++ b/packages/ecc-client-elixir-cloud-registry/src/components/services/index.ts @@ -0,0 +1,22 @@ +import ECCClientElixirCloudRegistryServices from "./services.js"; + +export * from "./services.js"; +export default ECCClientElixirCloudRegistryServices; + +// Only in browsers, and only once +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-elixir-cloud-registry-services") +) { + window.customElements.define( + "ecc-client-elixir-cloud-registry-services", + ECCClientElixirCloudRegistryServices + ); +} + +declare global { + interface HTMLElementTagNameMap { + "ecc-client-elixir-cloud-registry-services": ECCClientElixirCloudRegistryServices; + } +} diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts b/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts new file mode 100644 index 00000000..5cd9438c --- /dev/null +++ b/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts @@ -0,0 +1,254 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { LitElement, html, css, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import { keyed } from "lit/directives/keyed.js"; +import { + ECCClientGa4ghServiceRegistryServices, + type RowActionsRenderer, +} from "@elixir-cloud/service-registry/components/services/services.js"; +import "@elixir-cloud/design/components/button/index.js"; +import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; +import { GlobalStyles } from "../../global.js"; +import { RestCloudRegistryProvider } from "../../providers/rest-cr-provider.js"; +import { + CloudRegistryProvider, + ExternalService, + ServiceVerification, +} from "../../providers/index.js"; +import { verifiedServices } from "../../providers/verifications.js"; +import { ECCClientElixirCloudRegistryServiceVerification } from "../service-verification/service-verification.js"; +import type { + EccServiceVerificationChangedEvent, + EccServiceVerificationFailedEvent, +} from "../../events/index.js"; + +export type { RowActionsRenderer }; + +// Defines the elements that the list renders, unless they are already, and +// only in browsers, so that importing this module during server-side +// rendering (e.g. through the React wrapper) doesn't fail +if (typeof window !== "undefined" && window.customElements) { + const elements: [string, CustomElementConstructor][] = [ + [ + "ecc-client-ga4gh-service-registry-services", + ECCClientGa4ghServiceRegistryServices, + ], + [ + "ecc-client-elixir-cloud-registry-service-verification", + ECCClientElixirCloudRegistryServiceVerification, + ], + ]; + elements.forEach(([name, element]) => { + if (!window.customElements.get(name)) { + window.customElements.define(name, element); + } + }); +} + +/** The number of services per page of the services list by default. */ +const DEFAULT_PAGE_SIZE = 10; + +/** + * @summary Lists the services of a Cloud Registry, with a "Verified" badge for verified services + * @since 2.0.0 + * + * @property {string} baseUrl - Base URL of the Cloud Registry instance/gateway + * @property {CloudRegistryProvider} provider - Custom data provider (optional, overrides baseUrl); replacing it loads the services and verifications again + * @property {boolean} admin - Whether to offer "Verify" and "Remove verification" in each row (for admins of the registry); default `false` + * @property {boolean} filter - Determines if the type, organization and environment filters should be rendered + * @property {boolean} search - Determines if the search field should be rendered + * @property {number} pageSize - Number of services per page (attribute `page-size`, default 10); 0 or less shows all services on one page + * @property {RowActionsRenderer} rowActions - Renders the actions of each row (e.g. Edit and Delete buttons) from its service, after the badge; without it, rows have a "View Details" button + * + * @fires ecc-services-changed - Fired when services data changes + * @fires ecc-service-selected - Fired when a service, its row or its "View Details" button is clicked + * @fires ecc-service-verification-changed - Fired when an admin verified a service or removed its verification + * @fires ecc-service-verification-failed - Fired when loading the verifications (`serviceId` empty) or verifying a service or removing its verification fails + * + * @csspart list - The services list (`ecc-client-ga4gh-service-registry-services`) + */ +export class ECCClientElixirCloudRegistryServices extends LitElement { + static styles = [ + TailwindStyles, + GlobalStyles, + css` + :host { + display: block; + width: 100%; + } + `, + ]; + + @property({ type: String, reflect: true }) baseUrl = ""; + + @property({ attribute: false }) provider?: CloudRegistryProvider; + @property({ type: Boolean }) admin = false; + @property({ type: Boolean }) filter = true; + @property({ type: Boolean }) search = true; + @property({ type: Number, attribute: "page-size" }) + pageSize = DEFAULT_PAGE_SIZE; + + @property({ attribute: false }) rowActions?: RowActionsRenderer; + + // The services of the list, from its `ecc-services-changed` + @state() private services: ExternalService[] = []; + // The registry's verifications: `undefined` while they load or if loading + // failed, so that the rows don't load their own + @state() private verifications?: ServiceVerification[]; + + /** The provider for `baseUrl`, used if `provider` isn't set. */ + private restProvider: RestCloudRegistryProvider | null = null; + + /** The provider shown, whose verifications count. */ + private shownProvider: CloudRegistryProvider | null = null; + + /** Verifications by service ID, matched against the services. */ + private verified = new Map(); + + /** Renders the badge and actions of a row; new when they change. */ + private renderRowActions?: RowActionsRenderer; + + /** + * The provider to use: `provider`, else a REST provider for `baseUrl`. + */ + private currentProvider(): CloudRegistryProvider | null { + if (this.provider) return this.provider; + if (!this.baseUrl) { + this.restProvider = null; + } else if (this.restProvider?.baseUrl !== this.baseUrl) { + this.restProvider = new RestCloudRegistryProvider(this.baseUrl); + } + return this.restProvider; + } + + protected willUpdate(changedProperties: Map): void { + const provider = this.currentProvider(); + if (provider !== this.shownProvider) { + this.shownProvider = provider; + this.services = []; + this.verifications = undefined; + if (provider) this.loadVerifications(provider); + } + if ( + changedProperties.has("services") || + changedProperties.has("verifications") || + changedProperties.has("admin") || + changedProperties.has("rowActions") + ) { + this.verified = verifiedServices(this.services, this.verifications ?? []); + this.renderRowActions = this.createRowActions(); + } + } + + /** Loads the verifications of all services with one request. */ + private async loadVerifications( + provider: CloudRegistryProvider + ): Promise { + try { + if (!provider.getVerifications) { + throw new Error("The provider doesn't support verifications."); + } + const verifications = await provider.getVerifications(); + if (provider === this.shownProvider) this.verifications = verifications; + } catch (err) { + if (provider !== this.shownProvider) return; + const detail: EccServiceVerificationFailedEvent["detail"] = { + serviceId: "", + action: "load", + error: + err instanceof Error && err.message + ? err.message + : "Failed to load the verifications.", + cause: err, + }; + const { status } = (err ?? {}) as { status?: unknown }; + if (typeof status === "number") detail.status = status; + this.dispatchEvent( + new CustomEvent("ecc-service-verification-failed", { + detail, + bubbles: true, + composed: true, + }) + ); + } + } + + private handleServicesChanged( + e: CustomEvent<{ services: ExternalService[] }> + ): void { + this.services = e.detail.services; + } + + /** Updates the verifications after an admin verified or removed one. */ + private handleVerificationChanged( + e: EccServiceVerificationChangedEvent + ): void { + if (!this.verifications) return; + const { serviceId, verification } = e.detail; + const others = this.verifications.filter( + (item) => item.serviceId !== serviceId + ); + this.verifications = verification + ? [...others, { serviceId, ...verification }] + : others; + } + + private handleViewDetails(serviceId: string): void { + this.dispatchEvent( + new CustomEvent("ecc-service-selected", { + detail: { serviceId }, + bubbles: true, + composed: true, + }) + ); + } + + /** + * A renderer for the actions of the rows: the badge (once the + * verifications are loaded), then the app's actions or "View Details". + */ + private createRowActions(): RowActionsRenderer { + const { verified, verifications, admin, rowActions } = this; + const provider = this.shownProvider; + return (service: ExternalService) => html` +
+ ${verifications && provider + ? html`` + : nothing} + ${rowActions + ? rowActions(service) ?? nothing + : html` this.handleViewDetails(service.id)} + > + View Details + `} +
+ `; + } + + render() { + // A new provider needs a new list, which reads it when it first renders + return html`${keyed( + this.shownProvider, + html`` + )}`; + } +} + +export default ECCClientElixirCloudRegistryServices; diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts b/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts new file mode 100644 index 00000000..2c30efb1 --- /dev/null +++ b/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts @@ -0,0 +1,330 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, oneEvent, waitUntil } from "@open-wc/testing"; +import { html as litHtml } from "lit"; +import sinon from "sinon"; +import "../index.js"; +import type ECCClientElixirCloudRegistryServices from "../services.js"; +import type ECCClientElixirCloudRegistryServiceVerification from "../../service-verification/service-verification.js"; +import type { EccServiceVerificationFailedEvent } from "../../../events/index.js"; +import { + CloudRegistryProvider, + ExternalService, + RestCloudRegistryProvider, + ServiceVerification, +} from "../../../providers/index.js"; + +const BASE_URL = "https://registry.example.org/ga4gh/registry/v1"; + +const TES = { group: "org.ga4gh", artifact: "tes", version: "1.1.0" }; + +/** A service with the given ID. */ +const service = (id: string): ExternalService => ({ + id, + name: `Service ${id}`, + url: `https://${id}.example.org/ga4gh/tes/v1`, + type: TES, + organization: { name: "Example", url: "https://example.org" }, + version: "1.0.0", +}); + +const SERVICES = [service("s1"), service("s2"), service("s3")]; + +/** A verification of a service as it is registered. */ +const verificationOf = (svc: ExternalService): ServiceVerification => ({ + serviceId: svc.id, + url: svc.url, + type: svc.type, + verifiedAt: "2026-10-20T12:34:56.789Z", +}); + +/** A provider with stubbed list and verification methods. */ +const stubProvider = ( + verifications: ServiceVerification[] = [verificationOf(SERVICES[0])] +) => { + const provider = new RestCloudRegistryProvider(BASE_URL); + return { + provider: provider as CloudRegistryProvider, + services: sinon.stub(provider, "getServices").resolves(SERVICES), + types: sinon.stub(provider, "getServiceTypes").resolves([TES]), + verifications: sinon + .stub(provider, "getVerifications") + .resolves(verifications), + get: sinon.stub(provider, "getServiceVerification").rejects(), + verify: sinon.stub(provider, "verifyService"), + }; +}; + +type El = ECCClientElixirCloudRegistryServices; +type Badge = ECCClientElixirCloudRegistryServiceVerification; + +/** The services list inside the component. */ +const list = (el: El) => + el.shadowRoot?.querySelector( + "ecc-client-ga4gh-service-registry-services" + ) as HTMLElement & { provider?: unknown; filter: boolean; pageSize: number }; + +/** The row of a service in the list. */ +const row = (el: El, id: string) => + list(el)?.shadowRoot?.querySelector( + `ecc-utils-design-table-row[data-service-id="${id}"]` + ) as HTMLElement | null; + +/** The verification component in the row of a service. */ +const badge = (el: El, id: string) => + row(el, id)?.querySelector( + "ecc-client-elixir-cloud-registry-service-verification" + ) as Badge | null; + +/** Whether the row of a service shows the "Verified" badge. */ +const shownVerified = (el: El, id: string) => { + const part = badge(el, id)?.shadowRoot?.querySelector('[part="badge"]'); + return !!part && part.textContent?.includes("Verified") === true; +}; + +/** Renders the component and waits until the verifications are shown. */ +const renderLoaded = async (provider: CloudRegistryProvider): Promise => { + const el = await fixture( + html`` + ); + await waitUntil(() => !!badge(el, "s3"), "verifications not shown"); + await badge(el, "s1")?.updateComplete; + return el; +}; + +describe("ecc-client-elixir-cloud-registry-services", () => { + afterEach(() => { + sinon.restore(); + }); + + it("shows the verified badge with one request for all services", async () => { + const stubs = stubProvider(); + const el = await renderLoaded(stubs.provider); + + expect(shownVerified(el, "s1")).to.equal(true); + expect(shownVerified(el, "s2")).to.equal(false); + expect(badge(el, "s2")?.verification).to.equal(null); + expect(stubs.verifications.callCount).to.equal(1); + expect(stubs.get.callCount).to.equal(0); + }); + + it("doesn't count a verification of another URL", async () => { + const stale = { ...verificationOf(SERVICES[1]), url: "https://old.org" }; + const el = await renderLoaded(stubProvider([stale]).provider); + + expect(badge(el, "s2")?.verification).to.equal(null); + }); + + it("passes its provider and list properties to the list", async () => { + const stubs = stubProvider(); + const el = await fixture( + html`` + ); + + expect(list(el).provider).to.equal(stubs.provider); + expect(list(el).filter).to.equal(false); + expect(list(el).pageSize).to.equal(2); + }); + + it("creates a provider for baseUrl", async () => { + const el = await fixture( + html`` + ); + + const provider = list(el).provider as RestCloudRegistryProvider; + expect(provider).to.be.instanceOf(RestCloudRegistryProvider); + expect(provider.baseUrl).to.equal(BASE_URL); + }); + + it("passes admin to the badges", async () => { + const stubs = stubProvider(); + const el = await renderLoaded(stubs.provider); + el.admin = true; + await el.updateComplete; + await list(el).updateComplete; + + expect(badge(el, "s2")?.admin).to.equal(true); + }); + + it("follows a verification by an admin", async () => { + const stubs = stubProvider([]); + const fresh = verificationOf(SERVICES[1]); + stubs.verify.resolves(fresh); + const el = await renderLoaded(stubs.provider); + el.admin = true; + await el.updateComplete; + + badge(el, "s2")?.dispatchEvent( + new CustomEvent("ecc-service-verification-changed", { + detail: { serviceId: "s2", verification: fresh }, + bubbles: true, + composed: true, + }) + ); + await el.updateComplete; + await list(el).updateComplete; + + expect(badge(el, "s2")?.verification).to.deep.include({ + url: fresh.url, + verifiedAt: fresh.verifiedAt, + }); + }); + + it("follows a removed verification", async () => { + const el = await renderLoaded(stubProvider().provider); + + badge(el, "s1")?.dispatchEvent( + new CustomEvent("ecc-service-verification-changed", { + detail: { serviceId: "s1", verification: null }, + bubbles: true, + composed: true, + }) + ); + await el.updateComplete; + await list(el).updateComplete; + + expect(badge(el, "s1")?.verification).to.equal(null); + }); + + it("renders the app's row actions after the badge", async () => { + const stubs = stubProvider(); + const el = await fixture( + html` + litHtml``} + >` + ); + await waitUntil(() => !!badge(el, "s1"), "verifications not shown"); + + expect(row(el, "s1")?.querySelector('[data-edit="s1"]')).to.not.equal(null); + expect(row(el, "s1")?.querySelector("[data-view-details]")).to.equal(null); + }); + + it("keeps the View Details button without row actions", async () => { + const el = await renderLoaded(stubProvider().provider); + const button = row(el, "s2")?.querySelector( + "[data-view-details]" + ) as HTMLElement; + const selected = oneEvent(el, "ecc-service-selected"); + + button.click(); + + expect((await selected).detail).to.deep.equal({ serviceId: "s2" }); + }); + + it("shows no badges and fires an event if loading fails", async () => { + const stubs = stubProvider(); + stubs.verifications.rejects(new Error("Forbidden")); + const el = document.createElement( + "ecc-client-elixir-cloud-registry-services" + ); + const failed = oneEvent( + el, + "ecc-service-verification-failed" + ) as Promise; + el.provider = stubs.provider; + document.body.append(el); + + const { detail } = await failed; + expect(detail).to.include({ serviceId: "", action: "load" }); + expect(detail.error).to.equal("Forbidden"); + await waitUntil(() => !!row(el, "s1"), "services not listed"); + expect(badge(el, "s1")).to.equal(null); + expect(stubs.get.callCount).to.equal(0); + el.remove(); + }); + + it("renders row actions that the app sets later", async () => { + const el = await renderLoaded(stubProvider().provider); + el.rowActions = (svc: ExternalService) => + litHtml``; + await el.updateComplete; + await list(el).updateComplete; + + expect(row(el, "s2")?.querySelector('[data-edit="s2"]')).to.not.equal(null); + }); + + it("explains a failure without a message", async () => { + const stubs = stubProvider(); + stubs.verifications.callsFake(() => Promise.reject(new Error(""))); + const el = document.createElement( + "ecc-client-elixir-cloud-registry-services" + ); + const failed = oneEvent( + el, + "ecc-service-verification-failed" + ) as Promise; + el.provider = stubs.provider; + document.body.append(el); + + expect((await failed).detail.error).to.equal( + "Failed to load the verifications." + ); + el.remove(); + }); + + it("fires an event for a provider without verifications", async () => { + const stubs = stubProvider(); + const provider = { + ...stubs.provider, + getServices: stubs.services, + getServiceTypes: stubs.types, + getVerifications: undefined, + } as unknown as CloudRegistryProvider; + const el = document.createElement( + "ecc-client-elixir-cloud-registry-services" + ); + const failed = oneEvent(el, "ecc-service-verification-failed"); + el.provider = provider; + document.body.append(el); + + expect((await failed).detail).to.include({ action: "load" }); + el.remove(); + }); + + it("ignores verifications of a replaced provider", async () => { + const slow = stubProvider(); + let answer: ((value: ServiceVerification[]) => void) | undefined; + slow.verifications.returns( + new Promise((resolve) => { + answer = resolve; + }) + ); + const el = await fixture( + html`` + ); + const next = stubProvider([]); + el.provider = next.provider; + await waitUntil(() => !!badge(el, "s1"), "verifications not shown"); + answer?.([verificationOf(SERVICES[0])]); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + await el.updateComplete; + await list(el).updateComplete; + + expect(list(el).provider).to.equal(next.provider); + expect(badge(el, "s1")?.verification).to.equal(null); + }); + + it("shows an error without provider or base URL", async () => { + const el = await fixture( + html`` + ); + await list(el).updateComplete; + + expect(list(el).shadowRoot?.textContent).to.include( + "Please provide either a base URL" + ); + }); +}); diff --git a/packages/ecc-client-elixir-cloud-registry/src/events/index.ts b/packages/ecc-client-elixir-cloud-registry/src/events/index.ts index fb24aee0..e9825227 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/events/index.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/events/index.ts @@ -4,3 +4,9 @@ export type { EccServiceCreateValidationFailedEvent } from "./ecc-service-create export type { EccServiceFormChangedEvent } from "./ecc-service-form-changed.js"; export type { EccServiceVerificationChangedEvent } from "./ecc-service-verification-changed.js"; export type { EccServiceVerificationFailedEvent } from "./ecc-service-verification-failed.js"; +// Events of the services list of the service-registry package, which the +// services list of this package passes on +export type { + EccServiceSelectedEvent, + EccServicesChangedEvent, +} from "@elixir-cloud/service-registry/dist/events/index.js"; diff --git a/packages/ecc-client-elixir-cloud-registry/tsconfig.json b/packages/ecc-client-elixir-cloud-registry/tsconfig.json index 8c445248..21b92d8c 100644 --- a/packages/ecc-client-elixir-cloud-registry/tsconfig.json +++ b/packages/ecc-client-elixir-cloud-registry/tsconfig.json @@ -16,7 +16,13 @@ "rootDir": "./", "declaration": true, "emitDeclarationOnly": true, - "skipLibCheck": true + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@elixir-cloud/service-registry/components/*": [ + "../../node_modules/@elixir-cloud/service-registry/dist/components/*" + ] + } }, "include": ["**/*.ts"], "exclude": ["node_modules", "dist"] diff --git a/packages/ecc-client-ga4gh-service-registry/src/components/services/index.ts b/packages/ecc-client-ga4gh-service-registry/src/components/services/index.ts index ba65da33..5688f186 100644 --- a/packages/ecc-client-ga4gh-service-registry/src/components/services/index.ts +++ b/packages/ecc-client-ga4gh-service-registry/src/components/services/index.ts @@ -3,10 +3,18 @@ import { ECCClientGa4ghServiceRegistryServices } from "./services.js"; export * from "./services.js"; export default ECCClientGa4ghServiceRegistryServices; -window.customElements.define( - "ecc-client-ga4gh-service-registry-services", - ECCClientGa4ghServiceRegistryServices -); +// Only in browsers, and only once (the services list of the +// cloud-registry package may have defined it already) +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-client-ga4gh-service-registry-services") +) { + window.customElements.define( + "ecc-client-ga4gh-service-registry-services", + ECCClientGa4ghServiceRegistryServices + ); +} declare global { interface HTMLElementTagNameMap { From 553733973e4c62dc4a4d7ee0ee5e45b776a5b5b5 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 21:28:14 +0100 Subject: [PATCH 08/10] docs(cloud-registry): document the services list Co-Authored-By: Claude Opus 5.5 --- .../docs/cloud-registry/components/_meta.json | 5 +- .../components/service-verification.mdx | 2 +- .../cloud-registry/components/services.mdx | 101 ++++++++++++++++++ .../docs/cloud-registry/introduction.mdx | 1 + .../service-registry/components/services.mdx | 2 +- 5 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 apps/documentation/pages/docs/cloud-registry/components/services.mdx diff --git a/apps/documentation/pages/docs/cloud-registry/components/_meta.json b/apps/documentation/pages/docs/cloud-registry/components/_meta.json index b54e156f..2517c4a5 100644 --- a/apps/documentation/pages/docs/cloud-registry/components/_meta.json +++ b/apps/documentation/pages/docs/cloud-registry/components/_meta.json @@ -1,4 +1,5 @@ { "service-create": "Service Create", - "service-verification": "Service Verification" -} \ No newline at end of file + "service-verification": "Service Verification", + "services": "Services" +} diff --git a/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx index e05b6403..eb36299b 100644 --- a/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx +++ b/apps/documentation/pages/docs/cloud-registry/components/service-verification.mdx @@ -117,7 +117,7 @@ services.map((service) => ( After a verify or remove, the component shows the result for that service until the app sets `verification` again, also if the provider is replaced meanwhile by one for the same registry (the same base URL, e.g. with a new token); update your verifications from `ecc-service-verification-changed`, as above. Pass a stable value (e.g. the object from the map, not one built on each render): every new value counts as set again. - The services list of the GA4GH Service Registry package (`ecc-client-ga4gh-service-registry-services`) doesn't show verifications itself; place the component in a row's `actions-` slot or in your own list. + For the registry's services list with badges, use [`ecc-client-elixir-cloud-registry-services`](./services), which does all of this. The list of the GA4GH Service Registry package (`ecc-client-ga4gh-service-registry-services`) doesn't show verifications itself. ## Properties diff --git a/apps/documentation/pages/docs/cloud-registry/components/services.mdx b/apps/documentation/pages/docs/cloud-registry/components/services.mdx new file mode 100644 index 00000000..1aebb84a --- /dev/null +++ b/apps/documentation/pages/docs/cloud-registry/components/services.mdx @@ -0,0 +1,101 @@ +import { Callout, Tabs } from 'nextra/components' +import ComponentName from '@/components/common/ComponentName' + +# EccClientElixirCloudRegistryServices Component + + + +## Overview + +The `ecc-client-elixir-cloud-registry-services` component lists the services of a Cloud Registry and shows a "Verified" badge for the verified ones. It is the [services list](/docs/service-registry/components/services) of the GA4GH Service Registry package (search, filters, pages, row actions) with a [service verification](./service-verification) component in each row. + +It loads all verifications with one request (`GET /verifications`, Cloud Registry 0.4.0 and later), not one per row. A verification only counts while the service still has the verified URL and API type (see [Verifications](/docs/cloud-registry/providers#verifications)). Rows show the badge once the verifications are loaded; if loading fails, the list shows no badges and fires `ecc-service-verification-failed`. + +## Usage + + + + ```tsx showLineNumbers copy filename="services.tsx" + import React, { useMemo } from 'react'; + import { EccClientElixirCloudRegistryServices } from '@elixir-cloud/cloud-registry/react/services'; + import { RestCloudRegistryProvider } from '@elixir-cloud/cloud-registry/providers'; + + export default function Services({ me }) { + // created once: a new provider makes the list load again + const provider = useMemo( + () => new RestCloudRegistryProvider('https://cloud-registry.example.com/ga4gh/registry/v1'), + [] + ); + + return ( + console.log(event.detail.serviceId)} + /> + ); + } + ``` + + + ```html showLineNumbers copy filename="services.html" + + + + ``` + + + +## Properties + +| Property | Type | Default | Description | +|--------------|-----------------------------------------|-------------|-------------| +| `baseUrl` | `string` | `""` | Base URL of the Cloud Registry API, if no `provider` is set | +| `provider` | `CloudRegistryProvider` | `undefined` | Data provider (see [Providers](/docs/cloud-registry/providers)); takes precedence over `baseUrl` | +| `admin` | `boolean` | `false` | Whether to offer "Verify" and "Remove verification" in each row | +| `filter` | `boolean` | `true` | Whether to show the type, organization and environment filters | +| `search` | `boolean` | `true` | Whether to show the search field | +| `pageSize` | `number` | `10` | Services per page (attribute `page-size`); 0 or less shows all services on one page | +| `rowActions` | `(service: ExternalService) => unknown` | `undefined` | Renders the actions of each row after the badge; without it, each row has a "View Details" button | + +### `provider` and `baseUrl` +Replacing the provider (or changing `baseUrl`) renders a new list, which loads the services and the verifications again. In React, create the provider only when its inputs change, e.g. with `useMemo`. Custom providers implement the optional `getVerifications()` of `CloudRegistryProvider`; without it, the list shows no badges and fires `ecc-service-verification-failed`. + +### `admin` +Apps take the value from `admin` of the registry's `GET /me`. Admins see "Not verified" and "Verify" for unverified services and "Remove verification" for verified ones. The list follows their changes without loading the verifications again. The registry checks permissions itself. + +### `filter`, `search` and `pageSize` +As in the [services list](/docs/service-registry/components/services) of the GA4GH Service Registry package, which this component passes them to. + +### `rowActions` +As in the [services list](/docs/service-registry/components/services#rowactions): a function that renders the actions of a row from its service, e.g. Edit and Delete buttons. They follow the badge. Set a new function when what it renders changes. + + + The `actions-` slots of the services list aren't available through this component; use `rowActions` instead. + + +## Events + +| Event Name | React Event Name | Detail Type | Description | +|------------------------------------|-----------------------------------|-------------|-------------| +| `ecc-services-changed` | `onEccServicesChanged` | `{ services: ExternalService[] }` | Fired when the services are loaded | +| `ecc-service-selected` | `onEccServiceSelected` | `{ serviceId: string }` | Fired when a service, its row or its "View Details" button is clicked | +| `ecc-service-verification-changed` | `onEccServiceVerificationChanged` | `{ serviceId: string, verification: Verification \| null }` | Fired when an admin verified a service or removed its verification | +| `ecc-service-verification-failed` | `onEccServiceVerificationFailed` | `{ serviceId: string, action: "load" \| "verify" \| "remove", error: string, status?: number, ... }` | Fired when loading the verifications, or verifying a service or removing its verification, fails | + +### `ecc-service-verification-failed` +When loading the verifications of the list fails, `serviceId` is empty and `action` is `load`; `status` is the HTTP status if there is one, and `cause` the error that the provider threw. Failures of a row's verify or remove carry the row's service ID, as described for the [service verification](./service-verification#ecc-service-verification-failed) component. + +## CSS parts + +| Part | Description | +|--------|-------------| +| `list` | The services list (`ecc-client-ga4gh-service-registry-services`) | diff --git a/apps/documentation/pages/docs/cloud-registry/introduction.mdx b/apps/documentation/pages/docs/cloud-registry/introduction.mdx index 0be6e24c..d869dbb6 100644 --- a/apps/documentation/pages/docs/cloud-registry/introduction.mdx +++ b/apps/documentation/pages/docs/cloud-registry/introduction.mdx @@ -21,6 +21,7 @@ The `Cloud Registry` package provides web components for managing and creating s - [``](./components/service-create): A component for creating new service registrations. - [``](./components/service-verification): A component that shows whether a service is verified, and lets admins verify it or remove its verification. +- [``](./components/services): The services list of the registry, with a "Verified" badge for verified services. - [``](../service-registry/components/services): A component for listing existing service registrations. - [``](../service-registry/components/service): A component for displaying details of a specific service. diff --git a/apps/documentation/pages/docs/service-registry/components/services.mdx b/apps/documentation/pages/docs/service-registry/components/services.mdx index 0293e4a8..3b1885ba 100644 --- a/apps/documentation/pages/docs/service-registry/components/services.mdx +++ b/apps/documentation/pages/docs/service-registry/components/services.mdx @@ -168,7 +168,7 @@ Controls whether the search input field is displayed. The search matches the ID, Controls whether the filters are displayed: by type (the types the registry lists at `/services/types`, or else those of the loaded services), by organization, and by environment (only if a service has one). The filters, the search and the page combine; changing the search or a filter goes back to the first page. Only filters on display apply, and only with a value that is still among their options (e.g. after a reload from another registry), so the list never narrows without a visible reason. - The GA4GH Service Registry API doesn't describe whether a service is verified, so there is no filter for it yet. + The GA4GH Service Registry API doesn't describe whether a service is verified, so there is no filter for it yet. To show which services of a Cloud Registry are verified, use [`ecc-client-elixir-cloud-registry-services`](/docs/cloud-registry/components/services). From cdf3da2313a330d19054ce8f6f967e86ecd53b7d Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 21:50:17 +0100 Subject: [PATCH 09/10] fix(cloud-registry): ignore replaced service lists Ignore ecc-services-changed from a list of a previous provider, import the list's event types from the public events export, and test the remaining branches. Co-Authored-By: Claude Opus 5.5 --- .../src/components/services/services.ts | 3 + .../services/tests/services.test.ts | 85 +++++++++++++++++++ .../src/events/index.ts | 2 +- .../tsconfig.json | 3 + 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts b/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts index 5cd9438c..ad765b3f 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts @@ -176,6 +176,9 @@ export class ECCClientElixirCloudRegistryServices extends LitElement { private handleServicesChanged( e: CustomEvent<{ services: ExternalService[] }> ): void { + // A replaced list (of a previous provider) may still answer + const list = e.currentTarget as { provider?: unknown } | null; + if (list?.provider !== this.shownProvider) return; this.services = e.detail.services; } diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts b/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts index 2c30efb1..4dcba891 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts @@ -327,4 +327,89 @@ describe("ecc-client-elixir-cloud-registry-services", () => { "Please provide either a base URL" ); }); + + it("ignores services of a replaced list", async () => { + const el = await renderLoaded(stubProvider().provider); + const old = list(el); + el.provider = stubProvider([]).provider; + await el.updateComplete; + await waitUntil(() => !!badge(el, "s1"), "verifications not shown"); + + old.dispatchEvent( + new CustomEvent("ecc-services-changed", { + detail: { services: [service("other")] }, + }) + ); + await el.updateComplete; + + expect(list(el)).to.not.equal(old); + const internals = el as unknown as { services: ExternalService[] }; + expect(internals.services.map((svc) => svc.id)).to.deep.equal([ + "s1", + "s2", + "s3", + ]); + }); + + it("ignores verification changes before the verifications load", async () => { + const stubs = stubProvider(); + stubs.verifications.returns( + new Promise(() => { + // never settles + }) + ); + const el = await fixture( + html`` + ); + + list(el).dispatchEvent( + new CustomEvent("ecc-service-verification-changed", { + detail: { serviceId: "s1", verification: null }, + }) + ); + await el.updateComplete; + + const internals = el as unknown as { verifications?: unknown }; + expect(internals.verifications).to.equal(undefined); + }); + + it("creates a new provider when baseUrl changes", async () => { + const el = await fixture( + html`` + ); + const first = list(el).provider; + el.baseUrl = `${BASE_URL}/other`; + await el.updateComplete; + + const provider = list(el).provider as RestCloudRegistryProvider; + expect(provider).to.not.equal(first); + expect(provider.baseUrl).to.equal(`${BASE_URL}/other`); + el.baseUrl = ""; + await el.updateComplete; + expect(list(el).provider).to.equal(undefined); + }); + + it("passes the status of a failed load on", async () => { + const stubs = stubProvider(); + const error = Object.assign(new Error("Forbidden"), { status: 403 }); + stubs.verifications.rejects(error); + const el = document.createElement( + "ecc-client-elixir-cloud-registry-services" + ); + const failed = oneEvent( + el, + "ecc-service-verification-failed" + ) as Promise; + el.provider = stubs.provider; + document.body.append(el); + + const { detail } = await failed; + expect(detail.status).to.equal(403); + expect(detail.cause).to.equal(error); + el.remove(); + }); }); diff --git a/packages/ecc-client-elixir-cloud-registry/src/events/index.ts b/packages/ecc-client-elixir-cloud-registry/src/events/index.ts index e9825227..7f916d6d 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/events/index.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/events/index.ts @@ -9,4 +9,4 @@ export type { EccServiceVerificationFailedEvent } from "./ecc-service-verificati export type { EccServiceSelectedEvent, EccServicesChangedEvent, -} from "@elixir-cloud/service-registry/dist/events/index.js"; +} from "@elixir-cloud/service-registry/events"; diff --git a/packages/ecc-client-elixir-cloud-registry/tsconfig.json b/packages/ecc-client-elixir-cloud-registry/tsconfig.json index 21b92d8c..28098208 100644 --- a/packages/ecc-client-elixir-cloud-registry/tsconfig.json +++ b/packages/ecc-client-elixir-cloud-registry/tsconfig.json @@ -21,6 +21,9 @@ "paths": { "@elixir-cloud/service-registry/components/*": [ "../../node_modules/@elixir-cloud/service-registry/dist/components/*" + ], + "@elixir-cloud/service-registry/events": [ + "../../node_modules/@elixir-cloud/service-registry/dist/events/index" ] } }, From 503e1af3882c0c87233aa481de4cbe9faef8d8ac Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Sat, 14 Nov 2026 22:12:19 +0100 Subject: [PATCH 10/10] fix(cloud-registry): scope list state to its list Address Sourcery: number the lists so that answers and events of a replaced list are ignored, listen for verification changes on the badges so that rows off the page still count, reload the verifications after a failed verify or remove, and treat an empty page-size attribute as the default. Co-Authored-By: Claude Opus 5.5 --- .../cloud-registry/components/services.mdx | 4 +- .../src/components/services/services.ts | 73 +++++++++++++++--- .../services/tests/services.test.ts | 77 +++++++++++++++---- 3 files changed, 125 insertions(+), 29 deletions(-) diff --git a/apps/documentation/pages/docs/cloud-registry/components/services.mdx b/apps/documentation/pages/docs/cloud-registry/components/services.mdx index 1aebb84a..f9215e4c 100644 --- a/apps/documentation/pages/docs/cloud-registry/components/services.mdx +++ b/apps/documentation/pages/docs/cloud-registry/components/services.mdx @@ -63,14 +63,14 @@ It loads all verifications with one request (`GET /verifications`, Cloud Registr | `admin` | `boolean` | `false` | Whether to offer "Verify" and "Remove verification" in each row | | `filter` | `boolean` | `true` | Whether to show the type, organization and environment filters | | `search` | `boolean` | `true` | Whether to show the search field | -| `pageSize` | `number` | `10` | Services per page (attribute `page-size`); 0 or less shows all services on one page | +| `pageSize` | `number` | `10` | Services per page (attribute `page-size`); 0 or less shows all services on one page, an empty attribute 10 | | `rowActions` | `(service: ExternalService) => unknown` | `undefined` | Renders the actions of each row after the badge; without it, each row has a "View Details" button | ### `provider` and `baseUrl` Replacing the provider (or changing `baseUrl`) renders a new list, which loads the services and the verifications again. In React, create the provider only when its inputs change, e.g. with `useMemo`. Custom providers implement the optional `getVerifications()` of `CloudRegistryProvider`; without it, the list shows no badges and fires `ecc-service-verification-failed`. ### `admin` -Apps take the value from `admin` of the registry's `GET /me`. Admins see "Not verified" and "Verify" for unverified services and "Remove verification" for verified ones. The list follows their changes without loading the verifications again. The registry checks permissions itself. +Apps take the value from `admin` of the registry's `GET /me`. Admins see "Not verified" and "Verify" for unverified services and "Remove verification" for verified ones. The list follows their changes without loading the verifications again, also for rows that are no longer shown by then (e.g. on another page). After a failed verify or remove, it loads the verifications again, as the registry may have applied the change. The registry checks permissions itself. ### `filter`, `search` and `pageSize` As in the [services list](/docs/service-registry/components/services) of the GA4GH Service Registry package, which this component passes them to. diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts b/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts index ad765b3f..022c100d 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/services/services.ts @@ -85,7 +85,17 @@ export class ECCClientElixirCloudRegistryServices extends LitElement { @property({ type: Boolean }) admin = false; @property({ type: Boolean }) filter = true; @property({ type: Boolean }) search = true; - @property({ type: Number, attribute: "page-size" }) + @property({ + type: Number, + attribute: "page-size", + // An empty attribute is missing, not 0 (which shows all services) + converter: { + fromAttribute: (value: string | null) => + value === null || value.trim() === "" + ? DEFAULT_PAGE_SIZE + : Number(value), + }, + }) pageSize = DEFAULT_PAGE_SIZE; @property({ attribute: false }) rowActions?: RowActionsRenderer; @@ -102,6 +112,13 @@ export class ECCClientElixirCloudRegistryServices extends LitElement { /** The provider shown, whose verifications count. */ private shownProvider: CloudRegistryProvider | null = null; + /** + * Number of the list shown: it changes with the provider, so that + * answers and events of a previous list (also of an earlier use of the + * same provider) are ignored + */ + private generation = 0; + /** Verifications by service ID, matched against the services. */ private verified = new Map(); @@ -125,11 +142,14 @@ export class ECCClientElixirCloudRegistryServices extends LitElement { const provider = this.currentProvider(); if (provider !== this.shownProvider) { this.shownProvider = provider; + this.generation += 1; this.services = []; this.verifications = undefined; - if (provider) this.loadVerifications(provider); + if (provider) this.loadVerifications(provider, this.generation); } if ( + changedProperties.has("provider") || + changedProperties.has("baseUrl") || changedProperties.has("services") || changedProperties.has("verifications") || changedProperties.has("admin") || @@ -142,16 +162,17 @@ export class ECCClientElixirCloudRegistryServices extends LitElement { /** Loads the verifications of all services with one request. */ private async loadVerifications( - provider: CloudRegistryProvider + provider: CloudRegistryProvider, + generation: number ): Promise { try { if (!provider.getVerifications) { throw new Error("The provider doesn't support verifications."); } const verifications = await provider.getVerifications(); - if (provider === this.shownProvider) this.verifications = verifications; + if (generation === this.generation) this.verifications = verifications; } catch (err) { - if (provider !== this.shownProvider) return; + if (generation !== this.generation) return; const detail: EccServiceVerificationFailedEvent["detail"] = { serviceId: "", action: "load", @@ -177,16 +198,23 @@ export class ECCClientElixirCloudRegistryServices extends LitElement { e: CustomEvent<{ services: ExternalService[] }> ): void { // A replaced list (of a previous provider) may still answer - const list = e.currentTarget as { provider?: unknown } | null; - if (list?.provider !== this.shownProvider) return; + const list = this.shadowRoot?.querySelector( + "ecc-client-ga4gh-service-registry-services" + ); + if (e.currentTarget !== list) return; this.services = e.detail.services; } - /** Updates the verifications after an admin verified or removed one. */ + /** + * Updates the verifications after an admin verified or removed one. The + * badge reports it also if its row is no longer shown (e.g. on another + * page), as the listener is on the badge. + */ private handleVerificationChanged( - e: EccServiceVerificationChangedEvent + e: EccServiceVerificationChangedEvent, + generation: number ): void { - if (!this.verifications) return; + if (generation !== this.generation || !this.verifications) return; const { serviceId, verification } = e.detail; const others = this.verifications.filter( (item) => item.serviceId !== serviceId @@ -196,6 +224,21 @@ export class ECCClientElixirCloudRegistryServices extends LitElement { : others; } + /** + * Loads the verifications again after a verify or remove failed, as the + * registry may have applied it (e.g. if the answer was lost) + */ + private handleVerificationFailed( + e: EccServiceVerificationFailedEvent, + generation: number + ): void { + const provider = this.shownProvider; + if (generation !== this.generation || !provider) return; + if (e.detail.action !== "load") { + this.loadVerifications(provider, generation); + } + } + private handleViewDetails(serviceId: string): void { this.dispatchEvent( new CustomEvent("ecc-service-selected", { @@ -213,6 +256,7 @@ export class ECCClientElixirCloudRegistryServices extends LitElement { private createRowActions(): RowActionsRenderer { const { verified, verifications, admin, rowActions } = this; const provider = this.shownProvider; + const { generation } = this; return (service: ExternalService) => html`
${verifications && provider @@ -221,6 +265,12 @@ export class ECCClientElixirCloudRegistryServices extends LitElement { .service=${service} .verification=${verified.get(service.id) ?? null} ?admin=${admin} + @ecc-service-verification-changed=${( + e: EccServiceVerificationChangedEvent + ) => this.handleVerificationChanged(e, generation)} + @ecc-service-verification-failed=${( + e: EccServiceVerificationFailedEvent + ) => this.handleVerificationFailed(e, generation)} >` : nothing} ${rowActions @@ -239,7 +289,7 @@ export class ECCClientElixirCloudRegistryServices extends LitElement { render() { // A new provider needs a new list, which reads it when it first renders return html`${keyed( - this.shownProvider, + this.generation, html`` )}`; } diff --git a/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts b/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts index 4dcba891..eda8382e 100644 --- a/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts +++ b/packages/ecc-client-elixir-cloud-registry/src/components/services/tests/services.test.ts @@ -204,8 +204,13 @@ describe("ecc-client-elixir-cloud-registry-services", () => { ); await waitUntil(() => !!badge(el, "s1"), "verifications not shown"); - expect(row(el, "s1")?.querySelector('[data-edit="s1"]')).to.not.equal(null); + const edit = row(el, "s1")?.querySelector('[data-edit="s1"]'); + expect(edit).to.not.equal(null); expect(row(el, "s1")?.querySelector("[data-view-details]")).to.equal(null); + // the badge comes first + const cells = Array.from(edit?.parentElement?.children ?? []); + expect(cells.indexOf(badge(el, "s1") as Element)).to.equal(0); + expect(cells.indexOf(edit as Element)).to.equal(1); }); it("keeps the View Details button without row actions", async () => { @@ -351,28 +356,70 @@ describe("ecc-client-elixir-cloud-registry-services", () => { ]); }); - it("ignores verification changes before the verifications load", async () => { - const stubs = stubProvider(); - stubs.verifications.returns( - new Promise(() => { - // never settles + it("ignores verification changes of a replaced list", async () => { + const el = await renderLoaded(stubProvider().provider); + const old = badge(el, "s1"); + el.provider = stubProvider([]).provider; + await el.updateComplete; + await waitUntil(() => !!badge(el, "s1"), "verifications not shown"); + + old?.dispatchEvent( + new CustomEvent("ecc-service-verification-changed", { + detail: { serviceId: "s2", verification: verificationOf(SERVICES[1]) }, }) ); - const el = await fixture( - html`` - ); + await el.updateComplete; + await list(el).updateComplete; + + expect(badge(el, "s2")?.verification).to.equal(null); + }); - list(el).dispatchEvent( + it("follows a change of a row that is no longer shown", async () => { + const el = await renderLoaded(stubProvider([]).provider); + const offPage = badge(el, "s3"); + el.pageSize = 1; + await el.updateComplete; + await list(el).updateComplete; + expect(row(el, "s3")).to.equal(null); + + offPage?.dispatchEvent( new CustomEvent("ecc-service-verification-changed", { - detail: { serviceId: "s1", verification: null }, + detail: { serviceId: "s3", verification: verificationOf(SERVICES[2]) }, }) ); + el.pageSize = 10; await el.updateComplete; + await list(el).updateComplete; + + expect(badge(el, "s3")?.verification).to.not.equal(null); + }); + + it("loads the verifications again after a failed verify", async () => { + const stubs = stubProvider(); + const el = await renderLoaded(stubs.provider); + + badge(el, "s2")?.dispatchEvent( + new CustomEvent("ecc-service-verification-failed", { + detail: { serviceId: "s2", action: "verify", error: "Lost" }, + }) + ); + badge(el, "s2")?.dispatchEvent( + new CustomEvent("ecc-service-verification-failed", { + detail: { serviceId: "s2", action: "load", error: "Lost" }, + }) + ); + + expect(stubs.verifications.callCount).to.equal(2); + }); + + it("uses the default page size for an empty attribute", async () => { + const el = await fixture( + html`` + ); - const internals = el as unknown as { verifications?: unknown }; - expect(internals.verifications).to.equal(undefined); + expect(el.pageSize).to.equal(10); }); it("creates a new provider when baseUrl changes", async () => {