diff --git a/.github/workflows/inspection-log.yml b/.github/workflows/inspection-log.yml new file mode 100644 index 00000000..7f3db5f6 --- /dev/null +++ b/.github/workflows/inspection-log.yml @@ -0,0 +1,36 @@ +name: Inspection log +on: + push: + paths: ['applications/inspection-log/**', '.github/workflows/inspection-log.yml'] + pull_request: + paths: ['applications/inspection-log/**', '.github/workflows/inspection-log.yml'] +permissions: + contents: read +jobs: + ruby: + runs-on: ubuntu-24.04-arm + defaults: + run: + working-directory: applications/inspection-log + steps: + - uses: actions/checkout@v7.0.1 + - name: Build verified Ruby 4.0.7 + run: | + sudo apt-get update + sudo apt-get install -y build-essential autoconf bison pkg-config libssl-dev libyaml-dev libreadline-dev zlib1g-dev libffi-dev libgmp-dev libpq-dev libncurses-dev libgdbm-dev + curl -fsSLo "$RUNNER_TEMP/ruby.tar.gz" https://cache.ruby-lang.org/pub/ruby/4.0/ruby-4.0.7.tar.gz + echo "911ace20f90d068ca0e4dda6d0e4f0f81e52e52f2dd4f4004c721e253412e82d $RUNNER_TEMP/ruby.tar.gz" | sha256sum --check + tar -xzf "$RUNNER_TEMP/ruby.tar.gz" -C "$RUNNER_TEMP" + cd "$RUNNER_TEMP/ruby-4.0.7" + ./configure --prefix="$RUNNER_TEMP/ruby" --disable-install-doc --disable-yjit --disable-zjit + make -j2 + make install + echo "$RUNNER_TEMP/ruby/bin" >> "$GITHUB_PATH" + - name: Frozen native bundle and input/form/CSV checks without database credentials + env: + BUNDLE_PATH: ${{ runner.temp }}/inspection-bundle + BUNDLE_FROZEN: 'true' + run: | + gem install bundler --version 4.0.22 --no-document + bundle _4.0.22_ install + bin/check diff --git a/applications/inspection-log/.env.example b/applications/inspection-log/.env.example new file mode 100644 index 00000000..3cf301c8 --- /dev/null +++ b/applications/inspection-log/.env.example @@ -0,0 +1,8 @@ +# Runtime-only private file. Use KEY=value lines; launch.py does not run shell syntax. +PGHOST=YOUR_MANAGED_POSTGRES_HOST +PGPORT=5432 +PGDATABASE=postgres +PGSSLROOTCERT=/absolute/path/to/managed-postgres-ca.pem +PGPASSWORD=YOUR_INSPECTION_APP_PASSWORD +SESSION_SECRET=YOUR_PRIVATE_128_CHARACTER_LOWERCASE_HEX_SECRET +TZ=Pacific/Honolulu diff --git a/applications/inspection-log/.gitignore b/applications/inspection-log/.gitignore new file mode 100644 index 00000000..97ca4610 --- /dev/null +++ b/applications/inspection-log/.gitignore @@ -0,0 +1,4 @@ +/.bundle/ +/vendor/ +/.env +/__pycache__/ diff --git a/applications/inspection-log/.ruby-version b/applications/inspection-log/.ruby-version new file mode 100644 index 00000000..43beb400 --- /dev/null +++ b/applications/inspection-log/.ruby-version @@ -0,0 +1 @@ +4.0.7 diff --git a/applications/inspection-log/Gemfile b/applications/inspection-log/Gemfile new file mode 100644 index 00000000..19c32b58 --- /dev/null +++ b/applications/inspection-log/Gemfile @@ -0,0 +1,17 @@ +source 'https://rubygems.org' +ruby '4.0.7' + +gem 'sinatra', '4.2.1' +gem 'sequel', '5.109.0' +gem 'pg', '1.7.0', force_ruby_platform: true +gem 'puma', '8.0.2' +gem 'rack', '3.2.7' +gem 'rack-session', '2.1.2' +gem 'rack-protection', '4.2.1' +gem 'erb', '6.0.7' +gem 'csv', '3.3.6' + +group :test do + gem 'minitest', '6.0.6' + gem 'rack-test', '2.2.0' +end diff --git a/applications/inspection-log/Gemfile.lock b/applications/inspection-log/Gemfile.lock new file mode 100644 index 00000000..eda64c4e --- /dev/null +++ b/applications/inspection-log/Gemfile.lock @@ -0,0 +1,88 @@ +GEM + remote: https://rubygems.org/ + specs: + base64 (0.3.0) + bigdecimal (4.1.3) + csv (3.3.6) + drb (2.2.3) + erb (6.0.7) + logger (1.7.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + mustermann (3.1.1) + nio4r (2.7.5) + pg (1.7.0) + prism (1.9.0) + puma (8.0.2) + nio4r (~> 2.0) + rack (3.2.7) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + sequel (5.109.0) + bigdecimal + sinatra (4.2.1) + logger (>= 1.6.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.2.1) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) + tilt (2.9.0) + +PLATFORMS + aarch64-linux + aarch64-linux-musl + arm64-darwin + ruby + x86_64-darwin + x86_64-linux + x86_64-linux-musl + +DEPENDENCIES + csv (= 3.3.6) + erb (= 6.0.7) + minitest (= 6.0.6) + pg (= 1.7.0) + puma (= 8.0.2) + rack (= 3.2.7) + rack-protection (= 4.2.1) + rack-session (= 2.1.2) + rack-test (= 2.2.0) + sequel (= 5.109.0) + sinatra (= 4.2.1) + +CHECKSUMS + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66 + bundler (4.0.22) sha256=d8d5ec84c8555e0af71db63ed7aee4d1a8fb839ec46d84212d61979242a5d75a + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + pg (1.7.0) sha256=f7b536501284e883d4c9ef86dbde0313063e4aaafc23575b0017b0d572a26afb + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + sequel (5.109.0) sha256=e0d1d474442d620c8497d7f4851c0cc3a277920519051b8014b2877aa55efffe + sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 + tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5 + +RUBY VERSION + ruby 4.0.7 + +BUNDLED WITH + 4.0.22 diff --git a/applications/inspection-log/README.md b/applications/inspection-log/README.md new file mode 100644 index 00000000..b3be7bcd --- /dev/null +++ b/applications/inspection-log/README.md @@ -0,0 +1,188 @@ +# Inspection log + +A small Sinatra workbench for recording complete inspections of three synthetic assets in [ClickHouse Managed Postgres](https://clickhouse.com/docs/products/managed-postgres/overview). Choose an asset, calendar day, outcome, three checklist results and a short note. Save once, view immutable records, filter asset history, and download a bounded CSV. + +This is a trusted **loopback-only** sample. There is no operator identity, authentication or tenant isolation. Cookie sessions contain CSRF state. The shared database role covers every fixture asset; it is trusted server configuration. The app does not integrate with live equipment. + +## Transaction and retry behavior + +[`Store#save`](lib/store.rb) uses one explicit Sequel transaction. It locks the single seeded fixture-budget row, looks up the retained request UUID, then increments the budget and inserts the inspection header plus its three named checklist results. An exact canonical retry returns the original inspection. Reusing the UUID with different content returns 409 and preserves the submitted fields. Replay lookup precedes the 200-record capacity check, so a saved form can still replay at capacity. + +The canonical digest includes asset, Date, outcome, ordered housing/cable/label results and note. UUID spelling is normalized to lowercase. CRLF and CR normalize to LF in the stored note and digest; other note whitespace is retained. CSRF tokens are excluded from the digest. A lost acknowledgment may be retried with the same UUID and fields. Keep that UUID; changing it can create a second record. Validation 422, conflict 409, capacity 409 and database 503 pages retain safe submitted fields. Invalid UTF-8/control text gets 400 without echoing it. + +The budget lock intentionally serializes saves across all assets. It avoids a racing pre-count and is suitable for this small fixture. Header IDs are bounded 1–200 by a database check, request UUIDs are unique, and budget usage cannot exceed 200. A transaction failure restores the budget, header and children together. There is no edit/delete route or expiry of retry keys. + +Calendar days remain Ruby `Date` / PostgreSQL `DATE` and serialize with `iso8601`; they never pass through a midnight timestamp. Supported days are 2000-01-01 through 2100-12-31. History shows at most 25 rows ordered by day descending, then numeric ID descending. CSV includes at most 100 rows ordered by day ascending, then numeric ID ascending, across at most 31 inclusive calendar days. An oversized CSV gets 422 asking for narrower filters, rather than a truncated download. Rows are loaded before CSV generation so a slow download holds no pool checkout. + +Ruby CSV handles commas, quotes and newlines. Text in asset/note cells that starts with a formula-looking prefix (including leading whitespace) or a tab/newline receives a leading apostrophe. This **changes exported text** and reduces a defined formula risk; it is not a guarantee across every spreadsheet/import workflow. Review exports for the intended importer. See [OWASP's CSV injection discussion](https://community.owasp.org/attacks/CSV_Injection). + +## Native Linux setup + +Verified on Ubuntu 24.04 ARM 64 on 2 October 2026: Ruby 4.0.7, Bundler 4.0.22, Sinatra 4.2.1, Sequel 5.109.0, pg 1.7.0 / native libpq 16.15, Puma 8.0.2, Rack 3.2.7, rack-session 2.1.2, rack-protection 4.2.1, ERB 6.0.7 and CSV 3.3.6. The owned Cloud fixture ran PostgreSQL 18.6. `Gemfile.lock` pins transitive releases and gem checksums; only Linux ARM 64 was tested. The pg gem is deliberately compiled from source against libpq. + +Build in a native Linux directory, not a shared host mount: + +```bash +sudo apt-get update +sudo apt-get install -y build-essential autoconf bison pkg-config libssl-dev \ + libyaml-dev libreadline-dev zlib1g-dev libffi-dev libgmp-dev libpq-dev \ + libncurses-dev libgdbm-dev curl ca-certificates postgresql-client python3 jq +mkdir -p "$HOME/toolchains" +cd "$HOME/toolchains" +curl -fsSLo ruby-4.0.7.tar.gz https://cache.ruby-lang.org/pub/ruby/4.0/ruby-4.0.7.tar.gz +echo '911ace20f90d068ca0e4dda6d0e4f0f81e52e52f2dd4f4004c721e253412e82d ruby-4.0.7.tar.gz' | sha256sum --check +tar -xzf ruby-4.0.7.tar.gz +cd ruby-4.0.7 +./configure --prefix="$HOME/.local/ruby-4.0.7" --disable-install-doc --disable-yjit --disable-zjit +make -j2 +make install +export PATH="$HOME/.local/ruby-4.0.7/bin:$PATH" +gem install bundler --version 4.0.22 --no-document +cd /absolute/native/path/to/examples/applications/inspection-log +bundle _4.0.22_ config set --local path "$HOME/.local/inspection-bundle" +bundle _4.0.22_ config set --local frozen true +bundle _4.0.22_ install +bin/check +``` + +The archive checksum comes from the [official Ruby downloads page](https://www.ruby-lang.org/en/downloads/). `bin/check` needs no database credentials: it checks Ruby syntax, compiles ERB in a rendering method context, and runs 5 tests / 50 assertions for validation, raw form boundaries and parsed CSV. CI performs the same native build/frozen install on Ubuntu ARM 64. + +## Create the owned Cloud fixture + +Install and authenticate [clickhousectl](https://clickhouse.com/blog/getting-started-clickhousectl). These commands use its 0.5.0 syntax and an explicit organization. Save create output privately: it returns the administrator password once; subsequent `get` does not. + +```bash +mkdir -p "$HOME/inspection-private" +chmod 700 "$HOME/inspection-private" +export INSPECTION_ORG_ID='YOUR_ORG_ID' +clickhousectl cloud postgres create --json --org-id "$INSPECTION_ORG_ID" \ + --name inspection-log-example --provider aws --region us-east-1 \ + --size c6gd.large --pg-version 18 --ha-type none \ + > "$HOME/inspection-private/create.json" +chmod 600 "$HOME/inspection-private/create.json" +export INSPECTION_PG_ID=$(jq -r '.id' "$HOME/inspection-private/create.json") +clickhousectl cloud postgres get "$INSPECTION_PG_ID" --org-id "$INSPECTION_ORG_ID" +``` + +Repeat `get` until `state` is `running`, then retrieve the Cloud CA: + +```bash +clickhousectl cloud postgres certs get "$INSPECTION_PG_ID" \ + --org-id "$INSPECTION_ORG_ID" --output "$HOME/inspection-private/ca.pem" +``` + +This is a billable no-HA fixture. Delete it after the exercise; stopping the local app does not delete its database. + +## Bootstrap, migrate and seed + +In a setup terminal, export administrator connection fields from the private receipt and generate independent role/session secrets. Do not print those values: + +```bash +export PGHOST=$(jq -r '.hostname' "$HOME/inspection-private/create.json") +export PGPORT=5432 PGDATABASE=postgres +export PGUSER=$(jq -r '.username' "$HOME/inspection-private/create.json") +export PGPASSWORD=$(jq -r '.password' "$HOME/inspection-private/create.json") +export PGSSLMODE=verify-full PGCONNECT_TIMEOUT=5 +export PGSSLROOTCERT="$HOME/inspection-private/ca.pem" +python3 - <<'PY' +from pathlib import Path +import os, secrets +path = Path.home() / 'inspection-private/passwords.env' +path.write_text('MIGRATOR_PASSWORD=Aa1' + secrets.token_hex(24) + '\n' + 'APP_PASSWORD=Aa1' + secrets.token_hex(24) + '\n' + 'SESSION_SECRET=' + secrets.token_hex(64) + '\n') +path.chmod(0o600) +PY +set -a +source "$HOME/inspection-private/passwords.env" +set +a +psql -X -v ON_ERROR_STOP=1 -v migrator_password="$MIGRATOR_PASSWORD" \ + -v app_password="$APP_PASSWORD" -f sql/bootstrap.sql +bin/migrate +psql -X -v ON_ERROR_STOP=1 -f sql/seed.sql +``` + +`bin/migrate` uses the separate `inspection_migrator` login and assumes the non-login `inspection_owner` role on its single connection. It uses the same verified TLS configuration as runtime. Repeat migration stays at version 1; repeat seed creates no extra assets/budget. `bin/migrate 0` drops this app's tables and data; use it only on a disposable fixture before migrating and seeding again. + +The runtime has select on assets/budget/header/children, insert on header/children, and update only on the budget's `used` column. It cannot alter/delete history, edit assets, create schema objects/TEMP tables, or assume the owner role. The three-child completeness invariant belongs to the application transaction; a trusted owner or leaked runtime credential can bypass application validation. There is no row-level tenant boundary. + +Write a runtime-only file from the setup terminal: + +```bash +python3 - <<'PY' +from pathlib import Path +import os +values = {key: os.environ[key] for key in ('PGHOST', 'PGPORT', 'PGDATABASE', 'PGSSLROOTCERT')} +values.update(PGPASSWORD=os.environ['APP_PASSWORD'], SESSION_SECRET=os.environ['SESSION_SECRET'], TZ='Pacific/Honolulu') +path = Path.home() / 'inspection-private/app.env' +path.write_text(''.join(key + '=' + value + '\n' for key, value in values.items())) +path.chmod(0o600) +PY +``` + +## Run the protected form + +Open a new terminal that has not sourced setup credentials. Set the Ruby path, change to the app directory and start with the runtime file: + +```bash +export PATH="$HOME/.local/ruby-4.0.7/bin:$PATH" +cd /absolute/native/path/to/examples/applications/inspection-log +python3 checks/launch.py "$HOME/inspection-private/app.env" +``` + +The launcher passes only runtime fields and basic process settings to Puma; inherited administrator/migrator variables are excluded. Visit `http://127.0.0.1:9292/`, choose an asset/day/checklist, save, then follow **View asset history** and **Download CSV**. + +Puma binds 127.0.0.1 with 4 threads and no worker processes. Sequel permits 4 connections, a 3-second pool-acquisition wait and 5-second connection timeout. Each connection sets 8-second statement,3-second lock and 10-second idle-transaction timeouts. These are layer limits, not an end-to-end HTTP deadline. Sequel's [pg_auto_parameterize extension](https://sequel.jeremyevans.net/rdoc-plugins/files/lib/sequel/extensions/pg_auto_parameterize_rb.html) binds values in runtime dataset SQL. Fixed setup SQL contains no untrusted fragments. + +Sequel's pg adapter uses `sslmode: verify-full` and the downloaded `sslrootcert`; there is no insecure fallback. [Sequel documents adapter/pool configuration](https://sequel.jeremyevans.net/rdoc/files/doc/opening_databases_rdoc.html) and [transaction behavior](https://sequel.jeremyevans.net/rdoc/files/doc/transactions_rdoc.html). + +The app keeps Sinatra protection enabled and uses Rack's masked authenticity tokens with a private cookie-session secret. Exact allowed Host values are localhost/127.0.0.1; POST requires the matching HTTP loopback Origin. Cookies are HttpOnly/SameSite Strict and deliberately not Secure on local HTTP. This configuration is not a public deployment recipe. URL-encoded saves are capped at 32 KiB before parsing; duplicate decoded field names return 400. Empty `&` separators are ignored. Unknown fields/enums, dates, checklist results and note bounds are validated before SQL. ERB escapes displayed asset/note/field text. + +## Reproduce Cloud acceptance + +The `checks/` helpers use a disposable Cloud fixture and create synthetic data. They do not need CI credentials. Install browser tooling separately from the app bundle in the VM: + +```bash +python3 -m venv "$HOME/inspection-private/browser" +"$HOME/inspection-private/browser/bin/pip" install playwright==1.63.0 +"$HOME/inspection-private/browser/bin/python" -m playwright install --with-deps chromium +export EVIDENCE_DIR="$HOME/inspection-private" +"$HOME/inspection-private/browser/bin/python" checks/browser.py +``` + +Run the browser helper exactly once on the empty seeded fixture: it creates record 1, checks escaping/non-UTC DATE and parses CSV. Then, in a separate **test setup** terminal, explicitly load administrator PG* fields as above and export `APP_PASSWORD` from `passwords.env`. With the app still running: + +```bash +set -a +source "$HOME/inspection-private/passwords.env" +set +a +python3 checks/cloud.py +``` + +The helper checks native middleware, two independent session retries, conflicting fields, an owner-installed second-child fault with cleanup in `finally`, runtime grant/check failures and last-slot contention. It deliberately fills the fixture to 200 records. Its final CSV/history assertions can also run read-only as `python3 checks/cloud.py --reports-only`; this does not reset or rerun mutations. + +For the genuine process restart, record the **actual Puma PID** in a private file (a background `python3 checks/launch.py ...` retains its PID through exec). Keep the admin/test variables in the test terminal to verify the launcher excludes them: + +```bash +export RUNTIME_ENV="$HOME/inspection-private/app.env" +export SERVER_PID_FILE="$HOME/inspection-private/server.pid" +export EVIDENCE_DIR="$HOME/inspection-private" +python3 checks/security_restart.py +"$HOME/inspection-private/browser/bin/python" checks/mobile.py +``` + +`security_restart.py` checks the actual `Database.connect` path against the correct endpoint, an untrusted CA and an IP/name mismatch. It signals only the recorded Puma PID, asserts that process has disappeared before starting the replacement, then compares persisted asset/note/day/time/request fields and exact CSV. It also replays record 1's uppercase UUID at capacity. The helpers expect the stated browser fixture; keep failed attempts distinct from passing results. + +Observed on 2 October 2026: fresh frozen bundle;5 tests / 50 assertions; migration 1/repeat 1/down 0/up 1 and repeated seed; actual browser desktop/mobile; protected HTTP boundaries; concurrent retained requests with one debit; post-header/first-child rollback; cap 200 with 600 children; CSV overflow 422 / exact 100 rows; history 25 numeric ordering; actual runtime 42501/23514/23505; CA/name negatives through Sequel; real Puma 18797→19032 with original gone, exact persisted content/CSV and retained replay. These are bounded correctness checks, not a load benchmark. + +## Cleanup + +Stop the recorded Puma process, then delete only the service you created: + +```bash +kill "$(cat "$HOME/inspection-private/server.pid")" +clickhousectl cloud postgres delete "$INSPECTION_PG_ID" --org-id "$INSPECTION_ORG_ID" +clickhousectl cloud postgres list --org-id "$INSPECTION_ORG_ID" +``` + +Confirm its ID is absent, allowing for asynchronous deletion. Postgres deletion accepts a running service and has no `--force` flag. It removes the fixture permanently. Remove private credential/receipt files when no longer needed. The review run's owned service was deleted after acceptance. diff --git a/applications/inspection-log/app.rb b/applications/inspection-log/app.rb new file mode 100644 index 00000000..6c22262a --- /dev/null +++ b/applications/inspection-log/app.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true +require 'sinatra/base' +require 'rack/protection/authenticity_token' +require 'securerandom' +require 'time' +require 'sequel' +require_relative 'lib/body_limit' +require_relative 'lib/inputs' +require_relative 'lib/report' + +module InspectionLog + class App < Sinatra::Base + secret = ENV.fetch('SESSION_SECRET') + raise ArgumentError, 'Configure a private 128-character hex session secret' unless secret.match?(/\A[0-9a-f]{128}\z/) + set :environment, :production + set :sessions, {secret: secret, key: 'inspection.session', httponly: true, + same_site: :strict, secure: false, expire_after: 3600} + set :host_authorization, {permitted_hosts: ['localhost', '127.0.0.1']} + set :show_exceptions, false + set :raise_errors, false + set :logging, true + set :static, true + set :public_folder, File.expand_path('public', __dir__) + set :views, File.expand_path('views', __dir__) + set :store, nil + use Rack::Protection::AuthenticityToken + + helpers do + def h(value) = Rack::Utils.escape_html(value.to_s) + def csrf = Rack::Protection::AuthenticityToken.token(session) + def selected(actual, expected) = actual.to_s == expected.to_s ? 'selected' : '' + def store = settings.store + def assets = (@assets ||= store.assets) + def defaults + {'request_id' => SecureRandom.uuid, 'asset_id' => '1', 'inspected_on' => Date.today.iso8601, + 'outcome' => 'pass', 'housing' => 'ok', 'cable' => 'ok', 'label' => 'ok', 'note' => ''} + end + def default_filter + {'asset_id' => '1', 'from' => (Date.today - 30).iso8601, 'to' => Date.today.iso8601, 'outcome' => ''} + end + def form_error(status_code, message) + @error = message + status status_code + erb :form + end + end + + before do + headers 'Cache-Control' => 'no-store' + halt 400, 'Query is too large' if request.query_string.bytesize > 2048 + if request.request_method == 'POST' + allowed = ['http://127.0.0.1:9292', 'http://localhost:9292'] + halt 403, 'Origin not permitted' unless allowed.include?(request.env['HTTP_ORIGIN']) + halt 400, 'Query parameters are not accepted on save' unless request.query_string.empty? + end + end + + get '/' do + @fields = defaults + assets + erb :form + end + + post '/inspections' do + @fields = request.POST + # Never echo invalid byte sequences or controls into an error page. + begin + @fields.each_value { |value| Inputs.text(value, BodyLimit::MAX_BYTES) } + rescue InvalidInput + halt 400, 'Malformed form text' + end + assets + begin + id = store.save(Inputs.form(@fields)) + redirect "/inspections/#{id}", 303 + rescue InvalidInput => error + form_error(422, error.message) + rescue Conflict => error + form_error(409, error.message) + rescue CapacityReached => error + form_error(409, error.message) + rescue Sequel::DatabaseError => error + warn "Inspection save failed: #{error.class}: #{error.message}" + form_error(503, 'Save could not be confirmed. Keep this request ID and retry the same fields.') + end + end + + get '/inspections/:id' do + id = Inputs.integer(params.fetch('id'), 200) + @inspection = store.inspection(id) + halt 404, 'Inspection not found' unless @inspection + @asset = assets.find { |asset| asset[:id] == @inspection[:asset_id] } + erb :inspection + rescue InvalidInput => error + halt 400, h(error.message) + end + + get '/history' do + @filter_fields = request.GET.empty? ? default_filter : request.GET + assets + begin + @history = store.history(Inputs.filters(@filter_fields)) + rescue InvalidInput => error + @history = [] + @error = error.message + status 422 + end + erb :history + end + + get '/report.csv' do + @filter_fields = request.GET + assets + begin + rows = store.export_rows(Inputs.filters(@filter_fields)) + content_type 'text/csv', charset: 'utf-8' + attachment 'synthetic-inspections.csv' + Report.csv(rows) + rescue InvalidInput, ExportTooLarge => error + @error = error.message + @history = [] + status 422 + erb :history + end + end + + error do + warn "Inspection request failed: #{env['sinatra.error'].class}" + content_type 'text/plain', charset: 'utf-8' + status 503 + 'Records are temporarily unavailable. Try again after checking the service.' + end + end +end diff --git a/applications/inspection-log/bin/check b/applications/inspection-log/bin/check new file mode 100755 index 00000000..3d1df9d2 --- /dev/null +++ b/applications/inspection-log/bin/check @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +bundle _4.0.22_ check +while IFS= read -r file; do bundle _4.0.22_ exec ruby -c "$file"; done < <(find lib db test -name '*.rb' -print) +bundle _4.0.22_ exec ruby -c app.rb +bundle _4.0.22_ exec ruby -c config.ru +bundle _4.0.22_ exec ruby -c puma.rb +bundle _4.0.22_ exec ruby -c bin/migrate +bundle _4.0.22_ exec ruby -Itest -e 'Dir.glob("test/*_test.rb").sort.each { |file| require_relative file }' +bundle _4.0.22_ exec ruby -rerb -e 'Dir.glob("views/*.erb").sort.each { |file| RubyVM::InstructionSequence.compile("def render_template\n#{ERB.new(File.read(file)).src}\nend") }; puts "ERB templates compile"' diff --git a/applications/inspection-log/bin/migrate b/applications/inspection-log/bin/migrate new file mode 100755 index 00000000..58610590 --- /dev/null +++ b/applications/inspection-log/bin/migrate @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +require 'sequel' +require 'sequel/extensions/migration' +require_relative '../lib/database' + +raise ArgumentError, 'Use no argument to migrate, or exactly 0 to reset this disposable schema' unless ARGV.empty? || ARGV == ['0'] +db = InspectionLog::Database.connect(migration: true) +begin + options = ARGV.empty? ? {} : {target: 0} + Sequel::Migrator.run(db, File.expand_path('../db/migrations', __dir__), **options) + puts "Sequel migration version: #{db[:schema_info].get(:version)}" +ensure + db.disconnect +end diff --git a/applications/inspection-log/checks/browser.py b/applications/inspection-log/checks/browser.py new file mode 100644 index 00000000..62893f87 --- /dev/null +++ b/applications/inspection-log/checks/browser.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Browser flow on the real loopback application; Playwright lives outside the app bundle.""" +import csv, io, os +from pathlib import Path +from playwright.sync_api import sync_playwright + +out=Path(os.environ['EVIDENCE_DIR']);out.mkdir(parents=True,exist_ok=True) +note=' =1+1,"quoted"\n' +with sync_playwright() as p: + browser=p.chromium.launch(headless=True) + context=browser.new_context(viewport={'width':1100,'height':900},timezone_id='Pacific/Honolulu',accept_downloads=True) + page=context.new_page();page.goto('http://127.0.0.1:9292/') + page.locator('select[name=asset_id]').select_option('1') + page.locator('input[name=inspected_on]').fill('2026-01-01') + page.locator('select[name=outcome]').select_option('watch') + page.locator('select[name=cable]').select_option('issue') + page.locator('textarea[name=note]').fill(note) + request_id=page.locator('input[name=request_id]').input_value() + with page.expect_navigation():page.get_by_role('button',name='Save inspection',exact=True).click() + assert '/inspections/' in page.url + assert page.locator('h1').inner_text()=='Synthetic bench ' + assert '2026-01-01' in page.locator('.intro').inner_text() + assert page.locator('p.note').inner_text()==note + assert page.evaluate('window.__inspectionInjected') is None + assert request_id in page.locator('main').inner_text() + page.screenshot(path=str(out/'browser-record.png'),full_page=True) + page.get_by_role('link',name='View asset history').click() + assert page.locator('tbody tr').count()==1 + page.screenshot(path=str(out/'browser-history.png'),full_page=True) + with page.expect_download() as waiting:page.get_by_role('button',name='Download CSV',exact=True).click() + download=waiting.value;data=Path(download.path()).read_text() + rows=list(csv.DictReader(io.StringIO(data))) + assert len(rows)==1 and rows[0]['inspected_on']=='2026-01-01' + assert rows[0]['note']=="'"+note and rows[0]['asset']=='Synthetic bench ' + assert rows[0]['cable']=='issue' + (out/'browser-export.csv').write_text(data) + print('Real browser: protected form→saved inspection→history→parsed CSV passed') + print('Server/browser Pacific/Honolulu calendar day2026-01-01 stayed exact; escaped asset/note did not execute; quote/newline/formula prefix checked') + context.close();browser.close() diff --git a/applications/inspection-log/checks/cloud.py b/applications/inspection-log/checks/cloud.py new file mode 100644 index 00000000..d7f22077 --- /dev/null +++ b/applications/inspection-log/checks/cloud.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Synthetic acceptance against the real loopback app and its owned Cloud fixture. + +Run in the VM with administrator PG* and APP_PASSWORD exported. This helper +never resets a fixture: it expects the browser's single saved record first. +""" +import concurrent.futures +import csv +import html +import io +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +import http.cookiejar +import uuid + +BASE = 'http://127.0.0.1:9292' + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + +class Session: + def __init__(self): + self.client = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()), NoRedirect()) + status, _, body = self.get('/') + assert status == 200 + self.fields = dict(re.findall(r']*name="([^"]+)"[^>]*value="([^"]*)"', body)) + self.fields = {key: html.unescape(value) for key, value in self.fields.items()} + self.fields.update(asset_id='1', inspected_on='2026-01-01', outcome='pass', + housing='ok', cable='issue', label='ok', note='Synthetic operator note') + + def request(self, path, data=None, headers=None): + req = urllib.request.Request(BASE + path, data=data, headers=headers or {}) + try: + response = self.client.open(req, timeout=20) + except urllib.error.HTTPError as error: + response = error + with response: + return response.status, response.headers, response.read().decode('utf-8') + + def get(self, path): + return self.request(path) + + def post(self, fields=None, raw=None, origin=BASE): + headers = {'Content-Type': 'application/x-www-form-urlencoded', 'Origin': origin} + data = raw if raw is not None else urllib.parse.urlencode(fields or self.fields).encode() + return self.request('/inspections', data, headers) + + +def sql(statement, runtime=False, expected=None): + env = dict(os.environ) + if runtime: + env.update(PGUSER='inspection_app', PGPASSWORD=os.environ['APP_PASSWORD']) + command = ['psql', '-X', '-v', 'ON_ERROR_STOP=1', '-At'] + result = subprocess.run(command, input='\\set VERBOSITY verbose\n' + statement, + text=True, capture_output=True, env=env, timeout=25) + if expected: + assert result.returncode != 0 and expected in result.stderr, result.stderr + print(f'Actual runtime SQL rejection: {expected}') + return + assert result.returncode == 0, result.stderr.replace(os.environ['PGHOST'], '[private endpoint]') + return result.stdout.strip() + + +def owner(statement): + return sql('SET ROLE inspection_owner;\n' + statement) + + +def counts(): + return sql("SELECT (SELECT used FROM inspection_api.fixture_budget)," + "(SELECT count(*) FROM inspection_api.inspections)," + "(SELECT count(*) FROM inspection_api.inspection_results);") + + +def preserved(body, fields): + # Browser evidence checks selected controls; here verify the exact retained + # request and note on each server-side error response. + assert f'value="{html.escape(fields["request_id"], quote=True)}"' in body + note = re.search(r']*>(.*?)', body, re.S) + assert note and html.unescape(note.group(1)) == fields['note'] + + +def report(asset=1, outcome=''): + query = urllib.parse.urlencode(dict(asset_id=asset, **{'from':'2026-01-01', 'to':'2026-01-02'}, outcome=outcome)) + return Session().get('/report.csv?' + query) + + +if '--reports-only' not in sys.argv: + assert counts() == '1|1|3', 'Run this once after the browser creates the first record' + original = sql('SELECT request_id FROM inspection_api.inspections WHERE id=1;') + assert sql("SELECT inspected_on::text FROM inspection_api.inspections WHERE id=1;") == '2026-01-01' + print('Browser fixture SQL DATE stayed exactly 2026-01-01 in non-UTC runtime') + + # Native middleware controls through the real Puma listener. + s = Session() + missing = dict(s.fields); missing.pop('authenticity_token') + assert s.post(missing)[0] == 403 + assert Session().post(origin='https://foreign.invalid')[0] == 403 + assert Session().request('/', headers={'Host':'foreign.invalid'})[0] == 403 + s = Session() + raw = urllib.parse.urlencode(s.fields).encode() + assert s.post(raw=raw + b'&%6Eote=duplicate')[0] == 400 + assert s.post(raw=b'%ZZ=invalid')[0] == 400 + assert s.post(raw=b'note=' + b'x' * 32768)[0] == 413 + assert s.request('/inspections', b'{}', {'Content-Type':'application/json', 'Origin':BASE})[0] == 415 + assert s.post(raw=raw + b'&unknown=value')[0] == 422 + assert s.post(raw=raw + b'&')[0] == 303 # Standard empty separator ignored. + invalid = dict(s.fields, request_id=str(uuid.uuid4()), inspected_on='1999-12-31', note='Fields remain here') + status, _, body = s.post(invalid) + assert status == 422; preserved(body, invalid) + assert s.post(raw=raw.replace(b'Synthetic+operator+note', b'%FF'))[0] == 400 + print('Real HTTP: missing CSRF/foreign Origin/Host403, duplicate/malformed/UTF-8 form400, body413, type415, fields422; empty separator accepted') + + # Independent cookie/CSRF sessions submit the same UUID concurrently. + a, b = Session(), Session() + retained_id = str(uuid.uuid4()) + for client in (a, b): + client.fields.update(request_id=retained_id, note='Concurrent retained form', outcome='watch') + before = counts() + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda client: client.post(), [a, b])) + assert [item[0] for item in results] == [303, 303] + locations = [item[1]['Location'] for item in results] + assert locations[0] == locations[1] + assert counts() == '|'.join(str(int(n)+delta) for n, delta in zip(before.split('|'), [1, 1, 3])) + changed = dict(a.fields, note='Changed submitted text ') + status, _, body = a.post(changed) + assert status == 409; preserved(body, changed) + assert sql(f"SELECT count(*) FROM inspection_api.inspections WHERE request_id='{retained_id}'") == '1' + print('Concurrent retained UUID: two303 responses, same original inspection, one header/three children/one budget debit; changed content409 with fields retained') + + # Owner-only fault proves that the earlier writes reached the database, then + # forces failure on the second child. Remove the fault even if an assertion fails. + owner(""" + CREATE FUNCTION inspection_api.fail_second_child() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.check_name='cable' THEN + IF NOT EXISTS (SELECT 1 FROM inspection_api.inspections WHERE id=NEW.inspection_id) + OR NOT EXISTS (SELECT 1 FROM inspection_api.inspection_results + WHERE inspection_id=NEW.inspection_id AND check_name='housing') THEN + RAISE EXCEPTION 'Fault fixture did not observe earlier writes'; + END IF; + RAISE EXCEPTION 'Synthetic second-child fault after header and housing'; + END IF; + RETURN NEW; + END $$; + CREATE TRIGGER fault_second_child BEFORE INSERT ON inspection_api.inspection_results + FOR EACH ROW EXECUTE FUNCTION inspection_api.fail_second_child(); + """) + c = Session(); c.fields.update(note='Retain this entire failed form', request_id=str(uuid.uuid4())) + before = counts() + try: + status, _, body = c.post() + assert status == 503; preserved(body, c.fields) + assert counts() == before + assert sql(f"SELECT count(*) FROM inspection_api.inspections WHERE request_id='{c.fields['request_id']}'") == '0' + finally: + owner('DROP TRIGGER fault_second_child ON inspection_api.inspection_results; DROP FUNCTION inspection_api.fail_second_child();') + assert c.post()[0] == 303 + assert c.post()[0] == 303 + assert counts() == '|'.join(str(int(n)+delta) for n, delta in zip(before.split('|'), [1, 1, 3])) + print('Actual second-child failure503 after header/first child: budget/header/children all rolled back; unchanged retained form retried once successfully') + + # Direct runtime SQL cannot alter history, schema, or seeded assets. + for statement in [ + "UPDATE inspection_api.inspections SET note='changed' WHERE id=1;", + 'DELETE FROM inspection_api.inspections WHERE id=1;', + "UPDATE inspection_api.inspection_results SET result='issue' WHERE inspection_id=1;", + "UPDATE inspection_api.assets SET name='changed' WHERE id=1;", + 'CREATE TABLE inspection_api.forbidden(id integer);', + 'CREATE TEMP TABLE forbidden(id integer);', + 'SET ROLE inspection_owner;', + ]: + sql(statement, runtime=True, expected='42501') + sql('UPDATE inspection_api.fixture_budget SET used=201 WHERE id=1;', runtime=True, expected='23514') + sql("INSERT INTO inspection_api.inspections(id,request_id,digest,asset_id,inspected_on,outcome,note) " + f"VALUES(201,'{uuid.uuid4()}','{'0'*64}',1,'2026-01-01','pass','');", runtime=True, expected='23514') + sql("INSERT INTO inspection_api.inspections(id,request_id,digest,asset_id,inspected_on,outcome,note) " + f"VALUES(199,'{original}','{'0'*64}',1,'2026-01-01','pass','');", runtime=True, expected='23505') + + # Deliberately fill a synthetic owned fixture to one slot below its cap. + # This is an acceptance fixture, not an application/admin HTTP route. + owner(""" + BEGIN; + INSERT INTO inspection_api.inspections(id,request_id,digest,asset_id,inspected_on,outcome,note) + SELECT n,gen_random_uuid(),repeat('0',64),2,DATE '2026-01-02', + CASE WHEN n <= (SELECT used+100 FROM inspection_api.fixture_budget WHERE id=1) THEN 'watch' ELSE 'fail' END, + 'Synthetic cap fixture' + FROM generate_series((SELECT used+1 FROM inspection_api.fixture_budget WHERE id=1),199) n; + INSERT INTO inspection_api.inspection_results(inspection_id,check_name,result) + SELECT i.id,c,'ok' FROM inspection_api.inspections i CROSS JOIN unnest(ARRAY['housing','cable','label']) c + WHERE i.asset_id=2; + UPDATE inspection_api.fixture_budget SET used=199 WHERE id=1; + COMMIT; + """) + assert counts() == '199|199|597' + x, y = Session(), Session() + for client in (x, y): + client.fields.update(asset_id='3', note='Last fixture slot') + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda client: client.post(), [x, y])) + assert sorted(item[0] for item in results) == [303, 409] + assert counts() == '200|200|600' + assert a.post()[0] == 303 # The replay lookup deliberately precedes capacity. + assert counts() == '200|200|600' + print('Actual last-slot contention303/409: hard200 cap,600 children, no partial loser; retained exact retry still succeeds at cap') + +status, _, body = report(2) +assert status == 422 and 'More than 100 rows match' in body +status, headers, body = report(2, 'watch') +rows = list(csv.DictReader(io.StringIO(body))) +assert status == 200 and len(rows) == 100 +ids = [int(row['inspection_id']) for row in rows] +assert ids == sorted(ids) and 9 in ids and 10 in ids +assert all(row['inspected_on'] == '2026-01-02' for row in rows) +assert headers['Content-Disposition'].startswith('attachment;') +query = urllib.parse.urlencode({'asset_id':2, 'from':'2026-01-01','to':'2026-01-02','outcome':''}) +status, _, body = Session().get('/history?' + query) +links = [int(n) for n in re.findall(r'href="/inspections/(\d+)"', body)] +assert status == 200 and len(links) == 25 and links == list(range(199,174,-1)) +print('CSV101+ rejected422; exact100-row CSV has numeric9→10 order and Date output; bounded history25 has numeric199→175 order') +print('Focused bounded report controls passed on the immutable200-record fixture') diff --git a/applications/inspection-log/checks/launch.py b/applications/inspection-log/checks/launch.py new file mode 100644 index 00000000..9058d354 --- /dev/null +++ b/applications/inspection-log/checks/launch.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Start the loopback runtime with only its required environment. + +Use KEY=value lines (no shell syntax) in the private runtime file. Setup +credentials inherited by this helper are excluded from the Puma process. +""" +import os +from pathlib import Path +import sys + +ALLOWED = {'PGHOST', 'PGPORT', 'PGDATABASE', 'PGSSLROOTCERT', 'PGPASSWORD', 'SESSION_SECRET', 'TZ'} +values = {} +for line in Path(sys.argv[1]).read_text().splitlines(): + if not line or line.startswith('#'): + continue + key, value = line.split('=', 1) + if key in ALLOWED: + if key in values: + raise SystemExit('Duplicate runtime setting') + values[key] = value +for key in ('PGHOST', 'PGSSLROOTCERT', 'PGPASSWORD', 'SESSION_SECRET'): + if not values.get(key): + raise SystemExit('Missing runtime setting: ' + key) +env = {key: os.environ[key] for key in ('PATH', 'HOME', 'LANG') if key in os.environ} +env.update(values) +os.chdir(Path(__file__).resolve().parents[1]) +os.execvpe('bundle', ['bundle', 'exec', 'puma', '-C', 'puma.rb'], env) diff --git a/applications/inspection-log/checks/mobile.py b/applications/inspection-log/checks/mobile.py new file mode 100644 index 00000000..752269ab --- /dev/null +++ b/applications/inspection-log/checks/mobile.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Responsive width check after the Cloud acceptance fixture reaches its cap.""" +import os +from pathlib import Path +from playwright.sync_api import sync_playwright + +out = Path(os.environ['EVIDENCE_DIR']) +with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={'width':375, 'height':812}, timezone_id='Pacific/Honolulu') + page.goto('http://127.0.0.1:9292/inspections/1') + assert page.locator('p.note').inner_text().startswith(' =1+1,') + assert 'Retained request ID:' in page.locator('.hint').inner_text() + assert page.evaluate('document.documentElement.scrollWidth <= window.innerWidth') + page.screenshot(path=str(out/'browser-mobile-record.png'), full_page=True) + page.goto('http://127.0.0.1:9292/') + note = 'Long unbroken note ' + 'x'*580 + page.locator('textarea[name=note]').fill(note) + request_id = page.locator('input[name=request_id]').input_value() + with page.expect_response(lambda response: response.url.endswith('/inspections')) as waiting: + page.get_by_role('button', name='Save inspection', exact=True).click() + assert waiting.value.status == 409 + assert page.locator('textarea[name=note]').input_value() == note + assert page.locator('input[name=request_id]').input_value() == request_id + assert page.evaluate('document.documentElement.scrollWidth <= window.innerWidth') + page.screenshot(path=str(out/'browser-mobile-preserved-form.png'), full_page=True) + print('Real375px browser: saved long note/request UUID and599-character retained cap-error form fit viewport; no horizontal overflow') + browser.close() diff --git a/applications/inspection-log/checks/security_restart.py b/applications/inspection-log/checks/security_restart.py new file mode 100644 index 00000000..4e19bc36 --- /dev/null +++ b/applications/inspection-log/checks/security_restart.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Real adapter TLS/permissions and actual Puma exit/restart on the owned fixture. + +VM-only helper: RUNTIME_ENV, SERVER_PID_FILE and EVIDENCE_DIR are required. +Administrator PG* may be inherited for test work; launch.py strips them before +exec. This does not install packages or alter the database fixture. +""" +import csv +import html +import io +import json +import os +from pathlib import Path +import re +import signal +import socket +import subprocess +import time +import urllib.error +import urllib.parse +import urllib.request +import http.cookiejar + +APP = Path(__file__).resolve().parents[1] +OUT = Path(os.environ['EVIDENCE_DIR']) +RUNTIME = Path(os.environ['RUNTIME_ENV']) +PID_FILE = Path(os.environ['SERVER_PID_FILE']) +settings = dict(line.split('=', 1) for line in RUNTIME.read_text().splitlines() if line and not line.startswith('#')) +positive = dict(os.environ, **settings) + +# The same Database.connect path used by config.ru configures libpq verify-full. +probe = "require './lib/database'; db=InspectionLog::Database.connect; p db.fetch('SELECT current_user').first; db.disconnect" +command = ['bundle', 'exec', 'ruby', '-e', probe] +result = subprocess.run(command, cwd=APP, env=positive, capture_output=True, text=True, timeout=15) +assert result.returncode == 0, result.stderr.replace(settings['PGHOST'], '[private endpoint]') +print('Actual Sequel Database.connect same-endpoint positive: inspection_app, verify-full') +for label, overrides, expected in [ + ('untrusted system CA', {'PGSSLROOTCERT':'/etc/ssl/certs/ca-certificates.crt'}, 'certificate verify failed'), + ('hostname mismatch', {'PGHOST':socket.getaddrinfo(settings['PGHOST'], 5432, socket.AF_INET)[0][4][0]}, 'does not match host name'), +]: + env = dict(positive, **overrides) + result = subprocess.run(command, cwd=APP, env=env, capture_output=True, text=True, timeout=15) + diagnostic = result.stderr + assert result.returncode != 0 and expected in diagnostic, diagnostic.replace(settings['PGHOST'], '[private endpoint]') + assert 'PG::ConnectionBad' in diagnostic + # Preserve precise failure class/message, excluding private endpoint/IP. + for value in (settings['PGHOST'], overrides.get('PGHOST', '')): + if value: + diagnostic = diagnostic.replace(value, '[private endpoint]') + (OUT / ('tls-' + label.replace(' ', '-') + '.txt')).write_text(diagnostic) + print(f'Actual Sequel TLS negative: {label}: PG::ConnectionBad / {expected}') + +BASE = 'http://127.0.0.1:9292' +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as response: + assert response.status == 200 + return response.read().decode() + +def content(body): + # Persisted content, excluding layout changes and freshly generated CSRF state. + return (html.unescape(re.search(r'

(.*?)

', body, re.S).group(1)), + html.unescape(re.search(r'

(.*?)

', body, re.S).group(1)), + html.unescape(re.search(r'

Saved (.*?)

', body, re.S).group(1)), + re.search(r'\d{4}-\d{2}-\d{2}', body).group()) + +query = urllib.parse.urlencode({'asset_id':1, 'from':'2026-01-01', 'to':'2026-01-01', 'outcome':''}) +before_content = content(get('/inspections/1')) +before_csv = get('/report.csv?' + query) +assert list(csv.DictReader(io.StringIO(before_csv)))[0]['inspected_on'] == '2026-01-01' +first_pid = int(PID_FILE.read_text()) +os.kill(first_pid, signal.SIGTERM) +for _ in range(100): + try: + os.kill(first_pid, 0) + except ProcessLookupError: + break + time.sleep(.1) +else: + raise AssertionError('Original Puma did not exit; replacement will not start') +assert not Path(f'/proc/{first_pid}').exists() +with (OUT / 'server-restarted.log').open('w') as log: + process = subprocess.Popen([sys_executable := '/usr/bin/python3', str(APP/'checks/launch.py'), str(RUNTIME)], + cwd=APP, env=os.environ, stdout=log, stderr=log, start_new_session=True) +PID_FILE.write_text(str(process.pid) + '\n') +for _ in range(100): + assert process.poll() is None, 'Replacement exited' + try: + get('/') + break + except Exception: + time.sleep(.1) +else: + raise AssertionError('Replacement not ready') +assert process.pid != first_pid +assert content(get('/inspections/1')) == before_content +assert get('/report.csv?' + query) == before_csv +runtime_names = {part.split(b'=', 1)[0].decode() for part in Path(f'/proc/{process.pid}/environ').read_bytes().split(b'\0') if part} +assert not runtime_names & {'APP_PASSWORD','MIGRATOR_PASSWORD','PGUSER','CLICKHOUSE_API_KEY','CLICKHOUSE_API_SECRET'} +assert runtime_names <= {'PATH','HOME','LANG','PGHOST','PGPORT','PGDATABASE','PGSSLROOTCERT','PGPASSWORD','SESSION_SECRET','TZ'} +print(f'Actual Puma process {first_pid} exited before {process.pid} started; saved asset/note/day/time/request and exact CSV persisted') +print('Replacement runtime environment names: ' + ', '.join(sorted(runtime_names))) + +# A new cookie/CSRF session can replay the browser's original retained request. +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None +client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()), NoRedirect()) +with client.open(BASE + '/') as response: + body = response.read().decode() +token = html.unescape(re.search(r'name="authenticity_token" value="([^"]*)"', body).group(1)) +request_id = re.search(r'Retained request ID: ([0-9a-f-]+)', before_content[2]).group(1) +fields = dict(authenticity_token=token, request_id=request_id.upper(), asset_id='1', inspected_on='2026-01-01', + outcome='watch', housing='ok', cable='issue', label='ok', note=before_content[1]) +request = urllib.request.Request(BASE+'/inspections', data=urllib.parse.urlencode(fields).encode(), + headers={'Content-Type':'application/x-www-form-urlencoded', 'Origin':BASE}) +try: + response = client.open(request) +except urllib.error.HTTPError as error: + response = error +with response: + assert response.status == 303 and response.headers['Location'].endswith('/inspections/1') +assert get('/report.csv?' + query) == before_csv +print('Retained exact form replay (uppercase UUID) after actual process restart returned original inspection1 at full cap') +(OUT/'restart.json').write_text(json.dumps({'original_pid':first_pid,'replacement_pid':process.pid,'original_gone':True, + 'content_equal':True,'csv_equal':True,'retained_replay':303}, indent=2)+'\n') diff --git a/applications/inspection-log/config.ru b/applications/inspection-log/config.ru new file mode 100644 index 00000000..293ed68c --- /dev/null +++ b/applications/inspection-log/config.ru @@ -0,0 +1,9 @@ +# frozen_string_literal: true +require_relative 'lib/body_limit' +require_relative 'lib/database' +require_relative 'lib/store' +require_relative 'app' + +InspectionLog::App.set :store, InspectionLog::Store.new(InspectionLog::Database.connect) +use InspectionLog::BodyLimit +run InspectionLog::App diff --git a/applications/inspection-log/db/migrations/001_inspections.rb b/applications/inspection-log/db/migrations/001_inspections.rb new file mode 100644 index 00000000..4ace41d6 --- /dev/null +++ b/applications/inspection-log/db/migrations/001_inspections.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +Sequel.migration do + up do + run <<~SQL + CREATE TABLE inspection_api.assets ( + id integer PRIMARY KEY CHECK (id BETWEEN 1 AND 3), + name text NOT NULL CHECK (length(name) BETWEEN 1 AND 120) + ); + CREATE TABLE inspection_api.fixture_budget ( + id integer PRIMARY KEY CHECK (id = 1), + used integer NOT NULL CHECK (used BETWEEN 0 AND 200) + ); + CREATE TABLE inspection_api.inspections ( + id integer PRIMARY KEY CHECK (id BETWEEN 1 AND 200), + request_id uuid NOT NULL UNIQUE, + digest text NOT NULL CHECK (digest ~ '^[0-9a-f]{64}$'), + asset_id integer NOT NULL REFERENCES inspection_api.assets(id), + inspected_on date NOT NULL CHECK (inspected_on BETWEEN DATE '2000-01-01' AND DATE '2100-12-31'), + outcome text NOT NULL CHECK (outcome IN ('pass', 'watch', 'fail')), + note text NOT NULL CHECK (length(note) <= 600), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE inspection_api.inspection_results ( + inspection_id integer NOT NULL REFERENCES inspection_api.inspections(id), + check_name text NOT NULL CHECK (check_name IN ('housing', 'cable', 'label')), + result text NOT NULL CHECK (result IN ('ok', 'issue')), + PRIMARY KEY (inspection_id, check_name) + ); + CREATE INDEX inspection_history ON inspection_api.inspections(asset_id, inspected_on DESC, id DESC); + GRANT SELECT ON inspection_api.assets, inspection_api.fixture_budget, + inspection_api.inspections, inspection_api.inspection_results TO inspection_app; + GRANT INSERT ON inspection_api.inspections, inspection_api.inspection_results TO inspection_app; + GRANT UPDATE (used) ON inspection_api.fixture_budget TO inspection_app; + SQL + end + + down do + drop_table Sequel.qualify(:inspection_api, :inspection_results) + drop_table Sequel.qualify(:inspection_api, :inspections) + drop_table Sequel.qualify(:inspection_api, :fixture_budget) + drop_table Sequel.qualify(:inspection_api, :assets) + end +end diff --git a/applications/inspection-log/lib/body_limit.rb b/applications/inspection-log/lib/body_limit.rb new file mode 100644 index 00000000..2566f6cf --- /dev/null +++ b/applications/inspection-log/lib/body_limit.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +require 'stringio' +require 'uri' + +module InspectionLog + class BodyLimit + MAX_BYTES = 32_768 + def initialize(app) = @app = app + def call(env) + return @app.call(env) unless env['REQUEST_METHOD'] == 'POST' + return rejection(415, 'Use a URL-encoded form') unless env['CONTENT_TYPE'].to_s.split(';').first == 'application/x-www-form-urlencoded' + bytes = env['rack.input'].read(MAX_BYTES + 1) + return rejection(413, 'Form is too large') if bytes.bytesize > MAX_BYTES + # Empty separators follow Rack's ordinary form parsing and are ignored. + keys = bytes.split('&').reject(&:empty?).map do |pair| + key = URI.decode_www_form_component(pair.split('=', 2).first, Encoding::UTF_8) + raise ArgumentError unless key.valid_encoding? + key + end + return rejection(400, 'Duplicate form fields are not accepted') unless keys.uniq.length == keys.length + env['rack.input'] = StringIO.new(bytes) + @app.call(env) + rescue ArgumentError + rejection(400, 'Malformed form encoding') + end + private + def rejection(status, message) = [status, {'content-type' => 'text/plain; charset=utf-8'}, [message]] + end +end diff --git a/applications/inspection-log/lib/database.rb b/applications/inspection-log/lib/database.rb new file mode 100644 index 00000000..74ed16dd --- /dev/null +++ b/applications/inspection-log/lib/database.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +require 'sequel' +require 'pg' + +module InspectionLog + module Database + module_function + + def connect(migration: false) + host = ENV.fetch('PGHOST') + raise ArgumentError, 'Use one DNS hostname or IPv4 address' unless host.match?(/\A[A-Za-z0-9.-]+\z/) + port = Integer(ENV.fetch('PGPORT', '5432'), 10) + raise ArgumentError, 'Invalid port' unless (1..65_535).cover?(port) + user = migration ? 'inspection_migrator' : 'inspection_app' + password = ENV.fetch(migration ? 'MIGRATOR_PASSWORD' : 'PGPASSWORD') + Sequel.default_timezone = :utc + db = Sequel.connect(adapter: 'postgres', host: host, port: port, + database: ENV.fetch('PGDATABASE', 'postgres'), user: user, password: password, + sslmode: 'verify-full', sslrootcert: ENV.fetch('PGSSLROOTCERT'), + connect_timeout: 5, max_connections: migration ? 1 : 4, pool_timeout: 3, + after_connect: lambda { |connection| + connection.exec('SET ROLE inspection_owner') if migration + connection.exec('SET search_path TO inspection_api, pg_catalog') + connection.exec("SET statement_timeout TO '#{migration ? 15 : 8}s'") + connection.exec("SET lock_timeout TO '3s'") + connection.exec("SET idle_in_transaction_session_timeout TO '10s'") + }) + db.extension(:pg_auto_parameterize) unless migration + db + end + end +end diff --git a/applications/inspection-log/lib/inputs.rb b/applications/inspection-log/lib/inputs.rb new file mode 100644 index 00000000..6b7926cf --- /dev/null +++ b/applications/inspection-log/lib/inputs.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true +require 'date' +require 'digest' +require 'json' + +module InspectionLog + class InvalidInput < StandardError; end + class Conflict < StandardError; end + class CapacityReached < StandardError; end + class ExportTooLarge < StandardError; end + + module Inputs + CHECKS = %w[housing cable label].freeze + OUTCOMES = %w[pass watch fail].freeze + RESULTS = %w[ok issue].freeze + MIN_DAY = Date.new(2000, 1, 1) + MAX_DAY = Date.new(2100, 12, 31) + FORM_FIELDS = %w[authenticity_token request_id asset_id inspected_on outcome housing cable label note].freeze + FILTER_FIELDS = %w[asset_id from to outcome].freeze + + module_function + + def text(value, maximum) + raise InvalidInput, 'Expected text' unless value.is_a?(String) + value = value.dup.force_encoding(Encoding::UTF_8) + raise InvalidInput, 'Text is not valid UTF-8' unless value.valid_encoding? + raise InvalidInput, 'Text is too long' if value.length > maximum + raise InvalidInput, 'Text contains unsupported controls' if value.match?(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/) + value + end + + def integer(value, maximum) + raise InvalidInput, 'Expected a bounded positive integer' unless value.is_a?(String) && value.match?(/\A[1-9][0-9]{0,2}\z/) + parsed = Integer(value, 10) + raise InvalidInput, 'Identifier is outside the supported range' if parsed > maximum + parsed + end + + def uuid(value) + raise InvalidInput, 'A canonical request UUID is required' unless value.is_a?(String) && value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i) + value.downcase + end + + def day(value) + raise InvalidInput, 'Use a YYYY-MM-DD calendar day' unless value.is_a?(String) && value.match?(/\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/) + parsed = Date.iso8601(value) + raise InvalidInput, 'Supported calendar days are 2000–2100' unless (MIN_DAY..MAX_DAY).cover?(parsed) + parsed + rescue Date::Error + raise InvalidInput, 'Invalid calendar day' + end + + def fields(input, allowed) + raise InvalidInput, 'Unknown form field' unless input.is_a?(Hash) && (input.keys - allowed).empty? + input.each_value { |value| text(value, 2_000) } + end + + def form(input) + fields(input, FORM_FIELDS) + request_id = uuid(input['request_id']) + asset_id = integer(input['asset_id'], 3) + inspected_on = day(input['inspected_on']) + outcome = input['outcome'] + raise InvalidInput, 'Choose an inspection outcome' unless OUTCOMES.include?(outcome) + checklist = CHECKS.to_h do |name| + result = input[name] + raise InvalidInput, 'Complete every checklist result' unless RESULTS.include?(result) + [name, result] + end + note = text(input.fetch('note', ''), 600).gsub(/\r\n?/, "\n") + canonical = JSON.generate([asset_id, inspected_on.iso8601, outcome, CHECKS.map { |name| [name, checklist.fetch(name)] }, note]) + {request_id: request_id, asset_id: asset_id, inspected_on: inspected_on, + outcome: outcome, checklist: checklist, note: note, digest: Digest::SHA256.hexdigest(canonical)} + end + + def filters(input) + fields(input, FILTER_FIELDS) + asset_id = integer(input['asset_id'], 3) + first = day(input['from']) + last = day(input['to']) + raise InvalidInput, 'Choose at most 31 calendar days in order' unless (0..30).cover?((last - first).to_i) + outcome = input.fetch('outcome', '') + raise InvalidInput, 'Unknown outcome filter' unless outcome.empty? || OUTCOMES.include?(outcome) + {asset_id: asset_id, first: first, last: last, outcome: outcome} + end + end +end diff --git a/applications/inspection-log/lib/report.rb b/applications/inspection-log/lib/report.rb new file mode 100644 index 00000000..07d29d4e --- /dev/null +++ b/applications/inspection-log/lib/report.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +require 'csv' +require 'time' +require_relative 'inputs' + +module InspectionLog + module Report + module_function + + def spreadsheet_text(value) + # Apostrophe changes exported text. This is a bounded mitigation, not a universal importer guarantee. + value.match?(/\A(?:[[:space:]]*[=+\-@]|[\t\r\n])/) ? "'#{value}" : value + end + + def csv(rows) + CSV.generate(row_sep: "\r\n") do |output| + output << %w[inspection_id asset inspected_on outcome housing cable label note created_at_utc] + rows.each do |row| + output << [row.fetch(:id), spreadsheet_text(row.fetch(:asset_name)), row.fetch(:inspected_on).iso8601, + row.fetch(:outcome), *Inputs::CHECKS.map { |name| row.fetch(:checklist).fetch(name) }, + spreadsheet_text(row.fetch(:note)), row.fetch(:created_at).getutc.iso8601(6)] + end + end + end + end +end diff --git a/applications/inspection-log/lib/store.rb b/applications/inspection-log/lib/store.rb new file mode 100644 index 00000000..b5285122 --- /dev/null +++ b/applications/inspection-log/lib/store.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +require_relative 'inputs' + +module InspectionLog + class Store + CAPACITY = 200 + HISTORY_LIMIT = 25 + EXPORT_LIMIT = 100 + + def initialize(db) + @db = db + @assets = db[Sequel.qualify(:inspection_api, :assets)] + @budget = db[Sequel.qualify(:inspection_api, :fixture_budget)] + @inspections = db[Sequel.qualify(:inspection_api, :inspections)] + @results = db[Sequel.qualify(:inspection_api, :inspection_results)] + end + + def assets = @assets.order(:id).all + + def save(input) + @db.transaction do + budget = @budget.where(id: 1).for_update.first + raise 'Missing seeded fixture budget' unless budget + retained = @inspections.where(request_id: input.fetch(:request_id)).first + if retained + raise Conflict, 'Request ID already belongs to different content' unless retained[:digest] == input.fetch(:digest) + next retained[:id] + end + raise CapacityReached, 'This sample fixture has reached 200 inspections' if budget[:used] >= CAPACITY + raise InvalidInput, 'Unknown synthetic asset' unless @assets.where(id: input.fetch(:asset_id)).first + slot = budget[:used] + 1 + @budget.where(id: 1).update(used: slot) + @inspections.insert(id: slot, request_id: input.fetch(:request_id), digest: input.fetch(:digest), + asset_id: input.fetch(:asset_id), inspected_on: input.fetch(:inspected_on), + outcome: input.fetch(:outcome), note: input.fetch(:note)) + Inputs::CHECKS.each do |name| + @results.insert(inspection_id: slot, check_name: name, result: input.fetch(:checklist).fetch(name)) + end + slot + end + end + + def inspection(id) + header = @inspections.where(id: id).first + return nil unless header + header.merge(checklist: @results.where(inspection_id: id).order(:check_name).to_hash(:check_name, :result)) + end + + def filtered(filter) + query = @inspections.where(asset_id: filter.fetch(:asset_id), inspected_on: filter.fetch(:first)..filter.fetch(:last)) + filter.fetch(:outcome).empty? ? query : query.where(outcome: filter.fetch(:outcome)) + end + + def history(filter) + filtered(filter).order(Sequel.desc(:inspected_on), Sequel.desc(:id)).limit(HISTORY_LIMIT).all + end + + def export_rows(filter) + headers = filtered(filter).order(:inspected_on, :id).limit(EXPORT_LIMIT + 1).all + raise ExportTooLarge, 'More than 100 rows match; narrow the date or outcome filter' if headers.length > EXPORT_LIMIT + return [] if headers.empty? + # Each dataset call releases its pool checkout before CSV generation or response delivery. + checks = @results.where(inspection_id: headers.map { |row| row[:id] }).all.group_by { |row| row[:inspection_id] } + names = assets.to_h { |asset| [asset[:id], asset[:name]] } + headers.map do |header| + header.merge(asset_name: names.fetch(header[:asset_id]), + checklist: checks.fetch(header[:id]).to_h { |row| [row[:check_name], row[:result]] }) + end + end + end +end diff --git a/applications/inspection-log/public/style.css b/applications/inspection-log/public/style.css new file mode 100644 index 00000000..3cea66e3 --- /dev/null +++ b/applications/inspection-log/public/style.css @@ -0,0 +1 @@ +*{box-sizing:border-box}body{margin:0;background:#f5f6f1;color:#20332b;font:16px/1.55 system-ui,sans-serif}header{display:flex;justify-content:space-between;align-items:center;max-width:1040px;margin:auto;padding:24px 32px;border-bottom:1px solid #d4ddd5}a{color:#245b40;text-decoration:none}a:hover{text-decoration:underline}.brand{font-size:21px;font-weight:750}nav{display:flex;gap:24px}main{max-width:920px;margin:36px auto;padding:0 24px}.intro{margin-bottom:26px}.eyebrow{color:#4d7761;text-transform:uppercase;letter-spacing:.13em;font-size:12px;font-weight:700}h1{font-size:34px;line-height:1.2;margin:10px 0 14px}h2{font-size:20px}.panel{padding:28px;background:#fff;border:1px solid #d4ddd5;border-radius:14px;margin:20px 0;box-shadow:0 4px 16px #20332b06}.grid,.checks{display:grid;gap:20px;grid-template-columns:repeat(2,minmax(0,1fr))}.checks{grid-template-columns:repeat(3,minmax(0,1fr))}label{display:block;font-weight:650;margin-bottom:20px}input,select,textarea{display:block;width:100%;margin-top:8px;padding:11px 12px;border:1px solid #b9c8be;border-radius:7px;background:white;color:inherit;font:inherit}textarea{resize:vertical}fieldset{border:1px solid #d4ddd5;border-radius:9px;margin:8px 0 24px;padding:18px}legend{padding:0 8px;font-weight:700}.actions{display:flex;gap:18px;align-items:center;flex-wrap:wrap}button,.button{display:inline-block;background:#285f43;color:#fff;border:0;border-radius:8px;padding:12px 18px;font:inherit;font-weight:650;cursor:pointer}.secondary{background:#e5eee7;color:#245b40}.hint{overflow-wrap:anywhere;display:block;color:#617269;font-size:13px;font-weight:400;margin-top:8px}.notice{background:#fff3df;border-left:4px solid #bd7c19;padding:16px;border-radius:6px}.badge{background:#e5eee7;border-radius:6px;padding:3px 9px}.results{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.results dt{font-weight:700}.results dd{margin:5px 0}.note{white-space:pre-wrap;overflow-wrap:anywhere}table{border-collapse:collapse;width:100%;font-size:14px}th,td{text-align:left;border-bottom:1px solid #e0e6e1;padding:13px 8px;vertical-align:top}footer{max-width:920px;margin:30px auto;padding:0 24px 35px;color:#617269;font-size:13px}.filters{display:grid;grid-template-columns:repeat(2,1fr);gap:0 20px}.filters .actions,.filters .hint{grid-column:1/-1}@media(max-width:600px){header{padding:18px;align-items:flex-start;gap:12px}nav{gap:12px;flex-wrap:wrap}main{margin-top:25px;padding:0 16px}.panel{padding:20px}.grid,.checks,.filters{grid-template-columns:1fr}h1{font-size:28px}} diff --git a/applications/inspection-log/puma.rb b/applications/inspection-log/puma.rb new file mode 100644 index 00000000..5b1a9f34 --- /dev/null +++ b/applications/inspection-log/puma.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +bind 'tcp://127.0.0.1:9292' +threads 4, 4 +workers 0 +environment 'production' +rackup File.expand_path('config.ru', __dir__) diff --git a/applications/inspection-log/sql/bootstrap.sql b/applications/inspection-log/sql/bootstrap.sql new file mode 100644 index 00000000..ac991e81 --- /dev/null +++ b/applications/inspection-log/sql/bootstrap.sql @@ -0,0 +1,18 @@ +-- Cloud administrator; password variables supplied by private setup shell. +SELECT 'CREATE ROLE inspection_owner NOLOGIN' +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'inspection_owner') \gexec +SELECT 'CREATE ROLE inspection_migrator LOGIN NOINHERIT' +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'inspection_migrator') \gexec +SELECT 'CREATE ROLE inspection_app LOGIN NOINHERIT' +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'inspection_app') \gexec +ALTER ROLE inspection_migrator PASSWORD :'migrator_password'; +ALTER ROLE inspection_app PASSWORD :'app_password'; +GRANT inspection_owner TO inspection_migrator; +CREATE SCHEMA IF NOT EXISTS inspection_api AUTHORIZATION inspection_owner; +REVOKE ALL ON SCHEMA inspection_api FROM PUBLIC; +GRANT USAGE ON SCHEMA inspection_api TO inspection_app; +SELECT format('REVOKE TEMPORARY ON DATABASE %I FROM PUBLIC', current_database()) \gexec +ALTER ROLE inspection_app SET search_path TO inspection_api, pg_catalog; +ALTER ROLE inspection_app SET statement_timeout TO '8s'; +ALTER ROLE inspection_app SET lock_timeout TO '3s'; +ALTER ROLE inspection_app SET idle_in_transaction_session_timeout TO '10s'; diff --git a/applications/inspection-log/sql/seed.sql b/applications/inspection-log/sql/seed.sql new file mode 100644 index 00000000..78ff98b6 --- /dev/null +++ b/applications/inspection-log/sql/seed.sql @@ -0,0 +1,8 @@ +SET ROLE inspection_owner; +INSERT INTO inspection_api.assets(id, name) VALUES + (1, 'Synthetic bench
'), + (2, 'Synthetic trolley'), + (3, 'Synthetic cabinet') +ON CONFLICT (id) DO NOTHING; +INSERT INTO inspection_api.fixture_budget(id, used) VALUES(1, 0) +ON CONFLICT (id) DO NOTHING; diff --git a/applications/inspection-log/test/body_limit_test.rb b/applications/inspection-log/test/body_limit_test.rb new file mode 100644 index 00000000..d574f387 --- /dev/null +++ b/applications/inspection-log/test/body_limit_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +require 'minitest/autorun' +require 'rack/mock' +require_relative '../lib/body_limit' + +class BodyLimitTest < Minitest::Test + def middleware + InspectionLog::BodyLimit.new(->(env) { [200, {'content-type'=>'text/plain'}, [env['rack.input'].read]] }) + end + def test_duplicate_and_oversized_forms_fail_before_downstream_parser + client = Rack::MockRequest.new(middleware) + assert_equal 400, client.post('/', 'CONTENT_TYPE'=>'application/x-www-form-urlencoded', input:'note=one&%6Eote=two').status + assert_equal 413, client.post('/', 'CONTENT_TYPE'=>'application/x-www-form-urlencoded', input:'x' * 32_769).status + assert_equal 200, client.post('/', 'CONTENT_TYPE'=>'application/x-www-form-urlencoded', input:'a=1&&b=2').status + assert_equal 415, client.post('/', 'CONTENT_TYPE'=>'application/json', input:'{}').status + assert_equal 400, client.post('/', 'CONTENT_TYPE'=>'application/x-www-form-urlencoded', input:'bad%=1').status + response=client.post('/', 'CONTENT_TYPE'=>'application/x-www-form-urlencoded', input:'note=one%26two') + assert_equal 200, response.status + assert_equal 'note=one%26two', response.body + end +end diff --git a/applications/inspection-log/test/inputs_test.rb b/applications/inspection-log/test/inputs_test.rb new file mode 100644 index 00000000..c4f12078 --- /dev/null +++ b/applications/inspection-log/test/inputs_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +require 'minitest/autorun' +require_relative '../lib/inputs' + +class InputsTest < Minitest::Test + def form + {'request_id' => 'abcdef00-0000-0000-0000-000000000001', 'asset_id' => '1', + 'inspected_on' => '2026-10-02', 'outcome' => 'watch', 'housing' => 'ok', + 'cable' => 'issue', 'label' => 'ok', 'note' => 'Synthetic note'} + end + + def test_exact_canonical_retry_and_calendar_date + input = InspectionLog::Inputs.form(form) + assert_instance_of Date, input[:inspected_on] + assert_equal '2026-10-02', input[:inspected_on].iso8601 + assert_equal input[:digest], InspectionLog::Inputs.form(form.merge('request_id' => form['request_id'].upcase))[:digest] + assert_equal InspectionLog::Inputs.form(form.merge('note' => "line\r\nnext"))[:digest], + InspectionLog::Inputs.form(form.merge('note' => "line\nnext"))[:digest] + refute_equal input[:digest], InspectionLog::Inputs.form(form.merge('label' => 'issue'))[:digest] + refute_equal input[:digest], InspectionLog::Inputs.form(form.merge('note' => 'Synthetic note '))[:digest] + end + + def test_rejects_unknown_fields_membership_and_malformed_text + [{ 'owner' => 'forged' }, { 'asset_id' => '01' }, { 'asset_id' => '9' }, + { 'asset_id' => '9' * 1000 }, { 'housing' => 'skip' }, { 'outcome' => 'unknown' }, + { 'request_id' => '1-1-1-1-1' }, { 'note' => "x\u0000" }, { 'note' => "x\u0085" }, + { 'note' => 'x' * 601 }, { 'note' => "\xff".b }, { 'note' => [] }].each do |changed| + assert_raises(InspectionLog::InvalidInput) { InspectionLog::Inputs.form(form.merge(changed)) } + end + assert_raises(InspectionLog::InvalidInput) { InspectionLog::Inputs.uuid(nil) } + assert_equal '', InspectionLog::Inputs.form(form.merge('note' => ''))[:note] + end + + def test_date_and_range_boundaries + ['2000-01-01', '2100-12-31'].each { |day| assert_equal day, InspectionLog::Inputs.day(day).iso8601 } + ['1999-12-31', '2101-01-01', '2026-02-30', '2026-2-1'].each do |day| + assert_raises(InspectionLog::InvalidInput) { InspectionLog::Inputs.day(day) } + end + filter = {'asset_id' => '1', 'from' => '2026-10-01', 'to' => '2026-10-31', 'outcome' => ''} + assert_equal 30, (InspectionLog::Inputs.filters(filter)[:last] - InspectionLog::Inputs.filters(filter)[:first]).to_i + assert_raises(InspectionLog::InvalidInput) { InspectionLog::Inputs.filters(filter.merge('to' => '2026-11-01')) } + assert_raises(InspectionLog::InvalidInput) { InspectionLog::Inputs.filters(filter.merge('to' => '2026-09-30')) } + end +end diff --git a/applications/inspection-log/test/report_test.rb b/applications/inspection-log/test/report_test.rb new file mode 100644 index 00000000..6c87e4fd --- /dev/null +++ b/applications/inspection-log/test/report_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +require 'minitest/autorun' +require_relative '../lib/report' + +class ReportTest < Minitest::Test + def test_csv_quotes_multiline_text_and_mitigates_formula_prefixes + ['=1+1', '+1', '-1', '@SUM(A1)', "\t=1+1", ' =1+1', "\n=1+1"].each do |text| + assert_equal "'#{text}", InspectionLog::Report.spreadsheet_text(text) + end + note = " =1+1,\"quoted\"\nsecond line" + row = {id: 1, asset_name: 'Synthetic, bench', inspected_on: Date.new(2026,10,2), + outcome: 'watch', checklist: {'housing'=>'ok','cable'=>'issue','label'=>'ok'}, + note: note, created_at: Time.iso8601('2026-10-02T09:30:00.123456Z')} + parsed = CSV.parse(InspectionLog::Report.csv([row]), headers: true) + assert_equal 1, parsed.length + assert_equal 'Synthetic, bench', parsed[0]['asset'] + assert_equal "'#{note}", parsed[0]['note'] + assert_equal '2026-10-02', parsed[0]['inspected_on'] + assert_equal '2026-10-02T09:30:00.123456Z', parsed[0]['created_at_utc'] + assert_equal 'issue', parsed[0]['cable'] + assert_equal 'normal text', InspectionLog::Report.spreadsheet_text('normal text') + end +end diff --git a/applications/inspection-log/views/form.erb b/applications/inspection-log/views/form.erb new file mode 100644 index 00000000..254c2ab4 --- /dev/null +++ b/applications/inspection-log/views/form.erb @@ -0,0 +1,12 @@ +

Local operator workspace

Record an inspection

Choose a synthetic asset, record the checklist, and save one complete inspection.

+<% if @error %><% end %> +
+ + +
+
+ +
Checklist
<% InspectionLog::Inputs::CHECKS.each do |name| %><% end %>
+ +
Saved records can't be edited.
+
diff --git a/applications/inspection-log/views/history.erb b/applications/inspection-log/views/history.erb new file mode 100644 index 00000000..78c27b13 --- /dev/null +++ b/applications/inspection-log/views/history.erb @@ -0,0 +1,11 @@ +

Retained records

History & export

View up to 25 matching inspections. CSV exports include at most 100 records across 31 calendar days.

+<% if @error %><% end %> +
+ + + + +
+

Spreadsheet-looking text gets a leading apostrophe in CSV. Review exported text for your import workflow.

+
+

Matching inspections

<% if @history.empty? %>

No matching records.

<% else %><% @history.each do |row| %><% end %>
DayOutcomeNoteRecord
<%= h(row[:inspected_on].iso8601) %><%= h(row[:outcome].capitalize) %><%= h(row[:note]) %>#<%= h(row[:id]) %>
<% end %>
diff --git a/applications/inspection-log/views/inspection.erb b/applications/inspection-log/views/inspection.erb new file mode 100644 index 00000000..35060400 --- /dev/null +++ b/applications/inspection-log/views/inspection.erb @@ -0,0 +1,5 @@ +

Saved inspection #<%= h(@inspection[:id]) %>

<%= h(@asset[:name]) %>

<%= h(@inspection[:inspected_on].iso8601) %> · <%= h(@inspection[:outcome].capitalize) %>

+

Checklist results

<% InspectionLog::Inputs::CHECKS.each do |name| %>
<%= h(name.capitalize) %>
<%= h(@inspection[:checklist].fetch(name)) %>
<% end %>
+

Note

<%= h(@inspection[:note].empty? ? 'No note provided.' : @inspection[:note]) %>

+

Saved <%= h(@inspection[:created_at].getutc.iso8601(6)) %>. Retained request ID: <%= h(@inspection[:request_id]) %>.

+
Record anotherView asset history
diff --git a/applications/inspection-log/views/layout.erb b/applications/inspection-log/views/layout.erb new file mode 100644 index 00000000..f48f17eb --- /dev/null +++ b/applications/inspection-log/views/layout.erb @@ -0,0 +1,9 @@ + + +Inspection log + +
Inspection log
+
<%= yield %>
+ + +