diff --git a/packages/@sanity/cli-core/src/config/__tests__/cliToken.test.ts b/packages/@sanity/cli-core/src/config/__tests__/cliToken.test.ts index 768e3aa87..ac0b60438 100644 --- a/packages/@sanity/cli-core/src/config/__tests__/cliToken.test.ts +++ b/packages/@sanity/cli-core/src/config/__tests__/cliToken.test.ts @@ -1,7 +1,6 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {getCliUserConfig} from '../../services/cliUserConfig' -import * as cliTokenModule from '../../services/getCliToken' vi.mock('../../services/cliUserConfig', () => ({ getCliUserConfig: vi.fn(), @@ -9,27 +8,14 @@ vi.mock('../../services/cliUserConfig', () => ({ describe('getCliToken', () => { const originalEnv = process.env - let cachedToken: string | undefined + let getCliToken: () => Promise - beforeEach(() => { + beforeEach(async () => { process.env = {...originalEnv} vi.clearAllMocks() vi.resetModules() - cachedToken = undefined - vi.spyOn(cliTokenModule, 'getCliToken').mockImplementation(async () => { - if (cachedToken !== undefined) { - return cachedToken - } - - const token = process.env.SANITY_AUTH_TOKEN - if (token) { - cachedToken = token.trim() - return cachedToken - } - - cachedToken = await getCliUserConfig('authToken') - return cachedToken - }) + const module = await import('../../services/getCliToken.js') + getCliToken = module.getCliToken }) afterEach(() => { @@ -39,7 +25,7 @@ describe('getCliToken', () => { it('should return token from environment variable', async () => { process.env.SANITY_AUTH_TOKEN = 'test-token' - const token = await cliTokenModule.getCliToken() + const token = await getCliToken() expect(token).toBe('test-token') expect(getCliUserConfig).not.toHaveBeenCalled() }) @@ -48,7 +34,7 @@ describe('getCliToken', () => { delete process.env.SANITY_AUTH_TOKEN vi.mocked(getCliUserConfig).mockResolvedValueOnce('config-token') - const token = await cliTokenModule.getCliToken() + const token = await getCliToken() expect(token).toBe('config-token') expect(getCliUserConfig).toHaveBeenCalledWith('authToken') }) @@ -57,7 +43,7 @@ describe('getCliToken', () => { delete process.env.SANITY_AUTH_TOKEN vi.mocked(getCliUserConfig).mockResolvedValueOnce(undefined) - const token = await cliTokenModule.getCliToken() + const token = await getCliToken() expect(token).toBeUndefined() expect(getCliUserConfig).toHaveBeenCalledWith('authToken') }) @@ -65,9 +51,9 @@ describe('getCliToken', () => { it('should cache the token from environment variable', async () => { process.env.SANITY_AUTH_TOKEN = 'cached-env-token' - const firstCall = await cliTokenModule.getCliToken() + const firstCall = await getCliToken() process.env.SANITY_AUTH_TOKEN = 'new-token' - const secondCall = await cliTokenModule.getCliToken() + const secondCall = await getCliToken() expect(firstCall).toBe('cached-env-token') expect(secondCall).toBe('cached-env-token') @@ -78,8 +64,8 @@ describe('getCliToken', () => { delete process.env.SANITY_AUTH_TOKEN vi.mocked(getCliUserConfig).mockResolvedValueOnce('cached-config-token') - const firstCall = await cliTokenModule.getCliToken() - const secondCall = await cliTokenModule.getCliToken() + const firstCall = await getCliToken() + const secondCall = await getCliToken() expect(firstCall).toBe('cached-config-token') expect(secondCall).toBe('cached-config-token') diff --git a/packages/@sanity/cli/src/services/__tests__/telemetry.test.ts b/packages/@sanity/cli/src/services/__tests__/telemetry.test.ts index 7466f8d64..8ce761032 100644 --- a/packages/@sanity/cli/src/services/__tests__/telemetry.test.ts +++ b/packages/@sanity/cli/src/services/__tests__/telemetry.test.ts @@ -4,6 +4,7 @@ import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import { fetchTelemetryConsent, + getTelemetryConsentCacheKey, TELEMETRY_API_VERSION, TELEMETRY_CONSENT_CONFIG_KEY, } from '../telemetry.js' @@ -12,6 +13,7 @@ import { function createInMemoryConfigStore() { const store = new Map() return { + clear: () => store.clear(), delete: (key: string) => store.delete(key), get: (key: string) => store.get(key), set: (key: string, value: unknown) => store.set(key, value), @@ -20,18 +22,44 @@ function createInMemoryConfigStore() { const testConfigStore = createInMemoryConfigStore() +const mockGetCliToken = vi.hoisted(() => vi.fn<() => Promise>()) + vi.mock('@sanity/cli-core', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + getCliToken: mockGetCliToken, getUserConfig: vi.fn(() => testConfigStore), } }) +describe('#getTelemetryConsentCacheKey', () => { + test('returns base key when no token is provided', () => { + expect(getTelemetryConsentCacheKey(undefined)).toBe(TELEMETRY_CONSENT_CONFIG_KEY) + }) + + test('returns token-scoped key when a token is provided', () => { + const key = getTelemetryConsentCacheKey('test-token-abc') + expect(key).toMatch(new RegExp(`^${TELEMETRY_CONSENT_CONFIG_KEY}:[a-f0-9]{12}$`)) + }) + + test('returns different keys for different tokens', () => { + const keyA = getTelemetryConsentCacheKey('token-user-a') + const keyB = getTelemetryConsentCacheKey('token-user-b') + expect(keyA).not.toBe(keyB) + }) + + test('returns the same key for the same token', () => { + const key1 = getTelemetryConsentCacheKey('same-token') + const key2 = getTelemetryConsentCacheKey('same-token') + expect(key1).toBe(key2) + }) +}) + describe('#fetchTelemetryConsent', () => { beforeEach(() => { - const userConfig = getUserConfig() - userConfig.delete(TELEMETRY_CONSENT_CONFIG_KEY) + getUserConfig().clear() + mockGetCliToken.mockResolvedValue('test-token') }) afterEach(() => { @@ -48,4 +76,60 @@ describe('#fetchTelemetryConsent', () => { expect(consent).toEqual({status: 'granted'}) }) + + test('should cache consent under a token-scoped key', async () => { + mockApi({ + apiVersion: TELEMETRY_API_VERSION, + query: {tag: 'sanity.cli.telemetry-consent'}, + uri: '/intake/telemetry-status', + }).reply(200, {status: 'granted'}) + + await fetchTelemetryConsent() + + const scopedKey = getTelemetryConsentCacheKey('test-token') + const cached = getUserConfig().get(scopedKey) as {value: {status: string}} | undefined + expect(cached?.value).toEqual({status: 'granted'}) + }) + + test('should not reuse cache from a different token', async () => { + // Fetch and cache consent for token A + mockGetCliToken.mockResolvedValue('token-a') + mockApi({ + apiVersion: TELEMETRY_API_VERSION, + query: {tag: 'sanity.cli.telemetry-consent'}, + uri: '/intake/telemetry-status', + }).reply(200, {status: 'denied'}) + + const consentA = await fetchTelemetryConsent() + expect(consentA).toEqual({status: 'denied'}) + + // Now switch to token B - should make a new API call, not reuse token A's cache + mockGetCliToken.mockResolvedValue('token-b') + mockApi({ + apiVersion: TELEMETRY_API_VERSION, + query: {tag: 'sanity.cli.telemetry-consent'}, + uri: '/intake/telemetry-status', + }).reply(200, {status: 'granted'}) + + const consentB = await fetchTelemetryConsent() + expect(consentB).toEqual({status: 'granted'}) + }) + + test('should use base key when no token is available', async () => { + mockGetCliToken.mockResolvedValue(undefined) + mockApi({ + apiVersion: TELEMETRY_API_VERSION, + query: {tag: 'sanity.cli.telemetry-consent'}, + uri: '/intake/telemetry-status', + }).reply(200, {status: 'unset'}) + + const consent = await fetchTelemetryConsent() + expect(consent).toEqual({status: 'unset'}) + + // Should be cached under the base key + const cached = getUserConfig().get(TELEMETRY_CONSENT_CONFIG_KEY) as + | {value: {status: string}} + | undefined + expect(cached?.value).toEqual({status: 'unset'}) + }) }) diff --git a/packages/@sanity/cli/src/services/telemetry.ts b/packages/@sanity/cli/src/services/telemetry.ts index 8fd9f9bb4..f324e5300 100644 --- a/packages/@sanity/cli/src/services/telemetry.ts +++ b/packages/@sanity/cli/src/services/telemetry.ts @@ -1,4 +1,6 @@ -import {getGlobalCliClient, getUserConfig} from '@sanity/cli-core' +import {createHash} from 'node:crypto' + +import {getCliToken, getGlobalCliClient, getUserConfig} from '@sanity/cli-core' import {type TelemetryEvent} from '@sanity/telemetry' import {telemetryDebug} from '../actions/telemetry/telemetryDebug.js' @@ -67,6 +69,23 @@ function isValidApiConsentResponse(response: unknown): response is {status: Vali export const TELEMETRY_CONSENT_CONFIG_KEY = 'telemetryConsent' const FIVE_MINUTES = 1000 * 60 * 5 +/** + * Get a token-scoped cache key for telemetry consent. This ensures that switching + * users (via login/logout) always results in a cache miss, preventing one user + * from inheriting another user's cached consent status. + * + * @param token - The current auth token, or undefined if not logged in + * @returns A cache key scoped to the token + */ +export function getTelemetryConsentCacheKey(token: string | undefined): string { + if (!token) { + return TELEMETRY_CONSENT_CONFIG_KEY + } + + const hash = createHash('sha256').update(token).digest('hex').slice(0, 12) + return `${TELEMETRY_CONSENT_CONFIG_KEY}:${hash}` +} + /** * Fetch the telemetry consent status for the current user * @returns The telemetry consent status @@ -76,11 +95,18 @@ const FIVE_MINUTES = 1000 * 60 * 5 export async function fetchTelemetryConsent(): Promise<{ status: ValidApiConsentStatus }> { + const token = await getCliToken() + const cacheKey = getTelemetryConsentCacheKey(token) + + // NOTE: createExpiringConfig is instantiated on every call, so in-flight request + // deduplication (via currentFetch) does not work across concurrent calls to + // fetchTelemetryConsent(). Two concurrent callers will make two HTTP requests. + // Consider moving to module-level instance if this becomes a bottleneck. const telemetryConsentConfig = createExpiringConfig<{ status: ValidApiConsentStatus }>({ fetchValue: () => getTelemetryConsent(), - key: TELEMETRY_CONSENT_CONFIG_KEY, + key: cacheKey, onCacheHit() { telemetryDebug('Retrieved telemetry consent status from cache') }, diff --git a/packages/@sanity/cli/test/__fixtures__/exec-get-user-token.ts b/packages/@sanity/cli/test/__fixtures__/exec-get-user-token.ts index e8b40e56a..42070dcdd 100644 --- a/packages/@sanity/cli/test/__fixtures__/exec-get-user-token.ts +++ b/packages/@sanity/cli/test/__fixtures__/exec-get-user-token.ts @@ -12,8 +12,8 @@ try { console.log( JSON.stringify({ hasToken: typeof config.token === 'string' && config.token.length > 0, - token: config.token, success: true, + token: config.token, }), ) } catch (error) {