Skip to main content

parse_rust_schema/
clp_validate.rs

1//! Validating a class-level-permissions block, with upstream's exact messages.
2//!
3//! Upstream: `validateCLP` (`SchemaController.js:271-399`) and the four helpers it calls,
4//! `validateCLPjson` (`:401-420`), `validatePermissionKey` (`:218-235`),
5//! `validateProtectedFieldsKey` (`:237-254`) and `validatePointerPermission` (`:422-442`).
6//!
7//! These messages are wire-visible. `POST /schemas/:className` and `PUT /schemas/:className` both
8//! reach here, `parse-dashboard` renders what comes back, and `spec/Schema.spec.js` asserts on the
9//! strings. Reproduce them byte for byte, including the quoting, which is inconsistent upstream:
10//! the unknown-top-level-key message has no quotes and every other message does.
11//!
12//! Two things here are the reason this is a module rather than three lines in `schema_api`.
13//!
14//! **The two entity grammars are not interchangeable.** Operations accept `pointerFields`, `*`,
15//! `requiresAuthentication`, `role:<name>` and an objectId. `protectedFields` accepts
16//! `userField:<name>`, `*`, `authenticated`, `role:<name>` and an objectId. Neither accepts the
17//! other's spellings, and a shared validator gets it wrong in both directions.
18//!
19//! **The objectId grammar is a configuration input.** `^[a-zA-Z0-9]{1,}$` normally, `^.{1,}$`
20//! when `allowCustomObjectId` is on (`SchemaController.js:726-731`). Hardcoding the first one
21//! silently rejects valid CLPs on a server configured for the second, which is why
22//! [`ClpValidation`] has no `Default` and the caller must state it.
23
24use parse_rust_core::{
25    js_number, ClassLevelPermissions, ErrorCode, ParseError, ParseMap, ParseValue,
26};
27use parse_rust_storage::ClassSchema;
28
29use crate::infer::DEFAULT_COLUMNS;
30
31/// Which strings count as an objectId inside a CLP.
32///
33/// `SchemaController.js:726-731`. Not a `bool` at the call site, because a bare `true` there
34/// reads as "valid" rather than as "custom object ids are enabled".
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ObjectIdForm {
37    /// `^[a-zA-Z0-9]{1,}$`, the default.
38    Generated,
39    /// `^.{1,}$`, when `allowCustomObjectId` is on. Any non-empty string.
40    Custom,
41}
42
43impl ObjectIdForm {
44    fn accepts(self, key: &str) -> bool {
45        match self {
46            ObjectIdForm::Generated => {
47                !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric())
48            }
49            ObjectIdForm::Custom => !key.is_empty(),
50        }
51    }
52}
53
54/// Whether to refuse CLP features parse-rust validates but does not enforce.
55///
56/// The 0.2.0 milestone excludes `readUserFields`, `writeUserFields` and `userField:` protected
57/// fields. Accepting a block that configures them and then not honoring it is the failure mode
58/// worth avoiding: a class would report itself as restricted while serving every row. So the
59/// default posture is to refuse the write, on the same rule that makes an unsupported query
60/// constraint an error rather than a silent no-op.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Unenforceable {
63    /// Refuse the block. `COMMAND_UNAVAILABLE` (108), naming the key.
64    Refuse,
65    /// Accept exactly what upstream accepts. For reading a block already in the database, and for
66    /// the day the feature lands.
67    Accept,
68}
69
70/// Everything CLP validation needs that is not the block itself.
71///
72/// No `Default`. Both fields are decisions with a wire-visible consequence, and a default would
73/// let a caller inherit one without making it.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct ClpValidation {
76    pub object_id: ObjectIdForm,
77    pub unenforceable: Unenforceable,
78}
79
80/// Top-level keys a CLP block may carry (`CLPValidKeys`, `SchemaController.js:256-268`).
81pub const VALID_KEYS: [&str; 11] = [
82    "ACL",
83    "find",
84    "count",
85    "get",
86    "create",
87    "update",
88    "delete",
89    "addField",
90    "readUserFields",
91    "writeUserFields",
92    "protectedFields",
93];
94
95/// Validate a CLP block against a class, and parse it.
96///
97/// The block is moved in and comes back inside the returned [`ClassLevelPermissions`], which
98/// keeps it verbatim. That is deliberate: a key parse-rust does not model must survive a round
99/// trip through `_metadata.class_permissions`, or a parse-server node reading the same database
100/// sees the key vanish.
101///
102/// `schema` supplies the field table for the existence checks. Pass the schema the block is
103/// being stored against, meaning the *new* fields on a create and the merged fields on an update,
104/// which is what upstream passes (`SchemaController.js:1092`, `:1100`).
105pub fn validate_clp(
106    raw: ParseMap,
107    schema: &ClassSchema,
108    opts: ClpValidation,
109) -> Result<ClassLevelPermissions, ParseError> {
110    for (operation_key, operation) in &raw {
111        if !VALID_KEYS.contains(&operation_key.as_str()) {
112            // The one message in this module with no quotes around the interpolation.
113            return Err(ParseError::invalid_json(format!(
114                "{operation_key} is not a valid operation for class level permissions"
115            )));
116        }
117
118        validate_clp_json(operation, operation_key)?;
119
120        if operation_key == "readUserFields" || operation_key == "writeUserFields" {
121            if opts.unenforceable == Unenforceable::Refuse {
122                return Err(unenforceable(operation_key));
123            }
124            // `validateCLPjson` already proved this is an array.
125            if let ParseValue::Array(items) = operation {
126                for item in items {
127                    validate_pointer_permission(item, schema, operation_key)?;
128                }
129            }
130            continue;
131        }
132
133        if operation_key == "protectedFields" {
134            let entries = js_own_entries(operation);
135            for (entity, protected) in &entries {
136                let (entity, protected) = (entity.as_str(), protected);
137                validate_protected_fields_key(entity, opts.object_id)?;
138                if opts.unenforceable == Unenforceable::Refuse && entity.starts_with("userField:") {
139                    return Err(unenforceable(entity));
140                }
141
142                let ParseValue::Array(fields) = protected else {
143                    return Err(ParseError::invalid_json(format!(
144                        "'{}' is not a valid value for protectedFields[{entity}] - expected an \
145                         array.",
146                        js_string(protected)
147                    )));
148                };
149
150                for field in fields {
151                    let name = js_string(field);
152                    // Order matters: a default column reports as a default column even though it
153                    // also exists on the class.
154                    if DEFAULT_COLUMNS.iter().any(|(n, _)| *n == name) {
155                        return Err(ParseError::invalid_json(format!(
156                            "Default field '{name}' can not be protected"
157                        )));
158                    }
159                    if !schema.fields.contains_key(&name) {
160                        return Err(ParseError::invalid_json(format!(
161                            "Field '{name}' in protectedFields:{entity} does not exist"
162                        )));
163                    }
164                }
165            }
166            continue;
167        }
168
169        let entries = js_own_entries(operation);
170        for (entity, permit) in &entries {
171            let (entity, permit) = (entity.as_str(), permit);
172            validate_permission_key(entity, opts.object_id)?;
173
174            if entity == "pointerFields" {
175                let ParseValue::Array(pointer_fields) = permit else {
176                    return Err(ParseError::invalid_json(format!(
177                        "'{}' is not a valid value for {operation_key}[{entity}] - expected an \
178                         array.",
179                        js_string(permit)
180                    )));
181                };
182                for pointer_field in pointer_fields {
183                    // UPSTREAM-QUIRK: the third argument here is the whole operation *object*,
184                    // not the operation key (`SchemaController.js:355`), so the message ends in
185                    // the literal `[object Object]`. The grouped-pointer-permission call site two
186                    // branches up passes the key and reads correctly. Reproduced rather than
187                    // fixed: it is the string a client sees.
188                    validate_pointer_permission(pointer_field, schema, "[object Object]")?;
189                }
190                continue;
191            }
192
193            if operation_key == "ACL" {
194                validate_clp_acl_entry(permit)?;
195            } else if !matches!(permit, ParseValue::Bool(true)) {
196                // The trailing `acl` is upstream's, on a message that has nothing to do with
197                // ACLs (`SchemaController.js:394`).
198                return Err(ParseError::invalid_json(format!(
199                    "'{}' is not a valid value for class level permissions acl \
200                     {operation_key}:{entity}",
201                    js_string(permit)
202                )));
203            }
204        }
205    }
206
207    Ok(ClassLevelPermissions::from_map(raw))
208}
209
210/// Tier 2 refusal for a CLP feature 0.2.0 validates but cannot enforce.
211///
212/// `COMMAND_UNAVAILABLE` (108) is the code the milestone already uses for the other
213/// accept-but-do-not-honor case, a `/batch` asking for a transaction. The message is
214/// parse-rust's own; there is no upstream string to reproduce, because upstream implements the
215/// feature.
216fn unenforceable(key: &str) -> ParseError {
217    ParseError::new(
218        ErrorCode::CommandUnavailable,
219        format!(
220            "{key} is not supported yet. parse-rust validates it and cannot enforce it, so the \
221             class level permissions are refused rather than stored unenforced."
222        ),
223    )
224}
225
226/// `validateCLPjson` (`SchemaController.js:401-420`).
227fn validate_clp_json(operation: &ParseValue, operation_key: &str) -> Result<(), ParseError> {
228    if operation_key == "readUserFields" || operation_key == "writeUserFields" {
229        if !matches!(operation, ParseValue::Array(_)) {
230            return Err(ParseError::invalid_json(format!(
231                "'{}' is not a valid value for class level permissions {operation_key} - must be \
232                 an array",
233                js_string(operation)
234            )));
235        }
236        return Ok(());
237    }
238    // `typeof operation === 'object' && operation !== null`. An array passes this upstream, and
239    // so does every tagged value, because all of them are ordinary objects in the raw JSON that
240    // reaches `validateCLP`.
241    if is_js_object(operation) {
242        return Ok(());
243    }
244    Err(ParseError::invalid_json(format!(
245        "'{}' is not a valid value for class level permissions {operation_key} - must be an object",
246        js_string(operation)
247    )))
248}
249
250/// `validatePermissionKey` (`SchemaController.js:218-235`).
251///
252/// `clpFieldsRegex` is `pointerFields`, `*`, `requiresAuthentication`, `role:.*`, then the
253/// objectId regex. Note `/^role:.*/` accepts an empty role name; that is upstream's regex and it
254/// is not tightened here.
255fn validate_permission_key(key: &str, object_id: ObjectIdForm) -> Result<(), ParseError> {
256    let matches_some = key == "pointerFields"
257        || key == "*"
258        || key == "requiresAuthentication"
259        || key.starts_with("role:");
260    if matches_some || object_id.accepts(key) {
261        return Ok(());
262    }
263    Err(invalid_clp_key(key))
264}
265
266/// `validateProtectedFieldsKey` (`SchemaController.js:237-254`).
267///
268/// A different set: `userField:.*`, `*`, `authenticated`, `role:.*`. There is no
269/// `requiresAuthentication` and no `pointerFields` here, and both of those spellings fall through
270/// to the objectId regex, so `requiresAuthentication` is accepted as an objectId rather than as a
271/// predicate.
272fn validate_protected_fields_key(key: &str, object_id: ObjectIdForm) -> Result<(), ParseError> {
273    let matches_some = key.starts_with("userField:")
274        || key == "*"
275        || key == "authenticated"
276        || key.starts_with("role:");
277    if matches_some || object_id.accepts(key) {
278        return Ok(());
279    }
280    Err(invalid_clp_key(key))
281}
282
283/// Both key validators raise the same message (`:232`, `:251`).
284fn invalid_clp_key(key: &str) -> ParseError {
285    ParseError::invalid_json(format!(
286        "'{key}' is not a valid key for class level permissions"
287    ))
288}
289
290/// `validatePointerPermission` (`SchemaController.js:422-442`).
291///
292/// `Pointer<_User>` or `Array`, and nothing else. `Array` is accepted because a schema cannot
293/// constrain an array's element type, so the filter later keeps only the elements that are
294/// pointers to `_User`.
295fn validate_pointer_permission(
296    field: &ParseValue,
297    schema: &ClassSchema,
298    operation: &str,
299) -> Result<(), ParseError> {
300    let name = js_string(field);
301    let ok = match schema.fields.get(&name) {
302        Some(ty) => {
303            ty.target_class() == Some("_User") && ty.is_pointer()
304                || matches!(ty, parse_rust_storage::FieldType::Array)
305        }
306        None => false,
307    };
308    if ok {
309        return Ok(());
310    }
311    Err(ParseError::invalid_json(format!(
312        "'{name}' is not a valid column for class level pointer permissions {operation}"
313    )))
314}
315
316/// The `ACL` key's entity values (`SchemaController.js:369-390`).
317///
318/// This is the CLP's own ACL, so an entity maps to `{read, write}` rather than to `true`. Keys
319/// outside `read`/`write` and values that are not booleans are reported separately, each joining
320/// every offender with a comma.
321fn validate_clp_acl_entry(permit: &ParseValue) -> Result<(), ParseError> {
322    // `Object.prototype.toString.call(permit) !== '[object Object]'`. An array is `[object
323    // Array]` and fails here even though it passed `validateCLPjson`.
324    let ParseValue::Object(entry) = permit else {
325        return Err(ParseError::invalid_json(format!(
326            "'{}' is not a valid value for class level permissions acl",
327            js_string(permit)
328        )));
329    };
330
331    let invalid_keys: Vec<&str> = entry
332        .keys()
333        .filter(|k| k.as_str() != "read" && k.as_str() != "write")
334        .map(String::as_str)
335        .collect();
336    if !invalid_keys.is_empty() {
337        return Err(ParseError::invalid_json(format!(
338            "'{}' is not a valid key for class level permissions acl",
339            invalid_keys.join(",")
340        )));
341    }
342
343    let invalid_values: Vec<String> = entry
344        .values()
345        .filter(|v| !matches!(v, ParseValue::Bool(_)))
346        .map(js_string)
347        .collect();
348    if !invalid_values.is_empty() {
349        return Err(ParseError::invalid_json(format!(
350            "'{}' is not a valid value for class level permissions acl",
351            invalid_values.join(",")
352        )));
353    }
354    Ok(())
355}
356
357/// The `(key, value)` pairs a JavaScript `for...in` would visit.
358///
359/// `validateCLP` enumerates an operation's entries with `for (const entity in operation)`
360/// (`SchemaController.js:301`, `:345`), and `for...in` visits the own enumerable keys of **any**
361/// object-like value, not only a plain object. Skipping anything that is not a `ParseValue::Object`
362/// was therefore wrong in two directions at once:
363///
364/// - `{"find": ["*"]}` is an array upstream, so `for...in` yields the index `"0"`, which passes
365///   `validatePermissionKey` as an objectId, and the element `"*"` then fails `permit !== true`.
366///   Upstream answers `INVALID_JSON`; skipping it answered 200 for a block that grants nothing.
367/// - `{"find": {"__type": "Date", ...}}` is a plain object upstream, so its keys are checked and
368///   `__type` is refused. Skipping it answered 200 and stored the block, and the CLP reader then
369///   reads a truthy non-object as **deny-all**, so the class is silently locked with no error at
370///   the time of the write that locked it.
371///
372/// A primitive yields nothing, which is what `for...in` over a number, boolean or null does.
373///
374/// **A tagged value's key order is canonical here rather than the client's.** By this point the
375/// wire order is lost to classification. Every key of every tagged encoding is refused by
376/// `validate_permission_key`, so the outcome is the same refusal either way; only which key the
377/// message names can differ from upstream, and only for a body that was already invalid.
378fn js_own_entries(value: &ParseValue) -> Vec<(String, ParseValue)> {
379    let tagged = |pairs: Vec<(&str, ParseValue)>| {
380        pairs
381            .into_iter()
382            .map(|(k, v)| (k.to_string(), v))
383            .collect::<Vec<_>>()
384    };
385    let s = |v: &str| ParseValue::String(v.to_string());
386    match value {
387        ParseValue::Object(map) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
388        ParseValue::Array(items) => items
389            .iter()
390            .enumerate()
391            .map(|(i, v)| (i.to_string(), v.clone()))
392            .collect(),
393        // `for...in` over a string visits its character indices.
394        ParseValue::String(text) => text
395            .chars()
396            .enumerate()
397            .map(|(i, c)| (i.to_string(), ParseValue::String(c.to_string())))
398            .collect(),
399        ParseValue::Date(d) => tagged(vec![("__type", s("Date")), ("iso", s(&d.to_iso()))]),
400        ParseValue::Pointer {
401            class_name,
402            object_id,
403        } => tagged(vec![
404            ("__type", s("Pointer")),
405            ("className", s(class_name)),
406            ("objectId", s(object_id)),
407        ]),
408        ParseValue::GeoPoint {
409            latitude,
410            longitude,
411        } => tagged(vec![
412            ("__type", s("GeoPoint")),
413            ("latitude", ParseValue::Number(*latitude)),
414            ("longitude", ParseValue::Number(*longitude)),
415        ]),
416        ParseValue::Bytes(_) => tagged(vec![("__type", s("Bytes")), ("base64", s(""))]),
417        ParseValue::File { name, .. } => tagged(vec![("__type", s("File")), ("name", s(name))]),
418        ParseValue::Polygon(_) => tagged(vec![
419            ("__type", s("Polygon")),
420            ("coordinates", ParseValue::Array(Vec::new())),
421        ]),
422        ParseValue::Relation { class_name } => tagged(vec![
423            ("__type", s("Relation")),
424            ("className", s(class_name)),
425        ]),
426        ParseValue::Null | ParseValue::Bool(_) | ParseValue::Number(_) => Vec::new(),
427    }
428}
429
430/// Is this what JavaScript's `typeof x === 'object' && x !== null` would say?
431///
432/// Every tagged Parse value is an ordinary object at this point in upstream, because `validateCLP`
433/// runs on the parsed request body before anything classifies it. Arrays are objects too.
434fn is_js_object(value: &ParseValue) -> bool {
435    !matches!(
436        value,
437        ParseValue::Null | ParseValue::Bool(_) | ParseValue::Number(_) | ParseValue::String(_)
438    )
439}
440
441/// JavaScript's `String(x)`, for the messages that interpolate an offending value.
442///
443/// Needed because the messages quote the value back and a client can match on the result. The
444/// cases that matter: a number renders through the ECMAScript algorithm rather than Rust's
445/// `Display` (so `1` and not `1.0`), an array joins its elements with a comma and renders `null`
446/// as the empty string, and every object renders as the literal `[object Object]`.
447///
448/// The tagged variants all render as `[object Object]` for the same reason [`is_js_object`] treats
449/// them as objects: upstream sees the raw JSON, where they are plain objects.
450fn js_string(value: &ParseValue) -> String {
451    match value {
452        ParseValue::Null => "null".to_string(),
453        ParseValue::Bool(b) => b.to_string(),
454        ParseValue::Number(n) => js_number::to_ecma_string(*n),
455        ParseValue::String(s) => s.clone(),
456        ParseValue::Array(items) => items
457            .iter()
458            .map(|item| match item {
459                // `[null].toString()` is `""`, not `"null"`.
460                ParseValue::Null => String::new(),
461                other => js_string(other),
462            })
463            .collect::<Vec<_>>()
464            .join(","),
465        _ => "[object Object]".to_string(),
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use parse_rust_core::{classify, OpEntity, Operation, PfEntity};
473    use parse_rust_storage::FieldType;
474
475    fn opts() -> ClpValidation {
476        ClpValidation {
477            object_id: ObjectIdForm::Generated,
478            unenforceable: Unenforceable::Accept,
479        }
480    }
481
482    fn map(json: &str) -> ParseMap {
483        match classify(serde_json::from_str(json).expect("test literal must be valid JSON"))
484            .expect("classify")
485        {
486            ParseValue::Object(m) => m,
487            other => panic!("expected an object, got {other:?}"),
488        }
489    }
490
491    fn schema() -> ClassSchema {
492        crate::controller::default_schema("Post")
493            .with_field("title", FieldType::String)
494            .with_field(
495                "owner",
496                FieldType::Pointer {
497                    target_class: "_User".into(),
498                },
499            )
500            .with_field(
501                "author",
502                FieldType::Pointer {
503                    target_class: "Writer".into(),
504                },
505            )
506            .with_field("editors", FieldType::Array)
507    }
508
509    fn err(json: &str) -> ParseError {
510        validate_clp(map(json), &schema(), opts()).expect_err("should be rejected")
511    }
512
513    fn ok(json: &str) -> ClassLevelPermissions {
514        validate_clp(map(json), &schema(), opts()).expect("should be accepted")
515    }
516
517    #[test]
518    fn an_unknown_top_level_key_is_refused_without_quotes() {
519        let e = err(r#"{"nope":{"*":true}}"#);
520        assert_eq!(
521            e.message,
522            "nope is not a valid operation for class level permissions"
523        );
524        assert_eq!(e.code, ErrorCode::InvalidJson);
525    }
526
527    #[test]
528    fn every_valid_top_level_key_is_accepted() {
529        // Cheap, and it catches a transcription slip in the table.
530        assert_eq!(VALID_KEYS.len(), 11);
531        ok(r#"{
532            "ACL":{"*":{"read":true,"write":true}},
533            "find":{"*":true},"count":{"*":true},"get":{"*":true},
534            "create":{"*":true},"update":{"*":true},"delete":{"*":true},
535            "addField":{"*":true},
536            "readUserFields":["owner"],"writeUserFields":["owner"],
537            "protectedFields":{"*":["title"]}
538        }"#);
539    }
540
541    #[test]
542    fn only_literal_true_grants_an_operation() {
543        for (json, rendered) in [
544            (r#"{"find":{"*":false}}"#, "false"),
545            (r#"{"find":{"*":0}}"#, "0"),
546            (r#"{"find":{"*":"true"}}"#, "true"),
547            (r#"{"find":{"*":null}}"#, "null"),
548            (r#"{"find":{"*":1}}"#, "1"),
549        ] {
550            let e = err(json);
551            assert_eq!(
552                e.message,
553                format!("'{rendered}' is not a valid value for class level permissions acl find:*"),
554                "{json}"
555            );
556        }
557    }
558
559    /// The number rendering is not Rust's. `1` must not come out as `1.0`.
560    #[test]
561    fn numbers_render_through_the_ecmascript_algorithm() {
562        assert_eq!(js_string(&ParseValue::Number(1.0)), "1");
563        assert_eq!(js_string(&ParseValue::Number(1.5)), "1.5");
564        assert_eq!(js_string(&ParseValue::Number(-0.0)), "0");
565    }
566
567    #[test]
568    fn a_non_object_operation_is_refused_before_its_entities() {
569        let e = err(r#"{"find":true}"#);
570        assert_eq!(
571            e.message,
572            "'true' is not a valid value for class level permissions find - must be an object"
573        );
574        // And the array form has its own message.
575        let e = err(r#"{"readUserFields":"owner"}"#);
576        assert_eq!(
577            e.message,
578            "'owner' is not a valid value for class level permissions readUserFields - must be an \
579             array"
580        );
581    }
582
583    #[test]
584    fn the_two_entity_grammars_reject_each_others_spellings() {
585        // `authenticated` is not an operation entity, but it does match the objectId regex, so it
586        // is accepted as an objectId rather than refused. The refusal only bites on a string the
587        // objectId regex also rejects.
588        let e = err(r#"{"find":{"has-dash":true}}"#);
589        assert_eq!(
590            e.message,
591            "'has-dash' is not a valid key for class level permissions"
592        );
593        let e = err(r#"{"protectedFields":{"has-dash":["title"]}}"#);
594        assert_eq!(
595            e.message,
596            "'has-dash' is not a valid key for class level permissions"
597        );
598    }
599
600    #[test]
601    fn a_custom_object_id_configuration_widens_the_entity_grammar() {
602        let custom = ClpValidation {
603            object_id: ObjectIdForm::Custom,
604            unenforceable: Unenforceable::Accept,
605        };
606        // Rejected under the generated-id regex, accepted under the custom one. Hardcoding the
607        // first would lock a legitimately configured server out of its own CLP.
608        assert!(validate_clp(map(r#"{"find":{"has-dash":true}}"#), &schema(), opts()).is_err());
609        assert!(validate_clp(map(r#"{"find":{"has-dash":true}}"#), &schema(), custom).is_ok());
610        // Empty is still not an objectId under either form.
611        assert!(validate_clp(map(r#"{"find":{"":true}}"#), &schema(), custom).is_err());
612    }
613
614    /// `/^role:.*/` accepts an empty role name. Do not tighten it.
615    #[test]
616    fn an_empty_role_name_is_accepted() {
617        let c = ok(r#"{"find":{"role:":true}}"#);
618        let perm = c.op(Operation::Find).expect("find is present");
619        assert_eq!(perm.entities, vec![OpEntity::Role(String::new())]);
620    }
621
622    #[test]
623    fn pointer_fields_must_be_an_array_of_user_pointers_or_arrays() {
624        ok(r#"{"find":{"pointerFields":["owner"]}}"#);
625        ok(r#"{"find":{"pointerFields":["editors"]}}"#);
626
627        // A pointer at the wrong class is not a pointer permission.
628        let e = err(r#"{"find":{"pointerFields":["author"]}}"#);
629        assert_eq!(
630            e.message,
631            "'author' is not a valid column for class level pointer permissions [object Object]"
632        );
633        // Nor is a field of the wrong type, nor one that does not exist.
634        assert!(err(r#"{"find":{"pointerFields":["title"]}}"#)
635            .message
636            .starts_with("'title' is not a valid column"));
637        assert!(err(r#"{"find":{"pointerFields":["ghost"]}}"#)
638            .message
639            .starts_with("'ghost' is not a valid column"));
640    }
641
642    /// The grouped arrays pass the operation *key*, so their message reads correctly. The
643    /// per-operation `pointerFields` call site passes the operation object and produces
644    /// `[object Object]`. Both are upstream's.
645    #[test]
646    fn the_pointer_permission_message_differs_between_the_two_call_sites() {
647        assert_eq!(
648            err(r#"{"readUserFields":["title"]}"#).message,
649            "'title' is not a valid column for class level pointer permissions readUserFields"
650        );
651        assert_eq!(
652            err(r#"{"writeUserFields":["title"]}"#).message,
653            "'title' is not a valid column for class level pointer permissions writeUserFields"
654        );
655        assert_eq!(
656            err(r#"{"find":{"pointerFields":["title"]}}"#).message,
657            "'title' is not a valid column for class level pointer permissions [object Object]"
658        );
659    }
660
661    #[test]
662    fn a_non_array_pointer_fields_names_the_operation_and_the_entity() {
663        let e = err(r#"{"update":{"pointerFields":"owner"}}"#);
664        assert_eq!(
665            e.message,
666            "'owner' is not a valid value for update[pointerFields] - expected an array."
667        );
668    }
669
670    #[test]
671    fn protected_fields_must_be_arrays_of_existing_non_default_fields() {
672        let c = ok(r#"{"protectedFields":{"*":["title"],"role:A":["title","owner"]}}"#);
673        assert_eq!(
674            c.protected_fields().get(&PfEntity::Public),
675            Some(&vec!["title".to_string()])
676        );
677
678        let e = err(r#"{"protectedFields":{"*":"title"}}"#);
679        assert_eq!(
680            e.message,
681            "'title' is not a valid value for protectedFields[*] - expected an array."
682        );
683
684        let e = err(r#"{"protectedFields":{"role:A":["ghost"]}}"#);
685        assert_eq!(
686            e.message,
687            "Field 'ghost' in protectedFields:role:A does not exist"
688        );
689    }
690
691    /// The rule that keeps `objectId` from being hidden through the schema API. A protected
692    /// `objectId` would make every row unidentifiable to its own owner.
693    #[test]
694    fn no_default_column_can_be_protected() {
695        for column in ["objectId", "createdAt", "updatedAt", "ACL"] {
696            let e = err(&format!(r#"{{"protectedFields":{{"*":["{column}"]}}}}"#));
697            assert_eq!(
698                e.message,
699                format!("Default field '{column}' can not be protected")
700            );
701        }
702    }
703
704    #[test]
705    fn the_clp_acl_key_takes_read_and_write_booleans() {
706        ok(r#"{"ACL":{"*":{"read":true,"write":false}}}"#);
707
708        let e = err(r#"{"ACL":{"*":true}}"#);
709        assert_eq!(
710            e.message,
711            "'true' is not a valid value for class level permissions acl"
712        );
713
714        let e = err(r#"{"ACL":{"*":{"read":true,"delete":true,"update":true}}}"#);
715        assert_eq!(
716            e.message,
717            "'delete,update' is not a valid key for class level permissions acl"
718        );
719
720        let e = err(r#"{"ACL":{"*":{"read":1,"write":"yes"}}}"#);
721        assert_eq!(
722            e.message,
723            "'1,yes' is not a valid value for class level permissions acl"
724        );
725    }
726
727    /// 0.2.0 refuses what it cannot enforce rather than storing it unenforced.
728    #[test]
729    fn unenforceable_features_are_refused_not_ignored() {
730        let refuse = ClpValidation {
731            object_id: ObjectIdForm::Generated,
732            unenforceable: Unenforceable::Refuse,
733        };
734        for json in [
735            r#"{"readUserFields":["owner"]}"#,
736            r#"{"writeUserFields":["owner"]}"#,
737            r#"{"protectedFields":{"userField:owner":["title"]}}"#,
738        ] {
739            let e = validate_clp(map(json), &schema(), refuse).expect_err("must be refused");
740            assert_eq!(e.code, ErrorCode::CommandUnavailable, "{json}");
741        }
742        // And the same blocks are accepted when the caller asks for upstream behavior, which is
743        // what reading an existing database needs.
744        for json in [
745            r#"{"readUserFields":["owner"]}"#,
746            r#"{"writeUserFields":["owner"]}"#,
747            r#"{"protectedFields":{"userField:owner":["title"]}}"#,
748        ] {
749            assert!(validate_clp(map(json), &schema(), opts()).is_ok(), "{json}");
750        }
751    }
752
753    /// The refusal must not fire on an entity that merely looks similar.
754    #[test]
755    fn refusing_user_field_entries_does_not_refuse_ordinary_ones() {
756        let refuse = ClpValidation {
757            object_id: ObjectIdForm::Generated,
758            unenforceable: Unenforceable::Refuse,
759        };
760        assert!(validate_clp(
761            map(r#"{"protectedFields":{"*":["title"],"role:A":["title"],"authenticated":["title"]}}"#),
762            &schema(),
763            refuse
764        )
765        .is_ok());
766    }
767
768    /// Validation must not normalize. A key parse-rust does not model has to survive, or a
769    /// parse-server node reading the same database sees it disappear.
770    #[test]
771    fn the_raw_block_survives_validation_unchanged() {
772        let c = ok(r#"{"find":{"*":true},"ACL":{"*":{"read":true}}}"#);
773        assert!(c.raw().contains_key("ACL"));
774        assert!(c.raw().contains_key("find"));
775        assert_eq!(c.raw().len(), 2);
776    }
777
778    /// An empty block is valid and is not the same thing as an absent one.
779    #[test]
780    fn an_empty_block_validates() {
781        let c = ok("{}");
782        assert!(c.op(Operation::Find).is_none());
783        assert!(c.raw().is_empty());
784    }
785
786    /// An array operation value. Upstream's `for...in` yields the index, which passes as an
787    /// objectId, and the element then fails `permit !== true`. This answered 200 before.
788    #[test]
789    fn an_array_operation_value_is_refused() {
790        let err = validate_clp(map(r#"{"find": ["*"]}"#), &schema(), opts())
791            .expect_err("an array is not a permission object");
792        assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson);
793    }
794
795    /// The one that mattered. A tagged value stored unvalidated reads back as **deny-all**, so the
796    /// class locks and nothing reports it at the time of the write that locked it.
797    #[test]
798    fn a_tagged_operation_value_is_refused_rather_than_silently_locking_the_class() {
799        let err = validate_clp(
800            map(r#"{"find": {"__type": "Date", "iso": "2026-01-01T00:00:00.000Z"}}"#),
801            &schema(),
802            opts(),
803        )
804        .expect_err("a Date is not a permission object");
805        assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson);
806    }
807
808    /// The same hole on `protectedFields`, which uses the same `for...in`.
809    #[test]
810    fn an_array_protected_fields_value_is_refused() {
811        let err = validate_clp(map(r#"{"protectedFields": ["title"]}"#), &schema(), opts())
812            .expect_err("an array is not a protectedFields object");
813        assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson);
814    }
815
816    /// A primitive yields no keys, exactly as `for...in` over a number does, so it passes this
817    /// stage. Reproducing upstream's silence here is deliberate.
818    #[test]
819    fn a_primitive_operation_value_yields_no_entries() {
820        assert!(js_own_entries(&ParseValue::Number(1.0)).is_empty());
821        assert!(js_own_entries(&ParseValue::Bool(true)).is_empty());
822        assert!(js_own_entries(&ParseValue::Null).is_empty());
823    }
824}