Skip to content

Commit 70941e8

Browse files
os-zhuangclaude
andauthored
fix(attachments): read the storage service's new error envelope so gated downloads keep their friendly copy (objectstack#3675) (#2869)
`RecordAttachmentsPanel` maps the server's fail-closed 40x codes (`AUTH_REQUIRED`, `ATTACHMENT_DOWNLOAD_DENIED`) to human copy by reading `code` off the error body. It read the TOP LEVEL only. The storage service has moved that code into the envelope its contract declares — `{ success: false, error: { code, message } }` — so the top-level read now returns `undefined` and every gated download would have degraded from "You don't have access to download this attachment." to the generic "Download failed (403)". The download handler now reads `body?.error?.code ?? body?.code`, mirroring how the success branch two lines below already reads `body?.url ?? body?.data?.url`. Both dialects on purpose: the console ships independently of the server it talks to, so a current console must keep understanding an older one — tolerance is the right answer here, not a synchronized cutover. The two existing tests now mock what the server actually sends, and a third covers the legacy shape so the tolerance is an asserted property rather than an accident. Mutation-checked: dropping the nested read fails the two new-envelope cases. The upload path needed no change — it stringifies the whole body into the error message, so its substring match survives either shape. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 662bdf9 commit 70941e8

3 files changed

Lines changed: 70 additions & 8 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(attachments): read the storage service's new error envelope so gated downloads keep their friendly copy (objectstack#3675)
6+
7+
`RecordAttachmentsPanel` mapped the server's fail-closed 40x codes
8+
(`AUTH_REQUIRED`, `ATTACHMENT_DOWNLOAD_DENIED`) to human copy by reading
9+
`code` off the top level of the error body. The storage service has moved that
10+
code into the envelope its contract declares —
11+
`{ success: false, error: { code, message } }` — so the top-level read now
12+
returns `undefined`, and every gated download would have degraded from
13+
"You don't have access to download this attachment." to the generic
14+
"Download failed (403)".
15+
16+
The download handler now reads `body?.error?.code ?? body?.code`, mirroring how
17+
the success branch two lines below already reads `body?.url ?? body?.data?.url`.
18+
Both dialects on purpose: the console ships independently of the server it
19+
talks to, so a current console must keep understanding an older one. A test
20+
covers each shape, and the fix is mutation-checked — dropping the nested read
21+
fails the two new-envelope cases.

packages/app-shell/src/views/RecordAttachmentsPanel.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,17 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
211211
if (!res.ok) {
212212
let code: string | undefined;
213213
try {
214-
code = (await res.json())?.code;
214+
// Read BOTH error dialects, the same way the success branch below
215+
// reads both `url` shapes. The storage service moved its error
216+
// code from a sibling of `error` into the declared
217+
// `{ success: false, error: { code, message } }` envelope
218+
// (objectstack#3675); a console build is deployed independently of
219+
// the server it talks to, so it has to keep understanding the
220+
// older top-level `code` too. Without the nested branch the 401/403
221+
// downgrade silently to "Download failed (403)" and the friendly
222+
// copy below never fires.
223+
const body = (await res.json()) as { code?: string; error?: { code?: string } } | null;
224+
code = body?.error?.code ?? body?.code;
215225
} catch {
216226
/* non-JSON body */
217227
}

packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,13 @@ describe('RecordAttachmentsPanel — authenticated signed-URL download (#2970)',
124124
it('maps a 403 ATTACHMENT_DOWNLOAD_DENIED to friendly copy and does not open a tab', async () => {
125125
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
126126
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
127-
new Response(JSON.stringify({ code: 'ATTACHMENT_DOWNLOAD_DENIED' }), {
128-
status: 403,
129-
headers: { 'Content-Type': 'application/json' },
130-
}),
127+
new Response(
128+
JSON.stringify({
129+
success: false,
130+
error: { code: 'ATTACHMENT_DOWNLOAD_DENIED', message: 'You do not have access…' },
131+
}),
132+
{ status: 403, headers: { 'Content-Type': 'application/json' } },
133+
),
131134
);
132135
setup(makeDataSource());
133136
await waitFor(() => expect(screen.getByText('report.pdf')).toBeInTheDocument());
@@ -145,8 +148,33 @@ describe('RecordAttachmentsPanel — authenticated signed-URL download (#2970)',
145148
it('maps a 401 AUTH_REQUIRED to friendly copy', async () => {
146149
vi.spyOn(window, 'open').mockImplementation(() => null);
147150
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
148-
new Response(JSON.stringify({ code: 'AUTH_REQUIRED' }), {
149-
status: 401,
151+
new Response(
152+
JSON.stringify({
153+
success: false,
154+
error: { code: 'AUTH_REQUIRED', message: 'Authentication required to download this file' },
155+
}),
156+
{ status: 401, headers: { 'Content-Type': 'application/json' } },
157+
),
158+
);
159+
setup(makeDataSource());
160+
await waitFor(() => expect(screen.getByText('report.pdf')).toBeInTheDocument());
161+
162+
await userEvent.setup().click(screen.getByRole('button', { name: 'Download' }));
163+
164+
await waitFor(() =>
165+
expect(screen.getByRole('alert')).toHaveTextContent('Please sign in to download this attachment.'),
166+
);
167+
});
168+
169+
// The console ships independently of the server it talks to, so it must keep
170+
// reading the PRE-objectstack#3675 body — code as a sibling of `error`, not a
171+
// field of it. Without this, pointing a new console at an older server turns
172+
// every gated download into the generic "Download failed (403)".
173+
it('still maps the legacy top-level `code` shape (older server)', async () => {
174+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
175+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
176+
new Response(JSON.stringify({ error: 'You do not have access…', code: 'ATTACHMENT_DOWNLOAD_DENIED' }), {
177+
status: 403,
150178
headers: { 'Content-Type': 'application/json' },
151179
}),
152180
);
@@ -156,7 +184,10 @@ describe('RecordAttachmentsPanel — authenticated signed-URL download (#2970)',
156184
await userEvent.setup().click(screen.getByRole('button', { name: 'Download' }));
157185

158186
await waitFor(() =>
159-
expect(screen.getByRole('alert')).toHaveTextContent('Please sign in to download this attachment.'),
187+
expect(screen.getByRole('alert')).toHaveTextContent(
188+
"You don't have access to download this attachment.",
189+
),
160190
);
191+
expect(openSpy).not.toHaveBeenCalled();
161192
});
162193
});

0 commit comments

Comments
 (0)