diff --git a/frontend/src/app/components/share/PublishModal.tsx b/frontend/src/app/components/share/PublishModal.tsx index 4cdaee27..64910489 100644 --- a/frontend/src/app/components/share/PublishModal.tsx +++ b/frontend/src/app/components/share/PublishModal.tsx @@ -137,7 +137,7 @@ const PublishModal: React.FC = ({ outputId, outputName, open, onClose }) try { await unpublishApp(outputId); dispatch(setOutputPublishState({ id: outputId, published_slug: null, published_url: null, publish_status: null })); - setToast('App unpublished'); + setToast('App unpublished. The public link can take a minute or two to go dark.'); onClose(); } catch (e: any) { setConfirmUnpublish(false); diff --git a/frontend/src/app/components/share/publishApi.test.ts b/frontend/src/app/components/share/publishApi.test.ts new file mode 100644 index 00000000..ee2455ff --- /dev/null +++ b/frontend/src/app/components/share/publishApi.test.ts @@ -0,0 +1,84 @@ +// A refused takedown is not a takedown (ENG-283). +// +// /outputs/unpublish answers HTTP 200 with {ok:false,error} whenever the app could not actually be +// pulled off the internet: signed out, offline, or the hosting service refused. This helper used to +// read only res.ok, so every one of those cases toasted "App unpublished", cleared the card's +// publish state, and left the app serving to anyone with the link, with no way to retry. Same class +// as ENG-309 one surface over: never let "the request completed" stand in for "the thing happened". +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { unpublishApp } from './publishApi'; + +function stubFetch(res: { ok?: boolean; status?: number; body?: unknown; nonJson?: boolean }): () => void { + const real = (globalThis as any).fetch; + (globalThis as any).fetch = async () => ({ + ok: res.ok ?? true, + status: res.status ?? 200, + json: async () => { + if (res.nonJson) throw new SyntaxError('Unexpected token'); + return res.body; + }, + }); + return () => { (globalThis as any).fetch = real; }; +} + +async function rejection(fn: () => Promise): Promise { + try { + await fn(); + return null; + } catch (e) { + return e as Error; + } +} + +test('an explicit ok:true resolves', async () => { + const restore = stubFetch({ body: { ok: true } }); + try { + await unpublishApp('out-1'); + } finally { + restore(); + } +}); + +test('a 200 that says ok:false throws with the server reason', async () => { + const restore = stubFetch({ body: { ok: false, error: 'Sign in to your OpenSwarm account to manage published apps.' } }); + try { + const err = await rejection(() => unpublishApp('out-1')); + assert.ok(err, 'a refused takedown must not resolve; the app is still on the internet'); + assert.match(err!.message, /Sign in/, 'the user needs the actual reason, not a generic failure'); + } finally { + restore(); + } +}); + +test('a body with no ok field is treated as a failure, not a success', async () => { + // Only an explicit success counts. Anything else means we do not know that the app came down. + for (const body of [{}, { ok: 'yes' }, null, { error: 'boom' }]) { + const restore = stubFetch({ body }); + try { + assert.ok(await rejection(() => unpublishApp('out-1')), `ambiguous body ${JSON.stringify(body)} must not read as success`); + } finally { + restore(); + } + } +}); + +test('an unparseable body fails closed', async () => { + const restore = stubFetch({ nonJson: true }); + try { + assert.ok(await rejection(() => unpublishApp('out-1'))); + } finally { + restore(); + } +}); + +test('an HTTP error still throws, and says the app may still be live', async () => { + const restore = stubFetch({ ok: false, status: 500, body: {} }); + try { + const err = await rejection(() => unpublishApp('out-1')); + assert.match(err!.message, /still be live/i); + } finally { + restore(); + } +}); diff --git a/frontend/src/app/components/share/publishApi.ts b/frontend/src/app/components/share/publishApi.ts index 8c5c3504..2f72248e 100644 --- a/frontend/src/app/components/share/publishApi.ts +++ b/frontend/src/app/components/share/publishApi.ts @@ -39,11 +39,22 @@ export async function publishApp( return (await res.json()) as PublishResult; } +/** Only an explicit {ok:true} is a takedown. The route answers HTTP 200 with {ok:false,error} when it + * was refused (signed out, offline, cloud error), so reading res.ok alone told the user their app was + * off the internet while it kept serving, and cleared the publish state so they could not retry. */ export async function unpublishApp(outputId: string): Promise { const res = await fetch(`${OUTPUTS_API}/unpublish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ output_id: outputId }), }); - if (!res.ok) throw new Error("We couldn't unpublish this app."); + const fallback = "We couldn't unpublish this app. It may still be live."; + if (!res.ok) throw new Error(fallback); + let body: { ok?: boolean; error?: string } | null = null; + try { + body = await res.json(); + } catch { + throw new Error(fallback); + } + if (body?.ok !== true) throw new Error(body?.error || fallback); }