Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ mod tests {
//! `DocumentTypeV2::try_from_schema` rather than at the per-index
//! `Index::try_from` boundary.
use super::*;
use crate::data_contract::config::v0::DataContractConfigSettersV0;
use platform_value::platform_value;

/// Build a minimal v2-shaped document-type schema with
Expand Down Expand Up @@ -582,6 +583,14 @@ mod tests {
let platform_version = PlatformVersion::latest();
let config = DataContractConfig::default_for_version(platform_version)
.expect("default config available on latest platform version");
parse_with_config(schema, &config)
}

fn parse_with_config(
schema: Value,
config: &DataContractConfig,
) -> Result<DocumentTypeV2, ProtocolError> {
let platform_version = PlatformVersion::latest();
DocumentTypeV2::try_from_schema(
Identifier::new([1; 32]),
1,
Expand All @@ -597,6 +606,35 @@ mod tests {
)
}

fn basic_message_schema(extra: Vec<(&'static str, Value)>) -> Value {
let mut schema_map = vec![
(
Value::Text("type".to_string()),
Value::Text("object".to_string()),
),
(
Value::Text("properties".to_string()),
platform_value!({
"message": {
"type": "string",
"maxLength": 50,
"position": 0,
},
}),
),
(
Value::Text("additionalProperties".to_string()),
Value::Bool(false),
),
];

for (key, value) in extra {
schema_map.push((Value::Text(key.to_string()), value));
}

Value::Map(schema_map)
}

/// `documentsAverageable: "score" + rangeAverageable: true +
/// rangeCountable: false` — explicit-false on the count side
/// contradicts the shorthand. Must reject.
Expand Down Expand Up @@ -781,6 +819,162 @@ mod tests {
);
}

/// `documentsKeepHistory: true` + `canBeDeleted: true` is a
/// self-contradictory document-type configuration that contract
/// validation MUST reject at creation time.
///
/// The two flags are mutually exclusive in practice: when a
/// document type keeps history, rs-drive's delete path rejects
/// every deletion with `InvalidDeletionOfDocumentThatKeepsHistory`
/// (see `force_delete_document_for_contract_operations_v0` in
/// `rs-drive/src/drive/document/delete/.../v0/mod.rs`, which returns
/// the error unconditionally when `documents_keep_history()` is
/// true). So `canBeDeleted: true` on a keep-history type advertises
/// a capability the storage layer will always refuse.
///
/// Today nothing catches this contradiction up front: the parser
/// accepts the contract, the delete state-transition structure
/// validator only checks `documents_can_be_deleted()` (not
/// `documents_keep_history()`), and the contradiction surfaces only
/// at execution — after the transition has already passed broadcast
/// validation. An SDK user can deploy such a contract (testnet
/// contract `5CBPiadGmx3Zsjc26g5onopcx7pdxHPbrRAUD2T2yAbC`'s `note`
/// type is a live example) and only discover the problem when a
/// delete fails deep in Drive.
///
/// This test asserts the behavior we WANT — rejection at
/// contract-creation parse time, naming both offending flags so the
/// author can fix the schema immediately. It FAILS against current
/// code (the contract parses cleanly), pinning the missing guard to
/// the flag-parsing region of `try_from_schema` (mirror the existing
/// cross-flag `ContestedUniqueIndexOnMutableDocumentTypeError`
/// check). Once the guard lands, this test locks the behavior in.
#[test]
fn doctype_keep_history_with_can_be_deleted_rejected() {
let schema = basic_message_schema(vec![
("documentsKeepHistory", Value::Bool(true)),
("canBeDeleted", Value::Bool(true)),
]);
let result = parse(schema);
assert!(
result.is_err(),
"documentsKeepHistory: true + canBeDeleted: true must be rejected at \
contract-creation parse time: a keep-history document type can never be \
deleted (rs-drive returns InvalidDeletionOfDocumentThatKeepsHistory), so \
advertising canBeDeleted: true is a self-contradiction that should be caught \
here rather than at delete-execution time"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("documentsKeepHistory") && msg.contains("canBeDeleted"),
"rejection error must name both documentsKeepHistory and canBeDeleted so the \
contract author knows which flags conflict; got {msg}"
);
}

/// Same contradiction as the explicit `documentsKeepHistory: true`
/// + `canBeDeleted: true` test, but both values are inherited from
/// contract defaults. The parser resolves defaults before storing
/// the document type, so the guard must inspect the resolved
/// booleans, not only the raw keys present in the document schema.
#[test]
fn doctype_keep_history_with_can_be_deleted_inherited_from_defaults_rejected() {
let mut config = DataContractConfig::default_for_version(PlatformVersion::latest())
.expect("default config available on latest platform version");
config.set_documents_keep_history_contract_default(true);
config.set_documents_can_be_deleted_contract_default(true);

let result = parse_with_config(basic_message_schema(vec![]), &config);

assert!(
result.is_err(),
"contract defaults resolving to documentsKeepHistory: true + canBeDeleted: true \
must be rejected just like explicit document-type flags"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("documentsKeepHistory") && msg.contains("canBeDeleted"),
"error must name both resolved conflicting flags; got {msg}"
);
}

/// One-sided inheritance: keep-history comes from the contract
/// default, while `canBeDeleted: true` is explicitly set on the
/// document type. This is equally impossible to execute and must
/// not slip through a guard that only checks for both raw keys on
/// the same schema object.
#[test]
fn doctype_keep_history_default_with_explicit_can_be_deleted_rejected() {
let mut config = DataContractConfig::default_for_version(PlatformVersion::latest())
.expect("default config available on latest platform version");
config.set_documents_keep_history_contract_default(true);
config.set_documents_can_be_deleted_contract_default(false);

let result = parse_with_config(
basic_message_schema(vec![("canBeDeleted", Value::Bool(true))]),
&config,
);

assert!(
result.is_err(),
"documentsKeepHistory inherited true + explicit canBeDeleted: true must be rejected"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("documentsKeepHistory") && msg.contains("canBeDeleted"),
"error must name both resolved conflicting flags; got {msg}"
);
}

/// Opposite one-sided inheritance: `canBeDeleted: true` comes from
/// the contract default, while the document type explicitly opts
/// into history. This is the shape most likely to surprise
/// contract authors because canBeDeleted defaults to true.
#[test]
fn doctype_explicit_keep_history_with_can_be_deleted_default_rejected() {
let mut config = DataContractConfig::default_for_version(PlatformVersion::latest())
.expect("default config available on latest platform version");
config.set_documents_keep_history_contract_default(false);
config.set_documents_can_be_deleted_contract_default(true);

let result = parse_with_config(
basic_message_schema(vec![("documentsKeepHistory", Value::Bool(true))]),
&config,
);

assert!(
result.is_err(),
"explicit documentsKeepHistory: true + inherited canBeDeleted: true must be rejected"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("documentsKeepHistory") && msg.contains("canBeDeleted"),
"error must name both resolved conflicting flags; got {msg}"
);
}

/// Companion to the rejection test: `documentsKeepHistory: true`
/// with `canBeDeleted: false` (or absent) must keep parsing
/// cleanly. Guards the future guard against being over-broad — only
/// the `true + true` combination is contradictory; keep-history
/// types that are (correctly) non-deletable must remain valid.
#[test]
fn doctype_keep_history_with_can_be_deleted_false_accepted() {
let schema = basic_message_schema(vec![
("documentsKeepHistory", Value::Bool(true)),
("canBeDeleted", Value::Bool(false)),
]);
let v2 = parse(schema).expect("keep-history + canBeDeleted: false is a valid combination");
assert!(
v2.documents_keep_history,
"documentsKeepHistory: true must be carried into v2"
);
assert!(
!v2.documents_can_be_deleted,
"canBeDeleted: false must be carried into v2"
);
}

/// Shorthand `documentsAverageable: "score"` with
/// `rangeSummable: true` (no `rangeAverageable`, no
/// `rangeCountable`) must desugar to the SAME
Expand Down
72 changes: 72 additions & 0 deletions packages/rs-dpp/src/document/document_factory/v0/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,15 +544,19 @@ impl DocumentFactoryV0 {

#[cfg(test)]
mod test {
use crate::data_contract::schema::DataContractSchemaMethodsV0;
use data_contracts::SystemDataContract;
use platform_value::platform_value;
use platform_version::version::PlatformVersion;
use std::collections::BTreeMap;

use crate::data_contract::accessors::v0::DataContractV0Getters;
use crate::data_contract::document_type::accessors::DocumentTypeV0Getters;
use crate::document::document_factory::DocumentFactoryV0;
use crate::document::{Document, DocumentV0};
use crate::identifier::Identifier;
use crate::system_data_contracts::load_system_data_contract;
use crate::tests::fixtures::get_data_contract_fixture;

#[test]
/// Create a delete transition in DocumentFactoryV0 for an immutable but deletable document type
Expand Down Expand Up @@ -608,4 +612,72 @@ mod test {
let transitions = result.unwrap();
assert_eq!(transitions.len(), 1, "There should be one transition");
}

#[test]
/// A keep-history document type can never be deleted by Drive, even if the
/// contract advertises `canBeDeleted: true`. The generic DPP factory should
/// reject that delete locally instead of constructing a transition that will
/// be doomed at ABCI/Drive execution.
fn delete_keep_history_document_is_rejected_locally() {
let platform_version = PlatformVersion::latest();
let mut data_contract =
get_data_contract_fixture(None, 0, platform_version.protocol_version)
.data_contract_owned();
data_contract
.set_document_schema(
"historyDocument",
platform_value!({
"type": "object",
"documentsKeepHistory": true,
"canBeDeleted": true,
"properties": {
"name": {
"type": "string",
"position": 0,
},
},
"additionalProperties": false,
}),
false,
&mut vec![],
platform_version,
)
.expect("current parser accepts the contradictory doctype");

let document_type = data_contract
.document_type_for_name("historyDocument")
.expect("expected historyDocument document type");
assert!(document_type.documents_keep_history());
assert!(document_type.documents_can_be_deleted());

let document = Document::V0(DocumentV0 {
id: Identifier::random(),
owner_id: Identifier::random(),
properties: BTreeMap::new(),
revision: Some(1),
created_at: None,
updated_at: None,
transferred_at: None,
created_at_block_height: None,
updated_at_block_height: None,
transferred_at_block_height: None,
created_at_core_block_height: None,
updated_at_core_block_height: None,
transferred_at_core_block_height: None,
creator_id: None,
});

let mut nonce_counter = BTreeMap::new();
let result = DocumentFactoryV0::document_delete_transitions(
vec![(document, document_type, None)],
&mut nonce_counter,
platform_version,
);

assert!(
result.is_err(),
"DocumentFactoryV0 must reject delete transitions for keep-history document types \
before they can be signed and broadcast"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ impl SpecializedDocumentFactoryV0 {
#[allow(clippy::type_complexity)]
mod tests {
use super::*;
use crate::data_contract::schema::DataContractSchemaMethodsV0;
use crate::document::DocumentV0Getters;
use crate::tests::fixtures::get_data_contract_fixture;
use crate::util::entropy_generator::EntropyGenerator;
Expand All @@ -580,6 +581,39 @@ mod tests {
(factory, data_contract)
}

fn setup_factory_with_keep_history_can_be_deleted_document(
) -> (SpecializedDocumentFactoryV0, DataContract) {
let platform_version = PlatformVersion::latest();
let created = get_data_contract_fixture(None, 0, platform_version.protocol_version);
let mut data_contract = created.data_contract_owned();
data_contract
.set_document_schema(
"historyDocument",
platform_value!({
"type": "object",
"documentsKeepHistory": true,
"canBeDeleted": true,
"properties": {
"name": {
"type": "string",
"position": 0,
},
},
"additionalProperties": false,
}),
false,
&mut vec![],
platform_version,
)
.expect("current parser accepts the contradictory doctype");
let factory = SpecializedDocumentFactoryV0::new_with_entropy_generator(
platform_version.protocol_version,
data_contract.clone(),
Box::new(TestEntropyGenerator),
);
(factory, data_contract)
}

#[test]
fn new_creates_factory_with_default_entropy() {
let platform_version = PlatformVersion::latest();
Expand Down Expand Up @@ -1057,6 +1091,32 @@ mod tests {
assert_eq!(*nonce_counter.get(&key).unwrap(), 1);
}

#[test]
fn create_state_transition_delete_with_keep_history_document_returns_error() {
let (factory, data_contract) =
setup_factory_with_keep_history_can_be_deleted_document();
let owner_id = Identifier::from([7u8; 32]);
let doc_type = data_contract
.document_type_for_name("historyDocument")
.unwrap();
assert!(doc_type.documents_keep_history());
assert!(doc_type.documents_can_be_deleted());

let doc = build_document(&factory, owner_id, "historyDocument");
let mut nonce_counter = BTreeMap::new();
let entries = vec![(
DocumentTransitionActionType::Delete,
vec![(doc, doc_type, Bytes32::default(), None)],
)];
let result = factory.create_state_transition(entries, &mut nonce_counter);

assert!(
result.is_err(),
"SpecializedDocumentFactoryV0 must reject delete transitions for keep-history \
document types before they can be signed and broadcast"
);
}

#[test]
fn create_state_transition_delete_without_revision_returns_error() {
let (factory, data_contract) = setup_factory();
Expand Down
Loading
Loading