diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 503d18538..ee9c6d5f9 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -46,6 +46,25 @@ static const lib_pattern_t http_libraries[] = { * case-sensitive, so without this entry `Axios.get(url)` fell through to * the `.get` route-suffix fallback and became a route registration. */ {"Axios", CBM_SVC_HTTP, NULL}, + /* Angular HttpClient (#2768). The receiver rows end in a dot, like "gin.": + * a bare "this.http" / "$http" would also match this.httpAdapter.get + * (NestJS route registration) and $httpBackend.expectGET (an AngularJS test + * double), because an uppercase letter after an id counts as CamelCase + * glue. A chain wrapped before the verb keeps its line break in the callee + * text ("this.http\n .post", prettier's style), so each receiver also + * has a line-break twin: LF, and CR for CRLF files. Java/C# HttpClient is + * already listed below. */ + {"@angular/common/http", CBM_SVC_HTTP, NULL}, + {"angular.common.http", CBM_SVC_HTTP, NULL}, + {"this.http.", CBM_SVC_HTTP, NULL}, + {"this.http\n", CBM_SVC_HTTP, NULL}, + {"this.http\r", CBM_SVC_HTTP, NULL}, + {"httpClient.", CBM_SVC_HTTP, NULL}, + {"httpClient\n", CBM_SVC_HTTP, NULL}, + {"httpClient\r", CBM_SVC_HTTP, NULL}, + {"$http.", CBM_SVC_HTTP, NULL}, + {"$http\n", CBM_SVC_HTTP, NULL}, + {"$http\r", CBM_SVC_HTTP, NULL}, {"superagent", CBM_SVC_HTTP, NULL}, {"needle", CBM_SVC_HTTP, NULL}, {"node-fetch", CBM_SVC_HTTP, NULL}, diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 1f57d1c25..830cacfe1 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -720,6 +720,16 @@ static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *s char method[CBM_SZ_32] = {0}; json_str_prop(props, "url_path", url_path, sizeof(url_path)); json_str_prop(props, "method", method, sizeof(method)); + if (!method[0]) { + char callee[CBM_SZ_256] = {0}; + json_str_prop(props, "callee", callee, sizeof(callee)); + if (callee[0]) { + const char *m = cbm_service_pattern_http_method(callee); + if (m) { + snprintf(method, sizeof(method), "%s", m); + } + } + } if (!url_path[0]) { continue; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index ff7b21c83..e02d68c67 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2621,19 +2621,37 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, if (!cbm_service_pattern_is_http_route_literal(norm, call->callee_name)) { continue; } + const char *method = cbm_service_pattern_http_method(call->callee_name); + const char *prefix = method ? method : "ANY"; char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; - snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, cbm_route_canon_path(norm, cpath, sizeof(cpath))); - int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, - "{\"source\":\"arg_url\"}"); + char route_props[CBM_SZ_256]; + if (method) { + snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\",\"method\":\"%s\"}", + method); + } else { + snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\"}"); + } + int64_t route_id = + cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, route_props); char esc_c[CBM_SZ_256]; char esc_n[CBM_SZ_256]; cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name); cbm_json_escape(esc_n, sizeof(esc_n), norm); - char eprops[CBM_SZ_512]; - snprintf(eprops, sizeof(eprops), - "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); + /* Two escaped 255-byte strings plus the fixed keys and the verb: 1K + * never truncates the JSON (512 could, once the method was added). */ + char eprops[CBM_SZ_1K]; + if (method) { + snprintf( + eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\",\"method\":\"%s\"}", + esc_c, esc_n, method); + } else { + snprintf(eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); + } cbm_gbuf_insert_edge(gbuf, source->id, route_id, "HTTP_CALLS", eprops); break; } diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 9d318f8c0..0bf14c68b 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -944,10 +944,201 @@ TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459) { PASS(); } +static bool cross_repo_seed_server_route(const cross_repo_fixture_t *fixture, + const char *target_project, const char *method, + const char *route_path, const char *handler_name) { + char target_path[512]; + if (!cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) { + return false; + } + cbm_store_t *target = cbm_store_open_path(target_path); + if (!target) { + return false; + } + bool ok = cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK; + char target_route_qn[256]; + char handler_qn[256]; + char route_name[128]; + snprintf(target_route_qn, sizeof(target_route_qn), "__route__%s__%s", method, route_path); + snprintf(handler_qn, sizeof(handler_qn), "%s.%s", target_project, handler_name); + snprintf(route_name, sizeof(route_name), "%s %s", method, route_path); + + cbm_node_t target_route = {.project = target_project, + .label = "Route", + .name = route_name, + .qualified_name = target_route_qn, + .file_path = "server.java"}; + cbm_node_t handler = {.project = target_project, + .label = "Function", + .name = handler_name, + .qualified_name = handler_qn, + .file_path = "server.java"}; + int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0; + int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0; + cbm_edge_t handles = {.project = target_project, + .source_id = handler_id, + .target_id = target_route_id, + .type = "HANDLES"}; + ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0; + cbm_store_close(target); + return ok; +} + +static bool cross_repo_seed_client_call(const cross_repo_fixture_t *fixture, + const char *source_project, const char *caller_name, + const char *callee_name, const char *url_path, + const char *method) { + char source_path[512]; + if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) { + return false; + } + cbm_store_t *source = cbm_store_open_path(source_path); + if (!source) { + return false; + } + bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK; + char caller_qn[256]; + char local_route_qn[256]; + char edge_props[512]; + snprintf(caller_qn, sizeof(caller_qn), "%s.%s", source_project, caller_name); + snprintf(local_route_qn, sizeof(local_route_qn), "__route__%s__%s", method ? method : "ANY", + url_path); + if (method) { + snprintf(edge_props, sizeof(edge_props), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"method\":\"%s\"}", callee_name, + url_path, method); + } else { + snprintf(edge_props, sizeof(edge_props), + "{\"callee\":\"%s\",\"url_path\":\"%s\"}", callee_name, url_path); + } + cbm_node_t caller = {.project = source_project, + .label = "Function", + .name = caller_name, + .qualified_name = caller_qn, + .file_path = "item.service.ts"}; + cbm_node_t local_route = {.project = source_project, + .label = "Route", + .name = url_path, + .qualified_name = local_route_qn, + .file_path = "item.service.ts"}; + int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; + int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0; + cbm_edge_t http_call = {.project = source_project, + .source_id = caller_id, + .target_id = local_route_id, + .type = "HTTP_CALLS", + .properties_json = edge_props}; + ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0; + cbm_store_close(source); + return ok; +} + +static int cross_repo_count_edges_like(const cross_repo_fixture_t *fixture, const char *project, + const char *edge_type, const char *pattern) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return -1; + } + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) { + return -1; + } + sqlite3_stmt *s = NULL; + int count = -1; + if (sqlite3_prepare_v2( + cbm_store_get_db(store), + "SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = ?2 AND properties LIKE ?3", + -1, &s, NULL) == SQLITE_OK) { + sqlite3_bind_text(s, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_text(s, 2, edge_type, -1, SQLITE_STATIC); + sqlite3_bind_text(s, 3, pattern, -1, SQLITE_STATIC); + if (sqlite3_step(s) == SQLITE_ROW) { + count = sqlite3_column_int(s, 0); + } + sqlite3_finalize(s); + } + cbm_store_close(store); + return count; +} + +/* #2768: Angular HttpClient calls must match cross-repo routes with exact HTTP verbs. + * When client makes GET requests to /items/list, /items/42, and /items/job/10, + * and the server defines overlapping routes: + * DELETE /items/{id} (seeded first in DB) + * GET /items/{id} (seeded second in DB) + * POST /items/{parentId}/{id} (seeded first in DB) + * GET /items/job/{jobId} (seeded second in DB) + * the fuzzy route matcher must enforce method compatibility, linking the GET calls + * to their respective GET endpoints and never to DELETE or POST endpoints. + * getItem and getJob are seeded WITHOUT a method property (an edge written by an + * index that did not record the verb): the matcher must infer GET from the + * callee's verb suffix, or the method-less fuzzy match lands on the DELETE / POST + * route seeded first. getItems keeps its explicit "GET". */ +TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + /* Target server with overlapping endpoints */ + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/list", + "handle_list_items") && + cross_repo_seed_server_route(&fixture, "api-2768", "DELETE", "/items/{}", + "handle_delete_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/{}", + "handle_get_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "POST", "/items/{}/{}", + "handle_post_nested_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/job/{}", + "handle_get_job") && + /* Angular client making GET requests */ + cross_repo_seed_client_call(&fixture, "angular-2768", "getItems", + "this.http.get", "/items/list", "GET") && + cross_repo_seed_client_call(&fixture, "angular-2768", "getItem", + "this.http.get", "/items/42", NULL) && + cross_repo_seed_client_call(&fixture, "angular-2768", "getJob", + "this.http.get", "/items/job/10", NULL); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed issue 2768 fixture"); + } + + const char *target = "api-2768"; + cbm_cross_repo_result_t fwd = cbm_cross_repo_match("angular-2768", &target, 1); + ASSERT_FALSE(fwd.failed); + ASSERT_EQ(fwd.http_edges, 3); + + /* Assert that client CROSS_HTTP_CALLS edges are linked to GET handlers, not DELETE or POST */ + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_delete_item%"), + 0); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_post_nested_item%"), + 0); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_list_items%"), + 1); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_get_item%"), + 1); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_get_job%"), + 1); + + /* Reverse pass */ + const char *consumer = "angular-2768"; + cbm_cross_repo_result_t rev = cbm_cross_repo_match("api-2768", &consumer, 1); + ASSERT_FALSE(rev.failed); + ASSERT_EQ(rev.http_edges, 3); + + cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); + PASS(); +} + SUITE(cross_repo) { RUN_TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459); RUN_TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459); RUN_TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459); + RUN_TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768); RUN_TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609); RUN_TEST(cross_repo_null_target_fails_without_dereference); RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens); diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index 742390ae6..faffd78e4 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -173,6 +173,13 @@ TEST(infrascan_service_pattern_match_rejects_ids_inside_words) { {"kafkaesque.story.tell", CBM_SVC_NONE}, /* "Axios" glued to a lowercase continuation */ {"geo.Axiosphere.map", CBM_SVC_NONE}, + /* Angular receiver rows end in a dot (#2768): a longer receiver that + * only starts with the same letters is not the HttpClient (NestJS + * route registration, AngularJS test double) */ + {"this.httpAdapter.get", CBM_SVC_NONE}, + {"this.httpServer.post", CBM_SVC_NONE}, + {"$httpBackend.expectGET", CBM_SVC_NONE}, + {"this.httpAdapter\n .get", CBM_SVC_NONE}, {NULL, CBM_SVC_NONE}, }; ASSERT_EQ(svc_case_mismatches(cases), 0); @@ -202,6 +209,18 @@ TEST(infrascan_service_pattern_match_keeps_real_library_qns) { {"Axios.post", CBM_SVC_HTTP}, {"AxiosInstance.get", CBM_SVC_HTTP}, {"curl_exec", CBM_SVC_HTTP}, + /* Angular HttpClient receivers (#2768); `_httpClient` is the common + * C# field spelling of the same client */ + {"this.http.get", CBM_SVC_HTTP}, + {"this.httpClient.post", CBM_SVC_HTTP}, + {"httpClient.put", CBM_SVC_HTTP}, + {"_httpClient.GetAsync", CBM_SVC_HTTP}, + {"$http.get", CBM_SVC_HTTP}, + /* a chain wrapped before the verb keeps the line break (LF / CRLF) */ + {"this.http\n .post", CBM_SVC_HTTP}, + {"this.http\r\n .delete", CBM_SVC_HTTP}, + {"this.httpClient\n .put", CBM_SVC_HTTP}, + {"$http\n .get", CBM_SVC_HTTP}, {"surf.get", CBM_SVC_HTTP}, {"hyper.Client.request", CBM_SVC_HTTP}, {"phin.promisified", CBM_SVC_HTTP}, diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 906498c82..e547fe686 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -16188,6 +16188,203 @@ TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential) { return typed_http_no_url_twin_case(0); } +/* Issue #2768: Angular HttpClient calls (this.http.get, this.httpClient.post, + * this.http.delete) emit HTTP_CALLS edges typed with the method verb, and are + * never misclassified as route registrations. */ +static int angular_http_client_typed_case(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_angular_http_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/item.service.ts", + "import { Injectable } from '@angular/core';\n" + "import { HttpClient } from '@angular/common/http';\n" + "\n" + "@Injectable({ providedIn: 'root' })\n" + "export class ItemService {\n" + " constructor(private http: HttpClient) {}\n" + "\n" + " getItems() {\n" + " return this.http.get('/items/list');\n" + " }\n" + "\n" + " getItem(id: string) {\n" + " return this.http.get(`/items/${id}`);\n" + " }\n" + "\n" + " createItem(item: any) {\n" + " return this.http.post('/items', item);\n" + " }\n" + "\n" + " deleteItem(id: string) {\n" + " return this.http.delete(`/items/${id}`);\n" + " }\n" + "\n" + " updateItem(id: string, item: any) {\n" + " return this.http\n" + " .put(`/items/${id}`, item);\n" + " }\n" + "}\n"); + + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/angular_http.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* Assert exact HTTP_CALLS with proper methods */ + ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__GET__/items/list"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__GET__/items/{}"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__POST__/items"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__DELETE__/items/{}"), 1); + /* A chain wrapped before the verb (prettier style) is the same client call */ + ASSERT_EQ(http_calls_to_route(s, project, "updateItem", "__route__PUT__/items/{}"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "updateItem", "__route__ANY__"), 0); + + /* Assert no ANY twins */ + ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__ANY__"), 0); + + /* Angular service must NOT emit HANDLES edges (not a server route registration) */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HANDLES"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_angular_http_client_typed_parallel) { + return angular_http_client_typed_case(52); +} + +TEST(pipeline_angular_http_client_typed_sequential) { + return angular_http_client_typed_case(0); +} + +/* #2768: the arg-URL heuristic (detect_url_in_args, via=arg_url) covers calls + * no service pattern knows, and it now types the edge with the verb the callee + * names instead of minting a method-less __route__ANY__ route. Two wrapper + * shapes reach it on the parallel resolver: + * - a project wrapper method whose verb is not a route-registration suffix + * (`this.api.head('/items/health')`, `.options(...)`), resolved to the + * project method, so the URL may be the first argument; + * - a route-verb wrapper (`.get`) whose URL is not route shaped for the + * registration reader: here a keyword it does not read as a path + * (`self.session.get(resource="/items/list")`). + * Parallel only: the sequential resolver has no arg-URL heuristic (#2848). */ +TEST(pipeline_arg_url_wrapper_call_typed_with_verb_issue2768) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_arg_url_verb_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "src/items.api.ts", + "export class ItemsApi {\n" + " head(path: string): Promise {\n" + " return fetch(path, { method: 'HEAD' });\n" + " }\n" + " options(path: string): Promise {\n" + " return fetch(path, { method: 'OPTIONS' });\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/item.service.ts", + "import { ItemsApi } from './items.api';\n" + "export class ItemService {\n" + " constructor(private readonly api: ItemsApi) {}\n" + " pingItems() {\n" + " return this.api.head('/items/health');\n" + " }\n" + " probeItems() {\n" + " return this.api.options('/items/list');\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/reader.py", + "class ItemsSession:\n" + " def get(self, *args, **kwargs):\n" + " return args\n" + "\n" + "class Reader:\n" + " def __init__(self):\n" + " self.session = ItemsSession()\n" + "\n" + " def read_items(self):\n" + " return self.session.get(resource=\"/items/list\")\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/arg_url_verb.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + static const struct { + const char *caller; + const char *typed_route; + } wrapped[] = {{"pingItems", "__route__HEAD__/items/health"}, + {"probeItems", "__route__OPTIONS__/items/list"}, + {"read_items", "__route__GET__/items/list"}}; + for (size_t i = 0; i < sizeof(wrapped) / sizeof(wrapped[0]); i++) { + /* The edge carries the callee's verb ... */ + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, wrapped[i].typed_route), 1); + /* ... and is the call site's only HTTP_CALLS edge: no ANY twin. */ + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, "__route__"), 1); + } + /* Not a route registration */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HANDLES"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + /* #706 / #1147 end to end: a URL composed from module constants reaches the * HTTP classifier, so the client call forms an HTTP_CALLS edge. Python * `API_URL = API_BASE_URL + "/orders/create"` (env-derived base, literal path @@ -30688,6 +30885,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential); + RUN_TEST(pipeline_angular_http_client_typed_parallel); + RUN_TEST(pipeline_angular_http_client_typed_sequential); + RUN_TEST(pipeline_arg_url_wrapper_call_typed_with_verb_issue2768); RUN_TEST(pipeline_url_constant_folding_http_calls_issue706_1147); RUN_TEST(pipeline_folded_topics_preserve_identity_sequential_parallel); RUN_TEST(pipeline_relative_folded_wrapper_url_candidates);