Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/pr-1584.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- auto-generated -->
---
'@sanity/cli': patch
---

fix(cli): stop sanity logout from sending env tokens to the session endpoint
67 changes: 54 additions & 13 deletions packages/@sanity/cli/src/commands/__tests__/logout.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {getCliToken, setCliUserConfig} from '@sanity/cli-core'
import {getCliUserConfig, setCliUserConfig} from '@sanity/cli-core'
import {mockApi, testCommand} from '@sanity/cli-test'
import {cleanAll, pendingMocks} from 'nock'
import {afterEach, describe, expect, test, vi} from 'vitest'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'

import {AUTH_API_VERSION} from '../../services/auth.js'
import {LogoutCommand} from '../logout.js'
Expand All @@ -12,27 +12,35 @@ vi.mock('@sanity/cli-core', async () => {
const actual = await vi.importActual<typeof import('@sanity/cli-core')>('@sanity/cli-core')
return {
...actual,
getCliToken: vi.fn(),
getCliUserConfig: vi.fn(),
getUserConfig: vi.fn().mockReturnValue({
delete: mockConfigStoreDelete,
}),
setCliUserConfig: vi.fn(),
}
})

const mockedGetCliToken = vi.mocked(getCliToken)
const mockedGetCliUserConfig = vi.mocked(getCliUserConfig)
const mockedSetConfig = vi.mocked(setCliUserConfig)

beforeEach(() => {
// The command reads SANITY_AUTH_TOKEN directly — a token inherited from the developer's shell
// (e.g. running the suite from a minted directory) must not leak into the no-env-token cases.
// An empty stub reads as absent; env-token tests stub their own value over it.
vi.stubEnv('SANITY_AUTH_TOKEN', '')
})

afterEach(() => {
vi.unstubAllEnvs()
vi.clearAllMocks()
const pending = pendingMocks()
cleanAll()
expect(pending, 'pending mocks').toEqual([])
})

describe('#logout', () => {
test('logs out successfully if a token exists', async () => {
mockedGetCliToken.mockResolvedValueOnce('test-token')
test('logs out successfully if a stored session exists', async () => {
mockedGetCliUserConfig.mockReturnValueOnce('test-token')

mockApi({
apiVersion: AUTH_API_VERSION,
Expand All @@ -48,7 +56,7 @@ describe('#logout', () => {
})

test('logs out successfully when session is expired (401)', async () => {
mockedGetCliToken.mockResolvedValueOnce('test-token')
mockedGetCliUserConfig.mockReturnValueOnce('test-token')

mockApi({
apiVersion: AUTH_API_VERSION,
Expand All @@ -66,8 +74,8 @@ describe('#logout', () => {
expect(mockConfigStoreDelete).toHaveBeenCalledWith('telemetryConsent')
})

test('shows an error if no token exists', async () => {
mockedGetCliToken.mockResolvedValueOnce('')
test('shows an error if no credentials exist', async () => {
mockedGetCliUserConfig.mockReturnValueOnce(undefined)
Comment thread
cursor[bot] marked this conversation as resolved.

const {stdout} = await testCommand(LogoutCommand)

Expand All @@ -76,19 +84,52 @@ describe('#logout', () => {
expect(mockConfigStoreDelete).not.toHaveBeenCalled()
})

test('throws error on API failure (non-401)', async () => {
mockedGetCliToken.mockResolvedValueOnce('test-token')
test('env token only: explains it cannot be logged out, calls no API', async () => {
vi.stubEnv('SANITY_AUTH_TOKEN', 'sk-robot-token')
mockedGetCliUserConfig.mockReturnValueOnce(undefined)

const {error, stderr, stdout} = await testCommand(LogoutCommand)

expect(error).toBeUndefined()
expect(stderr).toContain('SANITY_AUTH_TOKEN is set in the environment')
// oclif wraps warnings, so assert a fragment that fits on one wrapped line.
expect(stderr).toContain('Remove that variable')
expect(stdout).not.toContain('No login credentials found')
expect(mockedSetConfig).not.toHaveBeenCalled()
expect(mockConfigStoreDelete).not.toHaveBeenCalled()
})

test('env token plus stored session: warns about the env token and ends the session', async () => {
vi.stubEnv('SANITY_AUTH_TOKEN', 'sk-robot-token')
mockedGetCliUserConfig.mockReturnValueOnce('session-token')

mockApi({
apiVersion: AUTH_API_VERSION,
method: 'post',
uri: '/auth/logout',
}).reply(200)

const {stderr, stdout} = await testCommand(LogoutCommand)

expect(stderr).toContain('SANITY_AUTH_TOKEN is set in the environment')
expect(stdout).toContain('Logged out successfully')
expect(mockedSetConfig).toHaveBeenCalledWith('authToken', undefined)
})

test('surfaces only the status on API failure, never the response body', async () => {
mockedGetCliUserConfig.mockReturnValueOnce('test-token')

mockApi({
apiVersion: AUTH_API_VERSION,
method: 'post',
uri: '/auth/logout',
}).reply(500, {message: 'Internal Server Error'})
}).reply(500, {message: 'Populus error: something internal'})

const {error} = await testCommand(LogoutCommand)

expect(error).toBeDefined()
expect(error?.message).toContain('Failed to logout')
expect(error?.message).toContain('Failed to logout (HTTP 500)')
expect(error?.message).not.toContain('Populus')
expect(mockedSetConfig).not.toHaveBeenCalled()
expect(mockConfigStoreDelete).not.toHaveBeenCalled()
})
Expand Down
31 changes: 26 additions & 5 deletions packages/@sanity/cli/src/commands/logout.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
exitCodes,
getCliToken,
getCliUserConfig,
getUserConfig,
SanityCommand,
setCliUserConfig,
Expand All @@ -15,14 +15,27 @@ export class LogoutCommand extends SanityCommand<typeof LogoutCommand> {
public async run(): Promise<void> {
await this.parse(LogoutCommand)

const previousToken = await getCliToken()
if (!previousToken) {
this.log('No login credentials found')
// An env-sourced token (SANITY_AUTH_TOKEN, often loaded from ./.env) is not a login
// session: there is nothing server-side to end, and clearing local config would not stop
// the next command from picking the variable up again. Robot tokens from `sanity new` make
// the session endpoint reject outright — so say what to do instead of calling it.
const envToken = process.env.SANITY_AUTH_TOKEN?.trim()
if (envToken) {
this.warn(
'SANITY_AUTH_TOKEN is set in the environment (often via ./.env) — logging out cannot end it. Remove that variable to stop acting as its identity.',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we improve this error message a bit?

)
}

// Target the stored login session directly: `getCliToken()` prefers the env token, which is
// exactly the credential `sanity logout` must not send to the session endpoint.
const sessionToken = getCliUserConfig('authToken')
if (!sessionToken) {
if (!envToken) this.log('No login credentials found')
return
}

try {
await logout()
await logout(sessionToken)

this.clearConfig()
} catch (error) {
Expand All @@ -33,6 +46,14 @@ export class LogoutCommand extends SanityCommand<typeof LogoutCommand> {
this.clearConfig()
return
}
// API failure bodies can name internal services — surface only the status, keep the
// local credentials so a retry is possible.
if (isHttpError(error)) {
this.error(
`Failed to logout (HTTP ${error.response.statusCode}). Your local session was kept — try again shortly.`,
{exit: exitCodes.RUNTIME_ERROR},
)
}
const err = error instanceof Error ? error : new Error(`${error}`)
this.error(`Failed to logout: ${err.message}`, {exit: exitCodes.RUNTIME_ERROR})
}
Expand Down
Loading