Skip to main content

pharos_native/runtime/
architect_preview_accounts.rs

1//! Credentials belong exclusively to Architect's isolated preview database.
2use 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
107/// Initializes the declared payment owner in isolated preview storage without exporting accounts or credentials.
108fn 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    // Only Architect's generated runtime configuration owns mutable preview paths.
180    // An uploaded app.conf must never redirect account initialization outside it.
181    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
193/// Reports whether the isolated namespace has received the named preview defaults.
194fn 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
204/// Initializes isolated preview roles and adds named defaults without resetting established accounts.
205pub(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        // Upgrade an established namespace without replacing its existing passwords.
736        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}