quality: MSRV, tarpaulin config, proptest for parsers (#84)

* fix: proxy dedup, multi-registry GC, TOCTOU and credential hygiene

- Deduplicate proxy_fetch/proxy_fetch_text into generic proxy_fetch_core
  with response extractor closure (removes ~50 lines of copy-paste)
- GC now scans all registry prefixes, not just docker/
- Add tracing::warn to fire-and-forget cache writes in docker proxy
- Mark S3 credentials as skip_serializing to prevent accidental leaks
- Remove TOCTOU race in LocalStorage get/delete (redundant exists check)

* chore: clean up root directory structure

- Move Dockerfile.astra and Dockerfile.redos to deploy/ (niche builds
  should not clutter the project root)
- Harden .gitignore to exclude session files, working notes, and
  internal review scripts

* refactor(metrics): replace 13 atomic fields with CounterMap

Per-registry download/upload counters were 13 individual AtomicU64
fields, each duplicated across new(), with_persistence(), save(),
record_download(), record_upload(), and get_registry_* (6 touch points
per counter). Adding a new registry required changes in 6+ places.

Now uses CounterMap (HashMap<String, AtomicU64>) for per-registry
counters. Adding a new registry = one entry in REGISTRIES const.
Added Go registry to REGISTRIES, gaining go metrics for free.

* quality: add MSRV, tarpaulin config, proptest for parsers

- Set rust-version = 1.75 in workspace Cargo.toml (MSRV policy)
- Add tarpaulin.toml: llvm engine, fail-under=25, json+html output
- Add coverage/ to .gitignore
- Update CI to use tarpaulin.toml instead of inline flags
- Add proptest dev-dependency and property tests:
  - validation.rs: 16 tests (never-panics + invariants for all 4 validators)
  - pypi.rs: 5 tests (extract_filename never-panics + format assertions)

* test: add unit tests for 14 modules, coverage 21% → 30%

Add 149 new tests across auth, backup, gc, metrics, mirror parsers,
docker (manifest detection, session cleanup, metadata serde),
docker_auth (token cache), maven, npm, pypi (normalize, rewrite, extract),
raw (content-type guessing), request_id, and s3 (URI encoding).

Update tarpaulin.toml: raise fail-under to 30, exclude UI/main from
coverage reporting as they require integration tests.

* bench: add criterion benchmarks for validation and manifest parsing

Add parsing benchmark suite with 14 benchmarks covering:
- Storage key, Docker name, digest, and reference validation
- Docker manifest media type detection (v2, OCI index, minimal, invalid)

Run with: cargo bench --package nora-registry --bench parsing

* test: add 48 integration tests via tower oneshot

Add integration tests for all HTTP handlers:
- health (3), raw (7), cargo (4), maven (4), request_id (2)
- pypi (5), npm (5), docker (12), auth (6)

Create test_helpers.rs with TestContext pattern.
Add tower and http-body-util dev-dependencies.
Update tarpaulin fail-under 30 to 40.

Coverage: 29.5% to 43.3% (2089/4825 lines)

* fix: clean clippy warnings in tests, fix flaky audit test

Add #[allow(clippy::unwrap_used)] to 18 test modules.
Fix 3 additional clippy lints: writeln_empty_string, needless_update,
unnecessary_get_then_check.
Fix flaky audit test: replace single sleep(50ms) with retry loop (max 1s).
Prefix unused token variable with underscore.

cargo clippy --all-targets = 0 warnings (was 245 errors)
This commit is contained in:
2026-04-05 10:01:50 +03:00
committed by GitHub
parent 35a9e34a3e
commit ac3a8a7c43
37 changed files with 3452 additions and 130 deletions

View File

@@ -141,3 +141,175 @@ fn guess_content_type(path: &str) -> &'static str {
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_guess_content_type_json() {
assert_eq!(guess_content_type("config.json"), "application/json");
}
#[test]
fn test_guess_content_type_xml() {
assert_eq!(guess_content_type("data.xml"), "application/xml");
}
#[test]
fn test_guess_content_type_html() {
assert_eq!(guess_content_type("index.html"), "text/html");
assert_eq!(guess_content_type("page.htm"), "text/html");
}
#[test]
fn test_guess_content_type_css() {
assert_eq!(guess_content_type("style.css"), "text/css");
}
#[test]
fn test_guess_content_type_js() {
assert_eq!(guess_content_type("app.js"), "application/javascript");
}
#[test]
fn test_guess_content_type_text() {
assert_eq!(guess_content_type("readme.txt"), "text/plain");
}
#[test]
fn test_guess_content_type_markdown() {
assert_eq!(guess_content_type("README.md"), "text/markdown");
}
#[test]
fn test_guess_content_type_yaml() {
assert_eq!(guess_content_type("config.yaml"), "application/x-yaml");
assert_eq!(guess_content_type("config.yml"), "application/x-yaml");
}
#[test]
fn test_guess_content_type_toml() {
assert_eq!(guess_content_type("Cargo.toml"), "application/toml");
}
#[test]
fn test_guess_content_type_archives() {
assert_eq!(guess_content_type("data.tar"), "application/x-tar");
assert_eq!(guess_content_type("data.gz"), "application/gzip");
assert_eq!(guess_content_type("data.gzip"), "application/gzip");
assert_eq!(guess_content_type("data.zip"), "application/zip");
}
#[test]
fn test_guess_content_type_images() {
assert_eq!(guess_content_type("logo.png"), "image/png");
assert_eq!(guess_content_type("photo.jpg"), "image/jpeg");
assert_eq!(guess_content_type("photo.jpeg"), "image/jpeg");
assert_eq!(guess_content_type("anim.gif"), "image/gif");
assert_eq!(guess_content_type("icon.svg"), "image/svg+xml");
}
#[test]
fn test_guess_content_type_special() {
assert_eq!(guess_content_type("doc.pdf"), "application/pdf");
assert_eq!(guess_content_type("module.wasm"), "application/wasm");
}
#[test]
fn test_guess_content_type_unknown() {
assert_eq!(guess_content_type("binary.bin"), "application/octet-stream");
assert_eq!(guess_content_type("noext"), "application/octet-stream");
}
#[test]
fn test_guess_content_type_case_insensitive() {
assert_eq!(guess_content_type("FILE.JSON"), "application/json");
assert_eq!(guess_content_type("IMAGE.PNG"), "image/png");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod integration_tests {
use crate::test_helpers::{
body_bytes, create_test_context, create_test_context_with_raw_disabled, send,
};
use axum::http::{Method, StatusCode};
#[tokio::test]
async fn test_raw_put_get_roundtrip() {
let ctx = create_test_context();
let put_resp = send(&ctx.app, Method::PUT, "/raw/test.txt", b"hello".to_vec()).await;
assert_eq!(put_resp.status(), StatusCode::CREATED);
let get_resp = send(&ctx.app, Method::GET, "/raw/test.txt", "").await;
assert_eq!(get_resp.status(), StatusCode::OK);
let body = body_bytes(get_resp).await;
assert_eq!(&body[..], b"hello");
}
#[tokio::test]
async fn test_raw_head() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/raw/test.txt",
b"hello world".to_vec(),
)
.await;
let head_resp = send(&ctx.app, Method::HEAD, "/raw/test.txt", "").await;
assert_eq!(head_resp.status(), StatusCode::OK);
let cl = head_resp.headers().get("content-length").unwrap();
assert_eq!(cl.to_str().unwrap(), "11");
}
#[tokio::test]
async fn test_raw_delete() {
let ctx = create_test_context();
send(&ctx.app, Method::PUT, "/raw/test.txt", b"data".to_vec()).await;
let del = send(&ctx.app, Method::DELETE, "/raw/test.txt", "").await;
assert_eq!(del.status(), StatusCode::NO_CONTENT);
let get = send(&ctx.app, Method::GET, "/raw/test.txt", "").await;
assert_eq!(get.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_raw_not_found() {
let ctx = create_test_context();
let resp = send(&ctx.app, Method::GET, "/raw/missing.txt", "").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_raw_content_type_json() {
let ctx = create_test_context();
send(&ctx.app, Method::PUT, "/raw/file.json", b"{}".to_vec()).await;
let resp = send(&ctx.app, Method::GET, "/raw/file.json", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp.headers().get("content-type").unwrap();
assert_eq!(ct.to_str().unwrap(), "application/json");
}
#[tokio::test]
async fn test_raw_payload_too_large() {
let ctx = create_test_context();
let big = vec![0u8; 2 * 1024 * 1024]; // 2 MB > 1 MB limit
let resp = send(&ctx.app, Method::PUT, "/raw/large.bin", big).await;
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn test_raw_disabled() {
let ctx = create_test_context_with_raw_disabled();
let get = send(&ctx.app, Method::GET, "/raw/test.txt", "").await;
assert_eq!(get.status(), StatusCode::NOT_FOUND);
let put = send(&ctx.app, Method::PUT, "/raw/test.txt", b"data".to_vec()).await;
assert_eq!(put.status(), StatusCode::NOT_FOUND);
}
}