foobara-aws 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4de7e0012dfa7d11c92054cd25827b8b7f2e0864bf779027fe8c35a754c423e
4
- data.tar.gz: b5009b5e2c62ba0db20ba010ad3580afec5c0524733fd8a8aa09f9a0afa8e475
3
+ metadata.gz: 4d08fb1f5a14798c744facb5a4f0a3986b544482f486b3962b90d8248b20bfab
4
+ data.tar.gz: 568943fcb7e647eb2fd620b0430076a173065bc8760ce8c0d6ec7cc7f89a8017
5
5
  SHA512:
6
- metadata.gz: cb24ae7b484641d23c71c27ff59743ff1a2ac60eb0eea8c672da9c7177d05083cecfc535b47e722c09f995ca70fd9c8b32ea74ca1c5b6e2f2b0767ee9d58eedd
7
- data.tar.gz: 87c12595977a88553fc092c6aa050dd22dcb25d433e5593d5b0594958b0e16cd42e47ae6553fcec8957089a8aeab68e620b251974a8e2da4bd38a65115793a29
6
+ metadata.gz: aae339846f621a18ccb328143d5543aa6b3cc42ab8e0f8429cbb8c2f69c156bf7000286fa6e3fa488a838cec557244581de972229320f38c5ea6d9cd28123f00
7
+ data.tar.gz: d02982c0c6f67749b548f411f246f614778f59af62043bd529f4227006ed83b06c9302af32271e388b60a90866be94104947859cc4a066f442a7dc28960745b5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-08-04
4
+
5
+ - `Foobara::AWS::Check` — checks a DEPLOYED API against the plan it was deployed from. It asserts the few things
6
+ that are true of every Foobara deployment and that an application's own tests structurally cannot see, because
7
+ they live at the edge: a public command is reachable without credentials, a gated one is refused without them
8
+ and with an invalid token, and a refusal is JSON rather than an HTML page with a 200 on it.
9
+ - Gated commands are only ever called WITHOUT credentials, so they are refused before executing and nothing is
10
+ written. Public commands are executed with empty inputs; `skip:` is there for when that is not wanted. A 422 is
11
+ treated as success — the question is whether the request reached the command, not whether it liked the inputs.
12
+
3
13
  ## [0.1.0] - 2026-08-04
4
14
 
5
15
  - Initial release. Renamed from foobara-cdk before publishing: half of it is runtime code that loads inside a
data/README.md CHANGED
@@ -151,6 +151,36 @@ Two things it handles that are easy to get wrong:
151
151
  and the load path rewritten. (Use `mounts:` so the build container can see
152
152
  them in the first place.)
153
153
 
154
+ ## Checking a deployment
155
+
156
+ ```ruby
157
+ result = Foobara::AWS::Check.new(url: "https://api.example.com", plan: plan).run
158
+ puts result.report
159
+ exit 1 unless result.ok?
160
+ ```
161
+
162
+ Driven by the plan, so it knows which commands are public without being told. It
163
+ asserts the few things true of every Foobara deployment that an application's own
164
+ tests structurally cannot see, because they live at the edge:
165
+
166
+ - a public command is reachable **without** credentials
167
+ - a gated command is refused without them, and with an invalid token
168
+ - a refusal is JSON, not an HTML page with a 200 on it
169
+
170
+ Each has a specific failure behind it. A public command that 401s usually means
171
+ the authorizer declared an identity source, so API Gateway answered before the
172
+ authorizer ran — silent, because the function was never invoked and logged
173
+ nothing. A refusal arriving as `200 text/html` usually means a SPA history
174
+ fallback is rewriting the API's errors, which turns every client-side error check
175
+ into a lie. Both are real, both shipped, and both passed a full green test suite.
176
+
177
+ Every request carries `{}`, so a command with required inputs answers 422 — which
178
+ counts as success, since the question is whether the request reached the command.
179
+ Gated commands are therefore only ever called **without** credentials: they are
180
+ refused before executing and nothing is written. Public commands are executed
181
+ with empty inputs, which is safe for the usual case of a read; `skip:` is there
182
+ for when it is not.
183
+
154
184
  ## What it does not do
155
185
 
156
186
  - **Create tables, buckets or queues.** Those are the application's, not the
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "error"
8
+ require_relative "plan"
9
+
10
+ module Foobara
11
+ module AWS
12
+ # Checks a DEPLOYED API against the plan it was deployed from.
13
+ #
14
+ # result = Foobara::AWS::Check.new(url: "https://api.example.com", plan: plan).run
15
+ # puts result.report
16
+ # exit 1 unless result.ok?
17
+ #
18
+ # Not a substitute for the application's own tests. It asserts the handful of
19
+ # things that are true of every Foobara deployment and that unit tests
20
+ # structurally cannot see, because they live at the edge:
21
+ #
22
+ # * a public command is reachable WITHOUT credentials
23
+ # * a command requiring authentication is refused without them
24
+ # * a refusal is a refusal, not an HTML page with a 200 on it
25
+ #
26
+ # Each of those has a specific failure behind it. A public command that 401s
27
+ # usually means the authorizer declared an identity source, so API Gateway
28
+ # answered before the authorizer ran — silent, because the function was never
29
+ # invoked and logged nothing. A refusal arriving as `200 text/html` usually
30
+ # means a SPA history fallback is rewriting the API's errors, which turns
31
+ # every client error check into a lie.
32
+ #
33
+ # WHAT IT SENDS. Every request carries `{}` as its body, so a command that
34
+ # requires inputs answers 422. That is treated as SUCCESS: the point is
35
+ # whether the request reached the command at all, not whether it liked the
36
+ # inputs. It also means gated commands are only ever called WITHOUT
37
+ # credentials, so they are refused before executing and nothing is written.
38
+ #
39
+ # Public commands ARE executed, with empty inputs. That is safe for the usual
40
+ # case — a command reachable anonymously is nearly always a read — but it is
41
+ # the caller's judgement, and `skip:` exists for when it is not.
42
+ class Check
43
+ # Anything at all, as long as it is not a refusal and not HTML.
44
+ REFUSALS = [401, 403].freeze
45
+
46
+ Finding = Data.define(:command, :description, :expected, :actual, :ok) do
47
+ def to_s
48
+ "#{ok ? "ok " : "FAIL"} #{command.ljust(28)} #{description} (expected #{expected}, got #{actual})"
49
+ end
50
+ end
51
+
52
+ Result = Data.define(:findings) do
53
+ def ok? = findings.all?(&:ok)
54
+ def failures = findings.reject(&:ok)
55
+
56
+ def report
57
+ summary = "#{findings.count(&:ok)}/#{findings.length} checks passed"
58
+ ([summary] + findings.map(&:to_s)).join("\n")
59
+ end
60
+ end
61
+
62
+ # +http+ is injectable so this can be tested without a network, and so a
63
+ # caller can supply its own client (a proxy, a custom CA, an SSM-fetched
64
+ # token). It receives (url, body_hash, headers) and returns
65
+ # [status, content_type, body_string].
66
+ def initialize(url:, plan:, skip: [], invalid_token: "not.a.token", http: nil)
67
+ @url = url.to_s.chomp("/")
68
+ @plan = plan
69
+ @skip = Array(skip).map(&:to_s)
70
+ @invalid_token = invalid_token
71
+ @http = http || method(:request)
72
+ end
73
+
74
+ def run
75
+ Result.new(findings: @plan.units.flat_map { |unit| check_unit(unit) })
76
+ end
77
+
78
+ private
79
+
80
+ def check_unit(unit)
81
+ unit.commands.reject { |c| @skip.include?(c) }.flat_map do |command|
82
+ if unit.public_commands.include?(command)
83
+ check_public(command)
84
+ else
85
+ check_gated(command)
86
+ end
87
+ end
88
+ end
89
+
90
+ # Reachable without credentials, and answering as an API rather than as a
91
+ # web page.
92
+ def check_public(command)
93
+ status, content_type, = call(command)
94
+
95
+ [
96
+ finding(command, "reachable anonymously", "not #{REFUSALS.join("/")}", status,
97
+ !REFUSALS.include?(status)),
98
+ finding(command, "answers as an API", "json", content_type, json?(content_type))
99
+ ]
100
+ end
101
+
102
+ # Refused without credentials, and refused with a token that does not
103
+ # verify — the second catches an authorizer that accepts anything, which
104
+ # the first cannot distinguish from one that is never invoked.
105
+ def check_gated(command)
106
+ anonymous, anonymous_type, = call(command)
107
+ forged, forged_type, = call(command, token: @invalid_token)
108
+
109
+ [
110
+ finding(command, "refused anonymously", REFUSALS.join("/"), anonymous,
111
+ REFUSALS.include?(anonymous)),
112
+ finding(command, "refusal is not a web page", "json", anonymous_type, json?(anonymous_type)),
113
+ finding(command, "refused with an invalid token", REFUSALS.join("/"), forged,
114
+ REFUSALS.include?(forged)),
115
+ finding(command, "invalid-token refusal is not a web page", "json", forged_type,
116
+ json?(forged_type))
117
+ ]
118
+ end
119
+
120
+ def finding(command, description, expected, actual, passed)
121
+ Finding.new(command: command, description: description, expected: expected,
122
+ actual: actual.to_s, ok: passed)
123
+ end
124
+
125
+ # A 200 serving text/html is the CloudFront-rewrite failure, and it is worth
126
+ # naming rather than reporting as an unexpected status.
127
+ def json?(content_type) = content_type.to_s.include?("json")
128
+
129
+ def call(command, token: nil)
130
+ path = command.split("::").join("/")
131
+ headers = { "Content-Type" => "application/json" }
132
+ headers["Authorization"] = "Bearer #{token}" if token
133
+
134
+ @http.call("#{@url}#{@plan.mount}/#{path}", {}, headers)
135
+ rescue StandardError => e
136
+ # A connection failure is a check failure, not a crash: reporting it
137
+ # alongside the others is more useful than aborting the run, and it
138
+ # applies to an injected client as much as to the default one.
139
+ [0, "error: #{e.class}", e.message]
140
+ end
141
+
142
+ def request(url, body, headers)
143
+ uri = URI(url)
144
+ request = Net::HTTP::Post.new(uri)
145
+ headers.each { |k, v| request[k] = v }
146
+ request.body = JSON.dump(body)
147
+
148
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
149
+ http.request(request)
150
+ end
151
+
152
+ [response.code.to_i, response["content-type"], response.body]
153
+ end
154
+ end
155
+ end
156
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Foobara
4
4
  module AWS
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/foobara/aws.rb CHANGED
@@ -27,4 +27,5 @@ end
27
27
 
28
28
  require_relative "aws/lambda"
29
29
  require_relative "aws/plan"
30
+ require_relative "aws/check"
30
31
  require_relative "aws/cdk/service"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foobara-aws
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Omar Qureshi
@@ -26,6 +26,7 @@ files:
26
26
  - lib/foobara/aws.rb
27
27
  - lib/foobara/aws/authorizer.rb
28
28
  - lib/foobara/aws/cdk/service.rb
29
+ - lib/foobara/aws/check.rb
29
30
  - lib/foobara/aws/error.rb
30
31
  - lib/foobara/aws/handler.rb
31
32
  - lib/foobara/aws/lambda.rb