Compare commits

...

5 Commits

Author SHA1 Message Date
b8ab27c5ee test(cli/e2e): add member management E2E suite (get/create/delete/set member)
28 tests covering all four member commands:

get member (10 tests)
  - list contains created member
  - required column headers (ID/NAME/EMAIL/ROLE/STATUS)
  - authenticated account appears as owner/active
  - JSON shape: data array with id/email/role/status fields
  - no ANSI codes in non-TTY output
  - pipe-friendly JSON output (ends with newline)
  - -o yaml returns valid YAML
  - -w flag overrides workspace
  - unauthenticated → exit 4

set member (7 tests)
  - promote normal → admin, verify via get member
  - demote admin → normal, verify via get member
  - --role owner rejected client-side (exit 2)
  - missing --role → usage error
  - missing member-id → usage error
  - non-existent UUID → server error
  - unauthenticated → exit 4

create member error paths (5 tests)
  - invalid role (superadmin) rejected client-side (exit 2)
  - --role owner rejected client-side (exit 2)
  - missing --email → usage error
  - missing --role → usage error
  - unauthenticated → exit 4

delete member (6 tests)
  - success: member removed from list
  - cannot delete self → server error
  - missing member-id → usage error
  - non-existent UUID → server error
  - -o json on error → structured error envelope
  - unauthenticated → exit 4

Data lifecycle: beforeAll invites two auto_test+<ts>@dify.ai
members; afterAll cleans them up. No extra env vars required.
2026-06-18 17:39:11 +08:00
fadf3acc94 test(cli/e2e): remove 5.70f — test premise was incorrect
5.70f assumed that passing wrong-type inputs to run app would be
intercepted by @accepts(body=) and return HTTP 422 with canonical
ErrorBody details[]. This premise is wrong:

- @accepts(body=) validates the API schema (inputs must be a dict),
  not the workflow-level variable types (num must be a number)
- Wrong-type workflow inputs go through the SSE execution layer,
  returning an error event with status:500, not a canonical 422
- The CLI receives this via decodeStreamError, not classifyResponse,
  so rawResponse is never set and error.server is never populated

The behavior tested by 5.70f (CLI rendering details[] as indented
lines) remains valid as a contract requirement but has no triggerable
E2E path on the current server. Covered by unit tests for renderHuman.
2026-06-17 11:59:56 +08:00
2da773e8d8 test(cli/e2e): rewrite 5.70d-h — correct triggers and add V2 hint priority test
Rewrites 5 existing tests and adds 1 new test to align with the V2 ErrorBody spec
(history page: https://km.dify.langgenius.ai/wiki/spaces/Enterprise/history/490602534/):

Trigger fix (5.70d, 5.70g, 5.70h):
  Before: 'run app wrong-type input' → hits SSE execution layer → HTTP 500
  After:  'describe app ZERO' → hits @returns canonical 404 ErrorBody → error.server set
  Result: 5.70d, 5.70g, 5.70h now pass on the current server

Trigger fix (5.70e):
  Before: 'run app wrong-type input' → SSE 500, no details
  After:  direct fetch to GET /apps?page=not-integer → @accepts(query=) returns 422
          with canonical ErrorBody and details[] (bypasses CLI client-side page validation)
  Result: 5.70e now passes on the current server

Forward-looking (5.70f):
  Kept 'run app wrong-type input' trigger — documents that once @accepts covers the
  app run path, details lines will appear; remains red until that server change deploys

New (5.70g2):
  Tests V2 spec correction: hint priority is cliHint ?? server?.hint (CLI wins).
  V1 doc incorrectly said 'server wins'; V2 aligned with codebase.
  Trigger: unauthenticated → 401 AuthExpired → CLI AUTH_LOGIN_HINT appears at
  error.hint regardless of any server-provided hint. Passes on current server.
2026-06-17 10:42:37 +08:00
e3e7ff26a5 test(cli/e2e): add 5.70i/5.70j — 404 canonical body and RFC 8628 boundary
5.70i [P1] — /openapi/v1 unknown route 404 carries canonical ErrorBody
             (code, status) and must NOT contain flask-restx route suggestions
             ('did you mean'); pins the ExternalApi._help_on_404 fix from
             PR #37285

5.70j [P1] — device-flow token endpoint returns RFC 8628 {error: string}
             on failure, not the canonical ErrorBody shape; pins the
             explicit RFC 8628 exception documented in PR #37285 and
             confirms zErrorBody.safeParse would fail gracefully on this
             shape (serverError = undefined, generic message, no crash)
2026-06-16 18:09:19 +08:00
1bd9cf8cc6 test(cli/e2e): add ErrorBody contract tests for error.server structure
Add 5 new test cases (5.70d-h) that verify the CLI correctly forwards the
canonical ErrorBody from the server when request validation fails (422).

These tests define the E2E contract introduced by the OpenAPI ErrorBody
unification spec. They will be red until the server-side change deploys;
the spec document is at:
  https://km.dify.langgenius.ai/wiki/spaces/Enterprise/pages/490602534/

5.70d [P0] — JSON envelope contains error.server with code='invalid_param',
             status=422, and a non-empty message string
5.70e [P1] — error.server.details array carries field-level error entries
             with the {type, loc, msg} shape from ErrorDetail
5.70f [P1] — human-readable text mode renders each details entry as
             '  - <loc>: <msg> (<type>)' line (no -v required)
5.70g [P1] — text mode header code is server's 'invalid_param', not CLI's
             'server_4xx_other' (server code wins in renderHuman)
5.70h [P1] — JSON envelope error.code is CLI classification code
             ('server_4xx_other'); semantic code is in error.server.code
2026-06-16 17:19:19 +08:00
3 changed files with 551 additions and 0 deletions

View File

@ -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 () => {

View 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)
})
})

View File

@ -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)