(.*?)
', 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
Choose a synthetic asset, record the checklist, and save one complete inspection.
<%= h(@error) %>
<% end %> + 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
View up to 25 matching inspections. CSV exports include at most 100 records across 31 calendar days.
<%= h(@error) %>
<% end %> + +No matching records.
<% else %>| Day | Outcome | Note | Record |
|---|---|---|---|
| <%= h(row[:inspected_on].iso8601) %> | <%= h(row[:outcome].capitalize) %> | <%= h(row[:note]) %> | #<%= h(row[:id]) %> |
Saved inspection #<%= h(@inspection[:id]) %>
<%= h(@inspection[:inspected_on].iso8601) %> · <%= h(@inspection[:outcome].capitalize) %>
<%= h(@inspection[:note].empty? ? 'No note provided.' : @inspection[:note]) %>
+Saved <%= h(@inspection[:created_at].getutc.iso8601(6)) %>. Retained request ID: <%= h(@inspection[:request_id]) %>.
+