From 0fbdc73cde4dbf1d56daee855238adf027dcc3c9 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:12:26 +0200 Subject: [PATCH] [#206] Add opt-in checked ticket references --- AUTHORS | 1 + doc/manual/en/97-acknowledgement.md | 1 + src/frontend/.env.example | 4 + src/frontend/README.md | 55 ++++ src/frontend/package-lock.json | 10 + src/frontend/package.json | 6 +- .../components/layout/AuthenticatedHeader.tsx | 2 + .../tickets/OptionalTicketReferences.tsx | 37 +++ .../components/tickets/TicketReference.tsx | 49 ++++ .../tickets/TicketReferenceLookup.tsx | 66 +++++ .../src/pages/SupportTicketDetailPage.tsx | 2 + .../src/pages/TicketWorkbenchFormPage.tsx | 4 + src/frontend/src/utils/ticketReferences.ts | 58 ++++ src/frontend/tests/ticket-references.test.mjs | 247 ++++++++++++++++++ 14 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/components/tickets/OptionalTicketReferences.tsx create mode 100644 src/frontend/src/components/tickets/TicketReference.tsx create mode 100644 src/frontend/src/components/tickets/TicketReferenceLookup.tsx create mode 100644 src/frontend/src/utils/ticketReferences.ts create mode 100644 src/frontend/tests/ticket-references.test.mjs diff --git a/AUTHORS b/AUTHORS index 4c15c746..677c8adb 100644 --- a/AUTHORS +++ b/AUTHORS @@ -12,3 +12,4 @@ Omar Goher Rawan Essam Huda Amr AbdElrhman Mohamed +PerchAnton <101743951+PerchAnton@users.noreply.github.com> diff --git a/doc/manual/en/97-acknowledgement.md b/doc/manual/en/97-acknowledgement.md index f50f2c9a..976de486 100644 --- a/doc/manual/en/97-acknowledgement.md +++ b/doc/manual/en/97-acknowledgement.md @@ -19,6 +19,7 @@ Omar Goher Rawan Essam Huda Amr AbdElrhman Mohamed +PerchAnton <101743951+PerchAnton@users.noreply.github.com> ``` ## Committers diff --git a/src/frontend/.env.example b/src/frontend/.env.example index 2c47a932..0ab3b3d7 100644 --- a/src/frontend/.env.example +++ b/src/frontend/.env.example @@ -4,3 +4,7 @@ VITE_KEYCLOAK_URL=http://localhost:8180 VITE_KEYCLOAK_REALM=billetsys VITE_KEYCLOAK_CLIENT_ID=billetsys-frontend + +# Experimental public ticket references; opt in at frontend build time. +# See README.md. Existing ticket names and numeric IDs remain unchanged. +VITE_TICKET_REFERENCES=false diff --git a/src/frontend/README.md b/src/frontend/README.md index f68dc766..55ff8c4d 100644 --- a/src/frontend/README.md +++ b/src/frontend/README.md @@ -196,3 +196,58 @@ The build output is written to `dist/`. Quarkus packages those files through Qui - In development, Quinoa proxies `/app/*` to the Vite dev server on port `5173`. - Top-level browser routes such as `/profile` are redirected by the backend to `/app/#...`, and `src/main.tsx` converts those locations back into normal client-side routes during app startup. - Production packaging uses the `dist/` output generated from this frontend project. The legacy generated folder under `src/backend/main/resources/META-INF/resources/app` should not be used as the active frontend source anymore. + +## Experimental ticket references + +This optional UI uses RefMint `0.1.0-alpha.3` to encode a ticket's existing positive +numeric ID with a checksum. It does not allocate another ID or add a database +column, migration, index, endpoint or mapping table. Ticket names, numeric URLs, +and the existing search remain unchanged. + +The controls are **off by default**. Set `VITE_TICKET_REFERENCES=true` in the +frontend build environment (or `.env` for local development), then rebuild or +restart Vite. Other values, including an unset variable, leave them hidden. +Disabled production builds omit the RefMint codec. Enabled builds load the +reference controls and codec on demand. +With it enabled, an existing ticket detail/form shows a copyable reference, and +the authenticated header for support, superuser, TAM and user accepts that reference in a separate **Open** field. +The decoder checks the code locally before opening the current role's existing +ticket route. Invalid input stays in the form with an error toast. Copy success +and clipboard errors use the application's toast notifications too. + +The namespace is `btk`, codec kind `uint64`, format version `1`. Do not change +these after sharing references without planning compatibility. IDs must be +positive signed Java Long values. Exact decimal strings are supported through +`9223372036854775807`; unsafe JSON numbers are not displayed as references, +because their original value may already have been rounded by JavaScript. + +A reference is **public, reversible and enumerable**. Its checksum helps detect +manual-entry mistakes; it is not encryption, authorization, an invitation, or a +secret ticket URL. Existing server authentication, role, company and participant +checks decide access. A valid code can still refer to a missing or inaccessible +ticket. Codes also do not identify an installation: use them only with the +Billetsys instance where they were issued. + +RefMint is still alpha and is pinned exactly. Its v1 wire contract does not make +the package API or maintenance stable. Adopting it requires dependency and +format-lifecycle review. References are longer than small ticket numbers; no +user study has established faster lookup or a practical benefit for Billetsys. +Keep the feature disabled if the existing ticket names and search are sufficient. +The dependency is installed even when the UI is disabled. + +Use Node.js 26 from `.nvmrc` for frontend checks (also used in CI). The dependency requires +Node.js 22 or newer, and the tests use Node's native TypeScript support: + +```bash +npm ci +npm run test:references +npm run check +npm run build +``` + +The focused tests cover the four ticket-viewing roles and rejection of admin +(which has no ticket API permission), exact large IDs, input formatting, +checksum errors, foreign codes and malformed input. They do not replace a real +Keycloak/PostgreSQL permissions test. Before enabling the feature in production, +check missing tickets, denied tickets, cross-company access, and normal access +with each ticket-viewing role in the deployment's existing authorization setup. diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 5468ae75..dbb39137 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -54,6 +54,7 @@ "react-markdown": "^10.1.0", "react-phone-number-input": "^3.4.16", "react-router-dom": "^7.8.2", + "refmint": "0.1.0-alpha.3", "rehype-highlight": "^7.0.2", "shadcn": "^4.2.0", "sonner": "^2.0.7", @@ -11219,6 +11220,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/refmint": { + "version": "0.1.0-alpha.3", + "resolved": "https://registry.npmjs.org/refmint/-/refmint-0.1.0-alpha.3.tgz", + "integrity": "sha512-PPmQ026pZkFzgt2eTt4L+ppsbcpXxeqonOKWoSVoZZKO9dVEm+jUwHZjJteRmfC3+G80fp9EhcQzpfSTaSExjQ==", + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22" + } + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index 64d145cf..be1477f8 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -14,8 +14,9 @@ "lint": "eslint .", "format": "prettier --write .", "fix": "prettier --write . && eslint . --fix", - "check": "npm run lint && npm run typecheck && prettier --check .", - "prepare": "cd ../.. && husky" + "check": "npm run test:references && npm run lint && npm run typecheck && prettier --check .", + "prepare": "cd ../.. && husky", + "test:references": "node --test tests/ticket-references.test.mjs" }, "lint-staged": { "src/**/*.{ts,tsx}": [ @@ -73,6 +74,7 @@ "react-markdown": "^10.1.0", "react-phone-number-input": "^3.4.16", "react-router-dom": "^7.8.2", + "refmint": "0.1.0-alpha.3", "rehype-highlight": "^7.0.2", "shadcn": "^4.2.0", "sonner": "^2.0.7", diff --git a/src/frontend/src/components/layout/AuthenticatedHeader.tsx b/src/frontend/src/components/layout/AuthenticatedHeader.tsx index eeaa1880..8f57391d 100644 --- a/src/frontend/src/components/layout/AuthenticatedHeader.tsx +++ b/src/frontend/src/components/layout/AuthenticatedHeader.tsx @@ -6,6 +6,7 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ +import { TicketReferenceLookup } from "../tickets/OptionalTicketReferences"; import { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import useJson from "../../hooks/useJson"; @@ -385,6 +386,7 @@ export default function AuthenticatedHeader({
{session?.authenticated && ( <> +
diff --git a/src/frontend/src/components/tickets/OptionalTicketReferences.tsx b/src/frontend/src/components/tickets/OptionalTicketReferences.tsx new file mode 100644 index 00000000..8fb0a87a --- /dev/null +++ b/src/frontend/src/components/tickets/OptionalTicketReferences.tsx @@ -0,0 +1,37 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import { lazy, Suspense } from "react"; +import type { Id, Role } from "../../types/app"; + +const Reference = + import.meta.env.VITE_TICKET_REFERENCES === "true" + ? lazy(() => import("./TicketReference")) + : null; +const Lookup = + import.meta.env.VITE_TICKET_REFERENCES === "true" + ? lazy(() => import("./TicketReferenceLookup")) + : null; + +/** Load the optional reference display only when the build enables it. */ +export function TicketReference({ ticketId }: { ticketId: Id }) { + return Reference ? ( + + + + ) : null; +} + +/** Load the optional lookup without adding its codec to disabled builds. */ +export function TicketReferenceLookup({ role }: { role?: Role }) { + return Lookup ? ( + + + + ) : null; +} diff --git a/src/frontend/src/components/tickets/TicketReference.tsx b/src/frontend/src/components/tickets/TicketReference.tsx new file mode 100644 index 00000000..fadba8bc --- /dev/null +++ b/src/frontend/src/components/tickets/TicketReference.tsx @@ -0,0 +1,49 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import { useId } from "react"; +import { toast } from "sonner"; +import type { Id } from "../../types/app"; +import { formatTicketReference } from "../../utils/ticketReferences"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +/** An opt-in display alias; existing ticket names, links and stored IDs stay primary. */ +export default function TicketReference({ ticketId }: { ticketId: Id }) { + const inputId = useId(); + if (import.meta.env.VITE_TICKET_REFERENCES !== "true") return null; + const reference = formatTicketReference(ticketId); + if (!reference) return null; + + async function copy(value: string) { + try { + await navigator.clipboard.writeText(value); + toast.success("Ticket reference copied."); + } catch { + toast.error("Select the reference above and copy it manually."); + } + } + + return ( +
+ +
+ + +
+
+ ); +} diff --git a/src/frontend/src/components/tickets/TicketReferenceLookup.tsx b/src/frontend/src/components/tickets/TicketReferenceLookup.tsx new file mode 100644 index 00000000..a1bbfd66 --- /dev/null +++ b/src/frontend/src/components/tickets/TicketReferenceLookup.tsx @@ -0,0 +1,66 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import { useState } from "react"; +import { toast } from "sonner"; +import { useNavigate } from "react-router-dom"; +import type { Role } from "../../types/app"; +import { ticketReferencePath } from "../../utils/ticketReferences"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +/** Check a manually entered alias before navigating to an existing protected route. */ +export default function TicketReferenceLookup({ role }: { role?: Role }) { + const [value, setValue] = useState(""); + const [invalid, setInvalid] = useState(false); + const navigate = useNavigate(); + if (import.meta.env.VITE_TICKET_REFERENCES !== "true") return null; + if (!["support", "superuser", "tam", "user"].includes(role || "")) + return null; + + return ( +
{ + event.preventDefault(); + const path = ticketReferencePath(value, role); + if (!path) { + setInvalid(true); + toast.error( + "Invalid ticket reference. Check the code and try again.", + ); + return; + } + setInvalid(false); + setValue(""); + navigate(path); + }} + > +
+ { + setValue(event.target.value); + setInvalid(false); + }} + className="w-44 font-mono text-foreground" + /> + +
+
+ ); +} diff --git a/src/frontend/src/pages/SupportTicketDetailPage.tsx b/src/frontend/src/pages/SupportTicketDetailPage.tsx index d4b5bb52..3da91139 100644 --- a/src/frontend/src/pages/SupportTicketDetailPage.tsx +++ b/src/frontend/src/pages/SupportTicketDetailPage.tsx @@ -10,6 +10,7 @@ import type { ChangeEvent, FormEvent } from "react"; import { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; +import { TicketReference } from "../components/tickets/OptionalTicketReferences"; import DataState from "../components/common/DataState"; import PageHeader from "../components/layout/PageHeader"; import MarkdownContent from "../components/markdown/MarkdownContent"; @@ -775,6 +776,7 @@ export default function SupportTicketDetailPage({ )} +
diff --git a/src/frontend/src/pages/TicketWorkbenchFormPage.tsx b/src/frontend/src/pages/TicketWorkbenchFormPage.tsx index 8a151a20..dbeb96b0 100644 --- a/src/frontend/src/pages/TicketWorkbenchFormPage.tsx +++ b/src/frontend/src/pages/TicketWorkbenchFormPage.tsx @@ -10,6 +10,7 @@ import type { FormEvent } from "react"; import { useEffect, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; +import { TicketReference } from "../components/tickets/OptionalTicketReferences"; import DataState from "../components/common/DataState"; import PageHeader from "../components/layout/PageHeader"; import CustomFieldsFields from "../components/tickets/CustomFieldsFields"; @@ -272,6 +273,9 @@ export default function TicketWorkbenchFormPage(props: SessionPageProps) { > {bootstrap && formState && ( + {bootstrap.ticket?.id != null && ( + + )} diff --git a/src/frontend/src/utils/ticketReferences.ts b/src/frontend/src/utils/ticketReferences.ts new file mode 100644 index 00000000..ea31cc17 --- /dev/null +++ b/src/frontend/src/utils/ticketReferences.ts @@ -0,0 +1,58 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import { createReferenceCodec } from "refmint/codec"; +import { PATHS } from "../routes/paths"; +import type { Id, Role } from "../types/app"; + +// This public, reversible alias is not an access token. Keep the namespace and +// v1 contract stable once an installation has shared a reference. +const tickets = createReferenceCodec({ + prefix: "btk", + kind: "uint64", + version: 1, +}); +const MAX_TICKET_ID = (1n << 63n) - 1n; + +/** Encode the existing positive Java Long ID without rounding a JSON number. */ +export function formatTicketReference(id: Id): string | null { + if (typeof id === "number" && (!Number.isSafeInteger(id) || id <= 0)) { + return null; + } + const decimal = String(id); + if (decimal.length > 19 || !/^[1-9][0-9]*$/.test(decimal)) { + return null; + } + const value = BigInt(decimal); + return value <= MAX_TICKET_ID ? tickets.format(tickets.encode(value)) : null; +} + +/** Resolve only checked references, preserving the exact decimal ID in the URL. */ +export function ticketReferencePath( + input: unknown, + role?: Role, +): string | null { + const decoded = tickets.decode(input); + if (!decoded.ok || decoded.value <= 0n || decoded.value > MAX_TICKET_ID) { + return null; + } + const id = decoded.value.toString(); + // These are the existing authenticated routes. Their server-side role, + // company and participant checks remain the authority for ticket access. + switch (role) { + case "support": + return `${PATHS.supportTickets}/${id}`; + case "superuser": + return `${PATHS.superuserTickets}/${id}`; + case "tam": + case "user": + return `${PATHS.userTickets}/${id}`; + default: + return null; + } +} diff --git a/src/frontend/tests/ticket-references.test.mjs b/src/frontend/tests/ticket-references.test.mjs new file mode 100644 index 00000000..fbf1ca4a --- /dev/null +++ b/src/frontend/tests/ticket-references.test.mjs @@ -0,0 +1,247 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { build, createServer } from "vite"; +import { createReferenceCodec } from "refmint/codec"; + +const utilityServer = await createServer({ + server: { middlewareMode: true, watch: null, hmr: false }, + appType: "custom", +}); +let formatTicketReference; +let ticketReferencePath; +try { + ({ formatTicketReference, ticketReferencePath } = + await utilityServer.ssrLoadModule("/src/utils/ticketReferences.ts")); +} finally { + await utilityServer.close(); +} + +const codec = createReferenceCodec({ + prefix: "btk", + kind: "uint64", + version: 1, +}); + +for (const enabled of [false, true]) { + test(`production bundle includes RefMint only when enabled (${enabled})`, async () => { + const bundle = await build({ + logLevel: "silent", + define: { + "import.meta.env.VITE_TICKET_REFERENCES": JSON.stringify( + String(enabled), + ), + }, + build: { write: false, minify: false }, + }); + const chunks = bundle.output.filter((entry) => entry.type === "chunk"); + const codecChunks = chunks.filter((chunk) => + Object.entries(chunk.modules).some( + ([id, module]) => + id.includes("/node_modules/refmint/") && module.renderedLength > 0, + ), + ); + assert.equal(codecChunks.length > 0, enabled); + const eagerChunks = new Set(); + function visitStaticImports(fileName) { + if (eagerChunks.has(fileName)) return; + eagerChunks.add(fileName); + const chunk = chunks.find((entry) => entry.fileName === fileName); + for (const dependency of chunk?.imports || []) + visitStaticImports(dependency); + } + for (const entry of chunks.filter((chunk) => chunk.isEntry)) { + visitStaticImports(entry.fileName); + } + for (const chunk of codecChunks) { + assert.equal(eagerChunks.has(chunk.fileName), false); + } + }); +} + +test("existing ticket IDs round trip without an allocation or mapping table", () => { + for (const id of [ + 1, + 42, + Number.MAX_SAFE_INTEGER, + "9007199254740993", + "9223372036854775807", + ]) { + const reference = formatTicketReference(id); + assert.equal(typeof reference, "string"); + assert.equal(ticketReferencePath(reference, "user"), `/user/tickets/${id}`); + assert.equal( + ticketReferencePath(codec.encode(BigInt(id)), "user"), + `/user/tickets/${id}`, + ); + } + // A stable vector catches accidental changes to the namespace or wire format. + assert.equal(formatTicketReference(42), "btk_1pqp2-cy59-jf"); +}); + +test("a reference opens the existing ticket route for each ticket-viewing role", () => { + const reference = formatTicketReference(42); + for (const [role, expected] of [ + ["support", "/support/tickets/42"], + ["superuser", "/superuser/tickets/42"], + ["tam", "/user/tickets/42"], + ["user", "/user/tickets/42"], + ]) { + assert.equal(ticketReferencePath(reference, role), expected); + } + for (const role of [undefined, "", "admin", "external", "administrator"]) { + assert.equal(ticketReferencePath(reference, role), null); + } +}); + +test("accepted presentation changes preserve the ID", () => { + const raw = codec.encode(42n); + for (const input of [ + raw, + raw.toUpperCase(), + codec.format(raw), + ` ${raw} `, + ]) { + assert.equal(ticketReferencePath(input, "support"), "/support/tickets/42"); + } +}); + +test("manual single-symbol errors never resolve to a different ticket", () => { + const raw = codec.encode(12345n); + const symbols = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + for (let i = raw.indexOf("1") + 1; i < raw.length; i++) { + for (const replacement of symbols) { + if (replacement === raw[i]) continue; + const typo = raw.slice(0, i) + replacement + raw.slice(i + 1); + assert.equal(ticketReferencePath(typo, "support"), null, typo); + } + } +}); + +test("foreign references and IDs outside the database domain are rejected", () => { + const other = createReferenceCodec({ prefix: "other", kind: "uint64" }); + const uuid = createReferenceCodec({ prefix: "btk", kind: "uuid" }); + for (const input of [ + other.encode(42n), + uuid.encode("00000000-0000-0000-0000-00000000002a"), + codec.encode(0n), + codec.encode(1n << 63n), + ]) { + assert.equal(ticketReferencePath(input, "user"), null); + } +}); + +test("malformed input cannot become a route or an uncaught exception", () => { + for (const input of [ + null, + undefined, + 42, + {}, + [], + "", + "42", + "../../tickets/42", + "https://example.com", + "x".repeat(10000), + "btk", + "btk_1💥", + " btk_1q ", + ]) { + assert.equal(ticketReferencePath(input, "user"), null); + } +}); + +test("unsafe JSON numbers are never advertised as a reference for another ID", () => { + for (const id of [ + Number.MAX_SAFE_INTEGER + 1, + Infinity, + NaN, + 1.5, + 0, + -1, + "0", + "01", + "-1", + "1.0", + "1e3", + " 42 ", + "9223372036854775808", + "9".repeat(10000), + ]) { + assert.equal(formatTicketReference(id), null); + } +}); + +// Exercise the actual components through the project's existing Vite compiler, +// without adding another test framework or changing the app's authentication. +for (const enabled of [false, true]) { + test(`reference controls respect the build flag (${enabled}) and roles`, async () => { + const server = await createServer({ + server: { middlewareMode: true, watch: null, hmr: false }, + optimizeDeps: { noDiscovery: true, include: [] }, + appType: "custom", + define: { + "import.meta.env.VITE_TICKET_REFERENCES": JSON.stringify( + String(enabled), + ), + }, + plugins: [ + { + name: "ticket-reference-test-renderer", + resolveId(id) { + if (id === "virtual:ticket-reference-test") return "\0" + id; + }, + load(id) { + if (id !== "\0virtual:ticket-reference-test") return; + return ` + import { createElement as h } from 'react'; + import { renderToReadableStream } from 'react-dom/server'; + import { MemoryRouter } from 'react-router-dom'; + import { TicketReference, TicketReferenceLookup } from '/src/components/tickets/OptionalTicketReferences.tsx'; + async function render(element) { + const stream = await renderToReadableStream(element); + await stream.allReady; + return (await new Response(stream).text()).replace(//g, ''); + } + export async function display(ticketId) { + return render(h(TicketReference, { ticketId })); + } + export async function lookup(role) { + return render(h(MemoryRouter, null, h(TicketReferenceLookup, { role }))); + } + `; + }, + }, + ], + }); + try { + const renderer = await server.ssrLoadModule( + "virtual:ticket-reference-test", + ); + const display = await renderer.display(42); + if (enabled) { + assert.ok(display.includes(formatTicketReference(42))); + assert.match(display, /readonly/i); + assert.match(display, /type="button"/); // Copy must not submit the edit form. + } else assert.equal(display, ""); + assert.equal(await renderer.display(Number.MAX_SAFE_INTEGER + 1), ""); + for (const role of ["support", "superuser", "tam", "user"]) { + const lookup = await renderer.lookup(role); + if (enabled) assert.match(lookup, /Find ticket by reference/); + else assert.equal(lookup, ""); + } + for (const role of [undefined, "admin", "external", "unknown"]) { + assert.equal(await renderer.lookup(role), ""); + } + } finally { + await server.close(); + } + }); +}