refactor(openapi/cli): split app usage-face from studio-app build-face (#37641)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Xiyuan Chen
2026-06-22 07:46:59 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 1d74bff311
commit 084f122814
59 changed files with 878 additions and 611 deletions
+3 -3
View File
@@ -519,14 +519,14 @@ async function provisionApps(
async function importAppCli(filePath: string, wsId: string): Promise<string> {
const result = await run(
['import', 'app', '--from-file', filePath, '--workspace', wsId],
['import', 'studio-app', '--from-file', filePath, '--workspace', wsId],
{ configDir, timeout: 60_000 },
)
if (result.exitCode !== 0)
throw new Error(`import app failed (exit ${result.exitCode}): ${result.stderr}`)
throw new Error(`import studio-app failed (exit ${result.exitCode}): ${result.stderr}`)
const match = result.stderr.match(/app ([0-9a-f-]{36})/)
if (!match?.[1])
throw new Error(`import app: could not parse app_id: ${result.stderr}`)
throw new Error(`import studio-app: could not parse app_id: ${result.stderr}`)
return match[1]
}
@@ -288,7 +288,7 @@ describe('E2E / agent skill — get app -o json (auth required)', () => {
expect(line.trim()).not.toMatch(/\s/)
})
itWithSso('[P0] [SSO] dfoe_ get app → JSON error envelope (insufficient_scope)', async () => {
itWithSso('[P0] [SSO] dfoe_ get app -o json → permitted-apps list envelope', async () => {
const tc = await withTempConfig()
try {
const { mkdir, writeFile } = await import('node:fs/promises')
@@ -296,12 +296,21 @@ describe('E2E / agent skill — get app -o json (auth required)', () => {
await mkdir(tc.configDir, { recursive: true })
await writeFile(
join(tc.configDir, 'hosts.yml'),
`${[`current_host: ${E.host}`, 'token_storage: file', 'tokens:', ` bearer: ${E.ssoToken}`].join('\n')}\n`,
`${[
`current_host: ${E.host}`,
'token_storage: file',
'tokens:',
` bearer: ${E.ssoToken}`,
'external_subject:',
' email: sso@example.com',
' issuer: https://issuer.example.com',
].join('\n')}\n`,
{ mode: 0o600 },
)
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
expect(r.exitCode).not.toBe(0)
assertErrorEnvelope(r)
assertExitCode(r, 0)
const parsed = assertJson<{ data: unknown[] }>(r)
expect(Array.isArray(parsed.data), 'permitted-apps envelope has a data array').toBe(true)
}
finally { await tc.cleanup() }
})
+6 -6
View File
@@ -57,6 +57,8 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
})
}
const itWithSso = optionalIt(Boolean(E.ssoToken))
// ── auth whoami — internal user ──────────────────────────────────────────────
it('[P0] internal user auth whoami outputs email', async () => {
@@ -123,12 +125,12 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
expect(result.exitCode).not.toBe(0)
})
it('[P0] external user get app returns insufficient_scope error', async () => {
// Spec: external user get app returns insufficient_scope
itWithSso('[P0] external user can list permitted apps via SSO token', async () => {
// External users read apps via the permitted-external surface (no workspace scope).
await withSSOAuth()
const result = await r(['get', 'app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/insufficient|scope|workspace|SSO/i)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i)
})
it('[P0] external user whoami outputs SSO email', async () => {
@@ -138,8 +140,6 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
expect(result.stdout).toContain('sso-user@example.com')
})
const itWithSso = optionalIt(Boolean(E.ssoToken))
itWithSso('[P0] external user can execute run app using SSO token', async () => {
await injectSsoAuth(configDir, {
host: E.host,
@@ -67,12 +67,6 @@ describe('E2E / difyctl describe app', () => {
expect(result.stdout).toMatch(/Name:/i)
})
it('[P1] describe output contains Tags field', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/Tags:/i)
})
// ── Input schema ──────────────────────────────────────────────────────────
it('[P0] describe output contains Parameters section', async () => {
@@ -172,8 +166,9 @@ describe('E2E / difyctl describe app', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user describe app returns insufficient_scope (3.86)', async () => {
// Spec 3.86: dfoe_ token → insufficient_scope, exit non-0.
itWithSso('[P0] external SSO user can describe a permitted app', async () => {
// A dfoe_ token resolves `describe app` via the permitted-external surface
// (not the account /apps surface), so a permitted app describes successfully.
// Uses DIFY_E2E_SSO_TOKEN; skipped when not configured.
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
@@ -191,8 +186,10 @@ describe('E2E / difyctl describe app', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['describe', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user describe app should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/ID:/i)
expect(result.stdout).toContain(E.chatAppId)
expect(result.stdout).toMatch(/Mode:/i)
}
finally {
await ssoTmp.cleanup()
@@ -225,16 +222,6 @@ describe('E2E / difyctl describe app', () => {
expect(result.stdout).toContain('e2e-test')
})
it('[P1] describe output contains Author field (3.67)', async () => {
// Spec 3.67: output includes Author field when app has an author.
const result = await withRetry(
() => fx.r(['describe', 'app', E.chatAppId]),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/Author:/i)
})
it('[P0] Inputs section shows parameter names (3.70)', async () => {
// Spec 3.70: Parameters/Inputs section displays variable names.
// workflow app has x, num, enum_var, paragraph.
@@ -61,7 +61,7 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => {
eeIt('[EE][P0] -o wide output contains WORKSPACE column and JSON has workspace_id (3.92)', async () => {
// Spec 3.92: WORKSPACE column (priority:1) appears only in -o wide mode.
// Default table shows priority:0 columns only (NAME/ID/MODE/TAGS/UPDATED).
// Default table shows priority:0 columns only (NAME/ID/MODE/UPDATED).
const wideResult = await withRetry(
() => fx.r(['get', 'app', '-A', '-o', 'wide']),
{ attempts: 3, delayMs: 2000 },
@@ -151,15 +151,15 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user get app -A returns insufficient_scope error (3.103)', async () => {
// Spec 3.103: dfoe_ token on -A → insufficient_scope, exit non-0.
// Merged from two duplicate fake-token cases; now uses real DIFY_E2E_SSO_TOKEN.
itWithSso('[P0] external SSO user get app -A is rejected as an invalid flag', async () => {
// --all-workspaces is meaningless for external SSO users (no workspace
// scope), so the CLI rejects it client-side with usage_invalid_flag (exit 2).
// Uses real DIFY_E2E_SSO_TOKEN; skipped when not configured.
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const ssoTmp = await withTempConfig()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
// Use minimal SSO hosts.yml (no workspace) so CLI hits the scope/auth error path.
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
@@ -171,8 +171,8 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user -A should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth|missing/i)
assertExitCode(result, 2)
expect(result.stderr).toMatch(/--all-workspaces is not available for external logins/)
}
finally {
await ssoTmp.cleanup()
@@ -8,7 +8,6 @@
* DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app
*/
import { Buffer } from 'node:buffer'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import {
assertErrorEnvelope,
@@ -99,8 +98,8 @@ describe('E2E / difyctl get app (list)', () => {
it('[P1] -o wide outputs extended fields', async () => {
const result = await fx.r(['get', 'app', '-o', 'wide'])
assertExitCode(result, 0)
// wide adds AUTHOR and WORKSPACE columns
expect(result.stdout).toMatch(/AUTHOR|WORKSPACE/i)
// wide adds the WORKSPACE column
expect(result.stdout).toMatch(/WORKSPACE/i)
})
it('[P1] output is pipe-friendly in JSON mode', async () => {
@@ -206,17 +205,15 @@ describe('E2E / difyctl get app (list)', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user get app returns insufficient_scope error (3.24 / 3.25)', async () => {
// Spec 3.24: dfoe_ token → insufficient_scope; Spec 3.25: exit code is 1.
itWithSso('[P0] external SSO user can list permitted apps', async () => {
// A dfoe_ token lists apps via the permitted-external surface
// (apps:read:permitted-external scope), with no workspace scoping.
// Uses DIFY_E2E_SSO_TOKEN (itWithSso skips when not configured).
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const ssoTmp = await withTempConfig()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
// SSO (dfoe_) users have apps:run scope only, not apps:list.
// Inject a minimal hosts.yml without workspace so the CLI reaches the
// scope-check path rather than resolving the workspace successfully.
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
@@ -228,8 +225,8 @@ describe('E2E / difyctl get app (list)', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user get app should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i)
}
finally {
await ssoTmp.cleanup()
@@ -348,114 +345,4 @@ describe('E2E / difyctl get app (list)', () => {
await networkTmp.cleanup()
}
})
it('[P1] --tag filter returns only apps that carry the specified tag (3.20)', async () => {
// Spec 3.20: --tag performs exact tag-name match.
//
// Before asserting: ensure echo-chat app has the 'e2e-test' tag.
// 1. GET /console/api/tags?type=app&keyword=e2e-test → find or confirm tag exists
// 2. POST /console/api/tags → create tag when absent
// 3. GET /console/api/apps/<id> → check existing bindings
// 4. POST /console/api/tag-bindings → bind when not yet bound
const base = E.host.replace(/\/$/, '')
// ── Console login: obtain cookie + CSRF (console API rejects dfoa_ Bearer) ──
const passwordB64 = Buffer.from(E.password, 'utf8').toString('base64')
const loginRes = await fetch(`${base}/console/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: E.email, password: passwordB64, remember_me: false }),
})
expect(loginRes.ok, `console login failed: ${loginRes.status}`).toBe(true)
// Helper: extract cookie string + csrf from Set-Cookie array
function parseCookies(res: Response): { cookieString: string, csrfToken: string } {
const setCookies = res.headers.getSetCookie?.() ?? []
const cookieString = setCookies.map(kv => kv.split(';')[0]).join('; ')
const csrfPair = setCookies.map(kv => kv.split(';')[0]).filter((p): p is string => typeof p === 'string' && p.includes('csrf_token='))[0]
const csrfToken = csrfPair !== undefined
? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length)
: ''
return { cookieString, csrfToken }
}
let { cookieString, csrfToken } = parseCookies(loginRes)
// ── Switch to the workspace that contains the test fixtures ──────────────
// E.workspaceId is resolved by global-setup; tag-bindings scope to the active workspace.
const switchRes = await fetch(`${base}/console/api/workspaces/switch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ tenant_id: E.workspaceId }),
})
// After workspace switch the server issues fresh cookies; use them for all subsequent calls.
if (switchRes.ok && switchRes.headers.getSetCookie?.().length) {
const switched = parseCookies(switchRes)
cookieString = switched.cookieString
csrfToken = switched.csrfToken
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cookie': cookieString,
'X-CSRF-Token': csrfToken,
}
// ── Step 1: find the 'e2e-test' app tag ──────────────────────────────────
const tagsRes = await fetch(`${base}/console/api/tags?type=app&keyword=e2e-test`, { headers })
expect(tagsRes.ok, `GET /tags failed: ${tagsRes.status}`).toBe(true)
const tagsList = await tagsRes.json() as Array<{ id: string, name: string }>
let tagId = tagsList.find(t => t.name === 'e2e-test')?.id
// ── Step 2: create the tag if it doesn't exist yet ───────────────────────
if (!tagId) {
const createRes = await fetch(`${base}/console/api/tags`, {
method: 'POST',
headers,
body: JSON.stringify({ name: 'e2e-test', type: 'app' }),
})
expect(createRes.ok, `POST /tags failed: ${createRes.status}`).toBe(true)
const created = await createRes.json() as { id: string, name: string }
tagId = created.id
}
expect(tagId, 'tag id must be resolved').toBeTruthy()
// ── Step 3 & 4: bind tag idempotently (tag-bindings is idempotent on duplicates) ──
const bindRes = await fetch(`${base}/console/api/tag-bindings`, {
method: 'POST',
headers,
body: JSON.stringify({
tag_ids: [tagId],
target_id: E.chatAppId,
type: 'app',
}),
})
// Accept 200 (bound) or 409/4xx if already bound — binding is idempotent
expect(
bindRes.ok || bindRes.status === 409,
`POST /tag-bindings failed unexpectedly: ${bindRes.status}`,
).toBe(true)
// ── Assertion: difyctl --tag e2e-test returns echo-chat ──────────────────
const result = await fx.r(['get', 'app', '--tag', 'e2e-test', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: Array<{ id: string, name: string, tags: Array<{ name: string }> }> }>(result)
// echo-chat must appear in the filtered list
const echoChatInResult = parsed.data.find(app => app.id === E.chatAppId)
expect(
echoChatInResult,
`echo-chat (id=${E.chatAppId}) should appear in --tag e2e-test results`,
).toBeDefined()
// Every returned app must carry the e2e-test tag
parsed.data.forEach(app =>
expect(
app.tags.some(t => t.name === 'e2e-test'),
`app "${app.name}" should carry the e2e-test tag`,
).toBe(true),
)
})
})
@@ -68,8 +68,9 @@ describe('E2E / difyctl get app <id> (single)', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user get app <id> returns insufficient_scope error (3.55)', async () => {
// Spec 3.55: dfoe_ token on get app <id> → insufficient_scope, exit 1.
itWithSso('[P0] external SSO user can get a permitted app by id', async () => {
// A dfoe_ token resolves get app <id> via the permitted-external describe
// surface (apps:read:permitted-external scope), so a permitted app is returned.
// Uses DIFY_E2E_SSO_TOKEN; skipped when not configured.
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
@@ -87,8 +88,8 @@ describe('E2E / difyctl get app <id> (single)', () => {
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
expect(result.exitCode, 'SSO user get app <id> should exit non-zero').not.toBe(0)
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
assertExitCode(result, 0)
expect(result.stdout).toContain(E.chatAppId)
}
finally {
await ssoTmp.cleanup()
@@ -153,13 +154,13 @@ describe('E2E / difyctl get app <id> (single)', () => {
})
it('[P1] get app <id> -o wide outputs extended columns (3.48)', async () => {
// Spec 3.48: -o wide → TAGS/UPDATED/AUTHOR columns, exit 0.
// Spec 3.48: -o wide → UPDATED/WORKSPACE columns, exit 0.
const result = await withRetry(
() => fx.r(['get', 'app', E.chatAppId, '-o', 'wide']),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(result, 0)
expect(result.stdout).toMatch(/AUTHOR|UPDATED|TAGS/i)
expect(result.stdout).toMatch(/UPDATED|WORKSPACE/i)
})
it('[P1] get app <id> -o json is pipe-friendly with no ANSI (3.49)', async () => {
@@ -1,5 +1,5 @@
/**
* E2E: difyctl export app DSL export
* E2E: difyctl export studio-app DSL export
*
* Prerequisites (DIFY_E2E_* env vars):
* DIFY_E2E_WORKFLOW_APP_ID echo-workflow app (no model provider dependency)
@@ -21,7 +21,7 @@ import { resolveEnv } from '../../setup/env.js'
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
describe('E2E / difyctl export app', () => {
describe('E2E / difyctl export studio-app', () => {
let fx: AuthFixture
beforeEach(async () => {
@@ -34,37 +34,37 @@ describe('E2E / difyctl export app', () => {
// ── Basic export ──────────────────────────────────────────────────────────
it('[P0] exported DSL is non-empty YAML printed to stdout', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout.trim().length).toBeGreaterThan(0)
})
it('[P0] exported YAML contains kind: app', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
})
it('[P0] exported YAML contains version field', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^version:/m)
})
it('[P0] exported YAML contains app section with mode', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^\s+mode:/m)
})
it('[P1] exported YAML ends with a newline (POSIX pipe convention)', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout.endsWith('\n')).toBe(true)
})
it('[P1] chat app export also succeeds and includes mode', async () => {
const result = await fx.r(['export', 'app', E.chatAppId])
const result = await fx.r(['export', 'studio-app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
expect(result.stdout).toMatch(/^\s+mode:/m)
@@ -76,7 +76,7 @@ describe('E2E / difyctl export app', () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-'))
const outPath = join(dir, 'exported.yaml')
try {
const result = await fx.r(['export', 'app', E.workflowAppId, '--output', outPath])
const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath])
assertExitCode(result, 0)
const content = await readFile(outPath, 'utf8')
expect(content).toMatch(/^kind:\s*app/m)
@@ -92,8 +92,8 @@ describe('E2E / difyctl export app', () => {
const outPath = join(dir, 'exported.yaml')
try {
const [stdoutResult, fileResult] = await Promise.all([
fx.r(['export', 'app', E.workflowAppId]),
fx.r(['export', 'app', E.workflowAppId, '--output', outPath]).then(async (r) => {
fx.r(['export', 'studio-app', E.workflowAppId]),
fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath]).then(async (r) => {
const content = await readFile(outPath, 'utf8')
return { exitCode: r.exitCode, content }
}),
@@ -113,12 +113,12 @@ describe('E2E / difyctl export app', () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-'))
const dslPath = join(dir, 'roundtrip.yaml')
try {
const exportResult = await fx.r(['export', 'app', E.workflowAppId, '--output', dslPath])
const exportResult = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', dslPath])
assertExitCode(exportResult, 0)
const importResult = await fx.r([
'import',
'app',
'studio-app',
'--from-file',
dslPath,
'--name',
@@ -137,7 +137,7 @@ describe('E2E / difyctl export app', () => {
// ── Error scenarios ───────────────────────────────────────────────────────
it('[P0] non-existent app returns exit code 1 with error in stderr', async () => {
const result = await fx.r(['export', 'app', 'nonexistent-app-id-export-e2e'])
const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-export-e2e'])
expect(result.exitCode).toBe(1)
expect(result.stderr.length).toBeGreaterThan(0)
})
@@ -145,7 +145,7 @@ describe('E2E / difyctl export app', () => {
it('[P0] unauthenticated export returns auth error (exit code 4)', async () => {
const unauthTmp = await withTempConfig()
try {
const result = await run(['export', 'app', E.workflowAppId], {
const result = await run(['export', 'studio-app', E.workflowAppId], {
configDir: unauthTmp.configDir,
})
assertExitCode(result, 4)
@@ -156,13 +156,13 @@ describe('E2E / difyctl export app', () => {
})
it('[P1] export with missing app id argument exits non-zero', async () => {
const result = await fx.r(['export', 'app'])
const result = await fx.r(['export', 'studio-app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument|required|app id/i)
})
it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/http_status:\s*4\d\d/)
expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/)
@@ -171,7 +171,7 @@ describe('E2E / difyctl export app', () => {
it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => {
const result = await fx.r([
'export',
'app',
'studio-app',
E.workflowAppId,
'--workflow-id',
'00000000-0000-0000-0000-000000000000',
@@ -184,7 +184,7 @@ describe('E2E / difyctl export app', () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-'))
const outPath = join(dir, 'should-not-exist.yaml')
try {
const result = await fx.r(['export', 'app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
expect(result.exitCode).not.toBe(0)
const exists = await readFile(outPath, 'utf8').then(() => true).catch(() => false)
expect(exists, 'output file must not be created on export failure').toBe(false)
@@ -82,10 +82,9 @@ describe('E2E / error message standards (spec 5.3)', () => {
// ── 5.63 dfoe_ token insufficient_scope ──────────────────────────────────
itWithSso('[P0] 5.63 dfoe_ SSO token with workspace returns insufficient_scope for management commands', async () => {
// Spec 5.63: an external SSO token (dfoe_) must not be able to access
// internal management APIs; the CLI must return an insufficient_scope
// error with exit 1.
itWithSso('[P0] dfoe_ SSO token is denied account-only management commands', async () => {
// A dfoe_ SSO token is rejected with a non-zero exit when it targets an
// account-only management command (`export studio-app`).
const { mkdir } = await import('node:fs/promises')
const ssoTmp = await withTempConfig()
try {
@@ -95,16 +94,13 @@ describe('E2E / error message standards (spec 5.3)', () => {
`token_storage: file`,
`tokens:`,
` bearer: ${E.ssoToken}`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: member`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
const result = await run(['export', 'studio-app', E.chatAppId], { configDir: ssoTmp.configDir })
assertNonZeroExit(result)
// In this environment ssoToken may be a dfoa_ token; the server returns
// either insufficient_scope or server_5xx — both are non-zero exits.
expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0)
}
finally {
+5 -35
View File
@@ -41,7 +41,7 @@ import type { AuthFixture } from '../../helpers/cli.js'
import { afterEach, beforeEach, describe, expect, it, inject } from 'vitest'
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
import { withAuthFixture } from '../../helpers/cli.js'
import { loadE2EEnv, resolveEnv } from '../../setup/env.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
@@ -65,19 +65,18 @@ describe('E2E / table output — header and column format (spec 5.15.19)', ()
expect(result.stdout.trim().length).toBeGreaterThan(0)
})
it('[P0] 5.2 header row contains all five expected column names', async () => {
// Spec 5.2: header columns are NAME / ID / MODE / TAGS / UPDATED.
it('[P0] 5.2 header row contains all four expected column names', async () => {
// Spec 5.2: header columns are NAME / ID / MODE / UPDATED.
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
const header = result.stdout.split('\n')[0] ?? ''
expect(header).toMatch(/NAME/i)
expect(header).toMatch(/ID/i)
expect(header).toMatch(/MODE/i)
expect(header).toMatch(/TAGS/i)
expect(header).toMatch(/UPDATED/i)
})
it('[P0] 5.3 column order is NAME → ID → MODE → TAGS → UPDATED', async () => {
it('[P0] 5.3 column order is NAME → ID → MODE → UPDATED', async () => {
// Spec 5.3: columns appear in the defined order (as verified from actual CLI output).
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
@@ -85,19 +84,16 @@ describe('E2E / table output — header and column format (spec 5.15.19)', ()
const nameIdx = header.indexOf('NAME')
const idIdx = header.indexOf('ID')
const modeIdx = header.indexOf('MODE')
const tagsIdx = header.indexOf('TAGS')
const updatedIdx = header.indexOf('UPDATED')
// All columns must be present
expect(nameIdx).toBeGreaterThanOrEqual(0)
expect(idIdx).toBeGreaterThanOrEqual(0)
expect(modeIdx).toBeGreaterThanOrEqual(0)
expect(tagsIdx).toBeGreaterThanOrEqual(0)
expect(updatedIdx).toBeGreaterThanOrEqual(0)
// Verify left-to-right order
expect(nameIdx).toBeLessThan(idIdx)
expect(idIdx).toBeLessThan(modeIdx)
expect(modeIdx).toBeLessThan(tagsIdx)
expect(tagsIdx).toBeLessThan(updatedIdx)
expect(modeIdx).toBeLessThan(updatedIdx)
})
it('[P0] 5.5 table displays multiple data rows when more than one app exists', async () => {
@@ -153,32 +149,6 @@ describe('E2E / table output — header and column format (spec 5.15.19)', ()
expect(result.stdout).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/)
})
// ── 5.17 — Empty-field rendering ─────────────────────────────────────────
it('[P1] 5.17 empty TAGS field is rendered as blank — not as a dash (-)', async () => {
// Spec 5.17: empty fields show blank, not the `-` placeholder.
// Most apps in the fixture workspace have no tags.
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
const lines = result.stdout.trim().split('\n')
const header = lines[0] ?? ''
const tagsStart = header.indexOf('TAGS')
const updatedStart = header.indexOf('UPDATED')
// Check at least one data row: the TAGS slice should be blank, not '-'
const dataLines = lines.slice(1).filter(l => l.trim())
if (dataLines.length > 0 && tagsStart >= 0 && updatedStart > tagsStart) {
const tagsSlice = (dataLines[0] ?? '').substring(tagsStart, updatedStart).trim()
// If there are no tags, the slice should be empty (not contain a lone '-')
if (tagsSlice === '') {
expect(tagsSlice).toBe('')
}
else {
// Tags are present — just verify it's not the placeholder dash
expect(tagsSlice).not.toBe('-')
}
}
})
// ── 5.25 — Performance ────────────────────────────────────────────────────
it('[P1] 5.25 querying up to 100 apps completes without timeout', async () => {
+28 -2
View File
@@ -269,8 +269,34 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
name: app.name,
description: app.description,
mode: app.mode,
author: app.author ?? '',
tags: app.tags,
updated_at: app.updated_at,
service_api_enabled: app.service_api_enabled ?? false,
is_agent: app.is_agent ?? false,
}
: null,
parameters: wantParams ? (app.parameters ?? null) : null,
input_schema: wantInputSchema ? (app.input_schema ?? null) : null,
})
})
app.get('/openapi/v1/permitted-external-apps/:id/describe', (c) => {
const id = c.req.param('id')
const fieldsRaw = c.req.query('fields') ?? ''
const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '')
// External subjects have no workspace scope; the app is reachable across workspaces.
const app = APPS.find(a => a.id === id)
if (app === undefined)
return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 })
const wantInfo = fields.length === 0 || fields.includes('info')
const wantParams = fields.length === 0 || fields.includes('parameters')
const wantInputSchema = fields.length === 0 || fields.includes('input_schema')
return c.json({
info: wantInfo
? {
id: app.id,
name: app.name,
description: app.description,
mode: app.mode,
updated_at: app.updated_at,
service_api_enabled: app.service_api_enabled ?? false,
is_agent: app.is_agent ?? false,