Files
trailbase/crates/core/tests/integration_test.rs

313 lines
8.5 KiB
Rust

use axum::extract::{Json, State};
use axum::http::StatusCode;
use axum_test::TestServer;
use axum_test::multipart::MultipartForm;
use tower_cookies::Cookie;
use tracing_subscriber::prelude::*;
use trailbase_sqlite::params;
use trailbase::AppState;
use trailbase::api::{CreateUserRequest, create_user_handler, login_with_password};
use trailbase::config::proto::{PermissionFlag, RecordApiConfig};
use trailbase::constants::{COOKIE_AUTH_TOKEN, RECORD_API_PATH};
use trailbase::util::id_to_b64;
use trailbase::{DataDir, Server, ServerOptions};
pub(crate) async fn add_record_api_config(
state: &AppState,
api: RecordApiConfig,
) -> Result<(), anyhow::Error> {
let mut config = state.get_config();
config.record_apis.push(api);
return Ok(state.validate_and_update_config(config, None).await?);
}
#[test]
fn integration_tests() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let _ = runtime.block_on(test_record_apis());
}
async fn test_record_apis() {
let data_dir = temp_dir::TempDir::new().unwrap();
let Server {
state,
main_router,
admin_router,
tls,
} = Server::init(ServerOptions {
data_dir: DataDir(data_dir.path().to_path_buf()),
address: "localhost:4041".to_string(),
admin_address: None,
public_dir: None,
dev: false,
disable_auth_ui: false,
cors_allowed_origins: vec![],
runtime_threads: None,
..Default::default()
})
.await
.unwrap();
assert!(admin_router.is_none());
assert!(tls.is_none());
let conn = state.conn();
let logs_conn = state.logs_conn();
create_chat_message_app_tables(conn).await.unwrap();
state.rebuild_schema_cache().await.unwrap();
let room = add_room(conn, "room0").await.unwrap();
let password = "Secret!1!!";
let client_ip = "22.11.22.11";
// Register message table as record API with moderator read access.
add_record_api_config(
&state,
RecordApiConfig{
name: Some("messages_api".to_string()),
table_name: Some("message".to_string()),
acl_authenticated: [PermissionFlag::Read as i32, PermissionFlag::Create as i32].into(),
create_access_rule: Some(
"(SELECT 1 FROM room_members AS m WHERE _USER_.id = _REQ_._owner AND m.user = _USER_.id AND m.room = _REQ_.room)".to_string(),
),
..Default::default()
}
)
.await.unwrap();
let user_x_email = "user_x@test.com";
let user_x = create_user_for_test(&state, user_x_email, password)
.await
.unwrap()
.into_bytes();
let user_x_token = login_with_password(&state, user_x_email, password)
.await
.unwrap();
add_user_to_room(conn, user_x, room).await.unwrap();
// Set up logging: declares **where** tracing is being logged to, e.g. stderr, file, sqlite.
tracing_subscriber::Registry::default()
.with(trailbase::logging::SqliteLogLayer::new(&state, false))
.set_default();
{
let server = TestServer::new(main_router.1).unwrap();
{
// User X can post to a JSON message.
let test_response = server
.post(&format!("/{RECORD_API_PATH}/messages_api"))
.add_header("X-Forwarded-For", client_ip)
.add_cookie(Cookie::new(
COOKIE_AUTH_TOKEN,
user_x_token.auth_token.clone(),
))
.json(&serde_json::json!({
"_owner": id_to_b64(&user_x),
"room": id_to_b64(&room),
"data": "user_x message to room",
}))
.await;
assert_eq!(
test_response.status_code(),
StatusCode::OK,
"{:?}",
test_response
);
}
{
// User X can post a form message.
let test_response = server
.post(&format!("/{RECORD_API_PATH}/messages_api"))
.add_cookie(Cookie::new(
COOKIE_AUTH_TOKEN,
user_x_token.auth_token.clone(),
))
.form(&serde_json::json!({
"_owner": id_to_b64(&user_x),
"room": id_to_b64(&room),
"data": "user_x message to room",
}))
.await;
assert_eq!(test_response.status_code(), StatusCode::OK);
}
{
// User X can post a multipart message.
let form = MultipartForm::new()
.add_text("_owner", id_to_b64(&user_x))
.add_text("room", id_to_b64(&room))
.add_text("data", "user_x message to room");
let test_response = server
.post(&format!("/{RECORD_API_PATH}/messages_api"))
.add_cookie(Cookie::new(
COOKIE_AUTH_TOKEN,
user_x_token.auth_token.clone(),
))
.multipart(form)
.await;
assert_eq!(test_response.status_code(), StatusCode::OK);
}
{
// Add a second record API for the same table
add_record_api_config(
&state,
RecordApiConfig {
name: Some("messages_api_yolo".to_string()),
table_name: Some("message".to_string()),
acl_world: [PermissionFlag::Read as i32, PermissionFlag::Create as i32].into(),
..Default::default()
},
)
.await
.unwrap();
// Anonymous can post to a JSON message (i.e. no credentials/tokens are attached).
let test_response = server
.post(&format!("/{RECORD_API_PATH}/messages_api_yolo"))
.json(&serde_json::json!({
// NOTE: Id must be not null and a random id would violate foreign key constraint as
// defined by the `message` table.
"_owner": id_to_b64(&user_x),
"room": id_to_b64(&room),
"data": "anonymous' message to room",
}))
.await;
assert_eq!(
test_response.status_code(),
StatusCode::OK,
"{test_response:?}"
);
}
}
let logs_count: i64 = logs_conn
.read_query_row_f("SELECT COUNT(*) FROM _logs", (), |row| row.get(0))
.await
.unwrap()
.unwrap();
assert!(logs_count > 0);
let (fetched_ip, latency, status): (String, f64, i64) = logs_conn
.read_query_row_f(
"SELECT client_ip, latency, status FROM _logs WHERE client_ip = $1",
trailbase_sqlite::params!(client_ip),
|row| -> Result<_, rusqlite::Error> {
return Ok((row.get(0)?, row.get(1)?, row.get(2)?));
},
)
.await
.unwrap()
.unwrap();
// We're also testing stiching here, since client_ip is recorded on_request and latency/status
// on_response.
assert_eq!(fetched_ip, client_ip);
assert!(latency > 0.0);
assert_eq!(status, 200);
}
pub async fn create_chat_message_app_tables(
conn: &trailbase_sqlite::Connection,
) -> Result<(), anyhow::Error> {
// Create a messages, chat room and members tables.
conn
.execute_batch(
r#"
CREATE TABLE room (
id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT(uuid_v7()),
name TEXT
) STRICT;
CREATE TABLE message (
id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT (uuid_v7()),
_owner BLOB NOT NULL,
room BLOB NOT NULL,
data TEXT NOT NULL DEFAULT 'empty',
-- on user delete, toombstone it.
FOREIGN KEY(_owner) REFERENCES _user(id) ON DELETE SET NULL,
-- On chatroom delete, delete message
FOREIGN KEY(room) REFERENCES room(id) ON DELETE CASCADE
) STRICT;
CREATE TABLE room_members (
user BLOB NOT NULL,
room BLOB NOT NULL,
FOREIGN KEY(room) REFERENCES room(id) ON DELETE CASCADE,
FOREIGN KEY(user) REFERENCES _user(id) ON DELETE CASCADE
) STRICT;
"#,
)
.await?;
return Ok(());
}
pub async fn add_room(
conn: &trailbase_sqlite::Connection,
name: &str,
) -> Result<[u8; 16], anyhow::Error> {
let room: [u8; 16] = conn
.query_row_f(
"INSERT INTO room (name) VALUES ($1) RETURNING id",
params!(name.to_string()),
|row| row.get::<_, [u8; 16]>(0),
)
.await?
.unwrap();
return Ok(room);
}
pub async fn add_user_to_room(
conn: &trailbase_sqlite::Connection,
user: [u8; 16],
room: [u8; 16],
) -> Result<(), anyhow::Error> {
conn
.execute(
"INSERT INTO room_members (user, room) VALUES ($1, $2)",
params!(user, room),
)
.await?;
return Ok(());
}
pub(crate) async fn create_user_for_test(
state: &AppState,
email: &str,
password: &str,
) -> Result<uuid::Uuid, anyhow::Error> {
return Ok(
create_user_handler(
State(state.clone()),
Json(CreateUserRequest {
email: email.to_string(),
password: password.to_string(),
verified: true,
admin: false,
}),
)
.await?
.id,
);
}