mirror of
https://github.com/actions/toolkit.git
synced 2026-08-08 00:00:33 +02:00
* feat(cache): surface cache read-denied as a distinct restore warning Mirror the existing cache write-denied handling on the restore path. When the receiver refuses a download URL because the run's token has no readable cache scopes, it returns a twirp PermissionDenied (HTTP 403). The twirp client wraps that 403 in a generic Error, so the stable 'cache read denied:' prefix is embedded in the message rather than at the start. - Add CACHE_READ_DENIED_PREFIX and CacheReadDeniedError - Dispatch on the prefix in the restoreCacheV2 catch block (V2 only), log a policy-specific warning, and report a cache miss so the run continues - Add a test mirroring the write-denied coverage * chore(cache): trim comments, bump to 6.2.0, add RELEASES entry * refactor(cache): dispatch read-denied by error name to mirror write path Re-throw CacheReadDeniedError from an inner try/catch around GetCacheEntryDownloadURL and dispatch on typedError.name in the outer catch, matching how saveCacheV2 handles CacheWriteDeniedError. * feat(cache): handle read-denied on the v1 restore path Extend the read-denied handling to Cache Service v1 so GHES (which forces v1 via _apis/artifactcache) is covered when read-scope enforcement ships there. - Surface the receiver's error body message from getCacheEntry instead of a generic status-code error, so the cache read denied: prefix reaches callers - Re-throw CacheReadDeniedError from restoreCacheV1 and dispatch on it in the outer catch, mirroring restoreCacheV2 and the write-denied v1 handling - Add a v1 read-denied test * refactor(cache): only surface receiver body for read-denied on v1 * test(cache): assert getCacheEntry only surfaces body for read-denied * test(cache): cover non-read-denied getCacheEntry passthrough on v1 * refactor(cache): share read-denied prefix via constants to avoid drift * feat(cache): skip restore/save per ACTIONS_CACHE_MODE * test(cache): expand ACTIONS_CACHE_MODE skip coverage across v1/v2 and unknown modes * fix copilot pr feedback Co-authored-by: Copilot Autofix powered by AI <[email protected]> * docs(cache): remove internal reference from cache-mode comment * test(cache): merge redundant cache-mode skip tests and simplify read-denied handling Address PR review feedback: - Merge the duplicate restore/save skip test.each blocks into single blocks parametrized over ACTIONS_CACHE_SERVICE_V2. - Drop the redundant CacheReadDeniedError catch arms; the typed error is not an HttpClientError so it already falls through to a non-fatal warning. - Clarify why read-denied classification happens both in getCacheEntry and cache.ts (dependency-free internal module cannot import the typed error). * refactor(cache): drop redundant CacheWriteDeniedError catch arms Mirror the read-denied simplification on the save path. CacheWriteDeniedError is not an HttpClientError and its name does not match the ReserveCacheError arm, so it falls through to the same non-fatal warning. Logging behavior is unchanged (warns, never fails the run) and the exported type is still thrown internally for consumers and tests. Also refresh stale doc wording. * test(cache): collapse redundant restore getCacheEntry-failure tests The two restoreCache tests exercised the identical warning + cache-miss path now that read-denied is no longer reclassified in the catch, so merge them into one. The read-denied prefix detection that actually branches on the message is covered by getCacheEntry tests in cacheHttpClient.test.ts. --------- Co-authored-by: Copilot Autofix powered by AI <[email protected]>
208 lines
6.4 KiB
TypeScript
208 lines
6.4 KiB
TypeScript
import {downloadCache, getCacheEntry} from '../src/internal/cacheHttpClient'
|
|
import {getCacheVersion} from '../src/internal/cacheUtils'
|
|
import {CompressionMethod} from '../src/internal/constants'
|
|
import * as downloadUtils from '../src/internal/downloadUtils'
|
|
import * as requestUtils from '../src/internal/requestUtils'
|
|
import {DownloadOptions, getDownloadOptions} from '../src/options'
|
|
import {HttpClientError} from '@actions/http-client'
|
|
|
|
jest.mock('../src/internal/downloadUtils')
|
|
|
|
test('getCacheVersion does not mutate arguments', async () => {
|
|
const paths = ['node_modules']
|
|
getCacheVersion(paths, undefined, true)
|
|
expect(paths).toEqual(['node_modules'])
|
|
})
|
|
|
|
test('getCacheVersion with one path returns version', async () => {
|
|
const paths = ['node_modules']
|
|
const result = getCacheVersion(paths, undefined, true)
|
|
expect(result).toEqual(
|
|
'b3e0c6cb5ecf32614eeb2997d905b9c297046d7cbf69062698f25b14b4cb0985'
|
|
)
|
|
})
|
|
|
|
test('getCacheVersion with multiple paths returns version', async () => {
|
|
const paths = ['node_modules', 'dist']
|
|
const result = getCacheVersion(paths, undefined, true)
|
|
expect(result).toEqual(
|
|
'165c3053bc646bf0d4fac17b1f5731caca6fe38e0e464715c0c3c6b6318bf436'
|
|
)
|
|
})
|
|
|
|
test('getCacheVersion with zstd compression returns version', async () => {
|
|
const paths = ['node_modules']
|
|
const result = getCacheVersion(paths, CompressionMethod.Zstd, true)
|
|
|
|
expect(result).toEqual(
|
|
'273877e14fd65d270b87a198edbfa2db5a43de567c9a548d2a2505b408befe24'
|
|
)
|
|
})
|
|
|
|
test('getCacheVersion with gzip compression returns version', async () => {
|
|
const paths = ['node_modules']
|
|
const result = getCacheVersion(paths, CompressionMethod.Gzip, true)
|
|
|
|
expect(result).toEqual(
|
|
'470e252814dbffc9524891b17cf4e5749b26c1b5026e63dd3f00972db2393117'
|
|
)
|
|
})
|
|
|
|
test('getCacheVersion with enableCrossOsArchive as false returns version on windows', async () => {
|
|
if (process.platform === 'win32') {
|
|
const paths = ['node_modules']
|
|
const result = getCacheVersion(paths)
|
|
|
|
expect(result).toEqual(
|
|
'2db19d6596dc34f51f0043120148827a264863f5c6ac857569c2af7119bad14e'
|
|
)
|
|
}
|
|
})
|
|
|
|
test('getCacheEntry throws a generic status-code error for non-read-denied failures', async () => {
|
|
// Regression: a non read-denied failure must NOT leak the server's body
|
|
// message; it should surface the generic status-code error.
|
|
jest.spyOn(requestUtils, 'retryTypedResponse').mockResolvedValue({
|
|
statusCode: 403,
|
|
result: null,
|
|
headers: {},
|
|
error: new HttpClientError('some other server detail', 403)
|
|
})
|
|
|
|
await expect(getCacheEntry(['key'], ['node_modules'])).rejects.toThrow(
|
|
'Cache service responded with 403'
|
|
)
|
|
})
|
|
|
|
test('getCacheEntry surfaces the body message for a cache read denial', async () => {
|
|
jest.spyOn(requestUtils, 'retryTypedResponse').mockResolvedValue({
|
|
statusCode: 403,
|
|
result: null,
|
|
headers: {},
|
|
error: new HttpClientError(
|
|
'cache read denied: token has no readable scopes',
|
|
403
|
|
)
|
|
})
|
|
|
|
await expect(getCacheEntry(['key'], ['node_modules'])).rejects.toThrow(
|
|
'cache read denied: token has no readable scopes'
|
|
)
|
|
})
|
|
|
|
test('downloadCache uses http-client for non-Azure URLs', async () => {
|
|
const downloadCacheHttpClientMock = jest.spyOn(
|
|
downloadUtils,
|
|
'downloadCacheHttpClient'
|
|
)
|
|
const downloadCacheStorageSDKMock = jest.spyOn(
|
|
downloadUtils,
|
|
'downloadCacheStorageSDK'
|
|
)
|
|
|
|
const archiveLocation = 'http://www.actionscache.test/download'
|
|
const archivePath = '/foo/bar'
|
|
|
|
await downloadCache(archiveLocation, archivePath)
|
|
|
|
expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(1)
|
|
expect(downloadCacheHttpClientMock).toHaveBeenCalledWith(
|
|
archiveLocation,
|
|
archivePath
|
|
)
|
|
|
|
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(0)
|
|
})
|
|
|
|
test('downloadCache uses storage SDK for Azure storage URLs', async () => {
|
|
const downloadCacheHttpClientMock = jest.spyOn(
|
|
downloadUtils,
|
|
'downloadCacheHttpClient'
|
|
)
|
|
const downloadCacheStorageSDKMock = jest.spyOn(
|
|
downloadUtils,
|
|
'downloadCacheStorageSDK'
|
|
)
|
|
|
|
const downloadCacheHttpClientConcurrentMock = jest.spyOn(
|
|
downloadUtils,
|
|
'downloadCacheHttpClientConcurrent'
|
|
)
|
|
|
|
const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz'
|
|
const archivePath = '/foo/bar'
|
|
|
|
await downloadCache(archiveLocation, archivePath)
|
|
|
|
expect(downloadCacheHttpClientConcurrentMock).toHaveBeenCalledTimes(1)
|
|
expect(downloadCacheHttpClientConcurrentMock).toHaveBeenCalledWith(
|
|
archiveLocation,
|
|
archivePath,
|
|
getDownloadOptions()
|
|
)
|
|
|
|
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(0)
|
|
expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(0)
|
|
})
|
|
|
|
test('downloadCache passes options to download methods', async () => {
|
|
const downloadCacheHttpClientMock = jest.spyOn(
|
|
downloadUtils,
|
|
'downloadCacheHttpClient'
|
|
)
|
|
const downloadCacheStorageSDKMock = jest.spyOn(
|
|
downloadUtils,
|
|
'downloadCacheStorageSDK'
|
|
)
|
|
|
|
const downloadCacheHttpClientConcurrentMock = jest.spyOn(
|
|
downloadUtils,
|
|
'downloadCacheHttpClientConcurrent'
|
|
)
|
|
|
|
const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz'
|
|
const archivePath = '/foo/bar'
|
|
const options: DownloadOptions = {downloadConcurrency: 4}
|
|
|
|
await downloadCache(archiveLocation, archivePath, options)
|
|
|
|
expect(downloadCacheHttpClientConcurrentMock).toHaveBeenCalledTimes(1)
|
|
expect(downloadCacheHttpClientConcurrentMock).toHaveBeenCalled()
|
|
expect(downloadCacheHttpClientConcurrentMock).toHaveBeenCalledWith(
|
|
archiveLocation,
|
|
archivePath,
|
|
getDownloadOptions(options)
|
|
)
|
|
|
|
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(0)
|
|
expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(0)
|
|
})
|
|
|
|
test('downloadCache uses http-client when overridden', async () => {
|
|
const downloadCacheHttpClientMock = jest.spyOn(
|
|
downloadUtils,
|
|
'downloadCacheHttpClient'
|
|
)
|
|
const downloadCacheStorageSDKMock = jest.spyOn(
|
|
downloadUtils,
|
|
'downloadCacheStorageSDK'
|
|
)
|
|
|
|
const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz'
|
|
const archivePath = '/foo/bar'
|
|
const options: DownloadOptions = {
|
|
useAzureSdk: false,
|
|
concurrentBlobDownloads: false
|
|
}
|
|
|
|
await downloadCache(archiveLocation, archivePath, options)
|
|
|
|
expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(1)
|
|
expect(downloadCacheHttpClientMock).toHaveBeenCalledWith(
|
|
archiveLocation,
|
|
archivePath
|
|
)
|
|
|
|
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(0)
|
|
})
|