Skip to content
Using Rent

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 run

Test 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 run

Run 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))
}