/** * 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 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') } }) })