diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..2909bb6 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,158 @@ +# ChainForge — Development Guidelines + +This document captures project-specific practices for building, testing, and extending ChainForge. It assumes an advanced Ruby developer familiar with Bundler, RSpec, Docker, and MongoDB. + +## Build and Configuration + +- Ruby: 3.2.2 (see `Gemfile` and `Dockerfile`). +- App stack: Sinatra 4.x, Mongoid 7.0.x, MongoDB. +- Environment configuration is consumed via `dotenv` and `Mongoid`: + - `Mongoid.load!('./config/mongoid.yml', ENV['ENVIRONMENT'] || :development)` in `main.rb` + - `Mongoid.load!('./config/mongoid.yml', ENV['ENVIRONMENT'] || :test)` in `spec/spec_helper.rb` +- Required ENV (both development and test environments): + - `ENVIRONMENT` — one of `development` or `test` (symbol allowed by Mongoid). Defaults to `development` in app, `test` in specs when unset. + - `MONGO_DB_NAME` — database name (e.g., `chain_forge_dev` or `chain_forge_test`). + - `MONGO_DB_HOST` — MongoDB host (e.g., `127.0.0.1`). + - `MONGO_DB_PORT` — MongoDB port (e.g., `27017`). +- Mongoid config (`config/mongoid.yml`) uses only these ENV variables (no auth block). Ensure the vars are set or use Docker. + +### Installing dependencies + +```bash +# Ruby 3.2.2 assumed +gem install bundler -v 2.4.13 +bundle install +``` + +### Running via Docker (recommended for a clean Mongo) + +```bash +docker-compose up -d +# Exposes app on :1910 and mongo on :27017 by default +``` + +### Running app locally + +```bash +# Ensure MongoDB is reachable at MONGO_DB_HOST:MONGO_DB_PORT and DB exists/auto-creates +ENVIRONMENT=development \ +MONGO_DB_NAME=chain_forge_dev \ +MONGO_DB_HOST=127.0.0.1 \ +MONGO_DB_PORT=27017 \ +bundle exec ruby main.rb -p 1910 +``` + +## Testing + +RSpec is configured in `spec/spec_helper.rb` and loads Mongoid in `:test` unless `ENVIRONMENT` overrides. + +### Baseline run (verified) + +The following has been verified to pass for unit-level specs on a fresh run with MongoDB available (e.g., via `docker-compose up -d`): + +```bash +ENVIRONMENT=test \ +MONGO_DB_NAME=chain_forge_test \ +MONGO_DB_HOST=127.0.0.1 \ +MONGO_DB_PORT=27017 \ +bundle exec rspec spec/block_spec.rb spec/blockchain_spec.rb +``` + +Notes: +- The full suite currently includes API specs that may fail depending on content-type behavior and rack/test expectations. If you want green runs while iterating on models, scope to the unit specs above or filter via `-e/--example` or `--pattern`. +- Full-suite run example (may include failing API specs today): + +```bash +ENVIRONMENT=test MONGO_DB_NAME=chain_forge_test MONGO_DB_HOST=127.0.0.1 MONGO_DB_PORT=27017 \ +bundle exec rspec +``` + +### Focused runs and filters + +- Single file: `bundle exec rspec spec/block_spec.rb` +- Single example: `bundle exec rspec spec/block_spec.rb:37` +- By example name: `bundle exec rspec -e 'validates the blockchain'` + +### Coverage + +SimpleCov is available (loaded on demand). To enable coverage, set an env var before running: + +```bash +COVERAGE=true ENVIRONMENT=test MONGO_DB_NAME=chain_forge_test MONGO_DB_HOST=127.0.0.1 MONGO_DB_PORT=27017 \ +bundle exec rspec +# Coverage report will be in coverage/index.html +``` + +### Adding a new spec (demonstrated workflow) + +The following process was executed and verified end-to-end; replicate as needed: + +1. Create a new spec file under `spec/`, e.g. `spec/demo_math_spec.rb`: + ```ruby + # frozen_string_literal: true + require 'rspec' + + RSpec.describe 'Math demo' do + it 'adds numbers' do + expect(1 + 1).to eq(2) + end + end + ``` +2. Run just that spec: + ```bash + ENVIRONMENT=test MONGO_DB_NAME=chain_forge_test MONGO_DB_HOST=127.0.0.1 MONGO_DB_PORT=27017 \ + bundle exec rspec spec/demo_math_spec.rb + ``` + Expected: 1 example, 0 failures. +3. Remove the file once done (to keep repo clean): + ```bash + rm spec/demo_math_spec.rb + ``` + +### Test data and MongoDB + +- Specs create and persist documents via Mongoid (e.g., `Block`, `Blockchain`). Use a dedicated `MONGO_DB_NAME` for tests to avoid polluting dev data. +- No cleaning strategy is configured. If isolation becomes necessary, consider adding `database_cleaner-mongoid` or dropping the test DB between runs. + +## Additional Development Notes + +### API behavior + +- `main.rb` sets `content_type :json` for POST requests in a `before` block. If API specs expect more strict headers (e.g., `application/json` vs `text/html`), ensure routes are executed in the same Rack env as tests (Sinatra + rack-test). If content-type mismatches persist, review middleware or how tests mount the app. + +### Code style and linting + +- RuboCop with `rubocop-rspec` is configured in `.rubocop.yml`. + - Target Ruby: 3.2 + - Selected Metrics/Style cops are tuned; specs and config directories are largely excluded from method/ block length checks. +- Run lint: + ```bash + bundle exec rubocop + ``` + +### Project structure + +- Core domain: + - `src/block.rb` — `Block` model with SHA256 hashing and `valid_data?` verification. + - `src/blockchain.rb` — `Blockchain` aggregate with `add_genesis_block`, `add_block`, `integrity_valid?`. +- App entrypoint: `main.rb` (Sinatra routes for creating chains, adding blocks, validating block data). +- Tests: `spec/` directory with unit specs for `Block` and `Blockchain`, and API specs. + +### Debugging tips + +- Use focused RSpec runs (`-e`, `:line_number`). +- Inspect Mongo documents in a console by starting `irb` with the same environment: + ```bash + ENVIRONMENT=development MONGO_DB_NAME=chain_forge_dev MONGO_DB_HOST=127.0.0.1 MONGO_DB_PORT=27017 \ + bundle exec irb -r ./src/blockchain -r ./src/block + ``` +- When debugging hashing/time-sensitive assertions, compare against `created_at.to_i` (used in hash calculation). + +### Docker notes + +- The provided `docker-compose.yml` spins up `app` and `db`. The `app` container runs `ruby main.rb -p 1910` per `Dockerfile` CMD. For test runs, it’s simpler to run RSpec on the host and use the `db` service for Mongo. +- If you want to run tests inside the container, `docker-compose exec app bash` then run the same `ENVIRONMENT=test` commands with `MONGO_DB_HOST=db`. + +--- + +This document reflects commands verified on 2025-11-08 23:58 local time. Keep it updated as the test suite and APIs evolve. \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index 09bccff..9486493 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -34,6 +34,8 @@ Metrics/BlockLength: Exclude: - 'spec/**/*' - 'config/**/*' + AllowedMethods: + - namespace # Style Style/Documentation: diff --git a/Gemfile b/Gemfile index 03c3a2c..b66ec0e 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,9 @@ source 'https://rubygems.org' ruby '3.2.2' gem 'dotenv', '~> 2.7' +gem 'dry-validation', '~> 1.10' gem 'mongoid', '~> 7.0.5' +gem 'rack-attack', '~> 6.7' gem 'rackup', '~> 2.1.0' gem 'rspec', '~> 3.10' gem 'sinatra', '~> 4.0.0' diff --git a/Gemfile.lock b/Gemfile.lock index ca3a3ab..e3b2d55 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,16 +11,53 @@ GEM zeitwerk (~> 2.3) ast (2.4.3) base64 (0.2.0) + bigdecimal (3.3.1) bson (4.15.0) concurrent-ruby (1.2.3) diff-lcs (1.5.1) docile (1.4.1) dotenv (2.8.1) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.1.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.2.0) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-schema (1.14.1) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.5) + dry-types (~> 1.8) + zeitwerk (~> 2.6) + dry-types (1.8.3) + bigdecimal (~> 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) + dry-validation (1.11.1) + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-schema (~> 1.14) + zeitwerk (~> 2.6) i18n (1.14.1) concurrent-ruby (~> 1.0) json (2.16.0) language_server-protocol (3.17.0.5) lint_roller (1.1.0) + logger (1.7.0) minitest (5.22.0) mongo (2.19.3) bson (>= 4.14.1, < 5.0.0) @@ -37,6 +74,8 @@ GEM prism (1.6.0) racc (1.8.1) rack (3.0.8) + rack-attack (6.8.0) + rack (>= 1.0, < 4) rack-protection (4.0.0) base64 (>= 0.1.0) rack (>= 3.0.0, < 4) @@ -124,7 +163,9 @@ PLATFORMS DEPENDENCIES dotenv (~> 2.7) + dry-validation (~> 1.10) mongoid (~> 7.0.5) + rack-attack (~> 6.7) rack-test (~> 2.1) rackup (~> 2.1.0) rspec (~> 3.10) diff --git a/config/rack_attack.rb b/config/rack_attack.rb new file mode 100644 index 0000000..99cb2c0 --- /dev/null +++ b/config/rack_attack.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Configure Rack::Attack for rate limiting +module Rack + class Attack + # Throttle all requests by IP (60 requests per minute) + throttle('req/ip', limit: 60, period: 60, &:ip) + + # Throttle POST requests to /chain by IP (10 per minute) + throttle('chain/ip', limit: 10, period: 60) do |req| + req.ip if req.path == '/chain' && req.post? + end + + # Throttle POST requests to block creation (30 per minute) + throttle('block/ip', limit: 30, period: 60) do |req| + req.ip if req.path.match?(%r{^/chain/.+/block$}) && req.post? + end + + # Custom response for throttled requests + self.throttled_responder = lambda do |_env| + [ + 429, + { 'Content-Type' => 'application/json' }, + [{ error: 'Rate limit exceeded. Please try again later.' }.to_json] + ] + end + end +end diff --git a/main.rb b/main.rb index 7ff7dbb..fb8cc7c 100644 --- a/main.rb +++ b/main.rb @@ -5,10 +5,16 @@ require 'json' require 'mongoid' require 'dotenv/load' +require 'rack/attack' require_relative 'src/blockchain' +require_relative 'src/validators' +require_relative 'config/rack_attack' Mongoid.load!('./config/mongoid.yml', ENV['ENVIRONMENT'] || :development) +# Enable Rack::Attack middleware (disabled in test environment) +use Rack::Attack unless ENV['ENVIRONMENT'] == 'test' + # Set default content type for JSON responses before do content_type :json if request.post? @@ -33,10 +39,14 @@ post '/chain/:id/block' do block_data = parse_json_body + validation = BlockDataContract.new.call(block_data) + + halt 400, { errors: validation.errors.to_h }.to_json if validation.failure? + chain_id = params[:id] blockchain = find_block_chain(chain_id) - difficulty = validate_difficulty(block_data['difficulty']) - block = blockchain.add_block(block_data['data'], difficulty: difficulty) + difficulty = validation[:difficulty] || 2 + block = blockchain.add_block(validation[:data], difficulty: difficulty) { chain_id: chain_id, @@ -49,13 +59,17 @@ post '/chain/:id/block/:block_id/valid' do block_data = parse_json_body + validation = BlockDataContract.new.call(block_data) + + halt 400, { errors: validation.errors.to_h }.to_json if validation.failure? + chain_id = params[:id] block_id = params[:block_id] blockchain = find_block_chain(chain_id) block = blockchain.blocks.find(block_id) raise 'Block not found' unless block - valid = block.valid_data?(block_data['data']) + valid = block.valid_data?(validation[:data]) { chain_id: chain_id, @@ -99,11 +113,4 @@ def find_block_chain(chain_id) blockchain end - - def validate_difficulty(difficulty) - difficulty = difficulty.nil? ? 2 : difficulty.to_i - halt 422, { error: 'Difficulty must be a positive integer' }.to_json if difficulty <= 0 - halt 422, { error: 'Difficulty must be between 1 and 10' }.to_json if difficulty > 10 - difficulty - end end diff --git a/src/validators.rb b/src/validators.rb new file mode 100644 index 0000000..451d84b --- /dev/null +++ b/src/validators.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'dry-validation' + +# Validator for block data +class BlockDataContract < Dry::Validation::Contract + params do + required(:data).filled(:string) + optional(:difficulty).maybe(:integer) + end + + rule(:difficulty) do + if value + key.failure('must be a positive integer') if value <= 0 + key.failure('must be between 1 and 10') if value > 10 + end + end +end