mirror of
https://github.com/langgenius/dify.git
synced 2026-06-27 01:27:08 +08:00
Compare commits
5 Commits
1.15.0
...
test/cli-e
| Author | SHA1 | Date | |
|---|---|---|---|
| b8ab27c5ee | |||
| fadf3acc94 | |||
| 2da773e8d8 | |||
| e3e7ff26a5 | |||
| 1bd9cf8cc6 |
@ -37,6 +37,7 @@ import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import { ZERO } from '@/util/uuid.js'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertNoAnsi,
|
||||
assertNonZeroExit,
|
||||
} from '../../helpers/assert.js'
|
||||
@ -215,6 +216,147 @@ describe('E2E / error message standards (spec 5.3)', () => {
|
||||
expect(result.stderr).not.toContain(sentValue)
|
||||
})
|
||||
|
||||
// ── 5.70d-h ErrorBody contract — error.server structure and rendering priorities ──
|
||||
// PR #37285 introduces canonical ErrorBody on every /openapi/v1 non-2xx response.
|
||||
// CLI strict-parses via zErrorBody.safeParse; success → full struct at error.server.
|
||||
//
|
||||
// V2 rendering priorities (format.ts, verified against codebase):
|
||||
// header code : server?.code ?? cliCode — server wins, CLI fallback
|
||||
// hint : cliHint ?? server?.hint — CLI wins, server fallback (V2 correction)
|
||||
// details : server?.details[] — " - loc: msg (type)" per entry, no -v
|
||||
|
||||
it('[P0] 5.70d JSON envelope contains error.server with canonical code/status/message', async () => {
|
||||
// Trigger: describe app ZERO — server returns canonical 404 ErrorBody
|
||||
// { code:"not_found", status:404, message:"app not found" }.
|
||||
// zErrorBody.safeParse succeeds → error.server is populated on the current server.
|
||||
const result = await fx.r(['describe', 'app', ZERO, '-o', 'json'])
|
||||
assertNonZeroExit(result)
|
||||
const envelope = JSON.parse(result.stderr.trim()) as {
|
||||
error: { code: string, server?: { code: string, status: number, message: string } }
|
||||
}
|
||||
expect(envelope.error.server, 'error.server must be present when server returns canonical ErrorBody').toBeDefined()
|
||||
expect(typeof envelope.error.server?.code, 'error.server.code must be a string').toBe('string')
|
||||
expect(envelope.error.server?.code.length).toBeGreaterThan(0)
|
||||
expect(typeof envelope.error.server?.status, 'error.server.status must be a number').toBe('number')
|
||||
expect(typeof envelope.error.server?.message, 'error.server.message must be a string').toBe('string')
|
||||
expect(envelope.error.server?.message.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P1] 5.70e @accepts query validation returns canonical 422 with details array', async () => {
|
||||
// Trigger: direct fetch to GET /apps?page=not-integer — @accepts(query=AppListQuery)
|
||||
// validates page as int and emits canonical 422 ErrorBody with details[].
|
||||
// Direct fetch is used because the CLI validates --page as integer client-side
|
||||
// (would exit 2 before hitting the server); this pins the server-side contract.
|
||||
const res = await fetch(
|
||||
`${E.host.replace(/\/$/, '')}/openapi/v1/apps?workspace_id=${E.workspaceId}&page=not-an-integer`,
|
||||
{ headers: { Authorization: `Bearer ${E.token}` }, signal: AbortSignal.timeout(8_000) },
|
||||
)
|
||||
expect(res.status).toBe(422)
|
||||
const body = await res.json() as {
|
||||
code?: string
|
||||
status?: number
|
||||
details?: Array<{ type: string, loc: Array<string | number>, msg: string }>
|
||||
}
|
||||
expect(body.code).toBe('invalid_param')
|
||||
expect(body.status).toBe(422)
|
||||
expect(Array.isArray(body.details), 'details must be an array').toBe(true)
|
||||
expect(body.details!.length).toBeGreaterThan(0)
|
||||
const entry = body.details![0]!
|
||||
expect(typeof entry.type).toBe('string')
|
||||
expect(typeof entry.msg).toBe('string')
|
||||
expect(Array.isArray(entry.loc)).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] 5.70g rendering priority — header code: server code wins over CLI classification code', async () => {
|
||||
// renderHuman: headerCode = server?.code ?? e.code (server wins, V2 unchanged)
|
||||
// When canonical ErrorBody is parsed, the server semantic code replaces the CLI
|
||||
// classification code ("server_4xx_other") in the human-readable output header.
|
||||
// Trigger: describe app ZERO → canonical 404; header starts with "not_found:".
|
||||
const result = await fx.r(['describe', 'app', ZERO])
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr.trimStart()).not.toMatch(/^server_4xx_other:/)
|
||||
expect(result.stderr.trimStart()).toMatch(/^not_found:/)
|
||||
})
|
||||
|
||||
it('[P1] 5.70g2 rendering priority — hint: CLI hint wins over server hint (V2 correction)', async () => {
|
||||
// renderHuman: hint = cliHint ?? server?.hint (CLI wins — V2 spec correction)
|
||||
// V1 incorrectly documented "server wins"; V2 aligns with codebase: CLI wins.
|
||||
// Test: 401 AuthExpired — classifyResponse sets c.hint = AUTH_LOGIN_HINT before
|
||||
// serverError is parsed; CLI hint takes precedence over any server-provided hint.
|
||||
// Verified on current server (no @accepts deployment required).
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'app', '-o', 'json'], { configDir: unauthTmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
const envelope = JSON.parse(result.stderr.trim()) as { error: { hint?: string } }
|
||||
expect(envelope.error.hint, 'CLI login hint must appear for auth error').toMatch(/auth login/i)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] 5.70h JSON envelope: error.code = CLI classification; error.server.code = server semantic code', async () => {
|
||||
// toEnvelope() sets error.code from HTTP status bucket (e.g. "server_4xx_other")
|
||||
// while the server's semantic code is separate in error.server.code.
|
||||
// Agents can branch on error.server.code without parsing human-readable text.
|
||||
// Trigger: describe app ZERO → canonical 404; error.code="server_4xx_other",
|
||||
// error.server.code="not_found" — always distinct when ErrorBody is present.
|
||||
const result = await fx.r(['describe', 'app', ZERO, '-o', 'json'])
|
||||
assertNonZeroExit(result)
|
||||
const envelope = JSON.parse(result.stderr.trim()) as {
|
||||
error: { code: string, server?: { code: string } }
|
||||
}
|
||||
expect(envelope.error.code).toBe('server_4xx_other')
|
||||
expect(envelope.error.server?.code).toBeDefined()
|
||||
expect(envelope.error.server?.code).not.toBe('server_4xx_other')
|
||||
})
|
||||
// ── 5.70i / 5.70j PR #37285 boundary contract ───────────────────────────
|
||||
|
||||
it('[P1] 5.70i unknown /openapi/v1 route returns canonical 404 ErrorBody without route suggestions', async () => {
|
||||
// PR #37285: ExternalApi._help_on_404 suppresses flask-restx route enumeration.
|
||||
// Previously, an unknown path under /openapi/v1/ returned flask-restx's default
|
||||
// 404 with a "Did you mean /openapi/v1/apps?" suggestion, leaking the route table.
|
||||
// After the fix it must return a canonical ErrorBody and contain no suggestions.
|
||||
const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/this-path-does-not-exist-e2e`, {
|
||||
headers: { Authorization: `Bearer ${E.token}` },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json() as Record<string, unknown>
|
||||
// canonical ErrorBody fields must be present
|
||||
expect(typeof body.code, '404 body must have a string code field').toBe('string')
|
||||
expect(body.status, '404 body must have status: 404').toBe(404)
|
||||
// no flask-restx route enumeration in the response
|
||||
const raw = JSON.stringify(body)
|
||||
expect(raw).not.toMatch(/did you mean/i)
|
||||
expect(raw).not.toMatch(/you might want/i)
|
||||
})
|
||||
|
||||
it('[P1] 5.70j device-flow 4xx uses RFC 8628 format, not ErrorBody — zErrorBody parse fails gracefully', async () => {
|
||||
// PR #37285 explicitly excludes RFC 8628 device-flow endpoints from the
|
||||
// ErrorBody contract. This test pins that contract:
|
||||
// - The device/token endpoint returns RFC 8628 {error: string} on failure,
|
||||
// not the canonical {code, status, message} shape.
|
||||
// - When the CLI's classifyResponse encounters this, zErrorBody.safeParse
|
||||
// returns failure → serverError = undefined → generic status-based message,
|
||||
// no error.server field, no crash.
|
||||
const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/oauth/device/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_code: 'fake-invalid-device-code-e2e-test', client_id: 'difyctl' }),
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
})
|
||||
// device flow errors are 4xx (400 bad_request or 401 expired_token etc.)
|
||||
expect(res.status).toBeGreaterThanOrEqual(400)
|
||||
expect(res.status).toBeLessThan(500)
|
||||
const body = await res.json() as Record<string, unknown>
|
||||
// RFC 8628 shape: has 'error' string, must NOT have ErrorBody 'code'/'status' pair
|
||||
expect(typeof body.error, 'RFC 8628 body must have a string error field').toBe('string')
|
||||
expect(body).not.toHaveProperty('status')
|
||||
// zErrorBody.safeParse would fail → CLI sets serverError = undefined → generic message
|
||||
})
|
||||
|
||||
// ── 5.76 Failed command + -o yaml → stderr is still JSON envelope ────────
|
||||
|
||||
it('[P1] 5.76 failed command with -o yaml still outputs a JSON error envelope on stderr', async () => {
|
||||
|
||||
407
cli/test/e2e/suites/member/member.e2e.ts
Normal file
407
cli/test/e2e/suites/member/member.e2e.ts
Normal file
@ -0,0 +1,407 @@
|
||||
/**
|
||||
* E2E: difyctl get/create/delete/set member — Member Management
|
||||
*
|
||||
* Data lifecycle:
|
||||
* beforeAll — generates two random emails, invites them as test fixtures
|
||||
* afterAll — removes both fixtures (best-effort)
|
||||
*
|
||||
* Email format: auto_test+<timestamp>@dify.ai
|
||||
* No extra env vars required.
|
||||
*
|
||||
* JSON response shape (MemberListResponse):
|
||||
* { page, limit, total, has_more, data: MemberResponse[] }
|
||||
*
|
||||
* MemberResponse fields:
|
||||
* { id, name, email, role, status, avatar?, current: bool }
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertNonZeroExit,
|
||||
} from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.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)
|
||||
|
||||
// ── Fixture state ─────────────────────────────────────────────────────────────
|
||||
|
||||
let fx: AuthFixture
|
||||
|
||||
/** ID of the member used by get / set tests. */
|
||||
let testMemberId: string
|
||||
|
||||
/** ID of the member reserved for the delete-success test. */
|
||||
let deleteTargetId: string
|
||||
|
||||
const ts = Date.now()
|
||||
const memberEmail = `auto_test+${ts}@dify.ai`
|
||||
const deleteTargetEmail = `auto_test+${ts + 1}@dify.ai`
|
||||
|
||||
// ── Response type helpers ─────────────────────────────────────────────────────
|
||||
|
||||
type MemberItem = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
status: string
|
||||
current?: boolean
|
||||
}
|
||||
|
||||
type MemberListJson = {
|
||||
data: MemberItem[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
|
||||
// Invite the main test member; capture member_id from response
|
||||
const createMain = await fx.r([
|
||||
'create',
|
||||
'member',
|
||||
'--email',
|
||||
memberEmail,
|
||||
'--role',
|
||||
'normal',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
if (createMain.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`beforeAll: failed to create test member (${memberEmail}): ${createMain.stderr}`,
|
||||
)
|
||||
}
|
||||
const mainData = JSON.parse(createMain.stdout.trim()) as { member_id?: string }
|
||||
testMemberId = mainData.member_id as string
|
||||
if (!testMemberId)
|
||||
throw new Error(`beforeAll: missing member_id in: ${createMain.stdout}`)
|
||||
|
||||
// Invite the delete-target member
|
||||
const createTarget = await fx.r([
|
||||
'create',
|
||||
'member',
|
||||
'--email',
|
||||
deleteTargetEmail,
|
||||
'--role',
|
||||
'normal',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
if (createTarget.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`beforeAll: failed to create delete-target member (${deleteTargetEmail}): ${createTarget.stderr}`,
|
||||
)
|
||||
}
|
||||
const targetData = JSON.parse(createTarget.stdout.trim()) as { member_id?: string }
|
||||
deleteTargetId = targetData.member_id as string
|
||||
if (!deleteTargetId)
|
||||
throw new Error(`beforeAll: missing member_id in: ${createTarget.stdout}`)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (testMemberId) {
|
||||
await fx.r(['delete', 'member', testMemberId, '--yes']).catch(() => {})
|
||||
}
|
||||
if (deleteTargetId) {
|
||||
await fx.r(['delete', 'member', deleteTargetId, '--yes']).catch(() => {})
|
||||
}
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── get member ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl get member', () => {
|
||||
it('[P0] member list contains the created test member', async () => {
|
||||
const result = await fx.r(['get', 'member', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const data = assertJson<MemberListJson>(result)
|
||||
const ids = (data.data ?? []).map(m => m.id)
|
||||
expect(ids, `testMemberId ${testMemberId} must appear in member list`).toContain(testMemberId)
|
||||
})
|
||||
|
||||
it('[P0] default table output contains required column headers', async () => {
|
||||
const result = await fx.r(['get', 'member'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/\bID\b/)
|
||||
expect(result.stdout).toMatch(/\bNAME\b/)
|
||||
expect(result.stdout).toMatch(/\bEMAIL\b/)
|
||||
expect(result.stdout).toMatch(/\bROLE\b/)
|
||||
expect(result.stdout).toMatch(/\bSTATUS\b/)
|
||||
})
|
||||
|
||||
it('[P0] authenticated account appears in member list', async () => {
|
||||
// The token owner (auto_test@dify.ai) must appear in the member list
|
||||
const result = await fx.r(['get', 'member', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const data = assertJson<MemberListJson>(result)
|
||||
const ownerRow = data.data.find(m => m.email === E.email)
|
||||
expect(ownerRow, `owner email ${E.email} must be in member list`).toBeDefined()
|
||||
expect(ownerRow?.role).toBe('owner')
|
||||
expect(ownerRow?.status).toBe('active')
|
||||
})
|
||||
|
||||
it('[P0] -o json returns valid JSON with data array', async () => {
|
||||
const result = await fx.r(['get', 'member', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const data = assertJson<MemberListJson>(result)
|
||||
expect(Array.isArray(data.data), 'data must be an array').toBe(true)
|
||||
expect(data.data.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] -o json each member has id, email, role, status fields', async () => {
|
||||
const result = await fx.r(['get', 'member', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const data = assertJson<MemberListJson>(result)
|
||||
const member = data.data[0]!
|
||||
expect(typeof member.id).toBe('string')
|
||||
expect(typeof member.email).toBe('string')
|
||||
expect(typeof member.role).toBe('string')
|
||||
expect(typeof member.status).toBe('string')
|
||||
})
|
||||
|
||||
it('[P0] output has no ANSI colour codes (non-TTY)', async () => {
|
||||
const result = await fx.r(['get', 'member'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated get member returns auth error (exit code 4)', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'member'], { configDir: tmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] -o yaml returns valid YAML (non-empty, no JSON braces)', async () => {
|
||||
const result = await fx.r(['get', 'member', '-o', 'yaml'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||||
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
|
||||
})
|
||||
|
||||
it('[P1] -o json output is pipe-friendly (no ANSI, ends with newline)', async () => {
|
||||
const result = await fx.r(['get', 'member', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
expect(result.stdout.endsWith('\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] -w overrides the workspace', async () => {
|
||||
const result = await fx.r(['get', 'member', '-w', E.workspaceId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const data = assertJson<MemberListJson>(result)
|
||||
expect(Array.isArray(data.data)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── set member ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl set member', () => {
|
||||
it('[P0] owner/admin can promote normal → admin', async () => {
|
||||
const result = await fx.r(['set', 'member', testMemberId, '--role', 'admin', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const list = await fx.r(['get', 'member', '-o', 'json'])
|
||||
const data = assertJson<MemberListJson>(list)
|
||||
const updated = data.data.find(m => m.id === testMemberId)
|
||||
expect(updated?.role).toBe('admin')
|
||||
})
|
||||
|
||||
it('[P0] owner/admin can demote admin → normal', async () => {
|
||||
await fx.r(['set', 'member', testMemberId, '--role', 'admin'])
|
||||
const result = await fx.r(['set', 'member', testMemberId, '--role', 'normal', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const list = await fx.r(['get', 'member', '-o', 'json'])
|
||||
const data = assertJson<MemberListJson>(list)
|
||||
const updated = data.data.find(m => m.id === testMemberId)
|
||||
expect(updated?.role).toBe('normal')
|
||||
})
|
||||
|
||||
it('[P0] --role owner is rejected client-side (exit 2, no API call)', async () => {
|
||||
const result = await fx.r(['set', 'member', testMemberId, '--role', 'owner'])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/invalid|role|owner/i)
|
||||
})
|
||||
|
||||
it('[P0] missing --role returns usage error', async () => {
|
||||
const result = await fx.r(['set', 'member', testMemberId])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/role|required|missing/i)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated set member returns auth error (exit 4)', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['set', 'member', testMemberId, '--role', 'normal'], {
|
||||
configDir: tmp.configDir,
|
||||
})
|
||||
assertExitCode(result, 4)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] missing member-id returns usage error', async () => {
|
||||
const result = await fx.r(['set', 'member', '--role', 'normal'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing|required|arg|member/i)
|
||||
})
|
||||
|
||||
it('[P1] non-existent member-id returns server error', async () => {
|
||||
const result = await fx.r([
|
||||
'set',
|
||||
'member',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'--role',
|
||||
'normal',
|
||||
])
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── create member — error paths ───────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl create member (error paths)', () => {
|
||||
it('[P0] --role with invalid value is rejected client-side (exit 2)', async () => {
|
||||
const result = await fx.r([
|
||||
'create',
|
||||
'member',
|
||||
'--email',
|
||||
`auto_test+unused${Date.now()}@dify.ai`,
|
||||
'--role',
|
||||
'superadmin',
|
||||
])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/invalid|role/i)
|
||||
})
|
||||
|
||||
it('[P0] --role owner is rejected client-side (exit 2)', async () => {
|
||||
const result = await fx.r([
|
||||
'create',
|
||||
'member',
|
||||
'--email',
|
||||
`auto_test+unused${Date.now()}@dify.ai`,
|
||||
'--role',
|
||||
'owner',
|
||||
])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/invalid|role|owner/i)
|
||||
})
|
||||
|
||||
it('[P0] missing --email returns usage error', async () => {
|
||||
const result = await fx.r(['create', 'member', '--role', 'normal'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/email|required|missing/i)
|
||||
})
|
||||
|
||||
it('[P0] missing --role returns usage error', async () => {
|
||||
const result = await fx.r(['create', 'member', '--email', memberEmail])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/role|required|missing/i)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated create member returns auth error (exit 4)', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(
|
||||
['create', 'member', '--email', `auto_test+unauth${Date.now()}@dify.ai`, '--role', 'normal'],
|
||||
{ configDir: tmp.configDir },
|
||||
)
|
||||
assertExitCode(result, 4)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── delete member ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl delete member', () => {
|
||||
it('[P0] owner/admin can remove a member from the workspace', async () => {
|
||||
const result = await fx.r(['delete', 'member', deleteTargetId, '--yes'])
|
||||
assertExitCode(result, 0)
|
||||
const list = await fx.r(['get', 'member', '-o', 'json'])
|
||||
const data = assertJson<MemberListJson>(list)
|
||||
const ids = data.data.map(m => m.id)
|
||||
expect(ids).not.toContain(deleteTargetId)
|
||||
deleteTargetId = ''
|
||||
})
|
||||
|
||||
it('[P0] attempting to delete self returns server error', async () => {
|
||||
const list = await fx.r(['get', 'member', '-o', 'json'])
|
||||
const data = assertJson<MemberListJson>(list)
|
||||
const self = data.data.find(m => m.email === E.email)
|
||||
if (!self) {
|
||||
console.warn('[E2E] could not identify self in member list — skipping')
|
||||
return
|
||||
}
|
||||
const result = await fx.r(['delete', 'member', self.id, '--yes'])
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr).toMatch(/self|yourself|cannot|not.*allow|400|forbidden/i)
|
||||
})
|
||||
|
||||
it('[P0] missing member-id argument returns usage error', async () => {
|
||||
const result = await fx.r(['delete', 'member'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing|required|arg|member/i)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated delete member returns auth error (exit 4)', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(
|
||||
['delete', 'member', '00000000-0000-0000-0000-000000000000', '--yes'],
|
||||
{ configDir: tmp.configDir },
|
||||
)
|
||||
assertExitCode(result, 4)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] non-existent member-id returns server error', async () => {
|
||||
const result = await fx.r([
|
||||
'delete',
|
||||
'member',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'--yes',
|
||||
])
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P1] -o json outputs structured envelope on error', async () => {
|
||||
const result = await fx.r([
|
||||
'delete',
|
||||
'member',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'--yes',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertNonZeroExit(result)
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
})
|
||||
@ -92,6 +92,8 @@ export default defineConfig({
|
||||
'test/e2e/suites/framework/**/*.e2e.ts',
|
||||
// discovery (get app / describe app)
|
||||
'test/e2e/suites/discovery/**/*.e2e.ts',
|
||||
// member management (get/create/delete/set member)
|
||||
'test/e2e/suites/member/**/*.e2e.ts',
|
||||
// dsl (export / import)
|
||||
'test/e2e/suites/dsl/**/*.e2e.ts',
|
||||
// run tests (require valid token)
|
||||
|
||||
Reference in New Issue
Block a user