reeve 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +176 -1
  3. data/README.md +202 -12
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +41 -3
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -0
  6. data/lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt +51 -0
  7. data/lib/generators/reeve/upgrade/upgrade_generator.rb +40 -2
  8. data/lib/reeve/audit/chain/canonical.rb +129 -0
  9. data/lib/reeve/audit/chain/head.rb +94 -0
  10. data/lib/reeve/audit/chain/keyring.rb +53 -0
  11. data/lib/reeve/audit/chain/normalizer.rb +88 -0
  12. data/lib/reeve/audit/chain/report.rb +134 -0
  13. data/lib/reeve/audit/chain/verification.rb +110 -0
  14. data/lib/reeve/audit/chain/verifier.rb +160 -0
  15. data/lib/reeve/audit/chain/walk.rb +123 -0
  16. data/lib/reeve/audit/chain.rb +168 -0
  17. data/lib/reeve/audit/chain_record.rb +61 -0
  18. data/lib/reeve/audit/isolated_recorder.rb +5 -0
  19. data/lib/reeve/audit/recorder.rb +32 -19
  20. data/lib/reeve/audit.rb +24 -4
  21. data/lib/reeve/authorization.rb +1 -11
  22. data/lib/reeve/configuration.rb +90 -2
  23. data/lib/reeve/decision.rb +6 -1
  24. data/lib/reeve/errors.rb +28 -0
  25. data/lib/reeve/inventory/report.rb +146 -0
  26. data/lib/reeve/inventory.rb +212 -0
  27. data/lib/reeve/invocation.rb +3 -1
  28. data/lib/reeve/ledger_key.rb +47 -0
  29. data/lib/reeve/railtie.rb +15 -0
  30. data/lib/reeve/tasks.rb +62 -0
  31. data/lib/reeve/testing/assertions.rb +2 -2
  32. data/lib/reeve/testing/checks/contract_version.rb +2 -1
  33. data/lib/reeve/testing/checks/endpoint_coverage.rb +133 -0
  34. data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
  35. data/lib/reeve/testing/checks.rb +43 -7
  36. data/lib/reeve/testing/compliance_assertions.rb +4 -1
  37. data/lib/reeve/testing/compliance_suite.rb +4 -1
  38. data/lib/reeve/testing/ledger.rb +11 -1
  39. data/lib/reeve/testing/matchers/pass_reeve_check.rb +1 -1
  40. data/lib/reeve/testing/report.rb +30 -6
  41. data/lib/reeve/testing/result.rb +28 -8
  42. data/lib/reeve/testing.rb +20 -4
  43. data/lib/reeve/version.rb +1 -1
  44. data/lib/reeve.rb +3 -0
  45. metadata +20 -2
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Reeve
6
+ # `bin/rails reeve:verify_ledger` (specs/002 FR-017, contracts/ledger-chain.md "Rake").
7
+ #
8
+ # A Ruby file rather than a .rake one on purpose: reeve.gemspec packages lib/**/*.rb, and a
9
+ # .rake file would pass every spec in the working tree and be missing from the gem users
10
+ # install. Registered by Reeve::Railtie; callable without Rake for anything else that
11
+ # wants the same answer — a deploy gate, a scheduled job, a console.
12
+ #
13
+ # status = Reeve::Tasks.verify_ledger(env: { "LIMIT" => "0" })
14
+ module Tasks
15
+ EXIT = { intact: 0, broken: 1, unverifiable: 2, no_chain: 2 }.freeze
16
+ FORMATS = %w[text json].freeze
17
+
18
+ module_function
19
+
20
+ # Verifies the ledger, writes the report to `out`, and returns the exit status:
21
+ # 0 intact, 1 broken, 2 could not verify (unverifiable, or no chain).
22
+ #
23
+ # LIMIT caps the breaks listed in the text report (default 100; the totals are never
24
+ # capped). FORMAT=json writes the whole result, every break, and ignores LIMIT.
25
+ def verify_ledger(env: ENV, out: $stdout)
26
+ format = env.fetch("FORMAT", "text")
27
+ unless FORMATS.include?(format)
28
+ raise ArgumentError, "FORMAT must be one of #{FORMATS.join(', ')}, got #{format.inspect}"
29
+ end
30
+
31
+ limit = limit_from(env)
32
+ require "reeve/audit"
33
+ result = Reeve::Audit::Chain.verify
34
+ out.puts(format == "json" ? JSON.generate(result.as_json) : result.to_s(limit: limit))
35
+ EXIT.fetch(result.status)
36
+ end
37
+
38
+ def limit_from(env)
39
+ raw = env.fetch("LIMIT", Reeve::Audit::Chain::Verification::DEFAULT_LIMIT.to_s)
40
+ Integer(raw, 10).tap { |limit| raise ArgumentError if limit.negative? }
41
+ rescue ArgumentError, TypeError
42
+ raise ArgumentError, "LIMIT must be a whole number of breaks to list, got #{raw.inspect}"
43
+ end
44
+
45
+ # The Rake task, defined on the current Rake application. Depends on :environment, so
46
+ # the host's initializer — and with it the ledger key — is loaded first.
47
+ #
48
+ # The DSL is borrowed by a throwaway object, so Reeve::Tasks itself never gains `task`,
49
+ # `sh` and the rest of Rake's methods.
50
+ def define
51
+ Object.new.extend(Rake::DSL).instance_exec do
52
+ namespace :reeve do
53
+ desc "Verify the audit ledger's chain " \
54
+ "(exit 0 intact, 1 broken, 2 could not verify; LIMIT=, FORMAT=json)"
55
+ task verify_ledger: :environment do
56
+ exit Reeve::Tasks.verify_ledger
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -56,14 +56,14 @@ module Reeve
56
56
  )
57
57
  end
58
58
 
59
- # SC-009's escape hatch: any of the seven, straight from Minitest.
59
+ # SC-009's escape hatch: any check, straight from Minitest.
60
60
  def assert_reeve_check(check)
61
61
  result = check.call
62
62
  assert result.passed?, result.message
63
63
  result
64
64
  end
65
65
 
66
- # A whole Report at once, for a host that would rather have one test than seven.
66
+ # A whole Report at once, for a host that would rather have one test than one per check.
67
67
  def assert_reeve_compliance(principals: nil, **options)
68
68
  report = Checks.run_all(
69
69
  principals: principals || Testing.compliance_principals, **options
@@ -46,9 +46,10 @@ module Reeve
46
46
  invocation_id occurred_at agent_id agent_name principal_type principal_id
47
47
  tool_name arguments outcome rule detail record_type record_ids record_count
48
48
  truncated derived guard duration_ms metadata contract_version
49
+ chain_sequence chain_key_id chain_digest
49
50
  ].freeze
50
51
 
51
- EXPECTED_VERSION = 2
52
+ EXPECTED_VERSION = 3
52
53
 
53
54
  def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
54
55
  super(tool: tool, ledger: ledger)
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # Whether the run covered the endpoint, or only the tools reeve was told about.
7
+ #
8
+ # Every other check walks a population reeve assembled itself: the classes that
9
+ # included `Reeve::Guard`. A server that dispatches some tools without ever handing
10
+ # them to reeve leaves them out of that population, and the run goes green on the
11
+ # subset it could see. This check compares the run against the host's own list of
12
+ # registered names (`config.inventory`), so a green run can only mean the endpoint.
13
+ #
14
+ # Four answers, and only one of them is a pass:
15
+ #
16
+ # * **no inventory declared** — skipped. Reeve cannot know what the server registers
17
+ # unless told, and a pass here would be the green that means "did not look".
18
+ # * **a subset** — skipped, when the host narrowed the run with `compliance_tools` or
19
+ # `tools:`. That is the incremental-adoption path; the run says what it certified
20
+ # and that it is not the endpoint, without turning the build red mid-retrofit.
21
+ # * **incomplete** — failed: a registered tool bypasses reeve or reaches it unguarded,
22
+ # and the run claimed to cover everything.
23
+ # * **complete** — passed.
24
+ #
25
+ # Reeve::Checks::EndpointCoverage.new.call
26
+ # Reeve::Checks::EndpointCoverage.new(inventory: McpInventory).call
27
+ class EndpointCoverage < Base
28
+ def initialize(inventory: :configured, tools: nil, ledger: nil)
29
+ super(ledger: ledger)
30
+ @inventory = inventory == :configured ? Reeve.config.inventory : inventory
31
+ @tools = tools
32
+ end
33
+
34
+ def call
35
+ return unverified if inventory.nil?
36
+
37
+ report = inventory.report
38
+ if narrowed?
39
+ subset(report)
40
+ elsif report.complete?
41
+ complete(report)
42
+ else
43
+ incomplete(report)
44
+ end
45
+ rescue StandardError => e
46
+ failed("the endpoint inventory could not be read, so its coverage is unknown: " \
47
+ "#{e.class}: #{e.message}", coverage: :unknown)
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :inventory
53
+
54
+ def narrowed?
55
+ !@tools.nil? || Testing.compliance_tools_narrowed?
56
+ end
57
+
58
+ def certified
59
+ @certified ||= @tools || Testing.compliance_tools
60
+ end
61
+
62
+ def unverified
63
+ skipped(
64
+ "no endpoint inventory is declared, so this run certifies the " \
65
+ "#{pluralize(certified.size, 'tool')} reeve knows about and cannot say whether " \
66
+ "the server dispatches others outside it — set config.inventory to a " \
67
+ "Reeve::Inventory to verify the endpoint",
68
+ coverage: :unverified
69
+ )
70
+ end
71
+
72
+ def complete(report)
73
+ passed(
74
+ "every one of the #{pluralize(report.registered.size, 'tool')} the server " \
75
+ "registers is protected and guarded (#{report.guarded.size}) or exempted " \
76
+ "(#{report.exempted.size})",
77
+ coverage: :complete
78
+ )
79
+ end
80
+
81
+ def incomplete(report)
82
+ failed(
83
+ "expected every tool the server registers to be protected by reeve or " \
84
+ "exempted, but #{gaps(report)}\n\n#{report}",
85
+ coverage: :incomplete
86
+ )
87
+ end
88
+
89
+ # Complete coverage with a narrowed run is still a subset: the inventory is sound,
90
+ # but this run did not certify all of it.
91
+ def subset(report)
92
+ routed = report.routed_tools
93
+ missing = routed - certified
94
+ lines = [
95
+ "this run certifies a subset, not the endpoint: " \
96
+ "#{(routed - missing).size} of the #{pluralize(routed.size, 'tool')} routed " \
97
+ "through reeve (compliance_tools or tools: narrowed it)"
98
+ ]
99
+ lines << "not certified: #{missing.map { |tool| tool.name || tool.inspect }.join(', ')}" \
100
+ unless missing.empty?
101
+ lines << "" << report.to_s unless report.complete?
102
+
103
+ skipped(lines.join("\n"), coverage: :subset)
104
+ end
105
+
106
+ def gaps(report)
107
+ [
108
+ gap(report.bypassing, "bypasses it", "bypass it"),
109
+ gap(report.unguarded, "is routed through it unguarded",
110
+ "are routed through it unguarded"),
111
+ gap(report.unregistered_entries, "declared in the inventory is not registered",
112
+ "declared in the inventory are not registered")
113
+ ].compact.join(", ")
114
+ end
115
+
116
+ def gap(entries, one, many)
117
+ return nil if entries.empty?
118
+
119
+ "#{entries.size} #{entries.size == 1 ? one : many}"
120
+ end
121
+
122
+ # Only this check can come back with nothing established either way.
123
+ def skipped(message, details = {})
124
+ Result.skipped(check: check_name, message: message, details: details)
125
+ end
126
+
127
+ def base_details
128
+ {}
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # Whether the ledger's chain holds (specs/002 FR-018).
7
+ #
8
+ # It writes entries of its own first — the first compliance tool, once per fixture
9
+ # principal — because a suite with transactional fixtures starts every example with
10
+ # an empty ledger, and a check that only looked would find nothing and say nothing.
11
+ # Then it verifies the whole ledger the run can see, not only its own rows: a break an
12
+ # earlier run left behind is reported, not hidden.
13
+ #
14
+ # * **intact** — passed; the message is the verification's own summary line, so it
15
+ # states the range covered and what was not.
16
+ # * **broken, unverifiable, or no chain after writing** — failed. The run signed its own
17
+ # entries, so a key it lacks or a recorder that did not chain is a configuration
18
+ # error, not an absence.
19
+ # * **no key configured** — failed, naming the setting.
20
+ # * **nothing to write or read** (no compliance tools, ledger not loaded) — skipped,
21
+ # saying the chain was not verified. Never passed.
22
+ #
23
+ # Reeve::Checks::LedgerChain.new.call
24
+ class LedgerChain < Base
25
+ NOT_VERIFIED = "the ledger chain was not verified"
26
+
27
+ def initialize(tools: nil, principals: nil, arguments: {}, invoke: nil, ledger: nil)
28
+ @tools = tools
29
+ @principals = principals
30
+ super(tool: nil, arguments: arguments, invoke: invoke, ledger: ledger)
31
+ end
32
+
33
+ def call
34
+ return skipped("there is no compliance tool to write an entry with") if subject.nil?
35
+ return skipped(unreadable_reason) unless readable?
36
+ return no_key if Reeve.config.ledger_key.nil?
37
+
38
+ unwritten = write_entries
39
+ return unwritten_entry(unwritten) if unwritten
40
+
41
+ judge(ledger.verify_chain)
42
+ end
43
+
44
+ private
45
+
46
+ def subject
47
+ @subject ||= Array(@tools || Testing.compliance_tools).first
48
+ end
49
+
50
+ def principals
51
+ configured = Testing.compliance_principals? ? Testing.compliance_principals : nil
52
+ list = Array(@principals || configured)
53
+ list.empty? ? [nil] : list
54
+ end
55
+
56
+ # Each principal on its own, so one refusal does not stop the next. A denial, or the
57
+ # tool's own error, still leaves a ledger entry — the envelope records before it
58
+ # re-raises — which is all this needs. A failed ledger write leaves none, and is
59
+ # returned as the reason the chain could not be checked.
60
+ #
61
+ # Returns nil when entries were written, or the reason none were — including the case
62
+ # where every invocation failed before reaching reeve, which a chain left by an
63
+ # earlier run must not be allowed to pass for.
64
+ def write_entries
65
+ before = ledger.count
66
+ principals.each do |principal|
67
+ invoke.call(tool: subject, arguments: arguments, principal: principal)
68
+ rescue Reeve::AuditWriteError => e
69
+ return "#{e.class}: #{e.message}"
70
+ rescue StandardError
71
+ next
72
+ end
73
+ ledger.count == before ? "its invocations wrote no entry" : nil
74
+ end
75
+
76
+ def readable?
77
+ ledger.available? && ledger.respond_to?(:verify_chain)
78
+ end
79
+
80
+ def unwritten_entry(reason)
81
+ failed("#{NOT_VERIFIED}: it wrote no entry of its own — #{reason}",
82
+ coverage: :unwritten)
83
+ end
84
+
85
+ def unreadable_reason
86
+ ledger.unavailable_reason || "this ledger cannot verify a chain"
87
+ end
88
+
89
+ def judge(verification)
90
+ return skipped("the audit ledger is not loaded") if verification.nil?
91
+
92
+ summary = verification.to_s
93
+ return passed(summary.lines.first.chomp, coverage: :intact) if verification.intact?
94
+
95
+ failed(summary, coverage: verification.status)
96
+ end
97
+
98
+ def no_key
99
+ failed("expected the ledger to be chained, but no ledger key is configured: set " \
100
+ "config.ledger_key (the generated initializer derives one from secret_key_base)",
101
+ coverage: :no_key)
102
+ end
103
+
104
+ # Every skip says, first, that the chain was not verified — then why.
105
+ def skipped(reason)
106
+ Result.skipped(check: check_name, message: "#{NOT_VERIFIED}: #{reason}",
107
+ details: { coverage: :unverified })
108
+ end
109
+
110
+ def base_details
111
+ {}
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -8,17 +8,19 @@ require_relative "checks/rule_present"
8
8
  require_relative "checks/redaction_holds"
9
9
  require_relative "checks/principal_required"
10
10
  require_relative "checks/contract_version"
11
+ require_relative "checks/endpoint_coverage"
12
+ require_relative "checks/ledger_chain"
11
13
 
12
14
  module Reeve
13
15
  module Testing
14
- # The seven guarantees, as objects.
16
+ # The guarantees, as objects.
15
17
  #
16
18
  # This is the whole of the testing kit's logic. The RSpec matchers and the Minitest
17
19
  # assertions are adapters over it and contain no assertions of their own, which is why
18
20
  # the same violation reads identically from either — and from neither:
19
21
  #
20
22
  # report = Reeve::Checks.run_all(principals: [alice, bob])
21
- # abort report.to_s unless report.passed?
23
+ # abort report.to_s if report.failed? || report.nothing_checked?
22
24
  #
23
25
  # Nothing here loads a test framework (FR-026).
24
26
  module Checks
@@ -29,11 +31,14 @@ module Reeve
29
31
  RulePresent,
30
32
  RedactionHolds,
31
33
  PrincipalRequired,
32
- ContractVersion
34
+ ContractVersion,
35
+ EndpointCoverage,
36
+ LedgerChain
33
37
  ].freeze
34
38
 
35
- # The checks that are asked once about the ledger rather than once per tool.
36
- GLOBAL = [ContractVersion].freeze
39
+ # The checks that are asked once — about the ledger, or about the endpoint as a
40
+ # whole — rather than once per tool.
41
+ GLOBAL = [ContractVersion, EndpointCoverage, LedgerChain].freeze
37
42
 
38
43
  # The compliance suite's engine (FR-018): every check, against every registered
39
44
  # guarded tool, in one Report.
@@ -53,9 +58,14 @@ module Reeve
53
58
  # test method out of, so that a failing suite names the guarantee that broke rather
54
59
  # than reporting "compliance" as one undifferentiated red.
55
60
  def self.run(check, principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
56
- return Report.new([check.new(ledger: ledger).call]) if GLOBAL.include?(check)
61
+ if GLOBAL.include?(check)
62
+ return Report.new([build(check, tool: nil, principals: principals, tools: tools,
63
+ invoke: invoke, ledger: ledger).call])
64
+ end
57
65
 
58
66
  subjects = tools || Testing.compliance_tools
67
+ return Report.new([nothing_to_check(check, tools)]) if subjects.empty?
68
+
59
69
  Report.new(
60
70
  subjects.map do |tool|
61
71
  build(check, tool: tool, principals: principals, arguments: arguments,
@@ -64,6 +74,25 @@ module Reeve
64
74
  )
65
75
  end
66
76
 
77
+ # A per-tool check over no tools establishes nothing, and an empty Report reads as
78
+ # passed — the green that means "did not look". So an empty population is one skip,
79
+ # saying which of the three ways the run came to have nothing in it.
80
+ def self.nothing_to_check(check, tools)
81
+ reason =
82
+ if !tools.nil? then "the run was given tools: []"
83
+ elsif Testing.compliance_tools_narrowed? then "compliance_tools is []"
84
+ else "the registry is empty and no endpoint inventory routes a tool"
85
+ end
86
+
87
+ Result.skipped(
88
+ check: check.check_name,
89
+ message: "no compliance tools to check, so #{check.check_name} established " \
90
+ "nothing — #{reason}",
91
+ details: { coverage: :none }
92
+ )
93
+ end
94
+ private_class_method :nothing_to_check
95
+
67
96
  # Every per-tool check for one tool, instantiated but not run.
68
97
  def self.for_tool(tool, principals:, arguments: {}, invoke: nil, ledger: nil)
69
98
  (ALL - GLOBAL).map do |check|
@@ -74,12 +103,19 @@ module Reeve
74
103
 
75
104
  # The one place that knows what each check's constructor wants. Front-ends and hosts
76
105
  # ask for a check by class and get a configured one back.
77
- def self.build(check, tool:, principals:, arguments: {}, invoke: nil, ledger: nil)
106
+ #
107
+ # +tools+ is the population the run was given, which only EndpointCoverage reads: an
108
+ # explicit list is a narrowed run, and it must say so.
109
+ def self.build(check, tool:, principals:, arguments: {}, invoke: nil, ledger: nil,
110
+ tools: nil)
78
111
  common = { tool: tool, arguments: arguments, invoke: invoke, ledger: ledger }
79
112
 
80
113
  case check.check_name
81
114
  when "GuardDeclared" then GuardDeclared.new(tool: tool, ledger: ledger)
82
115
  when "ContractVersion" then ContractVersion.new(ledger: ledger)
116
+ when "EndpointCoverage" then EndpointCoverage.new(tools: tools, ledger: ledger)
117
+ when "LedgerChain" then LedgerChain.new(tools: tools, principals: principals,
118
+ invoke: invoke, ledger: ledger)
83
119
  when "PrincipalRequired" then PrincipalRequired.new(**common)
84
120
  when "CrossPrincipalLeak" then CrossPrincipalLeak.new(principals: Array(principals),
85
121
  **common)
@@ -15,7 +15,9 @@ module Reeve
15
15
  #
16
16
  # One method per check, matching the RSpec shared example group one for one, so a red
17
17
  # build names the guarantee that broke rather than reporting "compliance" as one
18
- # undifferentiated failure.
18
+ # undifferentiated failure. A check that could establish nothing either way — no
19
+ # inventory declared, or no tools to run a per-tool check over — is skipped with its
20
+ # reason, never passed.
19
21
  module ComplianceAssertions
20
22
  include Assertions
21
23
 
@@ -25,6 +27,7 @@ module Reeve
25
27
 
26
28
  define_method(method_name) do
27
29
  report = Checks.run(check, principals: Testing.compliance_principals)
30
+ skip(report.to_s) if report.skipped?
28
31
  assert report.passed?, report.to_s
29
32
  report
30
33
  end
@@ -11,11 +11,14 @@
11
11
  # Reeve::Testing.compliance_principals = -> { [users(:alice), users(:bob)] }
12
12
  #
13
13
  # One example per check rather than one for the lot, so a red build names the guarantee
14
- # that broke — "CrossPrincipalLeak" is an incident, "compliance" is a shrug.
14
+ # that broke — "CrossPrincipalLeak" is an incident, "compliance" is a shrug. A check that
15
+ # could establish nothing either way (EndpointCoverage with no inventory, or a per-tool
16
+ # check with no tools to run over) is skipped, with its reason, rather than passed.
15
17
  RSpec.shared_examples "a reeve-compliant server" do
16
18
  Reeve::Checks::ALL.each do |check|
17
19
  it "satisfies #{check.check_name} for every registered guarded tool" do
18
20
  report = Reeve::Checks.run(check, principals: Reeve::Testing.compliance_principals)
21
+ skip(report.to_s) if report.skipped?
19
22
 
20
23
  expect(report).to be_passed, report.to_s
21
24
  end
@@ -10,7 +10,8 @@ module Reeve
10
10
  # the ledger is absent.
11
11
  #
12
12
  # A host with its own non-ActiveRecord recorder can pass any object with these six
13
- # methods as `ledger:` to any ledger-reading check.
13
+ # methods as `ledger:` to any ledger-reading check; LedgerChain also asks for
14
+ # #verify_chain, and is skipped by a ledger that has none.
14
15
  class Ledger
15
16
  def self.default
16
17
  new
@@ -67,6 +68,15 @@ module Reeve
67
68
  entry_class.column_names.map(&:to_s)
68
69
  end
69
70
 
71
+ # The chain's verification of the whole ledger (specs/002), or nil when the ledger is
72
+ # not loaded. The kit's only way to the chain: it names Reeve::Audit::Chain here, at
73
+ # call time, so `require "reeve/testing"` still loads with no ActiveRecord at all.
74
+ def verify_chain
75
+ return nil unless available? && Reeve::Audit.const_defined?(:Chain, false)
76
+
77
+ Reeve::Audit::Chain.verify(entry_class: entry_class)
78
+ end
79
+
70
80
  private
71
81
 
72
82
  def entry_class
@@ -3,7 +3,7 @@
3
3
  module Reeve
4
4
  module Testing
5
5
  module Matchers
6
- # The escape hatch that keeps SC-009 honest: every one of the seven checks is
6
+ # The escape hatch that keeps SC-009 honest: every check is
7
7
  # assertable from RSpec, not only the two with names of their own.
8
8
  #
9
9
  # expect(Reeve::Checks::RedactionHolds.new(tool: T, principal: alice))
@@ -15,6 +15,8 @@ module Reeve
15
15
  freeze
16
16
  end
17
17
 
18
+ # Nothing failed. A skip is not a failure — and it is printed, so it is not
19
+ # mistaken for a pass either.
18
20
  def passed?
19
21
  failures.empty?
20
22
  end
@@ -23,20 +25,39 @@ module Reeve
23
25
  !passed?
24
26
  end
25
27
 
28
+ # Every result was a skip: the run established nothing either way. The front-ends
29
+ # report this as a skipped test rather than a green one.
30
+ def skipped?
31
+ !results.empty? && results.all?(&:skipped?)
32
+ end
33
+
34
+ # A check had nothing to examine — a per-tool check run over no tools at all. passed?
35
+ # is still true then, since nothing failed; a test framework shows the skip, but a
36
+ # gate with no framework (a CI script, a deploy step) has only this to stop on. A skip
37
+ # for a missing inventory is not this: there the tools existed and were checked.
38
+ def nothing_checked?
39
+ results.any? { |result| result.skipped? && result.details[:coverage] == :none }
40
+ end
41
+
26
42
  def failures
27
- results.reject(&:passed?)
43
+ results.select(&:failed?)
28
44
  end
29
45
 
30
46
  def passes
31
47
  results.select(&:passed?)
32
48
  end
33
49
 
50
+ def skips
51
+ results.select(&:skipped?)
52
+ end
53
+
34
54
  def size
35
55
  results.size
36
56
  end
37
57
 
38
58
  def to_s
39
- [summary, *failures.map { |result| detail(result) }].join("\n")
59
+ [summary, *failures.map { |result| detail("FAIL", result) },
60
+ *skips.map { |result| detail("SKIP", result) }].join("\n")
40
61
  end
41
62
 
42
63
  def inspect
@@ -46,12 +67,15 @@ module Reeve
46
67
  private
47
68
 
48
69
  def summary
49
- "reeve compliance: #{size} #{size == 1 ? 'check' : 'checks'}, " \
50
- "#{passes.size} passed, #{failures.size} failed"
70
+ line = "reeve compliance: #{size} #{size == 1 ? 'check' : 'checks'}, " \
71
+ "#{passes.size} passed, #{failures.size} failed"
72
+ skips.empty? ? line : "#{line}, #{skips.size} skipped"
51
73
  end
52
74
 
53
- def detail(result)
54
- "\nFAIL #{result.check}\n #{result.message}"
75
+ # Every line of a multi-line message indented, so it reads as one block under its
76
+ # heading rather than as stray lines of the report.
77
+ def detail(heading, result)
78
+ "\n#{heading} #{result.check}\n #{result.message.gsub("\n", "\n ")}"
55
79
  end
56
80
  end
57
81
  end
@@ -9,31 +9,51 @@ module Reeve
9
9
  # Minitest assertion, a rake task — reads +message+ and prints it; none of them
10
10
  # composes their own (FR-019). That is what makes the same violation read identically
11
11
  # from all three.
12
+ #
13
+ # A result is passed, failed, or skipped. Skipped is for a guarantee the run could not
14
+ # establish either way — endpoint coverage with no inventory declared. Reporting that
15
+ # as a pass would be the green that means "we did not look"; reporting it as a failure
16
+ # would turn every build red over a question the host never asked. A skip is printed,
17
+ # and shows up yellow in both test frameworks, so it is neither.
12
18
  class Result
13
- attr_reader :check, :message, :details
19
+ STATUSES = %i[passed failed skipped].freeze
20
+
21
+ attr_reader :check, :message, :details, :status
14
22
 
15
23
  def self.passed(check:, message:, details: {})
16
- new(check: check, passed: true, message: message, details: details)
24
+ new(check: check, status: :passed, message: message, details: details)
17
25
  end
18
26
 
19
27
  def self.failed(check:, message:, details: {})
20
- new(check: check, passed: false, message: message, details: details)
28
+ new(check: check, status: :failed, message: message, details: details)
29
+ end
30
+
31
+ def self.skipped(check:, message:, details: {})
32
+ new(check: check, status: :skipped, message: message, details: details)
21
33
  end
22
34
 
23
- def initialize(check:, passed:, message:, details: {})
35
+ def initialize(check:, status:, message:, details: {})
36
+ unless STATUSES.include?(status)
37
+ raise ArgumentError, "status must be one of #{STATUSES.inspect}, got #{status.inspect}"
38
+ end
39
+
24
40
  @check = check.to_s
25
- @passed = passed ? true : false
41
+ @status = status
26
42
  @message = message.to_s
27
43
  @details = details.freeze
28
44
  freeze
29
45
  end
30
46
 
31
47
  def passed?
32
- @passed
48
+ status == :passed
33
49
  end
34
50
 
35
51
  def failed?
36
- !@passed
52
+ status == :failed
53
+ end
54
+
55
+ def skipped?
56
+ status == :skipped
37
57
  end
38
58
 
39
59
  def to_s
@@ -41,7 +61,7 @@ module Reeve
41
61
  end
42
62
 
43
63
  def inspect
44
- "#<Reeve::Testing::Result #{check} #{passed? ? 'passed' : 'failed'} " \
64
+ "#<Reeve::Testing::Result #{check} #{status} " \
45
65
  "#{message.inspect}>"
46
66
  end
47
67
  end