create-feedbax 0.1.0 → 0.2.0
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/LICENSE +201 -21
- package/README.md +11 -1
- package/dist/cli.js +1261 -26
- package/dist/index.js +1402 -160
- package/package.json +40 -14
- package/template/.feedbax-template.sha256 +1 -0
- package/template/.gitignore.template +6 -0
- package/template/.npmignore.template +5 -0
- package/template/.prettierignore.template +4 -0
- package/template/LICENSE +201 -0
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +25 -0
- package/template/package.json +70 -14
- package/template/public/feedbax-mark.svg +4 -0
- package/template/public/robots.txt +3 -0
- package/template/src/authorized-draft-post.test.tsx +212 -0
- package/template/src/authorized-draft-post.tsx +204 -0
- package/template/src/authorized-draft-query.ts +74 -0
- package/template/src/browser-post-state.test.ts +120 -0
- package/template/src/browser-post-state.ts +134 -0
- package/template/src/browser-vote-state.test.ts +14 -0
- package/template/src/browser-vote-state.ts +27 -0
- package/template/src/capability-authorization-error.ts +3 -0
- package/template/src/changelog-page.test.tsx +139 -0
- package/template/src/changelog-page.tsx +219 -0
- package/template/src/changelog-query.ts +13 -0
- package/template/src/changelog-runtime.ts +27 -0
- package/template/src/cloudflare-turnstile.ts +31 -0
- package/template/src/cloudflare-workers-test.ts +3 -0
- package/template/src/cloudflare-workers.d.ts +18 -0
- package/template/src/comment-creation.test.tsx +270 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +18 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +246 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +309 -0
- package/template/src/components/header.tsx +95 -0
- package/template/src/components/loader.tsx +10 -0
- package/template/src/components/profile-menu.tsx +89 -0
- package/template/src/components/ui/components/alert-dialog.tsx +185 -0
- package/template/src/components/ui/components/attachment.tsx +207 -0
- package/template/src/components/ui/components/avatar.tsx +107 -0
- package/template/src/components/ui/components/badge.tsx +52 -0
- package/template/src/components/ui/components/bubble.tsx +128 -0
- package/template/src/components/ui/components/button-group.tsx +87 -0
- package/template/src/components/ui/components/button.tsx +56 -0
- package/template/src/components/ui/components/card.tsx +103 -0
- package/template/src/components/ui/components/checkbox.tsx +26 -0
- package/template/src/components/ui/components/command.tsx +194 -0
- package/template/src/components/ui/components/dialog.tsx +154 -0
- package/template/src/components/ui/components/drawer.tsx +229 -0
- package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
- package/template/src/components/ui/components/empty.tsx +101 -0
- package/template/src/components/ui/components/field.tsx +236 -0
- package/template/src/components/ui/components/input-group.tsx +155 -0
- package/template/src/components/ui/components/input.tsx +20 -0
- package/template/src/components/ui/components/item.tsx +201 -0
- package/template/src/components/ui/components/kbd.tsx +26 -0
- package/template/src/components/ui/components/label.tsx +18 -0
- package/template/src/components/ui/components/marker.tsx +71 -0
- package/template/src/components/ui/components/message-scroller.tsx +128 -0
- package/template/src/components/ui/components/message.tsx +92 -0
- package/template/src/components/ui/components/native-select.tsx +65 -0
- package/template/src/components/ui/components/popover.tsx +88 -0
- package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
- package/template/src/components/ui/components/radio-group.tsx +36 -0
- package/template/src/components/ui/components/scroll-area.tsx +52 -0
- package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
- package/template/src/components/ui/components/select.tsx +199 -0
- package/template/src/components/ui/components/separator.tsx +23 -0
- package/template/src/components/ui/components/sheet.tsx +135 -0
- package/template/src/components/ui/components/skeleton.tsx +13 -0
- package/template/src/components/ui/components/sonner.tsx +43 -0
- package/template/src/components/ui/components/spinner.tsx +16 -0
- package/template/src/components/ui/components/tabs.tsx +80 -0
- package/template/src/components/ui/components/textarea.tsx +18 -0
- package/template/src/components/ui/components/tooltip.tsx +64 -0
- package/template/src/components/ui/hooks/.gitkeep +0 -0
- package/template/src/components/ui/lib/utils.ts +6 -0
- package/template/src/components/ui/styles/globals.css +151 -0
- package/template/src/create-post-overlay.tsx +82 -0
- package/template/src/draft-post-list.tsx +39 -0
- package/template/src/feedback-cache-invalidation.test.ts +185 -0
- package/template/src/feedback-cache-invalidation.ts +97 -0
- package/template/src/feedback-runtime.ts +38 -0
- package/template/src/feedbax/changelog/index.test.ts +156 -0
- package/template/src/feedbax/changelog/index.ts +206 -0
- package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
- package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
- package/template/src/feedbax/config/index.test.ts +111 -0
- package/template/src/feedbax/config/index.ts +271 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
- package/template/src/feedbax/feedback/index.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +942 -0
- package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
- package/template/src/feedbax/feedback/notion-comments.ts +231 -0
- package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
- package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
- package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
- package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/index.css +2138 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +10 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +130 -0
- package/template/src/overlay-post-detail.tsx +92 -0
- package/template/src/portal-comments.test.ts +375 -0
- package/template/src/portal-comments.ts +384 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +515 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +55 -0
- package/template/src/portal-feedback-server-function.ts +93 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +214 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +64 -0
- package/template/src/portal-voting.test.ts +106 -0
- package/template/src/portal-voting.ts +149 -0
- package/template/src/post-badges.tsx +27 -0
- package/template/src/post-creation-flow.test.tsx +230 -0
- package/template/src/post-creation-flow.tsx +88 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-feed-controls.test.tsx +239 -0
- package/template/src/post-feed-controls.tsx +298 -0
- package/template/src/post-presentation.ts +37 -0
- package/template/src/post-queries.test.ts +158 -0
- package/template/src/post-queries.ts +26 -0
- package/template/src/post-results.tsx +165 -0
- package/template/src/public-cache-policy.test.ts +141 -0
- package/template/src/public-cache-policy.ts +119 -0
- package/template/src/public-cache-tags.ts +7 -0
- package/template/src/public-changelog-page.ts +15 -0
- package/template/src/public-changelog-server-function.ts +27 -0
- package/template/src/public-date.ts +11 -0
- package/template/src/public-failure-state.test.tsx +69 -0
- package/template/src/public-feedback-index.tsx +124 -0
- package/template/src/public-feedback-page.test.tsx +290 -0
- package/template/src/public-feedback-page.ts +75 -0
- package/template/src/public-feedback-server-function.ts +22 -0
- package/template/src/public-portal-error.tsx +27 -0
- package/template/src/public-post-detail.tsx +682 -0
- package/template/src/public-post-page.test.tsx +173 -0
- package/template/src/public-post-page.ts +15 -0
- package/template/src/public-post-route.test.tsx +214 -0
- package/template/src/public-post-route.tsx +171 -0
- package/template/src/public-post-server-function.ts +48 -0
- package/template/src/public-post-unavailable.tsx +10 -0
- package/template/src/public-roadmap-page.test.tsx +139 -0
- package/template/src/public-roadmap-page.ts +24 -0
- package/template/src/public-roadmap-route.test.tsx +218 -0
- package/template/src/public-roadmap-route.tsx +50 -0
- package/template/src/public-roadmap-server-function.ts +44 -0
- package/template/src/public-roadmap-view.tsx +378 -0
- package/template/src/public-shell.test.tsx +518 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +131 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +25 -0
- package/template/src/routes/__root.tsx +116 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/index.tsx +61 -0
- package/template/src/routes/p.$slug.tsx +16 -0
- package/template/src/routes/roadmap.tsx +11 -0
- package/template/src/routes/submit.tsx +28 -0
- package/template/src/safe-public-failure.ts +45 -0
- package/template/src/server.ts +111 -0
- package/template/src/similar-posts.tsx +45 -0
- package/template/src/submit-route.test.tsx +134 -0
- package/template/src/trusted-feedback-handler.test.ts +425 -0
- package/template/src/trusted-feedback-handler.ts +181 -0
- package/template/src/trusted-feedback-runtime.ts +17 -0
- package/template/src/turnstile-configuration.test.ts +37 -0
- package/template/src/turnstile-configuration.ts +19 -0
- package/template/src/use-media-query.ts +17 -0
- package/template/src/vote-toggle.test.tsx +171 -0
- package/template/src/vote-toggle.tsx +211 -0
- package/template/tsconfig.json +45 -0
- package/template/vite.config.ts +20 -0
- package/template/vitest.config.ts +4 -0
- package/template/vitest.setup.ts +3 -0
- package/template/wrangler.jsonc +35 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/template/.env.example +0 -4
- package/template/README.md +0 -7
- package/template/apps/portal/components.json +0 -20
- package/template/apps/portal/package.json +0 -31
- package/template/apps/portal/src/lib/utils.ts +0 -6
- package/template/apps/portal/src/routeTree.gen.ts +0 -59
- package/template/apps/portal/src/router.tsx +0 -10
- package/template/apps/portal/src/routes/__root.tsx +0 -31
- package/template/apps/portal/src/routes/index.tsx +0 -35
- package/template/apps/portal/src/styles.css +0 -91
- package/template/apps/portal/tsconfig.json +0 -15
- package/template/apps/portal/vite.config.ts +0 -13
- package/template/feedbax.config.ts +0 -13
- package/template/feedbax.jsonc +0 -11
- package/template/pnpm-workspace.yaml +0 -3
- package/template/turbo.json +0 -8
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
+
import {
|
|
6
|
+
Link,
|
|
7
|
+
Outlet,
|
|
8
|
+
RouterProvider,
|
|
9
|
+
createMemoryHistory,
|
|
10
|
+
createRootRoute,
|
|
11
|
+
createRoute,
|
|
12
|
+
createRouter,
|
|
13
|
+
} from "@tanstack/react-router";
|
|
14
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
|
|
16
|
+
import { CreatePostOverlay } from "./create-post-overlay";
|
|
17
|
+
import { PostCreationSkeleton } from "./post-creation-flow";
|
|
18
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
19
|
+
|
|
20
|
+
const mutations = {
|
|
21
|
+
submitPost: vi.fn(),
|
|
22
|
+
editDraftPost: vi.fn(),
|
|
23
|
+
withdrawDraftPost: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
cleanup();
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
vi.unstubAllGlobals();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("/submit", () => {
|
|
33
|
+
it("opens from the feed at the canonical URL without discarding the feed", async () => {
|
|
34
|
+
const history = createMemoryHistory({ initialEntries: ["/"] });
|
|
35
|
+
const router = createSubmitRouter(history);
|
|
36
|
+
|
|
37
|
+
renderSubmitRouter(router);
|
|
38
|
+
fireEvent.click(await screen.findByRole("link", { name: "Create Post" }));
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
await screen.findByRole("dialog", { name: "Create a Post" }),
|
|
42
|
+
).toBeTruthy();
|
|
43
|
+
expect(document.body.textContent).toContain("Posts");
|
|
44
|
+
expect(history.location.pathname).toBe("/submit");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders a complete creation page when entered directly", async () => {
|
|
48
|
+
const history = createMemoryHistory({ initialEntries: ["/submit"] });
|
|
49
|
+
const router = createSubmitRouter(history);
|
|
50
|
+
|
|
51
|
+
renderSubmitRouter(router);
|
|
52
|
+
|
|
53
|
+
expect(await screen.findByRole("main")).toBeTruthy();
|
|
54
|
+
expect(screen.getByRole("heading", { name: "Create a Post" })).toBeTruthy();
|
|
55
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("uses a Drawer on mobile and Back restores the feed route", async () => {
|
|
59
|
+
vi.stubGlobal("matchMedia", (query: string) => ({
|
|
60
|
+
matches: query === "(max-width: 720px)",
|
|
61
|
+
media: query,
|
|
62
|
+
addEventListener: vi.fn(),
|
|
63
|
+
removeEventListener: vi.fn(),
|
|
64
|
+
}));
|
|
65
|
+
const history = createMemoryHistory({ initialEntries: ["/"] });
|
|
66
|
+
const router = createSubmitRouter(history);
|
|
67
|
+
|
|
68
|
+
renderSubmitRouter(router);
|
|
69
|
+
fireEvent.click(await screen.findByRole("link", { name: "Create Post" }));
|
|
70
|
+
|
|
71
|
+
const drawer = await screen.findByRole("dialog", { name: "Create a Post" });
|
|
72
|
+
expect(drawer.closest('[data-slot="drawer-popup"]')).toBeTruthy();
|
|
73
|
+
history.back();
|
|
74
|
+
await vi.waitFor(() => expect(history.location.pathname).toBe("/"));
|
|
75
|
+
await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("has a contextual creation skeleton for routed loading", () => {
|
|
79
|
+
const html = renderToStaticMarkup(<PostCreationSkeleton />);
|
|
80
|
+
|
|
81
|
+
expect(html).toContain('aria-label="Loading Post creation"');
|
|
82
|
+
expect(html).toContain('aria-busy="true"');
|
|
83
|
+
expect(html).toContain("Loading Post creation…");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function renderSubmitRouter(router: ReturnType<typeof createSubmitRouter>) {
|
|
88
|
+
render(
|
|
89
|
+
<QueryClientProvider client={new QueryClient()}>
|
|
90
|
+
<RouterProvider router={router} />
|
|
91
|
+
</QueryClientProvider>,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createSubmitRouter(history: ReturnType<typeof createMemoryHistory>) {
|
|
96
|
+
const root = createRootRoute({
|
|
97
|
+
component: () => (
|
|
98
|
+
<>
|
|
99
|
+
<Outlet />
|
|
100
|
+
<CreatePostOverlay mutations={mutations} />
|
|
101
|
+
</>
|
|
102
|
+
),
|
|
103
|
+
});
|
|
104
|
+
const index = createRoute({
|
|
105
|
+
getParentRoute: () => root,
|
|
106
|
+
path: "/",
|
|
107
|
+
component: () => (
|
|
108
|
+
<main>
|
|
109
|
+
<h1>Posts</h1>
|
|
110
|
+
<Link
|
|
111
|
+
to="/"
|
|
112
|
+
state={{ createPostOverlay: true }}
|
|
113
|
+
mask={{ to: "/submit", unmaskOnReload: true }}
|
|
114
|
+
>
|
|
115
|
+
Create Post
|
|
116
|
+
</Link>
|
|
117
|
+
</main>
|
|
118
|
+
),
|
|
119
|
+
});
|
|
120
|
+
const submit = createRoute({
|
|
121
|
+
getParentRoute: () => root,
|
|
122
|
+
path: "/submit",
|
|
123
|
+
component: () => (
|
|
124
|
+
<main>
|
|
125
|
+
<h1>Create a Post</h1>
|
|
126
|
+
</main>
|
|
127
|
+
),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return createRouter({
|
|
131
|
+
history,
|
|
132
|
+
routeTree: root.addChildren([index, submit]),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFeedbackModule,
|
|
3
|
+
createNotionFeedbackModule,
|
|
4
|
+
type FeedbackPropertyIds,
|
|
5
|
+
} from "@feedbax/feedback";
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { createTrustedFeedbackHandler } from "./trusted-feedback-handler";
|
|
9
|
+
import { createPortalServerEntry } from "./portal-server-entry";
|
|
10
|
+
|
|
11
|
+
const propertyIds: FeedbackPropertyIds = {
|
|
12
|
+
title: "title-id",
|
|
13
|
+
slug: "slug-id",
|
|
14
|
+
description: "description-id",
|
|
15
|
+
type: "type-id",
|
|
16
|
+
status: "status-id",
|
|
17
|
+
published: "published-id",
|
|
18
|
+
voteCount: "vote-count-id",
|
|
19
|
+
teamResponse: "team-response-id",
|
|
20
|
+
releaseNotesUrl: "release-notes-url-id",
|
|
21
|
+
submitterName: "submitter-name-id",
|
|
22
|
+
submitterEmail: "submitter-email-id",
|
|
23
|
+
source: "source-id",
|
|
24
|
+
externalId: "external-id",
|
|
25
|
+
editTokenHash: "edit-token-hash-id",
|
|
26
|
+
createdAt: "created-at-id",
|
|
27
|
+
updatedAt: "updated-at-id",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("trusted feedback Worker handler", () => {
|
|
31
|
+
it("redirects equivalent public filter URLs to one canonical cache identity", async () => {
|
|
32
|
+
const applicationHandler = vi.fn(async () => new Response("public"));
|
|
33
|
+
const server = createPortalServerEntry({
|
|
34
|
+
trustedFeedbackHandler: vi.fn(),
|
|
35
|
+
applicationHandler,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const response = await server.fetch(
|
|
39
|
+
new Request(
|
|
40
|
+
"https://feedback.example.com/?type=Bug%20Report&status=New&ignored=value",
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(response.status).toBe(308);
|
|
45
|
+
expect(response.headers.get("location")).toBe(
|
|
46
|
+
"https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=1",
|
|
47
|
+
);
|
|
48
|
+
expect(applicationHandler).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("serves minimal deployment health without invoking application handlers", async () => {
|
|
52
|
+
const server = createPortalServerEntry({
|
|
53
|
+
trustedFeedbackHandler: async () => {
|
|
54
|
+
throw new Error("Health dispatch reached the trusted handler.");
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const response = await server.fetch(
|
|
59
|
+
new Request("https://feedback.example.com/health"),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(response.status).toBe(200);
|
|
63
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
64
|
+
await expect(response.json()).resolves.toEqual({ status: "ok" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("serves the trusted POST contract through the production server-entry dispatch", async () => {
|
|
68
|
+
const server = createPortalServerEntry({
|
|
69
|
+
trustedFeedbackHandler: createTrustedFeedbackHandler({
|
|
70
|
+
...trustedHandlerSecurity("correct-api-key"),
|
|
71
|
+
feedback: createFeedbackModule(),
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const response = await server.fetch(
|
|
76
|
+
trustedRequest({}, "product-a:feedback-123"),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(response.status).toBe(200);
|
|
80
|
+
const body = await response.json();
|
|
81
|
+
expect(body).toMatchObject({
|
|
82
|
+
title: "Keyboard navigation",
|
|
83
|
+
description: "Let users navigate without a mouse.",
|
|
84
|
+
type: "Feature Request",
|
|
85
|
+
status: "New",
|
|
86
|
+
});
|
|
87
|
+
expect(body).not.toHaveProperty("published");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects missing and invalid Bearer keys before any Notion request", async () => {
|
|
91
|
+
const notionRequest = vi.fn<typeof fetch>();
|
|
92
|
+
const handler = createTrustedFeedbackHandler({
|
|
93
|
+
...trustedHandlerSecurity("correct-api-key"),
|
|
94
|
+
feedback: createNotionFeedbackModule({
|
|
95
|
+
token: "notion-token",
|
|
96
|
+
dataSourceId: "feedback-data-source",
|
|
97
|
+
propertyIds,
|
|
98
|
+
request: notionRequest,
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
const body = JSON.stringify({
|
|
102
|
+
title: "Keyboard navigation",
|
|
103
|
+
description: "Let users navigate without a mouse.",
|
|
104
|
+
type: "Feature Request",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const missing = await handler(
|
|
108
|
+
new Request("https://feedback.example.com/api/v1/posts", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body,
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
const invalid = await handler(
|
|
115
|
+
new Request("https://feedback.example.com/api/v1/posts", {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: {
|
|
118
|
+
Authorization: "Bearer wrong-api-key",
|
|
119
|
+
"Content-Type": "application/json",
|
|
120
|
+
},
|
|
121
|
+
body,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(missing.status).toBe(401);
|
|
126
|
+
expect(invalid.status).toBe(401);
|
|
127
|
+
await expect(missing.json()).resolves.toEqual({
|
|
128
|
+
error: "Unauthorized trusted submission.",
|
|
129
|
+
});
|
|
130
|
+
await expect(invalid.json()).resolves.toEqual({
|
|
131
|
+
error: "Unauthorized trusted submission.",
|
|
132
|
+
});
|
|
133
|
+
expect(notionRequest).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("requires and honors Idempotency-Key by returning the original Post", async () => {
|
|
137
|
+
const page = notionPage({
|
|
138
|
+
title: "Keyboard navigation",
|
|
139
|
+
description: "Let users navigate without a mouse.",
|
|
140
|
+
type: "Feature Request",
|
|
141
|
+
externalId: "product-a:feedback-123",
|
|
142
|
+
});
|
|
143
|
+
const notionRequest = vi
|
|
144
|
+
.fn<typeof fetch>()
|
|
145
|
+
.mockResolvedValueOnce(
|
|
146
|
+
Response.json({ results: [], has_more: false, next_cursor: null }),
|
|
147
|
+
)
|
|
148
|
+
.mockResolvedValueOnce(
|
|
149
|
+
Response.json({ results: [], has_more: false, next_cursor: null }),
|
|
150
|
+
)
|
|
151
|
+
.mockResolvedValueOnce(Response.json(page))
|
|
152
|
+
.mockResolvedValueOnce(
|
|
153
|
+
Response.json({ results: [page], has_more: false, next_cursor: null }),
|
|
154
|
+
);
|
|
155
|
+
const handler = createTrustedFeedbackHandler({
|
|
156
|
+
...trustedHandlerSecurity("correct-api-key"),
|
|
157
|
+
feedback: createNotionFeedbackModule({
|
|
158
|
+
token: "notion-token",
|
|
159
|
+
dataSourceId: "feedback-data-source",
|
|
160
|
+
propertyIds,
|
|
161
|
+
request: notionRequest,
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const missingKey = await handler(trustedRequest({}));
|
|
166
|
+
const first = await handler(trustedRequest({}, "product-a:feedback-123"));
|
|
167
|
+
const retried = await handler(
|
|
168
|
+
trustedRequest(
|
|
169
|
+
{
|
|
170
|
+
title: "A changed retry must not overwrite the original",
|
|
171
|
+
type: "Bug Report",
|
|
172
|
+
},
|
|
173
|
+
"product-a:feedback-123",
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(missingKey.status).toBe(400);
|
|
178
|
+
await expect(missingKey.json()).resolves.toEqual({
|
|
179
|
+
error: "Idempotency-Key header is required.",
|
|
180
|
+
});
|
|
181
|
+
expect(first.status).toBe(200);
|
|
182
|
+
expect(retried.status).toBe(200);
|
|
183
|
+
const firstBody = await first.json();
|
|
184
|
+
const retriedBody = await retried.json();
|
|
185
|
+
expect(retriedBody).toEqual(firstBody);
|
|
186
|
+
expect(firstBody).not.toHaveProperty("published");
|
|
187
|
+
expect(firstBody).not.toHaveProperty("submitter");
|
|
188
|
+
expect(notionRequest).toHaveBeenCalledTimes(4);
|
|
189
|
+
expect(
|
|
190
|
+
notionRequest.mock.calls.filter(([url]) =>
|
|
191
|
+
String(url).endsWith("/pages"),
|
|
192
|
+
),
|
|
193
|
+
).toHaveLength(1);
|
|
194
|
+
const [lookupUrl, lookupInit] = notionRequest.mock.calls[0]!;
|
|
195
|
+
expect(String(lookupUrl)).toBe(
|
|
196
|
+
"https://api.notion.com/v1/data_sources/feedback-data-source/query",
|
|
197
|
+
);
|
|
198
|
+
expect(JSON.parse(String(lookupInit?.body))).toEqual({
|
|
199
|
+
page_size: 1,
|
|
200
|
+
filter: {
|
|
201
|
+
property: "external-id",
|
|
202
|
+
rich_text: { equals: "product-a:feedback-123" },
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
const createInit = notionRequest.mock.calls[2]?.[1];
|
|
206
|
+
const createBody = JSON.parse(String(createInit?.body)) as {
|
|
207
|
+
properties: Record<string, unknown>;
|
|
208
|
+
};
|
|
209
|
+
expect(createBody.properties).toMatchObject({
|
|
210
|
+
"source-id": { select: { name: "API" } },
|
|
211
|
+
"external-id": {
|
|
212
|
+
rich_text: [
|
|
213
|
+
{
|
|
214
|
+
type: "text",
|
|
215
|
+
text: { content: "product-a:feedback-123" },
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
"edit-token-hash-id": { rich_text: [] },
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("rejects out-of-bounds trusted input before any Notion request", async () => {
|
|
224
|
+
const notionRequest = vi.fn<typeof fetch>();
|
|
225
|
+
const handler = createTrustedFeedbackHandler({
|
|
226
|
+
...trustedHandlerSecurity("correct-api-key"),
|
|
227
|
+
feedback: createNotionFeedbackModule({
|
|
228
|
+
token: "notion-token",
|
|
229
|
+
dataSourceId: "feedback-data-source",
|
|
230
|
+
propertyIds,
|
|
231
|
+
request: notionRequest,
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
const requests = [
|
|
235
|
+
trustedRequest({ title: "" }, "valid-key"),
|
|
236
|
+
trustedRequest({ title: "t".repeat(161) }, "valid-key"),
|
|
237
|
+
trustedRequest({ description: "" }, "valid-key"),
|
|
238
|
+
trustedRequest({ description: "d".repeat(5_001) }, "valid-key"),
|
|
239
|
+
trustedRequest({ submitter: { name: "n".repeat(201) } }, "valid-key"),
|
|
240
|
+
trustedRequest({ submitter: { email: "e".repeat(321) } }, "valid-key"),
|
|
241
|
+
trustedRequest({}, "k".repeat(201)),
|
|
242
|
+
trustedRequest({ ignored: "x".repeat(20_000) }, "valid-key"),
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
const responses = await Promise.all(requests.map(handler));
|
|
246
|
+
|
|
247
|
+
expect(responses.map(({ status }) => status)).toEqual(
|
|
248
|
+
Array(requests.length).fill(400),
|
|
249
|
+
);
|
|
250
|
+
for (const response of responses) {
|
|
251
|
+
await expect(response.json()).resolves.toEqual({
|
|
252
|
+
error: "Trusted submission exceeds an accepted bound.",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
expect(notionRequest).not.toHaveBeenCalled();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not log or serialize API keys and private submission fields on failure", async () => {
|
|
259
|
+
const notionRequest = vi
|
|
260
|
+
.fn<typeof fetch>()
|
|
261
|
+
.mockResolvedValue(
|
|
262
|
+
Response.json(
|
|
263
|
+
{ object: "error", message: "private Notion diagnostic" },
|
|
264
|
+
{ status: 500 },
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
const consoleError = vi
|
|
268
|
+
.spyOn(console, "error")
|
|
269
|
+
.mockImplementation(() => {});
|
|
270
|
+
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
271
|
+
const handler = createTrustedFeedbackHandler({
|
|
272
|
+
...trustedHandlerSecurity("plaintext-api-key"),
|
|
273
|
+
feedback: createNotionFeedbackModule({
|
|
274
|
+
token: "notion-token",
|
|
275
|
+
dataSourceId: "feedback-data-source",
|
|
276
|
+
propertyIds,
|
|
277
|
+
request: notionRequest,
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
const request = new Request("https://feedback.example.com/api/v1/posts", {
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: {
|
|
283
|
+
Authorization: "Bearer plaintext-api-key",
|
|
284
|
+
"Content-Type": "application/json",
|
|
285
|
+
"Idempotency-Key": "private-external-id",
|
|
286
|
+
},
|
|
287
|
+
body: JSON.stringify({
|
|
288
|
+
title: "private title",
|
|
289
|
+
description: "private description",
|
|
290
|
+
type: "General Feedback",
|
|
291
|
+
submitter: { email: "private@example.com" },
|
|
292
|
+
}),
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const response = await handler(request);
|
|
296
|
+
const serialized = await response.text();
|
|
297
|
+
|
|
298
|
+
expect(response.status).toBe(502);
|
|
299
|
+
expect(serialized).toBe('{"error":"Trusted submission failed."}');
|
|
300
|
+
expect(serialized).not.toContain("plaintext-api-key");
|
|
301
|
+
expect(serialized).not.toContain("private title");
|
|
302
|
+
expect(serialized).not.toContain("private@example.com");
|
|
303
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
304
|
+
expect(consoleLog).not.toHaveBeenCalled();
|
|
305
|
+
consoleError.mockRestore();
|
|
306
|
+
consoleLog.mockRestore();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("rate-limits by API-key hash before any Notion request", async () => {
|
|
310
|
+
const notionRequest = vi.fn<typeof fetch>();
|
|
311
|
+
const rateLimiter = {
|
|
312
|
+
limit: vi.fn().mockResolvedValue({ success: false }),
|
|
313
|
+
};
|
|
314
|
+
const apiKeyHash = hashApiKey("correct-api-key");
|
|
315
|
+
const handler = createTrustedFeedbackHandler({
|
|
316
|
+
apiKeyHash,
|
|
317
|
+
rateLimiter,
|
|
318
|
+
limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
|
|
319
|
+
feedback: createNotionFeedbackModule({
|
|
320
|
+
token: "notion-token",
|
|
321
|
+
dataSourceId: "feedback-data-source",
|
|
322
|
+
propertyIds,
|
|
323
|
+
request: notionRequest,
|
|
324
|
+
}),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const response = await handler(trustedRequest({}, "valid-key"));
|
|
328
|
+
|
|
329
|
+
expect(response.status).toBe(429);
|
|
330
|
+
await expect(response.json()).resolves.toEqual({
|
|
331
|
+
error: "Trusted submission rate limit exceeded.",
|
|
332
|
+
});
|
|
333
|
+
expect(rateLimiter.limit).toHaveBeenCalledWith({ key: apiKeyHash });
|
|
334
|
+
expect(rateLimiter.limit).not.toHaveBeenCalledWith({
|
|
335
|
+
key: "correct-api-key",
|
|
336
|
+
});
|
|
337
|
+
expect(notionRequest).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
function trustedHandlerSecurity(apiKey: string) {
|
|
342
|
+
return {
|
|
343
|
+
apiKeyHash: hashApiKey(apiKey),
|
|
344
|
+
rateLimiter: {
|
|
345
|
+
limit: vi.fn().mockResolvedValue({ success: true }),
|
|
346
|
+
},
|
|
347
|
+
limits: { maxTitleLength: 160, maxDescriptionLength: 5_000 },
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function hashApiKey(apiKey: string): string {
|
|
352
|
+
return createHash("sha256").update(apiKey).digest("base64url");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function trustedRequest(
|
|
356
|
+
overrides: Record<string, unknown>,
|
|
357
|
+
idempotencyKey?: string,
|
|
358
|
+
): Request {
|
|
359
|
+
return new Request("https://feedback.example.com/api/v1/posts", {
|
|
360
|
+
method: "POST",
|
|
361
|
+
headers: {
|
|
362
|
+
Authorization: "Bearer correct-api-key",
|
|
363
|
+
"Content-Type": "application/json",
|
|
364
|
+
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
|
365
|
+
},
|
|
366
|
+
body: JSON.stringify({
|
|
367
|
+
title: "Keyboard navigation",
|
|
368
|
+
description: "Let users navigate without a mouse.",
|
|
369
|
+
type: "Feature Request",
|
|
370
|
+
submitter: { name: "Ama", email: "ama@example.com" },
|
|
371
|
+
...overrides,
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function notionPage({
|
|
377
|
+
title,
|
|
378
|
+
description,
|
|
379
|
+
type,
|
|
380
|
+
externalId,
|
|
381
|
+
}: {
|
|
382
|
+
title: string;
|
|
383
|
+
description: string;
|
|
384
|
+
type: "Feature Request" | "Bug Report" | "General Feedback";
|
|
385
|
+
externalId: string;
|
|
386
|
+
}) {
|
|
387
|
+
return {
|
|
388
|
+
object: "page",
|
|
389
|
+
id: "notion-page-id",
|
|
390
|
+
created_time: "2026-08-20T13:00:00.000Z",
|
|
391
|
+
last_edited_time: "2026-08-20T13:00:00.000Z",
|
|
392
|
+
properties: {
|
|
393
|
+
Title: { id: "title-id", title: [{ plain_text: title }] },
|
|
394
|
+
Slug: {
|
|
395
|
+
id: "slug-id",
|
|
396
|
+
rich_text: [{ plain_text: "keyboard-navigation" }],
|
|
397
|
+
},
|
|
398
|
+
Description: {
|
|
399
|
+
id: "description-id",
|
|
400
|
+
rich_text: [{ plain_text: description }],
|
|
401
|
+
},
|
|
402
|
+
Type: { id: "type-id", select: { name: type } },
|
|
403
|
+
Status: { id: "status-id", select: { name: "New" } },
|
|
404
|
+
Published: { id: "published-id", checkbox: false },
|
|
405
|
+
"Vote Count": { id: "vote-count-id", number: 0 },
|
|
406
|
+
"Team Response": { id: "team-response-id", rich_text: [] },
|
|
407
|
+
"Release Notes URL": { id: "release-notes-url-id", url: null },
|
|
408
|
+
"Submitter Name": {
|
|
409
|
+
id: "submitter-name-id",
|
|
410
|
+
rich_text: [{ plain_text: "Ama" }],
|
|
411
|
+
},
|
|
412
|
+
"Submitter Email": {
|
|
413
|
+
id: "submitter-email-id",
|
|
414
|
+
email: "ama@example.com",
|
|
415
|
+
},
|
|
416
|
+
Source: { id: "source-id", select: { name: "API" } },
|
|
417
|
+
"External ID": {
|
|
418
|
+
id: "external-id",
|
|
419
|
+
rich_text: [{ plain_text: externalId }],
|
|
420
|
+
},
|
|
421
|
+
"Edit Token Hash": { id: "edit-token-hash-id", rich_text: [] },
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
import { createHash } from "node:crypto";
|