use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::body::Body;
use axum::http::{header::AUTHORIZATION, Method, Request, StatusCode};
use axum::Router;
use serde_json::{json, Value};
use tower::ServiceExt;
use codetopo_server::app;
use codetopo_server::config::Config;
use codetopo_server::keys::KeyStore;
use codetopo_server::metering::{current_month, Metering};
use codetopo_server::tenancy::Tenancy;
const OPERATOR: (&str, &str) = ("operator", "operator-secret-value");
fn fixture_repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture-repo")
}
struct Harness {
router: Router,
config: Config,
keys: Arc<KeyStore>,
tenancy: Arc<Tenancy>,
_dir: tempfile::TempDir,
}
impl Harness {
fn new() -> Self {
let dir = tempfile::tempdir().expect("temp dir");
let config = Config {
data_dir: dir.path().join("data"),
keys_file: dir.path().join("api_keys"),
metering_log: dir.path().join("metering.log"),
bind: "127.0.0.1:0".parse().expect("bind addr"),
index_ttl_secs: None,
deny_ownerless_indexes: None,
};
std::fs::create_dir_all(&config.data_dir).expect("data dir");
let keys = Arc::new(KeyStore::parse(&format!(
"{}:{}\n",
OPERATOR.0, OPERATOR.1
)));
let tenancy = Arc::new(Tenancy::open(&config.tenancy_db_path()).expect("tenancy store"));
let metering = Arc::new(Metering::new(&config.metering_log));
Harness {
router: app(
Arc::new(config.clone()),
Arc::clone(&keys),
metering,
Arc::clone(&tenancy),
),
config,
keys,
tenancy,
_dir: dir,
}
}
fn hosted_key(&self, key_id: &str) -> (String, String) {
let secret = self.keys.issue(key_id, "hosted").expect("issue hosted key");
(key_id.to_string(), secret)
}
async fn send(
&self,
key: (&str, &str),
method: Method,
uri: &str,
content_type: Option<&str>,
body: Vec<u8>,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header(AUTHORIZATION, format!("Bearer {}:{}", key.0, key.1));
if let Some(content_type) = content_type {
builder = builder.header("content-type", content_type);
}
let response = self
.router
.clone()
.oneshot(builder.body(Body::from(body)).expect("build request"))
.await
.expect("router response");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body");
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn get(&self, key: (&str, &str), uri: &str) -> (StatusCode, Value) {
self.send(key, Method::GET, uri, None, Vec::new()).await
}
async fn upload(&self, key: (&str, &str), zip: Vec<u8>) -> (StatusCode, Value) {
self.send(
key,
Method::POST,
"/v1/index",
Some("application/zip"),
zip,
)
.await
}
fn seed_active_indexes(&self, key_id: &str, count: usize) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_secs() as i64;
for i in 0..count {
self.tenancy
.record(&format!("seed-{key_id}-{i}"), key_id, now, None)
.expect("seed index record");
}
}
fn index_db_files(&self) -> Vec<String> {
std::fs::read_dir(&self.config.data_dir)
.expect("data dir")
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".db") && name != "tenancy.db")
.collect()
}
fn workdirs(&self) -> Vec<String> {
match std::fs::read_dir(self.config.data_dir.join("ingest")) {
Ok(entries) => entries
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect(),
Err(_) => Vec::new(),
}
}
}
fn seed_query_records(harness: &Harness, key_id: &str, count: usize) {
let month = current_month();
let mut text = String::new();
for _ in 0..count {
text.push_str(&format!(
"{{\"ts\":\"{month}-15T00:00:00.000Z\",\"key_id\":\"{key_id}\",\
\"plan\":\"hosted\",\"method\":\"GET\",\"path\":\"/v1/graphs/x/stats\",\
\"status\":200,\"response_bytes\":1,\"duration_ms\":1}}\n"
));
}
std::fs::write(&harness.config.metering_log, text).expect("seed metering log");
}
fn fixture_zip() -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
for name in ["src/alpha.rs", "src/beta.rs", "src/main.rs"] {
let bytes = std::fs::read(fixture_repo().join(name)).expect("fixture file");
writer
.start_file(format!("fixture-repo/{name}"), options)
.expect("start file");
writer.write_all(&bytes).expect("write file");
}
writer.finish().expect("finish zip").into_inner()
}
fn many_files_zip(files: usize) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
for i in 0..files {
writer
.start_file(format!("f{i:05}.rs"), options)
.expect("start file");
writer.write_all(b"").expect("write file");
}
writer.finish().expect("finish zip").into_inner()
}
#[tokio::test]
async fn hosted_index_quota_allows_the_25th_and_refuses_the_26th() {
let harness = Harness::new();
let tenant = harness.hosted_key("tenant");
let tenant = (tenant.0.as_str(), tenant.1.as_str());
harness.seed_active_indexes(tenant.0, 24);
let (status, body) = harness.upload(tenant, fixture_zip()).await;
assert_eq!(status, StatusCode::OK, "the 25th index is served: {body}");
assert!(body["index_id"].is_string());
let (status, body) = harness.upload(tenant, fixture_zip()).await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{body}");
assert_eq!(body["error"], "index_quota_exceeded");
let message = body["message"].as_str().expect("message");
assert!(message.contains("25"), "the limit is named: {message}");
assert!(message.contains("has 25"), "the count is named: {message}");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_secs() as i64;
assert_eq!(
harness
.tenancy
.count_active_for_owner(tenant.0, now)
.expect("count"),
25
);
assert_eq!(harness.index_db_files().len(), 1);
}
#[tokio::test]
async fn monthly_query_quota_serves_the_10000th_query_and_refuses_the_next() {
let harness = Harness::new();
let tenant = harness.hosted_key("tenant");
let tenant = (tenant.0.as_str(), tenant.1.as_str());
seed_query_records(&harness, tenant.0, 9_999);
let (status, _) = harness.get(tenant, "/v1/schema").await;
assert_eq!(status, StatusCode::OK, "the 10,000th query is served");
let (status, body) = harness.get(tenant, "/v1/schema").await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{body}");
assert_eq!(body["error"], "query_quota_exceeded");
let message = body["message"].as_str().expect("message");
assert!(message.contains("10000"), "the limit is named: {message}");
assert!(
message.contains(¤t_month()),
"the month is named: {message}"
);
let (status, body) = harness.upload(tenant, fixture_zip()).await;
assert_eq!(status, StatusCode::OK, "creation is not a query: {body}");
let (status, _) = harness.get(tenant, "/v1/schema").await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn file_cap_refuses_an_over_cap_tree_and_leaves_nothing_behind() {
let harness = Harness::new();
let tenant = harness.hosted_key("tenant");
let tenant = (tenant.0.as_str(), tenant.1.as_str());
let (status, body) = harness.upload(tenant, many_files_zip(50_001)).await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{body}");
assert_eq!(body["error"], "file_quota_exceeded");
assert!(
body["message"]
.as_str()
.expect("message")
.contains("50000"),
"the cap is named: {body}"
);
assert!(harness.index_db_files().is_empty());
assert!(harness.workdirs().is_empty());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_secs() as i64;
assert_eq!(
harness
.tenancy
.count_active_for_owner(tenant.0, now)
.expect("count"),
0
);
}
#[tokio::test]
async fn self_hosted_key_is_not_enveloped() {
let harness = Harness::new();
harness.seed_active_indexes(OPERATOR.0, 30);
seed_query_records(&harness, OPERATOR.0, 10_000);
let (status, _) = harness.get(OPERATOR, "/v1/schema").await;
assert_eq!(status, StatusCode::OK, "the operator still queries");
let (status, body) = harness
.send(
OPERATOR,
Method::POST,
"/v1/index",
Some("application/json"),
serde_json::to_vec(&json!({
"repo_path": fixture_repo().to_string_lossy(),
}))
.expect("serialise body"),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"the operator's 31st index is served: {body}"
);
}