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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +30 -0
- data/lib/foobara/aws/check.rb +156 -0
- data/lib/foobara/aws/version.rb +1 -1
- data/lib/foobara/aws.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d08fb1f5a14798c744facb5a4f0a3986b544482f486b3962b90d8248b20bfab
|
|
4
|
+
data.tar.gz: 568943fcb7e647eb2fd620b0430076a173065bc8760ce8c0d6ec7cc7f89a8017
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/foobara/aws/version.rb
CHANGED
data/lib/foobara/aws.rb
CHANGED
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.
|
|
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
|