Skip to content

Commit

Permalink
identity: add router tests (#125)
Browse files Browse the repository at this point in the history
  • Loading branch information
TheButlah authored Aug 27, 2024
1 parent 909593a commit 47f4dfe
Show file tree
Hide file tree
Showing 13 changed files with 178 additions and 44 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/.idea
/.vscode
/target
target/
/vendor

*.swp
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ egui-picking = { path = "crates/egui-picking" }
eyre = "0.6"
futures = "0.3.30"
hex-literal = "0.4.1"
http-body-util = "0.1.2"
jose-jwk = { version = "0.1.2", default-features = false }
lightyear = "0.12"
openxr = "0.18"
Expand All @@ -75,6 +76,7 @@ thiserror = "1.0.56"
tokio = { version = "1.35.1", default-features = false }
tokio-serde = "0.9"
tokio-util = { version = "0.7.10", default-features = true }
tower = "0.4.13"
tower-http = "0.5.2"
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
Expand Down
4 changes: 3 additions & 1 deletion apps/identity_server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ axum.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
color-eyre.workspace = true
did-simple.workspace = true
http-body-util.workspace = true
jose-jwk = { workspace = true, default-features = false }
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx = { version = "0.8.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "uuid"] }
sqlx = { version = "0.8.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "uuid", "migrate"] }
thiserror.workspace = true
tokio = { workspace = true, features = ["full"] }
tower-http = { workspace = true, features = ["trace"] }
Expand All @@ -28,3 +29,4 @@ uuid = { workspace = true, features = ["std", "v4", "serde"] }
[dev-dependencies]
base64.workspace = true
hex-literal.workspace = true
tower.workspace = true
6 changes: 6 additions & 0 deletions apps/identity_server/fixtures/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# SQL Test Fixtures

These are a series of SQL queries that are used to create a dummy database during
testing. See [the sqlx docs][docs] for more info.

[docs]: https://docs.rs/sqlx/latest/sqlx/attr.test.html
4 changes: 4 additions & 0 deletions apps/identity_server/fixtures/sample_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
INSERT INTO users (user_id, pubkeys_jwks) VALUES
(X'00000000000000000000000000000001', '{"keys":[{"kty": "OKP", "crv": "Ed25519", "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE"}]}'),
(X'00000000000000000000000000000002', '{"keys":[{"kty": "OKP", "crv": "Ed25519", "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI"}]}'),
(X'00000000000000000000000000000003', '{"keys":[{"kty": "OKP", "crv": "Ed25519", "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM"}]}');
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
CREATE TABLE "users"
(
user_id BLOB PRIMARY KEY NOT NULL,
pubkeys TEXT NOT NULL
pubkeys_jwks TEXT NOT NULL
) STRICT;
7 changes: 7 additions & 0 deletions apps/identity_server/migrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# SQL Migrations

These represent reversible steps to transform a SQL database from nothing to the
latest schema.

These SQL statements consist entirely of Data Definition Language (DDL) queries
(aka, CREATE, DROP, ALTER, etc).
21 changes: 19 additions & 2 deletions apps/identity_server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,27 @@ mod uuid;

use axum::routing::get;
use color_eyre::eyre::Context as _;
use sqlx::sqlite::SqlitePool;
use tower_http::trace::TraceLayer;

/// Main router of API
#[derive(Debug, Default)]
pub const MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");

/// A [`SqlitePool`] that has already been migrated.
#[derive(Debug, Clone)]
pub struct MigratedDbPool(SqlitePool);

impl MigratedDbPool {
pub async fn new(pool: SqlitePool) -> color_eyre::Result<Self> {
MIGRATOR
.run(&pool)
.await
.wrap_err("failed to run migrations")?;

Ok(Self(pool))
}
}

#[derive(Debug)]
pub struct RouterConfig {
pub v1: crate::v1::RouterConfig,
}
Expand Down
37 changes: 22 additions & 15 deletions apps/identity_server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ use std::net::{Ipv6Addr, SocketAddr};

use clap::Parser as _;
use color_eyre::eyre::Context as _;
use identity_server::MigratedDbPool;
use std::path::PathBuf;
use tracing::{info, warn};
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};

#[derive(clap::Parser, Debug)]
Expand All @@ -23,23 +24,29 @@ async fn main() -> color_eyre::Result<()> {
.init();

let cli = Cli::parse();
// Validate cli args further
if !cli.db_path.exists() {
warn!(
"no file exists at {}, creating a new database",
cli.db_path.display()
);
tokio::fs::OpenOptions::new()
.append(true)
.create_new(true)
.open(&cli.db_path)

let db_pool = {
let connect_opts = sqlx::sqlite::SqliteConnectOptions::new()
.create_if_missing(true)
.filename(&cli.db_path);
let pool_opts = sqlx::sqlite::SqlitePoolOptions::new();
let pool = pool_opts
.connect_with(connect_opts.clone())
.await
.wrap_err_with(|| {
format!(
"failed to connect to database with path {}",
connect_opts.get_filename().display()
)
})?;
MigratedDbPool::new(pool)
.await
.wrap_err("failed to create empty file for new database")?;
}
.wrap_err("failed to migrate db pool")?
};

let v1_cfg = identity_server::v1::RouterConfig {
db_url: format!("sqlite:{}", cli.db_path.display()),
..Default::default()
uuid_provider: Default::default(),
db_pool,
};
let router = identity_server::RouterConfig { v1: v1_cfg }
.build()
Expand Down
1 change: 0 additions & 1 deletion apps/identity_server/src/uuid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ impl UuidProvider {

/// Allows controlling the sequence of generated UUIDs. Only available in
/// `cfg(test)`.
#[allow(dead_code)]
#[cfg(test)]
pub fn new_from_sequence(uuids: Vec<Uuid>) -> Self {
Self {
Expand Down
133 changes: 110 additions & 23 deletions apps/identity_server/src/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,29 @@ use jose_jwk::{Jwk, JwkSet};
use tracing::error;
use uuid::Uuid;

use crate::uuid::UuidProvider;
use crate::{uuid::UuidProvider, MigratedDbPool};

#[derive(Debug, Clone)]
struct RouterState {
uuid_provider: Arc<UuidProvider>,
db_pool: sqlx::sqlite::SqlitePool,
db_pool: MigratedDbPool,
}

/// Configuration for the V1 api's router.
#[derive(Debug, Default)]
#[derive(Debug)]
pub struct RouterConfig {
pub uuid_provider: UuidProvider,
pub db_pool_opts: sqlx::sqlite::SqlitePoolOptions,
pub db_url: String,
pub db_pool: MigratedDbPool,
}

impl RouterConfig {
pub async fn build(self) -> color_eyre::Result<Router> {
let db_pool = self
.db_pool_opts
.connect(&self.db_url)
.await
.wrap_err_with(|| {
format!("failed to connect to pool with url {}", self.db_url)
})?;

sqlx::migrate!("./migrations")
.run(&db_pool)
.await
.wrap_err("failed to run migrations")?;

Ok(Router::new()
.route("/create", post(create))
.route("/users/:id/did.json", get(read))
.with_state(RouterState {
uuid_provider: Arc::new(self.uuid_provider),
db_pool,
db_pool: self.db_pool,
}))
}
}
Expand Down Expand Up @@ -85,10 +71,10 @@ async fn create(
};
let serialized_jwks = serde_json::to_string(&jwks).expect("infallible");

sqlx::query("INSERT INTO users (user_id, pubkeys) VALUES ($1, $2)")
sqlx::query("INSERT INTO users (user_id, pubkeys_jwks) VALUES ($1, $2)")
.bind(uuid)
.bind(serialized_jwks)
.execute(&state.db_pool)
.execute(&state.db_pool.0)
.await
.wrap_err("failed to insert identity into db")?;

Expand Down Expand Up @@ -129,9 +115,9 @@ async fn read(
Path(user_id): Path<Uuid>,
) -> Result<Json<JwkSet>, ReadErr> {
let keyset_in_string: Option<String> =
sqlx::query_scalar("SELECT pubkeys FROM users WHERE user_id = $1")
sqlx::query_scalar("SELECT pubkeys_jwks FROM users WHERE user_id = $1")
.bind(user_id)
.fetch_optional(&state.db_pool)
.fetch_optional(&state.db_pool.0)
.await
.wrap_err("failed to retrieve from database")?;
let Some(keyset_in_string) = keyset_in_string else {
Expand All @@ -143,3 +129,104 @@ async fn read(

Ok(Json(keyset))
}

#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{Request, Response},
};
use color_eyre::Result;
use http_body_util::BodyExt;
use jose_jwk::OkpCurves;
use sqlx::SqlitePool;
use tower::ServiceExt as _; // for `collect`

fn uuids(num_uuids: usize) -> Vec<Uuid> {
(1..=num_uuids)
.map(|x| Uuid::from_u128(x.try_into().unwrap()))
.collect()
}

async fn test_router(db_pool: SqlitePool) -> Result<Router> {
let db_pool = crate::MigratedDbPool::new(db_pool)
.await
.wrap_err("failed to migrate db")?;
let router = RouterConfig {
uuid_provider: UuidProvider::new_from_sequence(uuids(10)),
db_pool,
};
router.build().await.wrap_err("failed to build router")
}

/// Validates the response and ensures it matches `expected_keys`
async fn check_response_keys(
response: Response<Body>,
mut expected_keys: Vec<[u8; 32]>,
) -> Result<()> {
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["Content-Type"], "application/json");
let body = response.into_body().collect().await?.to_bytes();
let jwks: JwkSet =
serde_json::from_slice(&body).wrap_err("failed to deserialize response")?;
let mut ed25519_keys: Vec<[u8; 32]> = jwks
.keys
.into_iter()
.map(|jwk| {
let jose_jwk::Key::Okp(ref key) = jwk.key else {
panic!("did not encounter okp key group");
};
assert_eq!(key.crv, OkpCurves::Ed25519);
assert!(key.d.is_none(), "private keys should not be stored");
let key: [u8; 32] =
key.x.as_ref().try_into().expect("wrong key length");
key
})
.collect();

ed25519_keys.sort();
expected_keys.sort();
assert_eq!(ed25519_keys, expected_keys);

Ok(())
}

/// Puts `num` as last byte of pubkey, everything else zero.
fn key_from_number(num: u8) -> [u8; 32] {
let mut expected_key = [0; 32];
*expected_key.last_mut().unwrap() = num;
expected_key
}

#[sqlx::test(
migrator = "crate::MIGRATOR",
fixtures("../../fixtures/sample_users.sql")
)]
async fn test_read_db(db_pool: SqlitePool) -> Result<()> {
let router = test_router(db_pool).await?;
let req = Request::builder()
.method("GET")
.uri(format!("/users/{}/did.json", Uuid::from_u128(1)))
.body(axum::body::Body::empty())
.unwrap();
let response = router.oneshot(req).await?;

check_response_keys(response, vec![key_from_number(1)]).await
}

#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_read_nonexistent_user(db_pool: SqlitePool) -> Result<()> {
let router = test_router(db_pool).await?;
let req = Request::builder()
.method("GET")
.uri(format!("/users/{}/did.json", Uuid::nil()))
.body(axum::body::Body::empty())
.unwrap();
let response = router.oneshot(req).await?;

assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);

Ok(())
}
}
1 change: 1 addition & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ ignore = [
"RUSTSEC-2021-0064", # cpuid-bool renamed
"RUSTSEC-2023-0052", # webpki DOS
"RUSTSEC-2024-0336", # rusttls inf loop
"RUSTSEC-2024-0363", # waiting for sqlx 0.8.1 to release
]

[licenses]
Expand Down

0 comments on commit 47f4dfe

Please sign in to comment.