current commit
261104.4 · fix(cache): enforce redirect hop policy
'use strict'

const { test } = require('node:test')
const { notEqual, strictEqual, deepStrictEqual } = require('node:assert')
const { rm } = require('node:fs/promises')
const { join } = require('node:path')
const { tmpdir } = require('node:os')
const { cacheStoreTests, writeBody, compareGetResults } = require('./cache-store-test-utils.js')
const { runtimeFeatures } = require('../../lib/util/runtime-features.js')

const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js')
cacheStoreTests(SqliteCacheStore, { skip: runtimeFeatures.has('sqlite') === false })

test('SqliteCacheStore invalidates entries from the previous cache format', { skip: runtimeFeatures.has('sqlite') === false }, async (t) => {
  const { DatabaseSync } = require('node:sqlite')
  const sqliteLocation = join(tmpdir(), `undici-cache-interceptor-v4-${process.pid}.sqlite`)
  await rm(sqliteLocation, { force: true })
  const oldDatabase = new DatabaseSync(sqliteLocation)
  oldDatabase.exec(`
    CREATE TABLE cacheInterceptorV4 (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      url TEXT NOT NULL,
      method TEXT NOT NULL
    );
    INSERT INTO cacheInterceptorV4 (url, method)
    VALUES ('http://example.test//resource', 'GET');
  `)
  oldDatabase.close()

  const store = new SqliteCacheStore({ location: sqliteLocation })
  t.after(async () => {
    store.close()
    await rm(sqliteLocation, { force: true })
  })

  strictEqual(store.get({
    origin: 'http://example.test',
    path: '/resource',
    method: 'GET',
    headers: {}
  }), undefined)
  strictEqual(store.size, 0)
})

test('SqliteCacheStore works nicely with multiple stores', { skip: runtimeFeatures.has('sqlite') === false }, async (t) => {
  const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js')
  const sqliteLocation = 'cache-interceptor.sqlite'

  const storeA = new SqliteCacheStore({
    location: sqliteLocation
  })

  const storeB = new SqliteCacheStore({
    location: sqliteLocation
  })

  t.after(async () => {
    storeA.close()
    storeB.close()
    await rm(sqliteLocation)
  })

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
   */
  const key = {
    origin: 'localhost',
    path: '/',
    method: 'GET',
    headers: {}
  }

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
   */
  const value = {
    statusCode: 200,
    statusMessage: '',
    headers: { foo: 'bar' },
    cachedAt: Date.now(),
    staleAt: Date.now() + 10000,
    deleteAt: Date.now() + 20000
  }

  const body = [Buffer.from('asd'), Buffer.from('123')]

  {
    const writable = storeA.createWriteStream(key, value)
    notEqual(writable, undefined)
    writeBody(writable, body)
  }

  // Make sure we got the expected response from store a
  {
    const result = storeA.get(structuredClone(key))
    notEqual(result, undefined)
    await compareGetResults(result, value, body)
  }

  // Make sure we got the expected response from store b
  {
    const result = storeB.get(structuredClone(key))
    notEqual(result, undefined)
    await compareGetResults(result, value, body)
  }
})

test('SqliteCacheStore maxEntries', { skip: runtimeFeatures.has('sqlite') === false }, async () => {
  const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js')

  const maxCount = 10
  const store = new SqliteCacheStore({ maxCount })

  for (let i = 0; i < 20; i++) {
    /**
     * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
     */
    const key = {
      origin: 'localhost',
      path: '/' + i,
      method: 'GET',
      headers: {}
    }

    /**
     * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
     */
    const value = {
      statusCode: 200,
      statusMessage: '',
      headers: { foo: 'bar' },
      cachedAt: Date.now(),
      staleAt: Date.now() + 10000,
      deleteAt: Date.now() + 20000
    }

    const body = ['asd', '123']

    const writable = store.createWriteStream(key, value)
    notEqual(writable, undefined)
    writeBody(writable, body)
  }

  strictEqual(store.size <= maxCount, true)
})

test('SqliteCacheStore enforces maxCount after insert', { skip: runtimeFeatures.has('sqlite') === false }, () => {
  const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js')

  const store = new SqliteCacheStore({ maxCount: 1 })

  store.set(
    { origin: 'localhost', path: '/a', method: 'GET', headers: {} },
    {
      statusCode: 200,
      statusMessage: '',
      headers: {},
      cachedAt: Date.now(),
      staleAt: Date.now() + 10_000,
      deleteAt: Date.now() + 20_000,
      body: Buffer.from('a')
    }
  )

  store.set(
    { origin: 'localhost', path: '/b', method: 'GET', headers: {} },
    {
      statusCode: 200,
      statusMessage: '',
      headers: {},
      cachedAt: Date.now() + 1,
      staleAt: Date.now() + 10_001,
      deleteAt: Date.now() + 20_001,
      body: Buffer.from('b')
    }
  )

  strictEqual(store.size, 1)
  strictEqual(store.get({ origin: 'localhost', path: '/a', method: 'GET', headers: {} }), undefined)
  notEqual(store.get({ origin: 'localhost', path: '/b', method: 'GET', headers: {} }), undefined)
})

test('SqliteCacheStore two writes', { skip: runtimeFeatures.has('sqlite') === false }, async () => {
  const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js')

  const store = new SqliteCacheStore({
    maxCount: 10
  })

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
   */
  const key = {
    origin: 'localhost',
    path: '/',
    method: 'GET',
    headers: {}
  }

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
   */
  const value = {
    statusCode: 200,
    statusMessage: '',
    headers: { foo: 'bar' },
    cachedAt: Date.now(),
    staleAt: Date.now() + 10000,
    deleteAt: Date.now() + 20000
  }

  const body = ['asd', '123']

  {
    const writable = store.createWriteStream(key, value)
    notEqual(writable, undefined)
    writeBody(writable, body)
  }

  {
    const writable = store.createWriteStream(key, value)
    notEqual(writable, undefined)
    writeBody(writable, body)
  }
})

test('SqliteCacheStore prune evicts oldest entries first', { skip: runtimeFeatures.has('sqlite') === false }, async () => {
  const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js')

  const maxCount = 10
  const store = new SqliteCacheStore({ maxCount })

  const baseTime = Date.now()

  for (let i = 0; i < 20; i++) {
    const key = {
      origin: 'localhost',
      path: '/' + i,
      method: 'GET',
      headers: {}
    }

    const value = {
      statusCode: 200,
      statusMessage: '',
      headers: { foo: 'bar' },
      cachedAt: baseTime + i * 1000,
      staleAt: baseTime + i * 1000 + 60_000,
      deleteAt: baseTime + i * 1000 + 120_000,
      body: Buffer.from('x')
    }

    store.set(key, value)
  }

  // The most recently cached entry must still be present;
  // the oldest entry must have been evicted.
  const newest = store.get({ origin: 'localhost', path: '/19', method: 'GET', headers: {} })
  notEqual(newest, undefined)

  const oldest = store.get({ origin: 'localhost', path: '/0', method: 'GET', headers: {} })
  strictEqual(oldest, undefined)
})

test('SqliteCacheStore write & read', { skip: runtimeFeatures.has('sqlite') === false }, async () => {
  const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js')

  const store = new SqliteCacheStore({
    maxCount: 10
  })

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
   */
  const key = {
    origin: 'localhost',
    path: '/',
    method: 'GET',
    headers: {}
  }

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue & { body: Buffer }}
   */
  const value = {
    statusCode: 200,
    statusMessage: '',
    headers: { foo: 'bar' },
    cacheControlDirectives: { 'max-stale': 0 },
    cachedAt: Date.now(),
    staleAt: Date.now() + 10000,
    deleteAt: Date.now() + 20000,
    body: Buffer.from('asd'),
    etag: undefined,
    vary: undefined
  }

  store.set(key, value)

  deepStrictEqual(store.get(key), value)
})

test('SqliteCacheStore ignores expired Vary variants when a later one is still valid', { skip: runtimeFeatures.has('sqlite') === false }, () => {
  const store = new SqliteCacheStore()
  const now = Date.now()
  const baseKey = {
    origin: 'localhost',
    path: '/',
    method: 'GET'
  }

  store.set({
    ...baseKey,
    headers: {
      'accept-encoding': 'gzip'
    }
  }, {
    statusCode: 200,
    statusMessage: '',
    headers: {},
    vary: {
      'accept-encoding': 'gzip'
    },
    cachedAt: now - 2000,
    staleAt: now - 1000,
    deleteAt: now - 1,
    body: Buffer.from('expired gzip')
  })

  store.set({
    ...baseKey,
    headers: {
      'accept-encoding': 'br'
    }
  }, {
    statusCode: 200,
    statusMessage: '',
    headers: {},
    vary: {
      'accept-encoding': 'br'
    },
    cachedAt: now,
    staleAt: now + 1000,
    deleteAt: now + 2000,
    body: Buffer.from('valid br')
  })

  const result = store.get({
    ...baseKey,
    headers: {
      'accept-encoding': 'br'
    }
  })

  notEqual(result, undefined)
  strictEqual(result.body.toString(), 'valid br')
})

test('SqliteCacheStore updates vary when overwriting an existing row', { skip: runtimeFeatures.has('sqlite') === false }, () => {
  const store = new SqliteCacheStore()
  const now = Date.now()
  const baseKey = {
    origin: 'localhost',
    path: '/',
    method: 'GET'
  }

  store.set({
    ...baseKey,
    headers: {}
  }, {
    statusCode: 200,
    statusMessage: '',
    headers: {},
    cachedAt: now,
    staleAt: now + 1000,
    deleteAt: now + 2000,
    body: Buffer.from('initial')
  })

  store.set({
    ...baseKey,
    headers: {
      'accept-language': 'en'
    }
  }, {
    statusCode: 200,
    statusMessage: '',
    headers: {},
    vary: {
      'accept-language': 'en'
    },
    cachedAt: now,
    staleAt: now + 1000,
    deleteAt: now + 2000,
    body: Buffer.from('updated')
  })

  strictEqual(store.get({
    ...baseKey,
    headers: {
      'accept-language': 'fr'
    }
  }), undefined)
})

let hasZstd = false
try {
  hasZstd = typeof require('node:zlib').zstdCompressSync === 'function'
} catch {
  // zstd may be unavailable on older Node versions
}

const skipCompression = runtimeFeatures.has('sqlite') === false || hasZstd === false

test('SqliteCacheStore compresses big files with zstd', { skip: skipCompression }, async (t) => {
  const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js')
  const sqliteLocation = 'cache-interceptor-compressed.sqlite'

  const store = new SqliteCacheStore({
    location: sqliteLocation,
    compressThreshold: 1024
  })

  let storeClosed = false
  t.after(async () => {
    if (!storeClosed) {
      store.close()
    }
    await rm(sqliteLocation)
  })

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
   */
  const key = {
    origin: 'localhost',
    path: '/',
    method: 'GET',
    headers: {}
  }

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
   */
  const value = {
    statusCode: 200,
    statusMessage: '',
    headers: { foo: 'bar' },
    cachedAt: Date.now(),
    staleAt: Date.now() + 10000,
    deleteAt: Date.now() + 20000
  }

  // A body larger than compressThreshold must be stored compressed and
  // decompressed when read back.
  const bigBody = Buffer.from('a'.repeat(10_000))
  store.set({ ...key, path: '/big' }, { ...value, body: bigBody })

  {
    const result = store.get(structuredClone({ ...key, path: '/big' }))
    notEqual(result, undefined)
    deepStrictEqual(result.body, bigBody)
  }

  // A body smaller than compressThreshold must be stored uncompressed.
  const smallBody = Buffer.from('small')
  store.set({ ...key, path: '/small' }, { ...value, body: smallBody })

  {
    const result = store.get(structuredClone({ ...key, path: '/small' }))
    notEqual(result, undefined)
    deepStrictEqual(result.body, smallBody)
  }

  // A compressible mime type (application/json) must be compressed even when
  // the body is below compressThreshold.
  const jsonBody = Buffer.from('{"key":"value"}'.repeat(20))
  store.set({ ...key, path: '/json' }, { ...value, headers: { 'content-type': 'application/json' }, body: jsonBody })

  {
    const result = store.get(structuredClone({ ...key, path: '/json' }))
    notEqual(result, undefined)
    deepStrictEqual(result.body, jsonBody)
  }

  // Inspect the raw rows to make sure compression actually happened.
  store.close()
  storeClosed = true
  const DatabaseSync = require('node:sqlite').DatabaseSync
  const db = new DatabaseSync(sqliteLocation)
  const rows = db.prepare('SELECT compressed, body FROM cacheInterceptorV5 ORDER BY id').all()
  db.close()

  strictEqual(rows.length, 3)
  strictEqual(rows[0].compressed, 'zstd')
  strictEqual(rows[0].body.byteLength < bigBody.byteLength, true)
  strictEqual(rows[1].compressed, null)
  strictEqual(Buffer.from(rows[1].body).equals(smallBody), true)
  strictEqual(rows[2].compressed, 'zstd')
  strictEqual(rows[2].body.byteLength < jsonBody.byteLength, true)
})

test('SqliteCacheStore ignores a database locked by another connection', { skip: runtimeFeatures.has('sqlite') === false }, async (t) => {
  const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js')
  const { DatabaseSync } = require('node:sqlite')
  const sqliteLocation = 'cache-interceptor-locked.sqlite'

  const store = new SqliteCacheStore({ location: sqliteLocation })

  // A second connection holding the write lock is what another process (or
  // another store instance) sharing the same cache database looks like.
  const otherConnection = new DatabaseSync(sqliteLocation)

  t.after(async () => {
    store.close()
    otherConnection.close()
    await rm(sqliteLocation)
  })

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
   */
  const key = {
    origin: 'localhost',
    path: '/',
    method: 'GET',
    headers: {}
  }

  /**
   * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
   */
  const value = {
    statusCode: 200,
    statusMessage: '',
    headers: { foo: 'bar' },
    cachedAt: Date.now(),
    staleAt: Date.now() + 10000,
    deleteAt: Date.now() + 20000,
    body: Buffer.from('asd')
  }

  otherConnection.exec('BEGIN IMMEDIATE')

  // Contention is transient and the cache is best effort: none of these may
  // throw while the database is locked, the entry is just not cached.
  store.set(key, value)
  strictEqual(store.get(key), undefined)
  store.delete(key)

  otherConnection.exec('COMMIT')

  // Once the lock is released the store behaves as usual again.
  store.set(key, value)
  notEqual(store.get(key), undefined)
})