mirror of
https://github.com/langgenius/dify.git
synced 2026-06-12 19:43:43 +08:00
425 lines
17 KiB
TypeScript
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')
|
|
}
|
|
})
|
|
})
|