Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/29019-form-refusal-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/console': patch
---

fix(console): a refused form submit says a localized sentence, never the server's response body

Clause-②: no
Metadata-protocol: none

The Console form page (`/forms/NAME`, the in-app form and the public `/f/SLUG`) drew a refused write as the verb, the status and the raw response body, so a member without create rights read `Create failed (403): {"error":…,"code":"PERMISSION_DENIED","object":…}` in English on the banner and the toast. The page now reads the refusal the way the record dialog does, and the banner and the toast say the same sentence:

- a sentence the producer marked for the end user (`userMessage`), or a delegated administrator's scope refusal, as written;
- any other denial (`403`, `PERMISSION_DENIED`, `FORBIDDEN`) as the localized "You don't have permission to save this record.";
- the per-rule lines no input carries, one per line, as before;
- the server's own sentence for any other `4xx`, read out of the envelope (`error`, or the wrapped `error.message`), never the body itself;
- a `5xx`, or a refusal with no sentence (a proxy's markup page), as the localized "Could not save. Please try again."

The fields a refusal names are still marked under their inputs. No i18n key is added: both lines are the existing `form.noPermissionToSave` and `form.submitFailed`, present in all ten packs.
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ describe('#27496 — a submit the browser blocks replaces the stale banner', ()
stubPublic(false, 400, { error: 'Validation failed', code: 'VALIDATION_FAILED' });
await refuseThenSubmitBadEmail();
await fixEmailAndSubmit();
expect(await screen.findByText(/Submit failed \(400\)/)).toBeInTheDocument();
// The server's sentence, out of the envelope, never the body (#29019).
expect(await screen.findByText('Validation failed')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
* error to, the same refusal in the runtime dispatcher's wrapped envelope, and
* the flat `409 UNIQUE_VIOLATION` naming its one `field`.
*
* The banner and the toast are unchanged: a refusal that names no field marks
* no row and still says what it said before.
* A refusal that names no field marks no row and still says the server's
* sentence; what the banner says for every other refusal is pinned in
* `FormPage.refusalText-29019.test.tsx`.
*/

import type React from 'react';
Expand Down Expand Up @@ -180,9 +181,10 @@ describe('#27417 — a server refusal naming fields marks each of them', () => {
expect(describedText(email)).toContain('Email is not a valid email address');
// The row the refusal does not name stays unmarked.
expect(screen.getByLabelText('Company')).not.toHaveAttribute('aria-invalid', 'true');
// The banner and the toast are unchanged.
// Every named row carries its reason, so the banner and the toast say the
// server's sentence — never the response body (#29019).
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
expect(String(vi.mocked(toast.error).mock.calls[0][0])).toContain('Submit failed (400)');
expect(vi.mocked(toast.error).mock.calls[0][0]).toBe('Validation failed');
});

it('internal route: the wrapped dispatcher envelope marks both named fields', async () => {
Expand Down
256 changes: 256 additions & 0 deletions ui/apps/console/src/components/FormPage.refusalText-29019.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack-ai/objectstack#29019 — a refused write says a sentence in the
* reader's language, never the response body.
*
* A member with read but no create right opened a form link, pressed Submit,
* and the banner and the toast both read the server's answer verbatim:
* `Create failed (403): {"error":"You do not have permission…","code":
* "PERMISSION_DENIED","object":"…"}` — English JSON, with the object's machine
* name, under a page whose report twin says "You don't have access".
*
* The page now reads the refusal the way the record dialog does: a denial is
* the localized `form.noPermissionToSave` line, a server fault (`5xx`) the
* localized `form.submitFailed` line, a `4xx` the server's own sentence out of
* the envelope, and the rows a refusal names are still marked. The fixtures
* are the envelopes the server answers: the flat body the data routes answer,
* the runtime dispatcher's wrapped one, and a proxy's markup page.
*/

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { toast } from 'sonner';
import { createI18n } from '@object-ui/i18n';
import { FormPage } from './FormPage';

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

const FIELDS = {
name: { type: 'text', label: 'Name' },
email: { type: 'text', label: 'Email' },
};

/** The QA run's refusal, byte for byte: a `POST /data/:object` without create. */
const PERMISSION_DENIED = {
status: 403,
body: {
error: 'You do not have permission to perform this action. Contact your administrator if you need access.',
code: 'PERMISSION_DENIED',
object: 'qar8_fsb',
},
};

let posts = 0;

type Refusal = { status: number; body: unknown };

/** One answer of the stubbed server; a string body goes out as it is. */
function answer(ok: boolean, status: number, body: unknown): Response {
const text = typeof body === 'string' ? body : JSON.stringify(body);
return {
ok,
status,
statusText: ok ? 'OK' : 'Refused',
json: async () => JSON.parse(text),
text: async () => text,
} as unknown as Response;
}

/** The internal route `/forms/:name`, refusing its `POST /data/:object` with `refusal`. */
function stubInternal(refusal: Refusal) {
const view = {
name: 'qar8_fsb.form',
object: 'qar8_fsb',
viewKind: 'form',
label: 'Feedback',
config: { type: 'simple', sections: [{ fields: ['name', 'email'] }] },
};
const object = { name: 'qar8_fsb', label: 'Feedback', fields: FIELDS };
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
posts += 1;
return answer(false, refusal.status, refusal.body);
}
if (String(url).includes('/meta/view/')) return answer(true, 200, view);
if (String(url).includes('/meta/object/')) return answer(true, 200, object);
throw new Error(`unstubbed fetch: ${url}`);
}),
);
return render(
<MemoryRouter initialEntries={['/forms/qar8_fsb.form']}>
<Routes>
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
</Routes>
</MemoryRouter>,
);
}

/** The public route `/f/:slug`, refusing its `POST /forms/:slug/submit` with `refusal`. */
function stubPublic(refusal: Refusal) {
const payload = {
slug: 'feedback',
object: 'qar8_fsb',
label: 'Feedback',
form: { type: 'simple', sections: [{ fields: ['name', 'email'] }] },
objectSchema: { name: 'qar8_fsb', fields: FIELDS },
};
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
posts += 1;
return answer(false, refusal.status, refusal.body);
}
if (!String(url).includes('/forms/')) throw new Error(`unstubbed fetch: ${url}`);
return answer(true, 200, payload);
}),
);
return render(
<MemoryRouter initialEntries={['/f/feedback']}>
<Routes>
<Route path="/f/:slug" element={<FormPage mode="public" />} />
</Routes>
</MemoryRouter>,
);
}

async function fillAndSubmit(submitName: RegExp | string = /Submit/) {
await userEvent.type(await screen.findByLabelText('Name'), 'Ada');
await userEvent.click(screen.getByRole('button', { name: submitName }));
}

/** The banner's text, once the refusal has drawn it. */
async function bannerText(): Promise<string> {
await waitFor(() => expect(document.querySelector('[id^="form-error:"]')).not.toBeNull());
return document.querySelector('[id^="form-error:"]')?.textContent ?? '';
}

/** The one refusal toast's content, as a string. */
function toastText(): string {
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
return String(vi.mocked(toast.error).mock.calls[0][0]);
}

/** Neither the body nor the transport's prose reached the reader. */
function expectNoRawBody(text: string, status: number) {
expect(text).not.toContain('{');
expect(text).not.toContain(String(status));
expect(text).not.toMatch(/failed \(/);
}

beforeEach(() => {
posts = 0;
vi.mocked(toast.error).mockClear();
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('#29019 — a denied create reads the localized denial', () => {
it('internal route: the 403 PERMISSION_DENIED banner and toast say the same localized sentence', async () => {
stubInternal(PERMISSION_DENIED);
await fillAndSubmit();

const banner = await bannerText();
expect(posts).toBe(1);
expect(banner).toBe("You don't have permission to save this record.");
expect(toastText()).toBe(banner);
expectNoRawBody(banner, 403);
// No row is to blame for a denial.
expect(document.querySelectorAll('[id$="-error"]')).toHaveLength(0);
});

it('zh: the same refusal reads in the reader\'s language', async () => {
createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
stubInternal(PERMISSION_DENIED);
await fillAndSubmit('提交');

const banner = await bannerText();
expect(banner).toBe('您没有权限保存这条记录。');
expect(toastText()).toBe(banner);
expectNoRawBody(banner, 403);
});

it('public route: the dispatcher\'s wrapped 403 envelope reads the same denial', async () => {
stubPublic({
status: 403,
body: {
success: false,
error: { code: 'PERMISSION_DENIED', message: 'Access denied for qar8_fsb', httpStatus: 403 },
},
});
await fillAndSubmit();

const banner = await bannerText();
expect(banner).toBe("You don't have permission to save this record.");
expect(toastText()).toBe(banner);
expectNoRawBody(banner, 403);
});

it('a sentence the producer marked for the end user is said as written', async () => {
stubInternal({
status: 403,
body: { ...PERMISSION_DENIED.body, userMessage: 'Feedback closes on Fridays.' },
});
await fillAndSubmit();

expect(await bannerText()).toBe('Feedback closes on Fridays.');
expect(toastText()).toBe('Feedback closes on Fridays.');
});
});

describe('#29019 — every other refusal', () => {
it('a 400 with field details still marks its fields, and says no body', async () => {
stubInternal({
status: 400,
body: {
error: 'Validation failed',
code: 'VALIDATION_FAILED',
fields: [{ field: 'email', code: 'required', message: 'Email is required' }],
},
});
await fillAndSubmit();

const email = screen.getByLabelText('Email');
await waitFor(() => expect(email).toHaveAttribute('aria-invalid', 'true'));
expect(document.getElementById(email.getAttribute('aria-describedby') ?? '')?.textContent).toContain(
'Email is required',
);
expect(screen.getByLabelText('Name')).not.toHaveAttribute('aria-invalid', 'true');
const banner = await bannerText();
expect(banner).toBe('Validation failed');
expect(toastText()).toBe(banner);
expectNoRawBody(banner, 400);
});

it('a 500 reads the localized generic sentence, not the server fault', async () => {
stubInternal({
status: 500,
body: { error: 'SQLITE_ERROR: no such table: qar8_fsb', code: 'INTERNAL_ERROR' },
});
await fillAndSubmit();

const banner = await bannerText();
expect(banner).toBe('Could not save. Please try again.');
expect(toastText()).toBe(banner);
expectNoRawBody(banner, 500);
expect(banner).not.toContain('SQLITE');
});

it('a proxy\'s markup page reads the localized generic sentence', async () => {
stubPublic({ status: 502, body: '<html><body><h1>502 Bad Gateway</h1></body></html>' });
await fillAndSubmit();

const banner = await bannerText();
expect(banner).toBe('Could not save. Please try again.');
expect(toastText()).toBe(banner);
expect(banner).not.toContain('<');
});
});
Loading
Loading