diff --git a/src/base-command.ts b/src/base-command.ts index 9066108c1..f4f6a559c 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -1637,6 +1637,37 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { return clientId; } + /** + * The client ID a minted token is issued to: the command's --client-id, or + * the client ID the CLI acts as. "none" issues an anonymous token, with a + * warning, and "*" is refused rather than minting a token that can act as + * any client. + */ + protected resolveTokenClientId(flags: BaseFlags): string | undefined { + const requested = flags["client-id"]; + if (requested === undefined) return this.resolveClientId(flags); + + let identity; + try { + identity = resolveClientIdentity(requested, this.configManager); + } catch (error) { + if (error instanceof InvalidClientIdError) { + this.fail(error.message, flags, "clientId"); + } + + throw error; + } + + if (identity.optedOut) { + this.logWarning( + "Issuing a token with no client ID. Apps that require identified clients reject it.", + flags, + ); + } + + return identity.clientId; + } + private setClientId(options: Ably.ClientOptions, flags: BaseFlags): void { const clientId = this.resolveClientId(flags); if (clientId !== undefined) { diff --git a/src/commands/auth/issue-ably-token.ts b/src/commands/auth/issue-ably-token.ts index b3a5ee5ce..51a78ad0b 100644 --- a/src/commands/auth/issue-ably-token.ts +++ b/src/commands/auth/issue-ably-token.ts @@ -81,18 +81,8 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand { ttl: flags.ttl * 1000, // Convert to milliseconds for Ably SDK }; - // Handle client ID - use special "none" value to explicitly indicate no clientId - if (flags["client-id"]) { - if (flags["client-id"].toLowerCase() === "none") { - // No client ID - leave clientId undefined in the token params - } else { - // Use the provided client ID - tokenParams.clientId = flags["client-id"]; - } - } else { - // Default to the identity the CLI itself acts as - tokenParams.clientId = this.resolveClientId(flags); - } + const clientId = this.resolveTokenClientId(flags); + if (clientId !== undefined) tokenParams.clientId = clientId; // Create Ably REST client and request token const rest = await this.createAblyRestClient( @@ -126,9 +116,7 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand { value: tokenDetails.token, issuedAt: new Date(tokenDetails.issued).toISOString(), expiresAt: new Date(tokenDetails.expires).toISOString(), - ...(tokenDetails.clientId - ? { clientId: tokenDetails.clientId } - : {}), + clientId: tokenDetails.clientId ?? null, capability: tokenDetails.capability, }, }, @@ -145,11 +133,9 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand { `${formatLabel("Expires")} ${new Date(tokenDetails.expires).toISOString()}`, ); this.log(`${formatLabel("TTL")} ${flags.ttl} seconds`); - if (tokenDetails.clientId) { - this.log( - `${formatLabel("Client ID")} ${formatClientId(tokenDetails.clientId)}`, - ); - } + this.log( + `${formatLabel("Client ID")} ${tokenDetails.clientId ? formatClientId(tokenDetails.clientId) : "anonymous"}`, + ); this.log( `${formatLabel("Capability")} ${this.formatJsonOutput({ capability: tokenDetails.capability }, flags)}`, ); diff --git a/src/commands/auth/issue-jwt-token.ts b/src/commands/auth/issue-jwt-token.ts index 3c6025e89..17ef61696 100644 --- a/src/commands/auth/issue-jwt-token.ts +++ b/src/commands/auth/issue-jwt-token.ts @@ -17,6 +17,7 @@ interface JwtPayload { "x-ably-appId": string; "x-ably-capability": Record; "x-ably-clientId"?: string; + "x-ably-clientType"?: "server"; } export default class IssueJwtTokenCommand extends AblyBaseCommand { @@ -27,6 +28,7 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { '$ ably auth issue-jwt-token --capability \'{"*":["*"]}\'', '$ ably auth issue-jwt-token --capability \'{"chat:*":["publish","subscribe"], "status:*":["subscribe"]}\' --ttl 3600', "$ ably auth issue-jwt-token --client-id client123 --ttl 86400", + "$ ably auth issue-jwt-token --client-type server --client-id backend-worker", "$ ably auth issue-jwt-token --json", "$ ably auth issue-jwt-token --pretty-json", "$ ably auth issue-jwt-token --token-only", @@ -48,6 +50,11 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { description: 'Client ID to issue the token to (defaults to the client ID the CLI acts as). Use "none" to issue a token with no client ID.', }), + "client-type": Flags.string({ + description: + "Classify clients using the token as servers, exempt from MAU counting, by adding the signed x-ably-clientType claim", + options: ["server"], + }), "token-only": Flags.boolean({ default: false, description: @@ -102,22 +109,11 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { "x-ably-capability": capabilities, }; - // Handle client ID - use special "none" value to explicitly indicate no clientId - let clientId: null | string = null; - if (flags["client-id"]) { - if (flags["client-id"].toLowerCase() === "none") { - // No client ID - don't add it to the token - clientId = null; - } else { - // Use the provided client ID - jwtPayload["x-ably-clientId"] = flags["client-id"]; - clientId = flags["client-id"]; - } - } else { - // Default to the identity the CLI itself acts as - clientId = this.resolveClientId(flags) ?? null; - if (clientId) jwtPayload["x-ably-clientId"] = clientId; - } + const clientId = this.resolveTokenClientId(flags); + if (clientId !== undefined) jwtPayload["x-ably-clientId"] = clientId; + + const clientType = flags["client-type"] as "server" | undefined; + if (clientType) jwtPayload["x-ably-clientType"] = clientType; // Sign the JWT const token = jwt.sign(jwtPayload, keySecret, { @@ -141,7 +137,8 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { token: { appId, capability: capabilities, - ...(clientId ? { clientId } : {}), + clientId: clientId ?? null, + ...(clientType ? { clientType } : {}), expires: new Date(jwtPayload.exp * 1000).toISOString(), issued: new Date(jwtPayload.iat * 1000).toISOString(), keyId, @@ -165,8 +162,11 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { this.log(`${formatLabel("TTL")} ${flags.ttl} seconds`); this.log(`${formatLabel("App ID")} ${appId}`); this.log(`${formatLabel("Key ID")} ${keyId}`); - if (clientId) { - this.log(`${formatLabel("Client ID")} ${formatClientId(clientId)}`); + this.log( + `${formatLabel("Client ID")} ${clientId ? formatClientId(clientId) : "anonymous"}`, + ); + if (clientType) { + this.log(`${formatLabel("Client Type")} ${clientType}`); } this.log( `${formatLabel("Capability")} ${this.formatJsonOutput(capabilities, flags)}`, diff --git a/src/commands/auth/revoke-token.ts b/src/commands/auth/revoke-token.ts index 39ff5148e..8beaa90ef 100644 --- a/src/commands/auth/revoke-token.ts +++ b/src/commands/auth/revoke-token.ts @@ -1,5 +1,4 @@ import { Flags } from "@oclif/core"; -import * as https from "node:https"; import stripAnsi from "strip-ansi"; import { AblyBaseCommand } from "../../base-command.js"; @@ -97,128 +96,58 @@ export default class RevokeTokenCommand extends AblyBaseCommand { } } - try { - // Extract the keyName (appId.keyId) from the API key - const keyParts = apiKey.split(":"); - if (keyParts.length !== 2) { - this.fail( - "Invalid API key format. Expected format: appId.keyId:secret", - flags, - "revokeToken", - ); - } + let reauthNote = ""; + if (flags["allow-reauth-margin"]) { + reauthNote = + " Connected clients have a 30s grace period to obtain new tokens before disconnection."; + } - const keyName = keyParts[0]!; - const secret = keyParts[1]!; + try { + const rest = await this.createAblyRestClient({ + ...flags, + "api-key": apiKey, + }); + if (!rest) return; - const requestBody: Record = { - targets: [targetSpecifier], - }; + const response = await rest.auth.revokeTokens( + [{ type: clientId ? "clientId" : "revocationKey", value: targetValue }], + flags["allow-reauth-margin"] ? { allowReauthMargin: true } : undefined, + ); - let reauthNote = ""; - if (flags["allow-reauth-margin"]) { - requestBody.allowReauthMargin = true; - reauthNote = - " Connected clients have a 30s grace period to obtain new tokens before disconnection."; + const failure = response.results.find((result) => "error" in result); + if (failure && "error" in failure) { + this.fail(failure.error, flags, "revokeToken", { + target: targetSpecifier, + }); } - try { - // Make direct HTTPS request to Ably REST API - const response = await this.makeHttpRequest( - keyName, - secret, - requestBody, - ); - const successMessage = `Tokens matching ${targetLabel.toLowerCase()} ${formatResource(targetValue)} have been revoked.${reauthNote}`; - - if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - revocation: { - allowReauthMargin: flags["allow-reauth-margin"], - message: stripAnsi(successMessage), - target: targetSpecifier, - response, - }, + const successMessage = `Tokens matching ${targetLabel.toLowerCase()} ${formatResource(targetValue)} have been revoked.${reauthNote}`; + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + revocation: { + allowReauthMargin: flags["allow-reauth-margin"], + message: stripAnsi(successMessage), + target: targetSpecifier, + response, }, - flags, - ); - } else { - this.logSuccessMessage(successMessage, flags); - } - } catch (requestError: unknown) { - const error = requestError as Error & { statusCode?: number }; - if (error.statusCode === 404) { - this.fail( - "No matching tokens found or already revoked", - flags, - "revokeToken", - ); - } - throw requestError; + }, + flags, + ); + } else { + this.logSuccessMessage(successMessage, flags); } } catch (error) { + if ((error as { statusCode?: number }).statusCode === 404) { + this.fail( + "No matching tokens found or already revoked", + flags, + "revokeToken", + ); + } + this.fail(error, flags, "revokeToken"); } } - - // Helper method to make a direct HTTP request to the Ably REST API - private makeHttpRequest( - keyName: string, - secret: string, - requestBody: Record, - ): Promise | string | null> { - return new Promise((resolve, reject) => { - const encodedAuth = Buffer.from(`${keyName}:${secret}`).toString( - "base64", - ); - - const options = { - headers: { - Accept: "application/json", - Authorization: `Basic ${encodedAuth}`, - "Content-Type": "application/json", - }, - hostname: "rest.ably.io", - method: "POST", - path: `/keys/${keyName}/revokeTokens`, - port: 443, - }; - - const req = https.request(options, (res) => { - let data = ""; - - res.on("data", (chunk) => { - data += chunk; - }); - - res.on("end", () => { - if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { - try { - const jsonResponse: Record | null = - data.length > 0 - ? (JSON.parse(data) as Record) - : null; - resolve(jsonResponse); - } catch { - resolve(data); - } - } else { - const err = new Error( - `Request failed with status code ${res.statusCode}: ${data}`, - ) as Error & { statusCode?: number }; - err.statusCode = res.statusCode; - reject(err); - } - }); - }); - - req.on("error", (error) => { - reject(error); - }); - - req.write(JSON.stringify(requestBody)); - req.end(); - }); - } } diff --git a/src/services/client-identity.ts b/src/services/client-identity.ts index 2e306ffe2..b56dc6ce4 100644 --- a/src/services/client-identity.ts +++ b/src/services/client-identity.ts @@ -46,7 +46,7 @@ function validateClientId(value: string, source: ClientIdSource): void { if (value === "*") { throw new InvalidClientIdError( - `${origin} cannot be "*". The wildcard is only valid inside a token's capability; the CLI must act as one concrete client ID.`, + `${origin} cannot be "*". The CLI acts as, and issues tokens to, one concrete client ID.`, ); } } diff --git a/test/helpers/mock-ably-rest.ts b/test/helpers/mock-ably-rest.ts index f3e7a0275..19baf91e2 100644 --- a/test/helpers/mock-ably-rest.ts +++ b/test/helpers/mock-ably-rest.ts @@ -68,6 +68,7 @@ export interface MockRestAuth { clientId: string; createTokenRequest: Mock; requestToken: Mock; + revokeTokens: Mock; } /** @@ -232,6 +233,11 @@ function createMockRestAuth(): MockRestAuth { token: "mock-token", expires: Date.now() + 3600000, }), + revokeTokens: vi.fn().mockResolvedValue({ + successCount: 1, + failureCount: 0, + results: [], + }), }; } diff --git a/test/unit/commands/auth/issue-ably-token.test.ts b/test/unit/commands/auth/issue-ably-token.test.ts index 4390dac61..29505c7c6 100644 --- a/test/unit/commands/auth/issue-ably-token.test.ts +++ b/test/unit/commands/auth/issue-ably-token.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +import { + MOCK_DEFAULT_CLIENT_ID, + getMockConfigManager, +} from "../../../helpers/mock-config-manager.js"; import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; import { standardHelpTests, @@ -136,18 +139,41 @@ describe("auth:issue-ably-token command", () => { restMock.auth.createTokenRequest.mockResolvedValue({}); restMock.auth.requestToken.mockResolvedValue(mockTokenDetails); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["auth:issue-ably-token", "--client-id", "none"], import.meta.url, ); expect(stdout).toContain("Ably token generated."); - expect(stdout).not.toContain("Client ID:"); + expect(stdout).toContain("Client ID: anonymous"); + expect(stderr).toContain("Issuing a token with no client ID"); expect(restMock.auth.createTokenRequest).toHaveBeenCalled(); const tokenParams = restMock.auth.createTokenRequest.mock.calls[0][0]; expect(tokenParams.clientId).toBeUndefined(); }); + it("should default to the client ID the CLI acts as", async () => { + const restMock = getMockAblyRest(); + restMock.auth.createTokenRequest.mockResolvedValue({}); + + await runCommand(["auth:issue-ably-token"], import.meta.url); + + const tokenParams = restMock.auth.createTokenRequest.mock.calls[0][0]; + expect(tokenParams.clientId).toBe(MOCK_DEFAULT_CLIENT_ID); + }); + + it("should refuse a wildcard client ID", async () => { + const restMock = getMockAblyRest(); + + const { error } = await runCommand( + ["auth:issue-ably-token", "--client-id", "*"], + import.meta.url, + ); + + expect(error?.message).toContain('cannot be "*"'); + expect(restMock.auth.createTokenRequest).not.toHaveBeenCalled(); + }); + it("should output only token string with --token-only flag", async () => { const restMock = getMockAblyRest(); const mockTokenString = "mock-ably-token-only"; diff --git a/test/unit/commands/auth/issue-jwt-token.test.ts b/test/unit/commands/auth/issue-jwt-token.test.ts index 5790ca336..c12695d33 100644 --- a/test/unit/commands/auth/issue-jwt-token.test.ts +++ b/test/unit/commands/auth/issue-jwt-token.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from "vitest"; import { runCommand } from "@oclif/test"; import jwt from "jsonwebtoken"; -import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +import { + MOCK_DEFAULT_CLIENT_ID, + getMockConfigManager, +} from "../../../helpers/mock-config-manager.js"; import { standardHelpTests, standardArgValidationTests, @@ -128,6 +131,54 @@ describe("auth:issue-jwt-token command", () => { expect(decoded["x-ably-clientId"]).toBeUndefined(); }); + it("should default to the client ID the CLI acts as", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--token-only"], + import.meta.url, + ); + + expect(jwt.decode(stdout.trim(), { json: true })).toHaveProperty( + "x-ably-clientId", + MOCK_DEFAULT_CLIENT_ID, + ); + }); + + it("should refuse a wildcard client ID", async () => { + const { error } = await runCommand( + ["auth:issue-jwt-token", "--client-id", "*"], + import.meta.url, + ); + + expect(error?.message).toContain('cannot be "*"'); + }); + + it("should add the server claim with --client-type server", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--client-type", "server", "--json"], + import.meta.url, + ); + + const result = parseJsonOutput(stdout); + const token = (result.token as { value: string; clientType: string }) + .value; + expect(result.token).toHaveProperty("clientType", "server"); + expect(jwt.decode(token, { json: true })).toHaveProperty( + "x-ably-clientType", + "server", + ); + }); + + it("should omit the server claim by default", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--token-only"], + import.meta.url, + ); + + expect(jwt.decode(stdout.trim(), { json: true })).not.toHaveProperty( + "x-ably-clientType", + ); + }); + it("should output only token string with --token-only flag", async () => { const { stdout } = await runCommand( ["auth:issue-jwt-token", "--token-only"], @@ -247,13 +298,13 @@ describe("auth:issue-jwt-token command", () => { expect(stdout).toContain("Client ID: my-client"); }); - it("should omit client ID line when not specified with none", async () => { + it("should show the token as anonymous with none", async () => { const { stdout } = await runCommand( ["auth:issue-jwt-token", "--client-id", "none"], import.meta.url, ); - expect(stdout).not.toContain("Client ID:"); + expect(stdout).toContain("Client ID: anonymous"); }); }); }); diff --git a/test/unit/commands/auth/revoke-token.test.ts b/test/unit/commands/auth/revoke-token.test.ts index 9708860c7..41598cae8 100644 --- a/test/unit/commands/auth/revoke-token.test.ts +++ b/test/unit/commands/auth/revoke-token.test.ts @@ -1,9 +1,8 @@ import { Readable } from "node:stream"; -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { runCommand } from "@oclif/test"; -import nock from "nock"; -import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardFlagTests, @@ -14,14 +13,6 @@ describe("auth:revoke-token command", () => { const mockClientId = "test-client-id"; const mockRevocationKey = "group1"; - beforeEach(() => { - nock.cleanAll(); - }); - - afterEach(() => { - nock.cleanAll(); - }); - standardHelpTests("auth:revoke-token", import.meta.url); describe("argument validation", () => { @@ -60,31 +51,22 @@ describe("auth:revoke-token command", () => { describe("functionality", () => { describe("token revocation by client ID", () => { it("should successfully revoke tokens for a client ID", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`clientId:${mockClientId}`], - }) - .reply(200, {}); + const { auth } = getMockAblyRest(); const { stderr } = await runCommand( ["auth:revoke-token", "--client-id", mockClientId, "--force"], import.meta.url, ); + expect(auth.revokeTokens).toHaveBeenCalledWith( + [{ type: "clientId", value: mockClientId }], + undefined, + ); expect(stderr).toContain("have been revoked"); }); it("should include allowReauthMargin when --allow-reauth-margin is provided", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`clientId:${mockClientId}`], - allowReauthMargin: true, - }) - .reply(200, {}); + const { auth } = getMockAblyRest(); const { stderr } = await runCommand( [ @@ -97,18 +79,14 @@ describe("auth:revoke-token command", () => { import.meta.url, ); + expect(auth.revokeTokens).toHaveBeenCalledWith( + [{ type: "clientId", value: mockClientId }], + { allowReauthMargin: true }, + ); expect(stderr).toContain("have been revoked"); }); it("should output JSON format when --json flag is used", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`clientId:${mockClientId}`], - }) - .reply(200, { issuedBefore: 1234567890 }); - const { stdout } = await runCommand( [ "auth:revoke-token", @@ -139,13 +117,7 @@ describe("auth:revoke-token command", () => { describe("token revocation by revocation key", () => { it("should successfully revoke tokens for a revocation key", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`revocationKey:${mockRevocationKey}`], - }) - .reply(200, {}); + const { auth } = getMockAblyRest(); const { stderr } = await runCommand( [ @@ -157,6 +129,10 @@ describe("auth:revoke-token command", () => { import.meta.url, ); + expect(auth.revokeTokens).toHaveBeenCalledWith( + [{ type: "revocationKey", value: mockRevocationKey }], + undefined, + ); expect(stderr).toContain("have been revoked"); }); }); @@ -201,10 +177,7 @@ describe("auth:revoke-token command", () => { it("should cancel when user declines confirmation", async () => { mockStdinAnswer("n"); - - const revokeNock = nock("https://rest.ably.io") - .post(/\/keys\/.*\/revokeTokens/) - .reply(200, {}); + const { auth } = getMockAblyRest(); const { stderr } = await runCommand( ["auth:revoke-token", "--client-id", mockClientId], @@ -212,18 +185,16 @@ describe("auth:revoke-token command", () => { ); expect(stderr).toContain("Revocation cancelled"); - expect(revokeNock.isDone()).toBe(false); + expect(auth.revokeTokens).not.toHaveBeenCalled(); }); }); }); describe("error handling", () => { it("should handle token not found error", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`) - .reply(404, "token_not_found"); + getMockAblyRest().auth.revokeTokens.mockRejectedValueOnce( + Object.assign(new Error("token_not_found"), { statusCode: 404 }), + ); const { error } = await runCommand( ["auth:revoke-token", "--client-id", mockClientId, "--force"], @@ -236,35 +207,42 @@ describe("auth:revoke-token command", () => { }); it("should handle authentication error (invalid API key)", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`) - .reply(401, { error: { message: "Unauthorized" } }); + getMockAblyRest().auth.revokeTokens.mockRejectedValueOnce( + Object.assign(new Error("Unauthorized"), { + code: 40100, + statusCode: 401, + }), + ); const { error } = await runCommand( ["auth:revoke-token", "--client-id", mockClientId, "--force"], import.meta.url, ); - expect(error).toBeDefined(); - expect(error?.message).toMatch(/401|error|revoking/i); + expect(error?.message).toContain("Unauthorized"); }); - it("should handle server error", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`) - .reply(500, { error: "Internal Server Error" }); + it("should report a per-target failure", async () => { + getMockAblyRest().auth.revokeTokens.mockResolvedValueOnce({ + successCount: 0, + failureCount: 1, + results: [ + { + target: `clientId:${mockClientId}`, + error: Object.assign(new Error("Target rejected"), { + code: 40000, + statusCode: 400, + }), + }, + ], + }); const { error } = await runCommand( ["auth:revoke-token", "--client-id", mockClientId, "--force"], import.meta.url, ); - expect(error).toBeDefined(); - expect(error?.message).toMatch(/500|error|revoking/i); + expect(error?.message).toContain("Target rejected"); }); });