mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
188 lines
6.3 KiB
Rust
188 lines
6.3 KiB
Rust
|
|
//! Phase 21 — `SurfaceMap` Python + Flask vertical.
|
|||
|
|
//!
|
|||
|
|
//! Five-route Flask fixture exercising:
|
|||
|
|
//!
|
|||
|
|
//! * `@app.route("/", methods=["GET"])` – default GET
|
|||
|
|
//! * `@app.route("/submit", methods=["POST"])` – POST via methods kwarg
|
|||
|
|
//! * `@app.get("/users")` – verb decorator
|
|||
|
|
//! * `@bp.post("/admin")` – Blueprint receiver
|
|||
|
|
//! * `@app.route("/secret")` + `@login_required` – auth-guarded
|
|||
|
|
//!
|
|||
|
|
//! Asserts every route node appears with the correct `method`, `route`,
|
|||
|
|
//! `auth_required`, and `handler_name`. Round-trips the surface map
|
|||
|
|
//! through SQLite and confirms the byte representation is identical to
|
|||
|
|
//! the in-memory canonical JSON.
|
|||
|
|
|
|||
|
|
use nyx_scanner::commands::index::build_index;
|
|||
|
|
use nyx_scanner::commands::scan::scan_with_index_parallel;
|
|||
|
|
use nyx_scanner::database::index::Indexer;
|
|||
|
|
use nyx_scanner::entry_points::HttpMethod;
|
|||
|
|
use nyx_scanner::surface::{Framework, SurfaceMap, SurfaceNode};
|
|||
|
|
use nyx_scanner::utils::config::{AnalysisMode, Config};
|
|||
|
|
use std::path::Path;
|
|||
|
|
use std::sync::Arc;
|
|||
|
|
|
|||
|
|
fn test_cfg() -> Config {
|
|||
|
|
let mut cfg = Config::default();
|
|||
|
|
cfg.scanner.mode = AnalysisMode::Full;
|
|||
|
|
cfg.scanner.read_vcsignore = false;
|
|||
|
|
cfg.scanner.require_git_to_read_vcsignore = false;
|
|||
|
|
cfg.performance.worker_threads = Some(1);
|
|||
|
|
cfg.performance.batch_size = 8;
|
|||
|
|
cfg.performance.channel_multiplier = 1;
|
|||
|
|
cfg
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const FIVE_ROUTE_FIXTURE: &str = r#"
|
|||
|
|
from flask import Flask, Blueprint
|
|||
|
|
from flask_login import login_required
|
|||
|
|
|
|||
|
|
app = Flask(__name__)
|
|||
|
|
bp = Blueprint("admin", __name__)
|
|||
|
|
|
|||
|
|
@app.route("/", methods=["GET"])
|
|||
|
|
def index():
|
|||
|
|
return "home"
|
|||
|
|
|
|||
|
|
@app.route("/submit", methods=["POST"])
|
|||
|
|
def submit():
|
|||
|
|
return "ok"
|
|||
|
|
|
|||
|
|
@app.get("/users")
|
|||
|
|
def list_users():
|
|||
|
|
return "users"
|
|||
|
|
|
|||
|
|
@bp.post("/admin")
|
|||
|
|
def admin_create():
|
|||
|
|
return "created"
|
|||
|
|
|
|||
|
|
@login_required
|
|||
|
|
@app.route("/secret")
|
|||
|
|
def secret():
|
|||
|
|
return "shh"
|
|||
|
|
"#;
|
|||
|
|
|
|||
|
|
fn seed_flask_fixture(root: &Path) {
|
|||
|
|
std::fs::write(root.join("app.py"), FIVE_ROUTE_FIXTURE.as_bytes()).unwrap();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn surface_map_captures_five_flask_routes() {
|
|||
|
|
let project = tempfile::tempdir().unwrap();
|
|||
|
|
seed_flask_fixture(project.path());
|
|||
|
|
let db_dir = tempfile::tempdir().unwrap();
|
|||
|
|
let db_path = db_dir.path().join("surface.sqlite");
|
|||
|
|
build_index("surface", project.path(), &db_path, &test_cfg(), false)
|
|||
|
|
.expect("build_index on flask fixture should succeed");
|
|||
|
|
let pool = Indexer::init(&db_path).expect("re-init pool");
|
|||
|
|
let _ = scan_with_index_parallel(
|
|||
|
|
"surface",
|
|||
|
|
Arc::clone(&pool),
|
|||
|
|
&test_cfg(),
|
|||
|
|
false,
|
|||
|
|
project.path(),
|
|||
|
|
)
|
|||
|
|
.expect("indexed scan should succeed");
|
|||
|
|
|
|||
|
|
let idx = Indexer::from_pool("surface", &pool).expect("from_pool");
|
|||
|
|
let map = idx
|
|||
|
|
.load_surface_map()
|
|||
|
|
.expect("load_surface_map ok")
|
|||
|
|
.expect("surface map persisted after scan");
|
|||
|
|
|
|||
|
|
let entries: Vec<_> = map.entry_points().collect();
|
|||
|
|
assert_eq!(
|
|||
|
|
entries.len(),
|
|||
|
|
5,
|
|||
|
|
"expected five Flask routes, got {entries:#?}",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let assert_route = |method: HttpMethod, route: &str, handler: &str, auth: bool| {
|
|||
|
|
let ep = map.entry_for_route(method, route).unwrap_or_else(|| {
|
|||
|
|
panic!("missing route {method:?} {route}; map = {entries:#?}");
|
|||
|
|
});
|
|||
|
|
assert_eq!(
|
|||
|
|
ep.framework,
|
|||
|
|
Framework::Flask,
|
|||
|
|
"framework mismatch on {route}"
|
|||
|
|
);
|
|||
|
|
assert_eq!(ep.handler_name, handler, "handler mismatch on {route}");
|
|||
|
|
assert_eq!(
|
|||
|
|
ep.auth_required, auth,
|
|||
|
|
"auth mismatch on {route} (got {})",
|
|||
|
|
ep.auth_required
|
|||
|
|
);
|
|||
|
|
// Handler location must point inside the project file.
|
|||
|
|
assert!(
|
|||
|
|
ep.handler_location.file.ends_with("app.py"),
|
|||
|
|
"handler location not in app.py: {:?}",
|
|||
|
|
ep.handler_location.file
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
assert_route(HttpMethod::GET, "/", "index", false);
|
|||
|
|
assert_route(HttpMethod::POST, "/submit", "submit", false);
|
|||
|
|
assert_route(HttpMethod::GET, "/users", "list_users", false);
|
|||
|
|
assert_route(HttpMethod::POST, "/admin", "admin_create", false);
|
|||
|
|
assert_route(HttpMethod::GET, "/secret", "secret", true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn surface_map_round_trips_byte_identical_through_sqlite() {
|
|||
|
|
let project = tempfile::tempdir().unwrap();
|
|||
|
|
seed_flask_fixture(project.path());
|
|||
|
|
let db_dir = tempfile::tempdir().unwrap();
|
|||
|
|
let db_path = db_dir.path().join("rt.sqlite");
|
|||
|
|
|
|||
|
|
build_index("rt", project.path(), &db_path, &test_cfg(), false).expect("first build_index");
|
|||
|
|
let pool = Indexer::init(&db_path).expect("first pool");
|
|||
|
|
let _ = scan_with_index_parallel("rt", Arc::clone(&pool), &test_cfg(), false, project.path())
|
|||
|
|
.expect("first scan");
|
|||
|
|
let idx = Indexer::from_pool("rt", &pool).expect("first from_pool");
|
|||
|
|
let bytes_first = idx
|
|||
|
|
.load_surface_map_bytes()
|
|||
|
|
.expect("load bytes 1")
|
|||
|
|
.expect("surface map persisted 1");
|
|||
|
|
drop(idx);
|
|||
|
|
|
|||
|
|
// Rescan against the same DB. No source change → byte-identical
|
|||
|
|
// canonical surface map.
|
|||
|
|
let _ = scan_with_index_parallel("rt", Arc::clone(&pool), &test_cfg(), false, project.path())
|
|||
|
|
.expect("second scan");
|
|||
|
|
let idx2 = Indexer::from_pool("rt", &pool).expect("second from_pool");
|
|||
|
|
let bytes_second = idx2
|
|||
|
|
.load_surface_map_bytes()
|
|||
|
|
.expect("load bytes 2")
|
|||
|
|
.expect("surface map persisted 2");
|
|||
|
|
|
|||
|
|
assert_eq!(
|
|||
|
|
bytes_first, bytes_second,
|
|||
|
|
"surface_map JSON must be byte-identical across rescans"
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Round-trip through the in-memory representation: canonicalise →
|
|||
|
|
// serialise should reproduce the on-disk bytes exactly.
|
|||
|
|
let mut map = SurfaceMap::from_json(&bytes_first).expect("from_json");
|
|||
|
|
let bytes_after_round_trip = map.to_json().expect("to_json");
|
|||
|
|
assert_eq!(
|
|||
|
|
bytes_first, bytes_after_round_trip,
|
|||
|
|
"canonical JSON must match round-tripped JSON"
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Light sanity check: the same map deserialised twice still names
|
|||
|
|
// the five fixture routes (i.e. persistence does not lose nodes).
|
|||
|
|
let entries: Vec<&str> = map
|
|||
|
|
.nodes
|
|||
|
|
.iter()
|
|||
|
|
.filter_map(|n| match n {
|
|||
|
|
SurfaceNode::EntryPoint(ep) => Some(ep.route.as_str()),
|
|||
|
|
_ => None,
|
|||
|
|
})
|
|||
|
|
.collect();
|
|||
|
|
for route in ["/", "/submit", "/users", "/admin", "/secret"] {
|
|||
|
|
assert!(
|
|||
|
|
entries.contains(&route),
|
|||
|
|
"route {route} missing after round trip; got {entries:?}",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|