#![cfg(all(feature = "storage", feature = "friends", any(feature = "mysql", feature = "postgres", feature = "sqlite")))]
mod common;
use axum::body::Body;
use axum::Router;
use http::{Method, Request, StatusCode};
use net_backend_server::auth::{Auth, AuthConfig};
use net_backend_server::friends::{Friends, FriendsConfig};
use net_backend_server::protocol::routes;
use net_backend_server::storage::{Storage, StorageConfig};
use net_backend_server::{Config, NetBackendServer, SecretString};
use serde_json::{json, Value};
const PASSWORD: &str = "correct horse battery";
fn auth_config() -> AuthConfig {
let mut auth = AuthConfig::default();
auth.argon2_memory_kib = 64;
auth.argon2_iterations = 1;
auth.purge_interval_secs = 0;
auth.rate_limits = false;
auth
}
async fn start(url: &str, friends: bool) -> Router {
let mut config = Config::default();
config.database.url = SecretString::new(url);
config.database.migrations_dir = common::temp_dir("storage-vis-migrations");
let mut storage = StorageConfig::default();
storage.write_rate = 0;
let mut server = NetBackendServer::new(config).module(Auth::new().with_config(auth_config()));
if friends {
let mut settings = FriendsConfig::default();
settings.request_rate = 0;
server = server.module(Friends::new().with_config(settings));
}
let prepared = server.module(Storage::new().with_config(storage)).build().await.expect("build");
prepared.migrate().await.expect("migrate");
prepared.router()
}
async fn send(router: &Router, method: Method, path: &str, body: Option<Value>, token: &str) -> (StatusCode, Value) {
let request = Request::builder().method(method).uri(path).header("authorization", format!("Bearer {token}"));
let request = match body {
Some(body) => request.header("content-type", "application/json").body(Body::from(body.to_string())),
None => request.body(Body::empty()),
}
.expect("request");
let (status, _, body) = common::call(router, request).await;
(status, body)
}
async fn register(router: &Router, email: &str) -> (i64, String) {
let (status, _, body) = common::call(router, common::post_json(routes::auth::REGISTER, json!({"email": email, "password": PASSWORD}).to_string())).await;
assert_eq!(status, StatusCode::OK, "{body}");
(body["account"]["id"].as_i64().expect("id"), body["tokens"]["access_token"].as_str().expect("token").to_string())
}
async fn keys(router: &Router, path: &str, token: &str) -> Vec<String> {
let (status, body) = send(router, Method::GET, path, None, token).await;
assert_eq!(status, StatusCode::OK, "{body}");
body["items"].as_array().map(|items| items.iter().filter_map(|i| i["key"].as_str().map(str::to_string)).collect()).unwrap_or_default()
}
async fn visibility_rules(url: &str) {
let router = start(url, true).await;
let (alice, a) = register(&router, "alice@example.com").await;
let (_, b) = register(&router, "bob@example.com").await;
let (carol, c) = register(&router, "carol@example.com").await;
let put = |key: &str| format!("/v1/storage/profile/{key}");
let (status, body) = send(&router, Method::PUT, &put("card"), Some(json!({"value": {"title": "Ace"}, "visibility": "public"})), &a).await;
assert_eq!(status, StatusCode::OK, "{body}");
send(&router, Method::PUT, &put("secret"), Some(json!({"value": 1})), &a).await;
send(&router, Method::PUT, &put("pals"), Some(json!({"value": 2, "visibility": "friends"})), &a).await;
let (_, own) = send(&router, Method::GET, &put("card"), None, &a).await;
assert_eq!(own["visibility"], "public");
let (_, own) = send(&router, Method::GET, &put("secret"), None, &a).await;
assert_eq!(own["visibility"], "private");
let other = |key: &str| format!("/v1/users/{alice}/storage/profile/{key}");
let (status, card) = send(&router, Method::GET, &other("card"), None, &b).await;
assert_eq!((status, card["value"]["title"].as_str(), card["owner"].as_i64()), (StatusCode::OK, Some("Ace"), Some(alice)), "{card}");
assert_eq!(send(&router, Method::GET, &other("secret"), None, &b).await.0, StatusCode::NOT_FOUND);
assert_eq!(send(&router, Method::GET, &other("pals"), None, &b).await.0, StatusCode::NOT_FOUND);
assert_eq!(send(&router, Method::GET, &other("nothing"), None, &b).await.0, StatusCode::NOT_FOUND);
let list = format!("/v1/users/{alice}/storage/profile");
assert_eq!(keys(&router, &list, &b).await, vec!["card".to_string()]);
assert_eq!(keys(&router, &list, &a).await, vec!["card".to_string(), "pals".to_string(), "secret".to_string()], "the owner sees all");
let (status, body) = send(&router, Method::POST, routes::friends::REQUESTS, Some(json!({"user": carol})), &a).await;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, body) = send(&router, Method::POST, &format!("/v1/friends/requests/{alice}/accept"), None, &c).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(send(&router, Method::GET, &other("pals"), None, &c).await.0, StatusCode::OK);
assert_eq!(keys(&router, &list, &c).await, vec!["card".to_string(), "pals".to_string()]);
send(&router, Method::PUT, &put("card"), Some(json!({"value": {"title": "King"}})), &a).await;
assert_eq!(send(&router, Method::GET, &other("card"), None, &b).await.1["value"]["title"], "King");
let batch = json!({"objects": [{"collection": "levels", "key": "one", "value": [1], "visibility": "public"}]});
let (status, body) = send(&router, Method::POST, routes::storage::BATCH_PUT, Some(batch), &a).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(keys(&router, &format!("/v1/users/{alice}/storage/levels"), &b).await, vec!["one".to_string()]);
send(&router, Method::PUT, &put("card"), Some(json!({"value": {}, "visibility": "private"})), &a).await;
assert_eq!(send(&router, Method::GET, &other("card"), None, &b).await.0, StatusCode::NOT_FOUND);
let (status, body) = send(&router, Method::PUT, &put("card"), Some(json!({"value": {}, "visibility": "galaxy"})), &a).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert_eq!(send(&router, Method::PUT, &other("card"), Some(json!({"value": {}})), &b).await.0, StatusCode::METHOD_NOT_ALLOWED);
}
async fn database(backend: &str) -> (String, Option<(String, String)>) {
match backend {
"memory" => ("sqlite::memory:".into(), None),
#[cfg(any(feature = "mysql", feature = "postgres"))]
base => {
let (url, name) = common::fresh_database(base).await;
(url, Some((base.to_string(), name)))
}
#[cfg(not(any(feature = "mysql", feature = "postgres")))]
other => panic!("no backend for {other}"),
}
}
async fn suite(backend: &str) {
let (url, cleanup) = database(backend).await;
visibility_rules(&url).await;
#[cfg(any(feature = "mysql", feature = "postgres"))]
if let Some((base, name)) = cleanup {
common::drop_database(&base, &name).await;
}
#[cfg(not(any(feature = "mysql", feature = "postgres")))]
let _ = cleanup;
}
#[cfg(feature = "sqlite")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn sqlite_memory_suite() {
suite("memory").await;
}
#[cfg(feature = "mysql")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "needs NBS_TEST_MYSQL_URL (a MySQL 8 server; CI service container)"]
async fn mysql_storage_visibility_suite() {
suite(&common::env_url("NBS_TEST_MYSQL_URL")).await;
}
#[cfg(feature = "postgres")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "needs NBS_TEST_POSTGRES_URL (a PostgreSQL 16 server; CI service container)"]
async fn postgres_storage_visibility_suite() {
suite(&common::env_url("NBS_TEST_POSTGRES_URL")).await;
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn without_the_friends_module() {
let router = start("sqlite::memory:", false).await;
let (_, a) = register(&router, "solo@example.com").await;
let (status, body) = send(&router, Method::PUT, "/v1/storage/profile/card", Some(json!({"value": 1, "visibility": "friends"})), &a).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
let (status, _) = send(&router, Method::PUT, "/v1/storage/profile/card", Some(json!({"value": 1, "visibility": "public"})), &a).await;
assert_eq!(status, StatusCode::OK);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn the_write_hook_sees_and_changes_the_visibility() {
use net_backend_server::hooks::Decision;
use net_backend_server::protocol::storage::ObjectVisibility;
use net_backend_server::storage::events::BeforeStorageWrite;
use net_backend_server::AppError;
let mut config = Config::default();
config.database.url = SecretString::new("sqlite::memory:");
let mut storage = StorageConfig::default();
storage.write_rate = 0;
let prepared = NetBackendServer::new(config)
.module(Auth::new().with_config(auth_config()))
.module(Storage::new().with_config(storage))
.before::<BeforeStorageWrite, _, _>(|_ctx, mut write| async move {
match (write.collection.as_str(), write.visibility) {
("drafts", Some(ObjectVisibility::Public)) => return Ok(Decision::Reject(AppError::forbidden("drafts stay private"))),
("pending", Some(ObjectVisibility::Public)) => write.visibility = Some(ObjectVisibility::Private),
("odd", _) => write.visibility = Some(ObjectVisibility::Friends),
_ => {}
}
Ok(Decision::Continue(write))
})
.build()
.await
.expect("build");
prepared.migrate().await.expect("migrate");
let router = prepared.router();
let (alice, a) = register(&router, "hooked-a@example.com").await;
let (_, b) = register(&router, "hooked-b@example.com").await;
let public = json!({"value": 1, "visibility": "public"});
let (status, body) = send(&router, Method::PUT, "/v1/storage/drafts/one", Some(public.clone()), &a).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
let (status, body) = send(&router, Method::PUT, "/v1/storage/pending/one", Some(public.clone()), &a).await;
assert_eq!(status, StatusCode::OK, "{body}");
let (_, own) = send(&router, Method::GET, "/v1/storage/pending/one", None, &a).await;
assert_eq!(own["visibility"], "private", "the hook kept it private");
assert_eq!(send(&router, Method::GET, &format!("/v1/users/{alice}/storage/pending/one"), None, &b).await.0, StatusCode::NOT_FOUND);
let (status, body) = send(&router, Method::PUT, "/v1/storage/odd/one", Some(json!({"value": 1})), &a).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "the hook's visibility is checked again: {body}");
let (status, body) = send(&router, Method::PUT, "/v1/storage/other/one", Some(public), &a).await;
assert_eq!(status, StatusCode::OK, "{body}");
}