-
Notifications
You must be signed in to change notification settings - Fork 1
refactor(errors): centralize 403 FORBIDDEN translation in wrapResult #247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: lmjabreu/determined-poincare-43dd3d
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'), { | ||
| httpStatusCode: 403, | ||
| responseData: {}, | ||
| }), | ||
| ) | ||
|
|
||
| const { createWrappedTwistClient } = await import('./api.js') | ||
| const client = createWrappedTwistClient('test-token') | ||
|
|
||
| await expect(client.channels.deleteChannel(500)).rejects.toMatchObject({ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] This only pins the |
||
| 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] |
||
| 'Request failed with status 500', | ||
| ) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -194,6 +194,12 @@ function wrapResult( | |
| ['Run `tw auth login` to re-authenticate with the required scopes'], | ||
| ) | ||
| } | ||
| if (isForbidden(error)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| throw new CliError('FORBIDDEN', 'Twist refused this action: 403 Forbidden.', [ | ||
| 'You may not have permission for this action — workspace admins can perform it', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 'Or re-authenticate with `tw auth login` if your scopes look wrong', | ||
| ]) | ||
| } | ||
| throw error | ||
| }) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ export type ErrorCode = | |
| // Auth & permissions | ||
| | 'AUTH_FAILED' | ||
| | 'AUTH_MIGRATION_PENDING' | ||
| | 'FORBIDDEN' | ||
| | 'INSUFFICIENT_SCOPE' | ||
| | 'INVALID_TOKEN' | ||
| | 'NO_TOKEN' | ||
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] |
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] The
vi.mockblock at the top of this file explicitly provides aTwistRequestErrormock implementation, but these tests ignore it and manually reconstruct the same error shape usingObject.assign. You canimport { TwistRequestError } from '@doist/twist-sdk'and usenew TwistRequestError('Request failed with status 403', 403, {})here and in the tests below. This removes the duplicate error assembly and aligns with howerrors.test.tshandles SDK errors.