diff --git a/.talismanrc b/.talismanrc index 3f48fa6..8abebce 100644 --- a/.talismanrc +++ b/.talismanrc @@ -2,13 +2,13 @@ fileignoreconfig: - filename: src/core/failure.test.ts checksum: 242d43885d1e3ef46edf1e347899a90549ebf95ec6827426746852f7a6dfc418 - filename: test/integration/retired-commands.test.ts - checksum: 3d2af18ef58effd89e398e6cb523f6680d7385eb34cc660f559be59114ab07f7 + checksum: 620ae8b1cc10b2d1aed68418a0a82cfb0bb23edd99ad93e6f10ddf5089f446dd - filename: test/integration/manage-base-path.test.ts checksum: 79eb9ad420ea47d5bee8045d812c45b010bbeeafcaf5f85d11653140b0cd0db1 - filename: src/transport/rest-client.ts - checksum: bf0b19bdce7796208e7a8f29f7fdc8aad36ed2ffc10e148439fa19fd166c63d3 + checksum: 755aabbc8ee7a2440d4b696e0c502b612c6a60c50fea38db6537d46849521b41 - filename: src/transport/rest-client.test.ts - checksum: 3b5751449c61f7a1d71b978ed03b9472f714e12dd1679f691bbd4dd8f84be923 + checksum: 02981226eef6131634d0903fc04fa73a5fb8cac82213f00f49d5f66950d75f8e - filename: src/transport/cma-client.ts checksum: 87b8399a9b84ea60fe322802c2e5270772858ba85ac33c436941fe7cf40afc38 - filename: src/transport/auth-strategy.test.ts @@ -228,7 +228,7 @@ fileignoreconfig: - "a project name passed on argv to the project resolver" - "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)" - filename: AGENTS.md - checksum: dcebcddea3f29849c0b287e9cf8786a4e306394fe96b9ca4136525298eee6379 + checksum: 2a47698f4c9e0f3576b0ccd5fd1852b144ee3729d4e1e38c828c0d77d042e3fd allowed_patterns: - "Do not add an exemption to a guard to make a new file pass" - "passes its own error wording to `client.request\\(req, MESSAGES\\)`" @@ -252,7 +252,7 @@ fileignoreconfig: - filename: src/git/local-repository.ts checksum: b9091ed135dfebdf590a5412bbfba2b2f96d4a177cf944a20909b93405268e9f - filename: test/guards/layering.test.ts - checksum: 6740eea4e84f088081e31ac0c12c0abb2c402dcebbe0eb0ef454f60873da0194 + checksum: f8d2a8cab01feca56c55bc17fb1bced4cf76575d87b19aaee9b73bab595e52a1 - filename: src/commands/launch/projects/create.test.ts checksum: 9c40b4b6ed4f7d678ecb921a71f5af7284cd685c1337f47144456ae43a38b1ad - filename: src/commands/launch/projects/delete.test.ts @@ -324,7 +324,7 @@ fileignoreconfig: - filename: src/commands/launch/env/update.test.ts checksum: 2da219594e7bdd589adc0eff9ff035252a75aaa51dfa20514b18fd3266d7b752 - filename: README.md - checksum: b956e3b147ebb3513472f5f28f47f7895d86201d786f1ed80b10c1fd785267e7 + checksum: 2e2ca75a02e98610c5fd8a3fa01a1def99b5ec161c80b2a76532fe039f051926 allowed_patterns: - "password-protection" - "-p, --password" diff --git a/AGENTS.md b/AGENTS.md index 33c5f0b..6a878aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,8 @@ src/ git/ the internal git-namespace / repository / branch lookups organizations/ the --org flag, its organization picker, and the lookup behind it, which reads the Contentstack Management API rather than Launch + cache/ the CDN cache purge behind launch:cache:purge: CacheApi, the --path / --tags / --hosts + inputs and their normalisation, and the success lines functions/ the cloud-function runtime and the serve flags, treated as a resource resources.ts the composition root: it assembles the catalog, the resolution table, the dependency map and the api surface out of the resources @@ -552,6 +554,16 @@ mutation, because the server may already have acted on it. A `Retry-After` given lengthens the wait up to `MAX_RETRY_AFTER_MS` (30 s); an HTTP date or anything unparseable falls back to the policy's own backoff. +A request can opt out of retrying a 429 by code: `RestRequest.noRetryCodes` lists the error codes whose 429 +is final, and `RetryPolicy.shouldRetry` takes the codes the answer carried and refuses to retry when any is +listed. It is per request, so a 429 with any other code, or with no code, and every request that sets nothing, +still retries on the usual budget. Only `CacheApi.purge` sets it, with +`launch.ORGANIZATION.REVALIDATE_RATE_LIMIT_EXCEEDED`: that quota is a fixed UTC day already spent, so a retry +only delays the error and can never succeed. `test/integration/transport-retry.test.ts` proves on the wire that the +quota 429 is sent once and an ordinary 429 is sent `maxRetries + 1` times. `launch:cache:purge` rewords a 403 from +the purge whatever its code (`asPurgeFailure`), and a 404 from its environment read as the usage error `No +environment with UID "" found in this project.` (exit 2), so a foreign environment is refused before any POST. + A proxy URL shown in a diagnostic has its `user:…@` part removed first (`withoutCredentials`), and `https_proxy`/`http_proxy` are read as well as the uppercase names. @@ -821,7 +833,7 @@ checklist and nothing else; the create summary says `ready to set` because the e oclif's native `exclusive` / `relationships` on the flag definition, where it also shows in `--help` - and a simple range does too: `limit` and `skip` carry oclif's own `min`/`max` rather than being checked later. A rule that must read a *resolved* value (one that config, a prompt or a default may -have supplied) belongs in `src/core/rules.ts` - there are three: `atLeastOneOf` and `exactlyOneOf` (no consumer yet; +have supplied) belongs in `src/core/rules.ts` - there are three: `exactlyOneOf` (used by `cache:purge`), `atLeastOneOf` (no consumer yet; see "Built ahead of use" below) and `onlyWithValueOf` (used by `projects:create`) - declared as a `static rules = [...]` array on the command. `resolveInputs` evaluates them after resolution, and a failing rule is a usage error (exit 2). @@ -834,7 +846,7 @@ gate value `onlyWithValueOf` reads is a value, not a "was it supplied?" question wherever it came from. Write the next rule when a command needs it; a rule kept alive only by its own test proves nothing. -**Built ahead of use.** Four pieces of production code have no caller yet, on purpose, each held +**Built ahead of use.** Three pieces of production code have no caller yet, on purpose, each held for a named ticket whose shape it already fits. Nothing else in `src` is uncalled; add to this list rather than leave an orphan unexplained. @@ -842,7 +854,6 @@ rather than leave an orphan unexplained. |---|---|---| | `DeploymentsApi.list` | CL-7170 (`deployments:*`) | a `DeploymentScope` plus paging -> `DeploymentsPage` | | `src/core/redact.ts` | CL-7169 (variables) | see below | -| `exactlyOneOf` | CL-7172 (`cache:purge`) | "purge these paths, or everything, not both" - and it judges what the user supplied, not defaults | | `atLeastOneOf` | no ticket yet - its caller, `projects:update`, now prompts for missing fields instead | "at least one of these flags" for the next command that must not prompt - and it judges what the user supplied, not defaults | **Redaction.** `src/core/redact.ts` (`REDACTED`, `redactedColumn`) has no caller yet; it is kept diff --git a/README.md b/README.md index 116d6fe..4c2235f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ with exit code 2 and name their V2 replacements. `launch:functions:serve` is liv * [`csdx help [COMMAND]`](#csdx-help-command) * [`csdx launch`](#csdx-launch) +* [`csdx launch:cache:purge`](#csdx-launchcachepurge) * [`csdx launch:deployments`](#csdx-launchdeployments) * [`csdx launch:env:create`](#csdx-launchenvcreate) * [`csdx launch:env:delete`](#csdx-launchenvdelete) @@ -99,6 +100,41 @@ DESCRIPTION _See code: [src/commands/launch/index.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/index.ts)_ +## `csdx launch:cache:purge` + +Purge an environment's CDN cache by path, cache tags or hostnames + +``` +USAGE + $ csdx launch:cache:purge [-c ] [-d ] [--org ] [--project ] [--env ] [--path + ] [--tags ] [--hosts ] + +FLAGS + -c, --config= Path to the local '.cs-launch.json' file + -d, --data-dir= Current working directory + --env= Environment name or UID + --hosts= Comma-separated hostnames to purge + --org= Organization UID + --path= Path to purge, for example /blog. End it with /* to purge everything beneath it, or pass /* + for the whole site + --project= Project name or UID + --tags= Comma-separated cache tags to purge + +DESCRIPTION + Purge an environment's CDN cache by path, cache tags or hostnames + +EXAMPLES + $ csdx launch:cache:purge --org --project --env --path /blog + + $ csdx launch:cache:purge --org --project --env --path "/blog/*" + + $ csdx launch:cache:purge --org --project --env --tags header,footer + + $ csdx launch:cache:purge --org --project --env --hosts www.example.com +``` + +_See code: [src/commands/launch/cache/purge.ts](https://github.com/contentstack/launch-cli/blob/v2.0.0-beta.0/src/commands/launch/cache/purge.ts)_ + ## `csdx launch:deployments` Removed in v2 - list deployments with launch:deployments:list diff --git a/package-lock.json b/package-lock.json index 9541f5e..a562076 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@rollup/plugin-node-resolve": "^16.0.3", "adm-zip": "^0.6.1", "dotenv": "^16.4.7", - "express": "^4.22.0", + "express": "^4.22.3", "https-proxy-agent": "^5.0.1", "open": "^8.4.2", "rollup": "^4.63.4", @@ -5014,7 +5014,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.6", + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -5025,7 +5027,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -7128,7 +7130,9 @@ } }, "node_modules/express": { - "version": "4.22.2", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -7150,9 +7154,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", + "path-to-regexp": "~0.1.13", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.16.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -11510,9 +11514,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", - "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.0.tgz", + "integrity": "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", diff --git a/package.json b/package.json index f9861d7..d733530 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@rollup/plugin-node-resolve": "^16.0.3", "adm-zip": "^0.6.1", "dotenv": "^16.4.7", - "express": "^4.22.0", + "express": "^4.22.3", "https-proxy-agent": "^5.0.1", "open": "^8.4.2", "rollup": "^4.63.4", @@ -73,6 +73,9 @@ "launch:env": { "description": "Manage the environments of a Launch project" }, + "launch:cache": { + "description": "Purge the CDN cache of a Launch environment" + }, "launch:variables": { "description": "List, set and remove the environment variables of a Launch environment" }, @@ -145,6 +148,7 @@ "csdxConfig": { "shortCommandName": { "launch": "LNCH", + "launch:cache:purge": "LNCH-CCH-PRG", "launch:deployments": "LNCH-DPLMNT", "launch:environments": "LNCH-ENV", "launch:env:create": "LNCH-ENV-CRT", diff --git a/src/cache/cache.api.test.ts b/src/cache/cache.api.test.ts new file mode 100644 index 00000000..d7ddde3 --- /dev/null +++ b/src/cache/cache.api.test.ts @@ -0,0 +1,74 @@ +import type { RestApiClient, RestRequest } from '../transport/rest-client'; +import { LaunchApiError } from '../transport/errors'; +import { CacheApi } from './cache.api'; +import { CACHE_ERROR_MESSAGES } from './cache.errors'; + +const QUOTA_CODE = 'launch.ORGANIZATION.REVALIDATE_RATE_LIMIT_EXCEEDED'; + +function fakeRestClient(failure?: Error) { + const requests: RestRequest[] = []; + const messages: unknown[] = []; + const client = { + request: async (req: RestRequest, errorMessages: unknown) => { + requests.push(req); + messages.push(errorMessages); + + if (failure) { + throw failure; + } + + return { message: 'The CDN cache revalidation task has been initiated.' }; + }, + } as unknown as RestApiClient; + + return { client, requests, messages }; +} + +function purgeTags(client: RestApiClient): Promise { + return new CacheApi(client).purge({ org: 'o', project: 'p', environment: 'e', criterion: { cacheTags: ['a'] } }); +} + +describe('CacheApi', () => { + it('posts the criterion to the environment-scoped revalidate-cdn-cache route with the cache wording', async () => { + const { client, requests, messages } = fakeRestClient(); + + const result = await new CacheApi(client).purge({ + org: 'org1', + project: 'proj1', + environment: 'env1', + criterion: { cacheTags: ['a'] }, + }); + + expect(result).toBeUndefined(); + expect(requests).toEqual([ + { + method: 'POST', + path: '/projects/proj1/environments/env1/revalidate-cdn-cache', + orgUid: 'org1', + projectUid: 'proj1', + body: { cacheTags: ['a'] }, + noRetryCodes: [QUOTA_CODE], + }, + ]); + expect(messages[0]).toBe(CACHE_ERROR_MESSAGES); + }); + + it('propagates a failed request rather than reporting a purge', async () => { + const failure = new Error('purge exploded'); + const { client } = fakeRestClient(failure); + + await expect( + new CacheApi(client).purge({ org: 'o', project: 'p', environment: 'e', criterion: { hostnames: ['x'] } }), + ).rejects.toBe(failure); + }); + + it('rewords a 403 whatever its code as needing permission to update the environment', async () => { + const { client } = fakeRestClient(new LaunchApiError(403, [{ code: 'launch.FORBIDDEN' }])); + + const error = await purgeTags(client).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(LaunchApiError); + expect(error).toMatchObject({ status: 403, exitCode: 1 }); + expect((error as Error).message).toBe('Purging the CDN cache needs permission to update the environment.'); + }); +}); diff --git a/src/cache/cache.api.ts b/src/cache/cache.api.ts new file mode 100644 index 00000000..c8b9ffe --- /dev/null +++ b/src/cache/cache.api.ts @@ -0,0 +1,27 @@ +import type { RestApiClient } from '../transport/rest-client'; +import { CACHE_ERROR_MESSAGES, PURGE_QUOTA_CODE, asPurgeFailure } from './cache.errors'; +import type { PurgeCacheParams } from './types'; + +export * from './types'; + +export class CacheApi { + constructor(private readonly client: RestApiClient) {} + + async purge({ org, project, environment, criterion }: PurgeCacheParams): Promise { + try { + await this.client.request( + { + method: 'POST', + path: `/projects/${project}/environments/${environment}/revalidate-cdn-cache`, + orgUid: org, + projectUid: project, + body: criterion, + noRetryCodes: [PURGE_QUOTA_CODE], + }, + CACHE_ERROR_MESSAGES, + ); + } catch (error) { + throw asPurgeFailure(error); + } + } +} diff --git a/src/cache/cache.errors.test.ts b/src/cache/cache.errors.test.ts new file mode 100644 index 00000000..133d6cf --- /dev/null +++ b/src/cache/cache.errors.test.ts @@ -0,0 +1,63 @@ +import { EXIT_RUNTIME } from '../core/constants'; +import { LaunchApiError, parseErrorEnvelope } from '../transport/errors'; +import { CACHE_ERROR_MESSAGES, asPurgeFailure } from './cache.errors'; + +const QUOTA_CODE = 'launch.ORGANIZATION.REVALIDATE_RATE_LIMIT_EXCEEDED'; +const CLI_BUG_MESSAGE = 'The CLI sent a purge request the Launch API could not accept. This is a bug in the CLI.'; +const BUG_CODE = 'launch.ENVIRONMENT.REVALIDATE_CDN_CACHE.INVALID_INPUT.ONLY_ONE_OF_CACHEPATH_CACHETAGS_HOSTNAMES_ALLOWED'; + +describe('CACHE_ERROR_MESSAGES', () => { + it('words the daily purge quota 429 as the limit and when it resets, exiting 1', () => { + const error = parseErrorEnvelope(429, { errors: [{ code: QUOTA_CODE, message: 'Rate limit exceeded' }] }, CACHE_ERROR_MESSAGES); + + expect(error).toBeInstanceOf(LaunchApiError); + expect(error.message).toBe( + 'This organization has reached its daily CDN cache purge limit. The limit resets at 00:00 UTC.', + ); + expect(error.exitCode).toBe(EXIT_RUNTIME); + }); + + it('keeps the API wording for a 429 with another code', () => { + const error = parseErrorEnvelope(429, { errors: [{ code: 'launch.RATE_LIMITED', message: 'Too many requests.' }] }, CACHE_ERROR_MESSAGES); + + expect(error.message).toBe('Too many requests.'); + }); + + it.each([ + ['an empty body', {}], + ['an entry with an unrelated code', { errors: [{ code: 'launch.UNKNOWN_403', message: 'Forbidden' }] }], + ['an entry with no code', { errors: [{ message: 'Forbidden' }] }], + ])('words a 403 as needing permission to update the environment for %s', (_label, body) => { + const error = asPurgeFailure(parseErrorEnvelope(403, body, CACHE_ERROR_MESSAGES)) as LaunchApiError; + + expect(error.message).toBe('Purging the CDN cache needs permission to update the environment.'); + expect(error.exitCode).toBe(EXIT_RUNTIME); + expect(error.status).toBe(403); + }); + + it('words an environment that is not found as one missing from this project', () => { + const error = parseErrorEnvelope(404, { errors: [{ code: 'launch.ENVIRONMENT.NOT_FOUND', message: 'x' }] }, CACHE_ERROR_MESSAGES); + + expect(error.message).toBe('No environment with that UID was found in this project.'); + expect(error.exitCode).toBe(EXIT_RUNTIME); + }); + + it('reports a one-criterion rejection as a bug in the CLI, exiting 1', () => { + const error = parseErrorEnvelope(400, { errors: [{ code: BUG_CODE }] }, CACHE_ERROR_MESSAGES); + + expect(error.message).toBe(CLI_BUG_MESSAGE); + expect(error.exitCode).toBe(EXIT_RUNTIME); + }); + + it.each([401, 404, 409, 429, 500])('leaves a %i failure exactly as it was', (status) => { + const failure = parseErrorEnvelope(status, { errors: [{ code: 'launch.X', message: 'kept' }] }, CACHE_ERROR_MESSAGES); + + expect(asPurgeFailure(failure)).toBe(failure); + }); + + it('leaves an error that is not an API failure exactly as it was', () => { + const failure = new Error('boom'); + + expect(asPurgeFailure(failure)).toBe(failure); + }); +}); diff --git a/src/cache/cache.errors.ts b/src/cache/cache.errors.ts new file mode 100644 index 00000000..cee8e5a --- /dev/null +++ b/src/cache/cache.errors.ts @@ -0,0 +1,24 @@ +import { LaunchApiError } from '../transport/errors'; +import type { ErrorMessages } from '../transport/errors'; + +export const PURGE_QUOTA_CODE = 'launch.ORGANIZATION.REVALIDATE_RATE_LIMIT_EXCEEDED'; + +const PURGE_FORBIDDEN_CODE = 'launch.CACHE.PURGE_FORBIDDEN'; + +const CLI_BUG_MESSAGE = 'The CLI sent a purge request the Launch API could not accept. This is a bug in the CLI.'; + +export const CACHE_ERROR_MESSAGES: ErrorMessages = { + [PURGE_QUOTA_CODE]: 'This organization has reached its daily CDN cache purge limit. The limit resets at 00:00 UTC.', + [PURGE_FORBIDDEN_CODE]: 'Purging the CDN cache needs permission to update the environment.', + 'launch.ENVIRONMENT.NOT_FOUND': 'No environment with that UID was found in this project.', + 'launch.ENVIRONMENT.REVALIDATE_CDN_CACHE.INVALID_INPUT.ONLY_ONE_OF_CACHEPATH_CACHETAGS_HOSTNAMES_ALLOWED': + CLI_BUG_MESSAGE, +}; + +export function asPurgeFailure(error: unknown): unknown { + if (!(error instanceof LaunchApiError) || error.status !== 403) { + return error; + } + + return new LaunchApiError(403, [{ code: PURGE_FORBIDDEN_CODE }], CACHE_ERROR_MESSAGES); +} diff --git a/src/cache/cache.inputs.test.ts b/src/cache/cache.inputs.test.ts new file mode 100644 index 00000000..47c6a82 --- /dev/null +++ b/src/cache/cache.inputs.test.ts @@ -0,0 +1,129 @@ +import { UsageError } from '../core/errors'; +import type { ResolveServices } from '../core/resolution'; +import { resolveInputsTraced } from '../core/resolve'; +import { exactlyOneOf } from '../core/rules'; +import { inputs } from '../core/inputs'; +import type { UxLike } from '../core/prompt'; +import type { ApiSurface } from '../resources'; +import { cacheFlags, cacheResolution, criterionOf } from './cache.inputs'; + +function services(): ResolveServices { + const ux: UxLike = { print: () => undefined, inquire: async () => undefined as never }; + + return { api: {} as ApiSurface, ux, isTTY: false }; +} + +function normalize(flag: 'path' | 'tags' | 'hosts', value: string): Promise { + const spec = cacheResolution[flag] as { normalize(value: string, args: unknown): Promise }; + + return spec.normalize(value, { services: services(), resolved: {}, source: 'flag' }); +} + +const purgeInputs = inputs({ path: {}, tags: {}, hosts: {} }); + +describe('cache inputs', () => { + it('names the three purge criteria and declares none of them required', () => { + expect(Object.entries(cacheFlags).map(([flag]) => flag)).toEqual(['path', 'tags', 'hosts']); + for (const flag of Object.values(cacheFlags)) { + expect((flag as { required?: boolean }).required).toBeFalsy(); + } + }); + + it('gives no criterion a default or a prompt, so exactlyOneOf only ever sees what the user supplied', () => { + for (const spec of Object.values(cacheResolution)) { + expect(spec).not.toHaveProperty('default'); + expect(spec).not.toHaveProperty('prompt'); + } + }); + + it('treats an exact path as not a prefix and keeps it as typed', async () => { + expect(await normalize('path', '/blog')).toEqual({ path: '/blog', isPrefix: false, typed: '/blog' }); + }); + + it('strips a trailing /* into a prefix match and keeps the typed form for the success line', async () => { + expect(await normalize('path', '/blog/*')).toEqual({ path: '/blog', isPrefix: true, typed: '/blog/*' }); + }); + + it('turns /* alone into the root prefix rather than an empty path', async () => { + expect(await normalize('path', '/*')).toEqual({ path: '/', isPrefix: true, typed: '/*' }); + }); + + it('trims the path before reading it', async () => { + expect(await normalize('path', ' /blog/* ')).toEqual({ path: '/blog', isPrefix: true, typed: '/blog/*' }); + }); + + it('leaves a trailing slash that is not a wildcard in the path', async () => { + expect(await normalize('path', '/blog/')).toEqual({ path: '/blog/', isPrefix: false, typed: '/blog/' }); + }); + + it.each(['blog', 'blog/*', '*', 'https://example.com/blog'])( + 'rejects the path %s because it does not start with a slash', + async (value) => { + const error = await normalize('path', value).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(UsageError); + expect((error as UsageError).exitCode).toBe(2); + expect((error as UsageError).message).toBe(`--path must start with "/"; "${value}" does not.`); + }, + ); + + it('splits tags on commas, trimming and dropping empties and duplicates', async () => { + expect(await normalize('tags', 'a,b,a,')).toEqual(['a', 'b']); + }); + + it('keeps the first-seen order of tags rather than sorting them', async () => { + expect(await normalize('tags', ' b , a ,, b ')).toEqual(['b', 'a']); + }); + + it('normalises hosts the same way', async () => { + expect(await normalize('hosts', 'www.example.com, example.com,www.example.com,')).toEqual([ + 'www.example.com', + 'example.com', + ]); + }); + + it.each([ + ['tags', ','], + ['tags', ' , ,'], + ['hosts', ','], + ['hosts', ' , ,'], + ] as const)('rejects --%s %j because nothing is left once it is cleaned', async (flag, value) => { + const error = await normalize(flag, value).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(UsageError); + expect((error as UsageError).exitCode).toBe(2); + expect((error as UsageError).message).toBe(`--${flag} must name at least one value; "${value}" names none.`); + }); + + it.each([ + [' ', 'a'], + ['', 'a'], + ])('reads a blank --path %j as absent, so --tags alone satisfies exactlyOneOf', async (path, tags) => { + const { resolved, sources } = await resolveInputsTraced(purgeInputs, { + parsed: { path, tags }, + projectConfig: {}, + services: services(), + rules: [exactlyOneOf('path', 'tags', 'hosts')], + }); + + expect(resolved.path).toBeUndefined(); + expect(sources.path).toBeUndefined(); + expect(resolved.tags).toEqual(['a']); + }); +}); + +describe('criterionOf', () => { + it('sends a path as the cachePath object without its typed form', () => { + expect(criterionOf({ path: { path: '/blog', isPrefix: true, typed: '/blog/*' } })).toEqual({ + cachePath: { path: '/blog', isPrefix: true }, + }); + }); + + it('sends tags as cacheTags and nothing else', () => { + expect(criterionOf({ tags: ['a', 'b'] })).toStrictEqual({ cacheTags: ['a', 'b'] }); + }); + + it('sends hosts as hostnames and nothing else', () => { + expect(criterionOf({ hosts: ['example.com'] })).toStrictEqual({ hostnames: ['example.com'] }); + }); +}); diff --git a/src/cache/cache.inputs.ts b/src/cache/cache.inputs.ts new file mode 100644 index 00000000..028c943 --- /dev/null +++ b/src/cache/cache.inputs.ts @@ -0,0 +1,55 @@ +import { Flags } from '@contentstack/cli-utilities'; + +import { UsageError } from '../core/errors'; +import type { ResolutionSpec } from '../core/resolution'; +import type { CacheCriterion, CachePath, PurgeTarget } from './types'; + +const WILDCARD_SUFFIX = '/*'; + +export const cacheFlags = { + path: Flags.string({ + description: + 'Path to purge, for example /blog. End it with /* to purge everything beneath it, or pass /* for the whole site', + }), + tags: Flags.string({ description: 'Comma-separated cache tags to purge' }), + hosts: Flags.string({ description: 'Comma-separated hostnames to purge' }), +}; + +async function normalizePath(value: string): Promise { + const typed = value.trim(); + + if (!typed.startsWith('/')) { + throw new UsageError(`--path must start with "/"; "${value}" does not.`); + } + + const isPrefix = typed.endsWith(WILDCARD_SUFFIX); + const stripped = isPrefix ? typed.slice(0, -WILDCARD_SUFFIX.length) : typed; + + return { path: stripped === '' ? '/' : stripped, isPrefix, typed }; +} + +function listNormalizer(flag: string) { + return async (value: string): Promise => { + const entries = [...new Set(value.split(',').map((entry) => entry.trim()).filter((entry) => entry !== ''))]; + + if (entries.length === 0) { + throw new UsageError(`--${flag} must name at least one value; "${value}" names none.`); + } + + return entries; + }; +} + +export const cacheResolution = { + path: { normalize: normalizePath } satisfies ResolutionSpec, + tags: { normalize: listNormalizer('tags') } satisfies ResolutionSpec, + hosts: { normalize: listNormalizer('hosts') } satisfies ResolutionSpec, +}; + +export function criterionOf({ path, tags, hosts }: PurgeTarget): CacheCriterion { + return { + ...(path && { cachePath: { path: path.path, isPrefix: path.isPrefix } }), + ...(tags && { cacheTags: tags }), + ...(hosts && { hostnames: hosts }), + }; +} diff --git a/src/cache/cache.presenter.test.ts b/src/cache/cache.presenter.test.ts new file mode 100644 index 00000000..7f18aed --- /dev/null +++ b/src/cache/cache.presenter.test.ts @@ -0,0 +1,53 @@ +import { purgeInitiatedLines } from './cache.presenter'; + +const GREEN_OPEN = '\u001b[32m'; +const ESCAPE = '\u001b'; +const TASK_LINE = '→ CDN cache revalidation task initiated.'; + +describe('cache presenter', () => { + it('names a prefix path as the user typed it, the match type and the environment', () => { + const lines = purgeInitiatedLines( + { path: { path: '/blog', isPrefix: true, typed: '/blog/*' } }, + 'production', + false, + ); + + expect(lines).toEqual([ + '✔ Cache purge initiated for path /blog/* (prefix match) in environment "production".', + TASK_LINE, + ]); + }); + + it('calls a path without a wildcard an exact match', () => { + const lines = purgeInitiatedLines({ path: { path: '/blog', isPrefix: false, typed: '/blog' } }, 'production', false); + + expect(lines).toEqual(['✔ Cache purge initiated for path /blog (exact match) in environment "production".', TASK_LINE]); + }); + + it('lists the tags purged', () => { + expect(purgeInitiatedLines({ tags: ['a', 'b'] }, 'production', false)).toEqual([ + '✔ Cache purge initiated for tags a, b in environment "production".', + TASK_LINE, + ]); + }); + + it('lists the hosts purged', () => { + expect(purgeInitiatedLines({ hosts: ['www.example.com', 'example.com'] }, 'staging', false)).toEqual([ + '✔ Cache purge initiated for hosts www.example.com, example.com in environment "staging".', + TASK_LINE, + ]); + }); + + it('draws only the success line, in green, when stdout is a terminal', () => { + const lines = purgeInitiatedLines({ tags: ['a'] }, 'production', true); + + expect(lines[0]).toBe(`${GREEN_OPEN}✔ Cache purge initiated for tags a in environment "production".\u001b[39m`); + expect(lines[1]).toBe(TASK_LINE); + }); + + it('writes no escape code into either line when stdout is not a terminal', () => { + const lines = purgeInitiatedLines({ tags: ['a'] }, 'production', false); + + expect(lines.some((line) => line.includes(ESCAPE))).toBe(false); + }); +}); diff --git a/src/cache/cache.presenter.ts b/src/cache/cache.presenter.ts new file mode 100644 index 00000000..f82309e --- /dev/null +++ b/src/cache/cache.presenter.ts @@ -0,0 +1,20 @@ +import { styled } from '../core/style'; +import type { PurgeTarget } from './types'; + +const TASK_LINE = '→ CDN cache revalidation task initiated.'; + +function describeTarget({ path, tags, hosts }: PurgeTarget): string { + return [ + path && `path ${path.typed} (${path.isPrefix ? 'prefix' : 'exact'} match)`, + tags && `tags ${tags.join(', ')}`, + hosts && `hosts ${hosts.join(', ')}`, + ] + .filter(Boolean) + .join(' and '); +} + +export function purgeInitiatedLines(target: PurgeTarget, environmentName: string, outputIsTTY: boolean): string[] { + const initiated = `✔ Cache purge initiated for ${describeTarget(target)} in environment "${environmentName}".`; + + return [styled(initiated, 'green', outputIsTTY), TASK_LINE]; +} diff --git a/src/cache/types.ts b/src/cache/types.ts new file mode 100644 index 00000000..f54e6f7 --- /dev/null +++ b/src/cache/types.ts @@ -0,0 +1,24 @@ +export interface CachePath { + path: string; + isPrefix: boolean; + typed: string; +} + +export interface PurgeTarget { + path?: CachePath; + tags?: string[]; + hosts?: string[]; +} + +export interface CacheCriterion { + cachePath?: { path: string; isPrefix: boolean }; + cacheTags?: string[]; + hostnames?: string[]; +} + +export interface PurgeCacheParams { + org: string; + project: string; + environment: string; + criterion: CacheCriterion; +} diff --git a/src/commands/launch/cache/purge.test.ts b/src/commands/launch/cache/purge.test.ts new file mode 100644 index 00000000..a512002 --- /dev/null +++ b/src/commands/launch/cache/purge.test.ts @@ -0,0 +1,225 @@ +import { randomBytes } from 'node:crypto'; + +import type { CachePath } from '../../../cache/types'; +import { EXIT_RUNTIME, EXIT_USAGE } from '../../../core/constants'; +import { UsageError } from '../../../core/errors'; +import { LaunchApiError } from '../../../transport/errors'; +import type { UxLike } from '../../../core/prompt'; +import Purge from './purge'; + +const SCOPE = { org: 'org1', project: 'proj1', environment: 'env1' }; +const BLOG: CachePath = { path: '/blog', isPrefix: false, typed: '/blog' }; +const CRITERIA = ['--path', '--tags', '--hosts']; +const ONE_OF = `Pass exactly one of ${CRITERIA.join(', ')}`; +const TASK_LINE = '→ CDN cache revalidation task initiated.'; + +interface Harness { + resolved?: Record; + environment?: { uid?: string; name?: string }; + getFails?: Error; + purgeFails?: Error; + purgeResult?: unknown; + outputIsTTY?: boolean; +} + +function commandUnderTest(harness: Harness) { + const lines: string[] = []; + const calls: string[] = []; + const got: unknown[] = []; + const purged: unknown[] = []; + const ux: UxLike = { + print: (message: string) => { + lines.push(message); + }, + inquire: async () => undefined as never, + }; + const command = Object.create(Purge.prototype) as Purge; + + Object.assign(command, { + ux, + resolved: { org: 'org1', project: 'proj1', env: 'env1', path: BLOG, ...harness.resolved }, + services: { + ux, + isTTY: false, + outputIsTTY: harness.outputIsTTY ?? false, + api: { + environments: { + get: async (params: unknown) => { + calls.push('get'); + got.push(params); + + if (harness.getFails) { + throw harness.getFails; + } + + return 'environment' in harness ? harness.environment : { uid: 'env1', name: 'production' }; + }, + }, + cache: { + purge: async (params: unknown) => { + calls.push('purge'); + purged.push(params); + + if (harness.purgeFails) { + throw harness.purgeFails; + } + + return harness.purgeResult; + }, + }, + }, + }, + }); + + return { command, lines, calls, got, purged }; +} + +describe('launch:cache:purge', () => { + it('reads the environment, then purges the path and says what was purged in the environment named', async () => { + const { command, lines, calls, got, purged } = commandUnderTest({ + resolved: { path: { path: '/blog', isPrefix: true, typed: '/blog/*' } }, + }); + + await command.run(); + + expect(calls).toEqual(['get', 'purge']); + expect(got).toEqual([SCOPE]); + expect(purged).toEqual([{ ...SCOPE, criterion: { cachePath: { path: '/blog', isPrefix: true } } }]); + expect(lines).toEqual([ + '✔ Cache purge initiated for path /blog/* (prefix match) in environment "production".', + TASK_LINE, + ]); + }); + + it('purges tags as cacheTags', async () => { + const { command, lines, purged } = commandUnderTest({ resolved: { path: undefined, tags: ['a', 'b'] } }); + + await command.run(); + + expect(purged).toEqual([{ ...SCOPE, criterion: { cacheTags: ['a', 'b'] } }]); + expect(lines[0]).toBe('✔ Cache purge initiated for tags a, b in environment "production".'); + }); + + it('purges hosts as hostnames', async () => { + const { command, lines, purged } = commandUnderTest({ resolved: { path: undefined, hosts: ['example.com'] } }); + + await command.run(); + + expect(purged).toEqual([{ ...SCOPE, criterion: { hostnames: ['example.com'] } }]); + expect(lines[0]).toBe('✔ Cache purge initiated for hosts example.com in environment "production".'); + }); + + it('prints the same two lines whatever the success body says', async () => { + const empty = commandUnderTest({ purgeResult: {} }); + const other = commandUnderTest({ purgeResult: { message: 'something else' } }); + + await empty.command.run(); + await other.command.run(); + + expect(empty.lines).toEqual(other.lines); + expect(empty.lines).toEqual([ + '✔ Cache purge initiated for path /blog (exact match) in environment "production".', + TASK_LINE, + ]); + }); + + it('colours the success line only when stdout is a terminal', async () => { + const terminal = commandUnderTest({ outputIsTTY: true }); + const piped = commandUnderTest({ outputIsTTY: false }); + + await terminal.command.run(); + await piped.command.run(); + + expect(terminal.lines[0]).toContain('\u001b[32m'); + expect(piped.lines.join('')).not.toContain('\u001b'); + }); + + it.each([ + ['has no name', { uid: 'env1' }], + ['has an empty name', { uid: 'env1', name: '' }], + ['has a blank name', { uid: 'env1', name: ' ' }], + ])('falls back to the --env value as typed when the environment %s', async (_label, environment) => { + const { command, lines } = commandUnderTest({ resolved: { env: 'env1' }, environment }); + + await command.run(); + + expect(lines[0]).toBe('✔ Cache purge initiated for path /blog (exact match) in environment "env1".'); + expect(lines.join('')).not.toContain('undefined'); + }); + + it('propagates a failed environment lookup without purging or printing anything', async () => { + const failure = new Error('lookup exploded'); + const { command, lines, calls } = commandUnderTest({ getFails: failure }); + + await expect(command.run()).rejects.toBe(failure); + expect(calls).toEqual(['get']); + expect(lines).toEqual([]); + }); + + it('reports an environment the lookup cannot find as a usage error naming its uid, and never purges', async () => { + const uid = randomBytes(12).toString('hex'); + const { command, lines, calls } = commandUnderTest({ + resolved: { env: uid }, + getFails: new LaunchApiError(404, [{ code: 'launch.ENVIRONMENT.NOT_FOUND', message: 'Environment not found.' }]), + }); + + const error = await command.run().catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(UsageError); + expect((error as UsageError).exitCode).toBe(EXIT_USAGE); + expect((error as UsageError).message).toBe(`No environment with UID "${uid}" found in this project.`); + expect(calls).toEqual(['get']); + expect(lines).toEqual([]); + }); + + it('rethrows any other failed environment lookup unchanged, without purging', async () => { + const failure = new LaunchApiError(500, [{ code: 'launch.INTERNAL', message: 'boom' }]); + const { command, calls } = commandUnderTest({ getFails: failure }); + + await expect(command.run()).rejects.toBe(failure); + expect(failure.exitCode).toBe(EXIT_RUNTIME); + expect(failure.message).toBe('boom'); + expect(calls).toEqual(['get']); + }); + + it('propagates a failed purge rather than reporting a success', async () => { + const failure = new Error('purge exploded'); + const { command, lines, calls } = commandUnderTest({ purgeFails: failure }); + + await expect(command.run()).rejects.toBe(failure); + expect(calls).toEqual(['get', 'purge']); + expect(lines).toEqual([]); + }); + + it('declares org, project and env as required, the three criteria as optional, and the exactlyOneOf rule', () => { + expect(Purge.inputs).toEqual({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + path: {}, + tags: {}, + hosts: {}, + }); + expect(Object.entries(Purge.flags).map(([flag]) => flag).sort()).toEqual([ + 'env', + 'hosts', + 'org', + 'path', + 'project', + 'tags', + ]); + expect(Purge.rules).toHaveLength(1); + }); + + it('refuses none, two and three criteria through its rule', () => { + const [rule] = Purge.rules ?? []; + const supplied = { path: 'flag', tags: 'flag', hosts: 'flag' } as const; + + expect(() => rule({}, {})).toThrow(`${ONE_OF}; none was supplied.`); + expect(() => rule({ path: BLOG, tags: ['a'] }, supplied)).toThrow(`${ONE_OF}; --path, --tags were supplied.`); + expect(() => rule({ path: BLOG, tags: ['a'], hosts: ['h'] }, supplied)).toThrow( + `${ONE_OF}; --path, --tags, --hosts were supplied.`, + ); + expect(() => rule({ tags: ['a'] }, supplied)).not.toThrow(); + }); +}); diff --git a/src/commands/launch/cache/purge.ts b/src/commands/launch/cache/purge.ts new file mode 100644 index 00000000..7717c1a --- /dev/null +++ b/src/commands/launch/cache/purge.ts @@ -0,0 +1,56 @@ +import { criterionOf } from '../../../cache/cache.inputs'; +import { purgeInitiatedLines } from '../../../cache/cache.presenter'; +import { flagsFor, inputs } from '../../../core/inputs'; +import { UsageError } from '../../../core/errors'; +import { LaunchCommand } from '../../../core/launch-command'; +import { exactlyOneOf } from '../../../core/rules'; +import { isAbsent } from '../../../core/values'; +import { LaunchApiError } from '../../../transport/errors'; + +const purgeInputs = inputs({ + org: { required: true }, + project: { required: true }, + env: { required: true }, + path: {}, + tags: {}, + hosts: {}, +}); + +export default class CachePurge extends LaunchCommand { + static description = "Purge an environment's CDN cache by path, cache tags or hostnames"; + + static examples = [ + '$ csdx launch:cache:purge --org --project --env --path /blog', + '$ csdx launch:cache:purge --org --project --env --path "/blog/*"', + '$ csdx launch:cache:purge --org --project --env --tags header,footer', + '$ csdx launch:cache:purge --org --project --env --hosts www.example.com', + ]; + + static inputs = purgeInputs; + + static flags = flagsFor(purgeInputs); + + static rules = [exactlyOneOf('path', 'tags', 'hosts')]; + + async run(): Promise { + const { org, project, env, path, tags, hosts } = this.resolved; + const scope = { org, project, environment: env }; + const target = { path, tags, hosts }; + + const environment = await this.services.api.environments.get(scope).catch((error: unknown) => { + if (error instanceof LaunchApiError && error.status === 404) { + throw new UsageError(`No environment with UID "${env}" found in this project.`); + } + + throw error; + }); + + await this.services.api.cache.purge({ ...scope, criterion: criterionOf(target) }); + + const name = environment.name !== undefined && !isAbsent(environment.name) ? environment.name : env; + + for (const line of purgeInitiatedLines(target, name, this.services.outputIsTTY === true)) { + this.ux.print(line); + } + } +} diff --git a/src/environments/environments.api.test.ts b/src/environments/environments.api.test.ts index 06a2a6e..7748962 100644 --- a/src/environments/environments.api.test.ts +++ b/src/environments/environments.api.test.ts @@ -132,6 +132,7 @@ describe('EnvironmentsApi', () => { projectUid: PROJECT, }, ]); + expect(requests[0]).not.toHaveProperty('noRetryCodes'); expect(messages[0]).toBe(ENVIRONMENT_ERROR_MESSAGES); }); diff --git a/src/resources.test.ts b/src/resources.test.ts index d53f137..5c260fd 100644 --- a/src/resources.test.ts +++ b/src/resources.test.ts @@ -4,6 +4,7 @@ import { DeploymentsApi } from './deployments/deployments.api'; import { DeploymentLogsApi } from './deployments/deployment-logs.api'; import { EnvironmentsApi } from './environments/environments.api'; import { UploadsApi } from './uploads/uploads.api'; +import { CacheApi } from './cache/cache.api'; import { GitApi } from './git/git.api'; import { ProjectsApi } from './projects/projects.api'; import type { UxLike } from './core/prompt'; @@ -185,6 +186,7 @@ describe('the api surface', () => { const api = buildApi(client, UNUSED_CMA, {} as never); expect(Object.keys(api).sort()).toEqual([ + 'cache', 'deploymentLogs', 'deployments', 'environments', @@ -202,6 +204,7 @@ describe('the api surface', () => { expect(api.deployments).toBeInstanceOf(DeploymentsApi); expect(api.git).toBeInstanceOf(GitApi); expect(api.uploads).toBeInstanceOf(UploadsApi); + expect(api.cache).toBeInstanceOf(CacheApi); }); it('sends deployment log reads through the logs client and nothing else through it', async () => { diff --git a/src/resources.ts b/src/resources.ts index 76293c0..17e440d 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -8,6 +8,7 @@ import { environmentResolution, } from './environments/environment.inputs'; import { variableFlags, variableResolution } from './variables/variable.inputs'; +import { cacheFlags, cacheResolution } from './cache/cache.inputs'; import { ProjectsApi } from './projects/projects.api'; import { EnvironmentsApi } from './environments/environments.api'; import { DeploymentsApi } from './deployments/deployments.api'; @@ -17,10 +18,18 @@ import { OrganizationsApi } from './organizations/organizations.api'; import { UploadsApi } from './uploads/uploads.api'; import { StacksApi } from './stacks/stacks.api'; import { VariablesApi } from './variables/variables.api'; +import { CacheApi } from './cache/cache.api'; import type { CmaSession } from './transport/cma-client'; import type { RestApiClient } from './transport/rest-client'; -export const catalog = { ...organizationFlags, ...coreFlags, ...projectFlags, ...environmentFlags, ...variableFlags }; +export const catalog = { + ...organizationFlags, + ...coreFlags, + ...projectFlags, + ...environmentFlags, + ...variableFlags, + ...cacheFlags, +}; export type Catalog = typeof catalog; export type FlagKey = keyof Catalog; @@ -31,6 +40,7 @@ export const resolution = { ...coreResolution, ...environmentResolution, ...variableResolution, + ...cacheResolution, }; export type Resolution = typeof resolution; @@ -55,6 +65,7 @@ export interface ApiSurface { uploads: UploadsApi; stacks: StacksApi; variables: VariablesApi; + cache: CacheApi; } export function buildApi(client: RestApiClient, cma: CmaSession, logsClient: RestApiClient): ApiSurface { @@ -68,5 +79,6 @@ export function buildApi(client: RestApiClient, cma: CmaSession, logsClient: Res uploads: new UploadsApi(client), stacks: new StacksApi(cma), variables: new VariablesApi(client), + cache: new CacheApi(client), }; } diff --git a/src/transport/rest-client.test.ts b/src/transport/rest-client.test.ts index 849498d..0a06cac 100644 --- a/src/transport/rest-client.test.ts +++ b/src/transport/rest-client.test.ts @@ -8,6 +8,8 @@ import type { HttpClientLike } from './rest-client'; import { RestApiClient } from './rest-client'; import { DEFAULT_REQUEST_TIMEOUT_MS } from './retry-policy'; +const QUOTA_CODE = 'launch.ORGANIZATION.REVALIDATE_RATE_LIMIT_EXCEEDED'; + interface RecordedCall { method: string; path: string; @@ -352,6 +354,75 @@ describe('RestApiClient', () => { expect(http.calls).toHaveLength(2); }); + it('throws a 429 whose code the request opted out of after one attempt, without sleeping', async () => { + const http = fakeHttpClient([{ status: 429, data: { errors: [{ code: QUOTA_CODE, message: 'x' }] } }]); + + const error = await buildClient(http) + .request({ method: 'POST', path: '/x', noRetryCodes: [QUOTA_CODE] }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(LaunchApiError); + expect((error as LaunchApiError).status).toBe(429); + expect((error as LaunchApiError).code).toBe(QUOTA_CODE); + expect(http.calls).toHaveLength(1); + expect(http.waits).toEqual([]); + }); + + it.each([ + ['another code', { errors: [{ code: 'launch.RATE_LIMITED', message: 'slow down' }] }], + ['an empty body', {}], + ['an entry with no code', { errors: [{ message: 'slow down' }] }], + ])('keeps retrying a 429 carrying %s when the request opted out of a different code', async (_label, data) => { + const http = fakeHttpClient([{ status: 429, data }]); + + const error = await buildClient(http, { maxRetries: 3 }) + .request({ method: 'POST', path: '/x', noRetryCodes: [QUOTA_CODE] }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(LaunchApiError); + expect((error as LaunchApiError).status).toBe(429); + expect(http.calls).toHaveLength(4); + expect(http.waits).toHaveLength(3); + }); + + it('retries a 429 carrying the quota code when the request did not opt out, because the opt-out is per request', async () => { + const http = fakeHttpClient([{ status: 429, data: { errors: [{ code: QUOTA_CODE, message: 'x' }] } }]); + + const error = await buildClient(http, { maxRetries: 3 }) + .request({ method: 'POST', path: '/x' }) + .catch((caught: unknown) => caught); + + expect((error as LaunchApiError).code).toBe(QUOTA_CODE); + expect(http.calls).toHaveLength(4); + expect(http.waits).toHaveLength(3); + }); + + it('retries an ordinary 429 and then stops on the quota 429 that follows it', async () => { + const http = fakeHttpClient([ + { status: 429, data: { errors: [{ code: 'launch.RATE_LIMITED', message: 'slow down' }] } }, + { status: 429, data: { errors: [{ code: QUOTA_CODE, message: 'x' }] } }, + ]); + + const error = await buildClient(http, { maxRetries: 3 }) + .request({ method: 'POST', path: '/x', noRetryCodes: [QUOTA_CODE] }) + .catch((caught: unknown) => caught); + + expect((error as LaunchApiError).code).toBe(QUOTA_CODE); + expect(http.calls).toHaveLength(2); + expect(http.waits).toHaveLength(1); + }); + + it('stops on an opted-out code listed beside others in the same 429 body', async () => { + const http = fakeHttpClient([ + { status: 429, data: { errors: [{ message: 'first' }, { code: QUOTA_CODE, message: 'x' }] } }, + ]); + + await expect( + buildClient(http).request({ method: 'POST', path: '/x', noRetryCodes: ['launch.OTHER', QUOTA_CODE] }), + ).rejects.toBeInstanceOf(LaunchApiError); + expect(http.calls).toHaveLength(1); + }); + it('returns the payload when a retried request eventually succeeds', async () => { const http = fakeHttpClient([ { status: 429, data: {} }, diff --git a/src/transport/rest-client.ts b/src/transport/rest-client.ts index c731f50..54b4786 100644 --- a/src/transport/rest-client.ts +++ b/src/transport/rest-client.ts @@ -16,6 +16,7 @@ export interface RestRequest { body?: unknown; orgUid?: string; projectUid?: string; + noRetryCodes?: string[]; } export interface HttpResponse { @@ -85,6 +86,16 @@ function isAuthChallenge(status: number, data: unknown): boolean { return typeof errorMessage === 'string' && errorMessage.includes(EXPIRED_TOKEN_MESSAGE); } +function answeredCodes(response: HttpResponse, req: RestRequest): string[] { + if (req.noRetryCodes === undefined) { + return []; + } + + return parseErrorEnvelope(response.status, response.data).errors.flatMap((entry) => + entry.code === undefined ? [] : [entry.code], + ); +} + export class RestApiClient { constructor(private readonly options: RestApiClientOptions) {} @@ -119,7 +130,7 @@ export class RestApiClient { continue; } - if (policy.shouldRetry(response.status, req.method, attempt)) { + if (policy.shouldRetry(response.status, req.method, attempt, answeredCodes(response, req), req.noRetryCodes)) { attempt += 1; await sleep(policy.delayFor(attempt, retryAfterMs(response.headers))); continue; diff --git a/src/transport/retry-policy.test.ts b/src/transport/retry-policy.test.ts index b089372..fbfac65 100644 --- a/src/transport/retry-policy.test.ts +++ b/src/transport/retry-policy.test.ts @@ -42,6 +42,16 @@ describe('RetryPolicy', () => { expect(policy.retryDelayMs).toBe(value ?? DEFAULT_RETRY_DELAY_MS); }); + it.each(ALL_METHODS)('refuses to retry a %s whose answered code is in the opt-out list', (method) => { + const policy = new RetryPolicy(); + + expect(policy.shouldRetry(429, method, 0, ['launch.A'], ['launch.A'])).toBe(false); + expect(policy.shouldRetry(429, method, 0, ['launch.A'], ['launch.B'])).toBe(true); + expect(policy.shouldRetry(429, method, 0, [], ['launch.A'])).toBe(true); + expect(policy.shouldRetry(429, method, 0, ['launch.A'], [])).toBe(true); + expect(policy.shouldRetry(429, method, 0)).toBe(true); + }); + it.each(ALL_METHODS)('treats a 429 on %s as retryable whatever the method', (method) => { expect(new RetryPolicy().isRetryableStatus(429, method)).toBe(true); }); diff --git a/src/transport/retry-policy.ts b/src/transport/retry-policy.ts index 583ce71..5d06677 100644 --- a/src/transport/retry-policy.ts +++ b/src/transport/retry-policy.ts @@ -52,7 +52,17 @@ export class RetryPolicy { return attemptsMade < this.maxRetries && this.isRetryableTransportError(error, method); } - shouldRetry(status: number, method: HttpMethod, attemptsMade: number): boolean { + shouldRetry( + status: number, + method: HttpMethod, + attemptsMade: number, + answeredCodes: readonly string[] = [], + noRetryCodes: readonly string[] = [], + ): boolean { + if (answeredCodes.some((code) => noRetryCodes.includes(code))) { + return false; + } + return attemptsMade < this.maxRetries && this.isRetryableStatus(status, method); } diff --git a/test/guards/layering.test.ts b/test/guards/layering.test.ts index b7055a9..758b47b 100644 --- a/test/guards/layering.test.ts +++ b/test/guards/layering.test.ts @@ -19,6 +19,7 @@ const RESOURCE_EDGES: Record = { uploads: [], stacks: [], variables: ['environments', 'stacks'], + cache: [], }; function sourceFilesIn(layer: string): string[] { diff --git a/test/integration/cache-purge.test.ts b/test/integration/cache-purge.test.ts new file mode 100644 index 00000000..50ba6eb --- /dev/null +++ b/test/integration/cache-purge.test.ts @@ -0,0 +1,351 @@ +import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; + +import { authHandler, configHandler } from '@contentstack/cli-utilities'; +import type { Interfaces } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import nock from 'nock'; + +import { answerPrompts, onTerminal } from '../support/terminal'; +import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; +import { projectOwnership } from '../support/project-ownership'; + +import getFixture from '../fixtures/environment-get.json'; +import listFixture from '../fixtures/environments-list.json'; +import notFoundFixture from '../fixtures/environment-not-found.json'; + +const LAUNCH_HUB_URL = 'https://launch-api.integration.test'; +const ORG_UID = 'blt4d9e2a7c1f6b3085'; +const PROJECT_UID = randomBytes(12).toString('hex'); +const ENV_UID = randomBytes(12).toString('hex'); +const ENV_PATH = `/manage/projects/${PROJECT_UID}/environments/${ENV_UID}`; +const PURGE_PATH = `${ENV_PATH}/revalidate-cdn-cache`; +const FOREIGN_ENV_UID = randomBytes(12).toString('hex'); +const OTHER_ORG_UID = randomBytes(9).toString('hex'); +const DATA_DIR = tmpdir(); +const QUOTA_CODE = 'launch.ORGANIZATION.REVALIDATE_RATE_LIMIT_EXCEEDED'; +const QUOTA_MESSAGE = 'This organization has reached its daily CDN cache purge limit. The limit resets at 00:00 UTC.'; +const CRITERIA = ['--path', '--tags', '--hosts']; +const ONE_OF = `Pass exactly one of ${CRITERIA.join(', ')}`; +const TASK_LINE = '→ CDN cache revalidation task initiated.'; +const INITIATED = { message: 'The CDN cache revalidation task has been initiated.' }; + +const SCANNED = { + ...listFixture, + environments: listFixture.environments.map((environment) => + environment.name === 'production' ? { ...environment, uid: ENV_UID } : environment, + ), +}; + +const CONFIG: Record = { + authorisationType: 'BASIC', + authtoken: 'test-authtoken', + email: 'cli-user@example.test', + region: { + name: 'integration', + cma: 'https://cma.integration.test', + cda: 'https://cda.integration.test', + uiHost: 'https://app.integration.test', + launchHubUrl: LAUNCH_HUB_URL, + }, +}; + +let config: Interfaces.Config; +let onWire: string[]; + +function purgeArgs(...extra: string[]): string[] { + return ['launch:cache:purge', '--org', ORG_UID, '--project', PROJECT_UID, '--data-dir', DATA_DIR, ...extra]; +} + +function lookupAndPurge(body: nock.RequestBodyMatcher) { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const lookup = nock(LAUNCH_HUB_URL) + .matchHeader('x-project-uid', PROJECT_UID) + .get(ENV_PATH) + .reply(200, getFixture); + const purge = nock(LAUNCH_HUB_URL) + .matchHeader('x-organization-uid', ORG_UID) + .matchHeader('x-project-uid', PROJECT_UID) + .post(PURGE_PATH, body) + .reply(200, INITIATED); + + return { lookup, purge }; +} + +describe('integration: launch:cache:purge on the wire', () => { + beforeAll(async () => { + config = await loadCliConfig(); + nock.disableNetConnect(); + }); + + beforeEach(() => { + process.exitCode = 0; + onWire = []; + nock.emitter.on('no match', (request: { method?: string; path?: string }) => { + onWire.push(`${request.method ?? 'UNKNOWN'} ${request.path ?? ''}`); + }); + routeConsoleLogToStdout(); + jest.spyOn(configHandler, 'get').mockImplementation((setting: string) => CONFIG[setting]); + jest.spyOn(authHandler, 'compareOAuthExpiry').mockResolvedValue(undefined); + }); + + afterEach(() => { + process.exitCode = 0; + nock.emitter.removeAllListeners('no match'); + nock.cleanAll(); + }); + + afterAll(() => { + nock.enableNetConnect(); + nock.restore(); + }); + + it('reads the environment then sends one POST for an exact path and exits 0', async () => { + const { lookup, purge } = lookupAndPurge({ cachePath: { path: '/blog', isPrefix: false } }); + + const { error } = await runCommand(purgeArgs('--env', ENV_UID, '--path', '/blog'), config); + + expect(error).toBeUndefined(); + expect([lookup.isDone(), purge.isDone()]).toEqual([true, true]); + expect(onWire).toEqual([]); + }); + + it('resolves an environment name by scanning, reads it by uid and then purges', async () => { + const scan = nock(LAUNCH_HUB_URL) + .get(`/manage/projects/${PROJECT_UID}/environments`) + .query({ limit: '100', skip: '0' }) + .reply(200, SCANNED); + const { lookup, purge } = lookupAndPurge({ cachePath: { path: '/blog', isPrefix: false } }); + + const { error } = await runCommand(purgeArgs('--env', 'production', '--path', '/blog'), config); + + expect(error).toBeUndefined(); + expect([scan.isDone(), lookup.isDone(), purge.isDone()]).toEqual([true, true, true]); + expect(onWire).toEqual([]); + }); + + it('sends a trailing /* as a prefix match on the path without it', async () => { + const { purge } = lookupAndPurge({ cachePath: { path: '/blog', isPrefix: true } }); + + const { error } = await runCommand(purgeArgs('--env', ENV_UID, '--path', '/blog/*'), config); + + expect(error).toBeUndefined(); + expect(purge.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); + + it('sends /* alone as the root prefix with neither tags nor hostnames', async () => { + const { purge } = lookupAndPurge({ cachePath: { path: '/', isPrefix: true } }); + + const { error } = await runCommand(purgeArgs('--env', ENV_UID, '--path', '/*'), config); + + expect(error).toBeUndefined(); + expect(purge.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); + + it('sends tags trimmed, without empties or duplicates, as cacheTags', async () => { + const { purge } = lookupAndPurge({ cacheTags: ['a', 'b'] }); + + const { error } = await runCommand(purgeArgs('--env', ENV_UID, '--tags', 'a,b,a,'), config); + + expect(error).toBeUndefined(); + expect(purge.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); + + it('sends hosts the same way as hostnames', async () => { + const { purge } = lookupAndPurge({ hostnames: ['www.example.com', 'example.com'] }); + + const { error } = await runCommand( + purgeArgs('--env', ENV_UID, '--hosts', 'www.example.com,example.com,www.example.com'), + config, + ); + + expect(error).toBeUndefined(); + expect(purge.isDone()).toBe(true); + expect(onWire).toEqual([]); + }); + + it('names the environment, not the uid, in the success line, with no escape codes off a terminal', async () => { + lookupAndPurge({ cachePath: { path: '/blog', isPrefix: true } }); + + const { error, stdout } = await runCommand(purgeArgs('--env', ENV_UID, '--path', '/blog/*'), config); + + expect(error).toBeUndefined(); + expect(stdout).toBe( + `✔ Cache purge initiated for path /blog/* (prefix match) in environment "production".\n${TASK_LINE}\n`, + ); + }); + + it('exits 2 naming all three flags when none is given, without a terminal, and reads and purges nothing', async () => { + const { lookup, purge } = lookupAndPurge({}); + + const { error } = await runCommand(purgeArgs('--env', ENV_UID), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`${ONE_OF}; none was supplied.`); + expect([lookup.isDone(), purge.isDone()]).toEqual([false, false]); + expect(onWire).toEqual([]); + }); + + it('exits 2 the same way on a terminal, and never prompts for a criterion', async () => { + const { lookup, purge } = lookupAndPurge({}); + const prompts = answerPrompts({}); + + const { error } = await onTerminal(() => runCommand(purgeArgs('--env', ENV_UID), config)); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`${ONE_OF}; none was supplied.`); + expect(prompts.messages).toEqual([]); + expect([lookup.isDone(), purge.isDone()]).toEqual([false, false]); + expect(onWire).toEqual([]); + }); + + it('exits 2 naming the two flags supplied and sends no request about the environment', async () => { + const { lookup, purge } = lookupAndPurge({}); + + const { error } = await runCommand(purgeArgs('--env', ENV_UID, '--path', '/blog', '--tags', 'a'), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`${ONE_OF}; --path, --tags were supplied.`); + expect([lookup.isDone(), purge.isDone()]).toEqual([false, false]); + expect(onWire).toEqual([]); + }); + + it('exits 2 naming all three flags when all three are supplied and sends no purge', async () => { + const { lookup, purge } = lookupAndPurge({}); + + const { error } = await runCommand( + purgeArgs('--env', ENV_UID, '--path', '/blog', '--tags', 'a', '--hosts', 'example.com'), + config, + ); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`${ONE_OF}; --path, --tags, --hosts were supplied.`); + expect([lookup.isDone(), purge.isDone()]).toEqual([false, false]); + expect(onWire).toEqual([]); + }); + + it('exits 2 for a path without a leading slash and sends nothing about the environment', async () => { + const { lookup, purge } = lookupAndPurge({}); + + const { error } = await runCommand(purgeArgs('--env', ENV_UID, '--path', 'blog'), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('--path must start with "/"; "blog" does not.'); + expect([lookup.isDone(), purge.isDone()]).toEqual([false, false]); + expect(onWire).toEqual([]); + }); + + it('exits 2 for a tag list that is empty once cleaned and sends nothing about the environment', async () => { + const { lookup, purge } = lookupAndPurge({}); + + const { error } = await runCommand(purgeArgs('--env', ENV_UID, '--tags', ','), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('--tags must name at least one value; "," names none.'); + expect([lookup.isDone(), purge.isDone()]).toEqual([false, false]); + expect(onWire).toEqual([]); + }); + + it('stops on the quota 429 after one POST, words it, and exits 1', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + nock(LAUNCH_HUB_URL).matchHeader('x-project-uid', PROJECT_UID).get(ENV_PATH).reply(200, getFixture); + let posts = 0; + nock(LAUNCH_HUB_URL) + .post(PURGE_PATH) + .times(4) + .reply(() => { + posts += 1; + return [429, { errors: [{ code: QUOTA_CODE, message: 'Rate limit exceeded' }], status: 429 }]; + }); + + const { error, stderr } = await runCommand(purgeArgs('--env', ENV_UID, '--path', '/blog'), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe(QUOTA_MESSAGE); + expect(posts).toBe(1); + expect(stderr).not.toContain('\u001b'); + expect(onWire).toEqual([]); + }); + + it('prints the quota wording alone on stderr, with no escape codes off a terminal', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + nock(LAUNCH_HUB_URL).matchHeader('x-project-uid', PROJECT_UID).get(ENV_PATH).reply(200, getFixture); + nock(LAUNCH_HUB_URL) + .post(PURGE_PATH) + .reply(429, { errors: [{ code: QUOTA_CODE, message: 'Rate limit exceeded' }], status: 429 }); + + const { error, stderr } = await runCommand(purgeArgs('--env', ENV_UID, '--path', '/blog'), config); + + expect(error?.oclif?.exit).toBe(1); + expect(stderr).toBe(`error: ${QUOTA_MESSAGE}\n`); + }); + + it('exits 1 with the permission wording on a 403 and sends the POST once', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + nock(LAUNCH_HUB_URL).matchHeader('x-project-uid', PROJECT_UID).get(ENV_PATH).reply(200, getFixture); + let posts = 0; + nock(LAUNCH_HUB_URL) + .post(PURGE_PATH) + .times(2) + .reply(() => { + posts += 1; + return [403, { errors: [{ code: 'launch.FORBIDDEN', message: 'Forbidden' }], status: 403 }]; + }); + + const { error } = await runCommand(purgeArgs('--env', ENV_UID, '--tags', 'a'), config); + + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('Purging the CDN cache needs permission to update the environment.'); + expect(posts).toBe(1); + expect(onWire).toEqual([]); + }); + + it('exits 2 with the not-found wording for an environment uid from another project, and sends no POST', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const lookup = nock(LAUNCH_HUB_URL) + .get(`/manage/projects/${PROJECT_UID}/environments/${FOREIGN_ENV_UID}`) + .reply(404, notFoundFixture); + const purge = nock(LAUNCH_HUB_URL).post(`/manage/projects/${PROJECT_UID}/environments/${FOREIGN_ENV_UID}/revalidate-cdn-cache`).reply(200, INITIATED); + + const { error } = await runCommand(purgeArgs('--env', FOREIGN_ENV_UID, '--path', '/blog'), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`No environment with UID "${FOREIGN_ENV_UID}" found in this project.`); + expect([lookup.isDone(), purge.isDone()]).toEqual([true, false]); + expect(onWire).toEqual([]); + }); + + it('exits 2 with the not-found wording for an environment name the project does not have, before any lookup or POST', async () => { + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + const scan = nock(LAUNCH_HUB_URL) + .get(`/manage/projects/${PROJECT_UID}/environments`) + .query({ limit: '100', skip: '0' }) + .reply(200, SCANNED); + const { lookup, purge } = lookupAndPurge({}); + + const { error } = await runCommand(purgeArgs('--env', 'staging-eu', '--path', '/blog'), config); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe('No environment named "staging-eu" found in this project.'); + expect(scan.isDone()).toBe(true); + expect([lookup.isDone(), purge.isDone()]).toEqual([false, false]); + }); + + it('exits 2 with the not-found wording when the project belongs to another organization, before any environment request', async () => { + const ownership = projectOwnership(LAUNCH_HUB_URL, OTHER_ORG_UID, PROJECT_UID); + const { lookup, purge } = lookupAndPurge({}); + + const { error } = await runCommand( + ['launch:cache:purge', '--org', OTHER_ORG_UID, '--project', PROJECT_UID, '--data-dir', DATA_DIR, '--env', ENV_UID, '--path', '/blog'], + config, + ); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toBe(`No project with UID "${PROJECT_UID}" found in this organization.`); + expect(ownership.isDone()).toBe(true); + expect([lookup.isDone(), purge.isDone()]).toEqual([false, false]); + }); +}); diff --git a/test/integration/oclif-manifest.test.ts b/test/integration/oclif-manifest.test.ts index d38166c..3359b6c 100644 --- a/test/integration/oclif-manifest.test.ts +++ b/test/integration/oclif-manifest.test.ts @@ -44,6 +44,14 @@ describe('integration: the oclif manifest in package.json', () => { expect(manifest.oclif.topics?.['launch:env']?.description).toBe('Manage the environments of a Launch project'); }); + it('describes the launch:cache subtopic apart from the purge command it holds', async () => { + const description = manifest.oclif.topics?.['launch:cache']?.description; + const { default: Purge } = await import('../../src/commands/launch/cache/purge'); + + expect(description).toBeTruthy(); + expect(description).not.toBe(Purge.description); + }); + it('declares a description for every topic it declares', () => { const topics = manifest.oclif.topics ?? {}; diff --git a/test/integration/retired-commands.test.ts b/test/integration/retired-commands.test.ts index 2db6784..ed15716 100644 --- a/test/integration/retired-commands.test.ts +++ b/test/integration/retired-commands.test.ts @@ -162,6 +162,19 @@ describe('integration: retired V1 command names', () => { expect(stdout).toContain('launch:functions'); }); + it('lists the launch:cache topic with its own description under launch --help', async () => { + const { stdout } = await runCommand(['launch', '--help'], config); + + expect(stdout).toContain('launch:cache'); + expect(stdout).toContain('Purge the CDN cache of a Launch environment'); + }); + + it("lists the cache resource's verb under launch:cache --help", async () => { + const { stdout } = await runCommand(['launch:cache', '--help'], config); + + expect(stdout).toContain('launch:cache:purge'); + }); + it("lists the projects resource's verbs under launch:projects --help", async () => { const { stdout } = await runCommand(['launch:projects', '--help'], config); diff --git a/test/integration/transport-retry.test.ts b/test/integration/transport-retry.test.ts index 94d5f30..8a0c4fa 100644 --- a/test/integration/transport-retry.test.ts +++ b/test/integration/transport-retry.test.ts @@ -1,13 +1,17 @@ -import { randomUUID } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import nock from 'nock'; -import { LaunchNetworkError } from '../../src/transport/errors'; +import { CacheApi } from '../../src/cache/cache.api'; +import { PURGE_QUOTA_CODE } from '../../src/cache/cache.errors'; +import { LaunchApiError, LaunchNetworkError } from '../../src/transport/errors'; import { RestApiClient } from '../../src/transport/rest-client'; const ORIGIN = 'https://launch-api.transport.test'; const BASE_PATH = '/manage'; const ORG_UID = 'blt4d9e2a7c1f6b3085'; +const PROJECT_UID = randomBytes(12).toString('hex'); +const ENV_UID = randomBytes(12).toString('hex'); const ANALYTICS_INFO = '@contentstack/cli-launch/2.0.0-alpha.0 darwin-arm64 node-v22.0.0'; const TIMEOUT_ERROR = { message: 'timeout of 2000ms exceeded', code: 'ECONNABORTED' }; @@ -23,6 +27,14 @@ function buildClient(overrides: Partial + nock(ORIGIN) + .post(`${BASE_PATH}/projects/${PROJECT_UID}/environments/${ENV_UID}/revalidate-cdn-cache`) + .reply(429, { errors: [{ code, message: 'Too many requests.' }], status: 429 }), + ); +} + function postAttempts(count: number): nock.Scope[] { return Array.from({ length: count }, () => nock(ORIGIN).post(`${BASE_PATH}/projects`).replyWithError(TIMEOUT_ERROR), @@ -46,6 +58,29 @@ describe('integration: transport-level retries', () => { nock.restore(); }); + it('retries a purge answered 429 with another code up to the budget, one attempt per interceptor', async () => { + const attempts = purgeAttempts(6, 'launch.RATE_LIMITED'); + + const rejection = await new CacheApi(buildClient({ maxRetries: 3 })) + .purge({ org: ORG_UID, project: PROJECT_UID, environment: ENV_UID, criterion: { cacheTags: ['a'] } }) + .catch((error: unknown) => error); + + expect(rejection).toBeInstanceOf(LaunchApiError); + expect(attempts.map((attempt) => attempt.isDone())).toEqual([true, true, true, true, false, false]); + }); + + it('sends a purge answered with the quota 429 exactly once', async () => { + const attempts = purgeAttempts(6, PURGE_QUOTA_CODE); + + const rejection = await new CacheApi(buildClient({ maxRetries: 3 })) + .purge({ org: ORG_UID, project: PROJECT_UID, environment: ENV_UID, criterion: { cacheTags: ['a'] } }) + .catch((error: unknown) => error); + + expect(rejection).toBeInstanceOf(LaunchApiError); + expect((rejection as LaunchApiError).code).toBe(PURGE_QUOTA_CODE); + expect(attempts.map((attempt) => attempt.isDone())).toEqual([true, false, false, false, false, false]); + }); + it('sends a timed-out POST exactly once and explains the failure to the caller', async () => { const attempts = postAttempts(4);