Test your application
Use a dedicated test database that runs the same database engine and extensions as your application. Keep its URL separate from development and production credentials. Apply your checked-in migrations before running integration tests:
rent migrate apply --database-url "$TEST_DATABASE_URL"Configure your test client with that URL. If your application reads DATABASE_URL,
run its suite with the test URL in that variable:
DATABASE_URL="$TEST_DATABASE_URL" cargo nextest runTest the behavior your application promises
Cover the public operations of your application, including:
- Creating, reading, updating, and deleting records through the generated client.
- Loading relationships and paging through a stable ordering.
- Rolling back all related writes when a command fails.
- Rejecting stale optimistic updates and retrying complete transactions when needed.
- Keeping tenant-local identities isolated across reads, writes, and relationships.
Use independent fixtures or isolated databases for concurrent tests. Avoid relying on test execution order or fixed delays to coordinate transactions.
Write one focused behavior test
This test uses a temporary database migrated from the application's schema. It runs the same public operations as the example program:
#[tokio::test]
async fn sharing_and_revocation() -> anyhow::Result<()> {
let (_directory, url) = test_support::database()?;
example(&url).await
}The complete files at the end include test_support and example. The helper
creates a temporary directory, applies the RSL migration with the Rent CLI, and
drops the directory after the test. The example verifies that Ken can edit after
Maya shares a post and loses access after she revokes it. There is no shared test
state or fixed sleep to coordinate.
For a complete HTTP-level test, see the Axum capstone.
Test authorization as different people
Create users with different relationships to the same resource: an owner, an editor, a reader, and someone with no access. Bind a separate scoped client for each person and exercise your application's normal queries and commands.
Check both the response and the stored result. A rejected write should leave the resource and related rows unchanged. Include a second tenant with matching local IDs to catch accidental scope omissions.
For declarative permission expectations, use
rent permissions test. The
fixture tutorial shows how to seed users,
posts, and grants and assert allowed and denied access.
Verify migrations before deployment
Apply the complete migration history to an empty test database, then run
rent migrate drift against it. Also test an upgrade from the previous application
schema when you need to preserve existing data.
Use your deployment database for native behavior such as PostgreSQL extensions, full-text search, row locking, and partial indexes. SQLite is useful for isolated local tests, but it does not reproduce another database's locking or extension behavior.
Complete example
Download this project, or copy the files below into an empty directory.
Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.
Use a separate local database for this example:
export DATABASE_URL='sqlite://lesson.db?mode=rwc'
rent generate
rent migrate dev --name initial
cargo runRun the included test with cargo nextest run. It creates an isolated temporary database.
Cargo.toml
[package]
name = "rent-lesson-27-tenant-safe-sharing"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-27-tenant-safe-sharing"
path = "main.rs"
[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.7.2", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
uuid = { version = "1.26.0", features = ["serde", "v4", "v7"] }
tempfile = "3.27.0"rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"
[database]
dialect = "sqlite"
backends = ["sqlite"]schema.rsl
datasource db {
provider = "sqlite"
}
tenancy {
scope = "required"
field = "tenant_id"
}
access {
subjects = [User]
}
model User {
tenant_id String
id String
name String
posts Post[]
comments Comment[]
@@id([tenant_id, id])
}
model Post {
tenant_id String
id String
title String
body String
author_id String
author User @relation(fields: [tenant_id, author_id], references: [tenant_id, id]) @rent.creator
comments Comment[]
@@id([tenant_id, id])
}
model Comment {
tenant_id String
id String
body String
post_id String
author_id String
post Post @relation(fields: [tenant_id, post_id], references: [tenant_id, id], onDelete: Cascade)
author User @relation(fields: [tenant_id, author_id], references: [tenant_id, id]) @rent.creator
@@id([tenant_id, id])
}
permissions User {
read = anyone
create, update, delete = nobody
}
permissions Post {
editor = author or [User]
viewer = editor or [User]
read = viewer
create = anyone
update = editor
delete, manage_grants = author
}
permissions Comment {
read, create = post.viewer
update = author and post.viewer
delete = author and post.viewer or post.editor
}main.rs
use anyhow::{Result, ensure};
use rent::policy::viewer::Viewer;
use rent::uuid::Uuid;
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
use generated::client::Client;
use generated::{
post::{PostId, PostPermission},
user::{User, UserId},
};
async fn example(database_url: &str) -> Result<()> {
let root = Client::connect(database_url).await?;
// Account provisioning is trusted application work, outside request handlers.
let system = root.system();
system
.upsert_user(generated::user::unique::tenant_id_id)
.tenant_id("acme")
.id("maya")
.name("Maya")
.save()
.await?;
system
.upsert_user(generated::user::unique::tenant_id_id)
.tenant_id("acme")
.id("ken")
.name("Ken")
.save()
.await?;
let maya = root.clone().with_viewer(
Viewer::of(User::subject(UserId {
tenant_id: "acme".into(),
id: "maya".into(),
}))
.tenant("acme"),
);
let ken = root.clone().with_viewer(
Viewer::of(User::subject(UserId {
tenant_id: "acme".into(),
id: "ken".into(),
}))
.tenant("acme"),
);
let post = maya
.create_post()
.id(Uuid::now_v7().to_string())
.title("Launch notes")
.body("An early draft")
.save()
.await?;
let post_key = PostId {
tenant_id: "acme".into(),
id: post.id.clone(),
};
ensure!(!ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
maya.post_permissions(&post_key)
.grant_editor(User::subject(UserId {
tenant_id: "acme".into(),
id: "ken".into(),
}))
.save()
.await?;
let edited = ken
.update_post()
.one(post_key.clone())
.title("Reviewed launch notes")
.save_one()
.await?;
ensure!(edited.title == "Reviewed launch notes");
let comment = ken
.create_comment()
.id(Uuid::now_v7().to_string())
.post_id(&post.id)
.body("Ready for review")
.save()
.await?;
ensure!(comment.author_id == "ken");
let rows = ken.post().id_eq(&post.id).with_permissions().all().await?;
ensure!(rows.len() == 1);
ensure!(rows.first().is_some_and(|row| row.permissions.has(PostPermission::Editor)));
let people = maya
.user()
.who_can(PostPermission::Viewer, &post_key)
.order_by(User::name)
.all()
.await?;
ensure!(people.subjects.iter().any(|person| person.id == "ken"));
let explanation = ken.post_permissions(&post_key).explain(PostPermission::Viewer).await?;
ensure!(explanation.is_some_and(|value| value.allowed));
maya.post_permissions(&post_key)
.revoke_editor(User::subject(UserId {
tenant_id: "acme".into(),
id: "ken".into(),
}))
.await?;
ensure!(!ken.post_permissions(&post_key).can(PostPermission::Viewer).await?);
ensure!(ken.comment().id_eq(&comment.id).all().await?.is_empty());
println!("Maya shared a post with Ken, then removed his access to the discussion.");
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
example(&std::env::var("DATABASE_URL")?).await
}
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests {
#[tokio::test]
async fn sharing_and_revocation() -> anyhow::Result<()> {
let (_directory, url) = super::test_support::database()?;
super::example(&url).await
}
}test_support.rs
use anyhow::{Context, Result, ensure};
use std::process::Command;
pub fn database() -> Result<(tempfile::TempDir, String)> {
let directory = tempfile::tempdir()?;
std::fs::write(directory.path().join("schema.rsl"), include_str!("schema.rsl"))?;
std::fs::write(directory.path().join("rent.toml"), r#"schema = "schema.rsl""#)?;
let url = format!("sqlite://{}?mode=rwc", directory.path().join("app.db").display());
let executable = std::env::var("RENT_CLI").unwrap_or_else(|_| "rent".into());
let output = Command::new(executable)
.arg("migrate")
.arg("dev")
.arg("--root")
.arg(directory.path())
.arg("--to")
.arg("schema.rsl")
.arg("--database-url")
.arg(&url)
.output()
.context("run the Rent CLI")?;
ensure!(
output.status.success(),
"migration failed: {}",
String::from_utf8_lossy(&output.stderr)
);
Ok((directory, url))
}