Skip to content

Commit c25e56a

Browse files
committed
.
1 parent 0db44a2 commit c25e56a

File tree

18 files changed

+335
-451
lines changed

18 files changed

+335
-451
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Fix rubocop `RSpec/MultipleExpectations` adding rspec tag `:aggregate_failures`.
4343
-**Frontend**: Use Astro components in `frontend/src/`. Keep it simple.
4444
-**CSS**: Use frontend styles provided by Astro Starlight.
4545
-**Specs**: RSpec for Ruby, build tests for frontend.
46+
- ✅ When a spec needs to tweak environment variables, wrap the example in `ClimateControl.modify` so state is restored automatically.
4647

4748
## Don't
4849

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ AllCops:
1212
- '**/*.yaml'
1313
- '**/.tool-versions'
1414

15+
Style/Documentation:
16+
Enabled: false
17+
1518
Metrics/BlockLength:
1619
Exclude:
1720
- Rakefile

Makefile

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ test-frontend-unit: ## Run frontend unit tests only
4747
test-frontend-contract: ## Run frontend contract tests only
4848
@cd frontend && npm run test:contract
4949

50-
test-frontend-smoke: ## Run frontend smoke tests (Playwright)
51-
@cd frontend && npm run test:smoke
5250

5351
lint: lint-ruby lint-js ## Run all linters (Ruby + Frontend) - errors when issues found
5452
@echo "All linting complete!"

README.md

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,6 @@ The project includes a modern Astro frontend alongside the Ruby backend:
203203
| `make test-frontend` | Run frontend unit + contract tests |
204204
| `make test-frontend-unit` | Run frontend unit tests only |
205205
| `make test-frontend-contract` | Run frontend contract tests (Vitest + MSW) |
206-
| `make test-frontend-smoke` | Run frontend smoke tests (Playwright) |
207206
| `make lint` | Run all linters (Ruby + Frontend) |
208207
| `make lint-ruby` | Run Ruby linter only |
209208
| `make lint-js` | Run frontend linter only |
@@ -221,7 +220,6 @@ The project includes a modern Astro frontend alongside the Ruby backend:
221220
| `cd frontend && npm run build` | Build frontend for production |
222221
| `cd frontend && npm run test:unit` | Run unit tests (Vitest) |
223222
| `cd frontend && npm run test:contract` | Run contract tests with MSW |
224-
| `cd frontend && npm run test:smoke` | Run Playwright smoke tests |
225223

226224
### Testing Strategy
227225

@@ -230,14 +228,7 @@ The project includes a modern Astro frontend alongside the Ruby backend:
230228
| Ruby API | RSpec + Rack::Test | Deterministic request specs for the Roda app |
231229
| Frontend Unit | Vitest + Testing Library | Hook/component behaviour with mocked fetch |
232230
| Frontend Contract| Vitest + MSW | Exercises real fetch flows against mocked API responses |
233-
| Smoke | Playwright | Minimal API smoke checks via the real Puma server |
234-
235-
To execute Playwright smoke tests locally for the first time, install the required browsers:
236-
237-
```bash
238-
cd frontend
239-
npx playwright install --with-deps
240-
```
231+
| Docker Smoke | RSpec (tagged `:docker`) | Net::HTTP checks against a bundled Puma container |
241232

242233
## Contributing
243234

Rakefile

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,22 +50,14 @@ task :test do
5050
Output.describe 'Listing docker containers matching html2rss-web-test filter'
5151
sh 'docker ps -a --filter name=html2rss-web-test'
5252

53-
Output.describe 'Generating feed from a html2rss-configs config'
54-
sh 'curl -f "http://127.0.0.1:3000/github.com/releases.rss?username=html2rss&repository=html2rss-web" || exit 1'
55-
56-
Output.describe 'Generating example feed from feeds.yml'
57-
sh 'curl -f http://127.0.0.1:3000/example.rss || exit 1'
58-
59-
Output.describe 'Health check endpoint'
60-
sh <<~COMMAND
61-
docker exec html2rss-web-test curl -f \
62-
-H "Authorization: Bearer health-check-token-xyz789" \
63-
http://127.0.0.1:3000/api/v1/health || exit 1
64-
COMMAND
65-
66-
# skipped as html2rss is used in development version
67-
# Output.describe 'Print output of `html2rss help`'
68-
# sh 'docker exec html2rss-web-test html2rss help'
53+
Output.describe 'Running RSpec smoke suite against container'
54+
smoke_env = {
55+
'SMOKE_BASE_URL' => 'http://127.0.0.1:3000',
56+
'SMOKE_HEALTH_TOKEN' => 'health-check-token-xyz789',
57+
'SMOKE_API_TOKEN' => 'allow-any-urls-abcd-4321',
58+
'RUN_DOCKER_SPECS' => 'true'
59+
}
60+
sh smoke_env, 'bundle exec rspec --tag docker'
6961
ensure
7062
test_container_exists = JSON.parse(`docker inspect html2rss-web-test`).any?
7163

app/account_manager.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ def get_account(token)
2020
def accounts
2121
@accounts ||= begin # rubocop:disable ThreadSafety/ClassInstanceVariable
2222
auth_config = LocalConfig.global[:auth]
23+
raw_accounts = auth_config&.dig(:accounts)
2324

24-
auth_config&.dig(:accounts) ? auth_config[:accounts].transform_keys(&:to_sym) : []
25+
Array(raw_accounts).map { |account| account.transform_keys(&:to_sym) }
2526
end
2627
end
2728

app/feed_token.rb

Lines changed: 68 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -2,196 +2,127 @@
22

33
require 'base64'
44
require 'json'
5-
require 'zlib'
65
require 'openssl'
6+
require 'zlib'
77
require_relative 'url_validator'
88

99
module Html2rss
1010
module Web
11-
# Token configuration constants
1211
DEFAULT_EXPIRY = 315_360_000 # 10 years in seconds
1312
HMAC_ALGORITHM = 'SHA256'
13+
REQUIRED_TOKEN_KEYS = %i[p s].freeze
14+
COMPRESSED_PAYLOAD_KEYS = %i[u l e].freeze
1415

15-
# Compressed feed token with HMAC validation
1616
FeedToken = Data.define(:username, :url, :expires_at, :signature) do
17-
# @param username [String]
18-
# @param url [String]
19-
# @param secret_key [String]
20-
# @param expires_in [Integer] seconds (default: 10 years)
21-
# @return [FeedToken, nil]
22-
def self.create_with_validation(username:, url:, secret_key:, expires_in: Html2rss::Web::DEFAULT_EXPIRY)
23-
return nil unless valid_inputs?(username, url, secret_key)
17+
def self.create_with_validation(username:, url:, secret_key:, expires_in: DEFAULT_EXPIRY)
18+
return unless valid_inputs?(username, url, secret_key)
2419

2520
expires_at = Time.now.to_i + expires_in.to_i
26-
payload = create_payload(username, url, expires_at)
21+
payload = build_payload(username, url, expires_at)
2722
signature = generate_signature(secret_key, payload)
2823

2924
new(username: username, url: url, expires_at: expires_at, signature: signature)
3025
end
3126

32-
# @param encoded_token [String]
33-
# @return [FeedToken, nil]
34-
def self.decode(encoded_token)
35-
return nil unless encoded_token
27+
def self.decode(encoded_token) # rubocop:disable Metrics/MethodLength
28+
return unless encoded_token
3629

3730
token_data = parse_token_data(encoded_token)
38-
return nil unless valid_token_data?(token_data)
31+
return unless valid_token_data?(token_data)
3932

40-
create_from_token_data(token_data)
33+
payload = token_data[:p]
34+
new(
35+
username: payload[:u],
36+
url: payload[:l],
37+
expires_at: payload[:e],
38+
signature: token_data[:s]
39+
)
4140
rescue JSON::ParserError, ArgumentError, Zlib::DataError, Zlib::BufError
4241
nil
4342
end
4443

45-
# @return [String] compressed base64-encoded token
46-
def encode
47-
token_data = build_token_data
48-
compressed_data = Zlib::Deflate.deflate(token_data.to_json)
49-
Base64.urlsafe_encode64(compressed_data)
50-
end
51-
52-
# @return [Boolean]
53-
def expired?
54-
Time.now.to_i > expires_at
55-
end
56-
57-
# @param url [String]
58-
# @return [Boolean]
59-
def valid_for_url?(url)
60-
self.url == url
61-
end
62-
63-
# @param encoded_token [String]
64-
# @param expected_url [String]
65-
# @param secret_key [String]
66-
# @return [FeedToken, nil]
6744
def self.validate_and_decode(encoded_token, expected_url, secret_key)
6845
token = decode(encoded_token)
69-
return nil unless token
70-
71-
return nil unless token.valid_signature?(secret_key)
72-
return nil unless token.valid_for_url?(expected_url)
73-
return nil if token.expired?
46+
return unless token
47+
return unless token.valid_signature?(secret_key)
48+
return unless token.valid_for_url?(expected_url)
49+
return if token.expired?
7450

7551
token
7652
end
7753

78-
# @return [Hash] payload for HMAC verification
79-
def payload_for_signature
80-
{
81-
username: username,
82-
url: url,
83-
expires_at: expires_at
84-
}
54+
def encode
55+
compressed = Zlib::Deflate.deflate(build_token_data.to_json)
56+
Base64.urlsafe_encode64(compressed)
57+
end
58+
59+
def expired?
60+
Time.now.to_i > expires_at
8561
end
8662

87-
# @param username [String]
88-
# @param url [String]
89-
# @param secret_key [String]
90-
# @return [Boolean]
91-
def self.valid_inputs?(username, url, secret_key)
92-
valid_username?(username) && UrlValidator.valid_url?(url) && secret_key
63+
def valid_for_url?(candidate_url)
64+
url == candidate_url
9365
end
9466

95-
# @param secret_key [String]
96-
# @return [Boolean]
9767
def valid_signature?(secret_key)
98-
return false unless secret_key
68+
return false unless self.class.valid_secret_key?(secret_key)
9969

10070
expected_signature = self.class.generate_signature(secret_key, payload_for_signature)
10171
secure_compare(signature, expected_signature)
10272
end
10373

10474
private
10575

106-
# @param encoded_token [String]
107-
# @return [Hash, nil]
108-
def self.parse_token_data(encoded_token)
109-
compressed_data = Base64.urlsafe_decode64(encoded_token)
110-
json_data = Zlib::Inflate.inflate(compressed_data)
111-
JSON.parse(json_data, symbolize_names: true)
76+
def payload_for_signature
77+
{ username: username, url: url, expires_at: expires_at }
11278
end
11379

114-
# @param token_data [Hash]
115-
# @return [Boolean]
116-
def self.valid_token_data?(token_data)
117-
return false unless token_data[:p] && token_data[:s]
118-
119-
payload = token_data[:p]
120-
payload[:u] && payload[:l] && payload[:e]
80+
def build_token_data
81+
{ p: { u: username, l: url, e: expires_at }, s: signature }
12182
end
12283

123-
# @param token_data [Hash]
124-
# @return [FeedToken]
125-
def self.create_from_token_data(token_data)
126-
payload = token_data[:p]
127-
new(
128-
username: payload[:u],
129-
url: payload[:l],
130-
expires_at: payload[:e],
131-
signature: token_data[:s]
132-
)
133-
end
84+
def secure_compare(first, second)
85+
return false unless first && second && first.bytesize == second.bytesize
13486

135-
# @return [Hash]
136-
def build_token_data
137-
compressed_payload = {
138-
u: username,
139-
l: url,
140-
e: expires_at
141-
}
142-
143-
{
144-
p: compressed_payload,
145-
s: signature
146-
}
87+
first.each_byte.zip(second.each_byte).reduce(0) { |acc, (a, b)| acc | (a ^ b) }.zero?
14788
end
14889

149-
# @param username [String]
150-
# @param url [String]
151-
# @param expires_at [Integer]
152-
# @return [Hash]
153-
def self.create_payload(username, url, expires_at)
154-
{
155-
username: username,
156-
url: url,
157-
expires_at: expires_at
158-
}
159-
end
90+
class << self
91+
def build_payload(username, url, expires_at)
92+
{ username: username, url: url, expires_at: expires_at }
93+
end
16094

161-
# @param secret_key [String]
162-
# @param payload [Hash]
163-
# @return [String]
164-
def self.generate_signature(secret_key, payload)
165-
OpenSSL::HMAC.hexdigest(Html2rss::Web::HMAC_ALGORITHM, secret_key, payload.to_json)
166-
end
95+
def generate_signature(secret_key, payload)
96+
data = payload.is_a?(String) ? payload : JSON.generate(payload)
97+
OpenSSL::HMAC.hexdigest(HMAC_ALGORITHM, secret_key, data)
98+
end
16799

168-
# @param username [String]
169-
# @return [Boolean]
170-
def self.valid_username?(username)
171-
return false unless username.is_a?(String)
172-
return false if username.empty? || username.length > 100
173-
return false unless username.match?(/\A[a-zA-Z0-9_-]+\z/)
100+
def parse_token_data(encoded_token)
101+
decoded = Base64.urlsafe_decode64(encoded_token)
102+
inflated = Zlib::Inflate.inflate(decoded)
103+
JSON.parse(inflated, symbolize_names: true)
104+
end
174105

175-
true
176-
end
106+
def valid_token_data?(token_data)
107+
return false unless token_data.is_a?(Hash)
177108

178-
# @param url [String]
179-
# @return [Boolean]
180-
def self.valid_url?(url)
181-
UrlValidator.valid_url?(url)
182-
end
109+
payload = token_data[:p]
110+
signature = token_data[:s]
111+
payload.is_a?(Hash) && signature.is_a?(String) && !signature.empty? &&
112+
COMPRESSED_PAYLOAD_KEYS.all? { |key| payload[key] }
113+
end
114+
115+
def valid_inputs?(username, url, secret_key)
116+
valid_username?(username) && UrlValidator.valid_url?(url) && valid_secret_key?(secret_key)
117+
end
118+
119+
def valid_username?(username)
120+
username.is_a?(String) && !username.empty? && username.length <= 100 && username.match?(/\A[a-zA-Z0-9_-]+\z/)
121+
end
183122

184-
# Constant-time comparison to prevent timing attacks
185-
# @param first_string [String]
186-
# @param second_string [String]
187-
# @return [Boolean]
188-
def secure_compare(first_string, second_string)
189-
return false unless first_string && second_string
190-
return false unless first_string.bytesize == second_string.bytesize
191-
192-
result = 0
193-
first_string.bytes.zip(second_string.bytes) { |x, y| result |= x ^ y }
194-
result.zero?
123+
def valid_secret_key?(secret_key)
124+
secret_key.is_a?(String) && !secret_key.empty?
125+
end
195126
end
196127
end
197128
end

0 commit comments

Comments
 (0)