Skip to content

Commit d40fc5b

Browse files
committed
stabalization
1 parent 49dbbdc commit d40fc5b

3 files changed

Lines changed: 175 additions & 1 deletion

File tree

lib/asrfacet_rb/ui/cli.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def delete(target)
124124
desc "export TARGET", "Export a workspace as json or csv"
125125
option :format, type: :string, default: "json", enum: %w[json csv], desc: "Export format"
126126
def export(target)
127-
path = workspace_manager.export(target, format: options[:format])
127+
path = workspace_manager.export(target, format: export_format)
128128
puts path
129129
rescue ASRFacet::Error => e
130130
ASRFacet::Core::ThreadSafe.print_error(e.message)
@@ -134,6 +134,17 @@ def export(target)
134134
def workspace_manager
135135
ASRFacet::Intelligence::SessionManager.new
136136
end
137+
138+
def export_format
139+
subcommand_format = options[:format].to_s
140+
inherited = parent_options.to_h
141+
inherited_format = (inherited[:format] || inherited["format"]).to_s
142+
return inherited_format if subcommand_format == "json" && %w[json csv].include?(inherited_format)
143+
144+
subcommand_format
145+
rescue StandardError
146+
options[:format].to_s
147+
end
137148
end
138149
end
139150

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
# SPDX-License-Identifier: Proprietary
3+
#
4+
# ASRFacet-Rb: Attack Surface Reconnaissance Framework
5+
# Copyright (c) 2026 voltsparx
6+
#
7+
# Author: voltsparx
8+
# Repository: https://github.com/voltsparx/ASRFacet-Rb
9+
# Contact: voltsparx@gmail.com
10+
# License: See LICENSE file in the project root
11+
#
12+
# This file is part of ASRFacet-Rb and is subject to the terms
13+
# and conditions defined in the LICENSE file.
14+
15+
require "spec_helper"
16+
require "tmpdir"
17+
18+
RSpec.describe ASRFacet::Output::OutputRouter do
19+
subject(:router) { described_class.new(build_output_store, output_fixture_data[:target], asset_graph: build_output_graph) }
20+
21+
it "routes PDF and DOCX rendering through the JavaScript bridges when Node.js is available" do
22+
pdf_renderer = instance_double(ASRFacet::Output::Js::JsPdfBridge, render: true)
23+
docx_renderer = instance_double(ASRFacet::Output::Js::JsDocxBridge, render: true)
24+
25+
allow(ASRFacet::Output::RuntimeDetector).to receive(:node_available?).and_return(true)
26+
allow(ASRFacet::Output::Js::JsPdfBridge).to receive(:new).and_return(pdf_renderer)
27+
allow(ASRFacet::Output::Js::JsDocxBridge).to receive(:new).and_return(docx_renderer)
28+
29+
router.render("pdf", File.join(Dir.tmpdir, "fixture.pdf"))
30+
router.render("docx", File.join(Dir.tmpdir, "fixture.docx"))
31+
32+
expect(ASRFacet::Output::Js::JsPdfBridge).to have_received(:new)
33+
expect(ASRFacet::Output::Js::JsDocxBridge).to have_received(:new)
34+
expect(pdf_renderer).to have_received(:render)
35+
expect(docx_renderer).to have_received(:render)
36+
end
37+
38+
it "falls back to the Ruby PDF and DOCX renderers when Node.js is unavailable" do
39+
pdf_renderer = instance_double(ASRFacet::Output::Ruby::PdfRenderer, render: true)
40+
docx_renderer = instance_double(ASRFacet::Output::Ruby::DocxRenderer, render: true)
41+
42+
allow(ASRFacet::Output::RuntimeDetector).to receive(:node_available?).and_return(false)
43+
allow(ASRFacet::Output::Ruby::PdfRenderer).to receive(:new).and_return(pdf_renderer)
44+
allow(ASRFacet::Output::Ruby::DocxRenderer).to receive(:new).and_return(docx_renderer)
45+
46+
router.render("pdf", File.join(Dir.tmpdir, "fixture.pdf"))
47+
router.render("docx", File.join(Dir.tmpdir, "fixture.docx"))
48+
49+
expect(ASRFacet::Output::Ruby::PdfRenderer).to have_received(:new)
50+
expect(ASRFacet::Output::Ruby::DocxRenderer).to have_received(:new)
51+
expect(pdf_renderer).to have_received(:render)
52+
expect(docx_renderer).to have_received(:render)
53+
end
54+
55+
it "continues rendering later formats during render_all when one renderer raises an ASRFacet error" do
56+
formats = []
57+
58+
allow(router).to receive(:render).and_wrap_original do |_method, format, path|
59+
formats << [format, path]
60+
raise ASRFacet::Error, "PDF unavailable" if format == "pdf"
61+
62+
FileUtils.mkdir_p(File.dirname(path))
63+
File.write(path, format)
64+
end
65+
66+
Dir.mktmpdir do |dir|
67+
expect { router.render_all(dir) }.not_to raise_error
68+
end
69+
70+
expect(formats.map(&:first)).to eq(%w[txt html json csv pdf docx])
71+
end
72+
end

spec/ui/workspace_cli_spec.rb

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# SPDX-License-Identifier: Proprietary
2+
#
3+
# ASRFacet-Rb: Attack Surface Reconnaissance Framework
4+
# Copyright (c) 2026 voltsparx
5+
#
6+
# Author: voltsparx
7+
# Repository: https://github.com/voltsparx/ASRFacet-Rb
8+
# Contact: voltsparx@gmail.com
9+
# License: See LICENSE file in the project root
10+
#
11+
# This file is part of ASRFacet-Rb and is subject to the terms
12+
# and conditions defined in the LICENSE file.
13+
14+
require "spec_helper"
15+
require "stringio"
16+
require "tmpdir"
17+
18+
RSpec.describe ASRFacet::UI::CLI do
19+
def capture_stdout
20+
original = $stdout
21+
buffer = StringIO.new
22+
$stdout = buffer
23+
yield
24+
buffer.string
25+
ensure
26+
$stdout = original
27+
end
28+
29+
def wire_workspace_root(dir)
30+
manager = ASRFacet::Intelligence::SessionManager.new(root: dir)
31+
allow(ASRFacet::Intelligence::SessionManager).to receive(:new).and_return(manager)
32+
allow(ASRFacet::Intelligence::AssetGraph).to receive(:new).and_wrap_original do |method, target, **kwargs|
33+
method.call(target, **kwargs.merge(root: dir))
34+
end
35+
manager
36+
end
37+
38+
it "lists, shows, exports, and deletes persisted workspaces through the CLI" do
39+
Dir.mktmpdir do |dir|
40+
manager = wire_workspace_root(dir)
41+
manager.create("lab.example.com")
42+
build_intelligence_graph(root: dir)
43+
44+
list_output = capture_stdout { described_class.start(["workspace", "list"]) }
45+
show_output = capture_stdout { described_class.start(["workspace", "show", "lab.example.com"]) }
46+
export_output = capture_stdout { described_class.start(["workspace", "export", "lab.example.com", "--format", "csv"]) }
47+
48+
expect(list_output).to include("lab.example.com")
49+
expect(JSON.parse(show_output)).to include("target" => "lab.example.com")
50+
51+
export_path = export_output.strip
52+
expect(export_path).to end_with(".csv")
53+
expect(File).to exist(export_path)
54+
55+
delete_output = capture_stdout { described_class.start(["workspace", "delete", "lab.example.com"]) }
56+
expect(delete_output).to include("Workspace deleted")
57+
expect(capture_stdout { described_class.start(["workspace", "show", "lab.example.com"]) }).to include("Workspace not found")
58+
end
59+
end
60+
61+
it "tracks diffs, renders graph exports, and lists stored subdomains from a workspace" do
62+
Dir.mktmpdir do |dir|
63+
manager = wire_workspace_root(dir)
64+
manager.create("lab.example.com")
65+
build_intelligence_graph("intelligence_graph_previous", root: dir)
66+
manager.export("lab.example.com", format: "json")
67+
build_intelligence_graph(root: dir)
68+
69+
track_output = capture_stdout { described_class.start(["track", "lab.example.com", "--since", "2099-01-01"]) }
70+
viz_output = capture_stdout { described_class.start(["viz", "lab.example.com", "--format", "mermaid"]) }
71+
subs_output = capture_stdout { described_class.start(["subs", "lab.example.com"]) }
72+
73+
diff = JSON.parse(track_output)
74+
expect(diff.dig("summary", "added")).to be > 0
75+
expect(viz_output).to include("graph LR")
76+
expect(subs_output.lines.map(&:strip)).to eq(%w[api.lab.example.com app.lab.example.com])
77+
end
78+
end
79+
80+
it "reports invalid track dates as parse errors without crashing" do
81+
Dir.mktmpdir do |dir|
82+
manager = wire_workspace_root(dir)
83+
manager.create("lab.example.com")
84+
build_intelligence_graph(root: dir)
85+
86+
output = capture_stdout { described_class.start(["track", "lab.example.com", "--since", "not-a-date"]) }
87+
88+
expect(output).to include("Invalid --since value")
89+
end
90+
end
91+
end

0 commit comments

Comments
 (0)