@fuzdev/fuz_app 0.148.0 → 0.149.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/dist/actions/CLAUDE.md +2 -2
- package/dist/actions/action_event.d.ts.map +1 -1
- package/dist/actions/action_event.js +14 -1
- package/dist/actions/perform_action.d.ts +17 -1
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +22 -2
- package/dist/auth/CLAUDE.md +3 -2
- package/dist/auth/account_action_specs.d.ts +12 -0
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +16 -2
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +7 -4
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +29 -8
- package/dist/auth/audit_emitter.d.ts +14 -0
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +5 -1
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +5 -3
- package/dist/http/CLAUDE.md +14 -2
- package/dist/http/error_schemas.d.ts +8 -0
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +12 -0
- package/dist/http/jsonrpc_errors.d.ts +40 -15
- package/dist/http/jsonrpc_errors.d.ts.map +1 -1
- package/dist/http/jsonrpc_errors.js +90 -35
- package/dist/schema_meta.d.ts +60 -0
- package/dist/schema_meta.d.ts.map +1 -1
- package/dist/schema_meta.js +64 -0
- package/dist/testing/CLAUDE.md +162 -61
- package/dist/testing/adversarial_input_cases.d.ts +67 -7
- package/dist/testing/adversarial_input_cases.d.ts.map +1 -1
- package/dist/testing/adversarial_input_cases.js +210 -165
- package/dist/testing/audit_drift_guard.d.ts +3 -2
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +5 -3
- package/dist/testing/cross_backend/audit_cross_helpers.d.ts +6 -0
- package/dist/testing/cross_backend/audit_cross_helpers.d.ts.map +1 -1
- package/dist/testing/cross_backend/audit_cross_helpers.js +11 -0
- package/dist/testing/cross_backend/capabilities.d.ts +27 -4
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +10 -6
- package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_crud.js +117 -2
- package/dist/testing/cross_backend/cross_rpc.d.ts +3 -1
- package/dist/testing/cross_backend/cross_rpc.d.ts.map +1 -1
- package/dist/testing/cross_backend/cross_rpc.js +2 -2
- package/dist/testing/cross_backend/default_backend_configs.d.ts +9 -7
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +3 -3
- package/dist/testing/cross_backend/rpc_adversarial.d.ts +9 -0
- package/dist/testing/cross_backend/rpc_adversarial.d.ts.map +1 -1
- package/dist/testing/cross_backend/rpc_adversarial.js +16 -6
- package/dist/testing/cross_backend/rust_spine_consumer_surface.d.ts +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +6 -3
- package/dist/testing/cross_backend/setup.d.ts +2 -2
- package/dist/testing/cross_backend/spine_suites.d.ts +85 -5
- package/dist/testing/cross_backend/spine_suites.d.ts.map +1 -1
- package/dist/testing/cross_backend/spine_suites.js +291 -20
- package/dist/testing/cross_backend/spine_surface_constants.d.ts +15 -0
- package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
- package/dist/testing/cross_backend/spine_surface_constants.js +24 -0
- package/dist/testing/cross_backend/testing_backdoor.d.ts +27 -4
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +136 -41
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +30 -15
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +52 -35
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +6 -3
- package/dist/testing/rpc_attack_surface.d.ts +10 -0
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.js +15 -3
- package/dist/testing/rpc_envelope_cases.d.ts +5 -2
- package/dist/testing/rpc_envelope_cases.d.ts.map +1 -1
- package/dist/testing/rpc_envelope_cases.js +4 -1
- package/dist/testing/rpc_round_trip.js +3 -3
- package/dist/testing/schema_generators.d.ts +99 -2
- package/dist/testing/schema_generators.d.ts.map +1 -1
- package/dist/testing/schema_generators.js +244 -76
- package/dist/testing/stubs.d.ts +4 -3
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +6 -4
- package/dist/zod.d.ts +9 -0
- package/dist/zod.d.ts.map +1 -1
- package/dist/zod.js +20 -0
- package/package.json +1 -1
|
@@ -14,9 +14,10 @@ import './assert_dev_env.js';
|
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { zod_unwrap_to_object, zod_extract_fields } from '../zod.js';
|
|
17
|
+
import { resolve_invalid_reason } from '../schema_meta.js';
|
|
17
18
|
import { is_null_schema } from '../http/schema_helpers.js';
|
|
18
19
|
import { ERROR_INVALID_REQUEST_BODY, ERROR_INVALID_JSON_BODY } from '../http/error_schemas.js';
|
|
19
|
-
import { detect_format,
|
|
20
|
+
import { detect_format, detect_json_schema_format, format_zod_issues, generate_valid_object, generate_valid_object_with_field, generate_valid_string_param, to_field_json_schema } from './schema_generators.js';
|
|
20
21
|
// --- Payload generation ---
|
|
21
22
|
/** One wrong-type value per base type — one representative is sufficient. */
|
|
22
23
|
const wrong_type_for = (base_type) => {
|
|
@@ -55,7 +56,17 @@ const format_violation = (format) => {
|
|
|
55
56
|
return null;
|
|
56
57
|
}
|
|
57
58
|
};
|
|
58
|
-
|
|
59
|
+
/**
|
|
60
|
+
* `value` moved by `step`, up when that stays inside the field's numeric range
|
|
61
|
+
* and down otherwise — a number off the integer or `multipleOf` grid that
|
|
62
|
+
* still meets the range, so the grid check is the one a refusal names.
|
|
63
|
+
*/
|
|
64
|
+
const off_grid_in_range = (json, value, step) => {
|
|
65
|
+
const up = value + step;
|
|
66
|
+
const over = (typeof json.maximum === 'number' && up > json.maximum) ||
|
|
67
|
+
(typeof json.exclusiveMaximum === 'number' && up >= json.exclusiveMaximum);
|
|
68
|
+
return over ? value - step : up;
|
|
69
|
+
};
|
|
59
70
|
/**
|
|
60
71
|
* Generate adversarial test cases for a route's input schema.
|
|
61
72
|
*
|
|
@@ -65,12 +76,34 @@ const format_violation = (format) => {
|
|
|
65
76
|
* - One wrong-type value per field
|
|
66
77
|
* - Null for required non-nullable fields
|
|
67
78
|
* - One format violation per constrained field
|
|
79
|
+
* - String length (empty, one under `minLength`, one over `maxLength`), numeric
|
|
80
|
+
* (out of range, non-integer, off a `multipleOf` grid) and array-length
|
|
81
|
+
* (empty, one under `minItems`, one over `maxItems`) boundaries
|
|
82
|
+
*
|
|
83
|
+
* Format and boundaries come from the field's input-side JSON Schema
|
|
84
|
+
* (`to_field_json_schema`), so a field carrying a `.transform()` or pipe and a
|
|
85
|
+
* `.nullable()` / `.nullish()` field get them too. A bound only a `.refine()`
|
|
86
|
+
* enforces generates nothing — JSON Schema can't express it.
|
|
68
87
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
88
|
+
* Each per-field case is single-fault: valid except for the field it breaks.
|
|
89
|
+
* It changes one field of a valid base body — `options.base` when given, else
|
|
90
|
+
* one built by `generate_valid_object` — or, for a field that base omits, of
|
|
91
|
+
* the valid body `generate_valid_object_with_field` builds around it, so a case
|
|
92
|
+
* can't also break an object-level rule (adding `scope_kind` without its
|
|
93
|
+
* paired `scope_id`, or `slug` beside an exactly-one-of `id`). A field no valid
|
|
94
|
+
* body carries keeps its wrong-type and null cases on the base and gets no
|
|
95
|
+
* format or boundary cases, which would test no bound. A per-field case is
|
|
96
|
+
* emitted only when the field refuses its value, so no case is accidentally
|
|
97
|
+
* valid.
|
|
98
|
+
*
|
|
99
|
+
* @param input_schema - the input schema to attack
|
|
100
|
+
* @param options - a base body to seed the cases, and a label for errors
|
|
101
|
+
* @throws Error if the base body fails `input_schema.safeParse` — a seeded
|
|
102
|
+
* base the schema refuses, or a generated one that surfaces broken
|
|
103
|
+
* generation logic — naming the issues rather than producing nonsense
|
|
104
|
+
* adversarial cases.
|
|
72
105
|
*/
|
|
73
|
-
export const generate_input_test_cases = (input_schema) => {
|
|
106
|
+
export const generate_input_test_cases = (input_schema, options) => {
|
|
74
107
|
if (is_null_schema(input_schema))
|
|
75
108
|
return [];
|
|
76
109
|
const object_schema = zod_unwrap_to_object(input_schema);
|
|
@@ -78,33 +111,24 @@ export const generate_input_test_cases = (input_schema) => {
|
|
|
78
111
|
return [];
|
|
79
112
|
const fields = zod_extract_fields(object_schema);
|
|
80
113
|
const cases = [];
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (!base_result.success) {
|
|
91
|
-
// Fallback for schemas with a top-level `.refine()` that requires at
|
|
92
|
-
// least one of N optional fields. Fill optional fields until the base
|
|
93
|
-
// satisfies validation; surfaces a clear error if no combination works.
|
|
94
|
-
for (const field of fields) {
|
|
95
|
-
if (field.required || field.has_default || field.name in base)
|
|
96
|
-
continue;
|
|
97
|
-
base[field.name] = generate_valid_value(field, object_schema.shape[field.name]);
|
|
98
|
-
base_result = input_schema.safeParse(base);
|
|
99
|
-
if (base_result.success)
|
|
100
|
-
break;
|
|
114
|
+
const for_label = options?.label === undefined ? '' : ` for '${options.label}'`;
|
|
115
|
+
// the base object every case breaks one field of — must pass validation itself
|
|
116
|
+
let base;
|
|
117
|
+
if (options?.base) {
|
|
118
|
+
base = { ...options.base };
|
|
119
|
+
const base_result = input_schema.safeParse(base);
|
|
120
|
+
if (!base_result.success) {
|
|
121
|
+
throw new Error(`adversarial_input: the seeded base${for_label} fails validation — ` +
|
|
122
|
+
`fix the base for: ${format_zod_issues(base_result.error)}`);
|
|
101
123
|
}
|
|
102
124
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
125
|
+
else {
|
|
126
|
+
const generated = generate_valid_object(object_schema, input_schema);
|
|
127
|
+
if (!generated.result.success) {
|
|
128
|
+
throw new Error(`adversarial_input: generated base object fails validation${for_label} — ` +
|
|
129
|
+
`fix generate_valid_value for: ${format_zod_issues(generated.result.error)}`);
|
|
130
|
+
}
|
|
131
|
+
base = generated.value;
|
|
108
132
|
}
|
|
109
133
|
// whole-body structural: send array instead of object
|
|
110
134
|
cases.push({
|
|
@@ -124,8 +148,33 @@ export const generate_input_test_cases = (input_schema) => {
|
|
|
124
148
|
}
|
|
125
149
|
for (const field of fields) {
|
|
126
150
|
const field_schema = object_schema.shape[field.name];
|
|
151
|
+
// the valid body every case on this field changes only the field of: the
|
|
152
|
+
// base, or for a field the base omits, one that carries it — so the case
|
|
153
|
+
// can't also break an object-level rule (exactly one of, both or neither)
|
|
154
|
+
const field_base = generate_valid_object_with_field(object_schema, input_schema, base, field);
|
|
155
|
+
// every per-field case breaks the field, so the field itself must refuse
|
|
156
|
+
// its value — a candidate it accepts (a `z.coerce` field given a wrong
|
|
157
|
+
// type, a pattern that admits the violation string) is no invalid case.
|
|
158
|
+
// A type case (wrong type, null) on a field no valid body carries falls
|
|
159
|
+
// back to the base; a value case — a format or a bound — is built only on
|
|
160
|
+
// a body that is valid but for the field, or it tests no bound at all
|
|
161
|
+
const push_field_case = (label, value, kind) => {
|
|
162
|
+
if (field_schema.safeParse(value).success)
|
|
163
|
+
return;
|
|
164
|
+
const body_base = field_base ?? (kind === 'type' ? base : null);
|
|
165
|
+
if (!body_base)
|
|
166
|
+
return;
|
|
167
|
+
cases.push({
|
|
168
|
+
label,
|
|
169
|
+
body: { ...body_base, [field.name]: value },
|
|
170
|
+
field: field.name,
|
|
171
|
+
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
// the field's valid value the value cases start from
|
|
175
|
+
const valid_value = field_base?.[field.name];
|
|
127
176
|
// missing required field (skip fields with defaults — Zod fills them)
|
|
128
|
-
if (field.required && !field.has_default) {
|
|
177
|
+
if (field.required && !field.has_default && !field_schema.safeParse(undefined).success) {
|
|
129
178
|
const without = {};
|
|
130
179
|
for (const [k, v] of Object.entries(base)) {
|
|
131
180
|
if (k !== field.name)
|
|
@@ -134,159 +183,162 @@ export const generate_input_test_cases = (input_schema) => {
|
|
|
134
183
|
cases.push({
|
|
135
184
|
label: `missing: ${field.name}`,
|
|
136
185
|
body: without,
|
|
186
|
+
field: field.name,
|
|
137
187
|
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
138
188
|
});
|
|
139
189
|
}
|
|
140
190
|
// one wrong-type value
|
|
141
191
|
const wrong = wrong_type_for(field.base_type);
|
|
142
|
-
if (wrong)
|
|
143
|
-
|
|
144
|
-
label: `wrong type: ${field.name} (${wrong.label})`,
|
|
145
|
-
body: { ...base, [field.name]: wrong.value },
|
|
146
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
147
|
-
});
|
|
148
|
-
}
|
|
192
|
+
if (wrong)
|
|
193
|
+
push_field_case(`wrong type: ${field.name} (${wrong.label})`, wrong.value, 'type');
|
|
149
194
|
// null for required non-nullable fields
|
|
150
195
|
if (field.required && !field.nullable && field.base_type !== 'null') {
|
|
151
|
-
|
|
152
|
-
label: `null: ${field.name}`,
|
|
153
|
-
body: { ...base, [field.name]: null },
|
|
154
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
155
|
-
});
|
|
196
|
+
push_field_case(`null: ${field.name}`, null, 'type');
|
|
156
197
|
}
|
|
198
|
+
// the field's input-side JSON Schema — format, pattern and bounds, read
|
|
199
|
+
// through a transform or pipe and under a nullable's `anyOf`
|
|
200
|
+
const json = to_field_json_schema(field_schema);
|
|
201
|
+
if (!json || !field_base)
|
|
202
|
+
continue; // no constraints, or no body to break them on
|
|
157
203
|
// format violation for constrained string fields
|
|
158
|
-
const format =
|
|
204
|
+
const format = detect_json_schema_format(json);
|
|
159
205
|
if (format) {
|
|
160
206
|
const violation = format_violation(format);
|
|
161
207
|
if (violation) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
208
|
+
push_field_case(`format: ${field.name} (${violation.label})`, violation.value, 'value');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// string length boundaries — `x`s are one UTF-16 unit and one code point
|
|
212
|
+
// each, so the over-`maxLength` string is over in either count
|
|
213
|
+
if (field.base_type === 'string' || field.base_type === 'uuid' || field.base_type === 'email') {
|
|
214
|
+
if (typeof json.minLength === 'number' && json.minLength > 0) {
|
|
215
|
+
push_field_case(`empty string: ${field.name}`, '', 'value');
|
|
216
|
+
}
|
|
217
|
+
// one short of the minimum, distinct from the empty string — a prefix of
|
|
218
|
+
// the valid value where there is one, so a pattern it met still holds
|
|
219
|
+
if (typeof json.minLength === 'number' && json.minLength > 1) {
|
|
220
|
+
const under = json.minLength - 1;
|
|
221
|
+
push_field_case(`under minLength: ${field.name} (${under} chars)`, typeof valid_value === 'string' && valid_value.length >= under
|
|
222
|
+
? valid_value.slice(0, under)
|
|
223
|
+
: 'x'.repeat(under), 'value');
|
|
224
|
+
}
|
|
225
|
+
if (typeof json.maxLength === 'number') {
|
|
226
|
+
push_field_case(`over maxLength: ${field.name} (${json.maxLength + 1} chars)`, 'x'.repeat(json.maxLength + 1), 'value');
|
|
167
227
|
}
|
|
168
228
|
}
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
field.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
188
|
-
});
|
|
189
|
-
}
|
|
229
|
+
// numeric boundaries
|
|
230
|
+
if (field.base_type === 'number' || field.base_type === 'int') {
|
|
231
|
+
if (typeof json.minimum === 'number') {
|
|
232
|
+
push_field_case(`below minimum: ${field.name} (${json.minimum - 1})`, json.minimum - 1, 'value');
|
|
233
|
+
}
|
|
234
|
+
if (typeof json.maximum === 'number') {
|
|
235
|
+
push_field_case(`above maximum: ${field.name} (${json.maximum + 1})`, json.maximum + 1, 'value');
|
|
236
|
+
}
|
|
237
|
+
if (typeof json.exclusiveMinimum === 'number') {
|
|
238
|
+
push_field_case(`at exclusive minimum: ${field.name} (${json.exclusiveMinimum})`, json.exclusiveMinimum, 'value');
|
|
239
|
+
}
|
|
240
|
+
if (typeof json.exclusiveMaximum === 'number') {
|
|
241
|
+
push_field_case(`at exclusive maximum: ${field.name} (${json.exclusiveMaximum})`, json.exclusiveMaximum, 'value');
|
|
242
|
+
}
|
|
243
|
+
// 0 and negative for positive-only fields
|
|
244
|
+
if (typeof json.minimum === 'number' && json.minimum > 0) {
|
|
245
|
+
push_field_case(`zero for positive-only: ${field.name}`, 0, 'value');
|
|
246
|
+
push_field_case(`negative for positive-only: ${field.name}`, -1, 'value');
|
|
190
247
|
}
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
if (typeof json.maximum === 'number') {
|
|
201
|
-
cases.push({
|
|
202
|
-
label: `above maximum: ${field.name} (${json.maximum + 1})`,
|
|
203
|
-
body: { ...base, [field.name]: json.maximum + 1 },
|
|
204
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
if (typeof json.exclusiveMinimum === 'number') {
|
|
208
|
-
cases.push({
|
|
209
|
-
label: `at exclusive minimum: ${field.name} (${json.exclusiveMinimum})`,
|
|
210
|
-
body: { ...base, [field.name]: json.exclusiveMinimum },
|
|
211
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
if (typeof json.exclusiveMaximum === 'number') {
|
|
215
|
-
cases.push({
|
|
216
|
-
label: `at exclusive maximum: ${field.name} (${json.exclusiveMaximum})`,
|
|
217
|
-
body: { ...base, [field.name]: json.exclusiveMaximum },
|
|
218
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
// 0 and negative for positive-only fields
|
|
222
|
-
if (typeof json.minimum === 'number' && json.minimum > 0) {
|
|
223
|
-
cases.push({
|
|
224
|
-
label: `zero for positive-only: ${field.name}`,
|
|
225
|
-
body: { ...base, [field.name]: 0 },
|
|
226
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
227
|
-
});
|
|
228
|
-
cases.push({
|
|
229
|
-
label: `negative for positive-only: ${field.name}`,
|
|
230
|
-
body: { ...base, [field.name]: -1 },
|
|
231
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
232
|
-
});
|
|
233
|
-
}
|
|
248
|
+
// off the grid by half a step from the valid value, inside the range,
|
|
249
|
+
// so the integer or `multipleOf` check is the one that refuses
|
|
250
|
+
const valid_number = typeof valid_value === 'number' ? valid_value : 1;
|
|
251
|
+
if (json.type === 'integer') {
|
|
252
|
+
const value = off_grid_in_range(json, valid_number, 0.5);
|
|
253
|
+
push_field_case(`non-integer: ${field.name} (${value})`, value, 'value');
|
|
234
254
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
body: { ...base, [field.name]: [] },
|
|
241
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
if (typeof json.maxItems === 'number') {
|
|
245
|
-
// generate an array one item over the max (items are null — schema-agnostic)
|
|
246
|
-
cases.push({
|
|
247
|
-
label: `over maxItems: ${field.name} (${json.maxItems + 1} items)`,
|
|
248
|
-
body: { ...base, [field.name]: Array.from({ length: json.maxItems + 1 }, () => null) },
|
|
249
|
-
expected_error: ERROR_INVALID_REQUEST_BODY
|
|
250
|
-
});
|
|
251
|
-
}
|
|
255
|
+
if (typeof json.multipleOf === 'number' && json.multipleOf > 0) {
|
|
256
|
+
// a whole step for an integer field, so the integer check still holds
|
|
257
|
+
const step = json.type === 'integer' && json.multipleOf > 1 ? 1 : json.multipleOf / 2;
|
|
258
|
+
const value = off_grid_in_range(json, valid_number, step);
|
|
259
|
+
push_field_case(`not multipleOf: ${field.name} (${value}, multipleOf ${json.multipleOf})`, value, 'value');
|
|
252
260
|
}
|
|
253
261
|
}
|
|
254
|
-
|
|
255
|
-
|
|
262
|
+
// array length boundaries
|
|
263
|
+
if (field.base_type === 'array') {
|
|
264
|
+
if (typeof json.minItems === 'number' && json.minItems > 0) {
|
|
265
|
+
push_field_case(`empty array for minItems > 0: ${field.name}`, [], 'value');
|
|
266
|
+
}
|
|
267
|
+
// one short of the minimum, distinct from the empty array — the valid
|
|
268
|
+
// value's own items, so only the count is wrong
|
|
269
|
+
if (typeof json.minItems === 'number' &&
|
|
270
|
+
json.minItems > 1 &&
|
|
271
|
+
Array.isArray(valid_value) &&
|
|
272
|
+
valid_value.length >= json.minItems - 1) {
|
|
273
|
+
push_field_case(`under minItems: ${field.name} (${json.minItems - 1} items)`, valid_value.slice(0, json.minItems - 1), 'value');
|
|
274
|
+
}
|
|
275
|
+
if (typeof json.maxItems === 'number') {
|
|
276
|
+
// one item over the max (items are null — schema-agnostic)
|
|
277
|
+
push_field_case(`over maxItems: ${field.name} (${json.maxItems + 1} items)`, Array.from({ length: json.maxItems + 1 }, () => null), 'value');
|
|
278
|
+
}
|
|
256
279
|
}
|
|
257
280
|
}
|
|
258
281
|
return cases;
|
|
259
282
|
};
|
|
260
|
-
|
|
283
|
+
/**
|
|
284
|
+
* The `error.data.reason` an RPC refusal of `test_case` owes: the declared
|
|
285
|
+
* `invalid_reason` of the field it breaks, resolved as the dispatcher resolves
|
|
286
|
+
* a failure on that field (`resolve_invalid_reason`), or `undefined` — no
|
|
287
|
+
* reason — for an undeclared field or a whole-body case.
|
|
288
|
+
*
|
|
289
|
+
* @param input_schema - the input schema the case was generated from
|
|
290
|
+
* @param test_case - a case `generate_input_test_cases` produced from it
|
|
291
|
+
* @returns the owed reason, or `undefined` when the refusal carries none
|
|
292
|
+
*/
|
|
293
|
+
export const expected_invalid_reason = (input_schema, test_case) => test_case.field === undefined
|
|
294
|
+
? undefined
|
|
295
|
+
: resolve_invalid_reason(input_schema, [test_case.field]);
|
|
296
|
+
// --- Params and query test case generation ---
|
|
297
|
+
/**
|
|
298
|
+
* A valid string record for a route's params or query schema — each field the
|
|
299
|
+
* string `generate_valid_string_param` finds for it — checked against the
|
|
300
|
+
* whole schema, so a case changing one field breaks that field only.
|
|
301
|
+
*
|
|
302
|
+
* @throws Error if a field has no string it accepts, or the record fails the
|
|
303
|
+
* schema, naming the issues
|
|
304
|
+
*/
|
|
305
|
+
const generate_string_record_base = (schema, kind) => {
|
|
306
|
+
const base = {};
|
|
307
|
+
for (const field of zod_extract_fields(schema)) {
|
|
308
|
+
const value = generate_valid_string_param(field, schema.shape[field.name]);
|
|
309
|
+
if (value === null) {
|
|
310
|
+
throw new Error(`adversarial_input: no generated ${kind} value passes field '${field.name}'`);
|
|
311
|
+
}
|
|
312
|
+
base[field.name] = value;
|
|
313
|
+
}
|
|
314
|
+
const result = schema.safeParse(base);
|
|
315
|
+
if (!result.success) {
|
|
316
|
+
throw new Error(`adversarial_input: generated ${kind} base fails validation — ` +
|
|
317
|
+
`fix generate_valid_string_param for: ${format_zod_issues(result.error)}`);
|
|
318
|
+
}
|
|
319
|
+
return base;
|
|
320
|
+
};
|
|
261
321
|
/**
|
|
262
322
|
* Generate adversarial test cases for a route's params schema.
|
|
263
323
|
*
|
|
264
324
|
* Params are always strings from URL segments. Only generates cases for
|
|
265
325
|
* format-constrained fields (uuid, pattern) since unconstrained string
|
|
266
|
-
* params accept any string value.
|
|
326
|
+
* params accept any string value, and only a violation the field refuses.
|
|
327
|
+
* Each case breaks one field of a valid params record.
|
|
328
|
+
*
|
|
329
|
+
* @throws Error if no valid params record can be generated
|
|
267
330
|
*/
|
|
268
331
|
export const generate_params_test_cases = (params_schema) => {
|
|
269
332
|
const fields = zod_extract_fields(params_schema);
|
|
270
333
|
const cases = [];
|
|
271
|
-
|
|
272
|
-
const base_params = {};
|
|
273
|
-
for (const field of fields) {
|
|
274
|
-
const field_schema = params_schema.shape[field.name];
|
|
275
|
-
const format = detect_format(field_schema);
|
|
276
|
-
if (format === 'uuid' || field.base_type === 'uuid') {
|
|
277
|
-
base_params[field.name] = '00000000-0000-0000-0000-000000000000';
|
|
278
|
-
}
|
|
279
|
-
else {
|
|
280
|
-
base_params[field.name] = 'test_value';
|
|
281
|
-
}
|
|
282
|
-
}
|
|
334
|
+
const base_params = generate_string_record_base(params_schema, 'params');
|
|
283
335
|
for (const field of fields) {
|
|
284
336
|
const field_schema = params_schema.shape[field.name];
|
|
285
337
|
const format = detect_format(field_schema);
|
|
286
338
|
if (!format)
|
|
287
339
|
continue; // unconstrained string — any value passes
|
|
288
340
|
const violation = format_violation(format);
|
|
289
|
-
if (violation) {
|
|
341
|
+
if (violation && !field_schema.safeParse(violation.value).success) {
|
|
290
342
|
cases.push({
|
|
291
343
|
label: `${violation.label}: param ${field.name}`,
|
|
292
344
|
params: { ...base_params, [field.name]: violation.value }
|
|
@@ -295,32 +347,26 @@ export const generate_params_test_cases = (params_schema) => {
|
|
|
295
347
|
}
|
|
296
348
|
return cases;
|
|
297
349
|
};
|
|
298
|
-
// --- Query test case generation ---
|
|
299
350
|
/**
|
|
300
351
|
* Generate adversarial test cases for a route's query schema.
|
|
301
352
|
*
|
|
302
353
|
* Query params are always strings from the URL. Generates cases for:
|
|
303
|
-
* - Missing required fields
|
|
304
|
-
*
|
|
354
|
+
* - Missing required fields the field refuses to go without (a preprocessed
|
|
355
|
+
* field with a default accepts its absence)
|
|
356
|
+
* - Format violations on constrained fields (uuid, pattern) the field refuses
|
|
357
|
+
*
|
|
358
|
+
* Each case breaks one field of a valid query record.
|
|
359
|
+
*
|
|
360
|
+
* @throws Error if no valid query record can be generated
|
|
305
361
|
*/
|
|
306
362
|
export const generate_query_test_cases = (query_schema) => {
|
|
307
363
|
const fields = zod_extract_fields(query_schema);
|
|
308
364
|
const cases = [];
|
|
309
|
-
|
|
310
|
-
const base_query = {};
|
|
365
|
+
const base_query = generate_string_record_base(query_schema, 'query');
|
|
311
366
|
for (const field of fields) {
|
|
312
367
|
const field_schema = query_schema.shape[field.name];
|
|
313
|
-
const format = detect_format(field_schema);
|
|
314
|
-
if (format === 'uuid' || field.base_type === 'uuid') {
|
|
315
|
-
base_query[field.name] = '00000000-0000-0000-0000-000000000000';
|
|
316
|
-
}
|
|
317
|
-
else {
|
|
318
|
-
base_query[field.name] = 'test_value';
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
for (const field of fields) {
|
|
322
368
|
// missing required field
|
|
323
|
-
if (field.required && !field.has_default) {
|
|
369
|
+
if (field.required && !field.has_default && !field_schema.safeParse(undefined).success) {
|
|
324
370
|
const without = {};
|
|
325
371
|
for (const [k, v] of Object.entries(base_query)) {
|
|
326
372
|
if (k !== field.name)
|
|
@@ -332,12 +378,11 @@ export const generate_query_test_cases = (query_schema) => {
|
|
|
332
378
|
});
|
|
333
379
|
}
|
|
334
380
|
// format violation for constrained string fields
|
|
335
|
-
const field_schema = query_schema.shape[field.name];
|
|
336
381
|
const format = detect_format(field_schema);
|
|
337
382
|
if (!format)
|
|
338
383
|
continue;
|
|
339
384
|
const violation = format_violation(format);
|
|
340
|
-
if (violation) {
|
|
385
|
+
if (violation && !field_schema.safeParse(violation.value).success) {
|
|
341
386
|
cases.push({
|
|
342
387
|
label: `${violation.label}: query ${field.name}`,
|
|
343
388
|
query: { ...base_query, [field.name]: violation.value }
|
|
@@ -61,8 +61,9 @@ export interface RecordingAuditEmitter {
|
|
|
61
61
|
* `notify` is a no-op; `add_listener` records into a local array that
|
|
62
62
|
* `listener_count` reports, and returns a remover with the production
|
|
63
63
|
* semantics (per registration, idempotent) — registered listeners never fire,
|
|
64
|
-
* since this emitter captures `emit` shapes, not fan-out. `drain_inflight` resolves at once
|
|
65
|
-
* a recorded call writes nothing, so nothing is
|
|
64
|
+
* since this emitter captures `emit` shapes, not fan-out. `drain_inflight` resolves at once
|
|
65
|
+
* and `is_tracking_inflight` answers `false`: a recorded call writes nothing, so nothing is
|
|
66
|
+
* ever in flight.
|
|
66
67
|
*
|
|
67
68
|
* `emit` AND `emit_pool` both append to `calls`, so a test reads either
|
|
68
69
|
* entry point's writes off the same array.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit_drift_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/audit_drift_guard.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAW7B,OAAO,EAGN,KAAK,YAAY,EACjB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAGN,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AAErC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,yBAAyB,QAAO,IAO5C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,QAAO,IAgBxC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACrC,OAAO,EAAE,YAAY,CAAC;IACtB,KAAK,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC;CAC5B;AAED
|
|
1
|
+
{"version":3,"file":"audit_drift_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/audit_drift_guard.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAW7B,OAAO,EAGN,KAAK,YAAY,EACjB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAGN,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AAErC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,yBAAyB,QAAO,IAO5C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,QAAO,IAgBxC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACrC,OAAO,EAAE,YAAY,CAAC;IACtB,KAAK,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,8BAA8B,GAC1C,YAAY,KAAK,CAAC,aAAa,CAAC,KAC9B,qBAsCF,CAAC;AA2BF,iDAAiD;AACjD,MAAM,WAAW,+BAA+B;IAC/C;;;;OAIG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;CACvC;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,2BAA2B,GACvC,OAAO,IAAI,CAAC,iBAAiB,EAAE,YAAY,GAAG,SAAS,GAAG,UAAU,CAAC,EACrE,UAAU,+BAA+B,KACvC,IAcF,CAAC"}
|
|
@@ -64,8 +64,9 @@ export const assert_no_audit_drift = () => {
|
|
|
64
64
|
* `notify` is a no-op; `add_listener` records into a local array that
|
|
65
65
|
* `listener_count` reports, and returns a remover with the production
|
|
66
66
|
* semantics (per registration, idempotent) — registered listeners never fire,
|
|
67
|
-
* since this emitter captures `emit` shapes, not fan-out. `drain_inflight` resolves at once
|
|
68
|
-
* a recorded call writes nothing, so nothing is
|
|
67
|
+
* since this emitter captures `emit` shapes, not fan-out. `drain_inflight` resolves at once
|
|
68
|
+
* and `is_tracking_inflight` answers `false`: a recorded call writes nothing, so nothing is
|
|
69
|
+
* ever in flight.
|
|
69
70
|
*
|
|
70
71
|
* `emit` AND `emit_pool` both append to `calls`, so a test reads either
|
|
71
72
|
* entry point's writes off the same array.
|
|
@@ -118,7 +119,8 @@ export const create_recording_audit_emitter = (calls_ref) => {
|
|
|
118
119
|
};
|
|
119
120
|
},
|
|
120
121
|
listener_count: () => listeners.length,
|
|
121
|
-
drain_inflight: () => Promise.resolve()
|
|
122
|
+
drain_inflight: () => Promise.resolve(),
|
|
123
|
+
is_tracking_inflight: () => false
|
|
122
124
|
};
|
|
123
125
|
return { emitter, calls };
|
|
124
126
|
};
|
|
@@ -18,6 +18,12 @@ export interface AuditReadFixture {
|
|
|
18
18
|
* backend must mount the action — every spine test binary does; in-process
|
|
19
19
|
* suites mount `create_testing_drain_effects_action`.
|
|
20
20
|
*
|
|
21
|
+
* Refuses an answer without `tracked: true`. A binary whose audit emitter was
|
|
22
|
+
* built without in-flight tracking answers the drain at once, waiting on
|
|
23
|
+
* nothing, and its audit reads race the writes — an absent row then reads as
|
|
24
|
+
* a pass. Every audit read runs through here, so each one checks the barrier
|
|
25
|
+
* is live.
|
|
26
|
+
*
|
|
21
27
|
* @param fixture - the per-test fixture, whose daemon token authorizes the drain
|
|
22
28
|
* @param rpc_path - the RPC endpoint the drain action is mounted on
|
|
23
29
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit_cross_helpers.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/audit_cross_helpers.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAsB9B,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,gCAAgC,CAAC;AAErF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AAIvE,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,eAAe,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,KAAK,cAAc,CAAC;IAC5F,2FAA2F;IAC3F,QAAQ,CAAC,sBAAsB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5F,QAAQ,CAAC,2BAA2B,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjG,8FAA8F;IAC9F,QAAQ,CAAC,kCAAkC,CAAC,EAAE,OAAO,CAAC;CACtD;AAED
|
|
1
|
+
{"version":3,"file":"audit_cross_helpers.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/audit_cross_helpers.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAsB9B,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,gCAAgC,CAAC;AAErF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AAIvE,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,eAAe,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,KAAK,cAAc,CAAC;IAC5F,2FAA2F;IAC3F,QAAQ,CAAC,sBAAsB,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5F,QAAQ,CAAC,2BAA2B,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjG,8FAA8F;IAC9F,QAAQ,CAAC,kCAAkC,CAAC,EAAE,OAAO,CAAC;CACtD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,SAAS,gBAAgB,EACzB,WAAU,MAAuB,KAC/B,OAAO,CAAC,IAAI,CAmBd,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,IAAI,CAAC,gBAAgB,EAAE,oCAAoC,CAAC,EACrE,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,EACxC,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,SAAQ,aAAa,CAAC,MAAM,CAAM,KAChC,IASF,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iBAAiB,GAC7B,SAAS,gBAAgB,EACzB,QAAQ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,WAAU,MAAuB,KAC/B,OAAO,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAe/C,CAAC"}
|
|
@@ -26,12 +26,23 @@ import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
|
26
26
|
* backend must mount the action — every spine test binary does; in-process
|
|
27
27
|
* suites mount `create_testing_drain_effects_action`.
|
|
28
28
|
*
|
|
29
|
+
* Refuses an answer without `tracked: true`. A binary whose audit emitter was
|
|
30
|
+
* built without in-flight tracking answers the drain at once, waiting on
|
|
31
|
+
* nothing, and its audit reads race the writes — an absent row then reads as
|
|
32
|
+
* a pass. Every audit read runs through here, so each one checks the barrier
|
|
33
|
+
* is live.
|
|
34
|
+
*
|
|
29
35
|
* @param fixture - the per-test fixture, whose daemon token authorizes the drain
|
|
30
36
|
* @param rpc_path - the RPC endpoint the drain action is mounted on
|
|
31
37
|
*/
|
|
32
38
|
export const cross_drain_effects = async (fixture, rpc_path = SPINE_RPC_PATH) => {
|
|
33
39
|
const drained = await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, '_testing_drain_effects', undefined, fixture.create_daemon_token_headers());
|
|
34
40
|
assert.ok(drained.ok, `_testing_drain_effects failed: ${JSON.stringify(drained.error)}`);
|
|
41
|
+
const tracked = typeof drained.result === 'object' && drained.result !== null
|
|
42
|
+
? drained.result.tracked
|
|
43
|
+
: undefined;
|
|
44
|
+
assert.strictEqual(tracked, true, "_testing_drain_effects must answer tracked: true — the backend's audit emitter does not " +
|
|
45
|
+
`track in-flight writes, so the drain waits on nothing (got ${JSON.stringify(drained.result)})`);
|
|
35
46
|
};
|
|
36
47
|
/**
|
|
37
48
|
* Assert an audit row's `metadata` carries `expected` — each field compared
|