nyx/tests/surface_flask.rs

188 lines
6.3 KiB
Rust
Raw Permalink Normal View History

2026-06-05 10:16:30 -05:00
//! 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:?}",
);
}
}