diff --git a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts index 30a5d213b7d..932998d9afe 100644 --- a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts +++ b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts @@ -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, 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 + // 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 + // 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 () => {