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
46 changes: 46 additions & 0 deletions src/commands/status/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,31 @@ import {
import { isInteractive } from '../../utils/scripted-commands.js'
import type BaseCommand from '../base-command.js'

// TODO: centralize these in a shared error-code dictionary alongside the exit codes.
export const STATUS_ERROR_CODES = {
NOT_LOGGED_IN: 'NOT_LOGGED_IN',
NOT_LINKED: 'NOT_LINKED',
} as const

export const status = async (options: OptionValues, command: BaseCommand) => {
const { accounts, api, globalConfig, site, siteInfo } = command.netlify
const currentUserId = globalConfig.get('userId') as string | undefined
const [accessToken] = await getToken()

if (!accessToken) {
if (options.json) {
logJson({
loggedIn: false,
linked: false,
account: null,
siteData: null,
error: {
code: STATUS_ERROR_CODES.NOT_LOGGED_IN,
fix: 'netlify login or NETLIFY_AUTH_TOKEN',
},
})
return exit(1)
}
log(`Not logged in. Please log in to see project status.`)
log()
if (!isInteractive()) {
Expand All @@ -43,6 +62,18 @@ export const status = async (options: OptionValues, command: BaseCommand) => {
user = await api.getCurrentUser()
} catch (error_) {
if ((error_ as APIError).status === 401) {
if (options.json) {
logJson({
loggedIn: false,
linked: false,
account: null,
siteData: null,
error: {
code: STATUS_ERROR_CODES.NOT_LOGGED_IN,
fix: 'netlify login or NETLIFY_AUTH_TOKEN',
},
})
}
return logAndThrowError(
'Your session has expired. Please try to re-authenticate by running `netlify logout` and `netlify login`.',
)
Expand Down Expand Up @@ -70,6 +101,18 @@ export const status = async (options: OptionValues, command: BaseCommand) => {
log(prettyjson.render(cleanAccountData))

if (!siteId) {
if (options.json) {
logJson({
loggedIn: true,
linked: false,
account: cleanAccountData,
siteData: null,
error: {
code: STATUS_ERROR_CODES.NOT_LINKED,
fix: 'netlify link',
},
})
}
warn('Did you run `netlify link` yet?')
return logAndThrowError(`You don't appear to be in a folder that is linked to a project`)
}
Expand All @@ -85,6 +128,9 @@ export const status = async (options: OptionValues, command: BaseCommand) => {
'site-url': siteInfo.ssl_url || siteInfo.url,
'site-id': siteInfo.id,
},
loggedIn: true,
linked: true,
error: null,
})
}

Expand Down
186 changes: 186 additions & 0 deletions tests/unit/commands/status/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { describe, expect, test, vi, beforeEach } from 'vitest'

const { mockGetToken, mockGetCurrentUser, logMessages, jsonMessages, exitCalls } = vi.hoisted(() => {
const mockGetToken = vi.fn()
const mockGetCurrentUser = vi.fn()
const logMessages: string[] = []
const jsonMessages: unknown[] = []
const exitCalls: number[] = []
return { mockGetToken, mockGetCurrentUser, logMessages, jsonMessages, exitCalls }
})

vi.mock('../../../../src/utils/command-helpers.js', async () => ({
...(await vi.importActual('../../../../src/utils/command-helpers.js')),
getToken: (...args: unknown[]) => mockGetToken(...args) as unknown,
log: (...args: string[]) => {
logMessages.push(args.join(' '))
},
logJson: (message: unknown) => {
jsonMessages.push(message)
},
warn: (message: string) => {
logMessages.push(message)
},
logAndThrowError: (message: unknown): never => {
throw message instanceof Error ? message : new Error(String(message))
},
exit: (code = 0): never => {
exitCalls.push(code)
throw new Error(`exit(${String(code)})`)
},
}))

import { status, STATUS_ERROR_CODES } from '../../../../src/commands/status/status.js'

function createMockCommand(overrides: { siteId?: string } = {}) {
const { siteId } = overrides

return {
netlify: {
accounts: [{ name: 'My Team' }],
api: {
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args) as unknown,
},
globalConfig: {
get: vi.fn().mockReturnValue(undefined),
},
site: { id: siteId, configPath: '/project/netlify.toml' },
siteInfo: {
id: siteId,
name: 'my-site',
admin_url: 'https://app.netlify.com/sites/my-site',
ssl_url: 'https://my-site.netlify.app',
url: 'http://my-site.netlify.app',
},
},
} as unknown as Parameters<typeof status>[1]
}

describe('status', () => {
beforeEach(() => {
logMessages.length = 0
jsonMessages.length = 0
exitCalls.length = 0
vi.clearAllMocks()
mockGetToken.mockResolvedValue(['fake-token', 'config'])
mockGetCurrentUser.mockResolvedValue({ full_name: 'Test User', email: 'test@example.com' })
})

describe('not logged in', () => {
beforeEach(() => {
mockGetToken.mockResolvedValue([null, 'not found'])
})

test('with --json emits a NOT_LOGGED_IN error envelope and exits non-zero', async () => {
await expect(status({ json: true }, createMockCommand())).rejects.toThrow('exit(1)')

expect(jsonMessages).toHaveLength(1)
expect(jsonMessages[0]).toEqual({
loggedIn: false,
linked: false,
account: null,
siteData: null,
error: {
code: STATUS_ERROR_CODES.NOT_LOGGED_IN,
fix: 'netlify login or NETLIFY_AUTH_TOKEN',
},
})
expect(exitCalls).toEqual([1])
})

test('without --json keeps existing behavior: human message and exit 0', async () => {
await expect(status({}, createMockCommand())).rejects.toThrow('exit(0)')

expect(jsonMessages).toHaveLength(0)
expect(exitCalls).toEqual([0])
expect(logMessages.join('\n')).toContain('Not logged in')
})
})

describe('logged in but not linked', () => {
test('with --json emits a NOT_LINKED error envelope including account data', async () => {
await expect(status({ json: true }, createMockCommand())).rejects.toThrow(
"You don't appear to be in a folder that is linked to a project",
)

expect(jsonMessages).toHaveLength(1)
expect(jsonMessages[0]).toEqual({
loggedIn: true,
linked: false,
account: {
Name: 'Test User',
Email: 'test@example.com',
Teams: ['My Team'],
},
siteData: null,
error: {
code: STATUS_ERROR_CODES.NOT_LINKED,
fix: 'netlify link',
},
})
})

test('without --json keeps existing behavior: warns and throws without JSON output', async () => {
await expect(status({}, createMockCommand())).rejects.toThrow(
"You don't appear to be in a folder that is linked to a project",
)

expect(jsonMessages).toHaveLength(0)
expect(logMessages.join('\n')).toContain('Did you run `netlify link` yet?')
})
})

describe('logged in and linked', () => {
test('with --json keeps existing keys and adds loggedIn, linked, and error', async () => {
await status({ json: true }, createMockCommand({ siteId: 'site-123' }))

expect(jsonMessages).toHaveLength(1)
expect(jsonMessages[0]).toEqual({
account: {
Name: 'Test User',
Email: 'test@example.com',
Teams: ['My Team'],
},
siteData: {
'site-name': 'my-site',
'config-path': '/project/netlify.toml',
'admin-url': 'https://app.netlify.com/sites/my-site',
'site-url': 'https://my-site.netlify.app',
'site-id': 'site-123',
},
loggedIn: true,
linked: true,
error: null,
})
expect(exitCalls).toHaveLength(0)
})

test('without --json does not emit JSON', async () => {
await status({}, createMockCommand({ siteId: 'site-123' }))

expect(jsonMessages).toHaveLength(0)
})
})

describe('expired session', () => {
beforeEach(() => {
mockGetCurrentUser.mockRejectedValue(Object.assign(new Error('Unauthorized'), { status: 401 }))
})

test('with --json emits a NOT_LOGGED_IN error envelope before throwing', async () => {
await expect(status({ json: true }, createMockCommand())).rejects.toThrow('Your session has expired')

expect(jsonMessages).toHaveLength(1)
expect(jsonMessages[0]).toMatchObject({
loggedIn: false,
error: { code: STATUS_ERROR_CODES.NOT_LOGGED_IN },
})
})
Comment on lines +170 to +178

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Assert the complete expired-session envelope.

toMatchObject permits regressions to linked, account, siteData, and error.fix, despite those being part of the standardized response contract.

Proposed fix
-      expect(jsonMessages[0]).toMatchObject({
+      expect(jsonMessages[0]).toEqual({
         loggedIn: false,
-        error: { code: STATUS_ERROR_CODES.NOT_LOGGED_IN },
+        linked: false,
+        account: null,
+        siteData: null,
+        error: {
+          code: STATUS_ERROR_CODES.NOT_LOGGED_IN,
+          fix: 'netlify login or NETLIFY_AUTH_TOKEN',
+        },
       })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('with --json emits a NOT_LOGGED_IN error envelope before throwing', async () => {
await expect(status({ json: true }, createMockCommand())).rejects.toThrow('Your session has expired')
expect(jsonMessages).toHaveLength(1)
expect(jsonMessages[0]).toMatchObject({
loggedIn: false,
error: { code: STATUS_ERROR_CODES.NOT_LOGGED_IN },
})
})
test('with --json emits a NOT_LOGGED_IN error envelope before throwing', async () => {
await expect(status({ json: true }, createMockCommand())).rejects.toThrow('Your session has expired')
expect(jsonMessages).toHaveLength(1)
expect(jsonMessages[0]).toEqual({
loggedIn: false,
linked: false,
account: null,
siteData: null,
error: {
code: STATUS_ERROR_CODES.NOT_LOGGED_IN,
fix: 'netlify login or NETLIFY_AUTH_TOKEN',
},
})
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/commands/status/status.test.ts` around lines 170 - 178, The test
for the JSON expired-session response should validate the complete standardized
envelope rather than a partial match. Update the assertion in the “with --json
emits a NOT_LOGGED_IN error envelope before throwing” test to check linked,
account, siteData, and error.fix along with the existing loggedIn and error.code
fields, using the expected contract values.


test('without --json keeps existing behavior: throws without JSON output', async () => {
await expect(status({}, createMockCommand())).rejects.toThrow('Your session has expired')

expect(jsonMessages).toHaveLength(0)
})
})
})
Loading