1use super::{database, dynamic};
3use pharos_runtime_database::operations::{
4 AppSchema, DatabaseError, Field, FieldConstraint, Mutation, Predicate, Query, SortKey,
5 Transaction, WriteValue,
6};
7use serde_json::{Value, json};
8use std::{
9 collections::{BTreeMap, BTreeSet},
10 path::Path,
11};
12
13fn append_memberships(
14 schema: &AppSchema,
15 owner: bool,
16 user_id: WriteValue,
17 existing_id: Option<i64>,
18 memberships: &BTreeSet<(&str, i64)>,
19 steps: &mut Vec<Mutation>,
20) -> Result<(), DatabaseError> {
21 for name in ["store_membership", "user_profile"] {
22 if existing_id.is_some_and(|id| memberships.contains(&(name, id))) {
23 continue;
24 }
25 let Some(entity) = schema.entities.iter().find(|entity| entity.name == name) else {
26 continue;
27 };
28 let mut values = BTreeMap::new();
29 for field in &entity.fields {
30 let value = match field.name.as_str() {
31 "id" => continue,
32 "user_id" => {
33 values.insert(field.name.clone(), user_id.clone());
34 continue;
35 }
36 "role" => role_value(field, owner, if owner { "store_owner" } else { "customer" })?,
37 "account_type" => role_value(
38 field,
39 owner,
40 if owner {
41 "business_owner"
42 } else {
43 "business_customer"
44 },
45 )?,
46 "full_name" => json!(if owner {
47 "Preview Owner"
48 } else {
49 "Preview Customer"
50 }),
51 "payment_preference" => json!("credit_card"),
52 "email_verified" => json!(true),
53 _ if field.default.is_some() || field.nullable => continue,
54 _ => return Err(DatabaseError::InvalidContract),
55 };
56 values.insert(field.name.clone(), WriteValue::Literal(value));
57 }
58 steps.push(Mutation::Insert {
59 entity: name.into(),
60 values,
61 });
62 }
63 Ok(())
64}
65
66fn role_value(field: &Field, owner: bool, fallback: &str) -> Result<Value, DatabaseError> {
67 let names: &[&str] = if owner {
68 &[
69 "architect_admin",
70 "store_owner",
71 "business_owner",
72 "admin",
73 "owner",
74 ]
75 } else {
76 &["build_user", "business_customer", "customer", "user"]
77 };
78 if let Some(allowed) = field
79 .constraints
80 .iter()
81 .find_map(|constraint| match constraint {
82 FieldConstraint::Allowed(values) => Some(values),
83 _ => None,
84 })
85 {
86 return names
87 .iter()
88 .find_map(|name| {
89 allowed
90 .iter()
91 .find(|value| value.as_str() == Some(name))
92 .cloned()
93 })
94 .ok_or(DatabaseError::InvalidContract);
95 }
96 Ok(json!(fallback))
97}
98
99fn owner_role(value: &str) -> Option<bool> {
100 match value {
101 "architect_admin" | "store_owner" | "business_owner" | "admin" | "owner" => Some(true),
102 "build_user" | "business_customer" | "customer" | "user" => Some(false),
103 _ => None,
104 }
105}
106
107fn append_preview_payment_owner(
109 root: &Path,
110 store: &database::LogicalAppStore,
111 owner_id: WriteValue,
112 steps: &mut Vec<Mutation>,
113) -> Result<(), DatabaseError> {
114 let Some(checkout) = pharos_runtime_payment::checkout_manifest_config(root) else {
115 return Ok(());
116 };
117 if pharos_runtime_payment::checkout_manifest_string(&checkout, "kind", "commerce_cart")
118 != "commerce_cart"
119 {
120 return Ok(());
121 }
122 let management = pharos_runtime_payment::payment_provider_manifest_config(root)
123 .ok_or(DatabaseError::InvalidContract)?;
124 let field = |key: &str| {
125 management
126 .get(key)
127 .and_then(Value::as_str)
128 .ok_or(DatabaseError::InvalidContract)
129 };
130 let entity_name = field("owner_entity")?;
131 let route_field = field("owner_route_field")?;
132 let user_field = field("owner_user_id_field")?;
133 let route = pharos_runtime_payment::checkout_owner_route_value(root, &checkout)
134 .map_err(|_| DatabaseError::InvalidContract)?;
135 let entity = store
136 .schema
137 .entities
138 .iter()
139 .find(|entity| entity.name == entity_name)
140 .ok_or(DatabaseError::InvalidContract)?;
141 if !store
142 .adapter
143 .query(
144 &store.config,
145 &store.schema,
146 &Query {
147 entity: entity_name.into(),
148 fields: vec![route_field.into()],
149 filter: Some(Predicate::Equal(route_field.into(), json!(route))),
150 order: vec![],
151 limit: 1,
152 offset: 0,
153 },
154 )?
155 .is_empty()
156 {
157 return Ok(());
158 }
159 let mut values = BTreeMap::new();
160 for field in &entity.fields {
161 let value = match field.name.as_str() {
162 "id" => continue,
163 name if name == route_field => WriteValue::Literal(json!(route)),
164 name if name == user_field => owner_id.clone(),
165 "name" => WriteValue::Literal(json!(route)),
166 _ if field.default.is_some() || field.nullable => continue,
167 _ => return Err(DatabaseError::InvalidContract),
168 };
169 values.insert(field.name.clone(), value);
170 }
171 steps.push(Mutation::Insert {
172 entity: entity_name.into(),
173 values,
174 });
175 Ok(())
176}
177
178pub(super) fn state_root(workspace: &Path) -> std::path::PathBuf {
179 crate::config::dynamic_config_lookup_raw(&workspace.join("runtime.conf"), "state_dir")
182 .map(std::path::PathBuf::from)
183 .map(|path| {
184 if path.is_absolute() {
185 path
186 } else {
187 workspace.join(path)
188 }
189 })
190 .unwrap_or_else(|| workspace.join("state"))
191}
192
193fn named_defaults_ready(marker: &Path) -> bool {
195 std::fs::metadata(marker)
196 .ok()
197 .filter(|metadata| metadata.len() <= 4096)
198 .and_then(|_| std::fs::read(marker).ok())
199 .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
200 .and_then(|value| value.get("defaults_version").and_then(Value::as_u64))
201 == Some(2)
202}
203
204pub(super) fn prepare(root: &Path, workspace: &Path, app_id: &str) -> Result<(), DatabaseError> {
206 let marker = state_root(workspace)
207 .join(app_id)
208 .join("preview-accounts.json");
209 if named_defaults_ready(&marker) {
210 return Ok(());
211 }
212 static INITIALIZE: std::sync::Mutex<()> = std::sync::Mutex::new(());
213 let _guard = INITIALIZE.lock().map_err(|_| DatabaseError::Unavailable)?;
214 if named_defaults_ready(&marker) {
215 return Ok(());
216 }
217 let previously_initialized = marker.is_file();
218 let store =
219 database::app_operations_store(root, Some(&workspace.join("runtime.conf")), app_id)?;
220 let Some(account) = store
221 .schema
222 .entities
223 .iter()
224 .find(|entity| entity.name == "user_account")
225 else {
226 return Ok(());
227 };
228 if !["id", "username", "password_hash"]
229 .iter()
230 .all(|name| account.fields.iter().any(|field| field.name == *name))
231 {
232 return Err(DatabaseError::InvalidContract);
233 }
234 let users = store.adapter.query(
235 &store.config,
236 &store.schema,
237 &Query {
238 entity: account.name.clone(),
239 fields: account
240 .fields
241 .iter()
242 .filter(|field| {
243 matches!(
244 field.name.as_str(),
245 "id" | "username" | "role" | "account_type"
246 )
247 })
248 .map(|field| field.name.clone())
249 .collect(),
250 filter: None,
251 order: vec![SortKey {
252 field: "id".into(),
253 descending: false,
254 }],
255 limit: 64,
256 offset: 0,
257 },
258 )?;
259 let mut roles = BTreeMap::new();
260 let mut memberships = BTreeSet::new();
261 for (entity_name, role_field) in [
262 ("store_membership", "role"),
263 ("user_profile", "account_type"),
264 ] {
265 let Some(entity) = store
266 .schema
267 .entities
268 .iter()
269 .find(|entity| entity.name == entity_name)
270 else {
271 continue;
272 };
273 if !["user_id", role_field]
274 .iter()
275 .all(|name| entity.fields.iter().any(|field| field.name == *name))
276 {
277 continue;
278 }
279 for row in store.adapter.query(
280 &store.config,
281 &store.schema,
282 &Query {
283 entity: entity_name.into(),
284 fields: vec!["user_id".into(), role_field.into()],
285 filter: None,
286 order: vec![],
287 limit: 128,
288 offset: 0,
289 },
290 )? {
291 if let Some(id) = row.get("user_id").and_then(Value::as_i64) {
292 memberships.insert((entity_name, id));
293 }
294 if let (Some(id), Some(owner)) = (
295 row.get("user_id").and_then(Value::as_i64),
296 row.get(role_field)
297 .and_then(Value::as_str)
298 .and_then(owner_role),
299 ) {
300 roles
301 .entry(id)
302 .and_modify(|existing| *existing |= owner)
303 .or_insert(owner);
304 }
305 }
306 }
307 for user in &users {
308 if let (Some(id), Some(owner)) = (
309 user.get("id").and_then(Value::as_i64),
310 user.get("role")
311 .or_else(|| user.get("account_type"))
312 .and_then(Value::as_str)
313 .and_then(owner_role),
314 ) {
315 roles.insert(id, owner);
316 }
317 }
318 let selected = if roles.is_empty() {
319 [users.first(), users.get(1)]
320 } else {
321 [true, false].map(|owner| {
322 users.iter().find(|user| {
323 user.get("id")
324 .and_then(Value::as_i64)
325 .and_then(|id| roles.get(&id))
326 .copied()
327 == Some(owner)
328 })
329 })
330 };
331 let named = [("admin", true), ("user", false)].map(|(name, owner)| {
332 let user = users
333 .iter()
334 .find(|user| user.get("username").and_then(Value::as_str) == Some(name));
335 if let Some(user) = user {
336 let id = user
337 .get("id")
338 .and_then(Value::as_i64)
339 .ok_or(DatabaseError::InvalidContract)?;
340 let matches_role = roles.get(&id).copied().or_else(|| {
341 selected[usize::from(!owner)].and_then(|selected| {
342 (selected.get("id").and_then(Value::as_i64) == Some(id)).then_some(owner)
343 })
344 });
345 if matches_role != Some(owner) {
346 return Err(DatabaseError::InvalidContract);
347 }
348 }
349 Ok(user)
350 });
351 let [admin, user] = named;
352 let selected = [selected[0], selected[1], admin?, user?];
353 let mut steps = Vec::new();
354 let mut usernames = Vec::new();
355 for (index, user) in selected.into_iter().enumerate() {
356 let owner = index % 2 == 0;
357 let password = dynamic::hash_password("pass").map_err(|_| DatabaseError::Unavailable)?;
358 if let Some(user) = user {
359 let id = user
360 .get("id")
361 .cloned()
362 .ok_or(DatabaseError::InvalidContract)?;
363 usernames.push(
364 user.get("username")
365 .and_then(Value::as_str)
366 .ok_or(DatabaseError::InvalidContract)?
367 .to_owned(),
368 );
369 if previously_initialized {
370 continue;
371 }
372 let mut values =
373 BTreeMap::from([("password_hash".into(), WriteValue::Literal(json!(password)))]);
374 if account.fields.iter().any(|field| field.name == "is_active") {
375 values.insert("is_active".into(), WriteValue::Literal(json!(true)));
376 }
377 for field in account
378 .fields
379 .iter()
380 .filter(|field| matches!(field.name.as_str(), "role" | "account_type"))
381 {
382 if user
383 .get(&field.name)
384 .and_then(Value::as_str)
385 .and_then(owner_role)
386 .is_none()
387 {
388 let fallback = if field.name == "account_type" {
389 if owner {
390 "business_owner"
391 } else {
392 "business_customer"
393 }
394 } else if owner {
395 "owner"
396 } else {
397 "customer"
398 };
399 values.insert(
400 field.name.clone(),
401 WriteValue::Literal(role_value(field, owner, fallback)?),
402 );
403 }
404 }
405 steps.push(Mutation::Update {
406 entity: account.name.clone(),
407 filter: Predicate::Equal("id".into(), id.clone()),
408 values,
409 expected_rows: Some(1),
410 });
411 append_memberships(
412 &store.schema,
413 owner,
414 WriteValue::Literal(id.clone()),
415 id.as_i64(),
416 &memberships,
417 &mut steps,
418 )?;
419 if index == 0 {
420 append_preview_payment_owner(root, &store, WriteValue::Literal(id), &mut steps)?;
421 }
422 continue;
423 }
424 let default_username = ["owner1", "customer1", "admin", "user"][index];
425 let username = if users
426 .iter()
427 .any(|user| user.get("username").and_then(Value::as_str) == Some(default_username))
428 {
429 let nonce = pharos_runtime_security::csrf_generate_random_hex(6);
430 if nonce.is_empty() {
431 return Err(DatabaseError::Unavailable);
432 }
433 format!(
434 "preview_{}_{nonce}",
435 if owner { "owner" } else { "customer" }
436 )
437 } else {
438 default_username.to_owned()
439 };
440 let mut values = BTreeMap::new();
441 for field in &account.fields {
442 let value = match field.name.as_str() {
443 "id" => continue,
444 "username" => json!(username),
445 "password_hash" => json!(password),
446 "email" | "normalized_email" => json!(format!("{username}@preview.example.test")),
447 "display_name" => json!(if owner {
448 "Preview Owner"
449 } else {
450 "Preview Customer"
451 }),
452 "is_active" | "email_verified" => json!(true),
453 "account_status" => json!("active"),
454 "role" => role_value(field, owner, if owner { "owner" } else { "customer" })?,
455 "account_type" => role_value(
456 field,
457 owner,
458 if owner {
459 "business_owner"
460 } else {
461 "business_customer"
462 },
463 )?,
464 "created_at" | "updated_at" => json!(super::timestamp_after_seconds_utc(0)),
465 _ if field.default.is_some() || field.nullable => continue,
466 _ => return Err(DatabaseError::InvalidContract),
467 };
468 values.insert(field.name.clone(), WriteValue::Literal(value));
469 }
470 let account_step = steps.len();
471 steps.push(Mutation::Insert {
472 entity: account.name.clone(),
473 values,
474 });
475 append_memberships(
476 &store.schema,
477 owner,
478 WriteValue::GeneratedId(account_step),
479 None,
480 &memberships,
481 &mut steps,
482 )?;
483 if index == 0 {
484 append_preview_payment_owner(
485 root,
486 &store,
487 WriteValue::GeneratedId(account_step),
488 &mut steps,
489 )?;
490 }
491 usernames.push(username);
492 }
493 if !steps.is_empty() {
494 store.adapter.transact(
495 &store.config,
496 &store.schema,
497 &Transaction {
498 steps,
499 idempotency_key: None,
500 },
501 )?;
502 }
503 std::fs::create_dir_all(marker.parent().ok_or(DatabaseError::InvalidInput)?)
504 .map_err(|_| DatabaseError::Unavailable)?;
505 std::fs::write(
506 marker,
507 serde_json::to_vec(&json!({"defaults_version":2,"usernames": usernames}))
508 .map_err(|_| DatabaseError::InvalidInput)?,
509 )
510 .map_err(|_| DatabaseError::Unavailable)
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use pharos_runtime_database::{
517 contract::AppDataContract,
518 operations::{AppSchema, Entity, Field, FieldType, Identity, Migration, SchemaChange},
519 };
520
521 #[test]
522 fn preview_accounts_have_pass_and_survive_repeated_preparation() {
523 let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
524 .join("tmp/0.8.4-testing")
525 .join(format!("preview-account-unit-{}", std::process::id()));
526 let root = workspace.join("app");
527 std::fs::create_dir_all(root.join("data/contracts")).unwrap();
528 std::fs::write(root.join("pharos.app.json"), r#"{"app_id":"preview_accounts_test","database":{"migration_contract":"data/contracts/database.json"}}"#).unwrap();
529 std::fs::write(root.join("pharos.app.yml"), "app_id: preview_accounts_test\ndatabase:\n migration_contract: data/contracts/database.json\n").unwrap();
530 std::fs::write(
531 root.join("app.conf"),
532 format!("state_dir={}\n", root.join("untrusted-state").display()),
533 )
534 .unwrap();
535 assert_eq!(state_root(&workspace), workspace.join("state"));
536 std::fs::write(
537 workspace.join("runtime.conf"),
538 format!(
539 "db_backend=sqlite\ndb_path={}\n",
540 workspace.join("preview.sqlite3").display()
541 ),
542 )
543 .unwrap();
544 let manifest = json!({"app_id":"preview_accounts_test","database":{"migration_contract":"data/contracts/database.json"},
545 "payments":{"provider_management":{"owner_entity":"store","owner_route_field":"slug","owner_user_id_field":"owner_user_id","supported_providers":[],"provider_checkout":{"kind":"commerce_cart","owner_route_value":"preview-shop"}}}});
546 std::fs::write(
547 root.join("pharos.app.json"),
548 serde_json::to_vec(&manifest).unwrap(),
549 )
550 .unwrap();
551 std::fs::write(
552 root.join("pharos.app.yml"),
553 pharos_runtime_core::yaml::serialize_yaml_value(&manifest).unwrap(),
554 )
555 .unwrap();
556 let entity = Entity {
557 name: "user_account".into(),
558 identity: Identity::AutoInteger("id".into()),
559 fields: [
560 "id",
561 "username",
562 "email",
563 "password_hash",
564 "display_name",
565 "is_active",
566 "role",
567 ]
568 .into_iter()
569 .map(|name| Field {
570 name: name.into(),
571 kind: match name {
572 "id" => FieldType::Integer,
573 "is_active" => FieldType::Boolean,
574 _ => FieldType::Text,
575 },
576 nullable: false,
577 max_length: (name == "username").then_some(128),
578 default: None,
579 references: None,
580 on_delete: None,
581 constraints: if name == "role" {
582 vec![FieldConstraint::Allowed(vec![
583 json!("owner"),
584 json!("customer"),
585 ])]
586 } else {
587 vec![]
588 },
589 })
590 .collect(),
591 unique: vec![vec!["username".into()]],
592 indexes: vec![],
593 conditional_indexes: vec![],
594 };
595 let payment_owner: Entity = serde_json::from_value(json!({"name":"store","identity":{"auto_integer":"id"},
596 "fields":[{"name":"id","kind":"integer","nullable":false,"constraints":[]},
597 {"name":"slug","kind":"text","nullable":false,"max_length":128,"constraints":[]},
598 {"name":"owner_user_id","kind":"integer","nullable":false,"references":["user_account","id"],"on_delete":"restrict","constraints":[]},
599 {"name":"name","kind":"text","nullable":false,"max_length":256,"constraints":[]}],
600 "unique":[["slug"]],"indexes":[],"conditional_indexes":[]})).unwrap();
601 let changes = vec![
602 SchemaChange::CreateEntity(entity.clone()),
603 SchemaChange::CreateEntity(payment_owner.clone()),
604 ];
605 let contract = AppDataContract {
606 schema: AppSchema {
607 app_id: "preview_accounts_test".into(),
608 entities: vec![entity, payment_owner],
609 },
610 migrations: vec![Migration {
611 app_id: "preview_accounts_test".into(),
612 id: "create_users".into(),
613 checksum: pharos_runtime_database::contract::migration_checksum(
614 "preview_accounts_test",
615 "create_users",
616 &changes,
617 )
618 .unwrap(),
619 changes,
620 }],
621 legacy_adoption: None,
622 };
623 std::fs::write(
624 root.join("data/contracts/database.json"),
625 serde_json::to_vec(&contract).unwrap(),
626 )
627 .unwrap();
628 database::source_selected_logical_contract(
629 &root,
630 Some(&workspace.join("runtime.conf")),
631 "preview_accounts_test",
632 None,
633 )
634 .expect("valid preview test contract")
635 .expect("selected preview test contract");
636 database::checked_backend_config(
637 &root,
638 Some(&workspace.join("runtime.conf")),
639 Some("preview_accounts_test"),
640 )
641 .expect("valid preview test backend");
642 assert_eq!(
643 database::dynamic_runtime_prepare(
644 &root,
645 Some(&workspace.join("runtime.conf")),
646 Some("preview_accounts_test")
647 ),
648 0
649 );
650 prepare(&root, &workspace, "preview_accounts_test").unwrap();
651 let store = database::app_operations_store(
652 &root,
653 Some(&workspace.join("runtime.conf")),
654 "preview_accounts_test",
655 )
656 .unwrap();
657 let rows = store
658 .adapter
659 .query(
660 &store.config,
661 &store.schema,
662 &Query {
663 entity: "user_account".into(),
664 fields: vec!["role".into(), "password_hash".into()],
665 filter: None,
666 order: vec![SortKey {
667 field: "id".into(),
668 descending: false,
669 }],
670 limit: 2,
671 offset: 0,
672 },
673 )
674 .unwrap();
675 assert_eq!(rows[0]["role"], "owner");
676 assert_eq!(rows[1]["role"], "customer");
677 let payment_owners = store
678 .adapter
679 .query(
680 &store.config,
681 &store.schema,
682 &Query {
683 entity: "store".into(),
684 fields: vec!["slug".into(), "owner_user_id".into()],
685 filter: None,
686 order: vec![],
687 limit: 2,
688 offset: 0,
689 },
690 )
691 .unwrap();
692 assert_eq!(payment_owners.len(), 1);
693 assert_eq!(payment_owners[0]["slug"], "preview-shop");
694 assert_eq!(payment_owners[0]["owner_user_id"], 1);
695 assert_ne!(rows[0]["password_hash"], rows[1]["password_hash"]);
696 for username in ["owner1", "customer1", "admin", "user"] {
697 assert!(
698 database::dynamic_relational_authenticate_user(
699 &root,
700 Some(&workspace.join("runtime.conf")),
701 Some("preview_accounts_test"),
702 username,
703 "pass"
704 )
705 .unwrap()
706 .is_some()
707 );
708 }
709 let changed = dynamic::hash_password("changed-preview-password").unwrap();
710 let store = database::app_operations_store(
711 &root,
712 Some(&workspace.join("runtime.conf")),
713 "preview_accounts_test",
714 )
715 .unwrap();
716 store
717 .adapter
718 .transact(
719 &store.config,
720 &store.schema,
721 &Transaction {
722 steps: vec![Mutation::Update {
723 entity: "user_account".into(),
724 filter: Predicate::Equal("id".into(), json!(1)),
725 values: BTreeMap::from([(
726 "password_hash".into(),
727 WriteValue::Literal(json!(changed)),
728 )]),
729 expected_rows: Some(1),
730 }],
731 idempotency_key: None,
732 },
733 )
734 .unwrap();
735 store
737 .adapter
738 .transact(
739 &store.config,
740 &store.schema,
741 &Transaction {
742 steps: vec![
743 Mutation::Delete {
744 entity: "user_account".into(),
745 filter: Predicate::Equal("username".into(), json!("admin")),
746 expected_rows: Some(1),
747 },
748 Mutation::Delete {
749 entity: "user_account".into(),
750 filter: Predicate::Equal("username".into(), json!("user")),
751 expected_rows: Some(1),
752 },
753 ],
754 idempotency_key: None,
755 },
756 )
757 .unwrap();
758 std::fs::write(
759 state_root(&workspace).join("preview_accounts_test/preview-accounts.json"),
760 br#"{"usernames":["owner1","customer1"]}"#,
761 )
762 .unwrap();
763 prepare(&root, &workspace, "preview_accounts_test").unwrap();
764 for (username, role) in [("admin", "owner"), ("user", "customer")] {
765 let rows = store
766 .adapter
767 .query(
768 &store.config,
769 &store.schema,
770 &Query {
771 entity: "user_account".into(),
772 fields: vec!["role".into()],
773 filter: Some(Predicate::Equal("username".into(), json!(username))),
774 order: vec![],
775 limit: 1,
776 offset: 0,
777 },
778 )
779 .unwrap();
780 assert_eq!(rows[0]["role"], role);
781 assert!(
782 database::dynamic_relational_authenticate_user(
783 &root,
784 Some(&workspace.join("runtime.conf")),
785 Some("preview_accounts_test"),
786 username,
787 "pass"
788 )
789 .unwrap()
790 .is_some()
791 );
792 }
793 assert!(
794 database::dynamic_relational_authenticate_user(
795 &root,
796 Some(&workspace.join("runtime.conf")),
797 Some("preview_accounts_test"),
798 "owner1",
799 "changed-preview-password"
800 )
801 .unwrap()
802 .is_some()
803 );
804 store
805 .adapter
806 .transact(
807 &store.config,
808 &store.schema,
809 &Transaction {
810 steps: vec![Mutation::Update {
811 entity: "user_account".into(),
812 filter: Predicate::Equal("id".into(), json!(2)),
813 values: BTreeMap::from([(
814 "role".into(),
815 WriteValue::Literal(json!("owner")),
816 )]),
817 expected_rows: Some(1),
818 }],
819 idempotency_key: None,
820 },
821 )
822 .unwrap();
823 std::fs::remove_file(
824 state_root(&workspace).join("preview_accounts_test/preview-accounts.json"),
825 )
826 .unwrap();
827 prepare(&root, &workspace, "preview_accounts_test").unwrap();
828 let rows = store
829 .adapter
830 .query(
831 &store.config,
832 &store.schema,
833 &Query {
834 entity: "user_account".into(),
835 fields: vec!["username".into(), "role".into()],
836 filter: None,
837 order: vec![],
838 limit: 4,
839 offset: 0,
840 },
841 )
842 .unwrap();
843 assert_eq!(rows.len(), 4);
844 assert!(rows.iter().any(|row| {
845 row["role"] == "customer" && row["username"].as_str().is_some_and(|name| name == "user")
846 }));
847 assert!(!root.join("untrusted-state").exists());
848 store
849 .adapter
850 .transact(
851 &store.config,
852 &store.schema,
853 &Transaction {
854 steps: vec![Mutation::Update {
855 entity: "user_account".into(),
856 filter: Predicate::Equal("username".into(), json!("admin")),
857 values: BTreeMap::from([(
858 "role".into(),
859 WriteValue::Literal(json!("customer")),
860 )]),
861 expected_rows: Some(1),
862 }],
863 idempotency_key: None,
864 },
865 )
866 .unwrap();
867 std::fs::remove_file(
868 state_root(&workspace).join("preview_accounts_test/preview-accounts.json"),
869 )
870 .unwrap();
871 assert_eq!(
872 prepare(&root, &workspace, "preview_accounts_test"),
873 Err(DatabaseError::InvalidContract)
874 );
875 std::fs::remove_dir_all(workspace).unwrap();
876 }
877}