@wenathlan/devthink 2.0.12 → 2.0.14
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/CHANGELOG.md +20 -0
- package/accounts.ts +2087 -0
- package/agent.ts +2331 -0
- package/antigravity.ts +2139 -0
- package/apifreeze.ts +4146 -0
- package/auth.ts +642 -0
- package/background.ts +16837 -0
- package/bridge.ts +2080 -0
- package/build.ts +54 -0
- package/bun.ts +26 -0
- package/capture.ts +1614 -0
- package/checksecrets.ts +72 -0
- package/cjs.ts +9 -0
- package/cli.ts +3500 -0
- package/commands.ts +1101 -0
- package/companion.ts +450 -0
- package/compatibility.ts +37 -0
- package/config.ts +3137 -0
- package/constants.ts +943 -0
- package/core.ts +1473 -0
- package/crossbrowser.ts +1359 -0
- package/dashdone.ts +605 -0
- package/data.ts +943 -0
- package/database.ts +133 -0
- package/debug.ts +850 -0
- package/deno.ts +27 -0
- package/devthink.ts +707 -0
- package/dist/accounts.d.ts +409 -0
- package/dist/agent.d.ts +837 -0
- package/dist/antigravity.d.ts +380 -0
- package/dist/apifreeze.d.ts +113 -0
- package/dist/auth.d.ts +224 -0
- package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
- package/dist/capture.d.ts +595 -0
- package/dist/cjs.d.ts +6 -0
- package/{cli.js → dist/cli.js} +2 -2
- package/dist/commands.d.ts +310 -0
- package/dist/compatibility.d.ts +17 -0
- package/dist/config.d.ts +1853 -0
- package/dist/constants.d.ts +637 -0
- package/dist/core.d.ts +361 -0
- package/dist/data.d.ts +330 -0
- package/dist/database.d.ts +24 -0
- package/dist/debug.d.ts +270 -0
- package/dist/engine.d.ts +74 -0
- package/dist/environments.d.ts +244 -0
- package/dist/evidence.d.ts +277 -0
- package/dist/export.d.ts +311 -0
- package/dist/fingerprint.d.ts +594 -0
- package/dist/flow.d.ts +300 -0
- package/dist/gates.d.ts +103 -0
- package/dist/identity.d.ts +51 -0
- package/dist/ids.d.ts +7 -0
- package/dist/llm.d.ts +652 -0
- package/dist/models.d.ts +1807 -0
- package/dist/net.d.ts +325 -0
- package/dist/neutral.d.ts +41 -0
- package/dist/oauth.d.ts +703 -0
- package/dist/page.d.ts +1166 -0
- package/dist/perf.d.ts +481 -0
- package/dist/plan.d.ts +134 -0
- package/dist/project.d.ts +395 -0
- package/dist/providers.d.ts +37 -0
- package/dist/quota.d.ts +453 -0
- package/dist/recovery.d.ts +621 -0
- package/dist/request.d.ts +1492 -0
- package/dist/run.d.ts +779 -0
- package/dist/runtime.d.ts +212 -0
- package/dist/search.d.ts +375 -0
- package/dist/security.d.ts +534 -0
- package/dist/serve.d.ts +136 -0
- package/dist/session.d.ts +296 -0
- package/dist/storage.d.ts +53 -0
- package/dist/streaming.d.ts +1119 -0
- package/dist/swarm.d.ts +328 -0
- package/dist/tools.d.ts +170 -0
- package/dist/types.d.ts +8357 -0
- package/dist/utils.d.ts +82 -0
- package/dist/version.d.ts +7 -0
- package/dist/versionregistry.d.ts +406 -0
- package/dist/views.d.ts +579 -0
- package/dist/workbenchsession.d.ts +69 -0
- package/dist/workflow.d.ts +649 -0
- package/docs/readme.md +92 -0
- package/docs/schemas/antigravity.json +192 -0
- package/engine.ts +1524 -0
- package/environments.ts +1005 -0
- package/evidence.ts +643 -0
- package/export.ts +889 -0
- package/fingerprint.ts +1458 -0
- package/flow.ts +1190 -0
- package/gates.ts +307 -0
- package/gateway.ts +34 -0
- package/hardening.ts +191 -0
- package/headless.ts +369 -0
- package/http.ts +2110 -0
- package/identity.ts +246 -0
- package/ids.ts +26 -0
- package/index.ts +369 -0
- package/llm.ts +2682 -0
- package/mcp.ts +1835 -0
- package/{memory.js → memory.ts} +3834 -2310
- package/models.ts +3805 -0
- package/modes.ts +43 -0
- package/net.ts +1214 -0
- package/neutral.ts +362 -0
- package/node.ts +26 -0
- package/oauth.ts +2216 -0
- package/offscreen.ts +170 -0
- package/pack.ts +956 -0
- package/package.json +175 -82
- package/page.ts +7824 -0
- package/pagebridge.ts +1116 -0
- package/perf.ts +957 -0
- package/plan.ts +672 -0
- package/plugin.ts +76 -0
- package/{policy.js → policy.ts} +5073 -4679
- package/prisma.config.ts +27 -0
- package/progress.ts +1308 -0
- package/project.ts +1291 -0
- package/protocol.ts +4187 -0
- package/providers.ts +246 -0
- package/quota.ts +1887 -0
- package/recovery.ts +2433 -0
- package/request.ts +4482 -0
- package/run.ts +2135 -0
- package/runtime.ts +526 -0
- package/search.ts +1760 -0
- package/security.ts +1673 -0
- package/serve.ts +419 -0
- package/server.ts +5538 -0
- package/session.ts +1162 -0
- package/storage.ts +182 -0
- package/streaming.ts +4101 -0
- package/swarm.ts +884 -0
- package/sync.ts +56 -0
- package/tests/agentcert.test.ts +128 -0
- package/tests/agentfleet.test.ts +214 -0
- package/tests/agentreview.test.ts +241 -0
- package/tests/agentstream.test.ts +335 -0
- package/tests/agentwork.test.ts +356 -0
- package/tests/apifreeze.test.ts +290 -0
- package/tests/apimap.test.ts +112 -0
- package/tests/approvalgate.test.ts +175 -0
- package/tests/artifactmanifest.test.ts +204 -0
- package/tests/attentionfeed.test.ts +163 -0
- package/tests/backgroundruns.test.ts +100 -0
- package/tests/batchscheduling.test.ts +131 -0
- package/tests/blackboard.test.ts +238 -0
- package/tests/bridge.test.ts +1088 -0
- package/tests/browsercoverage.test.ts +134 -0
- package/tests/browserpolyfills.test.ts +124 -0
- package/tests/build.mjs +1260 -0
- package/tests/clientauth.test.ts +223 -0
- package/tests/clitools.test.ts +578 -0
- package/tests/confirmgates.test.ts +190 -0
- package/tests/containerpack.test.ts +143 -0
- package/tests/controlflow.test.ts +2120 -0
- package/tests/coordination.test.ts +345 -0
- package/tests/copyscreen.test.ts +139 -0
- package/tests/core.test.ts +417 -0
- package/tests/costcert.test.ts +95 -0
- package/tests/crossbrowser.test.ts +115 -0
- package/tests/cspaudit.test.ts +79 -0
- package/tests/dashdone.test.ts +615 -0
- package/tests/datagrid.test.ts +137 -0
- package/tests/doccheck.test.ts +107 -0
- package/tests/dombatch.test.ts +109 -0
- package/tests/emulation.test.ts +282 -0
- package/tests/environments.test.ts +259 -0
- package/tests/evidenceviews.test.ts +88 -0
- package/tests/exporttools.test.ts +185 -0
- package/tests/firefoxprep.test.ts +134 -0
- package/tests/flowlibrary.test.ts +242 -0
- package/tests/flowrun.test.ts +303 -0
- package/tests/forensics.test.ts +439 -0
- package/tests/gateway.test.ts +939 -0
- package/tests/headlesslib.test.ts +171 -0
- package/tests/httpstream.test.ts +277 -0
- package/tests/immutablelog.test.ts +157 -0
- package/tests/inboundguard.test.ts +177 -0
- package/tests/lazyload.test.ts +72 -0
- package/tests/librarymodes.test.ts +623 -0
- package/tests/llm.test.ts +696 -0
- package/tests/manifest.test.ts +226 -0
- package/tests/maskinputs.test.ts +150 -0
- package/tests/matrixverify.test.ts +111 -0
- package/tests/mavenpack.test.ts +75 -0
- package/tests/mcpserver.test.ts +704 -0
- package/tests/memory.test.ts +4155 -0
- package/tests/memorycare.test.ts +230 -0
- package/tests/migrateplan.test.ts +349 -0
- package/tests/minimization.test.ts +414 -0
- package/tests/modelroute.test.ts +112 -0
- package/tests/multiagent.test.ts +440 -0
- package/tests/nativebridge.test.ts +807 -0
- package/tests/navigation.test.ts +284 -0
- package/tests/nugetpack.test.ts +83 -0
- package/tests/orchestration.test.ts +449 -0
- package/tests/originpolicy.test.ts +405 -0
- package/tests/outputcompare.test.ts +97 -0
- package/tests/package.test.ts +91 -0
- package/tests/pagebridge.test.ts +5909 -0
- package/tests/pentest.test.ts +75 -0
- package/tests/perfrecords.test.ts +257 -0
- package/tests/permdiff.test.ts +85 -0
- package/tests/phishguard.test.ts +128 -0
- package/tests/pickerviews.test.ts +155 -0
- package/tests/pipelines.test.ts +381 -0
- package/tests/planlint.test.ts +240 -0
- package/tests/planreview.test.ts +383 -0
- package/tests/platformtargets.test.ts +56 -0
- package/tests/policy.test.ts +8284 -0
- package/tests/portability.test.ts +96 -0
- package/tests/progress.test.ts +1036 -0
- package/tests/promptlibrary.test.ts +116 -0
- package/tests/protocol.test.ts +4793 -0
- package/tests/publishingpipeline.test.ts +314 -0
- package/tests/quickactions.test.ts +164 -0
- package/tests/readiness.test.ts +78 -0
- package/tests/recipes.test.ts +163 -0
- package/tests/redactshots.test.ts +216 -0
- package/tests/registernvidiakeys.ts +51 -0
- package/tests/relayserve.test.ts +304 -0
- package/tests/releasenotes.test.ts +276 -0
- package/tests/resilience.test.ts +467 -0
- package/tests/resourceaware.test.ts +187 -0
- package/tests/runbudget.test.ts +125 -0
- package/tests/runhistory.test.ts +299 -0
- package/tests/runreplay.test.ts +113 -0
- package/tests/runresume.test.ts +184 -0
- package/tests/runstate.test.ts +288 -0
- package/tests/runtimeadapters.test.ts +251 -0
- package/tests/safariskeleton.test.ts +115 -0
- package/tests/sandboxframe.test.ts +196 -0
- package/tests/sbom.test.ts +105 -0
- package/tests/secretvault.test.ts +188 -0
- package/tests/selcache.test.ts +111 -0
- package/tests/sessioninterface.test.ts +791 -0
- package/tests/sessions.test.ts +591 -0
- package/tests/siteprefs.test.ts +228 -0
- package/tests/snapshotdelta.test.ts +165 -0
- package/tests/statusviews.test.ts +237 -0
- package/tests/stepmeter.test.ts +200 -0
- package/tests/streamparse.test.ts +163 -0
- package/tests/stylepruning.test.ts +90 -0
- package/tests/surfaces.test.ts +282 -0
- package/tests/sweep.test.ts +78 -0
- package/tests/syncbridge.test.ts +164 -0
- package/tests/taskqueue.test.ts +206 -0
- package/tests/toolcalls.test.ts +336 -0
- package/tests/toolcatalog.test.ts +201 -0
- package/tests/torture-agent.test.ts +706 -0
- package/tests/torture-auth.test.ts +461 -0
- package/tests/torture-commands.test.ts +895 -0
- package/tests/torture-data.test.ts +923 -0
- package/tests/torture-environments.test.ts +285 -0
- package/tests/torture-export.test.ts +263 -0
- package/tests/torture-http.test.ts +1379 -0
- package/tests/torture-llm.test.ts +808 -0
- package/tests/torture-memory.test.ts +1084 -0
- package/tests/torture-plan.test.ts +829 -0
- package/tests/torture-run.test.ts +626 -0
- package/tests/torture-session.test.ts +928 -0
- package/tests/torture-swarm.test.ts +1245 -0
- package/tests/torture-tools.test.ts +964 -0
- package/tests/torture-workflow.test.ts +2354 -0
- package/tests/tourviews.test.ts +52 -0
- package/tests/transparency.test.ts +147 -0
- package/tests/trigger.test.ts +941 -0
- package/tests/v1sunset.test.ts +428 -0
- package/tests/virtlist.test.ts +94 -0
- package/tests/vision.test.ts +540 -0
- package/tests/vsixpack.test.ts +145 -0
- package/tests/webapi.test.ts +523 -0
- package/tests/workflow.test.ts +1785 -0
- package/tests/workfloweditor.test.ts +759 -0
- package/tests/xpipack.test.ts +128 -0
- package/tools.ts +734 -0
- package/types.ts +9908 -0
- package/umd.ts +9 -0
- package/utils.ts +249 -0
- package/version.ts +8 -0
- package/versionregistry.ts +1310 -0
- package/views.ts +2153 -0
- package/vitest.config.ts +12 -0
- package/web/App.tsx +78 -0
- package/web/README.md +47 -0
- package/web/capacitor.config.ts +46 -0
- package/web/const.ts +19 -0
- package/web/control.shell.tsx +66 -0
- package/web/dashboardpage.ts +554 -0
- package/web/db.ts +493 -0
- package/web/design.html +541 -0
- package/web/gatewayview/Gateway.tsx +345 -0
- package/web/gatewayview/config.ts +1042 -0
- package/web/gatewayview/definition.ts +174 -0
- package/web/icons.ts +15 -0
- package/{manifest.json → web/manifest.json} +1 -1
- package/web/optionspage.ts +691 -0
- package/web/popup.ts +1651 -0
- package/web/schema.prisma +176 -0
- package/web/sidepanel.ts +7941 -0
- package/web/sitemanifest.json +13 -0
- package/web/terminal-ui.tsx +622 -0
- package/web/transparencypage.ts +541 -0
- package/web/vite.config.ts +39 -0
- package/web/workspace.ts +23 -0
- package/workbenchmemory.ts +104 -0
- package/workbenchsession.ts +304 -0
- package/workflow.ts +4495 -0
- package/bridge.d.ts.map +0 -1
- package/bridge.js +0 -1877
- package/bun.d.ts.map +0 -1
- package/bun.js +0 -52329
- package/caps/background.json +0 -712
- package/caps/cli.json +0 -360
- package/caps/library.json +0 -3611
- package/caps/mcp.json +0 -126
- package/caps/pagebridge.json +0 -377
- package/caps/popup.json +0 -51
- package/caps/sidepanel.json +0 -522
- package/checksums.txt +0 -142
- package/cli.d.ts.map +0 -1
- package/companion.d.ts.map +0 -1
- package/companion.js +0 -389
- package/crossbrowser.d.ts.map +0 -1
- package/crossbrowser.js +0 -1191
- package/dashdone.d.ts.map +0 -1
- package/dashdone.js +0 -368
- package/deno.d.ts.map +0 -1
- package/deno.js +0 -52325
- package/devthink.umd.js +0 -52346
- package/fixtures/bun.ts +0 -7
- package/fixtures/cjs.js +0 -6
- package/fixtures/deno.ts +0 -6
- package/fixtures/example-org-pagestate.json +0 -22
- package/fixtures/importers/automa-workflow.json +0 -18
- package/fixtures/importers/selenium-side.json +0 -20
- package/fixtures/importers/tabular-plan.csv +0 -7
- package/fixtures/importers/uivision-macro.json +0 -10
- package/fixtures/importers/v1-plan.json +0 -13
- package/fixtures/mcp-client.mjs +0 -14
- package/fixtures/pages/checkout.html +0 -192
- package/fixtures/pages/dashboard.html +0 -110
- package/fixtures/pages/feed.html +0 -113
- package/fixtures/pages/product-grid.html +0 -150
- package/fixtures/plans/form-inventory-plan.json +0 -11
- package/fixtures/plans/release-notes-plan.json +0 -11
- package/fixtures/recipes/agentscompete.json +0 -11
- package/fixtures/recipes/agentsforms.json +0 -11
- package/fixtures/recipes/agentsmonitor.json +0 -11
- package/fixtures/recipes/agentsreview.json +0 -29
- package/fixtures/recipes/agentswarm.json +0 -11
- package/fixtures/recipes/fillcalendar.json +0 -12
- package/fixtures/recipes/fillcheckout.json +0 -13
- package/fixtures/recipes/filldependentdropdowns.json +0 -12
- package/fixtures/recipes/fillfromcsv.json +0 -13
- package/fixtures/recipes/fillloginform.json +0 -12
- package/fixtures/recipes/fillmultistepwizard.json +0 -13
- package/fixtures/recipes/fillregistration.json +0 -12
- package/fixtures/recipes/fillsearchfilters.json +0 -12
- package/fixtures/recipes/filltypeahead.json +0 -11
- package/fixtures/recipes/monitoralerts.json +0 -12
- package/fixtures/recipes/monitoravailability.json +0 -11
- package/fixtures/recipes/monitornetworkquiet.json +0 -12
- package/fixtures/recipes/monitorpagechanges.json +0 -11
- package/fixtures/recipes/monitorpricechange.json +0 -11
- package/fixtures/recipes/monitorschedule.json +0 -12
- package/fixtures/recipes/scrapedynamicprices.json +0 -13
- package/fixtures/recipes/scrapeimagegallery.json +0 -12
- package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
- package/fixtures/recipes/scrapenestedlists.json +0 -12
- package/fixtures/recipes/scrapeproductgrid.json +0 -12
- package/fixtures/recipes/scrapereviews.json +0 -12
- package/fixtures/recipes/scrapesearchresults.json +0 -12
- package/fixtures/recipes/scrapetablepagination.json +0 -12
- package/fixtures/recipes/testaccessibility.json +0 -34
- package/fixtures/recipes/testconsoleerrors.json +0 -12
- package/fixtures/recipes/testdeeplinks.json +0 -12
- package/fixtures/recipes/testformvalidation.json +0 -13
- package/fixtures/recipes/testlayoutshift.json +0 -12
- package/fixtures/recipes/testlinkcheck.json +0 -12
- package/fixtures/recipes/testloadtiming.json +0 -12
- package/fixtures/recipes/testvisualdiff.json +0 -12
- package/fixtures/workflows/changelog-digest-workflow.json +0 -13
- package/fixtures/workflows/form-survey-workflow.json +0 -13
- package/gallery.json +0 -706
- package/hardening.d.ts.map +0 -1
- package/hardening.js +0 -188
- package/headless.d.ts.map +0 -1
- package/headless.js +0 -11522
- package/http.d.ts.map +0 -1
- package/http.js +0 -2165
- package/index.cjs +0 -54682
- package/index.d.ts.map +0 -1
- package/index.js +0 -52310
- package/index.neutral.js +0 -46343
- package/mcp.d.ts.map +0 -1
- package/mcp.js +0 -20515
- package/memory.d.ts.map +0 -1
- package/nativehost.template.json +0 -10
- package/node.cjs +0 -54686
- package/node.d.ts.map +0 -1
- package/pack.d.ts.map +0 -1
- package/pack.js +0 -805
- package/policy.d.ts.map +0 -1
- package/progress.d.ts.map +0 -1
- package/progress.js +0 -645
- package/protocol.d.ts.map +0 -1
- package/protocol.js +0 -8334
- package/schemas/audit.schema.json +0 -226
- package/schemas/capability.schema.json +0 -113
- package/schemas/envelope.schema.json +0 -162
- package/schemas/memory.schema.json +0 -51
- package/schemas/observation.schema.json +0 -113
- package/schemas/plan.schema.json +0 -208
- package/schemas/progress.schema.json +0 -126
- package/schemas/proposal.schema.json +0 -68
- package/schemas/session.schema.json +0 -82
- package/schemas/tool.schema.json +0 -94
- package/server.d.ts.map +0 -1
- package/server.js +0 -28877
- package/umd-example.html +0 -18
- package/umd.d.ts.map +0 -1
- /package/{bun.d.ts → dist/bun.d.ts} +0 -0
- /package/{cli.d.ts → dist/cli.d.ts} +0 -0
- /package/{companion.d.ts → dist/companion.d.ts} +0 -0
- /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
- /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
- /package/{deno.d.ts → dist/deno.d.ts} +0 -0
- /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
- /package/{headless.d.ts → dist/headless.d.ts} +0 -0
- /package/{http.d.ts → dist/http.d.ts} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
- /package/{memory.d.ts → dist/memory.d.ts} +0 -0
- /package/{node.d.ts → dist/node.d.ts} +0 -0
- /package/{pack.d.ts → dist/pack.d.ts} +0 -0
- /package/{policy.d.ts → dist/policy.d.ts} +0 -0
- /package/{progress.d.ts → dist/progress.d.ts} +0 -0
- /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
- /package/{server.d.ts → dist/server.d.ts} +0 -0
- /package/{umd.d.ts → dist/umd.d.ts} +0 -0
|
@@ -0,0 +1,1379 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
channellive,
|
|
4
|
+
closeidlechannels,
|
|
5
|
+
createlinepump,
|
|
6
|
+
defaultheartbeatms,
|
|
7
|
+
defaulthttpstream,
|
|
8
|
+
defaultidlewindowms,
|
|
9
|
+
enforcemaxclients,
|
|
10
|
+
fetchrequestof,
|
|
11
|
+
fetchoptionsof,
|
|
12
|
+
graphqlopenvelope,
|
|
13
|
+
graphqlrequestof,
|
|
14
|
+
httpanswer,
|
|
15
|
+
httpendpoint,
|
|
16
|
+
httpframepipeline,
|
|
17
|
+
openstreamchannel,
|
|
18
|
+
parsepost,
|
|
19
|
+
parsehtmlbody,
|
|
20
|
+
payloadvalid,
|
|
21
|
+
payloadwithdefaults,
|
|
22
|
+
readpath,
|
|
23
|
+
readstream,
|
|
24
|
+
relayconnectionclose,
|
|
25
|
+
relayconnectionopen,
|
|
26
|
+
relayframeof,
|
|
27
|
+
relayidleconnections,
|
|
28
|
+
relaylivetokens,
|
|
29
|
+
relaypendingpairings,
|
|
30
|
+
relayserverframe,
|
|
31
|
+
newrelayserverstate,
|
|
32
|
+
routepath,
|
|
33
|
+
sendfetch,
|
|
34
|
+
sseframe,
|
|
35
|
+
splitlines,
|
|
36
|
+
starttls,
|
|
37
|
+
statusclassof,
|
|
38
|
+
streampathof,
|
|
39
|
+
streamwindowof,
|
|
40
|
+
templateurl,
|
|
41
|
+
tlsdecision,
|
|
42
|
+
unwrapgraphql,
|
|
43
|
+
heartbeat,
|
|
44
|
+
listremotestatus,
|
|
45
|
+
} from "../http.js";
|
|
46
|
+
import { issuetoken, grantallowlistentry } from "../auth.js";
|
|
47
|
+
import { defaultmcpconfig, localhostbind } from "../mcp.js";
|
|
48
|
+
import type { clientrecord, fetchrequest, mcpserverconfig, streamchannel } from "../types.js";
|
|
49
|
+
import type { fetchtransport, transportresponse } from "../http.js";
|
|
50
|
+
|
|
51
|
+
const now = 1_800_000_000_000;
|
|
52
|
+
const instant = (): Promise<void> => Promise.resolve();
|
|
53
|
+
|
|
54
|
+
/** Builds one transport response fixture. */
|
|
55
|
+
function response(
|
|
56
|
+
status: number,
|
|
57
|
+
body = "",
|
|
58
|
+
headers: Record<string, string> = {},
|
|
59
|
+
location?: string,
|
|
60
|
+
): transportresponse {
|
|
61
|
+
return { status, headers, body, ...(location !== undefined ? { location } : {}) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Builds one allowlist fixture entry the pipeline trusts. */
|
|
65
|
+
async function pipelinefixture(): Promise<{
|
|
66
|
+
tokens: Awaited<ReturnType<typeof issuetoken>>["token"][];
|
|
67
|
+
raw: string;
|
|
68
|
+
allowlist: ReturnType<typeof grantallowlistentry>;
|
|
69
|
+
}> {
|
|
70
|
+
const issued = await issuetoken({ clientid: "client1", scopes: ["browser"], now, raw: "raw-token" });
|
|
71
|
+
const allowlist = grantallowlistentry({
|
|
72
|
+
entries: [],
|
|
73
|
+
identity: { fingerprint: "fp-1", displayname: "Laptop agent" },
|
|
74
|
+
namespaces: ["browser"],
|
|
75
|
+
actor: "user",
|
|
76
|
+
now,
|
|
77
|
+
});
|
|
78
|
+
return { tokens: [issued.token], raw: "raw-token", allowlist };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("torture: status class boundaries", () => {
|
|
82
|
+
it("grades the exact class edges and unknown out of range statuses", () => {
|
|
83
|
+
expect(statusclassof(100)).toBe("informational");
|
|
84
|
+
expect(statusclassof(199)).toBe("informational");
|
|
85
|
+
expect(statusclassof(200)).toBe("success");
|
|
86
|
+
expect(statusclassof(299)).toBe("success");
|
|
87
|
+
expect(statusclassof(300)).toBe("redirect");
|
|
88
|
+
expect(statusclassof(399)).toBe("redirect");
|
|
89
|
+
expect(statusclassof(400)).toBe("clienterror");
|
|
90
|
+
expect(statusclassof(499)).toBe("clienterror");
|
|
91
|
+
expect(statusclassof(500)).toBe("servererror");
|
|
92
|
+
expect(statusclassof(599)).toBe("servererror");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("refuses the out of range and hostile statuses as unknown", () => {
|
|
96
|
+
expect(statusclassof(0)).toBe("unknown");
|
|
97
|
+
expect(statusclassof(-1)).toBe("unknown");
|
|
98
|
+
expect(statusclassof(600)).toBe("unknown");
|
|
99
|
+
expect(statusclassof(99)).toBe("unknown");
|
|
100
|
+
expect(statusclassof(Number.NaN)).toBe("unknown");
|
|
101
|
+
expect(statusclassof(Number.POSITIVE_INFINITY)).toBe("unknown");
|
|
102
|
+
expect(statusclassof(200.5)).toBe("success");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("torture: url templating and request normalization", () => {
|
|
107
|
+
it("substitutes reviewed values while missing keys keep their placeholder", () => {
|
|
108
|
+
expect(templateurl("https://api.example/items/{id}", { id: "42" })).toBe("https://api.example/items/42");
|
|
109
|
+
expect(templateurl("https://api.example/items/{id}", {})).toBe("https://api.example/items/{id}");
|
|
110
|
+
expect(templateurl("https://api.example/{a}/{a}", { a: "x" })).toBe("https://api.example/x/x");
|
|
111
|
+
expect(templateurl("https://api.example/{ user }", { user: "x" })).toBe("https://api.example/{ user }");
|
|
112
|
+
expect(templateurl("https://api.example/{ID}", { id: "x" })).toBe("https://api.example/{ID}");
|
|
113
|
+
expect(templateurl("https://api.example/{}", {})).toBe("https://api.example/{}");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("refuses malformed fetch request shapes while normalizing the clean ones", () => {
|
|
117
|
+
expect(fetchrequestof(undefined)).toBeUndefined();
|
|
118
|
+
expect(fetchrequestof(null)).toBeUndefined();
|
|
119
|
+
expect(fetchrequestof([])).toBeUndefined();
|
|
120
|
+
expect(fetchrequestof("https://x.example")).toBeUndefined();
|
|
121
|
+
expect(fetchrequestof({ url: " " })).toBeUndefined();
|
|
122
|
+
expect(fetchrequestof({ url: 42 })).toBeUndefined();
|
|
123
|
+
expect(fetchrequestof({ url: "https://x.example", method: " " })).toEqual({ url: "https://x.example" });
|
|
124
|
+
expect(fetchrequestof({ url: " https://x.example ", method: " delete ", body: "x", mode: "cors" })).toEqual({
|
|
125
|
+
url: "https://x.example",
|
|
126
|
+
method: "DELETE",
|
|
127
|
+
body: "x",
|
|
128
|
+
mode: "cors",
|
|
129
|
+
});
|
|
130
|
+
expect(fetchrequestof({ url: "https://x.example", mode: "websocket" as never })).toEqual({
|
|
131
|
+
url: "https://x.example",
|
|
132
|
+
});
|
|
133
|
+
expect(fetchrequestof({ url: "https://x.example", headers: { keep: "1", drop: 2, alsodrop: null } })).toEqual({
|
|
134
|
+
url: "https://x.example",
|
|
135
|
+
headers: { keep: "1" },
|
|
136
|
+
});
|
|
137
|
+
expect(fetchrequestof({ url: "https://x.example", headers: [] })).toEqual({ url: "https://x.example" });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("normalizes fetch options and stream windows under hostile values", () => {
|
|
141
|
+
expect(fetchoptionsof(null)).toEqual({});
|
|
142
|
+
expect(fetchoptionsof([])).toEqual({});
|
|
143
|
+
expect(fetchoptionsof({ timeout: Number.POSITIVE_INFINITY })).toEqual({});
|
|
144
|
+
expect(fetchoptionsof({ timeout: "100" as never })).toEqual({});
|
|
145
|
+
expect(fetchoptionsof({ retries: -5, backoff: 0, follow: 0 })).toEqual({ retries: -5, backoff: 0, follow: 0 });
|
|
146
|
+
expect(streamwindowof({ budget: 0 })).toEqual({ budget: 0 });
|
|
147
|
+
expect(streamwindowof({ budget: Number.NaN })).toEqual({});
|
|
148
|
+
expect(streamwindowof({ budget: -1 })).toEqual({ budget: -1 });
|
|
149
|
+
expect(streamwindowof([])).toEqual({});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("torture: sendfetch transport loop", () => {
|
|
154
|
+
it("follows redirect chains inside the limit and refuses past it", async () => {
|
|
155
|
+
const hops: string[] = [];
|
|
156
|
+
const transport: fetchtransport = async (url): Promise<transportresponse> => {
|
|
157
|
+
hops.push(url);
|
|
158
|
+
if (url === "https://a.example/start") return response(302, "", {}, "/one");
|
|
159
|
+
if (url === "https://a.example/one") return response(301, "", {}, "/two");
|
|
160
|
+
if (url === "https://a.example/two") return response(308, "", {}, "/final");
|
|
161
|
+
return response(200, "landed");
|
|
162
|
+
};
|
|
163
|
+
const request: fetchrequest = { url: "https://a.example/start" };
|
|
164
|
+
const outcome = await sendfetch({ request, transport, sleep: instant });
|
|
165
|
+
expect(outcome.status).toBe(200);
|
|
166
|
+
expect(outcome.redirects).toBe(3);
|
|
167
|
+
expect(outcome.url).toBe("https://a.example/final");
|
|
168
|
+
expect(hops).toHaveLength(4);
|
|
169
|
+
await expect(sendfetch({ request, options: { follow: 2 }, transport, sleep: instant })).rejects.toThrow(
|
|
170
|
+
/exceeded the reviewed follow limit of 2/,
|
|
171
|
+
);
|
|
172
|
+
await expect(sendfetch({ request, options: { follow: 3 }, transport, sleep: instant })).resolves.toMatchObject({
|
|
173
|
+
status: 200,
|
|
174
|
+
redirects: 3,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("refuses the chain one hop past the reviewed follow limit", async () => {
|
|
179
|
+
const transport: fetchtransport = async (url): Promise<transportresponse> =>
|
|
180
|
+
url.endsWith("/a")
|
|
181
|
+
? response(302, "", {}, "/b")
|
|
182
|
+
: url.endsWith("/b")
|
|
183
|
+
? response(302, "", {}, "/c")
|
|
184
|
+
: response(404, "gone");
|
|
185
|
+
await expect(
|
|
186
|
+
sendfetch({ request: { url: "https://a.example/a" }, options: { follow: 1 }, transport, sleep: instant }),
|
|
187
|
+
).rejects.toThrow(/exceeded the reviewed follow limit of 1/);
|
|
188
|
+
await expect(
|
|
189
|
+
sendfetch({ request: { url: "https://a.example/a" }, options: { follow: 0 }, transport, sleep: instant }),
|
|
190
|
+
).rejects.toThrow(/exceeded the reviewed follow limit of 0/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("resumes a retry from the redirect position the failed attempt reached", async () => {
|
|
194
|
+
const transport: fetchtransport = async (url): Promise<transportresponse> =>
|
|
195
|
+
url.endsWith("/a")
|
|
196
|
+
? response(302, "", {}, "/b")
|
|
197
|
+
: url.endsWith("/b")
|
|
198
|
+
? response(302, "", {}, "/c")
|
|
199
|
+
: response(404, "gone");
|
|
200
|
+
const outcome = await sendfetch({
|
|
201
|
+
request: { url: "https://a.example/a" },
|
|
202
|
+
options: { follow: 1, retries: 1 },
|
|
203
|
+
transport,
|
|
204
|
+
sleep: instant,
|
|
205
|
+
});
|
|
206
|
+
expect(outcome.status).toBe(404);
|
|
207
|
+
expect(outcome.url).toBe("https://a.example/c");
|
|
208
|
+
expect(outcome.retries).toBe(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("downgrades the post body to a bodiless GET on a 301, 302 or 303 redirect", async () => {
|
|
212
|
+
const seen: Array<{ url: string; method: string; body?: string }> = [];
|
|
213
|
+
const transport: fetchtransport = async (url, init): Promise<transportresponse> => {
|
|
214
|
+
seen.push({ url, method: init.method, ...(init.body !== undefined ? { body: init.body } : {}) });
|
|
215
|
+
if (url.endsWith("/pay")) return response(302, "", {}, "/landing");
|
|
216
|
+
return response(200, "ok");
|
|
217
|
+
};
|
|
218
|
+
const outcome = await sendfetch({
|
|
219
|
+
request: { url: "https://a.example/pay", method: "POST", body: "secret-payload" },
|
|
220
|
+
transport,
|
|
221
|
+
sleep: instant,
|
|
222
|
+
});
|
|
223
|
+
expect(outcome.status).toBe(200);
|
|
224
|
+
expect(seen).toHaveLength(2);
|
|
225
|
+
expect(seen[1]?.method).toBe("GET");
|
|
226
|
+
expect(seen[1]?.body).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("keeps the post body through a 307 and 308 redirect", async () => {
|
|
230
|
+
const seen: Array<{ url: string; method: string; body?: string }> = [];
|
|
231
|
+
const transport: fetchtransport = async (url, init): Promise<transportresponse> => {
|
|
232
|
+
seen.push({ url, method: init.method, ...(init.body !== undefined ? { body: init.body } : {}) });
|
|
233
|
+
if (url.endsWith("/pay")) return response(307, "", {}, "/pay2");
|
|
234
|
+
return response(200, "ok");
|
|
235
|
+
};
|
|
236
|
+
await sendfetch({
|
|
237
|
+
request: { url: "https://a.example/pay", method: "POST", body: "kept" },
|
|
238
|
+
transport,
|
|
239
|
+
sleep: instant,
|
|
240
|
+
});
|
|
241
|
+
expect(seen[1]?.method).toBe("POST");
|
|
242
|
+
expect(seen[1]?.body).toBe("kept");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("resolves relative and absolute redirect locations against the current url", async () => {
|
|
246
|
+
const transport: fetchtransport = async (url): Promise<transportresponse> => {
|
|
247
|
+
if (url === "https://a.example/dir/page") return response(302, "", {}, "other/page");
|
|
248
|
+
return response(200, "ok");
|
|
249
|
+
};
|
|
250
|
+
const outcome = await sendfetch({ request: { url: "https://a.example/dir/page" }, transport, sleep: instant });
|
|
251
|
+
expect(outcome.url).toBe("https://a.example/dir/other/page");
|
|
252
|
+
const absolute: fetchtransport = async (url): Promise<transportresponse> =>
|
|
253
|
+
url === "https://a.example/page" ? response(302, "", {}, "https://b.example/x") : response(200, "ok");
|
|
254
|
+
const crossed = await sendfetch({
|
|
255
|
+
request: { url: "https://a.example/page" },
|
|
256
|
+
transport: absolute,
|
|
257
|
+
sleep: instant,
|
|
258
|
+
});
|
|
259
|
+
expect(crossed.url).toBe("https://b.example/x");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("stops only at a redirect without a location header", async () => {
|
|
263
|
+
const transport: fetchtransport = async (url): Promise<transportresponse> =>
|
|
264
|
+
url === "https://a.example/hop" ? response(302, "", {}, "") : response(200, "ok");
|
|
265
|
+
const outcome = await sendfetch({ request: { url: "https://a.example/hop" }, transport, sleep: instant });
|
|
266
|
+
expect(outcome.status).toBe(302);
|
|
267
|
+
expect(outcome.redirects).toBe(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("retries failed attempts with the backoff wait and reports the reason", async () => {
|
|
271
|
+
const waits: number[] = [];
|
|
272
|
+
let attempts = 0;
|
|
273
|
+
const transport: fetchtransport = async (): Promise<transportresponse> => {
|
|
274
|
+
attempts += 1;
|
|
275
|
+
if (attempts < 3) throw new Error("socket hangup");
|
|
276
|
+
return response(200, "third time");
|
|
277
|
+
};
|
|
278
|
+
const outcome = await sendfetch({
|
|
279
|
+
request: { url: "https://a.example/" },
|
|
280
|
+
options: { retries: 3, backoff: 25 },
|
|
281
|
+
transport,
|
|
282
|
+
sleep: async (ms) => {
|
|
283
|
+
waits.push(ms);
|
|
284
|
+
},
|
|
285
|
+
onretry: (attempt, wait, reason) => {
|
|
286
|
+
waits.push(-attempt);
|
|
287
|
+
void reason;
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
expect(outcome.body).toBe("third time");
|
|
291
|
+
expect(outcome.retries).toBe(2);
|
|
292
|
+
expect(waits).toContain(25);
|
|
293
|
+
expect(waits).toContain(50);
|
|
294
|
+
expect(waits).toContain(-1);
|
|
295
|
+
expect(waits).toContain(-2);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("throws the attempt accounting when every attempt fails", async () => {
|
|
299
|
+
const transport: fetchtransport = async (): Promise<transportresponse> => {
|
|
300
|
+
throw new Error("refused");
|
|
301
|
+
};
|
|
302
|
+
await expect(
|
|
303
|
+
sendfetch({ request: { url: "https://a.example/" }, options: { retries: 2 }, transport, sleep: instant }),
|
|
304
|
+
).rejects.toThrow(/failed after 3 attempts with 2 retries: refused/);
|
|
305
|
+
await expect(sendfetch({ request: { url: "https://a.example/" }, transport, sleep: instant })).rejects.toThrow(
|
|
306
|
+
/failed after 1 attempt with 0 retries/,
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("times one attempt out inside the reviewed timeout and answers the attempt accounting", async () => {
|
|
311
|
+
let slowcalls = 0;
|
|
312
|
+
const transport: fetchtransport = async (url): Promise<transportresponse> => {
|
|
313
|
+
if (url.includes("slow")) {
|
|
314
|
+
slowcalls += 1;
|
|
315
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
316
|
+
return response(200, "late");
|
|
317
|
+
}
|
|
318
|
+
return response(200, "fast");
|
|
319
|
+
};
|
|
320
|
+
await expect(
|
|
321
|
+
sendfetch({
|
|
322
|
+
request: { url: "https://a.example/slow" },
|
|
323
|
+
options: { timeout: 5, retries: 0 },
|
|
324
|
+
transport,
|
|
325
|
+
sleep: instant,
|
|
326
|
+
}),
|
|
327
|
+
).rejects.toThrow(/failed after 1 attempt with 0 retries: The request timed out after 5 milliseconds/);
|
|
328
|
+
expect(slowcalls).toBe(1);
|
|
329
|
+
const hanging: fetchtransport = async (): Promise<transportresponse> => new Promise(() => undefined);
|
|
330
|
+
await expect(
|
|
331
|
+
sendfetch({
|
|
332
|
+
request: { url: "https://a.example/" },
|
|
333
|
+
options: { timeout: 5 },
|
|
334
|
+
transport: hanging,
|
|
335
|
+
sleep: instant,
|
|
336
|
+
}),
|
|
337
|
+
).rejects.toThrow(/timed out after 5 milliseconds/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("times the attempt out and retries until the next attempt succeeds", async () => {
|
|
341
|
+
let calls = 0;
|
|
342
|
+
const transport: fetchtransport = async (): Promise<transportresponse> => {
|
|
343
|
+
calls += 1;
|
|
344
|
+
if (calls === 1) await new Promise((resolve) => setTimeout(resolve, 30));
|
|
345
|
+
return response(200, "recovered");
|
|
346
|
+
};
|
|
347
|
+
const outcome = await sendfetch({
|
|
348
|
+
request: { url: "https://a.example/" },
|
|
349
|
+
options: { timeout: 5, retries: 1 },
|
|
350
|
+
transport,
|
|
351
|
+
sleep: instant,
|
|
352
|
+
});
|
|
353
|
+
expect(outcome.body).toBe("recovered");
|
|
354
|
+
expect(calls).toBe(2);
|
|
355
|
+
expect(outcome.retries).toBe(1);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("never strips a body from a POST and never adds one to a GET", async () => {
|
|
359
|
+
const seen: Array<{ method: string; body?: string }> = [];
|
|
360
|
+
const transport: fetchtransport = async (_url, init): Promise<transportresponse> => {
|
|
361
|
+
seen.push({ method: init.method, ...(init.body !== undefined ? { body: init.body } : {}) });
|
|
362
|
+
return response(200, "");
|
|
363
|
+
};
|
|
364
|
+
await sendfetch({
|
|
365
|
+
request: { url: "https://a.example/", method: "POST", body: "payload" },
|
|
366
|
+
transport,
|
|
367
|
+
sleep: instant,
|
|
368
|
+
});
|
|
369
|
+
await sendfetch({
|
|
370
|
+
request: { url: "https://a.example/", method: "GET", body: "dropped" },
|
|
371
|
+
transport,
|
|
372
|
+
sleep: instant,
|
|
373
|
+
});
|
|
374
|
+
await sendfetch({ request: { url: "https://a.example/", method: "HEAD" }, transport, sleep: instant });
|
|
375
|
+
expect(seen[0]).toEqual({ method: "POST", body: "payload" });
|
|
376
|
+
expect(seen[1]).toEqual({ method: "GET" });
|
|
377
|
+
expect(seen[2]).toEqual({ method: "HEAD" });
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("torture: stream reading budget", () => {
|
|
382
|
+
it("aborts exactly past the byte budget while the exact fit passes", async () => {
|
|
383
|
+
const exact = await readstream({ chunks: ["abc", "def"], window: { budget: 6 } });
|
|
384
|
+
expect(exact).toEqual({ chunks: 2, bytes: 6, aborted: false });
|
|
385
|
+
const over = await readstream({ chunks: ["abc", "def"], window: { budget: 5 } });
|
|
386
|
+
expect(over.aborted).toBe(true);
|
|
387
|
+
expect(over.bytes).toBe(3);
|
|
388
|
+
expect(over.chunks).toBe(1);
|
|
389
|
+
expect(over.reason).toMatch(/aborted at 6 bytes past the reviewed byte budget of 5/);
|
|
390
|
+
const zero = await readstream({ chunks: ["a"], window: { budget: 0 } });
|
|
391
|
+
expect(zero.aborted).toBe(true);
|
|
392
|
+
expect(zero.chunks).toBe(0);
|
|
393
|
+
const empty = await readstream({ chunks: [], window: { budget: 10 } });
|
|
394
|
+
expect(empty).toEqual({ chunks: 0, bytes: 0, aborted: false });
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("stops cleanly on the reviewed abort flag between chunks", async () => {
|
|
398
|
+
let stop = false;
|
|
399
|
+
const outcome = await readstream({ chunks: ["one", "two", "three"], window: { abort: () => stop, budget: 100 } });
|
|
400
|
+
expect(outcome.aborted).toBe(false);
|
|
401
|
+
expect(outcome.chunks).toBe(3);
|
|
402
|
+
stop = true;
|
|
403
|
+
const halted = await readstream({ chunks: ["one", "two"], window: { abort: () => stop } });
|
|
404
|
+
expect(halted).toEqual({
|
|
405
|
+
chunks: 0,
|
|
406
|
+
bytes: 0,
|
|
407
|
+
aborted: true,
|
|
408
|
+
reason: "The reviewed abort flag stopped the stream.",
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("reads a pull function to its exhaustion", async () => {
|
|
413
|
+
let pulls = 0;
|
|
414
|
+
const outcome = await readstream({
|
|
415
|
+
chunks: async () => {
|
|
416
|
+
pulls += 1;
|
|
417
|
+
return pulls <= 2 ? "x" : undefined;
|
|
418
|
+
},
|
|
419
|
+
window: {},
|
|
420
|
+
});
|
|
421
|
+
expect(outcome).toEqual({ chunks: 2, bytes: 2, aborted: false });
|
|
422
|
+
expect(pulls).toBe(3);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe("torture: json path reading and graphql envelopes", () => {
|
|
427
|
+
it("reads dotted paths with array indexes, defaults and misses", () => {
|
|
428
|
+
const parsed = { user: { name: "ada", tags: ["a", "b"] }, count: 3, active: true };
|
|
429
|
+
expect(readpath(parsed, [{ name: "n", path: "user.name", kind: "text" }])[0]).toMatchObject({
|
|
430
|
+
name: "n",
|
|
431
|
+
value: "ada",
|
|
432
|
+
});
|
|
433
|
+
expect(readpath(parsed, [{ name: "t", path: "user.tags.1", kind: "text" }])[0]?.value).toBe("b");
|
|
434
|
+
expect(readpath(parsed, [{ name: "c", path: "count", kind: "number" }])[0]?.value).toBe(3);
|
|
435
|
+
expect(readpath(parsed, [{ name: "b", path: "active", kind: "boolean" }])[0]?.value).toBe(true);
|
|
436
|
+
expect(readpath(parsed, [{ name: "b", path: "missing.deep", kind: "number", default: -1 }])[0]).toMatchObject({
|
|
437
|
+
value: -1,
|
|
438
|
+
missing: true,
|
|
439
|
+
});
|
|
440
|
+
expect(readpath(parsed, [{ name: "x", path: "user.name", kind: "number" }])[0]?.missing).toBe(true);
|
|
441
|
+
expect(readpath(null, [{ name: "x", path: "a.b" }])[0]?.missing).toBe(true);
|
|
442
|
+
expect(readpath(parsed, [{ name: "x", path: "count", kind: "text" }])[0]?.value).toBe("3");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("wraps and unwraps graphql envelopes while malformed answers refuse", () => {
|
|
446
|
+
expect(
|
|
447
|
+
graphqlopenvelope({ query: "query { x }", operationkind: "query", variables: { a: 1 }, operationname: "op" }),
|
|
448
|
+
).toBe(JSON.stringify({ query: "query { x }", variables: { a: 1 }, operationName: "op" }));
|
|
449
|
+
expect(unwrapgraphql({ data: { x: 1 }, errors: [{ message: "bad" }, "plain", 5] })).toEqual({
|
|
450
|
+
data: { x: 1 },
|
|
451
|
+
errors: ["bad", "plain", "5"],
|
|
452
|
+
});
|
|
453
|
+
expect(unwrapgraphql("nope")).toEqual({ errors: ["The graphql response is not a json object."] });
|
|
454
|
+
expect(unwrapgraphql([1])).toEqual({ errors: ["The graphql response is not a json object."] });
|
|
455
|
+
expect(unwrapgraphql({ errors: null })).toEqual({ errors: [] });
|
|
456
|
+
expect(graphqlrequestof({ query: " ", operationkind: "query" })).toBeUndefined();
|
|
457
|
+
expect(graphqlrequestof({ query: "x", operationkind: "mutation" })).toBeDefined();
|
|
458
|
+
expect(graphqlrequestof({ query: "x", operationkind: "subscribe" as never })).toBeUndefined();
|
|
459
|
+
expect(graphqlrequestof({ query: "x", operationkind: "query", variables: [1] })).toEqual({
|
|
460
|
+
query: "x",
|
|
461
|
+
operationkind: "query",
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("parses html bodies through the injected seam with the multi flag", () => {
|
|
466
|
+
const seam = (markup: string) => ({
|
|
467
|
+
query: (selector: string) =>
|
|
468
|
+
selector === "p" ? markup.split("|").map((text) => ({ text, attributes: {} })) : [],
|
|
469
|
+
});
|
|
470
|
+
const outcome = parsehtmlbody({
|
|
471
|
+
body: "a|b|c",
|
|
472
|
+
queries: [{ selector: "p", multi: true }, { selector: "p" }],
|
|
473
|
+
parse: seam,
|
|
474
|
+
});
|
|
475
|
+
expect(outcome[0]?.values).toEqual(["a", "b", "c"]);
|
|
476
|
+
expect(outcome[0]?.count).toBe(3);
|
|
477
|
+
expect(outcome[1]?.values).toEqual(["a"]);
|
|
478
|
+
expect(outcome[1]?.multi).toBe(false);
|
|
479
|
+
const attr = parsehtmlbody({ body: "x", queries: [{ selector: "p", attribute: "href" }], parse: seam });
|
|
480
|
+
expect(attr[0]?.values).toEqual([""]);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("validates and defaults payload schemas", () => {
|
|
484
|
+
const schema = {
|
|
485
|
+
fields: [
|
|
486
|
+
{ name: "id", kind: "string" as const, required: true },
|
|
487
|
+
{ name: "n", kind: "number" as const },
|
|
488
|
+
{ name: "b", kind: "boolean" as const, default: false },
|
|
489
|
+
],
|
|
490
|
+
};
|
|
491
|
+
expect(payloadvalid({ id: "x", n: 1 }, schema)).toEqual({ ok: true, errors: [] });
|
|
492
|
+
expect(payloadvalid({}, undefined)).toEqual({
|
|
493
|
+
ok: false,
|
|
494
|
+
errors: ["The typed endpoint call needs a reviewed payload schema before it runs."],
|
|
495
|
+
});
|
|
496
|
+
expect(payloadvalid({ id: null }, schema).errors[0]).toMatch(/required field id of kind string is missing/);
|
|
497
|
+
expect(payloadvalid({ id: 1 }, schema).errors[0]).toMatch(/field id must be a string/);
|
|
498
|
+
expect(payloadvalid({ id: "x", n: "nan" }, schema).errors[0]).toMatch(/field n must be a finite number/);
|
|
499
|
+
expect(payloadvalid({ id: "x", b: "yes" }, schema).errors[0]).toMatch(/field b must be a boolean/);
|
|
500
|
+
expect(payloadwithdefaults({ id: "x" }, schema)).toEqual({ id: "x", b: false });
|
|
501
|
+
expect(payloadwithdefaults({ id: "x" }, undefined)).toEqual({ id: "x" });
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe("torture: http transport endpoints and routing", () => {
|
|
506
|
+
it("builds the endpoint records from the config and refuses path confusion", () => {
|
|
507
|
+
const config: mcpserverconfig = {
|
|
508
|
+
...defaultmcpconfig(),
|
|
509
|
+
port: 9000,
|
|
510
|
+
httpstream: { endpoint: "/agent", streampath: "/agent/stream", tls: { mode: "off" } },
|
|
511
|
+
};
|
|
512
|
+
const endpoint = httpendpoint(config);
|
|
513
|
+
expect(endpoint).toMatchObject({
|
|
514
|
+
kind: "http",
|
|
515
|
+
endpoint: "http://127.0.0.1:9000/agent",
|
|
516
|
+
bind: localhostbind,
|
|
517
|
+
port: 9000,
|
|
518
|
+
localhost: true,
|
|
519
|
+
path: "/agent",
|
|
520
|
+
});
|
|
521
|
+
expect(streampathof(config)).toMatchObject({ path: "/agent/stream", bind: localhostbind, localhost: true });
|
|
522
|
+
const defaulted = httpendpoint({
|
|
523
|
+
...defaultmcpconfig(),
|
|
524
|
+
port: 7436,
|
|
525
|
+
httpstream: { endpoint: " ", streampath: " ", tls: { mode: "off" } },
|
|
526
|
+
});
|
|
527
|
+
expect(defaulted.path).toBe("/mcp");
|
|
528
|
+
expect(streampathof({ ...defaultmcpconfig(), port: 7436 }).path).toBe("/mcp/stream");
|
|
529
|
+
expect(
|
|
530
|
+
streampathof({
|
|
531
|
+
...defaultmcpconfig(),
|
|
532
|
+
port: 7436,
|
|
533
|
+
httpstream: { endpoint: "/mcp", streampath: "agent", tls: { mode: "off" } },
|
|
534
|
+
}).path,
|
|
535
|
+
).toBe("agent");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("routes exactly the endpoint and stream paths", () => {
|
|
539
|
+
const config: mcpserverconfig = { ...defaultmcpconfig(), httpstream: defaulthttpstream() };
|
|
540
|
+
expect(routepath(config, "/mcp")).toBe("endpoint");
|
|
541
|
+
expect(routepath(config, "/mcp/stream")).toBe("stream");
|
|
542
|
+
expect(routepath(config, "/mcp/")).toBeUndefined();
|
|
543
|
+
expect(routepath(config, "/mcp?x=1")).toBeUndefined();
|
|
544
|
+
expect(routepath(config, "/mcp/stream/")).toBeUndefined();
|
|
545
|
+
expect(routepath(config, "/")).toBeUndefined();
|
|
546
|
+
expect(routepath(config, "")).toBeUndefined();
|
|
547
|
+
expect(routepath(config, "//mcp")).toBeUndefined();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("decides tls under every hostile configuration", () => {
|
|
551
|
+
expect(tlsdecision({ config: { mode: "off" }, presented: { fingerprint: "sha256:zz" }, now })).toEqual({
|
|
552
|
+
tls: false,
|
|
553
|
+
verified: false,
|
|
554
|
+
});
|
|
555
|
+
expect(tlsdecision({ config: { mode: "required" }, now }).reason).toMatch(/presented no certificate/i);
|
|
556
|
+
expect(
|
|
557
|
+
tlsdecision({
|
|
558
|
+
config: { mode: "on", certificatefingerprint: "sha256:aa" },
|
|
559
|
+
presented: { fingerprint: "sha256:AA" },
|
|
560
|
+
now,
|
|
561
|
+
}).reason,
|
|
562
|
+
).toMatch(/does not match the user configured fingerprint/i);
|
|
563
|
+
expect(
|
|
564
|
+
tlsdecision({
|
|
565
|
+
config: { mode: "required", certificatefingerprint: "sha256:aa" },
|
|
566
|
+
presented: { fingerprint: "sha256:aa" },
|
|
567
|
+
now,
|
|
568
|
+
}),
|
|
569
|
+
).toEqual({ tls: true, verified: true });
|
|
570
|
+
expect(
|
|
571
|
+
tlsdecision({ config: { mode: "required", certificatefingerprint: "" }, presented: { fingerprint: "" }, now }),
|
|
572
|
+
).toEqual({ tls: true, verified: true });
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
describe("torture: posted frame intake and sse framing", () => {
|
|
577
|
+
it("parses one frame, one batch and refuses the mixed or garbage bodies", () => {
|
|
578
|
+
const frame = { jsonrpc: "2.0", id: 1, method: "ping" };
|
|
579
|
+
expect(parsepost(JSON.stringify(frame))).toEqual({ message: frame });
|
|
580
|
+
expect(parsepost(JSON.stringify([frame, { jsonrpc: "2.0", id: 2, method: "ping" }]))).toEqual({
|
|
581
|
+
message: [frame, { jsonrpc: "2.0", id: 2, method: "ping" }],
|
|
582
|
+
});
|
|
583
|
+
expect(parsepost(JSON.stringify([frame, "garbage"]))).toEqual({
|
|
584
|
+
error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
|
|
585
|
+
});
|
|
586
|
+
expect(parsepost("[]")).toEqual({
|
|
587
|
+
error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
|
|
588
|
+
});
|
|
589
|
+
expect(parsepost("null")).toEqual({
|
|
590
|
+
error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
|
|
591
|
+
});
|
|
592
|
+
expect(parsepost("42")).toEqual({
|
|
593
|
+
error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
|
|
594
|
+
});
|
|
595
|
+
expect(parsepost("{nope")).toEqual({
|
|
596
|
+
error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
|
|
597
|
+
});
|
|
598
|
+
expect(parsepost("")).toEqual({
|
|
599
|
+
error: { code: "parse", message: "The posted body does not parse as one json rpc message." },
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("formats the http answer and the server sent event frames exactly", () => {
|
|
604
|
+
expect(httpanswer({ jsonrpc: "2.0", id: 7, result: { pong: true } })).toBe(
|
|
605
|
+
`${JSON.stringify({ jsonrpc: "2.0", id: 7, result: { pong: true } })}\n`,
|
|
606
|
+
);
|
|
607
|
+
expect(
|
|
608
|
+
httpanswer([
|
|
609
|
+
{ jsonrpc: "2.0", id: 1, result: 1 },
|
|
610
|
+
{ jsonrpc: "2.0", id: 2, result: 2 },
|
|
611
|
+
] as never),
|
|
612
|
+
).toBe(
|
|
613
|
+
`${JSON.stringify([
|
|
614
|
+
{ jsonrpc: "2.0", id: 1, result: 1 },
|
|
615
|
+
{ jsonrpc: "2.0", id: 2, result: 2 },
|
|
616
|
+
])}\n`,
|
|
617
|
+
);
|
|
618
|
+
const event = sseframe({ id: 12, event: "message", frame: { jsonrpc: "2.0", id: 9, result: null } });
|
|
619
|
+
expect(event).toBe(`id: 12\nevent: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 9, result: null })}\n\n`);
|
|
620
|
+
expect(sseframe({ id: 0, event: "", frame: { jsonrpc: "2.0" } }).startsWith("id: 0\n")).toBe(true);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
describe("torture: stream channels, heartbeats and the client ceiling", () => {
|
|
625
|
+
it("opens, beats and sweeps channels with exact idle window edges", () => {
|
|
626
|
+
const open = openstreamchannel({ clientid: "c1", now });
|
|
627
|
+
expect(open.id).toMatch(/^channel-/);
|
|
628
|
+
expect(open.clientid).toBe("c1");
|
|
629
|
+
expect(open.openedat).toBe(now);
|
|
630
|
+
const beaten = heartbeat({
|
|
631
|
+
channels: [open, { ...open, clientid: "c2", closedat: now - 5 }],
|
|
632
|
+
clientid: "c1",
|
|
633
|
+
now: now + 1000,
|
|
634
|
+
});
|
|
635
|
+
expect(beaten[0]?.lastbeatat).toBe(now + 1000);
|
|
636
|
+
expect(beaten[1]?.lastbeatat).toBe(now);
|
|
637
|
+
expect(defaulthttpstream()).toEqual({
|
|
638
|
+
endpoint: "/mcp",
|
|
639
|
+
streampath: "/mcp/stream",
|
|
640
|
+
tls: { mode: "off" },
|
|
641
|
+
heartbeatms: defaultheartbeatms,
|
|
642
|
+
idlewindowms: defaultidlewindowms,
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("treats the exact idle window edge as dead while one millisecond under stays live", () => {
|
|
647
|
+
const channel: streamchannel = { id: "ch", clientid: "c1", openedat: now - 100, lastbeatat: now - 10_000 };
|
|
648
|
+
expect(channellive(channel, now, 10_000)).toBe(false);
|
|
649
|
+
expect(channellive(channel, now - 1, 10_000)).toBe(true);
|
|
650
|
+
expect(channellive({ ...channel, closedat: now }, now - 100_000, 1_000_000)).toBe(false);
|
|
651
|
+
expect(channellive(channel, now)).toBe(true);
|
|
652
|
+
expect(channellive(channel, now, 9_999)).toBe(false);
|
|
653
|
+
expect(channellive({ ...channel, lastbeatat: now }, now, 1)).toBe(true);
|
|
654
|
+
const swept = closeidlechannels({
|
|
655
|
+
channels: [channel, { ...channel, id: "ch2", lastbeatat: now }],
|
|
656
|
+
now,
|
|
657
|
+
idlewindow: 10_000,
|
|
658
|
+
});
|
|
659
|
+
expect(swept[0]?.closedat).toBe(now);
|
|
660
|
+
expect(swept[1]?.closedat).toBeUndefined();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("enforces the client ceiling at the exact boundary", () => {
|
|
664
|
+
const clients: clientrecord[] = [
|
|
665
|
+
{ id: "a", transport: "http", paired: true, connectedat: now },
|
|
666
|
+
{ id: "b", transport: "http", paired: false, connectedat: now },
|
|
667
|
+
];
|
|
668
|
+
expect(enforcemaxclients({ clients, maxclients: 2 }).allowed).toBe(false);
|
|
669
|
+
expect(enforcemaxclients({ clients, maxclients: 2 }).reason).toMatch(/maximum of 2 remote clients is reached/);
|
|
670
|
+
expect(enforcemaxclients({ clients, maxclients: 3 }).allowed).toBe(true);
|
|
671
|
+
expect(enforcemaxclients({ clients }).allowed).toBe(true);
|
|
672
|
+
expect(
|
|
673
|
+
enforcemaxclients({ clients: [{ ...clients[0]!, disconnectedat: now }, clients[1]!], maxclients: 2 }).allowed,
|
|
674
|
+
).toBe(true);
|
|
675
|
+
expect(enforcemaxclients({ clients, maxclients: 0 }).allowed).toBe(false);
|
|
676
|
+
expect(enforcemaxclients({ clients, maxclients: Number.NaN }).reason).toMatch(/maximum of NaN/);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("reports the remote status of a configured transport", () => {
|
|
680
|
+
const config: mcpserverconfig = {
|
|
681
|
+
...defaultmcpconfig(),
|
|
682
|
+
enabled: true,
|
|
683
|
+
remoteaccess: { endpoint: "https://agent.example", tls: { mode: "on" }, maxclients: 5 },
|
|
684
|
+
};
|
|
685
|
+
const status = listremotestatus({
|
|
686
|
+
config,
|
|
687
|
+
channels: [],
|
|
688
|
+
clients: [{ id: "c1", transport: "http", paired: false, connectedat: now }],
|
|
689
|
+
tokens: [],
|
|
690
|
+
now,
|
|
691
|
+
});
|
|
692
|
+
expect(status.endpoint).toBe("https://agent.example");
|
|
693
|
+
expect(status.tls.mode).toBe("on");
|
|
694
|
+
expect(status.tls.certificaterequired).toBe(false);
|
|
695
|
+
expect(status.clients).toBe(1);
|
|
696
|
+
expect(status.paired).toBe(0);
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
describe("torture: the ordered remote frame pipeline", () => {
|
|
701
|
+
it("refuses before the token when tls fails and never leaks pairing state", async () => {
|
|
702
|
+
const config: mcpserverconfig = {
|
|
703
|
+
...defaultmcpconfig(),
|
|
704
|
+
enabled: true,
|
|
705
|
+
remoteaccess: {
|
|
706
|
+
endpoint: "https://agent.example",
|
|
707
|
+
tls: { mode: "required", certificatefingerprint: "sha256:aa" },
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
const fixture = await pipelinefixture();
|
|
711
|
+
const refused = await httpframepipeline({
|
|
712
|
+
config,
|
|
713
|
+
presented: { fingerprint: "sha256:zz" },
|
|
714
|
+
tokens: fixture.tokens,
|
|
715
|
+
rawtoken: "totally-wrong-token",
|
|
716
|
+
allowlist: fixture.allowlist,
|
|
717
|
+
fingerprint: "fp-unknown",
|
|
718
|
+
now,
|
|
719
|
+
});
|
|
720
|
+
expect(refused.error?.code).toBe("consentrefused");
|
|
721
|
+
expect(refused.error?.message).toMatch(/does not match the user configured fingerprint/);
|
|
722
|
+
expect(refused.error?.message).not.toContain("raw-token");
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("answers the fixed auth message for missing, wrong, revoked and expired tokens", async () => {
|
|
726
|
+
const config: mcpserverconfig = { ...defaultmcpconfig(), enabled: true };
|
|
727
|
+
const fixture = await pipelinefixture();
|
|
728
|
+
const missing = await httpframepipeline({
|
|
729
|
+
config,
|
|
730
|
+
tokens: fixture.tokens,
|
|
731
|
+
allowlist: fixture.allowlist,
|
|
732
|
+
fingerprint: "fp-1",
|
|
733
|
+
now,
|
|
734
|
+
});
|
|
735
|
+
expect(missing.error?.message).toBe("The remote frame failed its authentication handshake.");
|
|
736
|
+
const wrong = await httpframepipeline({
|
|
737
|
+
config,
|
|
738
|
+
tokens: fixture.tokens,
|
|
739
|
+
rawtoken: "",
|
|
740
|
+
allowlist: fixture.allowlist,
|
|
741
|
+
fingerprint: "fp-1",
|
|
742
|
+
now,
|
|
743
|
+
});
|
|
744
|
+
expect(wrong.error?.message).toBe("The remote frame failed its authentication handshake.");
|
|
745
|
+
const emptytokens = await httpframepipeline({
|
|
746
|
+
config,
|
|
747
|
+
tokens: [],
|
|
748
|
+
rawtoken: fixture.raw,
|
|
749
|
+
allowlist: fixture.allowlist,
|
|
750
|
+
fingerprint: "fp-1",
|
|
751
|
+
now,
|
|
752
|
+
});
|
|
753
|
+
expect(emptytokens.error?.message).toBe("The remote frame failed its authentication handshake.");
|
|
754
|
+
const revokedtoken = { ...fixture.tokens[0]!, revokedat: now - 1 };
|
|
755
|
+
const revoked = await httpframepipeline({
|
|
756
|
+
config,
|
|
757
|
+
tokens: [revokedtoken],
|
|
758
|
+
rawtoken: fixture.raw,
|
|
759
|
+
allowlist: fixture.allowlist,
|
|
760
|
+
fingerprint: "fp-1",
|
|
761
|
+
now,
|
|
762
|
+
});
|
|
763
|
+
expect(revoked.error?.message).toBe("The remote frame failed its authentication handshake.");
|
|
764
|
+
const expiredtoken = { ...fixture.tokens[0]!, expiresat: now };
|
|
765
|
+
const expired = await httpframepipeline({
|
|
766
|
+
config,
|
|
767
|
+
tokens: [expiredtoken],
|
|
768
|
+
rawtoken: fixture.raw,
|
|
769
|
+
allowlist: fixture.allowlist,
|
|
770
|
+
fingerprint: "fp-1",
|
|
771
|
+
now,
|
|
772
|
+
});
|
|
773
|
+
expect(expired.error?.message).toBe("The remote frame failed its authentication handshake.");
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("refuses unknown fingerprints and ungranted scopes after the token verified", async () => {
|
|
777
|
+
const config: mcpserverconfig = { ...defaultmcpconfig(), enabled: true };
|
|
778
|
+
const fixture = await pipelinefixture();
|
|
779
|
+
const unknownfp = await httpframepipeline({
|
|
780
|
+
config,
|
|
781
|
+
tokens: fixture.tokens,
|
|
782
|
+
rawtoken: fixture.raw,
|
|
783
|
+
allowlist: fixture.allowlist,
|
|
784
|
+
fingerprint: "fp-attacker",
|
|
785
|
+
now,
|
|
786
|
+
});
|
|
787
|
+
expect(unknownfp.error?.message).toMatch(/fp-attacker is not on the allowlist/);
|
|
788
|
+
const namespaceoff = await httpframepipeline({
|
|
789
|
+
config,
|
|
790
|
+
tokens: fixture.tokens,
|
|
791
|
+
rawtoken: fixture.raw,
|
|
792
|
+
allowlist: fixture.allowlist,
|
|
793
|
+
fingerprint: "fp-1",
|
|
794
|
+
toolname: "memory.list",
|
|
795
|
+
now,
|
|
796
|
+
});
|
|
797
|
+
expect(namespaceoff.error?.code).toBe("consentrefused");
|
|
798
|
+
expect(namespaceoff.error?.message).toMatch(/grants no memory tools/);
|
|
799
|
+
const unknownnamespace = await httpframepipeline({
|
|
800
|
+
config,
|
|
801
|
+
tokens: fixture.tokens,
|
|
802
|
+
rawtoken: fixture.raw,
|
|
803
|
+
allowlist: fixture.allowlist,
|
|
804
|
+
fingerprint: "fp-1",
|
|
805
|
+
toolname: "spy.satellite",
|
|
806
|
+
now,
|
|
807
|
+
});
|
|
808
|
+
expect(unknownnamespace.error?.code).toBe("params");
|
|
809
|
+
expect(unknownnamespace.error?.message).toMatch(/no reviewed namespace/);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("verifies a clean frame and returns its token", async () => {
|
|
813
|
+
const config: mcpserverconfig = { ...defaultmcpconfig(), enabled: true, httpstream: defaulthttpstream() };
|
|
814
|
+
const fixture = await pipelinefixture();
|
|
815
|
+
const clean = await httpframepipeline({
|
|
816
|
+
config,
|
|
817
|
+
tokens: fixture.tokens,
|
|
818
|
+
rawtoken: fixture.raw,
|
|
819
|
+
allowlist: fixture.allowlist,
|
|
820
|
+
fingerprint: "fp-1",
|
|
821
|
+
toolname: "browser.readtext",
|
|
822
|
+
now,
|
|
823
|
+
});
|
|
824
|
+
expect(clean.error).toBeUndefined();
|
|
825
|
+
expect(clean.token?.clientid).toBe("client1");
|
|
826
|
+
const tokenless = await httpframepipeline({
|
|
827
|
+
config,
|
|
828
|
+
tokens: fixture.tokens,
|
|
829
|
+
rawtoken: fixture.raw,
|
|
830
|
+
allowlist: [],
|
|
831
|
+
fingerprint: "fp-1",
|
|
832
|
+
now,
|
|
833
|
+
});
|
|
834
|
+
expect(tokenless.error?.message).toMatch(/not on the allowlist/);
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
describe("torture: relay server state machine", () => {
|
|
839
|
+
/** Builds one relay session through the deterministic id and hash seams. */
|
|
840
|
+
function createsession(
|
|
841
|
+
state: ReturnType<typeof newrelayserverstate>,
|
|
842
|
+
code: string,
|
|
843
|
+
connectionid = "conn-ext",
|
|
844
|
+
): { state: ReturnType<typeof newrelayserverstate>; token: string; sessionid: string } {
|
|
845
|
+
const outcome = relayserverframe({
|
|
846
|
+
state,
|
|
847
|
+
connectionid,
|
|
848
|
+
frame: relayframeof({
|
|
849
|
+
op: "sessioncreate",
|
|
850
|
+
opid: "op-1",
|
|
851
|
+
at: now,
|
|
852
|
+
body: { role: "extension", pairingcode: code },
|
|
853
|
+
}),
|
|
854
|
+
now,
|
|
855
|
+
idof: (() => {
|
|
856
|
+
let n = 0;
|
|
857
|
+
return () => `${++n}`;
|
|
858
|
+
})(),
|
|
859
|
+
hashof: (token) => `h(${token})`,
|
|
860
|
+
});
|
|
861
|
+
const reply = JSON.parse(outcome.replies[0] ?? "{}") as {
|
|
862
|
+
sessionid: string;
|
|
863
|
+
token: string;
|
|
864
|
+
body: { sessionid: string; token: string };
|
|
865
|
+
};
|
|
866
|
+
return { state: outcome.state, token: reply.body.token, sessionid: reply.body.sessionid };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
it("refuses the unregistered connection and the unparseable frame without state drift", () => {
|
|
870
|
+
const state = newrelayserverstate();
|
|
871
|
+
const silent = relayserverframe({ state, connectionid: "conn-ghost", frame: "{}", now });
|
|
872
|
+
expect(silent).toEqual({ state, replies: [], routed: [] });
|
|
873
|
+
const registered = relayconnectionopen(state, "conn-1", now);
|
|
874
|
+
const parse = relayserverframe({ state: registered, connectionid: "conn-1", frame: "not json", now });
|
|
875
|
+
expect(parse.replies[0]).toContain("does not parse as a servercontract envelope");
|
|
876
|
+
expect(parse.routed).toEqual([]);
|
|
877
|
+
const emptyid = relayserverframe({
|
|
878
|
+
state: registered,
|
|
879
|
+
connectionid: "conn-1",
|
|
880
|
+
frame: JSON.stringify({ version: 1, op: "eventpost", opid: "", at: now }),
|
|
881
|
+
now,
|
|
882
|
+
});
|
|
883
|
+
expect(emptyid.replies[0]).toContain("does not parse as a servercontract envelope");
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it("registers connections once and drops their tokens on close", () => {
|
|
887
|
+
let state = relayconnectionopen(newrelayserverstate(), "conn-1", now);
|
|
888
|
+
state = relayconnectionopen(state, "conn-1", now + 5);
|
|
889
|
+
expect(state.connections).toHaveLength(1);
|
|
890
|
+
expect(state.connections[0]?.lastframeat).toBe(now);
|
|
891
|
+
expect(() => relayconnectionopen(state, " ", now)).toThrow(/empty connection id never registers/);
|
|
892
|
+
const created = createsession(state, "DT-AAA", "conn-1");
|
|
893
|
+
expect(
|
|
894
|
+
created.state.tokens.some((record) => record.connectionid === "conn-1" && record.token === created.token),
|
|
895
|
+
).toBe(true);
|
|
896
|
+
const closed = relayconnectionclose(created.state, "conn-1");
|
|
897
|
+
expect(closed.connections).toHaveLength(0);
|
|
898
|
+
expect(closed.tokens).toHaveLength(0);
|
|
899
|
+
expect(closed.sessions).toHaveLength(1);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it("serves the sessioncreate operation for the extension member only", () => {
|
|
903
|
+
let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
|
|
904
|
+
state = relayconnectionopen(state, "conn-attacker", now);
|
|
905
|
+
const attacker = relayserverframe({
|
|
906
|
+
state,
|
|
907
|
+
connectionid: "conn-attacker",
|
|
908
|
+
frame: relayframeof({ op: "sessioncreate", opid: "op-a", at: now, body: { role: "site" } }),
|
|
909
|
+
now,
|
|
910
|
+
idof: () => "9",
|
|
911
|
+
hashof: (t) => `h(${t})`,
|
|
912
|
+
});
|
|
913
|
+
expect(attacker.replies[0]).toContain("serves the extension member only");
|
|
914
|
+
expect(attacker.state.sessions).toHaveLength(0);
|
|
915
|
+
const created = relayserverframe({
|
|
916
|
+
state,
|
|
917
|
+
connectionid: "conn-ext",
|
|
918
|
+
frame: relayframeof({
|
|
919
|
+
op: "sessioncreate",
|
|
920
|
+
opid: "op-1",
|
|
921
|
+
at: now,
|
|
922
|
+
body: { role: "extension", memberid: "ext-1" },
|
|
923
|
+
}),
|
|
924
|
+
now,
|
|
925
|
+
idof: () => "1",
|
|
926
|
+
hashof: (t) => `h(${t})`,
|
|
927
|
+
});
|
|
928
|
+
const reply = JSON.parse(created.replies[0] ?? "{}") as {
|
|
929
|
+
body: { members: Array<{ role: string; id: string }>; capabilities: unknown };
|
|
930
|
+
};
|
|
931
|
+
expect(reply.body.members).toEqual([{ role: "extension", id: "ext-1", joinedat: now }]);
|
|
932
|
+
expect(created.state.tokens).toHaveLength(1);
|
|
933
|
+
expect(created.state.pairings).toHaveLength(0);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("exchanges each pairing code exactly once and refuses the second spend", () => {
|
|
937
|
+
let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
|
|
938
|
+
state = relayconnectionopen(state, "conn-site", now);
|
|
939
|
+
state = relayconnectionopen(state, "conn-attacker", now);
|
|
940
|
+
const created = createsession(state, "DT-PAIR");
|
|
941
|
+
expect(created.state.pairings[0]).toMatchObject({ code: "DT-PAIR", used: false });
|
|
942
|
+
const firstjoin = relayserverframe({
|
|
943
|
+
state: created.state,
|
|
944
|
+
connectionid: "conn-site",
|
|
945
|
+
frame: relayframeof({
|
|
946
|
+
op: "sessionjoin",
|
|
947
|
+
opid: "op-2",
|
|
948
|
+
at: now + 1,
|
|
949
|
+
body: { role: "site", pairingcode: "DT-PAIR" },
|
|
950
|
+
}),
|
|
951
|
+
now: now + 1,
|
|
952
|
+
idof: () => "2",
|
|
953
|
+
hashof: (t) => `h(${t})`,
|
|
954
|
+
});
|
|
955
|
+
const joinreply = JSON.parse(firstjoin.replies[0] ?? "{}") as { body: { token: string; sessionid: string } };
|
|
956
|
+
expect(joinreply.body.token).toBe("token-2");
|
|
957
|
+
expect(firstjoin.state.pairings[0]?.used).toBe(true);
|
|
958
|
+
const secondspend = relayserverframe({
|
|
959
|
+
state: firstjoin.state,
|
|
960
|
+
connectionid: "conn-attacker",
|
|
961
|
+
frame: relayframeof({
|
|
962
|
+
op: "sessionjoin",
|
|
963
|
+
opid: "op-3",
|
|
964
|
+
at: now + 2,
|
|
965
|
+
body: { role: "site", pairingcode: "DT-PAIR" },
|
|
966
|
+
}),
|
|
967
|
+
now: now + 2,
|
|
968
|
+
idof: () => "3",
|
|
969
|
+
hashof: (t) => `h(${t})`,
|
|
970
|
+
});
|
|
971
|
+
expect(secondspend.replies[0]).toContain("matches no pending session");
|
|
972
|
+
expect(secondspend.state.sessions).toHaveLength(1);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it("refuses expired and wrong pairing codes", () => {
|
|
976
|
+
let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
|
|
977
|
+
state = relayconnectionopen(state, "conn-site", now);
|
|
978
|
+
const created = createsession(state, "DT-EXP");
|
|
979
|
+
const expired = relayserverframe({
|
|
980
|
+
state: created.state,
|
|
981
|
+
connectionid: "conn-site",
|
|
982
|
+
frame: relayframeof({
|
|
983
|
+
op: "sessionjoin",
|
|
984
|
+
opid: "op-2",
|
|
985
|
+
at: now + 300_001,
|
|
986
|
+
body: { role: "site", pairingcode: "DT-EXP" },
|
|
987
|
+
}),
|
|
988
|
+
now: now + 300_001,
|
|
989
|
+
idof: () => "2",
|
|
990
|
+
hashof: (t) => `h(${t})`,
|
|
991
|
+
});
|
|
992
|
+
expect(expired.replies[0]).toContain("matches no pending session");
|
|
993
|
+
const wrong = relayserverframe({
|
|
994
|
+
state: created.state,
|
|
995
|
+
connectionid: "conn-site",
|
|
996
|
+
frame: relayframeof({
|
|
997
|
+
op: "sessionjoin",
|
|
998
|
+
opid: "op-2",
|
|
999
|
+
at: now + 1,
|
|
1000
|
+
body: { role: "site", pairingcode: "DT-OTHER" },
|
|
1001
|
+
}),
|
|
1002
|
+
now: now + 1,
|
|
1003
|
+
idof: () => "2",
|
|
1004
|
+
hashof: (t) => `h(${t})`,
|
|
1005
|
+
});
|
|
1006
|
+
expect(wrong.replies[0]).toContain("matches no pending session");
|
|
1007
|
+
const whitespace = relayserverframe({
|
|
1008
|
+
state: created.state,
|
|
1009
|
+
connectionid: "conn-site",
|
|
1010
|
+
frame: relayframeof({ op: "sessionjoin", opid: "op-2", at: now + 1, body: { role: "site", pairingcode: " " } }),
|
|
1011
|
+
now: now + 1,
|
|
1012
|
+
idof: () => "2",
|
|
1013
|
+
hashof: (t) => `h(${t})`,
|
|
1014
|
+
});
|
|
1015
|
+
expect(whitespace.replies[0]).toContain("failed the token authentication");
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("refuses a second site member while the session holds one", () => {
|
|
1019
|
+
let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
|
|
1020
|
+
state = relayconnectionopen(state, "conn-site", now);
|
|
1021
|
+
state = relayconnectionopen(state, "conn-evil", now);
|
|
1022
|
+
const created = createsession(state, "DT-SITE");
|
|
1023
|
+
const first = relayserverframe({
|
|
1024
|
+
state: created.state,
|
|
1025
|
+
connectionid: "conn-site",
|
|
1026
|
+
frame: relayframeof({
|
|
1027
|
+
op: "sessionjoin",
|
|
1028
|
+
opid: "op-2",
|
|
1029
|
+
at: now + 1,
|
|
1030
|
+
body: { role: "site", pairingcode: "DT-SITE" },
|
|
1031
|
+
}),
|
|
1032
|
+
now: now + 1,
|
|
1033
|
+
idof: () => "2",
|
|
1034
|
+
hashof: (t) => `h(${t})`,
|
|
1035
|
+
});
|
|
1036
|
+
expect(JSON.parse(first.replies[0] ?? "{}").body.token).toBe("token-2");
|
|
1037
|
+
const second = relayserverframe({
|
|
1038
|
+
state: first.state,
|
|
1039
|
+
connectionid: "conn-evil",
|
|
1040
|
+
frame: relayframeof({
|
|
1041
|
+
op: "sessionjoin",
|
|
1042
|
+
opid: "op-3",
|
|
1043
|
+
at: now + 2,
|
|
1044
|
+
body: { role: "site", pairingcode: "DT-SITE" },
|
|
1045
|
+
}),
|
|
1046
|
+
now: now + 2,
|
|
1047
|
+
idof: () => "3",
|
|
1048
|
+
hashof: (t) => `h(${t})`,
|
|
1049
|
+
});
|
|
1050
|
+
expect(second.replies[0]).toContain("matches no pending session");
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("refuses forged tokens on every operation without touching the session state", () => {
|
|
1054
|
+
let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
|
|
1055
|
+
state = relayconnectionopen(state, "conn-site", now);
|
|
1056
|
+
state = relayconnectionopen(state, "conn-evil", now);
|
|
1057
|
+
const created = createsession(state, "DT-TOK");
|
|
1058
|
+
const forged = relayserverframe({
|
|
1059
|
+
state: created.state,
|
|
1060
|
+
connectionid: "conn-evil",
|
|
1061
|
+
frame: relayframeof({
|
|
1062
|
+
op: "eventpost",
|
|
1063
|
+
opid: "op-e",
|
|
1064
|
+
sessionid: created.sessionid,
|
|
1065
|
+
token: "forged-token",
|
|
1066
|
+
at: now + 1,
|
|
1067
|
+
body: { kind: "chat", stream: "chat" },
|
|
1068
|
+
}),
|
|
1069
|
+
now: now + 1,
|
|
1070
|
+
idof: () => "2",
|
|
1071
|
+
hashof: (t) => `h(${t})`,
|
|
1072
|
+
});
|
|
1073
|
+
expect(forged.replies[0]).toContain("failed the token authentication of its session");
|
|
1074
|
+
expect(forged.state.sessions[0]?.events).toHaveLength(0);
|
|
1075
|
+
const tokenless = relayserverframe({
|
|
1076
|
+
state: created.state,
|
|
1077
|
+
connectionid: "conn-ext",
|
|
1078
|
+
frame: relayframeof({
|
|
1079
|
+
op: "eventpost",
|
|
1080
|
+
opid: "op-e",
|
|
1081
|
+
sessionid: created.sessionid,
|
|
1082
|
+
at: now + 1,
|
|
1083
|
+
body: { kind: "chat" },
|
|
1084
|
+
}),
|
|
1085
|
+
now: now + 1,
|
|
1086
|
+
idof: () => "2",
|
|
1087
|
+
hashof: (t) => `h(${t})`,
|
|
1088
|
+
});
|
|
1089
|
+
expect(tokenless.replies[0]).toContain("failed the token authentication");
|
|
1090
|
+
const wrongsession = relayserverframe({
|
|
1091
|
+
state: created.state,
|
|
1092
|
+
connectionid: "conn-ext",
|
|
1093
|
+
frame: relayframeof({
|
|
1094
|
+
op: "eventpost",
|
|
1095
|
+
opid: "op-e",
|
|
1096
|
+
sessionid: "session-other",
|
|
1097
|
+
token: created.token,
|
|
1098
|
+
at: now + 1,
|
|
1099
|
+
body: { kind: "chat" },
|
|
1100
|
+
}),
|
|
1101
|
+
now: now + 1,
|
|
1102
|
+
idof: () => "2",
|
|
1103
|
+
hashof: (t) => `h(${t})`,
|
|
1104
|
+
});
|
|
1105
|
+
expect(wrongsession.replies[0]).toContain("failed the token authentication");
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("routes events to the other member with the member token and never echoes secrets", () => {
|
|
1109
|
+
let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
|
|
1110
|
+
state = relayconnectionopen(state, "conn-site", now);
|
|
1111
|
+
const created = createsession(state, "DT-ROUTE");
|
|
1112
|
+
const joined = relayserverframe({
|
|
1113
|
+
state: created.state,
|
|
1114
|
+
connectionid: "conn-site",
|
|
1115
|
+
frame: relayframeof({
|
|
1116
|
+
op: "sessionjoin",
|
|
1117
|
+
opid: "op-2",
|
|
1118
|
+
at: now + 1,
|
|
1119
|
+
body: { role: "site", pairingcode: "DT-ROUTE" },
|
|
1120
|
+
}),
|
|
1121
|
+
now: now + 1,
|
|
1122
|
+
idof: () => "2",
|
|
1123
|
+
hashof: (t) => `h(${t})`,
|
|
1124
|
+
});
|
|
1125
|
+
const sitetoken = (JSON.parse(joined.replies[0] ?? "{}") as { body: { token: string } }).body.token;
|
|
1126
|
+
const posted = relayserverframe({
|
|
1127
|
+
state: joined.state,
|
|
1128
|
+
connectionid: "conn-ext",
|
|
1129
|
+
frame: relayframeof({
|
|
1130
|
+
op: "eventpost",
|
|
1131
|
+
opid: "op-3",
|
|
1132
|
+
sessionid: created.sessionid,
|
|
1133
|
+
token: created.token,
|
|
1134
|
+
at: now + 2,
|
|
1135
|
+
body: { kind: "chat", stream: "chat", payload: { text: "hello" } },
|
|
1136
|
+
}),
|
|
1137
|
+
now: now + 2,
|
|
1138
|
+
idof: () => "3",
|
|
1139
|
+
hashof: (t) => `h(${t})`,
|
|
1140
|
+
});
|
|
1141
|
+
expect(posted.routed).toHaveLength(1);
|
|
1142
|
+
expect(posted.routed[0]?.connectionid).toBe("conn-site");
|
|
1143
|
+
const routedframe = JSON.parse(posted.routed[0]?.frame ?? "{}") as {
|
|
1144
|
+
token?: string;
|
|
1145
|
+
body: { payload: { text: string } };
|
|
1146
|
+
};
|
|
1147
|
+
expect(routedframe.token).toBe(sitetoken);
|
|
1148
|
+
expect(routedframe.body.payload.text).toBe("hello");
|
|
1149
|
+
const reply = JSON.parse(posted.replies[0] ?? "{}") as { body: { accepted: boolean } };
|
|
1150
|
+
expect(reply.body.accepted).toBe(true);
|
|
1151
|
+
expect(posted.state.sessions[0]?.events[0]).toMatchObject({ opid: "op-3", kind: "chat", stream: "chat" });
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
it("acks the eventstream subscription and refuses the operations outside the contract", () => {
|
|
1155
|
+
let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
|
|
1156
|
+
const created = createsession(state, "DT-STREAM");
|
|
1157
|
+
const subscribed = relayserverframe({
|
|
1158
|
+
state: created.state,
|
|
1159
|
+
connectionid: "conn-ext",
|
|
1160
|
+
frame: relayframeof({
|
|
1161
|
+
op: "eventstream",
|
|
1162
|
+
opid: "op-2",
|
|
1163
|
+
sessionid: created.sessionid,
|
|
1164
|
+
token: created.token,
|
|
1165
|
+
at: now + 1,
|
|
1166
|
+
body: { streams: ["chat", "review"], subscribed: true },
|
|
1167
|
+
}),
|
|
1168
|
+
now: now + 1,
|
|
1169
|
+
idof: () => "2",
|
|
1170
|
+
hashof: (t) => `h(${t})`,
|
|
1171
|
+
});
|
|
1172
|
+
const ack = JSON.parse(subscribed.replies[0] ?? "{}") as { body: { streams: string[]; subscribed: boolean } };
|
|
1173
|
+
expect(ack.body.streams).toEqual(["chat", "review"]);
|
|
1174
|
+
expect(ack.body.subscribed).toBe(true);
|
|
1175
|
+
const invalidstreams = relayserverframe({
|
|
1176
|
+
state: created.state,
|
|
1177
|
+
connectionid: "conn-ext",
|
|
1178
|
+
frame: relayframeof({
|
|
1179
|
+
op: "eventstream",
|
|
1180
|
+
opid: "op-3",
|
|
1181
|
+
sessionid: created.sessionid,
|
|
1182
|
+
token: created.token,
|
|
1183
|
+
at: now + 1,
|
|
1184
|
+
body: { streams: "chat" },
|
|
1185
|
+
}),
|
|
1186
|
+
now: now + 1,
|
|
1187
|
+
idof: () => "3",
|
|
1188
|
+
hashof: (t) => `h(${t})`,
|
|
1189
|
+
});
|
|
1190
|
+
expect(JSON.parse(invalidstreams.replies[0] ?? "{}").body.streams).toEqual([]);
|
|
1191
|
+
const outside = relayserverframe({
|
|
1192
|
+
state: created.state,
|
|
1193
|
+
connectionid: "conn-ext",
|
|
1194
|
+
frame: JSON.stringify({ version: 1, op: "eventack", opid: "op-4", at: now + 1 }),
|
|
1195
|
+
now: now + 1,
|
|
1196
|
+
idof: () => "4",
|
|
1197
|
+
hashof: (t) => `h(${t})`,
|
|
1198
|
+
});
|
|
1199
|
+
expect(outside.replies[0]).toContain("does not parse as a servercontract envelope");
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
it("rotates the token of a returning member and revokes the previous one", () => {
|
|
1203
|
+
let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
|
|
1204
|
+
const created = createsession(state, "");
|
|
1205
|
+
const rotated = relayserverframe({
|
|
1206
|
+
state: created.state,
|
|
1207
|
+
connectionid: "conn-ext",
|
|
1208
|
+
frame: relayframeof({
|
|
1209
|
+
op: "sessionjoin",
|
|
1210
|
+
opid: "op-2",
|
|
1211
|
+
sessionid: created.sessionid,
|
|
1212
|
+
token: created.token,
|
|
1213
|
+
at: now + 1,
|
|
1214
|
+
body: { role: "extension" },
|
|
1215
|
+
}),
|
|
1216
|
+
now: now + 1,
|
|
1217
|
+
idof: () => "9",
|
|
1218
|
+
hashof: (t) => `h(${t})`,
|
|
1219
|
+
});
|
|
1220
|
+
const reply = JSON.parse(rotated.replies[0] ?? "{}") as { body: { token: string; rotated: boolean } };
|
|
1221
|
+
expect(reply.body.token).toBe("token-9");
|
|
1222
|
+
expect(reply.body.rotated).toBe(true);
|
|
1223
|
+
const session = rotated.state.sessions[0]!;
|
|
1224
|
+
expect(session.revoked).toContain(`h(${created.token})`);
|
|
1225
|
+
expect(relaylivetokens(session)).not.toContain(`h(${created.token})`);
|
|
1226
|
+
const replay = relayserverframe({
|
|
1227
|
+
state: rotated.state,
|
|
1228
|
+
connectionid: "conn-ext",
|
|
1229
|
+
frame: relayframeof({
|
|
1230
|
+
op: "eventpost",
|
|
1231
|
+
opid: "op-3",
|
|
1232
|
+
sessionid: created.sessionid,
|
|
1233
|
+
token: created.token,
|
|
1234
|
+
at: now + 2,
|
|
1235
|
+
body: { kind: "chat", stream: "chat" },
|
|
1236
|
+
}),
|
|
1237
|
+
now: now + 2,
|
|
1238
|
+
idof: () => "3",
|
|
1239
|
+
hashof: (t) => `h(${t})`,
|
|
1240
|
+
});
|
|
1241
|
+
expect(replay.replies[0]).toContain("failed the token authentication");
|
|
1242
|
+
const fresh = relayserverframe({
|
|
1243
|
+
state: rotated.state,
|
|
1244
|
+
connectionid: "conn-ext",
|
|
1245
|
+
frame: relayframeof({
|
|
1246
|
+
op: "eventpost",
|
|
1247
|
+
opid: "op-4",
|
|
1248
|
+
sessionid: created.sessionid,
|
|
1249
|
+
token: "token-9",
|
|
1250
|
+
at: now + 3,
|
|
1251
|
+
body: { kind: "chat", stream: "chat" },
|
|
1252
|
+
}),
|
|
1253
|
+
now: now + 3,
|
|
1254
|
+
idof: () => "4",
|
|
1255
|
+
hashof: (t) => `h(${t})`,
|
|
1256
|
+
});
|
|
1257
|
+
expect(JSON.parse(fresh.replies[0] ?? "{}").body.accepted).toBe(true);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
it("sweeps idle connections and lists pending pairings inside their window", () => {
|
|
1261
|
+
let state = relayconnectionopen(newrelayserverstate(), "conn-ext", now);
|
|
1262
|
+
state = relayconnectionopen(state, "conn-site", now - 60_000);
|
|
1263
|
+
expect(relayidleconnections(state, now, 60_000)).toEqual(["conn-site"]);
|
|
1264
|
+
expect(relayidleconnections(state, now, 60_001)).toEqual([]);
|
|
1265
|
+
expect(relayidleconnections(state, now, Number.NaN)).toEqual([]);
|
|
1266
|
+
expect(relayidleconnections(state, now, 0)).toEqual([]);
|
|
1267
|
+
expect(relayidleconnections(state, now, -1)).toEqual([]);
|
|
1268
|
+
const created = createsession(state, "DT-PEND");
|
|
1269
|
+
expect(relaypendingpairings(created.state, now)).toEqual(["DT-PEND"]);
|
|
1270
|
+
expect(relaypendingpairings(created.state, now + 300_000)).toEqual([]);
|
|
1271
|
+
const joined = relayserverframe({
|
|
1272
|
+
state: created.state,
|
|
1273
|
+
connectionid: "conn-site",
|
|
1274
|
+
frame: relayframeof({
|
|
1275
|
+
op: "sessionjoin",
|
|
1276
|
+
opid: "op-2",
|
|
1277
|
+
at: now + 1,
|
|
1278
|
+
body: { role: "site", pairingcode: "DT-PEND" },
|
|
1279
|
+
}),
|
|
1280
|
+
now: now + 1,
|
|
1281
|
+
idof: () => "2",
|
|
1282
|
+
hashof: (t) => `h(${t})`,
|
|
1283
|
+
});
|
|
1284
|
+
expect(relaypendingpairings(joined.state, now + 1)).toEqual([]);
|
|
1285
|
+
expect(relayframeof({ op: "eventpost", opid: "op-x", at: now, body: {} })).toBe(
|
|
1286
|
+
JSON.stringify({ version: 1, op: "eventpost", opid: "op-x", at: now, body: {} }),
|
|
1287
|
+
);
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
describe("torture: stdio framing", () => {
|
|
1292
|
+
it("splits lines while partial tails stay buffered", () => {
|
|
1293
|
+
expect(splitlines("a\nb\n")).toEqual({ lines: ["a", "b"], rest: "" });
|
|
1294
|
+
expect(splitlines("a\nb")).toEqual({ lines: ["a"], rest: "b" });
|
|
1295
|
+
expect(splitlines("abc")).toEqual({ lines: [], rest: "abc" });
|
|
1296
|
+
expect(splitlines("")).toEqual({ lines: [], rest: "" });
|
|
1297
|
+
expect(splitlines("\n\n\n")).toEqual({ lines: [], rest: "" });
|
|
1298
|
+
expect(splitlines(" a \n b ")).toEqual({ lines: ["a"], rest: " b " });
|
|
1299
|
+
expect(splitlines("a\0b\nc").lines).toEqual(["a\0b"]);
|
|
1300
|
+
expect(splitlines("a\r\nb\n").lines).toEqual(["a", "b"]);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
it("pumps partial chunks into complete frames with the counters advancing", async () => {
|
|
1304
|
+
const written: string[] = [];
|
|
1305
|
+
const pump = createlinepump({
|
|
1306
|
+
write: (line) => {
|
|
1307
|
+
written.push(line);
|
|
1308
|
+
},
|
|
1309
|
+
handle: async (frame) => ({ jsonrpc: "2.0", id: frame.id ?? null, result: { echo: frame.method } }),
|
|
1310
|
+
now,
|
|
1311
|
+
});
|
|
1312
|
+
await pump.feed('{"jsonrpc":"2.0","id":1,');
|
|
1313
|
+
expect(written).toHaveLength(0);
|
|
1314
|
+
await pump.feed('"method":"ping"}\n{"jsonrpc":"2.0","id":2,"method":"ping"}\n');
|
|
1315
|
+
expect(written).toEqual([
|
|
1316
|
+
`${JSON.stringify({ jsonrpc: "2.0", id: 1, result: { echo: "ping" } })}\n`,
|
|
1317
|
+
`${JSON.stringify({ jsonrpc: "2.0", id: 2, result: { echo: "ping" } })}\n`,
|
|
1318
|
+
]);
|
|
1319
|
+
await pump.feed("\n\n");
|
|
1320
|
+
const record = pump.record();
|
|
1321
|
+
expect(record.kind).toBe("stdio");
|
|
1322
|
+
expect(record.endpoint).toBe("stdio://devthink");
|
|
1323
|
+
expect(record.received).toBe(2);
|
|
1324
|
+
expect(record.sent).toBe(2);
|
|
1325
|
+
expect(record.startedat).toBe(now);
|
|
1326
|
+
expect(record.lastframeat).toBe(now);
|
|
1327
|
+
const closed = pump.close(now + 10);
|
|
1328
|
+
expect(closed.closedat).toBe(now + 10);
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
it("answers notifications with nothing and decode failures with the parse error", async () => {
|
|
1332
|
+
const written: string[] = [];
|
|
1333
|
+
let handled = 0;
|
|
1334
|
+
const pump = createlinepump({
|
|
1335
|
+
write: (line) => {
|
|
1336
|
+
written.push(line);
|
|
1337
|
+
},
|
|
1338
|
+
handle: async (frame) => {
|
|
1339
|
+
handled += 1;
|
|
1340
|
+
return { jsonrpc: "2.0", id: frame.id ?? null, result: null };
|
|
1341
|
+
},
|
|
1342
|
+
now,
|
|
1343
|
+
});
|
|
1344
|
+
await pump.feed('{"jsonrpc":"2.0","method","notifications/only"}\n'.replace("method", ",method"));
|
|
1345
|
+
await pump.feed('{"jsonrpc":"2.0","method":"notify/x"}\n');
|
|
1346
|
+
expect(handled).toBe(0);
|
|
1347
|
+
expect(written).toHaveLength(1);
|
|
1348
|
+
expect(JSON.parse(written[0] ?? "{}")).toMatchObject({ jsonrpc: "2.0", id: null, error: { code: "parse" } });
|
|
1349
|
+
await pump.feed("not json at all\n");
|
|
1350
|
+
expect(written).toHaveLength(2);
|
|
1351
|
+
expect(JSON.parse(written[1] ?? "{}")).toMatchObject({ id: null, error: { code: "parse" } });
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
it("keeps the transport alive when one handler throws", async () => {
|
|
1355
|
+
const written: string[] = [];
|
|
1356
|
+
let calls = 0;
|
|
1357
|
+
const pump = createlinepump({
|
|
1358
|
+
write: (line) => {
|
|
1359
|
+
written.push(line);
|
|
1360
|
+
},
|
|
1361
|
+
handle: async (frame) => {
|
|
1362
|
+
calls += 1;
|
|
1363
|
+
if (calls === 1) throw new Error("handler exploded");
|
|
1364
|
+
return { jsonrpc: "2.0", id: frame.id ?? null, result: "ok" };
|
|
1365
|
+
},
|
|
1366
|
+
now,
|
|
1367
|
+
});
|
|
1368
|
+
await pump.feed(
|
|
1369
|
+
'{"jsonrpc":"2.0","id":"boom","method":"tools/call"}\n{"jsonrpc":"2.0","id":"fine","method":"tools/call"}\n',
|
|
1370
|
+
);
|
|
1371
|
+
expect(written).toHaveLength(2);
|
|
1372
|
+
expect(JSON.parse(written[0] ?? "{}")).toMatchObject({
|
|
1373
|
+
id: "boom",
|
|
1374
|
+
error: { code: "internal", message: "handler exploded" },
|
|
1375
|
+
});
|
|
1376
|
+
expect(JSON.parse(written[1] ?? "{}")).toMatchObject({ id: "fine", result: "ok" });
|
|
1377
|
+
expect(pump.record().received).toBe(2);
|
|
1378
|
+
});
|
|
1379
|
+
});
|