diff --git a/blastradius/server/server.py b/blastradius/server/server.py index c369c7a..cdbec97 100644 --- a/blastradius/server/server.py +++ b/blastradius/server/server.py @@ -21,6 +21,7 @@ from blastradius.graph import Node, Edge, Counter, Graph app = Flask(__name__) +MAX_DOT_BYTES = 2 * 1024 * 1024 @app.route('/') @@ -60,8 +61,11 @@ def index(): @app.route('/upload', methods=['POST']) def upload(): if 'file' not in request.files: - flash('No file submitted') - return redirect("/") + return graph_error( + 400, + 'missing_dot', + 'Submit a Graphviz DOT file in the "file" field.', + ) file = request.files['file'] filecontent = file.read().decode("utf-8") @@ -69,8 +73,14 @@ def upload(): module_depth = request.args.get('module_depth', default=None, type=int) refocus = request.args.get('refocus', default=None, type=str) - dot = initalizeDotGraph(content=filecontent, - module_depth=module_depth, refocus=refocus) + try: + dot, _warnings = render_dot_graph( + content=filecontent, + module_depth=module_depth, + refocus=refocus, + ) + except ValueError as error: + return graph_error(422, 'invalid_graph', str(error)) resp = {"SVG": dot.svg(), "JSON": dot.json()} return jsonify(resp) @@ -79,20 +89,107 @@ def upload(): @app.route('/input', methods=['POST']) def input(): if 'input' not in request.form: - flash('No input submitted') - return redirect("/") + return graph_error( + 400, + 'missing_dot', + 'Submit Graphviz DOT text in the "input" field.', + ) dot_input = request.form['input'] module_depth = request.args.get('module_depth', default=None, type=int) refocus = request.args.get('refocus', default=None, type=str) - dot = initalizeDotGraph(content=dot_input, - module_depth=module_depth, refocus=refocus) + try: + dot, _warnings = render_dot_graph( + content=dot_input, + module_depth=module_depth, + refocus=refocus, + ) + except ValueError as error: + return graph_error(422, 'invalid_graph', str(error)) resp = {"SVG": dot.svg(), "JSON": dot.json()} return jsonify(resp) +@app.route('/api/graphs/render', methods=['POST']) +def render_graph(): + if request.content_length and request.content_length > MAX_DOT_BYTES: + return graph_error( + 413, + 'payload_too_large', + 'The DOT document exceeds the 2 MiB request limit.', + ) + + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return graph_error( + 400, + 'invalid_request', + 'Send a JSON object containing a "dot" string.', + ) + + dot_input = payload.get('dot') + if not isinstance(dot_input, str) or not dot_input.strip(): + return graph_error( + 400, + 'missing_dot', + 'The "dot" field must be a non-empty string.', + ) + if len(dot_input.encode('utf-8')) > MAX_DOT_BYTES: + return graph_error( + 413, + 'payload_too_large', + 'The DOT document exceeds the 2 MiB request limit.', + ) + + module_depth = payload.get('module_depth') + if ( + module_depth is not None + and ( + isinstance(module_depth, bool) + or not isinstance(module_depth, int) + or module_depth < 0 + ) + ): + return graph_error( + 400, + 'invalid_module_depth', + '"module_depth" must be a non-negative integer.', + ) + + refocus = payload.get('refocus') + if refocus is not None and not isinstance(refocus, str): + return graph_error( + 400, + 'invalid_refocus', + '"refocus" must be a node label string.', + ) + + Graph.reset_counters() + try: + dot, warnings = render_dot_graph( + content=dot_input, + module_depth=module_depth, + refocus=refocus, + ) + svg = dot.svg() + except (OSError, RuntimeError, ValueError) as error: + return graph_error(422, 'invalid_graph', str(error)) + + return jsonify( + { + 'svg': svg, + 'graph': json.loads(dot.json()), + 'warnings': warnings, + } + ) + + +def graph_error(status, code, message): + return jsonify({'error': {'code': code, 'message': message}}), status + + # @app.route('/convert/', methods=['POST']) # def convert(filetype): # removeExistingFiles() @@ -204,23 +301,44 @@ def run_tf_graph(): return completed.stdout.decode('utf-8') -def initalizeDotGraph(content, module_depth, refocus): +def render_dot_graph(content, module_depth=None, refocus=None): dot = DotGraph('', file_contents=content) - - # module_depth = request.args.get('module_depth', default=None, type=int) - # refocus = request.args.get('refocus', default=None, type=str) + if not dot.nodes and not dot.edges: + raise ValueError( + 'No Graphviz node or edge declarations were found in the DOT document.' + ) if module_depth is not None and module_depth >= 0: dot.set_module_depth(module_depth) - tf = Terraform(os.getcwd()) - for node in dot.nodes: - node.definition = tf.get_def(node) + warnings = [] + try: + tf = Terraform(os.getcwd()) + for node in dot.nodes: + node.definition = tf.get_def(node) + except (OSError, RuntimeError) as error: + warnings.append( + 'Terraform definitions were unavailable: {}'.format(error) + ) if refocus is not None: node = dot.get_node_by_name(refocus) if node: dot.center(node) + else: + warnings.append( + 'The requested refocus node was not found; the full graph was rendered.' + ) + + return dot, warnings + + +def initalizeDotGraph(content, module_depth=None, refocus=None): + dot, _warnings = render_dot_graph( + content=content, + module_depth=module_depth, + refocus=refocus, + ) return dot diff --git a/blastradius/server/static/js/blast-radius.js b/blastradius/server/static/js/blast-radius.js index 9229fd3..440d102 100644 --- a/blastradius/server/static/js/blast-radius.js +++ b/blastradius/server/static/js/blast-radius.js @@ -28,35 +28,81 @@ build_uri = function (url, params) { return url.slice(0, -1); } -let uploadRequest = (url, formData, selector) => { - - fetch(url, { - method: "POST", - body: formData - }) - .then(response => (response.json())) - .then(async function (resjson) { - let br_state = { - selector: {} - } +let showGraphMessage = (selector, message, level = "danger") => { + let container = document.querySelector(selector); + if (!container) { + return; + } + + let existing = container.querySelector(".blast-radius-message"); + if (existing) { + existing.remove(); + } + + let notice = document.createElement("div"); + notice.className = `alert alert-${level} blast-radius-message`; + notice.setAttribute("role", "alert"); + notice.textContent = message; + container.prepend(notice); +} - xml = $.parseXML(resjson.SVG); - json = JSON.parse(resjson.JSON); - await blastradius(selector, '/graph.svg', '/graph.json', br_state, xml, json) +let clearGraphMessage = (selector) => { + let existing = document.querySelector(`${selector} .blast-radius-message`); + if (existing) { + existing.remove(); + } +} + +let renderDotSource = async (dot, selector) => { + try { + let response = await fetch("/api/graphs/render", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({dot: dot}) }); + let payload = await response.json(); + + if (!response.ok) { + let message = payload.error && payload.error.message + ? payload.error.message + : "The graph could not be rendered."; + throw new Error(message); + } + + let xml = new DOMParser().parseFromString(payload.svg, "image/svg+xml"); + if (xml.querySelector("parsererror")) { + throw new Error("Graphviz returned an invalid SVG document."); + } + + clearGraphMessage(selector); + let br_state = {}; + await blastradius( + selector, + "/graph.svg", + "/graph.json", + br_state, + xml, + payload.graph + ); + + if (payload.warnings.length > 0) { + showGraphMessage(selector, payload.warnings.join(" "), "warning"); + } + } catch (error) { + showGraphMessage(selector, error.message); + } } -let uploadFile = function (file, tabNumber) { - let fileType = file.type; +let uploadFile = async function (file, tabNumber) { let selector = "#graph-" + tabNumber; - let validExtensions = ["text/plain"]; - if (validExtensions.includes(fileType)) { - let formData = new FormData(); - formData.set('file', file); - uploadRequest('/upload', formData, selector) - } else { - alert("This is not a valid File!"); + if (!file) { + showGraphMessage(selector, "Choose a Graphviz DOT file to upload."); + return; } + + await renderDotSource(await file.text(), selector); } let inputGraph = async () => { @@ -71,10 +117,7 @@ let inputGraph = async () => { await insertTabContent(prevNumber) await createTab(`input-graph${curNumber}`, curNumber); - let formData = new FormData(); - formData.set('input', graphinput); - - await uploadRequest('/input', formData, selector); + await renderDotSource(graphinput, selector); $('#tablink-' + curNumber).click(); } else { @@ -82,7 +125,13 @@ let inputGraph = async () => { } } else { - alert("Invalid graph input or empty input!") + let lastTabContent = $("div.tabcontent").last()[0]; + if (lastTabContent) { + showGraphMessage( + `#${lastTabContent.id} .graph`, + "Paste a non-empty Graphviz DOT document." + ); + } } } /** @@ -328,6 +377,10 @@ blastradius = function (selector, svg_url, json_url, br_state = {}, uploadXML = if (uploadXML != null) { xml = uploadXML; } + if (error && uploadXML == null) { + showGraphMessage(selector, "The graph SVG could not be loaded."); + return; + } container.node() .appendChild(document.importNode(xml.documentElement, true)); @@ -358,9 +411,10 @@ blastradius = function (selector, svg_url, json_url, br_state = {}, uploadXML = data = uploadJSON } - if (error) { + if (error && uploadJSON === null) { console.error("No Terraform files were found, so JSON details will not be available. The graph is still usable but without all features enabled such as filtering content"); - // alert("No Terraform files were found, so JSON details will not be available. The graph is still usable but without all features enabled such as filtering content"); + showGraphMessage(selector, "The graph details could not be loaded."); + return; } // if (!error) { @@ -729,29 +783,25 @@ blastradius = function (selector, svg_url, json_url, br_state = {}, uploadXML = if (nodes) { var root = nodes['[root] root']; - if (root == undefined) { - if (confirm("Invalid graph detected! Would you like to reload the page?") === true) { - window.location.reload() - } - } - - svg.selectAll('g.node#' + root.svg_id) - .data(svg_nodes, function (d) { - return (d && d.svg_id) || d3.select(this).attr("id"); - }) - .on('mouseover', node_mouseover) - .on('mouseout', node_mouseout) - .on('mousedown', node_mousedown) - .select('polygon') - .attr('fill', function (d) { - return color(d.group); - }) - .style('fill', (function (d) { - if (d) + if (root) { + svg.selectAll('g.node#' + root.svg_id) + .data(svg_nodes, function (d) { + return (d && d.svg_id) || d3.select(this).attr("id"); + }) + .on('mouseover', node_mouseover) + .on('mouseout', node_mouseout) + .on('mousedown', node_mousedown) + .select('polygon') + .attr('fill', function (d) { return color(d.group); - else - return '#000'; - })); + }) + .style('fill', (function (d) { + if (d) + return color(d.group); + else + return '#000'; + })); + } } else { console.warn("Mouse events and coloration may not work due to nodes being undefined.") } @@ -968,4 +1018,4 @@ blastradius = function (selector, svg_url, json_url, br_state = {}, uploadXML = }); // end json success callback }); // end svg success callback -} // end blastradius() \ No newline at end of file +} // end blastradius() diff --git a/tests/test_frontend_assets.py b/tests/test_frontend_assets.py new file mode 100644 index 0000000..b15d527 --- /dev/null +++ b/tests/test_frontend_assets.py @@ -0,0 +1,19 @@ +import unittest +from pathlib import Path + +from blastradius.server import server + + +class FrontendAssetTests(unittest.TestCase): + def test_dot_input_uses_api_and_reports_inline_errors(self): + javascript = ( + Path(server.__file__).parent / "static" / "js" / "blast-radius.js" + ).read_text(encoding="utf-8") + + self.assertIn('fetch("/api/graphs/render"', javascript) + self.assertIn("showGraphMessage", javascript) + self.assertNotIn('confirm("Invalid graph detected!', javascript) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_render_api.py b/tests/test_render_api.py new file mode 100644 index 0000000..d3939a6 --- /dev/null +++ b/tests/test_render_api.py @@ -0,0 +1,118 @@ +import io +import unittest +from unittest.mock import patch + +from blastradius.server import server + +ROOTLESS_DOT = """\ +digraph { + "[root] aws_vpc.main" [label = "aws_vpc.main"] + "[root] aws_instance.web" [label = "aws_instance.web"] + "[root] aws_instance.web" -> "[root] aws_vpc.main" +} +""" + + +class RenderApiTests(unittest.TestCase): + def setUp(self): + self.client = server.app.test_client() + + @patch.object(server.DotGraph, "svg", return_value="") + def test_renders_modern_terraform_graph_without_synthetic_root(self, _svg): + response = self.client.post( + "/api/graphs/render", + json={"dot": ROOTLESS_DOT}, + ) + + self.assertEqual(response.status_code, 200) + payload = response.get_json() + self.assertEqual(payload["svg"], "") + self.assertEqual(payload["warnings"], []) + self.assertEqual(len(payload["graph"]["nodes"]), 2) + self.assertEqual(len(payload["graph"]["edges"]), 1) + self.assertNotIn( + "[root] root", + {node["label"] for node in payload["graph"]["nodes"]}, + ) + + @patch.object(server.DotGraph, "svg", return_value="") + def test_legacy_input_and_upload_routes_remain_compatible(self, _svg): + input_response = self.client.post("/input", data={"input": ROOTLESS_DOT}) + upload_response = self.client.post( + "/upload", + data={"file": (io.BytesIO(ROOTLESS_DOT.encode()), "graph.dot")}, + content_type="multipart/form-data", + ) + + for response in (input_response, upload_response): + with self.subTest(path=response.request.path): + self.assertEqual(response.status_code, 200) + payload = response.get_json() + self.assertIn("SVG", payload) + self.assertIn("JSON", payload) + + def test_rejects_non_json_and_missing_dot_requests(self): + not_json = self.client.post( + "/api/graphs/render", + data="not json", + content_type="text/plain", + ) + missing_dot = self.client.post("/api/graphs/render", json={}) + + self.assertEqual(not_json.status_code, 400) + self.assertEqual( + not_json.get_json()["error"]["code"], + "invalid_request", + ) + self.assertEqual(missing_dot.status_code, 400) + self.assertEqual( + missing_dot.get_json()["error"]["code"], + "missing_dot", + ) + + def test_rejects_unparseable_and_invalid_options(self): + unparseable = self.client.post( + "/api/graphs/render", + json={"dot": "digraph { this is not a Terraform graph }"}, + ) + invalid_depth = self.client.post( + "/api/graphs/render", + json={"dot": ROOTLESS_DOT, "module_depth": -1}, + ) + + self.assertEqual(unparseable.status_code, 422) + self.assertEqual( + unparseable.get_json()["error"]["code"], + "invalid_graph", + ) + self.assertEqual(invalid_depth.status_code, 400) + self.assertEqual( + invalid_depth.get_json()["error"]["code"], + "invalid_module_depth", + ) + + def test_rejects_oversized_dot_documents(self): + with patch.object(server, "MAX_DOT_BYTES", 32): + response = self.client.post( + "/api/graphs/render", + json={"dot": ROOTLESS_DOT}, + ) + + self.assertEqual(response.status_code, 413) + self.assertEqual( + response.get_json()["error"]["code"], + "payload_too_large", + ) + + @patch.object(server.DotGraph, "svg", return_value="") + def test_missing_refocus_node_returns_a_warning(self, _svg): + response = self.client.post( + "/api/graphs/render", + json={"dot": ROOTLESS_DOT, "refocus": "missing.resource"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("not found", response.get_json()["warnings"][0]) + +if __name__ == "__main__": + unittest.main()