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>,
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();
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}");
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");
assert!(!format!("{found:?}").contains("ACME"));
assert_eq!(
serde_json::to_value(&found).unwrap()["bank_account"],
"ACME 123-456-789"
);
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();
let mut same = original.clone();
assert!(!same.save_changes(db, &original).await.unwrap());
assert_eq!(stored(&app, "bank_account").await.unwrap(), first);
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"
);
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();
let Some(pool) = app.db().sqlite().cloned() else {
return; };
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();
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)));
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);
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"]);
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"]);
}