Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 11 additions & 25 deletions packages/@sanity/cli-core/src/config/__tests__/cliToken.test.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,21 @@
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(),
}))

describe('getCliToken', () => {
const originalEnv = process.env
let cachedToken: string | undefined
let getCliToken: () => Promise<string | undefined>

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(() => {
Expand All @@ -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()
})
Expand All @@ -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')
})
Expand All @@ -57,17 +43,17 @@ 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')
})

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')
Expand All @@ -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')
Expand Down
88 changes: 86 additions & 2 deletions packages/@sanity/cli/src/services/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -12,6 +13,7 @@ import {
function createInMemoryConfigStore() {
const store = new Map<string, unknown>()
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),
Expand All @@ -20,18 +22,44 @@ function createInMemoryConfigStore() {

const testConfigStore = createInMemoryConfigStore()

const mockGetCliToken = vi.hoisted(() => vi.fn<() => Promise<string | undefined>>())

vi.mock('@sanity/cli-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@sanity/cli-core')>()
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(() => {
Expand All @@ -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'})
})
})
30 changes: 28 additions & 2 deletions packages/@sanity/cli/src/services/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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')
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading