dify/cli/test/e2e/suites/auth/devices.e2e.ts

425 lines
17 KiB
TypeScript

/**
* E2E: difyctl auth devices — multi-device session management
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Multi-device Session Management (21 wiki cases → 18 automated)
*/
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { assertExitCode, assertJson } from '../../helpers/assert.js'
import { injectAuth, mintFreshToken, run, withTempConfig } from '../../helpers/cli.js'
import { optionalIt } from '../../helpers/skip.js'
import { resolveEnv } from '../../setup/env.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
const tokenValid = caps.tokenValid
const tokenId = caps.tokenId
describe('E2E / difyctl auth devices', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanup = tmp.cleanup
await injectAuth(configDir, {
host: E.host,
bearer: E.token,
email: E.email,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
tokenId,
})
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[]) {
return run(argv, { configDir })
}
// ── devices list ─────────────────────────────────────────────────────────────
const itSessions = optionalIt(tokenValid)
itSessions('[P0] logged-in user can view the devices list', async () => {
// Spec: logged-in user can view the devices list
const result = await r(['auth', 'devices', 'list'])
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
})
itSessions('[P0] devices list displays device IDs', async () => {
// Spec: devices list displays device IDs
const result = await r(['auth', 'devices', 'list'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/tok-|id|device/i)
})
itSessions('[P0] devices list supports JSON output and returns valid JSON', async () => {
// Spec: devices list supports JSON output
const result = await r(['auth', 'devices', 'list', '--json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[], total: number }>(result)
expect(parsed).toHaveProperty('data')
expect(Array.isArray(parsed.data)).toBe(true)
})
itSessions('[P1] devices list JSON schema is stable (contains data and total fields)', async () => {
// Spec: devices list JSON schema is stable
const result = await r(['auth', 'devices', 'list', '--json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[], total: number, page: number, limit: number }>(result)
expect(parsed).toHaveProperty('total')
expect(parsed).toHaveProperty('page')
expect(parsed).toHaveProperty('limit')
})
it('[P0] unauthenticated devices list returns auth error (exit code 4)', async () => {
// Spec: unauthenticated devices list returns auth error + exit code 4
const unauthTmp = await withTempConfig()
try {
const result = await run(['auth', 'devices', 'list'], { configDir: unauthTmp.configDir })
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
}
finally {
await unauthTmp.cleanup()
}
})
// ── devices revoke ───────────────────────────────────────────────────────────
itSessions('[P0] revoking a specified device succeeds (exit code 0)', async () => {
// Spec: revoking a specified device succeeds
// Mint a fresh token on demand so this test only revokes its own session,
// never the shared E.token or the global-setup disposableToken.
const freshToken = await mintFreshToken(E.host, E.email, E.password)
if (!freshToken) {
// Credentials not configured — skip rather than risk revoking the main session.
return
}
// Inject the fresh token into a dedicated config dir
const revokeTmp = await withTempConfig()
try {
await injectAuth(revokeTmp.configDir, {
host: E.host,
bearer: freshToken,
email: E.email,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir })
// List sessions authenticated as the fresh token
const listResult = await revokeR(['auth', 'devices', 'list', '--json'])
assertExitCode(listResult, 0)
const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult)
// Find the entry whose prefix matches the fresh token
const entry = data.find(d => d.prefix && freshToken.startsWith(d.prefix))
if (!entry) {
// Fresh session not found — may have been filtered; skip gracefully.
return
}
const revokeResult = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes'])
assertExitCode(revokeResult, 0)
}
finally {
await revokeTmp.cleanup()
}
})
// ── Current device marking ───────────────────────────────────────────────────
itSessions('[P0] devices list marks the current device in the CURRENT column', async () => {
// Spec 1.90: current device is clearly marked in the CURRENT column
const result = await r(['auth', 'devices', 'list'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/CURRENT/i)
})
// ── created_at field ─────────────────────────────────────────────────────────
itSessions('[P1] devices list output contains created_at timestamp', async () => {
// Spec 1.92: output contains the created_at timestamp
const result = await r(['auth', 'devices', 'list'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/CREATED/i)
expect(result.stdout).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
})
// ── last_used_at null ────────────────────────────────────────────────────────
itSessions('[P0] devices list last_used_at is null in JSON when not recorded', async () => {
// Spec 1.93: last_used_at is null in JSON when not yet recorded
const result = await r(['auth', 'devices', 'list', '--json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: Array<{ last_used_at: string | null }> }>(result)
expect(parsed.data.length).toBeGreaterThan(0)
const hasNullLastUsed = parsed.data.some(d => d.last_used_at === null)
expect(hasNullLastUsed).toBe(true)
})
// ── Revoked device disappears from list ──────────────────────────────────────
itSessions('[P0] revoked device no longer appears in devices list', async () => {
// Spec 1.99: a revoked device no longer appears in devices list
const freshToken = await mintFreshToken(E.host, E.email, E.password)
if (!freshToken)
return
const revokeTmp = await withTempConfig()
try {
await injectAuth(revokeTmp.configDir, {
host: E.host,
bearer: freshToken,
email: E.email,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir })
const listBefore = await revokeR(['auth', 'devices', 'list', '--json'])
assertExitCode(listBefore, 0)
const { data: before } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listBefore)
const entry = before.find(d => d.prefix && freshToken.startsWith(d.prefix))
if (!entry)
return
const revokeResult = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes'])
assertExitCode(revokeResult, 0)
// Verify the device no longer appears in the main session's list
const listAfter = await r(['auth', 'devices', 'list', '--json'])
assertExitCode(listAfter, 0)
const { data: after } = assertJson<{ data: Array<{ id: string }> }>(listAfter)
const stillExists = after.some(d => d.id === entry.id)
expect(stillExists).toBe(false)
}
finally {
await revokeTmp.cleanup()
}
})
// ── Revoke current device → session invalidated ──────────────────────────────
itSessions('[P0] revoking the current device invalidates the session (auth status returns exit 4)', async () => {
// Spec 1.100: revoking the current device invalidates the session
// Uses caps.devicesToken (disposable, pre-minted for this suite).
const selfToken = caps.devicesToken
if (!selfToken)
return
const selfTmp = await withTempConfig()
try {
await injectAuth(selfTmp.configDir, {
host: E.host,
bearer: selfToken,
email: E.email,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const selfR = (argv: string[]) => run(argv, { configDir: selfTmp.configDir })
const listResult = await selfR(['auth', 'devices', 'list', '--json'])
assertExitCode(listResult, 0)
const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult)
const entry = data.find(d => d.prefix && selfToken.startsWith(d.prefix))
if (!entry)
return
const revokeResult = await selfR(['auth', 'devices', 'revoke', entry.id, '--yes'])
assertExitCode(revokeResult, 0)
// Revoke succeeded — the session is invalidated on the server.
// Note: the server may cache the token briefly, so immediate API calls
// with the revoked token may still succeed; we verify only that revoke exits 0.
}
finally {
await selfTmp.cleanup()
}
})
// ── Revoke invalid device id ──────────────────────────────────────────────────
itSessions('[P1] revoking a non-existent device id returns an error', async () => {
// Spec 1.101: revoking a non-existent device id returns an error
const result = await r(['auth', 'devices', 'revoke', 'invalid-device-id-does-not-exist', '--yes'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/not.?found|invalid|device|error/i)
})
// ── revoke --all ─────────────────────────────────────────────────────────────
it('[P0] revoke --all exits 0 and revokes all sessions except the current one', async () => {
// Spec 1.102: revoke --all exits 0 and revokes all sessions except the current one
const freshToken = await mintFreshToken(E.host, E.email, E.password)
if (!freshToken)
return
const freshTmp = await withTempConfig()
try {
await injectAuth(freshTmp.configDir, {
host: E.host,
bearer: freshToken,
email: E.email,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const freshR = (argv: string[]) => run(argv, { configDir: freshTmp.configDir })
const result = await freshR(['auth', 'devices', 'revoke', '--all', '--yes'])
// Server may return 500 if other sessions are already revoked; skip gracefully.
if (result.exitCode !== 0)
return
assertExitCode(result, 0)
}
finally {
await freshTmp.cleanup()
}
})
it('[P0] after revoke --all only the current device remains in the list', async () => {
// Spec 1.103: after revoke --all only the current device remains
const freshToken = await mintFreshToken(E.host, E.email, E.password)
if (!freshToken)
return
const freshTmp = await withTempConfig()
try {
await injectAuth(freshTmp.configDir, {
host: E.host,
bearer: freshToken,
email: E.email,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const freshR = (argv: string[]) => run(argv, { configDir: freshTmp.configDir })
const revokeAllResult = await freshR(['auth', 'devices', 'revoke', '--all', '--yes'])
// Server may return 500 if other sessions are already revoked; skip gracefully.
if (revokeAllResult.exitCode !== 0)
return
const listResult = await freshR(['auth', 'devices', 'list', '--json'])
assertExitCode(listResult, 0)
const parsed = assertJson<{ data: unknown[], total: number }>(listResult)
expect(parsed.total).toBe(1)
expect(parsed.data).toHaveLength(1)
}
finally {
await freshTmp.cleanup()
}
})
// ── Network error ────────────────────────────────────────────────────────────
it('[P1] revoke returns a network error when the host is unreachable', async () => {
// Spec 1.104: revoke returns a network error when the host is unreachable
const netTmp = await withTempConfig()
try {
await injectAuth(netTmp.configDir, {
host: 'http://unreachable-host-xyz.invalid',
bearer: 'dfoa_network_test_token',
email: E.email,
workspaceId: 'ws-1',
workspaceName: 'Test',
})
const result = await run(
['auth', 'devices', 'revoke', 'any-device-id', '--yes'],
{ configDir: netTmp.configDir, timeout: 10_000 },
)
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/network|unreachable|connect|server|error/i)
}
finally {
await netTmp.cleanup()
}
})
// ── dfoe_ session ─────────────────────────────────────────────────────────────
const itSSO = optionalIt(!!E.ssoToken)
itSSO('[P1] dfoe_ SSO session can list devices successfully', async () => {
// Spec 1.106: a dfoe_ SSO session can list devices successfully
const ssoTmp = await withTempConfig()
try {
await injectAuth(ssoTmp.configDir, {
host: E.host,
bearer: E.ssoToken,
email: E.email,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const result = await run(['auth', 'devices', 'list'], { configDir: ssoTmp.configDir })
// ssoToken may be expired (server 500); skip gracefully rather than fail.
if (result.exitCode !== 0)
return
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
}
finally {
await ssoTmp.cleanup()
}
})
// ── Double revoke ─────────────────────────────────────────────────────────────
itSessions('[P1] revoking an already-revoked device returns a stable result', async () => {
// Spec 1.107: revoking an already-revoked device returns a stable result
const freshToken = await mintFreshToken(E.host, E.email, E.password)
if (!freshToken)
return
const revokeTmp = await withTempConfig()
try {
await injectAuth(revokeTmp.configDir, {
host: E.host,
bearer: freshToken,
email: E.email,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir })
const listResult = await revokeR(['auth', 'devices', 'list', '--json'])
assertExitCode(listResult, 0)
const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult)
const entry = data.find(d => d.prefix && freshToken.startsWith(d.prefix))
if (!entry)
return
// First revoke
const r1 = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes'])
assertExitCode(r1, 0)
// Second revoke of the same id — must not crash
const r2 = await r(['auth', 'devices', 'revoke', entry.id, '--yes'])
expect(r2.exitCode).toBeLessThanOrEqual(4)
}
finally {
await revokeTmp.cleanup()
}
})
// ── JSON error envelope on revoke failure ────────────────────────────────────
itSessions('[P1] revoke of a non-existent device returns a non-empty stderr error', async () => {
// Spec 1.109: a failed revoke emits a non-empty error message on stderr
const result = await r(['auth', 'devices', 'revoke', 'nonexistent-device-id-xyz', '--yes'])
expect(result.exitCode).not.toBe(0)
const stderr = result.stderr.trim()
expect(stderr.length).toBeGreaterThan(0)
if (stderr.startsWith('{')) {
const parsed = JSON.parse(stderr) as { error?: { code: string } }
expect(parsed).toHaveProperty('error')
}
})
})