renox 1.1.0

A batteries-included web framework for Rust, inspired by Laravel: Axum, HTMX and Alpine.js, SQLite or PostgreSQL, deployed as one binary.
Documentation
//! M23: `Encrypted<T>` model fields and savepoints, on SQLite and (with
//! TEST_DATABASE_URL) PostgreSQL.

use renox::db::{Encrypted, Migration};
use renox::prelude::*;
use renox::testing::TestApp;

const SCHEMA: Migration = Migration::new("20300101000000_secrets", "", None).sqlite("CREATE TABLE suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE,
                 bank_account TEXT NOT NULL, api_keys TEXT, updated_at TEXT);
             CREATE TABLE ledger (id INTEGER PRIMARY KEY AUTOINCREMENT, note TEXT NOT NULL UNIQUE);", None).postgres("CREATE TABLE suppliers (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT NOT NULL UNIQUE,
                 bank_account TEXT NOT NULL, api_keys TEXT, updated_at TIMESTAMPTZ);
             CREATE TABLE ledger (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, note TEXT NOT NULL UNIQUE);", None);

#[derive(Model, serde::Serialize, serde::Deserialize, Default, Debug, Clone, PartialEq)]
#[model(table = "suppliers")]
struct Supplier {
    id: i64,
    name: String,
    bank_account: Encrypted<String>,
    /// Any serde type, stored as sealed JSON; optional.
    api_keys: Option<Encrypted<Vec<String>>>,
    updated_at: Option<DateTime>,
}

async fn app() -> TestApp {
    TestApp::new(App::new().migrations(&[SCHEMA])).await
}

async fn stored(app: &TestApp, column: &str) -> Option<String> {
    renox::db::sql(format!("SELECT {column} FROM suppliers"))
        .scalar(app.db())
        .await
        .unwrap()
}

#[renox::test]
async fn encrypted_fields_are_sealed_in_the_table() {
    let app = app().await;
    let db = app.db();
    let supplier = Supplier::create(
        db,
        Supplier {
            name: "Island Coffee".into(),
            bank_account: Encrypted::new("ACME 123-456-789".into()),
            api_keys: Some(vec!["sk_live_1".to_owned(), "sk_live_2".to_owned()].into()),
            ..Default::default()
        },
    )
    .await
    .unwrap();

    // The table holds sealed text, never the value.
    let account = stored(&app, "bank_account").await.unwrap();
    assert!(
        !account.contains("ACME") && !account.contains("123"),
        "{account}"
    );
    let keys = stored(&app, "api_keys").await.unwrap();
    assert!(!keys.contains("sk_live"), "{keys}");

    // Read back as the value, through the model and through `fetch_as`.
    let found = Supplier::find_or_404(db, supplier.id).await.unwrap();
    assert_eq!(*found.bank_account, "ACME 123-456-789");
    assert_eq!(
        found.api_keys.as_deref().map(Vec::as_slice),
        Some(&["sk_live_1".to_owned(), "sk_live_2".to_owned()][..])
    );
    let rows: Vec<(String, Encrypted<String>)> =
        renox::db::sql("SELECT name, bank_account FROM suppliers")
            .fetch_as(db)
            .await
            .unwrap();
    assert_eq!(*rows[0].1, "ACME 123-456-789");

    // Debug never shows it; JSON (your choice to send) does.
    assert!(!format!("{found:?}").contains("ACME"));
    assert_eq!(
        serde_json::to_value(&found).unwrap()["bank_account"],
        "ACME 123-456-789"
    );
    // A form or JSON body fills it like a plain field.
    let parsed: Supplier = serde_json::from_value(json!({
        "id": 0, "name": "X", "bank_account": "Zenith 1", "api_keys": null, "updated_at": null
    }))
    .unwrap();
    assert_eq!(*parsed.bank_account, "Zenith 1");
}

#[renox::test]
async fn encrypted_fields_update_and_compare_by_value() {
    let app = app().await;
    let db = app.db();
    let original = Supplier::create(
        db,
        Supplier {
            name: "Java Tea".into(),
            bank_account: Encrypted::new("Union 1".into()),
            ..Default::default()
        },
    )
    .await
    .unwrap();
    let first = stored(&app, "bank_account").await.unwrap();

    // Unchanged: nothing to write, although a new seal would differ.
    let mut same = original.clone();
    assert!(!same.save_changes(db, &original).await.unwrap());
    assert_eq!(stored(&app, "bank_account").await.unwrap(), first);

    // Changed through `DerefMut`: written, sealed again.
    let mut changed = original.clone();
    changed.bank_account.push_str("-2");
    assert!(changed.save_changes(db, &original).await.unwrap());
    let second = stored(&app, "bank_account").await.unwrap();
    assert_ne!(second, first);
    assert_eq!(
        *Supplier::find_or_404(db, original.id)
            .await
            .unwrap()
            .bank_account,
        "Union 1-2"
    );

    // Inside a transaction too (the transaction carries the key).
    let mut tx = db.begin().await.unwrap();
    let mut row = Supplier::find_or_404(&mut tx, original.id).await.unwrap();
    row.bank_account = Encrypted::new("Union 9".into());
    row.save(&mut tx).await.unwrap();
    tx.commit().await.unwrap();
    assert_eq!(
        *Supplier::find_or_404(db, original.id)
            .await
            .unwrap()
            .bank_account,
        "Union 9"
    );
}

#[renox::test]
async fn encrypted_fields_need_the_apps_key() {
    let app = app().await;
    Supplier::create(
        app.db(),
        Supplier {
            name: "Palm Sugar".into(),
            bank_account: Encrypted::new("ACME 7".into()),
            ..Default::default()
        },
    )
    .await
    .unwrap();
    // A Db made from the raw pool has no APP_KEY: reading and writing
    // sealed columns fail loudly instead of returning or storing garbage.
    let Some(pool) = app.db().sqlite().cloned() else {
        return; // the same holds on PostgreSQL; the pool is only reachable on SQLite here
    };
    let bare = Db::from(pool);
    let read = Supplier::all(&bare).await.unwrap_err();
    assert!(format!("{read:?}").contains("without a key"), "{read:?}");
    let mut new = Supplier {
        name: "No key".into(),
        bank_account: Encrypted::new("x".into()),
        ..Default::default()
    };
    let write = new.save(&bare).await.unwrap_err();
    assert!(format!("{write:?}").contains("APP_KEY"), "{write:?}");
}

async fn notes(db: &Db) -> Vec<String> {
    renox::db::sql("SELECT note FROM ledger ORDER BY id")
        .scalars(db)
        .await
        .unwrap()
}

fn insert(note: &'static str) -> renox::db::Sql {
    renox::db::sql("INSERT INTO ledger (note) VALUES (?)").bind(note)
}

#[renox::test]
async fn a_failed_savepoint_undoes_only_itself() {
    let app = app().await;
    let db = app.db();
    let mut tx = db.begin().await.unwrap();
    insert("order").execute(&mut tx).await.unwrap();

    // Fails after writing: its row goes, the order stays.
    let voucher: Result<()> = tx
        .savepoint(|tx| {
            Box::pin(async move {
                insert("voucher used").execute(&mut *tx).await?;
                Err(Error::NotFound)
            })
        })
        .await;
    assert!(matches!(voucher, Err(Error::NotFound)));

    // Succeeds: kept, with a nested savepoint that fails inside it.
    let points = tx
        .savepoint(|tx| {
            Box::pin(async move {
                insert("points").execute(&mut *tx).await?;
                let nested: Result<()> = tx
                    .savepoint(|tx| {
                        Box::pin(async move {
                            insert("bonus").execute(&mut *tx).await?;
                            Err(Error::NotFound)
                        })
                    })
                    .await;
                assert!(nested.is_err());
                Ok(7)
            })
        })
        .await
        .unwrap();
    assert_eq!(points, 7);

    // A database error in a savepoint (a duplicate) is expected here: the
    // transaction goes on, on PostgreSQL too.
    let duplicate = tx
        .savepoint(|tx| Box::pin(async move { Ok(insert("order").execute(&mut *tx).await?) }))
        .await;
    assert!(duplicate.is_err());
    insert("shipped").execute(&mut tx).await.unwrap();
    tx.commit().await.unwrap();
    assert_eq!(notes(db).await, ["order", "points", "shipped"]);
}

#[renox::test]
async fn savepoints_work_inside_db_transaction() {
    let app = app().await;
    let db = app.db();
    let kept = db
        .transaction(|tx| {
            Box::pin(async move {
                insert("a").execute(&mut *tx).await?;
                let inner: Result<()> = tx
                    .savepoint(|tx| {
                        Box::pin(async move {
                            insert("b").execute(&mut *tx).await?;
                            Err(Error::BadRequest("no".into()))
                        })
                    })
                    .await;
                Ok(inner.is_err())
            })
        })
        .await
        .unwrap();
    assert!(kept);
    assert_eq!(notes(db).await, ["a"]);

    // The whole transaction rolled back takes a released savepoint with it.
    let mut tx = db.begin().await.unwrap();
    tx.savepoint(|tx| Box::pin(async move { Ok(insert("c").execute(&mut *tx).await?) }))
        .await
        .unwrap();
    tx.rollback().await.unwrap();
    assert_eq!(notes(db).await, ["a"]);
}