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
13 changes: 0 additions & 13 deletions src/commands/channel/channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,19 +582,6 @@ describe('tw channel delete', () => {
expect(apiMocks.deleteChannel).not.toHaveBeenCalled()
})

it('translates a 403 from the API into a FORBIDDEN CliError', async () => {
const apiError = Object.assign(new Error('Request failed with status 403'), {
httpStatusCode: 403,
responseData: {},
})
apiMocks.deleteChannel.mockRejectedValueOnce(apiError)
const program = createProgram()

await expect(
program.parseAsync(['node', 'tw', 'channel', 'delete', 'Engineering', '--yes']),
).rejects.toMatchObject({ code: 'FORBIDDEN' })
})

it('outputs JSON result with --yes --json', async () => {
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
Expand Down
25 changes: 1 addition & 24 deletions src/commands/channel/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,6 @@ import { resolveChannelRef } from '../../lib/refs.js'

type DeleteChannelOptions = MutationOptions & { yes?: boolean }

function isForbidden(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'httpStatusCode' in error &&
(error as { httpStatusCode: number }).httpStatusCode === 403
)
}

export async function deleteChannelCommand(
ref: string,
options: DeleteChannelOptions,
Expand Down Expand Up @@ -42,21 +33,7 @@ export async function deleteChannelCommand(
return
}

try {
await deleteChannel(channel.id)
} catch (error) {
if (isForbidden(error)) {
throw new CliError(
'FORBIDDEN',
`Twist refused to delete "${channel.name}" (id:${channel.id}): 403 Forbidden.`,
[
'Channel deletion is typically restricted to workspace admins',
'Ask a workspace admin to delete it, or use the Twist web UI',
],
)
}
throw error
}
await deleteChannel(channel.id)

if (options.json) {
console.log(formatJson({ id: channel.id, deleted: true }))
Expand Down
88 changes: 88 additions & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const sdkMocks = vi.hoisted(() => ({
deleteChannel: vi.fn(),
}))

vi.mock('@doist/twist-sdk', () => {
class TwistApi {
channels = { deleteChannel: sdkMocks.deleteChannel }
constructor(_token: string) {}
}
return {
TwistApi,
TwistRequestError: class TwistRequestError extends Error {
constructor(
message: string,
public httpStatusCode: number,
public responseData?: unknown,
) {
super(message)
}
},
}
})

vi.mock('./auth.js', () => ({
getApiToken: vi.fn().mockResolvedValue('test-token'),
getAuthMetadata: vi.fn().mockResolvedValue({ authMode: 'full' }),
}))

vi.mock('./spinner.js', () => ({
withSpinner: (_config: unknown, fn: () => Promise<unknown>) => fn(),
}))

describe('wrapResult — central 403 translation', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})

it('translates a plain 403 into a FORBIDDEN CliError', async () => {
sdkMocks.deleteChannel.mockRejectedValueOnce(
Object.assign(new Error('Request failed with status 403'), {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] The vi.mock block at the top of this file explicitly provides a TwistRequestError mock implementation, but these tests ignore it and manually reconstruct the same error shape using Object.assign. You can import { TwistRequestError } from '@doist/twist-sdk' and use new TwistRequestError('Request failed with status 403', 403, {}) here and in the tests below. This removes the duplicate error assembly and aligns with how errors.test.ts handles SDK errors.

httpStatusCode: 403,
responseData: {},
}),
)

const { createWrappedTwistClient } = await import('./api.js')
const client = createWrappedTwistClient('test-token')

await expect(client.channels.deleteChannel(500)).rejects.toMatchObject({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] This only pins the code. The change here is the centralized friendly translation, so the 403 cases should also assert the user-facing message (and ideally hints). As written, a regression to the wrong text would still pass. The same gap exists in the insufficient-scope case below.

code: 'FORBIDDEN',
})
})

it('prefers INSUFFICIENT_SCOPE over FORBIDDEN when error_string indicates scope', async () => {
sdkMocks.deleteChannel.mockRejectedValueOnce(
Object.assign(new Error('Request failed with status 403'), {
httpStatusCode: 403,
responseData: { error_string: 'Insufficient scope provided: channels:write' },
}),
)

const { createWrappedTwistClient } = await import('./api.js')
const client = createWrappedTwistClient('test-token')

await expect(client.channels.deleteChannel(500)).rejects.toMatchObject({
code: 'INSUFFICIENT_SCOPE',
})
})

it('passes non-403 errors through untranslated', async () => {
sdkMocks.deleteChannel.mockRejectedValueOnce(
Object.assign(new Error('Request failed with status 500'), {
httpStatusCode: 500,
responseData: {},
}),
)

const { createWrappedTwistClient } = await import('./api.js')
const client = createWrappedTwistClient('test-token')

await expect(client.channels.deleteChannel(500)).rejects.toThrow(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] toThrow('Request failed with status 500') doesn't actually prove non-403 errors are passed through untouched. A wrapped CliError with the same message would still satisfy this. Capture the original error object and assert the rejection is that same instance, or at least assert it's not a CliError.

'Request failed with status 500',
)
})
})
8 changes: 7 additions & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from '@doist/twist-sdk'
import { getApiToken } from './auth.js'
import { getConfig, updateConfig } from './config.js'
import { CliError, isInsufficientScope } from './errors.js'
import { CliError, isForbidden, isInsufficientScope } from './errors.js'
import { ensureWriteAllowed, isMutatingMethod } from './permissions.js'
import { getProgressTracker } from './progress.js'
import { withSpinner } from './spinner.js'
Expand Down Expand Up @@ -194,6 +194,12 @@ function wrapResult(
['Run `tw auth login` to re-authenticate with the required scopes'],
)
}
if (isForbidden(error)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] This only translates 403s for rejected promise-returning methods that pass through wrapResult. client.batch(...) doesn’t: it’s a top-level TwistApi method, and batched 403s are still surfaced later by assertBatchData() / getOptionalBatchData() as API_ERROR or BATCH_FAILED. Since commands like thread view, inbox, comment delete, and msg delete rely on batch, the new "central" FORBIDDEN/INSUFFICIENT_SCOPE translation is still incomplete. Either wrap batch on the root proxy too, or teach the batch helpers to map 403 responses into the same CliErrors.

throw new CliError('FORBIDDEN', 'Twist refused this action: 403 Forbidden.', [
'You may not have permission for this action — workspace admins can perform it',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] This hint now shows up for every SDK method that goes through wrapResult, including read-only calls like getChannel or getWorkspaces. On those paths, “workspace admins can perform it” is often irrelevant or misleading. Either pass method/mutation context into wrapResult and tailor the hint, or keep the centralized 403 copy fully generic.

'Or re-authenticate with `tw auth login` if your scopes look wrong',
])
}
throw error
})

Expand Down
47 changes: 46 additions & 1 deletion src/lib/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { TwistRequestError } from '@doist/twist-sdk'
import { describe, expect, it } from 'vitest'

import { isInsufficientScope } from './errors.js'
import { isForbidden, isInsufficientScope } from './errors.js'

describe('isInsufficientScope', () => {
it('returns true for a 403 with "Insufficient scope" error_string', () => {
Expand Down Expand Up @@ -38,3 +38,48 @@ describe('isInsufficientScope', () => {
expect(isInsufficientScope('string')).toBe(false)
})
})

describe('isForbidden', () => {
it('returns true for any 403 — even without responseData', () => {
const error = new TwistRequestError('Request failed with status 403', 403, undefined)
expect(isForbidden(error)).toBe(true)
})

it('returns true for a 403 with an arbitrary error_string', () => {
const error = new TwistRequestError('Request failed with status 403', 403, {
error_code: 100,
error_string: 'Access denied',
})
expect(isForbidden(error)).toBe(true)
})

it('returns false for non-403 status codes', () => {
expect(isForbidden(new TwistRequestError('Request failed with status 401', 401, {}))).toBe(
false,
)
expect(isForbidden(new TwistRequestError('Request failed with status 404', 404, {}))).toBe(
false,
)
expect(isForbidden(new TwistRequestError('Request failed with status 500', 500, {}))).toBe(
false,
)
})

it('returns false for plain errors and non-object values', () => {
expect(isForbidden(new Error('something'))).toBe(false)
expect(isForbidden(null)).toBe(false)
expect(isForbidden(undefined)).toBe(false)
expect(isForbidden('string')).toBe(false)
})

// Precedence: both predicates fire on a scope 403, so callers must test
// isInsufficientScope first (the central handler in api.ts does).
it('fires alongside isInsufficientScope for an "Insufficient scope" 403', () => {
const error = new TwistRequestError('Request failed with status 403', 403, {
error_code: 109,
error_string: 'Insufficient scope provided: user:write',
})
expect(isInsufficientScope(error)).toBe(true)
expect(isForbidden(error)).toBe(true)
})
})
16 changes: 16 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type ErrorCode =
// Auth & permissions
| 'AUTH_FAILED'
| 'AUTH_MIGRATION_PENDING'
| 'FORBIDDEN'
| 'INSUFFICIENT_SCOPE'
| 'INVALID_TOKEN'
| 'NO_TOKEN'
Expand Down Expand Up @@ -86,6 +87,21 @@ export function isInsufficientScope(error: unknown): boolean {
)
}

/**
* Check whether an error is any Twist API 403 response.
*
* Precedence: callers must test `isInsufficientScope` first so OAuth-scope
* 403s keep their dedicated `INSUFFICIENT_SCOPE` code and hints; `isForbidden`
* is the catch-all fallback for plain workspace-permission 403s.
*/
export function isForbidden(error: unknown): boolean {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] isForbidden() duplicates the same Twist-request shape/status narrowing that isInsufficientScope() already does. Please extract the common httpStatusCode check into a tiny shared helper (or build one predicate on top of the other) so these classifiers don't drift as more API-status helpers get added.

if (typeof error !== 'object' || error === null || !('httpStatusCode' in error)) {
return false
}
const { httpStatusCode } = error as { httpStatusCode: number }
return httpStatusCode === 403
}

/**
* Twist-flavoured CliError that preserves the historical positional
* `(code, message, hints?, type?)` signature used across hundreds of call
Expand Down
Loading