@dsh-workbench/shared 0.3.10 → 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/api/blocker-table.d.ts +3 -3
- package/dist/api/blocker-table.js +47 -23
- package/dist/api/dsh.d.ts +19 -1
- package/dist/api/host-write.d.ts +0 -15
- package/dist/api/host-write.js +1 -35
- package/dist/api/workbench-client.js +2 -1
- package/dist/components/Activity.css +0 -1
- package/dist/components/ApprovalDetail.css +1 -2
- package/dist/components/ApprovalDetail.d.ts +1 -1
- package/dist/components/ApprovalPlan.css +0 -1
- package/dist/components/Card.css +0 -1
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/CardBoundary.d.ts +1 -1
- package/dist/components/CardHead.css +0 -1
- package/dist/components/CardHead.d.ts +1 -1
- package/dist/components/ChoiceGroup.css +1 -2
- package/dist/components/ConfirmBar.d.ts +1 -1
- package/dist/components/ConfirmBar.js +1 -1
- package/dist/components/Form.css +1 -1
- package/dist/components/FormActions.js +1 -1
- package/dist/components/LongTaskRow.css +1 -2
- package/dist/components/LongTaskRow.d.ts +1 -1
- package/dist/components/LongTaskRow.js +1 -1
- package/dist/components/Note.css +0 -1
- package/dist/components/ResourceStates.css +0 -1
- package/dist/components/SettingsSection.css +0 -1
- package/dist/components/Spinner.css +0 -1
- package/dist/components/Stack.css +0 -1
- package/dist/components/StateBlock.css +0 -1
- package/dist/components/board-module.d.ts +3 -3
- package/dist/components/icons.d.ts +1 -7
- package/dist/components/icons.js +11 -16
- package/dist/config/constants.d.ts +2 -2
- package/dist/config/constants.js +2 -2
- package/dist/config/storage.d.ts +0 -4
- package/dist/config/storage.js +0 -11
- package/dist/contract/adapters.d.ts +10 -0
- package/dist/contract/cli.d.ts +3 -0
- package/dist/contract/data.d.ts +126 -0
- package/dist/{api → contract}/facts.d.ts +12 -3
- package/dist/{api → contract}/facts.js +19 -1
- package/dist/contract/needs.d.ts +132 -0
- package/dist/contract/needs.js +232 -0
- package/dist/contract/plugin-source.d.ts +1 -0
- package/dist/contract/plugin-source.js +1 -0
- package/dist/contract/protocol/bundle-install.d.ts +136 -0
- package/dist/contract/protocol/bundle-install.js +12 -0
- package/dist/contract/protocol/config-sync.d.ts +55 -0
- package/dist/contract/protocol/config-sync.js +2 -0
- package/dist/contract/protocol/diy.d.ts +4 -0
- package/dist/contract/protocol/diy.js +4 -0
- package/dist/contract/protocol/dsh-plugins.d.ts +11 -0
- package/dist/contract/protocol/dsh-plugins.js +1 -0
- package/dist/contract/protocol/long-tasks.d.ts +13 -0
- package/dist/contract/protocol/long-tasks.js +1 -0
- package/dist/contract/protocol/market-index.d.ts +195 -0
- package/dist/contract/protocol/market-index.js +7 -0
- package/dist/contract/protocol/platform.d.ts +44 -0
- package/dist/contract/protocol/platform.js +1 -0
- package/dist/contract/protocol/preflight-wake.d.ts +7 -0
- package/dist/contract/protocol/preflight-wake.js +1 -0
- package/dist/contract/protocol/profile-create.d.ts +30 -0
- package/dist/contract/protocol/profile-create.js +2 -0
- package/dist/contract/protocol/sidebar-edition.d.ts +14 -0
- package/dist/contract/protocol/sidebar-edition.js +2 -0
- package/dist/contract/protocol/unit-manifests.d.ts +6 -0
- package/dist/contract/protocol/unit-manifests.js +1 -0
- package/dist/contract/unit-board.d.ts +18 -0
- package/dist/contract/unit-board.js +1 -0
- package/dist/contract/unit-capability.d.ts +34 -0
- package/dist/contract/unit-capability.js +1 -0
- package/dist/{api → contract}/unit-declaration.d.ts +7 -5
- package/dist/{api → contract}/unit-declaration.js +19 -13
- package/dist/contract/unit.d.ts +57 -0
- package/dist/contract/unit.js +24 -0
- package/dist/contract/workbench-contract.d.ts +15 -0
- package/dist/contract/workbench-contract.js +1 -0
- package/dist/host/adapter-set.d.ts +26 -0
- package/dist/host/adapter-set.js +243 -0
- package/dist/host/adapter-types.d.ts +79 -0
- package/dist/host/adapter-types.js +23 -0
- package/dist/host/adapters.d.ts +22 -0
- package/dist/host/adapters.js +9 -0
- package/dist/host/browser.d.ts +3 -0
- package/dist/host/browser.js +3 -0
- package/dist/host/changelog.d.ts +21 -0
- package/dist/host/changelog.js +188 -0
- package/dist/host/channel-plugin.d.ts +15 -0
- package/dist/host/channel-plugin.js +42 -0
- package/dist/host/cli.d.ts +4 -0
- package/dist/host/cli.js +7 -0
- package/dist/host/command-name.d.ts +1 -0
- package/dist/host/command-name.js +14 -0
- package/dist/host/data-error.d.ts +13 -0
- package/dist/host/data-error.js +20 -0
- package/dist/host/data-root.js +3 -0
- package/dist/host/diy-debug.js +2 -1
- package/dist/host/diy-record.js +2 -1
- package/dist/host/facts.d.ts +1 -1
- package/dist/host/facts.js +3 -2
- package/dist/host/git.js +2 -2
- package/dist/host/http.js +1 -1
- package/dist/host/lib-capabilities.d.ts +22 -0
- package/dist/host/lib-capabilities.js +37 -0
- package/dist/host/lib-install.d.ts +9 -0
- package/dist/host/lib-install.js +10 -0
- package/dist/host/long-task.d.ts +1 -1
- package/dist/host/long-task.js +4 -3
- package/dist/host/market-index.d.ts +22 -0
- package/dist/host/market-index.js +420 -0
- package/dist/host/needs.d.ts +4 -0
- package/dist/host/needs.js +2 -0
- package/dist/host/patch.d.ts +9 -0
- package/dist/host/patch.js +78 -0
- package/dist/host/paths.d.ts +12 -0
- package/dist/host/paths.js +28 -0
- package/dist/host/platform.d.ts +14 -0
- package/dist/host/platform.js +40 -0
- package/dist/host/plugin-source.d.ts +8 -6
- package/dist/host/plugin-source.js +43 -33
- package/dist/host/plugin.d.ts +4 -0
- package/dist/host/plugin.js +39 -0
- package/dist/host/port-types.d.ts +191 -0
- package/dist/host/port-types.js +1 -0
- package/dist/host/preflight.d.ts +28 -0
- package/dist/host/preflight.js +1 -0
- package/dist/host/profile-port.d.ts +4 -0
- package/dist/host/profile-port.js +4 -0
- package/dist/host/provision.d.ts +2 -2
- package/dist/host/provision.js +5 -3
- package/dist/host/recipe-rules.d.ts +5 -0
- package/dist/host/recipe-rules.js +382 -0
- package/dist/host/recipe.d.ts +14 -0
- package/dist/host/recipe.js +283 -0
- package/dist/host/shell-text.d.ts +9 -2
- package/dist/host/shell-text.js +15 -3
- package/dist/host/shell.d.ts +2 -0
- package/dist/host/shell.js +7 -0
- package/dist/host/spec.d.ts +8 -0
- package/dist/host/spec.js +24 -0
- package/dist/host/supply-index.d.ts +34 -0
- package/dist/host/supply-index.js +202 -0
- package/dist/host/supply-port.d.ts +5 -0
- package/dist/host/supply-port.js +39 -0
- package/dist/host/time.d.ts +12 -0
- package/dist/host/time.js +19 -0
- package/dist/host/tokens.d.ts +26 -0
- package/dist/host/tokens.js +154 -0
- package/dist/host/tool-approval.d.ts +12 -0
- package/dist/host/tool-approval.js +4 -0
- package/dist/host/tool-kit.js +1 -1
- package/dist/host/unit-face.d.ts +5 -4
- package/dist/host/version-range.d.ts +2 -0
- package/dist/host/version-range.js +73 -0
- package/dist/host/version.d.ts +3 -0
- package/dist/host/version.js +29 -0
- package/dist/host.d.ts +47 -16
- package/dist/host.js +47 -16
- package/dist/i18n/en.js +259 -86
- package/dist/i18n/index.d.ts +333 -88
- package/dist/i18n/merge.js +1 -1
- package/dist/i18n/need-text.d.ts +26 -0
- package/dist/i18n/need-text.js +26 -0
- package/dist/i18n/settings.d.ts +70 -0
- package/dist/i18n/settings.js +136 -0
- package/dist/i18n/zh.d.ts +332 -87
- package/dist/i18n/zh.js +260 -87
- package/dist/index.d.ts +24 -8
- package/dist/index.js +24 -8
- package/dist/lib/blocker-action.d.ts +0 -15
- package/dist/lib/blocker-action.js +1 -25
- package/dist/{api → lib}/dsh-client.d.ts +12 -1
- package/dist/lib/dsh-client.js +1 -0
- package/dist/lib/market-index.d.ts +7 -0
- package/dist/lib/market-index.js +25 -0
- package/dist/lib/react-types.d.ts +5 -0
- package/dist/lib/react-types.js +1 -0
- package/dist/lib/react.d.ts +1 -40
- package/dist/lib/react.js +0 -36
- package/dist/lib/ui.js +3 -9
- package/dist/testing/data.d.ts +13 -0
- package/dist/testing/data.js +46 -0
- package/dist/testing/declaration.d.ts +1 -1
- package/dist/testing/declaration.js +1 -1
- package/dist/testing/host-fakes.d.ts +0 -13
- package/dist/testing/host-fakes.js +0 -24
- package/dist/testing/needs.d.ts +16 -0
- package/dist/testing/needs.js +46 -0
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +2 -0
- package/package.json +18 -2
- package/src/api/__tests__/blocker-table.test.ts +135 -86
- package/src/api/blocker-table.ts +52 -26
- package/src/api/dsh.ts +17 -1
- package/src/api/host-write.ts +0 -43
- package/src/api/workbench-client.test.ts +2 -2
- package/src/api/workbench-client.ts +2 -1
- package/src/components/Activity.css +0 -1
- package/src/components/ApprovalDetail.css +1 -2
- package/src/components/ApprovalDetail.tsx +1 -1
- package/src/components/ApprovalPlan.css +0 -1
- package/src/components/Card.css +0 -1
- package/src/components/Card.tsx +1 -1
- package/src/components/CardAnchor.test.tsx +1 -1
- package/src/components/CardBoundary.tsx +1 -1
- package/src/components/CardHead.css +0 -1
- package/src/components/CardHead.tsx +1 -1
- package/src/components/ChoiceGroup.css +1 -2
- package/src/components/ConfirmBar.tsx +1 -1
- package/src/components/Form.css +1 -1
- package/src/components/Form.tsx +1 -1
- package/src/components/FormActions.tsx +1 -1
- package/src/components/LongTaskRow.css +1 -2
- package/src/components/LongTaskRow.tsx +1 -1
- package/src/components/Note.css +0 -1
- package/src/components/ResourceStates.css +0 -1
- package/src/components/SelectFieldNodes.tsx +0 -1
- package/src/components/SettingsSection.css +0 -1
- package/src/components/Spinner.css +0 -1
- package/src/components/Stack.css +0 -1
- package/src/components/StateBlock.css +0 -1
- package/src/components/__tests__/long-task-row.test.ts +1 -1
- package/src/components/board-module.ts +3 -3
- package/src/components/icons.tsx +16 -24
- package/src/config/constants.ts +2 -2
- package/src/config/storage.test.ts +1 -25
- package/src/config/storage.ts +0 -12
- package/src/{api → contract}/__tests__/facts.test.ts +73 -25
- package/src/{api → contract}/__tests__/long-task.test.ts +3 -3
- package/src/contract/__tests__/needs.test.ts +74 -0
- package/src/{host → contract}/__tests__/unit-declaration.test.ts +1 -1
- package/src/contract/__tests__/unit.test.ts +43 -0
- package/src/contract/adapters.ts +9 -0
- package/src/contract/cli.ts +3 -0
- package/src/contract/data.ts +145 -0
- package/src/{api → contract}/facts.ts +34 -4
- package/src/contract/needs.ts +331 -0
- package/src/contract/plugin-source.ts +1 -0
- package/src/contract/protocol/bundle-install.ts +198 -0
- package/src/contract/protocol/config-sync.ts +65 -0
- package/src/contract/protocol/diy.ts +4 -0
- package/src/contract/protocol/dsh-plugins.ts +13 -0
- package/src/contract/protocol/long-tasks.ts +16 -0
- package/src/contract/protocol/market-index.ts +246 -0
- package/src/contract/protocol/platform.ts +47 -0
- package/src/contract/protocol/preflight-wake.ts +17 -0
- package/src/contract/protocol/profile-create.ts +36 -0
- package/src/contract/protocol/sidebar-edition.ts +19 -0
- package/src/contract/protocol/unit-manifests.ts +8 -0
- package/src/contract/unit-board.ts +23 -0
- package/src/contract/unit-capability.ts +37 -0
- package/src/{host → contract}/unit-declaration.ts +22 -18
- package/src/contract/unit.ts +76 -0
- package/src/contract/workbench-contract.ts +30 -0
- package/src/host/__tests__/adapter-set.test.ts +228 -0
- package/src/host/__tests__/adapters.test.ts +27 -0
- package/src/host/__tests__/changelog.test.ts +102 -0
- package/src/host/__tests__/command-name.test.ts +16 -0
- package/src/host/__tests__/diy-record.test.ts +3 -3
- package/src/host/__tests__/facts.test.ts +5 -5
- package/src/host/__tests__/long-task.test.ts +1 -1
- package/src/host/__tests__/market-index.test.ts +179 -0
- package/src/host/__tests__/plugin-ledger.test.ts +10 -10
- package/src/host/__tests__/provision.test.ts +7 -6
- package/src/host/__tests__/shell-text.test.ts +16 -1
- package/src/host/__tests__/supply-index.test.ts +137 -0
- package/src/host/__tests__/tokens.test.ts +110 -0
- package/src/host/__tests__/version-range.test.ts +21 -0
- package/src/host/adapter-set.ts +298 -0
- package/src/host/adapter-types.ts +105 -0
- package/src/host/adapters.ts +44 -0
- package/src/host/browser.ts +3 -0
- package/src/host/changelog.ts +212 -0
- package/src/host/channel-plugin.ts +55 -0
- package/src/host/cli.ts +11 -0
- package/src/host/command-name.ts +15 -0
- package/src/host/data-error.ts +21 -0
- package/src/host/data-root.ts +2 -0
- package/src/host/diy-debug.ts +2 -1
- package/src/host/diy-record.ts +2 -1
- package/src/host/facts.ts +4 -3
- package/src/host/git.ts +2 -2
- package/src/host/http.ts +1 -1
- package/src/host/lib-capabilities.ts +52 -0
- package/src/host/lib-install.ts +18 -0
- package/src/host/long-task.ts +4 -3
- package/src/host/market-index.ts +413 -0
- package/src/host/needs.ts +6 -0
- package/src/host/patch.ts +93 -0
- package/src/host/paths.ts +37 -0
- package/src/host/platform.ts +48 -0
- package/src/host/plugin-source.ts +48 -56
- package/src/host/plugin.ts +39 -0
- package/src/host/port-types.ts +222 -0
- package/src/host/preflight.ts +31 -0
- package/src/host/profile-port.ts +9 -0
- package/src/host/provision.ts +7 -4
- package/src/host/recipe-rules.ts +330 -0
- package/src/host/recipe.test.ts +334 -0
- package/src/host/recipe.ts +323 -0
- package/src/host/shell-text.ts +15 -3
- package/src/host/shell.ts +9 -0
- package/src/host/spec.ts +32 -0
- package/src/host/supply-index.ts +256 -0
- package/src/host/supply-port.ts +45 -0
- package/src/host/time.ts +22 -0
- package/src/host/tokens.ts +186 -0
- package/src/host/tool-approval.ts +17 -0
- package/src/host/tool-kit.ts +1 -1
- package/src/host/unit-face.ts +5 -4
- package/src/host/version-range.ts +71 -0
- package/src/host/version.ts +30 -0
- package/src/host.ts +47 -16
- package/src/i18n/en.ts +261 -86
- package/src/i18n/merge.test.ts +6 -6
- package/src/i18n/merge.ts +1 -1
- package/src/i18n/need-text.ts +27 -0
- package/src/i18n/settings.ts +141 -0
- package/src/i18n/zh.ts +263 -87
- package/src/index.ts +24 -8
- package/src/lib/blocker-action.ts +0 -28
- package/src/{api → lib}/dsh-client.ts +16 -2
- package/src/lib/market-index.ts +36 -0
- package/src/lib/react-types.ts +5 -0
- package/src/lib/react.ts +1 -40
- package/src/lib/text.test.ts +2 -2
- package/src/lib/ui.test.ts +6 -5
- package/src/lib/ui.ts +3 -9
- package/src/testing/__tests__/declaration.test.ts +1 -1
- package/src/testing/data.ts +66 -0
- package/src/testing/declaration.ts +2 -2
- package/src/testing/host-fakes.ts +0 -38
- package/src/testing/needs.ts +80 -0
- package/src/testing.ts +2 -0
- package/dist/api/unit-board.d.ts +0 -39
- package/dist/api/unit-board.js +0 -45
- package/dist/api/unit.d.ts +0 -85
- package/dist/api/unit.js +0 -8
- package/dist/api/workbench-contract.d.ts +0 -44
- package/dist/components/MockStrip.css +0 -39
- package/dist/components/MockStrip.d.ts +0 -15
- package/dist/components/MockStrip.js +0 -8
- package/dist/components/SaveBar.css +0 -7
- package/dist/host/browser-find.d.ts +0 -38
- package/dist/host/browser-find.js +0 -242
- package/dist/host/browser-read.d.ts +0 -28
- package/dist/host/browser-read.js +0 -243
- package/dist/host/opencli-runtime.d.ts +0 -25
- package/dist/host/opencli-runtime.js +0 -56
- package/dist/host/opencli-window.d.ts +0 -2
- package/dist/host/opencli-window.js +0 -8
- package/dist/host/opencli.d.ts +0 -37
- package/dist/host/opencli.js +0 -155
- package/dist/host/unit-declaration.d.ts +0 -65
- package/dist/host/unit-declaration.js +0 -213
- package/dist/host/unit-libs.d.ts +0 -37
- package/dist/lib/diy-debug.d.ts +0 -80
- package/dist/lib/diy-debug.js +0 -88
- package/dist/lib/diy-record.d.ts +0 -135
- package/dist/lib/diy-record.js +0 -162
- package/dist/lib/lint-probe.d.ts +0 -1
- package/dist/lib/lint-probe.js +0 -6
- package/dist/lib/panel-state.d.ts +0 -120
- package/dist/lib/panel-state.js +0 -176
- package/src/api/__tests__/host-write.test.ts +0 -75
- package/src/api/__tests__/unit-board.test.ts +0 -76
- package/src/api/__tests__/unit.test.ts +0 -38
- package/src/api/unit-board.ts +0 -69
- package/src/api/unit.ts +0 -93
- package/src/api/workbench-contract.ts +0 -59
- package/src/host/__tests__/browser-find.test.ts +0 -330
- package/src/host/__tests__/browser-read.test.ts +0 -228
- package/src/host/__tests__/opencli-runtime.test.ts +0 -46
- package/src/host/browser-find.ts +0 -311
- package/src/host/browser-read.ts +0 -311
- package/src/host/opencli-runtime.ts +0 -79
- package/src/host/opencli-window.ts +0 -11
- package/src/host/opencli.ts +0 -213
- package/src/host/unit-libs.ts +0 -41
- package/src/lib/blocker-action.test.ts +0 -40
- /package/dist/{api/blocker.js → contract/adapters.js} +0 -0
- /package/dist/{api → contract}/blocker.d.ts +0 -0
- /package/dist/{api/dsh-client.js → contract/blocker.js} +0 -0
- /package/dist/{api/workbench-contract.js → contract/cli.js} +0 -0
- /package/dist/{host/unit-libs.js → contract/data.js} +0 -0
- /package/dist/{api → contract}/long-task.d.ts +0 -0
- /package/dist/{api → contract}/long-task.js +0 -0
- /package/src/{api → contract}/blocker.ts +0 -0
- /package/src/{api → contract}/long-task.ts +0 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import type { CallOverrides, CredentialSource, RecipeAuth, RequestRecipe } from '../contract/data.js';
|
|
2
|
+
|
|
3
|
+
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
4
|
+
const RESPONSE_KINDS = ['json', 'text'];
|
|
5
|
+
const CONTENT_TYPES = ['json', 'form'];
|
|
6
|
+
const CREDENTIAL_HEADERS = [
|
|
7
|
+
'authorization',
|
|
8
|
+
'cookie',
|
|
9
|
+
'x-api-key',
|
|
10
|
+
'x-auth-token',
|
|
11
|
+
'x-csrf-token',
|
|
12
|
+
'x-xsrf-token',
|
|
13
|
+
];
|
|
14
|
+
export const DEFAULT_RECIPE_MAX_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
export const MAX_RECIPE_CALLS = 8;
|
|
16
|
+
const MAX_MAX_BYTES = 8 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
20
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
21
|
+
? (value as Record<string, unknown>)
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function textOf(value: unknown): string {
|
|
26
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function httpOriginProblem(value: unknown): string | undefined {
|
|
30
|
+
const text = textOf(value);
|
|
31
|
+
if (text === '') return 'origin 不能为空';
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(text);
|
|
34
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'origin 只认 http(s)';
|
|
35
|
+
if (url.origin !== text) return 'origin 只写到域(不带路径、不带尾斜杠)';
|
|
36
|
+
} catch {
|
|
37
|
+
return `origin 不是合法地址:${text}`;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function relativePathProblem(value: unknown, label: string): string | undefined {
|
|
43
|
+
const text = textOf(value);
|
|
44
|
+
if (text === '') return `${label} 不能为空`;
|
|
45
|
+
if (text.includes('\\')) return `${label} 里不许有反斜杠`;
|
|
46
|
+
if (text.startsWith('//')) return `${label} 不许用协议相对地址`;
|
|
47
|
+
if (text.includes('?') || text.includes('#')) return `${label} 不带查询 / 锚点,参数走 query`;
|
|
48
|
+
if (!text.startsWith('/')) return `${label} 要以 / 开头`;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function absoluteUrlProblem(value: unknown, label: string): string | undefined {
|
|
53
|
+
const text = textOf(value);
|
|
54
|
+
if (text === '') return `${label} 不能为空`;
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(text);
|
|
57
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return `${label} 只认 http(s)`;
|
|
58
|
+
} catch {
|
|
59
|
+
return `${label} 不是合法地址:${text}`;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requestPathProblem(value: unknown, origin: string, label: string): string | undefined {
|
|
65
|
+
const text = textOf(value);
|
|
66
|
+
const relative = relativePathProblem(value, label);
|
|
67
|
+
if (relative === undefined) return undefined;
|
|
68
|
+
if (text.startsWith('/') || text.startsWith('//') || text.includes('\\') || text.includes('?') || text.includes('#')) {
|
|
69
|
+
return relative;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(text);
|
|
73
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return `${label} 只认 http(s)`;
|
|
74
|
+
if (url.origin !== origin) return `${label} 要和 origin 同源`;
|
|
75
|
+
} catch {
|
|
76
|
+
return `${label} 要以 / 开头,或者是同源的绝对地址`;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolvedUrlProblem(value: unknown, origin: string, label: string): string | undefined {
|
|
82
|
+
const text = textOf(value);
|
|
83
|
+
if (text === '') return `${label} 不能为空`;
|
|
84
|
+
if (text.startsWith('//')) return `${label} 不许用协议相对地址`;
|
|
85
|
+
return absoluteUrlProblem(text.startsWith('/') ? `${origin}${text}` : text, label);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function queryProblem(value: unknown): string | undefined {
|
|
89
|
+
if (value === undefined) return undefined;
|
|
90
|
+
const map = recordOf(value);
|
|
91
|
+
if (map === undefined) return 'query 要是对象';
|
|
92
|
+
for (const [name, entry] of Object.entries(map)) {
|
|
93
|
+
if (name.trim() === '') return 'query 的键不能为空';
|
|
94
|
+
if (typeof entry !== 'string' && typeof entry !== 'number' && typeof entry !== 'boolean') {
|
|
95
|
+
return `query.${name} 只能是字符串 / 数字 / 布尔`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function headersProblem(value: unknown): string | undefined {
|
|
102
|
+
if (value === undefined) return undefined;
|
|
103
|
+
const map = recordOf(value);
|
|
104
|
+
if (map === undefined) return 'headers 要是对象';
|
|
105
|
+
for (const [name, entry] of Object.entries(map)) {
|
|
106
|
+
if (name.trim() === '') return 'headers 的键不能为空';
|
|
107
|
+
if (typeof entry !== 'string') return `headers.${name} 要是字符串`;
|
|
108
|
+
if (CREDENTIAL_HEADERS.includes(name.toLowerCase())) {
|
|
109
|
+
return `headers 里不许写凭据头 ${name} —— 凭据走 auth.place`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function bodyProblem(value: unknown, contentType: string): string | undefined {
|
|
116
|
+
if (value === undefined) return undefined;
|
|
117
|
+
if (contentType === 'form') {
|
|
118
|
+
const map = recordOf(value);
|
|
119
|
+
if (map === undefined) return 'contentType: form 时 body 要是对象';
|
|
120
|
+
for (const [name, entry] of Object.entries(map)) {
|
|
121
|
+
if (typeof entry !== 'string') return `contentType: form 时 body.${name} 要是字符串`;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
JSON.stringify(value);
|
|
127
|
+
} catch {
|
|
128
|
+
return 'body 要能 JSON 序列化';
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sourceProblem(value: unknown, origin: string): string | undefined {
|
|
134
|
+
const source = recordOf(value);
|
|
135
|
+
if (source === undefined) return 'auth.source 要是对象';
|
|
136
|
+
const kind = textOf(source['kind']);
|
|
137
|
+
if (kind === 'cookie') return undefined;
|
|
138
|
+
if (kind === 'cookieValue') {
|
|
139
|
+
return textOf(source['name']) === '' ? 'auth.source.name 不能为空' : undefined;
|
|
140
|
+
}
|
|
141
|
+
if (kind === 'meta') {
|
|
142
|
+
return textOf(source['name']) === '' ? 'auth.source.name 不能为空' : undefined;
|
|
143
|
+
}
|
|
144
|
+
if (kind === 'global') {
|
|
145
|
+
return textOf(source['path']) === '' ? 'auth.source.path 不能为空' : undefined;
|
|
146
|
+
}
|
|
147
|
+
if (kind === 'storage') {
|
|
148
|
+
if (textOf(source['area']) !== 'local' && textOf(source['area']) !== 'session') {
|
|
149
|
+
return 'auth.source.area 只认 local / session';
|
|
150
|
+
}
|
|
151
|
+
return textOf(source['key']) === '' ? 'auth.source.key 不能为空' : undefined;
|
|
152
|
+
}
|
|
153
|
+
if (kind === 'endpoint') {
|
|
154
|
+
const urlIssue = resolvedUrlProblem(source['url'], origin, 'auth.source.url');
|
|
155
|
+
if (urlIssue !== undefined) return urlIssue;
|
|
156
|
+
const method = textOf(source['method']);
|
|
157
|
+
if (method !== '' && method !== 'GET' && method !== 'POST') {
|
|
158
|
+
return 'auth.source.method 只认 GET / POST';
|
|
159
|
+
}
|
|
160
|
+
if (textOf(source['path']) === '') return 'auth.source.path(token 在返回里的位置)不能为空';
|
|
161
|
+
return bodyProblem(source['body'], 'json');
|
|
162
|
+
}
|
|
163
|
+
return `不认识的 auth.source.kind:${kind === '' ? '(空)' : kind}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function placeProblem(value: unknown, source: CredentialSource | undefined): string | undefined {
|
|
167
|
+
const place = recordOf(value);
|
|
168
|
+
if (place === undefined) return 'auth.place 要是对象';
|
|
169
|
+
const where = textOf(place['in']);
|
|
170
|
+
if (where === 'cookie') {
|
|
171
|
+
if (source !== undefined && source.kind !== 'cookie') {
|
|
172
|
+
return '凭据不是 cookie,place.in 不能是 cookie';
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
if (where === 'header' || where === 'query') {
|
|
177
|
+
if (textOf(place['name']) === '') return 'auth.place.name 不能为空';
|
|
178
|
+
if (source !== undefined && source.kind === 'cookie') {
|
|
179
|
+
return 'cookie 来源没有 token 可放,place.in 要写 cookie';
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
if (where === 'body') {
|
|
184
|
+
if (textOf(place['field']) === '') return 'auth.place.field 不能为空';
|
|
185
|
+
if (source !== undefined && source.kind === 'cookie') {
|
|
186
|
+
return 'cookie 来源没有 token 可放,place.in 要写 cookie';
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
return `不认识的 auth.place.in:${where === '' ? '(空)' : where}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function authProblem(value: unknown, origin: string): string | undefined {
|
|
194
|
+
if (value === undefined) return undefined;
|
|
195
|
+
const auth = recordOf(value);
|
|
196
|
+
if (auth === undefined) return 'auth 要是对象';
|
|
197
|
+
if (textOf(auth['kind']) === 'none') {
|
|
198
|
+
if (auth['source'] !== undefined || auth['place'] !== undefined) {
|
|
199
|
+
return 'auth.kind: none 时不许带 source / place';
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
if (auth['kind'] !== undefined) return 'auth.kind 只认 none';
|
|
204
|
+
const sourceIssue = sourceProblem(auth['source'], origin);
|
|
205
|
+
if (sourceIssue !== undefined) return sourceIssue;
|
|
206
|
+
return placeProblem(auth['place'], auth['source'] as CredentialSource);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function maxBytesProblem(value: unknown): string | undefined {
|
|
210
|
+
if (value === undefined) return undefined;
|
|
211
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return 'response.maxBytes 要是正整数';
|
|
212
|
+
if (value > MAX_MAX_BYTES) return `response.maxBytes 最大 ${MAX_MAX_BYTES}`;
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function responseProblem(value: unknown): string | undefined {
|
|
217
|
+
if (value === undefined) return undefined;
|
|
218
|
+
const response = recordOf(value);
|
|
219
|
+
if (response === undefined) return 'response 要是对象';
|
|
220
|
+
const kind = textOf(response['kind']);
|
|
221
|
+
if (!RESPONSE_KINDS.includes(kind)) return `response.kind 只认 ${RESPONSE_KINDS.join(' / ')}`;
|
|
222
|
+
if (response['path'] !== undefined && textOf(response['path']) === '') return 'response.path 不能为空';
|
|
223
|
+
return maxBytesProblem(response['maxBytes']);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function responseOverrideProblem(value: unknown): string | undefined {
|
|
227
|
+
if (value === undefined) return undefined;
|
|
228
|
+
const response = recordOf(value);
|
|
229
|
+
if (response === undefined) return 'response 要是对象';
|
|
230
|
+
if (response['kind'] !== undefined && !RESPONSE_KINDS.includes(textOf(response['kind']))) {
|
|
231
|
+
return `response.kind 只认 ${RESPONSE_KINDS.join(' / ')}`;
|
|
232
|
+
}
|
|
233
|
+
if (response['path'] !== undefined && textOf(response['path']) === '') return 'response.path 不能为空';
|
|
234
|
+
return maxBytesProblem(response['maxBytes']);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function authOf(recipe: RequestRecipe): RecipeAuth {
|
|
238
|
+
return recipe.auth ?? { source: { kind: 'cookie' }, place: { in: 'cookie' } };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function overridesProblem(recipe: RequestRecipe, overrides: CallOverrides): string | undefined {
|
|
242
|
+
if (overrides.path !== undefined) {
|
|
243
|
+
const pathIssue = requestPathProblem(overrides.path, recipe.origin, 'path');
|
|
244
|
+
if (pathIssue !== undefined) return pathIssue;
|
|
245
|
+
}
|
|
246
|
+
const queryIssue = queryProblem(overrides.query);
|
|
247
|
+
if (queryIssue !== undefined) return queryIssue;
|
|
248
|
+
const headersIssue = headersProblem(overrides.headers);
|
|
249
|
+
if (headersIssue !== undefined) return headersIssue;
|
|
250
|
+
const responseIssue = responseOverrideProblem(overrides.response);
|
|
251
|
+
if (responseIssue !== undefined) return responseIssue;
|
|
252
|
+
const contentType = recipe.request.contentType ?? 'json';
|
|
253
|
+
const bodyIssue = bodyProblem(overrides.body, contentType);
|
|
254
|
+
if (bodyIssue !== undefined) return bodyIssue;
|
|
255
|
+
if (overrides.body !== undefined && recipe.request.method === 'GET') {
|
|
256
|
+
return 'GET 不带 body';
|
|
257
|
+
}
|
|
258
|
+
const auth = authOf(recipe);
|
|
259
|
+
const place = auth.kind === 'none' ? undefined : auth.place;
|
|
260
|
+
if (place?.in === 'body' && overrides.body !== undefined && recordOf(overrides.body) === undefined) {
|
|
261
|
+
return 'auth.place 是 body 时 overrides.body 要是对象';
|
|
262
|
+
}
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
export function recipeProblemOf(recipe: unknown, overrides?: CallOverrides): string | undefined {
|
|
268
|
+
const box = recordOf(recipe);
|
|
269
|
+
if (box === undefined) return '配方要是对象';
|
|
270
|
+
const originIssue = httpOriginProblem(box['origin']);
|
|
271
|
+
if (originIssue !== undefined) return originIssue;
|
|
272
|
+
const origin = textOf(box['origin']);
|
|
273
|
+
if (box['loginUrl'] !== undefined) {
|
|
274
|
+
const loginIssue = absoluteUrlProblem(box['loginUrl'], 'loginUrl');
|
|
275
|
+
if (loginIssue !== undefined) return loginIssue;
|
|
276
|
+
}
|
|
277
|
+
if (box['session'] !== undefined && textOf(box['session']) === '') return 'session 不能为空';
|
|
278
|
+
if (box['timeoutMs'] !== undefined) {
|
|
279
|
+
const timeout = box['timeoutMs'];
|
|
280
|
+
if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
|
|
281
|
+
return 'timeoutMs 要是正整数';
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const request = recordOf(box['request']);
|
|
285
|
+
if (request === undefined) return 'request 要是对象';
|
|
286
|
+
const method = textOf(request['method']);
|
|
287
|
+
if (!METHODS.includes(method)) return `request.method 只认 ${METHODS.join(' / ')}`;
|
|
288
|
+
const pathIssue = requestPathProblem(request['path'], origin, 'request.path');
|
|
289
|
+
if (pathIssue !== undefined) return pathIssue;
|
|
290
|
+
const queryIssue = queryProblem(request['query']);
|
|
291
|
+
if (queryIssue !== undefined) return queryIssue;
|
|
292
|
+
const headersIssue = headersProblem(request['headers']);
|
|
293
|
+
if (headersIssue !== undefined) return headersIssue;
|
|
294
|
+
const contentType = textOf(request['contentType']);
|
|
295
|
+
if (contentType !== '' && !CONTENT_TYPES.includes(contentType)) {
|
|
296
|
+
return `request.contentType 只认 ${CONTENT_TYPES.join(' / ')}`;
|
|
297
|
+
}
|
|
298
|
+
const bodyIssue = bodyProblem(request['body'], contentType === 'form' ? 'form' : 'json');
|
|
299
|
+
if (bodyIssue !== undefined) return bodyIssue;
|
|
300
|
+
if (request['body'] !== undefined && method === 'GET') return 'GET 不带 body';
|
|
301
|
+
const authIssue = authProblem(box['auth'], origin);
|
|
302
|
+
if (authIssue !== undefined) return authIssue;
|
|
303
|
+
const auth = recordOf(box['auth']);
|
|
304
|
+
const place = auth === undefined ? undefined : recordOf(auth['place']);
|
|
305
|
+
if (place !== undefined && textOf(place['in']) === 'body' && request['body'] !== undefined) {
|
|
306
|
+
if (recordOf(request['body']) === undefined) return 'auth.place 是 body 时 request.body 要是对象';
|
|
307
|
+
}
|
|
308
|
+
const responseIssue = responseProblem(box['response']);
|
|
309
|
+
if (responseIssue !== undefined) return responseIssue;
|
|
310
|
+
if (overrides === undefined) return undefined;
|
|
311
|
+
return overridesProblem(recipe as RequestRecipe, overrides);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function callsProblemOf(recipe: unknown, calls: unknown): string | undefined {
|
|
315
|
+
const recipeIssue = recipeProblemOf(recipe);
|
|
316
|
+
if (recipeIssue !== undefined) return recipeIssue;
|
|
317
|
+
const box = recordOf(calls);
|
|
318
|
+
if (box === undefined) return 'calls 要是对象,键就是结果名';
|
|
319
|
+
const names = Object.keys(box);
|
|
320
|
+
if (names.length === 0) return 'calls 不能为空';
|
|
321
|
+
if (names.length > MAX_RECIPE_CALLS) return `一次最多 ${MAX_RECIPE_CALLS} 条 calls`;
|
|
322
|
+
for (const name of names) {
|
|
323
|
+
if (name.trim() === '') return 'calls 的键不能为空';
|
|
324
|
+
if (recordOf(box[name]) === undefined) return `calls.${name} 要是对象`;
|
|
325
|
+
const issue = overridesProblem(recipe as RequestRecipe, box[name] as CallOverrides);
|
|
326
|
+
if (issue !== undefined) return `calls.${name}:${issue}`;
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { CallOverrides, RequestRecipe } from '../contract/data.js';
|
|
3
|
+
import { describeRecipe, scriptOf, scriptOfMany } from './recipe.js';
|
|
4
|
+
import { callsProblemOf, MAX_RECIPE_CALLS, recipeProblemOf } from './recipe-rules.js';
|
|
5
|
+
|
|
6
|
+
const BASE: RequestRecipe = {
|
|
7
|
+
origin: 'https://x.example.com',
|
|
8
|
+
request: { method: 'GET', path: '/api/balance' },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('scriptOf · 六种配方', () => {
|
|
12
|
+
it('公开:只判 origin 再 fetch,不带 token', () => {
|
|
13
|
+
const script = scriptOf({ ...BASE, auth: { kind: 'none' } });
|
|
14
|
+
expect(script.startsWith('(async () => {')).toBe(true);
|
|
15
|
+
expect(script.endsWith('})()')).toBe(true);
|
|
16
|
+
expect(script).toContain('if (location.origin !== "https://x.example.com")');
|
|
17
|
+
expect(script).toContain('let url = "https://x.example.com/api/balance";');
|
|
18
|
+
expect(script).toContain('credentials: "include"');
|
|
19
|
+
expect(script).not.toContain('token');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('cookie 是默认值:不换 token、不加头', () => {
|
|
23
|
+
const script = scriptOf(BASE);
|
|
24
|
+
expect(script).not.toContain('document.cookie');
|
|
25
|
+
expect(script).not.toContain('const token');
|
|
26
|
+
expect(script).toContain('const headers = {};');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('bearer-refresh:先 POST 换 token,再按路径取', () => {
|
|
30
|
+
const script = scriptOf({
|
|
31
|
+
...BASE,
|
|
32
|
+
auth: {
|
|
33
|
+
source: {
|
|
34
|
+
kind: 'endpoint',
|
|
35
|
+
url: '/api/user/auth/refresh',
|
|
36
|
+
method: 'POST',
|
|
37
|
+
body: {},
|
|
38
|
+
path: 'data.access_token',
|
|
39
|
+
},
|
|
40
|
+
place: { in: 'header', name: 'authorization', prefix: 'Bearer ' },
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
expect(script).toContain('fetch("https://x.example.com/api/user/auth/refresh"');
|
|
44
|
+
expect(script).toContain('const token = pick(preJson, "data.access_token");');
|
|
45
|
+
expect(script).toContain('headers["authorization"] = "Bearer " + token;');
|
|
46
|
+
expect(script).toContain('if (pre.status === 401 || pre.status === 403)');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('CSRF 从 meta 取,放到头里', () => {
|
|
50
|
+
const script = scriptOf({
|
|
51
|
+
...BASE,
|
|
52
|
+
auth: { source: { kind: 'meta', name: 'csrf-token' }, place: { in: 'header', name: 'X-CSRF-Token' } },
|
|
53
|
+
});
|
|
54
|
+
expect(script).toContain('document.querySelector("meta[name=\\"csrf-token\\"]")');
|
|
55
|
+
expect(script).toContain('headers["X-CSRF-Token"] = "" + token;');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('CSRF 从 cookie 取,先 decodeURIComponent', () => {
|
|
59
|
+
const script = scriptOf({
|
|
60
|
+
...BASE,
|
|
61
|
+
auth: { source: { kind: 'cookieValue', name: 'XSRF-TOKEN' }, place: { in: 'header', name: 'X-XSRF-TOKEN' } },
|
|
62
|
+
});
|
|
63
|
+
expect(script).toContain('document.cookie.split("; ")');
|
|
64
|
+
expect(script).toContain('decodeURIComponent(hit.slice(name.length + 1))');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('页面全局变量与 storage 两种来源', () => {
|
|
68
|
+
const global = scriptOf({
|
|
69
|
+
...BASE,
|
|
70
|
+
auth: { source: { kind: 'global', path: '__INITIAL_STATE__.token' }, place: { in: 'header', name: 'authorization', prefix: 'Bearer ' } },
|
|
71
|
+
});
|
|
72
|
+
expect(global).toContain('const token = pick(globalThis, "__INITIAL_STATE__.token");');
|
|
73
|
+
|
|
74
|
+
const storage = scriptOf({
|
|
75
|
+
...BASE,
|
|
76
|
+
auth: {
|
|
77
|
+
source: { kind: 'storage', area: 'session', key: 'token', path: 'access_token' },
|
|
78
|
+
place: { in: 'header', name: 'authorization', prefix: 'Bearer ' },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
expect(storage).toContain('sessionStorage.getItem("token")');
|
|
82
|
+
expect(storage).toContain('const token = pick(parsed, "access_token");');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('token 放 query / body 的两种 place', () => {
|
|
86
|
+
const query = scriptOf({
|
|
87
|
+
...BASE,
|
|
88
|
+
auth: { source: { kind: 'cookieValue', name: 'sid' }, place: { in: 'query', name: 'token' } },
|
|
89
|
+
});
|
|
90
|
+
expect(query).toContain('url += (url.indexOf("?") === -1 ? "?" : "&") + "token="');
|
|
91
|
+
expect(query).toContain('encodeURIComponent(token)');
|
|
92
|
+
|
|
93
|
+
const body = scriptOf({
|
|
94
|
+
...BASE,
|
|
95
|
+
request: { method: 'POST', path: '/api/do', body: { a: 1 } },
|
|
96
|
+
auth: { source: { kind: 'cookieValue', name: 'sid' }, place: { in: 'body', field: 'meta.token' } },
|
|
97
|
+
});
|
|
98
|
+
expect(body).toContain('set(body, "meta.token", token);');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('form 请求体编成 URLSearchParams,并补 content-type', () => {
|
|
102
|
+
const script = scriptOf({
|
|
103
|
+
...BASE,
|
|
104
|
+
request: { method: 'POST', path: '/api/issues', contentType: 'form', body: { title: 'a' } },
|
|
105
|
+
auth: { source: { kind: 'meta', name: 'csrf' }, place: { in: 'body', field: 'csrf' } },
|
|
106
|
+
});
|
|
107
|
+
expect(script).toContain('new URLSearchParams({"title":"a"})');
|
|
108
|
+
expect(script).toContain('body.set("csrf", token);');
|
|
109
|
+
expect(script).toContain('"content-type":"application/x-www-form-urlencoded"');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('response: text 直接回文本;json 带 path 时取那一格', () => {
|
|
113
|
+
const text = scriptOf({ ...BASE, response: { kind: 'text' } });
|
|
114
|
+
expect(text).toContain('return { ok: true, text };');
|
|
115
|
+
|
|
116
|
+
const json = scriptOf({ ...BASE, response: { kind: 'json', path: 'data.list' } });
|
|
117
|
+
expect(json).toContain('const value = pick(data, "data.list");');
|
|
118
|
+
expect(json).toContain('if (value === undefined)');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('overrides 合并 query 与 body,不污染配方', () => {
|
|
122
|
+
const recipe: RequestRecipe = {
|
|
123
|
+
...BASE,
|
|
124
|
+
request: { method: 'POST', path: '/api/do', query: { a: '1' }, body: { x: 1 } },
|
|
125
|
+
};
|
|
126
|
+
const script = scriptOf(recipe, { query: { b: '2' }, body: { x: 2 } });
|
|
127
|
+
expect(script).toContain('?a=1&b=2');
|
|
128
|
+
expect(script).toContain('let body = {"x":2};');
|
|
129
|
+
expect(recipe.request.body).toEqual({ x: 1 });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('JSON 请求体在页面里 stringify,不把对象直接交给 fetch', () => {
|
|
133
|
+
const script = scriptOf({ ...BASE, request: { method: 'POST', path: '/api/do', body: { a: 1 } } });
|
|
134
|
+
expect(script).toContain('let body = {"a":1};');
|
|
135
|
+
expect(script).toContain('if (typeof body !== "string") body = JSON.stringify(body);');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('换登录态那一次请求的 body 也 stringify', () => {
|
|
139
|
+
const script = scriptOf({
|
|
140
|
+
...BASE,
|
|
141
|
+
auth: {
|
|
142
|
+
source: { kind: 'endpoint', url: '/r', method: 'POST', body: { a: 1 }, path: 't' },
|
|
143
|
+
place: { in: 'header', name: 'x-token' },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
expect(script).toContain('body: JSON.stringify({"a":1})');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('返回值里不带凭据', () => {
|
|
150
|
+
const script = scriptOf({
|
|
151
|
+
...BASE,
|
|
152
|
+
auth: { source: { kind: 'global', path: 'token' }, place: { in: 'header', name: 'authorization' } },
|
|
153
|
+
});
|
|
154
|
+
expect(script).not.toContain('return { ok: true, token');
|
|
155
|
+
expect(script).toContain('return { ok: true, data };');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('describeRecipe', () => {
|
|
160
|
+
it('回 method / url / body / 凭据位置,不回 token', () => {
|
|
161
|
+
const described = describeRecipe(
|
|
162
|
+
{
|
|
163
|
+
...BASE,
|
|
164
|
+
request: { method: 'GET', path: '/api/balance', query: { limit: 5 } },
|
|
165
|
+
auth: { source: { kind: 'cookieValue', name: 'sid' }, place: { in: 'header', name: 'authorization', prefix: 'Bearer ' } },
|
|
166
|
+
},
|
|
167
|
+
{ query: { limit: 10 } },
|
|
168
|
+
);
|
|
169
|
+
expect(described).toEqual({
|
|
170
|
+
method: 'GET',
|
|
171
|
+
url: 'https://x.example.com/api/balance?limit=10',
|
|
172
|
+
auth: { source: 'cookieValue', place: 'header', name: 'authorization' },
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('没有 auth 时不出 auth 字段;body 原样回', () => {
|
|
177
|
+
const described = describeRecipe({ ...BASE, request: { method: 'POST', path: '/api/do', body: { a: 1 } } });
|
|
178
|
+
expect(described.body).toEqual({ a: 1 });
|
|
179
|
+
expect(described.auth).toEqual({ source: 'cookie', place: 'cookie' });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('recipeProblemOf · 校验', () => {
|
|
184
|
+
const bad = (recipe: unknown): string | undefined => recipeProblemOf(recipe);
|
|
185
|
+
|
|
186
|
+
it('origin 只收裸 origin', () => {
|
|
187
|
+
expect(bad({ ...BASE, origin: 'https://x.example.com/' })).toContain('origin');
|
|
188
|
+
expect(bad({ ...BASE, origin: 'ftp://x.example.com' })).toContain('http');
|
|
189
|
+
expect(bad({ ...BASE, origin: '' })).toContain('origin');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('path 不许带查询 / 反斜杠 / 协议相对;绝对地址要同源', () => {
|
|
193
|
+
expect(bad({ ...BASE, request: { method: 'GET', path: '/a?b=1' } })).toContain('查询');
|
|
194
|
+
expect(bad({ ...BASE, request: { method: 'GET', path: '/a\\b' } })).toContain('反斜杠');
|
|
195
|
+
expect(bad({ ...BASE, request: { method: 'GET', path: '//evil.test/a' } })).toContain('协议相对');
|
|
196
|
+
expect(bad({ ...BASE, request: { method: 'GET', path: 'https://evil.test/a' } })).toContain('同源');
|
|
197
|
+
expect(bad({ ...BASE, request: { method: 'GET', path: 'https://x.example.com/a' } })).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('method / contentType / response.kind 只认枚举', () => {
|
|
201
|
+
expect(bad({ ...BASE, request: { method: 'HEAD', path: '/a' } })).toContain('request.method');
|
|
202
|
+
expect(bad({ ...BASE, request: { method: 'GET', path: '/a', contentType: 'xml' } })).toContain('contentType');
|
|
203
|
+
expect(bad({ ...BASE, response: { kind: 'xml' } })).toContain('response.kind');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('凭据头写死 → 红(request.headers 与 overrides 都拦)', () => {
|
|
207
|
+
expect(bad({ ...BASE, request: { method: 'GET', path: '/a', headers: { Authorization: 'Bearer x' } } })).toContain('auth.place');
|
|
208
|
+
expect(() => scriptOf(BASE, { headers: { cookie: 'a=b' } })).toThrow('auth.place');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('GET 不带 body;form body 只要字符串', () => {
|
|
212
|
+
expect(bad({ ...BASE, request: { method: 'GET', path: '/a', body: { x: 1 } } })).toContain('GET');
|
|
213
|
+
expect(bad({ ...BASE, request: { method: 'POST', path: '/a', contentType: 'form', body: { x: 1 } } })).toContain('字符串');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('auth 的 source / place 成对,且 cookie 来源不能放 header', () => {
|
|
217
|
+
expect(bad({ ...BASE, auth: { source: { kind: 'cookie' } } })).toContain('auth.place');
|
|
218
|
+
expect(
|
|
219
|
+
bad({ ...BASE, auth: { source: { kind: 'cookie' }, place: { in: 'header', name: 'authorization' } } }),
|
|
220
|
+
).toContain('place.in 要写 cookie');
|
|
221
|
+
expect(
|
|
222
|
+
bad({ ...BASE, auth: { kind: 'none', source: { kind: 'cookie' }, place: { in: 'cookie' } } }),
|
|
223
|
+
).toContain('source / place');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('source 的必填格与枚举', () => {
|
|
227
|
+
expect(bad({ ...BASE, auth: { source: { kind: 'meta' }, place: { in: 'header', name: 'x' } } })).toContain('name');
|
|
228
|
+
expect(bad({ ...BASE, auth: { source: { kind: 'storage', area: 'memory', key: 'k' }, place: { in: 'header', name: 'x' } } })).toContain('area');
|
|
229
|
+
expect(bad({ ...BASE, auth: { source: { kind: 'endpoint', url: '/r', path: '' }, place: { in: 'header', name: 'x' } } })).toContain('path');
|
|
230
|
+
expect(bad({ ...BASE, auth: { source: { kind: 'nope' }, place: { in: 'header', name: 'x' } } })).toContain('kind');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('endpoint.url 可以相对,也可以绝对;loginUrl 只收 http(s)', () => {
|
|
234
|
+
expect(
|
|
235
|
+
bad({ ...BASE, auth: { source: { kind: 'endpoint', url: '/r', path: 't' }, place: { in: 'header', name: 'x' } } }),
|
|
236
|
+
).toBeUndefined();
|
|
237
|
+
expect(
|
|
238
|
+
bad({ ...BASE, auth: { source: { kind: 'endpoint', url: 'https://sso.example.com/r', path: 't' }, place: { in: 'header', name: 'x' } } }),
|
|
239
|
+
).toBeUndefined();
|
|
240
|
+
expect(bad({ ...BASE, loginUrl: 'javascript:alert(1)' })).toContain('loginUrl');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('maxBytes 是正整数且有上限', () => {
|
|
244
|
+
expect(bad({ ...BASE, response: { kind: 'json', maxBytes: 0 } })).toContain('maxBytes');
|
|
245
|
+
expect(bad({ ...BASE, response: { kind: 'json', maxBytes: 9 * 1024 * 1024 } })).toContain('maxBytes');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('参数里的引号与反斜杠被 JSON.stringify 转义,不能改脚本结构', () => {
|
|
249
|
+
const script = scriptOf({
|
|
250
|
+
...BASE,
|
|
251
|
+
request: { method: 'GET', path: '/api/do', headers: { 'x-note': 'a"b\\c' } },
|
|
252
|
+
});
|
|
253
|
+
expect(script).toContain('"x-note":"a\\"b\\\\c"');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('scriptOfMany · 批量读', () => {
|
|
258
|
+
it('一次换 token、N 条并发,各自取自己那一格', () => {
|
|
259
|
+
const script = scriptOfMany(
|
|
260
|
+
{
|
|
261
|
+
...BASE,
|
|
262
|
+
auth: {
|
|
263
|
+
source: { kind: 'endpoint', url: '/api/user/auth/refresh', method: 'POST', body: {}, path: 'data.access_token' },
|
|
264
|
+
place: { in: 'header', name: 'authorization', prefix: 'Bearer ' },
|
|
265
|
+
},
|
|
266
|
+
request: { method: 'GET', path: '/api/subscription/self', query: { lang: 'zh' } },
|
|
267
|
+
response: { kind: 'json', path: 'data' },
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
wallet: { path: '/api/wallet/balance', response: { path: 'data.balance' } },
|
|
271
|
+
usage: { query: { days: 7 }, headers: { 'x-trace': '1' } },
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
expect(script).toContain('fetch("https://x.example.com/api/user/auth/refresh"');
|
|
275
|
+
expect(script).toContain('headers["authorization"] = "Bearer " + token;');
|
|
276
|
+
expect(script).toContain('one(withToken("https://x.example.com/api/wallet/balance?lang=zh"), headers, "json", "data.balance"');
|
|
277
|
+
expect(script).toContain('one(withToken("https://x.example.com/api/subscription/self?lang=zh&days=7"), Object.assign({}, headers, {"x-trace":"1"}), "json", "data"');
|
|
278
|
+
expect(script).toContain('await Promise.all([');
|
|
279
|
+
expect(script).toContain('return { ok: true, results };');
|
|
280
|
+
expect(script).not.toContain('auth.access_token');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('没有 token 的批量读不带 withToken 前缀', () => {
|
|
284
|
+
const script = scriptOfMany(
|
|
285
|
+
{ ...BASE, auth: { kind: 'none' } },
|
|
286
|
+
{ a: { path: '/a' }, b: { path: '/b' } },
|
|
287
|
+
);
|
|
288
|
+
expect(script).toContain('const withToken = (url) => url;');
|
|
289
|
+
expect(script).not.toContain('const token');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('token 放 query 时逐条拼上去', () => {
|
|
293
|
+
const script = scriptOfMany(
|
|
294
|
+
{
|
|
295
|
+
...BASE,
|
|
296
|
+
auth: { source: { kind: 'global', path: 'state.token' }, place: { in: 'query', name: 'access_token' } },
|
|
297
|
+
},
|
|
298
|
+
{ a: { path: '/a' } },
|
|
299
|
+
);
|
|
300
|
+
expect(script).toContain('const withToken = (url) => url + (url.indexOf("?") === -1 ? "?" : "&")');
|
|
301
|
+
expect(script).toContain('"access_token=" + encodeURIComponent(token)');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('批量只认 GET,也不带 body 里的凭据', () => {
|
|
305
|
+
expect(() =>
|
|
306
|
+
scriptOfMany({ ...BASE, request: { method: 'POST', path: '/a' } }, { a: {} }),
|
|
307
|
+
).toThrow('GET');
|
|
308
|
+
expect(() =>
|
|
309
|
+
scriptOfMany(
|
|
310
|
+
{ ...BASE, auth: { source: { kind: 'global', path: 't' }, place: { in: 'body', field: 'token' } } },
|
|
311
|
+
{ a: {} },
|
|
312
|
+
),
|
|
313
|
+
).toThrow('body');
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('callsProblemOf · 批量校验', () => {
|
|
318
|
+
it('calls 要是对象、非空、不超过上限', () => {
|
|
319
|
+
expect(callsProblemOf(BASE, undefined)).toContain('对象');
|
|
320
|
+
expect(callsProblemOf(BASE, {})).toContain('不能为空');
|
|
321
|
+
const many: Record<string, CallOverrides> = {};
|
|
322
|
+
for (let index = 0; index < MAX_RECIPE_CALLS + 1; index += 1) many[`c${index}`] = {};
|
|
323
|
+
expect(callsProblemOf(BASE, many)).toContain('最多');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('每格按 overrides 校验,并带上是哪一条', () => {
|
|
327
|
+
expect(callsProblemOf(BASE, { a: null })).toContain('calls.a');
|
|
328
|
+
expect(callsProblemOf(BASE, { a: { path: '/a?b=1' } })).toContain('查询');
|
|
329
|
+
expect(callsProblemOf(BASE, { a: { path: 'https://evil.test/a' } })).toContain('同源');
|
|
330
|
+
expect(callsProblemOf(BASE, { a: { headers: { 'x-api-key': 'k' } } })).toContain('calls.a');
|
|
331
|
+
expect(callsProblemOf(BASE, { a: { response: { kind: 'xml' } } })).toContain('calls.a');
|
|
332
|
+
expect(callsProblemOf(BASE, { a: { path: '/a' } })).toBeUndefined();
|
|
333
|
+
});
|
|
334
|
+
});
|