mirror of
https://github.com/langgenius/dify.git
synced 2026-09-21 05:11:22 +08:00
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:
co-authored by
autofix-ci[bot]
parent
1d74bff311
commit
084f122814
@@ -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() }
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+19
-19
@@ -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 {
|
||||
|
||||
@@ -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.1–5.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.1–5.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.1–5.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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user