@remit/web-client 0.0.1
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.
- package/harness/build.mjs +84 -0
- package/harness/index.html +37 -0
- package/harness/vite-preset.ts +29 -0
- package/index.html +47 -0
- package/package.json +147 -0
- package/public/config.js +5 -0
- package/public/locales/de/common.json +1 -0
- package/public/locales/de/errors.json +1 -0
- package/public/locales/de/mail.json +13 -0
- package/public/locales/de/settings.json +1 -0
- package/public/locales/en/common.json +26 -0
- package/public/locales/en/errors.json +1 -0
- package/public/locales/en/mail.json +69 -0
- package/public/locales/en/settings.json +1 -0
- package/public/locales/fr/common.json +1 -0
- package/public/locales/fr/errors.json +1 -0
- package/public/locales/fr/mail.json +13 -0
- package/public/locales/fr/settings.json +1 -0
- package/public/locales/nl/common.json +1 -0
- package/public/locales/nl/errors.json +1 -0
- package/public/locales/nl/mail.json +13 -0
- package/public/locales/nl/settings.json +1 -0
- package/src/auth/AccountMenu.tsx +58 -0
- package/src/auth/AccountSession.tsx +19 -0
- package/src/auth/BetterAuthShell.tsx +198 -0
- package/src/auth/account-menu-mode.test.ts +64 -0
- package/src/auth/account-menu-mode.ts +28 -0
- package/src/auth/amplify-config.test.ts +158 -0
- package/src/auth/amplify-config.ts +60 -0
- package/src/auth/auth-error.test.ts +109 -0
- package/src/auth/auth-error.ts +107 -0
- package/src/auth/auth-interceptor.test.ts +86 -0
- package/src/auth/auth-interceptor.ts +23 -0
- package/src/auth/auth.css +334 -0
- package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
- package/src/auth/better-auth-config.ts +85 -0
- package/src/auth/better-auth-provider.tsx +20 -0
- package/src/auth/cognito/CognitoAccount.tsx +48 -0
- package/src/auth/cognito/CognitoShell.tsx +266 -0
- package/src/auth/cognito/cognito-token.test.ts +110 -0
- package/src/auth/cognito/cognito-token.ts +47 -0
- package/src/auth/cognito/cognito.css +21 -0
- package/src/auth/cognito-provider.tsx +22 -0
- package/src/auth/combined-provider.tsx +42 -0
- package/src/auth/composition.test.ts +136 -0
- package/src/auth/provider.tsx +63 -0
- package/src/auth/sign-out-visibility.test.ts +40 -0
- package/src/auth/sign-out-visibility.ts +10 -0
- package/src/auth/sign-up-disabled.test.ts +23 -0
- package/src/components/compose/AddressField.tsx +237 -0
- package/src/components/compose/ComposeBody.tsx +36 -0
- package/src/components/compose/ComposeForm.tsx +676 -0
- package/src/components/compose/ComposeProvider.tsx +133 -0
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
- package/src/components/compose/FromSelector.tsx +67 -0
- package/src/components/compose/FullCompose.tsx +82 -0
- package/src/components/compose/InlineCompose.tsx +29 -0
- package/src/components/compose/MobileComposeSheet.tsx +152 -0
- package/src/components/compose/PlateEditor.tsx +86 -0
- package/src/components/compose/PlateToolbar.tsx +110 -0
- package/src/components/compose/SubjectField.tsx +19 -0
- package/src/components/compose/draft-discard-banner.test.ts +76 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/sanitize-quote-html.ts +23 -0
- package/src/components/layout/AppShellSkeleton.tsx +53 -0
- package/src/components/layout/ComposeFab.tsx +86 -0
- package/src/components/layout/Drawer.tsx +105 -0
- package/src/components/mail/AutoMovedIndicator.tsx +48 -0
- package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
- package/src/components/mail/BlockedImagesNotice.tsx +41 -0
- package/src/components/mail/BriefPane.tsx +289 -0
- package/src/components/mail/ConversationView.tsx +462 -0
- package/src/components/mail/DailyBrief.tsx +451 -0
- package/src/components/mail/DraftsView.tsx +279 -0
- package/src/components/mail/FlaggedList.tsx +170 -0
- package/src/components/mail/FlaggedPane.tsx +217 -0
- package/src/components/mail/IntelligencePane.test.ts +123 -0
- package/src/components/mail/IntelligencePane.tsx +441 -0
- package/src/components/mail/MailListHeader.tsx +187 -0
- package/src/components/mail/MailNav.tsx +70 -0
- package/src/components/mail/MailSidebarAdapter.tsx +351 -0
- package/src/components/mail/MailViewChrome.tsx +95 -0
- package/src/components/mail/MailboxPane.tsx +1219 -0
- package/src/components/mail/MessageActionMenu.tsx +368 -0
- package/src/components/mail/MessageBody.tsx +207 -0
- package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
- package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
- package/src/components/mail/MessageCard.tsx +401 -0
- package/src/components/mail/MessageDetail.tsx +153 -0
- package/src/components/mail/MessageList.tsx +804 -0
- package/src/components/mail/MessageListItem.tsx +267 -0
- package/src/components/mail/MessageToolbar.render.test.ts +57 -0
- package/src/components/mail/MessageToolbar.tsx +166 -0
- package/src/components/mail/MobileMessageBar.tsx +142 -0
- package/src/components/mail/MoveToTrigger.tsx +250 -0
- package/src/components/mail/NavMenuButton.tsx +25 -0
- package/src/components/mail/OutboxPane.tsx +531 -0
- package/src/components/mail/PullToRefresh.tsx +24 -0
- package/src/components/mail/RawMessageView.tsx +84 -0
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
- package/src/components/mail/SelectionToolbar.tsx +139 -0
- package/src/components/mail/SpamRescue.render.test.ts +35 -0
- package/src/components/mail/SpamRescue.tsx +138 -0
- package/src/components/mail/SwipeableMessageRow.tsx +148 -0
- package/src/components/mail/message-body-error-banner-content.ts +87 -0
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
- package/src/components/mail/organize/OrganizePanel.tsx +344 -0
- package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
- package/src/components/settings/AccountFormPanel.test.ts +72 -0
- package/src/components/settings/AccountFormPanel.tsx +942 -0
- package/src/components/settings/DangerZone.tsx +30 -0
- package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
- package/src/components/settings/DeleteAccountDialog.tsx +224 -0
- package/src/components/settings/FiltersList.render.test.ts +71 -0
- package/src/components/settings/FiltersList.tsx +93 -0
- package/src/components/settings/account-form-helpers.test.ts +172 -0
- package/src/components/settings/account-form-helpers.ts +105 -0
- package/src/components/ui/AppVersion.tsx +50 -0
- package/src/components/ui/BugReportButton.tsx +45 -0
- package/src/components/ui/ConfirmDialog.tsx +125 -0
- package/src/components/ui/DropdownMenu.tsx +105 -0
- package/src/components/ui/EmptyState.tsx +11 -0
- package/src/components/ui/ErrorBanner.tsx +88 -0
- package/src/components/ui/ErrorBannerProvider.tsx +77 -0
- package/src/components/ui/ErrorBannerStack.tsx +34 -0
- package/src/components/ui/ErrorState.tsx +80 -0
- package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
- package/src/components/ui/FatalErrorOverlay.tsx +151 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
- package/src/components/ui/SlidePanel.tsx +72 -0
- package/src/components/ui/error-banners.test.ts +207 -0
- package/src/components/ui/error-banners.ts +111 -0
- package/src/hooks/queries/keys.ts +38 -0
- package/src/hooks/useArchiveMailbox.ts +104 -0
- package/src/hooks/useAutoMovedBadge.ts +77 -0
- package/src/hooks/useCurrentMailboxName.ts +99 -0
- package/src/hooks/useDebouncedValue.ts +12 -0
- package/src/hooks/useDeleteMessages.test.ts +103 -0
- package/src/hooks/useDeleteMessages.ts +226 -0
- package/src/hooks/useFilters.ts +112 -0
- package/src/hooks/useIntelligenceData.test.ts +269 -0
- package/src/hooks/useIntelligenceData.ts +284 -0
- package/src/hooks/useIsDark.ts +27 -0
- package/src/hooks/useKeyboardNavigation.ts +192 -0
- package/src/hooks/useLayoutTier.test.ts +47 -0
- package/src/hooks/useLayoutTier.ts +44 -0
- package/src/hooks/useLongPress.test.ts +291 -0
- package/src/hooks/useLongPress.ts +87 -0
- package/src/hooks/useMailboxAccount.ts +62 -0
- package/src/hooks/useMailboxNameIndex.ts +32 -0
- package/src/hooks/useMarkAsRead.test.ts +165 -0
- package/src/hooks/useMarkAsRead.ts +319 -0
- package/src/hooks/useMediaQuery.ts +39 -0
- package/src/hooks/useMessageBodyContent.test.ts +370 -0
- package/src/hooks/useMessageBodyContent.ts +380 -0
- package/src/hooks/useMoveMessages.test.ts +63 -0
- package/src/hooks/useMoveMessages.ts +221 -0
- package/src/hooks/useOrganizeJob.ts +90 -0
- package/src/hooks/useOrganizePreview.ts +40 -0
- package/src/hooks/useRescueCandidates.test.ts +217 -0
- package/src/hooks/useRescueCandidates.ts +48 -0
- package/src/hooks/useSaveDraft.ts +118 -0
- package/src/hooks/useSelection.test.ts +75 -0
- package/src/hooks/useSelection.ts +211 -0
- package/src/hooks/useSemanticSearch.ts +94 -0
- package/src/hooks/useSignature.ts +69 -0
- package/src/hooks/useStaleAccountSync.test.ts +211 -0
- package/src/hooks/useStaleAccountSync.ts +246 -0
- package/src/hooks/useSwipeNavigation.test.ts +35 -0
- package/src/hooks/useSwipeNavigation.ts +99 -0
- package/src/hooks/useToggleStar.ts +187 -0
- package/src/hooks/useToggleTrusted.test.ts +102 -0
- package/src/hooks/useToggleTrusted.ts +161 -0
- package/src/hooks/useTriageKeyboard.ts +103 -0
- package/src/hooks/useTriggerSync.test.ts +24 -0
- package/src/hooks/useTriggerSync.ts +58 -0
- package/src/hooks/useUpdateAddressFlags.ts +125 -0
- package/src/hooks/useVisualViewport.test.ts +127 -0
- package/src/hooks/useVisualViewport.ts +51 -0
- package/src/index.css +19 -0
- package/src/lib/account-order.test.ts +84 -0
- package/src/lib/account-order.ts +13 -0
- package/src/lib/adjacent-message.test.ts +43 -0
- package/src/lib/adjacent-message.ts +20 -0
- package/src/lib/api.ts +82 -0
- package/src/lib/app-info.ts +15 -0
- package/src/lib/auto-moved.test.ts +140 -0
- package/src/lib/auto-moved.ts +68 -0
- package/src/lib/autodiscovery.test.ts +94 -0
- package/src/lib/autodiscovery.ts +354 -0
- package/src/lib/brief.test.ts +366 -0
- package/src/lib/brief.ts +180 -0
- package/src/lib/bug-report.test.ts +213 -0
- package/src/lib/bug-report.ts +176 -0
- package/src/lib/client.ts +36 -0
- package/src/lib/console-errors.test.ts +64 -0
- package/src/lib/console-errors.ts +92 -0
- package/src/lib/conversation-target.test.ts +68 -0
- package/src/lib/conversation-target.ts +59 -0
- package/src/lib/display-category.ts +20 -0
- package/src/lib/drafts.test.ts +241 -0
- package/src/lib/drafts.ts +121 -0
- package/src/lib/error-classifier.test.ts +133 -0
- package/src/lib/error-classifier.ts +96 -0
- package/src/lib/fatal-error.test.ts +97 -0
- package/src/lib/fatal-error.ts +165 -0
- package/src/lib/folder-roles.test.ts +113 -0
- package/src/lib/folder-roles.ts +112 -0
- package/src/lib/format.test.ts +115 -0
- package/src/lib/format.ts +198 -0
- package/src/lib/i18n.ts +36 -0
- package/src/lib/intelligence-pref.test.ts +57 -0
- package/src/lib/intelligence-pref.ts +25 -0
- package/src/lib/keymap-dispatch.test.ts +178 -0
- package/src/lib/keymap-dispatch.ts +187 -0
- package/src/lib/keymap.test.ts +49 -0
- package/src/lib/keymap.ts +178 -0
- package/src/lib/mail-context.ts +64 -0
- package/src/lib/mail-route.test.ts +80 -0
- package/src/lib/mail-route.ts +44 -0
- package/src/lib/message-body-source.test.ts +98 -0
- package/src/lib/message-body-source.ts +48 -0
- package/src/lib/move-targets.test.ts +195 -0
- package/src/lib/move-targets.ts +74 -0
- package/src/lib/onboarding-completion.test.ts +64 -0
- package/src/lib/onboarding-completion.ts +23 -0
- package/src/lib/organize/filter-status.test.ts +99 -0
- package/src/lib/organize/filter-status.ts +86 -0
- package/src/lib/organize/organize-copy.test.ts +93 -0
- package/src/lib/organize/organize-copy.ts +61 -0
- package/src/lib/organize/organize-model.test.ts +104 -0
- package/src/lib/organize/organize-model.ts +105 -0
- package/src/lib/organize/organize-poll.test.ts +37 -0
- package/src/lib/organize/organize-poll.ts +28 -0
- package/src/lib/outbox-status.test.ts +64 -0
- package/src/lib/outbox-status.ts +75 -0
- package/src/lib/plate-serializer.ts +164 -0
- package/src/lib/provider-presets.test.ts +96 -0
- package/src/lib/provider-presets.ts +81 -0
- package/src/lib/query-error-handler.test.ts +144 -0
- package/src/lib/query-error-handler.ts +34 -0
- package/src/lib/query-escalation.integration.test.ts +138 -0
- package/src/lib/recent-searches.test.ts +53 -0
- package/src/lib/recent-searches.ts +34 -0
- package/src/lib/rescue-candidates.test.ts +94 -0
- package/src/lib/rescue-candidates.ts +35 -0
- package/src/lib/rescue-telemetry.test.ts +75 -0
- package/src/lib/rescue-telemetry.ts +55 -0
- package/src/lib/rum-adapter.test.ts +299 -0
- package/src/lib/rum-adapter.ts +107 -0
- package/src/lib/saved-searches.test.ts +80 -0
- package/src/lib/saved-searches.ts +52 -0
- package/src/lib/search-pending.test.ts +61 -0
- package/src/lib/search-pending.ts +24 -0
- package/src/lib/search-query.test.ts +35 -0
- package/src/lib/search-query.ts +11 -0
- package/src/lib/search-result.test.ts +160 -0
- package/src/lib/search-result.ts +102 -0
- package/src/lib/search-schemas.ts +34 -0
- package/src/lib/search-token-index.test.ts +112 -0
- package/src/lib/search-token-index.ts +56 -0
- package/src/lib/search-tokens.test.ts +218 -0
- package/src/lib/search-tokens.ts +133 -0
- package/src/lib/telemetry-context.tsx +9 -0
- package/src/lib/telemetry.ts +17 -0
- package/src/lib/theme-preference.ts +46 -0
- package/src/lib/theme.ts +33 -0
- package/src/lib/utils.ts +4 -0
- package/src/main.tsx +10 -0
- package/src/routeTree.gen.ts +392 -0
- package/src/router.test.ts +81 -0
- package/src/router.tsx +31 -0
- package/src/routes/__root.tsx +69 -0
- package/src/routes/index.tsx +7 -0
- package/src/routes/mail/$mailboxId.tsx +27 -0
- package/src/routes/mail/flagged.tsx +37 -0
- package/src/routes/mail/index.tsx +41 -0
- package/src/routes/mail/outbox.tsx +13 -0
- package/src/routes/mail.tsx +413 -0
- package/src/routes/onboarding.tsx +35 -0
- package/src/routes/settings/-accounts.test.ts +65 -0
- package/src/routes/settings/accounts.tsx +542 -0
- package/src/routes/settings/advanced.tsx +55 -0
- package/src/routes/settings/appearance.tsx +140 -0
- package/src/routes/settings/filters.tsx +141 -0
- package/src/routes/settings/folders.tsx +216 -0
- package/src/routes/settings/index.tsx +5 -0
- package/src/routes/settings/senders.tsx +466 -0
- package/src/routes/settings/suggested-vips.tsx +10 -0
- package/src/routes/settings.tsx +66 -0
- package/src/runtime-config.ts +75 -0
- package/src/shell/index.tsx +98 -0
- package/src/types/index.ts +73 -0
- package/src/vite-env.d.ts +4 -0
- package/vite.base.ts +43 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
BodyFetchError,
|
|
5
|
+
classifyBodyFetchFailure,
|
|
6
|
+
fetchBodyContent,
|
|
7
|
+
fetchBodyContentAttempt,
|
|
8
|
+
isContentTypeMismatch,
|
|
9
|
+
isSpaShellResponse,
|
|
10
|
+
parseRetryAfterSeconds,
|
|
11
|
+
} from "./useMessageBodyContent";
|
|
12
|
+
|
|
13
|
+
const SPA_SHELL = `<!doctype html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head><title>Remit</title></head>
|
|
16
|
+
<body>
|
|
17
|
+
<div id="root"></div>
|
|
18
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
19
|
+
</body>
|
|
20
|
+
</html>`;
|
|
21
|
+
|
|
22
|
+
describe("isSpaShellResponse — guard against CloudFront 403/404 → /index.html leak (#310 review)", () => {
|
|
23
|
+
it("flags the SPA shell HTML served as text/html", () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
isSpaShellResponse(SPA_SHELL, "text/html; charset=utf-8"),
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("flags single-quoted variant of the React mount node", () => {
|
|
31
|
+
const html = `<html><body><div id='root'></div></body></html>`;
|
|
32
|
+
assert.equal(isSpaShellResponse(html, "text/html"), true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not flag a real email's HTML body (no #root mount node)", () => {
|
|
36
|
+
const emailHtml = `<html><body><p>Hello there!</p></body></html>`;
|
|
37
|
+
assert.equal(isSpaShellResponse(emailHtml, "text/html"), false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not flag identical bytes when the Content-Type is not text/html", () => {
|
|
41
|
+
assert.equal(
|
|
42
|
+
isSpaShellResponse(SPA_SHELL, "application/octet-stream"),
|
|
43
|
+
false,
|
|
44
|
+
"Content-Type gates the check — a plain-text body that happens to contain '<div id=\"root\">' must not trigger",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("treats a missing Content-Type as not-html (no false positive on null)", () => {
|
|
49
|
+
assert.equal(isSpaShellResponse(SPA_SHELL, null), false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("isContentTypeMismatch — text/plain part must never come back as text/html", () => {
|
|
54
|
+
it("flags a text-kind fetch that returns text/html", () => {
|
|
55
|
+
assert.equal(
|
|
56
|
+
isContentTypeMismatch("text", "text/html; charset=utf-8"),
|
|
57
|
+
true,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not flag a text-kind fetch that returns text/plain", () => {
|
|
62
|
+
assert.equal(
|
|
63
|
+
isContentTypeMismatch("text", "text/plain; charset=utf-8"),
|
|
64
|
+
false,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("does not flag a text-kind fetch that returns application/octet-stream (dev-server)", () => {
|
|
69
|
+
assert.equal(
|
|
70
|
+
isContentTypeMismatch("text", "application/octet-stream"),
|
|
71
|
+
false,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not flag an html-kind fetch returning text/html (legitimate)", () => {
|
|
76
|
+
assert.equal(
|
|
77
|
+
isContentTypeMismatch("html", "text/html; charset=utf-8"),
|
|
78
|
+
false,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("treats a missing Content-Type as not-mismatch (the SPA-shell guard catches the html-shaped variant separately)", () => {
|
|
83
|
+
assert.equal(isContentTypeMismatch("text", null), false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("classifyBodyFetchFailure — distinguishes auth failure vs. missing-S3-object 403 (#401)", () => {
|
|
88
|
+
it("classifies 401 as auth (Lambda@Edge denies missing/invalid tokens with 401)", () => {
|
|
89
|
+
assert.equal(classifyBodyFetchFailure(401, null), "auth");
|
|
90
|
+
assert.equal(classifyBodyFetchFailure(401, "auth-missing"), "auth");
|
|
91
|
+
assert.equal(classifyBodyFetchFailure(401, "auth-invalid"), "auth");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("classifies a 403 carrying the edge x-remit-403-reason header as auth (tenant-mismatch)", () => {
|
|
95
|
+
assert.equal(
|
|
96
|
+
classifyBodyFetchFailure(403, "tenant-mismatch"),
|
|
97
|
+
"auth",
|
|
98
|
+
"a 403 with the edge reason header is an auth denial, not a storage miss",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("classifies a bare 403 with NO edge header as body-missing (S3 origin response — OAC blocks list so missing objects 403)", () => {
|
|
103
|
+
assert.equal(
|
|
104
|
+
classifyBodyFetchFailure(403, null),
|
|
105
|
+
"body-missing",
|
|
106
|
+
"a 403 without the edge reason header originates from S3 — the object is missing in storage",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("treats an empty / whitespace-only edge reason header as absent (a stripped header is not a real auth denial)", () => {
|
|
111
|
+
assert.equal(classifyBodyFetchFailure(403, ""), "body-missing");
|
|
112
|
+
assert.equal(classifyBodyFetchFailure(403, " "), "body-missing");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("classifies 404 as body-missing (defensive — current infra never produces a 404 here but a future bucket-policy change could)", () => {
|
|
116
|
+
assert.equal(classifyBodyFetchFailure(404, null), "body-missing");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("classifies other 4xx/5xx as generic", () => {
|
|
120
|
+
assert.equal(classifyBodyFetchFailure(400, null), "generic");
|
|
121
|
+
assert.equal(classifyBodyFetchFailure(500, null), "generic");
|
|
122
|
+
assert.equal(classifyBodyFetchFailure(502, null), "generic");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("fetchBodyContent — throws BodyFetchError with discriminated reason (#401)", () => {
|
|
127
|
+
const originalFetch = globalThis.fetch;
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
// Stub fetch on each test so the body-fetcher hits a known response
|
|
130
|
+
// shape without any real network or Amplify involvement.
|
|
131
|
+
});
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
globalThis.fetch = originalFetch;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const stubFetch = (
|
|
137
|
+
status: number,
|
|
138
|
+
headers: Record<string, string> = {},
|
|
139
|
+
body = "",
|
|
140
|
+
): void => {
|
|
141
|
+
globalThis.fetch = (async () =>
|
|
142
|
+
new Response(body, { status, headers })) as typeof fetch;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
it("throws BodyFetchError(reason=auth) on a 401 from the edge", async () => {
|
|
146
|
+
stubFetch(
|
|
147
|
+
401,
|
|
148
|
+
{ "x-remit-403-reason": "auth-invalid" },
|
|
149
|
+
"Invalid id_token",
|
|
150
|
+
);
|
|
151
|
+
await assert.rejects(
|
|
152
|
+
() => fetchBodyContent("/content/accounts/x/messages/m/body.eml", "text"),
|
|
153
|
+
(err: unknown) => {
|
|
154
|
+
assert.ok(err instanceof BodyFetchError);
|
|
155
|
+
assert.equal(err.reason, "auth");
|
|
156
|
+
assert.equal(err.status, 401);
|
|
157
|
+
return true;
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("throws BodyFetchError(reason=auth) on a 403 with x-remit-403-reason (tenant-mismatch)", async () => {
|
|
163
|
+
stubFetch(
|
|
164
|
+
403,
|
|
165
|
+
{ "x-remit-403-reason": "tenant-mismatch" },
|
|
166
|
+
"Tenant mismatch",
|
|
167
|
+
);
|
|
168
|
+
await assert.rejects(
|
|
169
|
+
() => fetchBodyContent("/content/accounts/x/messages/m/body.eml", "text"),
|
|
170
|
+
(err: unknown) => {
|
|
171
|
+
assert.ok(err instanceof BodyFetchError);
|
|
172
|
+
assert.equal(err.reason, "auth");
|
|
173
|
+
assert.equal(err.status, 403);
|
|
174
|
+
return true;
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("throws BodyFetchError(reason=body-missing) on a bare S3 403 (no edge reason header)", async () => {
|
|
180
|
+
stubFetch(403, {}, "Forbidden");
|
|
181
|
+
await assert.rejects(
|
|
182
|
+
() => fetchBodyContent("/content/accounts/x/messages/m/body.eml", "text"),
|
|
183
|
+
(err: unknown) => {
|
|
184
|
+
assert.ok(err instanceof BodyFetchError);
|
|
185
|
+
assert.equal(err.reason, "body-missing");
|
|
186
|
+
assert.equal(err.status, 403);
|
|
187
|
+
return true;
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("throws BodyFetchError(reason=generic) on a 5xx", async () => {
|
|
193
|
+
stubFetch(502, {}, "Bad Gateway");
|
|
194
|
+
await assert.rejects(
|
|
195
|
+
() => fetchBodyContent("/content/accounts/x/messages/m/body.eml", "text"),
|
|
196
|
+
(err: unknown) => {
|
|
197
|
+
assert.ok(err instanceof BodyFetchError);
|
|
198
|
+
assert.equal(err.reason, "generic");
|
|
199
|
+
assert.equal(err.status, 502);
|
|
200
|
+
return true;
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("throws BodyFetchError(reason=content-type-mismatch) when a text part returns text/html", async () => {
|
|
206
|
+
stubFetch(
|
|
207
|
+
200,
|
|
208
|
+
{ "content-type": "text/html; charset=utf-8" },
|
|
209
|
+
"<html>oops</html>",
|
|
210
|
+
);
|
|
211
|
+
await assert.rejects(
|
|
212
|
+
() => fetchBodyContent("/content/accounts/x/messages/m/body.eml", "text"),
|
|
213
|
+
(err: unknown) => {
|
|
214
|
+
assert.ok(err instanceof BodyFetchError);
|
|
215
|
+
assert.equal(err.reason, "content-type-mismatch");
|
|
216
|
+
return true;
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("throws BodyFetchError(reason=spa-shell-leak) when the response is the SPA shell HTML", async () => {
|
|
222
|
+
stubFetch(
|
|
223
|
+
200,
|
|
224
|
+
{ "content-type": "text/html; charset=utf-8" },
|
|
225
|
+
`<!doctype html><html><body><div id="root"></div></body></html>`,
|
|
226
|
+
);
|
|
227
|
+
await assert.rejects(
|
|
228
|
+
() => fetchBodyContent("/content/accounts/x/messages/m/body.eml", "html"),
|
|
229
|
+
(err: unknown) => {
|
|
230
|
+
assert.ok(err instanceof BodyFetchError);
|
|
231
|
+
assert.equal(err.reason, "spa-shell-leak");
|
|
232
|
+
return true;
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns the body text on a 2xx with a matching Content-Type", async () => {
|
|
238
|
+
stubFetch(
|
|
239
|
+
200,
|
|
240
|
+
{ "content-type": "text/plain; charset=utf-8" },
|
|
241
|
+
"hello world",
|
|
242
|
+
);
|
|
243
|
+
const body = await fetchBodyContent(
|
|
244
|
+
"/content/accounts/x/messages/m/body.eml",
|
|
245
|
+
"text",
|
|
246
|
+
);
|
|
247
|
+
assert.equal(body, "hello world");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("throws a retryable not-ready error carrying Retry-After on a 202", async () => {
|
|
251
|
+
// A 202 is `ok`, so this must never be rendered as the body — the body is
|
|
252
|
+
// still syncing and the query must retry (#body-sync-cue).
|
|
253
|
+
stubFetch(202, { "Retry-After": "2" }, "body not yet synced");
|
|
254
|
+
await assert.rejects(
|
|
255
|
+
() => fetchBodyContent("/content/accounts/x/messages/m/parts/1", "text"),
|
|
256
|
+
(err: unknown) => {
|
|
257
|
+
assert.ok(err instanceof BodyFetchError);
|
|
258
|
+
assert.equal(err.reason, "not-ready");
|
|
259
|
+
assert.equal(err.status, 202);
|
|
260
|
+
assert.equal(err.retryAfterSeconds, 2);
|
|
261
|
+
return true;
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("defaults Retry-After to 1s on a 202 without the header", async () => {
|
|
267
|
+
stubFetch(202, {}, "body not yet synced");
|
|
268
|
+
await assert.rejects(
|
|
269
|
+
() => fetchBodyContent("/content/accounts/x/messages/m/parts/1", "text"),
|
|
270
|
+
(err: unknown) => {
|
|
271
|
+
assert.ok(err instanceof BodyFetchError);
|
|
272
|
+
assert.equal(err.reason, "not-ready");
|
|
273
|
+
assert.equal(err.retryAfterSeconds, 1);
|
|
274
|
+
return true;
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("fetchBodyContentAttempt — self-heals a deferred per-part object on a 202 retry (#1240)", () => {
|
|
281
|
+
it("refetches the describe query before re-fetching the content URL when isRetryAfterNotReady", async () => {
|
|
282
|
+
const calls: string[] = [];
|
|
283
|
+
const result = await fetchBodyContentAttempt(
|
|
284
|
+
{
|
|
285
|
+
fetchContent: async (url, kind) => {
|
|
286
|
+
calls.push(`fetch:${url}:${kind}`);
|
|
287
|
+
return "hello world";
|
|
288
|
+
},
|
|
289
|
+
refetchDescribeMessage: async () => {
|
|
290
|
+
calls.push("describe");
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
contentUrl: "/content/accounts/x/messages/m/parts/1",
|
|
295
|
+
kind: "text",
|
|
296
|
+
isRetryAfterNotReady: true,
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
assert.equal(result, "hello world");
|
|
301
|
+
assert.deepEqual(calls, [
|
|
302
|
+
"describe",
|
|
303
|
+
"fetch:/content/accounts/x/messages/m/parts/1:text",
|
|
304
|
+
]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("skips the describe refetch on a first attempt (isRetryAfterNotReady = false)", async () => {
|
|
308
|
+
const calls: string[] = [];
|
|
309
|
+
await fetchBodyContentAttempt(
|
|
310
|
+
{
|
|
311
|
+
fetchContent: async (url) => {
|
|
312
|
+
calls.push(`fetch:${url}`);
|
|
313
|
+
return "hello world";
|
|
314
|
+
},
|
|
315
|
+
refetchDescribeMessage: async () => {
|
|
316
|
+
calls.push("describe");
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
contentUrl: "/content/accounts/x/messages/m/parts/1",
|
|
321
|
+
kind: "text",
|
|
322
|
+
isRetryAfterNotReady: false,
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
assert.deepEqual(calls, ["fetch:/content/accounts/x/messages/m/parts/1"]);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("propagates a not-ready error from the content fetch even after refetching describe (bounded retry keeps working)", async () => {
|
|
330
|
+
await assert.rejects(
|
|
331
|
+
() =>
|
|
332
|
+
fetchBodyContentAttempt(
|
|
333
|
+
{
|
|
334
|
+
fetchContent: async () => {
|
|
335
|
+
throw new BodyFetchError("not-ready", "still syncing", 202, 1);
|
|
336
|
+
},
|
|
337
|
+
refetchDescribeMessage: async () => {},
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
contentUrl: "/content/accounts/x/messages/m/parts/1",
|
|
341
|
+
kind: "text",
|
|
342
|
+
isRetryAfterNotReady: true,
|
|
343
|
+
},
|
|
344
|
+
),
|
|
345
|
+
(err: unknown) => {
|
|
346
|
+
assert.ok(err instanceof BodyFetchError);
|
|
347
|
+
assert.equal(err.reason, "not-ready");
|
|
348
|
+
return true;
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("parseRetryAfterSeconds", () => {
|
|
355
|
+
it("parses a positive delta-seconds value", () => {
|
|
356
|
+
assert.equal(parseRetryAfterSeconds("5"), 5);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("falls back to 1 for missing/invalid values", () => {
|
|
360
|
+
assert.equal(parseRetryAfterSeconds(null), 1);
|
|
361
|
+
assert.equal(parseRetryAfterSeconds(""), 1);
|
|
362
|
+
assert.equal(parseRetryAfterSeconds("0"), 1);
|
|
363
|
+
assert.equal(parseRetryAfterSeconds("-3"), 1);
|
|
364
|
+
assert.equal(parseRetryAfterSeconds("notanumber"), 1);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("caps an absurdly large value", () => {
|
|
368
|
+
assert.equal(parseRetryAfterSeconds("99999"), 30);
|
|
369
|
+
});
|
|
370
|
+
});
|