@remit/web-client 0.0.238 → 0.0.240

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * A quarantine row names the folder its message sits in, which means cutting a
3
+ * provider path into a leaf — and that cut is only correct on the account's own
4
+ * hierarchy separator. The separator comes from a second read (the account's
5
+ * mailbox list), so the pane must never paint a row before that read lands:
6
+ * `INBOX.Projects.Q3` cut on a slash is one segment, and the row would show the
7
+ * whole path as if it were a folder name. See #900.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { afterEach, describe, it } from "node:test";
12
+ import { createElement } from "react";
13
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
14
+ import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
15
+ import { QuarantinePanel } from "./QuarantinePanel";
16
+
17
+ const ACCOUNT_ID = "acct-1";
18
+ const MAILBOX_ID = "mbx-q3";
19
+ const FULL_PATH = "INBOX.Projects.Q3";
20
+
21
+ const CONFIG = {
22
+ accounts: [{ accountId: ACCOUNT_ID, folderAppointments: [] }],
23
+ };
24
+
25
+ const MAILBOXES = {
26
+ items: [
27
+ {
28
+ mailboxId: MAILBOX_ID,
29
+ fullPath: FULL_PATH,
30
+ hierarchyDelimiter: ".",
31
+ },
32
+ ],
33
+ };
34
+
35
+ const QUARANTINE = {
36
+ entries: [
37
+ {
38
+ quarantineId: "q-1",
39
+ accountId: ACCOUNT_ID,
40
+ mailboxId: MAILBOX_ID,
41
+ uidValidity: 1_712_000_000,
42
+ uid: 40251,
43
+ mailboxPath: FULL_PATH,
44
+ failureStage: "BodyParse",
45
+ failureCode: "UnreadableBody",
46
+ failureMessage: "stream ended before the declared body length",
47
+ quarantinedAt: Date.parse("2026-07-19T06:03:00Z"),
48
+ attempts: 1,
49
+ sizeBytes: 2_140,
50
+ structure: [],
51
+ },
52
+ ],
53
+ };
54
+
55
+ let harness: DomHarness | undefined;
56
+ let http: HttpMock | undefined;
57
+
58
+ afterEach(() => {
59
+ harness?.close();
60
+ harness = undefined;
61
+ http?.restore();
62
+ http = undefined;
63
+ });
64
+
65
+ const mount = async (mailboxes: () => unknown): Promise<DomHarness> => {
66
+ http = mockFetch((call) => {
67
+ if (call.path.endsWith("/config")) return CONFIG;
68
+ if (call.path.endsWith("/mailboxes")) return mailboxes();
69
+ if (call.path.endsWith("/quarantine")) return QUARANTINE;
70
+ return {};
71
+ });
72
+ harness = createDomHarness();
73
+ harness.renderApp(createElement(QuarantinePanel));
74
+ // Two waves: the mailbox-list fan-out is keyed on the accounts `/config`
75
+ // returns, so it cannot even start until that first read has settled.
76
+ await harness.flush();
77
+ await harness.wait(0);
78
+ await harness.flush();
79
+ return harness;
80
+ };
81
+
82
+ describe("QuarantinePanel folder names", () => {
83
+ it("waits for the account's delimiter rather than cutting on a guess", async () => {
84
+ const view = await mount(() => new Promise(() => {}));
85
+
86
+ assert.match(view.text(), /Checking for messages set aside/);
87
+ assert.doesNotMatch(
88
+ view.text(),
89
+ new RegExp(FULL_PATH.replace(/\./g, "\\.")),
90
+ );
91
+ });
92
+
93
+ it("names the folder by its leaf once the delimiter has arrived", async () => {
94
+ const view = await mount(() => MAILBOXES);
95
+
96
+ assert.match(view.text(), /Q3/);
97
+ assert.doesNotMatch(
98
+ view.text(),
99
+ new RegExp(FULL_PATH.replace(/\./g, "\\.")),
100
+ );
101
+ });
102
+
103
+ it("reports a failed mailbox read instead of naming folders on a guess", async () => {
104
+ const view = await mount(() => httpError(500));
105
+
106
+ assert.match(view.html(), /role="alert"/);
107
+ assert.doesNotMatch(
108
+ view.text(),
109
+ new RegExp(FULL_PATH.replace(/\./g, "\\.")),
110
+ );
111
+ });
112
+ });
@@ -6,7 +6,10 @@
6
6
  * visible: the list, the reassurance that there is nothing in it, and the
7
7
  * failure to read it at all. None of them is an empty panel.
8
8
  */
9
- import { meOperationsListQuarantineOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
9
+ import {
10
+ configOperationsGetConfigOptions,
11
+ meOperationsListQuarantineOptions,
12
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
10
13
  import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
11
14
  import {
12
15
  Button,
@@ -20,9 +23,10 @@ import { useQuery } from "@tanstack/react-query";
20
23
  import { Bug } from "lucide-react";
21
24
  import { useMemo, useState } from "react";
22
25
  import { formatErrorMessage } from "@/components/ui/ErrorState";
23
- import { useAccountDelimiters } from "@/hooks/useAccountDelimiters";
26
+ import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
24
27
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
25
28
  import { toQuarantineEntry } from "@/lib/quarantine-entries";
29
+ import type { ResultFolderIndex } from "@/lib/result-folder";
26
30
 
27
31
  /** Stable identity, so an empty list does not rebuild the memos below. */
28
32
  const EMPTY_WIRE_ENTRIES: readonly RemitImapQuarantineResponse[] = [];
@@ -126,30 +130,49 @@ export function QuarantinePanelView({
126
130
  );
127
131
  }
128
132
 
133
+ /**
134
+ * A row names the folder it came from by the leaf of its path, which cannot be
135
+ * cut until that account's own delimiter has arrived. A mailbox the account no
136
+ * longer lists never gets one, and keeps its whole path — `folderLeaf` on an
137
+ * empty delimiter is the path itself.
138
+ */
139
+ function delimiterForMailbox(
140
+ folders: ResultFolderIndex,
141
+ mailboxId: string,
142
+ ): string {
143
+ return folders.get(mailboxId)?.hierarchyDelimiter ?? "";
144
+ }
145
+
146
+ /**
147
+ * The folder names come from the account's mailbox lists, which are a second
148
+ * read — started alongside the quarantine list, never behind it. Until both have
149
+ * landed the pane says it is still reading, because a path cut on a delimiter
150
+ * that has not arrived is a wrong folder name rather than a late one, and a
151
+ * failure on either read is reported rather than papered over with a guess.
152
+ */
129
153
  export function QuarantinePanel() {
130
- const { data, isPending, error } = useQuery(
131
- meOperationsListQuarantineOptions(),
132
- );
154
+ const quarantine = useQuery(meOperationsListQuarantineOptions());
155
+ const config = useQuery(configOperationsGetConfigOptions());
156
+ const accounts = useMemo(() => config.data?.accounts ?? [], [config.data]);
157
+ const folders = useResultFolderIndex(accounts);
133
158
 
134
- const wireEntries = data?.entries ?? EMPTY_WIRE_ENTRIES;
135
- const accountIds = useMemo(
136
- () => [...new Set(wireEntries.map((entry) => entry.accountId))],
137
- [wireEntries],
138
- );
139
- const delimiters = useAccountDelimiters(accountIds);
159
+ const wireEntries = quarantine.data?.entries ?? EMPTY_WIRE_ENTRIES;
140
160
  const entries = useMemo(
141
161
  () =>
142
162
  wireEntries.map((entry) =>
143
- toQuarantineEntry(entry, delimiters.get(entry.accountId) ?? "/"),
163
+ toQuarantineEntry(
164
+ entry,
165
+ delimiterForMailbox(folders.index, entry.mailboxId),
166
+ ),
144
167
  ),
145
- [wireEntries, delimiters],
168
+ [wireEntries, folders.index],
146
169
  );
147
170
 
148
171
  return (
149
172
  <QuarantinePanelView
150
173
  entries={entries}
151
- isPending={isPending}
152
- error={error}
174
+ isPending={quarantine.isPending || config.isPending || folders.isPending}
175
+ error={quarantine.error ?? config.error ?? folders.error}
153
176
  />
154
177
  );
155
178
  }
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Sharing a calendar by its secret address, and taking it back (#1067).
3
+ *
4
+ * What is asserted is the request that left and what the panel then shows,
5
+ * because the address is only legible for one render: a control that posted
6
+ * nothing and a control that posted and dropped the answer look identical.
7
+ *
8
+ * Held with the real caches from `lib/query-error-handler.ts` and the overlay
9
+ * they escalate to. Which failures this card owns and which take the whole
10
+ * screen is the decision under test: on the harness's quiet default client
11
+ * every status stays inline, so a card that had quietly lost its 403 would
12
+ * still pass.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
18
+ import { createElement, Fragment } from "react";
19
+ import { CalendarFeedPanel } from "@/components/settings/CalendarFeedPanel";
20
+ import { FatalErrorOverlay } from "@/components/ui/FatalErrorOverlay";
21
+ import { __resetFatalError } from "@/lib/fatal-error";
22
+ import {
23
+ handleMutationCacheError,
24
+ handleQueryCacheError,
25
+ } from "@/lib/query-error-handler";
26
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
27
+ import {
28
+ type HttpCall,
29
+ type HttpMock,
30
+ httpError,
31
+ mockFetch,
32
+ } from "../../test-support/http";
33
+
34
+ const WORK = "11111111-1111-4111-8111-111111111111";
35
+ const FEED_PATH = `/calendars/${WORK}/feed`;
36
+ const TOKEN = "9Xq2mB7tK1vHs4dLpZ0rY6wJfN3aC8eQuIoT5gRbVkE";
37
+ const NEXT_TOKEN = "Ab1Cd2Ef3Gh4Ij5Kl6Mn7Op8Qr9St0UvWxYz-_1234A";
38
+ const CREATED = Date.parse("2026-05-04T09:12:00Z");
39
+
40
+ let harness: DomHarness | undefined;
41
+ let http: HttpMock | undefined;
42
+
43
+ afterEach(() => {
44
+ harness?.close();
45
+ harness = undefined;
46
+ http?.restore();
47
+ http = undefined;
48
+ __resetFatalError();
49
+ });
50
+
51
+ type Responder = (call: HttpCall) => unknown;
52
+
53
+ const escalatingClient = (): QueryClient =>
54
+ new QueryClient({
55
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
56
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
57
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
58
+ });
59
+
60
+ const mount = async (responder: Responder) => {
61
+ http = mockFetch(responder);
62
+ harness = createDomHarness({ queryClient: escalatingClient() });
63
+ harness.renderApp(
64
+ createElement(
65
+ Fragment,
66
+ null,
67
+ createElement(FatalErrorOverlay),
68
+ createElement(CalendarFeedPanel, {
69
+ calendarId: WORK,
70
+ calendarName: "Work",
71
+ }),
72
+ ),
73
+ );
74
+ await harness.flush();
75
+ await harness.wait(20);
76
+ await harness.flush();
77
+ };
78
+
79
+ /** The full-screen page took over, instead of this card answering for itself. */
80
+ const escalated = (): boolean =>
81
+ harness?.query('[data-testid="fatal-error-overlay"]') != null;
82
+
83
+ const settle = async () => {
84
+ await harness?.flush();
85
+ await harness?.wait(20);
86
+ await harness?.flush();
87
+ };
88
+
89
+ /**
90
+ * The card's own controls, never the confirmation's. Both spell the action the
91
+ * same way on purpose — the dialog repeats the verb of the button that opened
92
+ * it — so a lookup across the whole panel would answer the confirmation and
93
+ * make "asked before writing" untestable.
94
+ */
95
+ const cardButtons = (): HTMLButtonElement[] =>
96
+ (harness?.queryAll<HTMLButtonElement>("button") ?? []).filter(
97
+ (node) => node.closest('[role="dialog"]') === null,
98
+ );
99
+
100
+ const named = (
101
+ nodes: HTMLButtonElement[],
102
+ label: string,
103
+ ): HTMLButtonElement | undefined =>
104
+ nodes.find((node) => (node.textContent ?? "").trim() === label);
105
+
106
+ const button = (label: string): HTMLButtonElement => {
107
+ const found = named(cardButtons(), label);
108
+ if (!found) throw new Error(`no button labelled "${label}"`);
109
+ return found;
110
+ };
111
+
112
+ const hasButton = (label: string): boolean =>
113
+ named(cardButtons(), label) !== undefined;
114
+
115
+ const confirmButton = (label: string): HTMLButtonElement => {
116
+ const dialog = harness?.query('[role="dialog"]');
117
+ if (!dialog) throw new Error("no confirmation is open");
118
+ const found = named(
119
+ [...dialog.querySelectorAll("button")] as HTMLButtonElement[],
120
+ label,
121
+ );
122
+ if (!found) throw new Error(`the confirmation has no "${label}"`);
123
+ return found;
124
+ };
125
+
126
+ /** A calendar nobody has ever shared: the read is a 404 and owns it. */
127
+ const unshared: Responder = (call) => {
128
+ if (call.method === "GET") return httpError(404, "no feed");
129
+ return {};
130
+ };
131
+
132
+ describe("a calendar that is not shared", () => {
133
+ it("offers to create an address instead of reporting a failure", async () => {
134
+ await mount(unshared);
135
+ assert.match(harness?.text() ?? "", /Create subscription address/);
136
+ assert.doesNotMatch(harness?.text() ?? "", /Couldn't read/);
137
+ });
138
+
139
+ it("mints the address with one PUT to the calendar's feed", async () => {
140
+ let feed: unknown;
141
+ await mount((call) => {
142
+ if (call.method === "PUT") {
143
+ feed = {
144
+ calendarId: WORK,
145
+ feedToken: TOKEN,
146
+ createdAt: CREATED,
147
+ rotatedAt: 0,
148
+ };
149
+ return feed;
150
+ }
151
+ if (feed) return feed;
152
+ return httpError(404, "no feed");
153
+ });
154
+
155
+ harness?.click(button("Create subscription address"));
156
+ await settle();
157
+
158
+ const writes = (http?.to(FEED_PATH) ?? []).filter(
159
+ (call) => call.method === "PUT",
160
+ );
161
+ assert.equal(writes.length, 1, "one write minted the address");
162
+ });
163
+
164
+ /**
165
+ * The one render the plaintext exists in. Nothing stores it, so a panel that
166
+ * dropped it here has handed the reader a calendar they cannot subscribe to
167
+ * without rotating it again.
168
+ */
169
+ it("shows the address once, then never again", async () => {
170
+ let feed: unknown;
171
+ await mount((call) => {
172
+ if (call.method === "PUT") {
173
+ feed = {
174
+ calendarId: WORK,
175
+ feedToken: TOKEN,
176
+ createdAt: CREATED,
177
+ rotatedAt: 0,
178
+ };
179
+ return feed;
180
+ }
181
+ if (feed) return feed;
182
+ return httpError(404, "no feed");
183
+ });
184
+
185
+ harness?.click(button("Create subscription address"));
186
+ await settle();
187
+
188
+ const shown = harness?.text() ?? "";
189
+ assert.match(shown, /shown once and cannot be read back/);
190
+ assert.ok(
191
+ harness
192
+ ?.queryAll<HTMLInputElement>("input")
193
+ .some(
194
+ (input) =>
195
+ input.value === `webcal://localhost/feeds/calendar/${TOKEN}.ics`,
196
+ ),
197
+ `the webcal address was not on screen: ${harness?.html()}`,
198
+ );
199
+
200
+ harness?.click(button("I've saved it"));
201
+ await settle();
202
+
203
+ assert.doesNotMatch(harness?.text() ?? "", /shown once/);
204
+ assert.ok(
205
+ !(harness?.html() ?? "").includes(TOKEN),
206
+ "the dismissed address is gone from the document",
207
+ );
208
+ assert.match(harness?.text() ?? "", /Address created/);
209
+ });
210
+
211
+ /**
212
+ * Off the screen is not gone. React Query keeps a mutation's answer for its
213
+ * whole gcTime, and that answer is the plaintext token — the one thing this
214
+ * feature promises exists nowhere it can be read back from.
215
+ */
216
+ it("drops the minted token from the mutation cache when it is dismissed", async () => {
217
+ let feed: unknown;
218
+ await mount((call) => {
219
+ if (call.method === "PUT") {
220
+ feed = {
221
+ calendarId: WORK,
222
+ feedToken: TOKEN,
223
+ createdAt: CREATED,
224
+ rotatedAt: 0,
225
+ };
226
+ return feed;
227
+ }
228
+ if (feed) return feed;
229
+ return httpError(404, "no feed");
230
+ });
231
+
232
+ harness?.click(button("Create subscription address"));
233
+ await settle();
234
+
235
+ const cached = () =>
236
+ (harness?.queryClient.getMutationCache().getAll() ?? []).filter(
237
+ (mutation) => mutation.state.data !== undefined,
238
+ );
239
+ assert.equal(cached().length, 1, "the answer is held while it is shown");
240
+
241
+ harness?.click(button("I've saved it"));
242
+ await settle();
243
+
244
+ assert.deepEqual(cached(), [], "no cached answer still holds the token");
245
+ assert.ok(
246
+ !JSON.stringify(
247
+ (harness?.queryClient.getMutationCache().getAll() ?? []).map(
248
+ (mutation) => mutation.state,
249
+ ),
250
+ ).includes(TOKEN),
251
+ "the token survived in the mutation cache",
252
+ );
253
+ });
254
+
255
+ it("states a refused write where the button is", async () => {
256
+ await mount((call) => {
257
+ if (call.method === "PUT") return httpError(404, "Calendar not found");
258
+ return httpError(404, "no feed");
259
+ });
260
+
261
+ harness?.click(button("Create subscription address"));
262
+ await settle();
263
+
264
+ assert.match(harness?.text() ?? "", /was not changed/);
265
+ assert.match(harness?.text() ?? "", /Calendar not found/);
266
+ assert.ok(!escalated(), "the card owns a refused write, inline");
267
+ });
268
+ });
269
+
270
+ describe("a calendar that is shared", () => {
271
+ const shared =
272
+ (rotatedAt: number): Responder =>
273
+ (call) => {
274
+ if (call.method === "GET")
275
+ return { calendarId: WORK, createdAt: CREATED, rotatedAt };
276
+ return {};
277
+ };
278
+
279
+ it("shows no address for the feed it merely knows exists", async () => {
280
+ await mount(shared(0));
281
+ assert.match(harness?.text() ?? "", /Address created/);
282
+ assert.ok(!(harness?.html() ?? "").includes("webcal://"));
283
+ });
284
+
285
+ it("asks before replacing the address, and says the old one stops", async () => {
286
+ await mount(shared(0));
287
+
288
+ harness?.click(button("Replace address"));
289
+ await settle();
290
+
291
+ const asked = harness?.text() ?? "";
292
+ assert.match(asked, /Replace the address for Work\?/);
293
+ assert.match(asked, /stops updating/);
294
+ assert.equal(
295
+ (http?.to(FEED_PATH) ?? []).filter((call) => call.method === "PUT")
296
+ .length,
297
+ 0,
298
+ "nothing was written before the confirmation was answered",
299
+ );
300
+ });
301
+
302
+ it("replaces the address and hands back the new one, once", async () => {
303
+ let rotated = false;
304
+ await mount((call) => {
305
+ if (call.method === "PUT") {
306
+ rotated = true;
307
+ return {
308
+ calendarId: WORK,
309
+ feedToken: NEXT_TOKEN,
310
+ createdAt: CREATED,
311
+ rotatedAt: CREATED + 1000,
312
+ };
313
+ }
314
+ return {
315
+ calendarId: WORK,
316
+ createdAt: CREATED,
317
+ rotatedAt: rotated ? CREATED + 1000 : 0,
318
+ };
319
+ });
320
+
321
+ harness?.click(button("Replace address"));
322
+ await settle();
323
+ harness?.click(confirmButton("Replace address"));
324
+ await settle();
325
+
326
+ assert.equal(
327
+ (http?.to(FEED_PATH) ?? []).filter((call) => call.method === "PUT")
328
+ .length,
329
+ 1,
330
+ );
331
+ assert.ok(
332
+ harness
333
+ ?.queryAll<HTMLInputElement>("input")
334
+ .some((input) => input.value.includes(NEXT_TOKEN)),
335
+ "the replacement address was shown",
336
+ );
337
+ assert.match(harness?.text() ?? "", /last replaced/);
338
+ });
339
+
340
+ it("asks before revoking, then leaves the calendar unshared", async () => {
341
+ let revoked = false;
342
+ await mount((call) => {
343
+ if (call.method === "DELETE") {
344
+ revoked = true;
345
+ return {};
346
+ }
347
+ if (revoked) return httpError(404, "no feed");
348
+ return { calendarId: WORK, createdAt: CREATED, rotatedAt: 0 };
349
+ });
350
+
351
+ harness?.click(button("Stop sharing"));
352
+ await settle();
353
+ assert.match(harness?.text() ?? "", /Stop sharing Work\?/);
354
+
355
+ harness?.click(confirmButton("Stop sharing"));
356
+ await settle();
357
+
358
+ assert.equal(
359
+ (http?.to(FEED_PATH) ?? []).filter((call) => call.method === "DELETE")
360
+ .length,
361
+ 1,
362
+ );
363
+ assert.ok(hasButton("Create subscription address"));
364
+ assert.doesNotMatch(harness?.text() ?? "", /Address created/);
365
+ });
366
+
367
+ /**
368
+ * A refusal is about the write that was refused, not about the card. Left
369
+ * standing, it reads as if the replacement that just landed had failed too —
370
+ * over an address that is on screen and working.
371
+ */
372
+ it("clears a refused revoke once the next write succeeds", async () => {
373
+ let rotated = false;
374
+ await mount((call) => {
375
+ if (call.method === "DELETE") return httpError(409, "Feed is locked");
376
+ if (call.method === "PUT") {
377
+ rotated = true;
378
+ return {
379
+ calendarId: WORK,
380
+ feedToken: NEXT_TOKEN,
381
+ createdAt: CREATED,
382
+ rotatedAt: CREATED + 1000,
383
+ };
384
+ }
385
+ return {
386
+ calendarId: WORK,
387
+ createdAt: CREATED,
388
+ rotatedAt: rotated ? CREATED + 1000 : 0,
389
+ };
390
+ });
391
+
392
+ harness?.click(button("Stop sharing"));
393
+ await settle();
394
+ harness?.click(confirmButton("Stop sharing"));
395
+ await settle();
396
+ assert.match(harness?.text() ?? "", /Feed is locked/);
397
+
398
+ harness?.click(button("Replace address"));
399
+ await settle();
400
+ harness?.click(confirmButton("Replace address"));
401
+ await settle();
402
+
403
+ assert.doesNotMatch(
404
+ harness?.text() ?? "",
405
+ /was not changed/,
406
+ "the refused revoke outlived the replacement that succeeded",
407
+ );
408
+ assert.doesNotMatch(harness?.text() ?? "", /Feed is locked/);
409
+ assert.ok(
410
+ harness
411
+ ?.queryAll<HTMLInputElement>("input")
412
+ .some((input) => input.value.includes(NEXT_TOKEN)),
413
+ "the replacement address was shown",
414
+ );
415
+ });
416
+ });
417
+
418
+ describe("a read the server refuses", () => {
419
+ /**
420
+ * A 403 is not "not shared". Drawing the create button for it would tell the
421
+ * reader their calendar is private while it may be subscribed to right now.
422
+ */
423
+ it("says the state is unknown rather than offering to create one", async () => {
424
+ await mount((call) => {
425
+ if (call.method === "GET") return httpError(403, "not yours");
426
+ return {};
427
+ });
428
+
429
+ assert.ok(
430
+ !escalated(),
431
+ "a 403 the card draws inline must not take the whole screen",
432
+ );
433
+ assert.match(harness?.text() ?? "", /Couldn't read whether Work is shared/);
434
+ assert.ok(!hasButton("Create subscription address"));
435
+ });
436
+
437
+ /**
438
+ * The one status this card may not keep to itself. No banner here signs
439
+ * anyone back in, so a 401 belongs on the page that does.
440
+ */
441
+ it("escalates a lapsed session instead of drawing it in the card", async () => {
442
+ await mount((call) => {
443
+ if (call.method === "GET") return httpError(401, "signed out");
444
+ return {};
445
+ });
446
+
447
+ assert.ok(escalated(), "a 401 on the read stayed inside the card");
448
+ });
449
+ });