diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index fd68eca19..ff6be23f1 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -1088,6 +1088,132 @@ static int et_handles_exact(const EtFile *files, int nfiles, const EtHandles *wa return et_handles_exact_routes(files, nfiles, want, parallel, NULL); } +/* The exact DATA_FLOWS set, caller -> handler by QN tail. route_qns (NULL-terminated) must + * exist, so a test that expects NO flow on a path cannot pass because the caller's or the + * handler's Route was never created; handles is the exact HANDLES count. */ +typedef struct { + const char *caller_tail; + const char *handler_tail; +} EtFlow; + +static int et_flow_matches(cbm_store_t *store, const cbm_edge_t *e, const EtFlow *want, + int wanted, int *found) { + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + int have_src = cbm_store_find_node_by_id(store, e->source_id, &src) == CBM_STORE_OK; + int have_tgt = cbm_store_find_node_by_id(store, e->target_id, &tgt) == CBM_STORE_OK; + int matched = 0; + for (int wi = 0; have_src && have_tgt && wi < wanted; wi++) { + if (!found[wi] && et_qn_has_tail(src.qualified_name, want[wi].caller_tail) && + et_qn_has_tail(tgt.qualified_name, want[wi].handler_tail)) { + found[wi] = matched = 1; + break; + } + } + if (!matched) { + fprintf(stderr, " [ET-FLOWS] unexpected %s -> %s\n", + have_src && src.qualified_name ? src.qualified_name : "", + have_tgt && tgt.qualified_name ? tgt.qualified_name : ""); + } + if (have_src) cbm_node_free_fields(&src); + if (have_tgt) cbm_node_free_fields(&tgt); + return matched; +} + +static int et_data_flows_exact(const EtFile *files, int nfiles, const EtFlow *want, int parallel, + const char *const *route_qns, int handles) { + EtProj lp; + cbm_store_t *store = + parallel ? et_index_parallel(&lp, files, nfiles) : et_index_files(&lp, files, nfiles); + int wanted = 0; + int found[ET_ROUTE_ASSERT_MAX] = {0}; + while (want[wanted].caller_tail && wanted < ET_ROUTE_ASSERT_MAX) { + wanted++; + } + cbm_edge_t *edges = NULL; + int n = 0; + int ok = store != NULL && + cbm_store_find_edges_by_type(store, lp.project, "DATA_FLOWS", &edges, &n) == + CBM_STORE_OK && + n == wanted; + for (int i = 0; store && i < n; i++) { + if (!et_flow_matches(store, &edges[i], want, wanted, found)) { + ok = 0; + } + } + for (int wi = 0; wi < wanted; wi++) { + if (!found[wi]) { + ok = 0; + fprintf(stderr, " [ET-FLOWS] missing %s -> %s\n", want[wi].caller_tail, + want[wi].handler_tail); + } + } + for (int ri = 0; route_qns && route_qns[ri]; ri++) { + cbm_node_t route = {0}; + if (!store || + cbm_store_find_node_by_qn(store, lp.project, route_qns[ri], &route) != CBM_STORE_OK) { + ok = 0; + fprintf(stderr, " [ET-FLOWS] missing Route %s\n", route_qns[ri]); + } else { + cbm_node_free_fields(&route); + } + } + int got_handles = store ? cbm_store_count_edges_by_type(store, lp.project, "HANDLES") : -1; + if (got_handles != handles) { + ok = 0; + fprintf(stderr, " [ET-FLOWS] HANDLES=%d expected %d\n", got_handles, handles); + } + if (!ok) { + fprintf(stderr, " [ET-FLOWS] FAIL (%s path) expected=%d actual=%d\n", + parallel ? "parallel" : "sequential", wanted, n); + } + if (edges) cbm_store_free_edges(edges, n); + et_cleanup(&lp, store); + return ok; +} + +/* R1 + R2 (user decision, 2026-10-18): DATA_FLOWS pair a test-file caller only with handlers in its + * own file, and a test-file handler only with callers in its own file. A Spring controller and a + * mock controller under src/test/ both answer `@RequestMapping` (any method). A production TS + * client and a TS test (.test.ts) call them with fetch, which names no method, so every caller and + * handler meets on the same __route__ANY__ node. Only the production client -> production + * controller pair is a data flow of the application, on the sequential and the parallel path. */ +static const EtFile et_r12_files[] = { + {"server/OrderController.java", + "package com.example;\n\n" + "import org.springframework.web.bind.annotation.RequestMapping;\n" + "import org.springframework.web.bind.annotation.RestController;\n\n" + "@RestController\n@RequestMapping(\"/api\")\npublic class OrderController {\n" + " @RequestMapping(\"/orders\")\n" + " public String orders() {\n return \"orders\";\n }\n}\n"}, + {"server/src/test/java/com/example/MockPaymentController.java", + "package com.example;\n\n" + "import org.springframework.web.bind.annotation.RequestMapping;\n" + "import org.springframework.web.bind.annotation.RestController;\n\n" + "@RestController\n@RequestMapping(\"/api\")\npublic class MockPaymentController {\n" + " @RequestMapping(\"/payments\")\n" + " public String pay() {\n return \"paid\";\n }\n}\n"}, + {"web/src/api.ts", + "export async function loadOrders() {\n return fetch(\"/api/orders\");\n}\n\n" + "export async function loadPayments() {\n return fetch(\"/api/payments\");\n}\n"}, + {"web/src/api.test.ts", + "export async function testLoadOrders() {\n return fetch(\"/api/orders\");\n}\n"}}; + +static const EtFlow et_r12_flows[] = {{"api.loadOrders", "OrderController.orders"}, {NULL, NULL}}; + +static const char *const et_r12_routes[] = {"__route__ANY__/api/orders", + "__route__ANY__/api/payments", NULL}; + +TEST(data_flows_test_files_pair_within_their_file_sequential) { + ASSERT_TRUE(et_data_flows_exact(et_r12_files, 4, et_r12_flows, 0, et_r12_routes, 2)); + PASS(); +} + +TEST(data_flows_test_files_pair_within_their_file_parallel) { + ASSERT_TRUE(et_data_flows_exact(et_r12_files, 4, et_r12_flows, 1, et_r12_routes, 2)); + PASS(); +} + /* #1146: Laravel's two class-based handler forms — * Route::get('/x', [UserController::class, 'show']) (controller method) * Route::get('/x', GetCurrentUserController::class) (invokable: __invoke) @@ -2589,6 +2715,8 @@ SUITE(edge_types_probe) { RUN_TEST(handles_fiber_group_prefix_parallel_issue686); RUN_TEST(handles_gin_group_prefix_issue686); RUN_TEST(handles_spring_java); + RUN_TEST(data_flows_test_files_pair_within_their_file_sequential); + RUN_TEST(data_flows_test_files_pair_within_their_file_parallel); RUN_TEST(handles_spring_java_path_attribute_fourth); RUN_TEST(handles_spring_kotlin); RUN_TEST(handles_jaxrs_java); diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index 33c272198..abfa8788c 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -477,6 +477,78 @@ TEST(infrascan_data_flows_reach_every_infra_handler) { PASS(); } +/* R1 + R2 (user decision, 2026-10-18): a caller in a test file pairs only with handlers in that same + * file, and a handler in a test file pairs only with callers in that same file. A test's request to + * the application, a production caller matched to a test's mock server, and the private mock servers + * of two tests pairing across files are not data flows of the application. */ +static int64_t r12_fn(cbm_gbuf_t *gb, const char *name, const char *file) { + char qn[96]; + snprintf(qn, sizeof(qn), "test.%s", name); + return cbm_gbuf_upsert_node(gb, "Function", name, qn, file, 1, 3, "{}"); +} + +static int64_t r12_route(cbm_gbuf_t *gb, const char *path) { + char qn[128]; + snprintf(qn, sizeof(qn), "__route__GET__%s", path); + return cbm_gbuf_upsert_node(gb, "Route", path, qn, "", 0, 0, "{\"method\":\"GET\"}"); +} + +static void r12_call(cbm_gbuf_t *gb, int64_t caller, int64_t route, const char *path) { + char props[160]; + snprintf(props, sizeof(props), + "{\"callee\":\"requests.get\",\"url_path\":\"%s\",\"method\":\"GET\"}", path); + cbm_gbuf_insert_edge(gb, caller, route, "HTTP_CALLS", props); +} + +TEST(infrascan_data_flows_test_callers_pair_only_within_their_file) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_r12_callers"); + ASSERT_NOT_NULL(gb); + int64_t route = r12_route(gb, "/api/orders"); + int64_t handler = r12_fn(gb, "list_orders", "app/views.py"); + cbm_gbuf_insert_edge(gb, handler, route, "HANDLES", "{}"); + /* Test files by each language's own rule: a tests/ directory, a Java *Test.java, a TS .spec.ts. */ + int64_t py_test = r12_fn(gb, "test_list_orders", "tests/test_orders.py"); + int64_t java_test = r12_fn(gb, "listsOrders", "app/OrderServiceTest.java"); + int64_t ts_spec = r12_fn(gb, "fetchesOrders", "web/orders.spec.ts"); + int64_t prod = r12_fn(gb, "load_orders", "app/client.py"); + r12_call(gb, py_test, route, "/api/orders"); + r12_call(gb, java_test, route, "/api/orders"); + r12_call(gb, ts_spec, route, "/api/orders"); + r12_call(gb, prod, route, "/api/orders"); + cbm_pipeline_create_route_nodes(gb); + ASSERT_FALSE(has_data_flow(gb, py_test, handler)); + ASSERT_FALSE(has_data_flow(gb, java_test, handler)); + ASSERT_FALSE(has_data_flow(gb, ts_spec, handler)); + /* Control: the production caller keeps its flow. */ + ASSERT_TRUE(has_data_flow(gb, prod, handler)); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(infrascan_data_flows_test_handlers_pair_only_within_their_file) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_r12_handlers"); + ASSERT_NOT_NULL(gb); + int64_t route = r12_route(gb, "/api/s3"); + /* Two tests each run their own mock server on the same path. */ + int64_t mock_a = r12_fn(gb, "mock_s3_a", "tests/test_upload.py"); + int64_t mock_b = r12_fn(gb, "mock_s3_b", "tests/conftest.py"); + cbm_gbuf_insert_edge(gb, mock_a, route, "HANDLES", "{}"); + cbm_gbuf_insert_edge(gb, mock_b, route, "HANDLES", "{}"); + int64_t prod = r12_fn(gb, "upload", "app/storage.py"); + int64_t test_a = r12_fn(gb, "test_upload", "tests/test_upload.py"); + r12_call(gb, prod, route, "/api/s3"); + r12_call(gb, test_a, route, "/api/s3"); + cbm_pipeline_create_route_nodes(gb); + /* A production caller never reaches a test's mock server. */ + ASSERT_FALSE(has_data_flow(gb, prod, mock_a)); + ASSERT_FALSE(has_data_flow(gb, prod, mock_b)); + /* A test reaches its own file's mock server, not another test file's. */ + ASSERT_TRUE(has_data_flow(gb, test_a, mock_a)); + ASSERT_FALSE(has_data_flow(gb, test_a, mock_b)); + cbm_gbuf_free(gb); + PASS(); +} + SUITE(infrascan) { RUN_TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths); RUN_TEST(infrascan_http_route_literal_guard_rejects_document_extensions); @@ -488,4 +560,6 @@ SUITE(infrascan) { RUN_TEST(infrascan_data_flows_link_every_caller_of_a_route); RUN_TEST(infrascan_data_flows_do_not_depend_on_caller_insertion_order); RUN_TEST(infrascan_data_flows_reach_every_infra_handler); + RUN_TEST(infrascan_data_flows_test_callers_pair_only_within_their_file); + RUN_TEST(infrascan_data_flows_test_handlers_pair_only_within_their_file); }