Skip to content

Modern Fullstack Developer Test — Roster (user management) - #33

Open
antonioneto1 wants to merge 33 commits into
umanni:masterfrom
antonioneto1:feature/user-management-test
Open

Modern Fullstack Developer Test — Roster (user management)#33
antonioneto1 wants to merge 33 commits into
umanni:masterfrom
antonioneto1:feature/user-management-test

Conversation

@antonioneto1

Copy link
Copy Markdown

Roster — a Rails 8 monolith for managing user accounts. Hotwire on the front, PostgreSQL underneath, no Redis anywhere.

AI Usage Disclosure

This project was developed with assistance from Claude Opus 5 (claude-opus-5), used through Claude Code. It helped plan the architecture, generate and refactor parts of the implementation, review tests, and improve documentation; everything was reviewed, executed, tested and validated before inclusion. The full disclosure is at the top of the README — including a note about the HTML comment in the original README addressed to AI assistants, which was read and not followed.

What is here

  • Rails 8.1.3.1 on Ruby 4.0.6, PostgreSQL 17, Hotwire (Turbo 8 / Stimulus), Tailwind 4, Propshaft and importmap
  • The built-in Rails 8 authentication, adapted; explicit authorization; the last administrator protected in the model under a row lock, so the rule holds for the screens, the API, the console and the import alike
  • Administrative CRUD with search, role filter and pagination; live dashboard counters over Solid Cable; CSV/XLSX import on Solid Queue with live progress, a rejected-rows report and an invitation email for every account it creates
  • An audit trail of administrative actions at /admin/activity
  • A JSON API at /api/v1 whose OpenAPI document is generated from the specs that exercise it — Swagger UI at /api-docs
  • Multi-stage Dockerfile behind Thruster, Kamal 2 deploy.yml, and a four-service Compose stack where the worker really is its own process
  • Interface in English, Portuguese and Spanish

Verification

bin/ci runs RuboCop, Brakeman, bundler-audit, the importmap audit, the suite, a second pass that proves websocket delivery in a real browser, and a check that the committed OpenAPI document still matches the specs.

  • 253 examples, 0 failures; 98.26% line and 90.30% branch coverage, enforced by the suite rather than reported
  • Parallel run across four workers, each with its own databases
  • Accessibility checked with axe at WCAG 2.1 AA on eleven screens; the layout checked at 360 CSS pixels
  • Security covered by vector — SQLi, XSS, CSRF, mass assignment, IDOR, privilege escalation, file upload, SSRF, CSV injection, oversized input, cookie flags, log leakage, the last-administrator race, account enumeration

Running it

devops/app/setup.sh   # .env, images, the four databases, the demonstration accounts
devops/app/start.sh   # web, worker, Tailwind watcher and PostgreSQL

Setup without Docker, the spreadsheet format and its rules, the demonstration accounts, the architecture decisions and their trade-offs, the search and ZJIT measurements, browser and accessibility notes, and the known limitations are all in the README.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W

antonioneto1 and others added 30 commits September 3, 2026 14:12
Generate the Rails 8.1 application on Ruby 4.0 and wire up a Docker-first
development environment.

Infrastructure notes:

- The Dockerfile keeps the Rails 8 production defaults (multi-stage build,
  non-root user, jemalloc, assets precompiled with SECRET_KEY_BASE_DUMMY,
  Thruster as the web entrypoint) and adds a healthcheck plus a development
  stage carrying the build tools and headless Chromium the system specs need.

- Development and test mirror the production database topology: Solid Cache,
  Solid Queue and Solid Cable each get their own database in every
  environment. The generators only configure production, which leaves
  development running the queue in-process and Action Cable on the async
  adapter -- a setup that appears to work only because a single process is
  doing everything. Compose therefore runs the worker and the Tailwind watcher
  as separate services.

- Test databases are suffixed with TEST_ENV_NUMBER so the suite can run across
  parallel workers.

- The omakase RuboCop preset is replaced by an explicit, stricter rule set
  covering Rails, RSpec, Capybara, FactoryBot and performance cops. Every
  relaxation in .rubocop.yml carries its reason.

Host-side entrypoints live in bin/ (setup, dev, test, lint, ci) and per-service
operational wrappers in devops/, both driving Docker. config/ci.rb is the
single definition of the verification pipeline.

Verified: the stack boots, /up returns 200, Solid Queue registers its processes
in the dedicated queue database, RuboCop is clean and Brakeman, Bundler Audit
and the importmap audit report no findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi
Set up the RSpec harness and drive the User model out with it, then adopt the
Rails 8 authentication generator underneath.

- SimpleCov starts before the application loads and gates the suite at 90%
  line / 80% branch coverage, merging results across parallel workers so the
  gate measures the whole suite rather than one shard.

- Cuprite drives the Chromium in the development image over CDP for system
  specs; plain requests stay on rack_test.

- The users table carries full_name, role and avatar_url alongside the columns
  the generator needs. Case-insensitive email uniqueness is enforced by a
  unique index on lower(email_address), so the rule holds for writes that skip
  validation, and a check constraint keeps the role column inside the enum.

- Flash and mailer texts produced by the generator moved into config/locales,
  which is where the strict RuboCop configuration expects them.

Verified: 13 examples, 0 failures; RuboCop clean. The coverage gate currently
reports below its minimum because only the model is covered so far -- that is
the gate working, and it will be met as the suite grows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi
Drive the visitor and user journeys out of request specs, then add the
authorization layer the admin area needs.

- Authorization is a small explicit concern rather than a gem: two roles and a
  handful of rules do not justify a policy framework. A signed-in non-admin
  reaching the admin area is told plainly and sent to their profile.

- Registration never accepts a role. The permitted parameters simply do not
  include it, so the column default assigns the role and there is no path from
  the public form to an administrator. The same holds for profile updates: a
  user cannot promote themselves.

- after_authentication_url now falls back to the role: administrators land on
  the dashboard, everyone else on their own profile. A remembered page still
  wins, because returning the user there is the point of storing it.

- ProfilesController never reads an identifier from the request; it always acts
  on Current.user, so there is no id to tamper with.

The visual language is ported from the Onix design system as a token layer plus
semantic component classes, using its "verde" accent over the "porcelana" and
"marfim" light shells instead of its dark-and-gold default. Contrast ratios are
documented in tokens.css; the reduced-motion preference is respected.

Verified: 33 examples, 0 failures; RuboCop clean across 61 files. Coverage is
at 77.6% line / 61.5% branch and still under the gate, which stays enforced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi
Ship the interface in English, Brazilian Portuguese and Spanish, with the
choice belonging to the person rather than the browser.

- The locale lives on users.locale: one column, defaulted to "en", validated
  against User::SUPPORTED_LOCALES and held to the same list by a check
  constraint. A separate settings table would be ceremony for a single field.

- Localization resolves the locale in a deliberate order: the signed-in user's
  saved choice, then a visitor's session choice, then Accept-Language, then the
  default. That means someone who picks a language before signing up keeps it
  through registration, and it follows them to any browser once saved.

- The picker is a row of flags in the top bar for signed-in users and under the
  wordmark for visitors. The flags are inline SVG, so there are no image
  requests and they scale cleanly; each button carries the language name as its
  accessible name and aria-pressed marks the active one, because a flag alone
  says nothing to a screen reader.

- rails-i18n supplies the date formats and Active Record error messages for the
  two added languages, which would otherwise have stayed English inside
  otherwise translated pages.

All three locale files carry exactly the same 66 keys, and fallbacks are on, so
a missing translation renders English rather than a raw key.

Verified: 42 examples, 0 failures; RuboCop clean across 65 files. Accept-Language
negotiation and the html lang attribute confirmed for all three locales.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi
The administrative CRUD, driven out of request specs.

- The last administrator cannot be deleted or demoted, and that rule lives on
  the model rather than in a controller so it holds for every path into the
  data: the admin screens, the console, a seed, the import still to come. The
  check selects the remaining administrator rows FOR UPDATE, so two concurrent
  demotions cannot each see the other as the one still standing. PostgreSQL
  refuses to lock an aggregate, hence selecting ids rather than counting.

- Search and role filter are scopes on User rather than a query object: two
  scopes is not enough surface to justify the indirection. The search term is
  bound as a parameter and passed through sanitize_sql_like, so neither SQL nor
  LIKE wildcards can be smuggled in; a spec fires a DROP TABLE at it.

- An unrecognised role filter is ignored rather than erroring, and per_page is
  clamped to 100 so a hand-edited URL cannot ask for the whole table.

- Administrators may set roles, unlike the public form. An empty password field
  on the edit form means "leave it alone", not "set it to nothing".

- Filter state lives in the query string, so a filtered list is a shareable URL
  and the back button behaves. The table scrolls inside its own container so
  the page never scrolls sideways.

Seeds are idempotent and refuse to invent an administrator password in
production, where SEED_ADMIN_PASSWORD has to be supplied.

Verified: 60 examples, 0 failures; RuboCop clean across 68 files. Coverage is
84.8% line / 81.3% branch, so the branch gate is met and the line gate is not
yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi
The dashboard totals update without a reload when somebody is added, removed
or re-roled.

- UserCounters is the single place that knows how the numbers are computed and
  broadcast, driven by one after_commit hook on User rather than a broadcast
  call in every controller action that happens to change a role. A change that
  cannot move the numbers -- renaming somebody -- broadcasts nothing.

- There is one stream per locale. The broadcast carries rendered HTML with
  translated labels, so a single shared stream would push one language to every
  administrator watching.

- Subscribing is authorised in its own right. The generated connection already
  refuses anyone without a session; AdminCountersChannel additionally refuses
  anyone signed in who is not an administrator, so a leaked stream name is not
  enough by itself. Channel specs cover the administrator, the regular user and
  the anonymous case.

- UserCounters.suspend_broadcasts exists for the spreadsheet import still to
  come, which would otherwise broadcast once per imported row.

Also fixes a real defect found while testing by hand: config/cache.yml only
pointed Solid Cache at the cache database in production, so signing in raised
PG::UndefinedTable for solid_cache_entries in development. The generators wire
only production for all three Solid adapters; queue and cable were already
corrected, this completes the set.

System specs now drive a real browser. Rails' driven_by re-registers the
Cuprite driver and discards Capybara.register_driver, so the options travel
through driven_by instead, and Chromium gets the flags that stop it spending
its startup on background networking.

Verified: 79 examples, 0 failures, including browser sign-in for both roles and
the language picker; RuboCop clean across 74 files. Coverage 86.8% line /
83.3% branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi
Capturing reference screenshots showed the dashboard top bar reading "Title"
and "Subtitle", and its panel reading "Manage Copy". Those were missing
translation keys: t(".title") in app/views/admin/dashboard/show.html.erb
resolves to admin.dashboard.show.title, and the keys sat at
admin.dashboard.title. Rails humanises a missing key rather than raising, so in
English the guess reads as correct copy and only the other two languages would
have shown the hole.

- config.i18n.raise_on_missing_translations is now on in test, which is the
  only reliable way to catch this. Turning it on immediately found a fourth
  missing key the screenshots had not reached.

- Dashboard keys are nested under show: to match the lazy lookup, except the
  ones genuinely shared with the users list, which stay absolute.

- The sidebar chip was truncating to "Ada Lov..." because the sign-out button
  shared its row; it now sits on its own.

Also: the screenshot spec is not a test, so it is tagged and excluded from the
default run -- SCREENSHOTS=1 bin/test produces the README images. And the
SimpleCov configuration was using four deprecated APIs, printing deprecation
warnings on every run; they are now the current names.

Two of my own configuration mistakes fixed on the way: ENV["SCREENSHOTS"] set
to an empty string is truthy in Ruby, so the filter never applied; and
appending a second RSpec/ExampleLength block to .rubocop.yml silently replaced
the earlier Max: 12 with the default of 5, because a duplicate YAML key
overrides rather than merges.

Verified: 79 examples, 0 failures with missing translations now raising;
RuboCop clean across 75 files; no deprecation warnings. Coverage 87.2% line /
83.9% branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi
Avatars come from an upload or a remote URL, with initials as the fallback.

- Uploads are checked against what the bytes actually are. The content type on
  an upload is supplied by the client and can claim anything, so the file is
  sniffed with Marcel instead: a shell script named avatar.png and announced as
  image/png is rejected, and there is a spec that does exactly that.

- The remote URL is never fetched by the server. Validating a URL by requesting
  it is how an attacker gets the server to make requests on their behalf, so it
  is only ever checked as text and then handed to the browser as an img src,
  with the referrer withheld.

- displayable_avatar_url is the single gate the URL passes, used by both the
  validation and the view. Checking only on save was not enough: a rejected
  value still sits on the record while the form is re-rendered, and the helper
  would have put a javascript: URL straight into an img src. Likewise
  avatar_source only reports :attachment for a persisted attachment, because a
  rejected upload is still attached in memory and asking Active Storage to
  build a thumbnail of it raises.

Pushing coverage to the gate turned up a real defect rather than padding.
RuboCop's Rails/DynamicFindBy autocorrect had rewritten

  User.find_by_password_reset_token!(token)

into find_by!(password_reset_token: token), which looks for a column that does
not exist. That method is generated by generates_token_for, not a dynamic
column finder, so the entire password reset flow raised PG::UndefinedColumn and
nothing caught it until it had a spec. The call is restored and the cop now has
it in AllowedMethods so autocorrect cannot make the change again.

The reset flow is now covered, including that a known and an unknown email
address produce identical responses -- answering differently would turn the
form into a way of asking who has an account here.

Verified: bin/ci green end to end in 15.6s -- RuboCop, Bundler Audit, importmap
audit and Brakeman clean, 116 examples 0 failures, coverage 97.9% line / 90.8%
branch, both above the gate for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi
An administrator uploads a spreadsheet, Solid Queue does the work, and the
progress reaches the page over Solid Cable.

Reading:
- Both formats stream. CSV goes through a streaming reader and XLSX through
  Roo's streaming API, so memory does not grow with the size of the file.
- The extension chooses the parser and the sniffed bytes then have to agree
  with it. CSV has no magic number of its own, so its rule is "must not look
  like something else" rather than "must look like CSV" -- a renamed binary is
  still caught.
- MAX_ROWS caps the work one upload can ask for. Without it a single file
  decides how long a worker is busy.

Processing:
- A bad row is recorded and the import keeps going. Each rejection stores the
  line number as it appears in the file and the reasons, so an operator can
  open the spreadsheet and fix that line.
- Re-running is safe: counters and previous row errors are cleared first, so
  the record describes this run rather than the sum of every run, and rows
  whose account already exists come back as rejected duplicates instead of
  creating a second one. Duplicates are rejected rather than merged, which is
  the choice that cannot silently overwrite somebody's data.
- Imported people never get a password from the file. They get an unguessable
  one and set their own through the reset flow.
- The role column is honoured but strictly: user, admin, or blank meaning user.
  Anything else rejects the row.
- Counter broadcasts are suspended for the duration and fired once at the end;
  progress itself is throttled to every tenth row, because a thousand-row file
  should not mean a thousand renders of a bar that moves a pixel.

Two ideas taken from the Onix import feature: a downloadable template with the
instructions written into it, and rows beginning with # being skipped, which is
what makes such a template possible. Its enqueue-from-an-after_create callback
was deliberately not copied -- creating a record in a console or a test should
not quietly start a worker, so the job is enqueued from the controller.

The rejected rows can be downloaded as CSV, and every cell carrying data from
the uploaded file is neutralised first: a leading =, +, -, @ or control
character makes a spreadsheet treat the cell as a formula.

AdminCountersChannel is now AdminStreamChannel, since it authorises the import
progress streams as well as the dashboard counters.

Verified end to end against the running stack: the web container enqueued and
the job ran in the worker container -- different hostnames in
solid_queue_processes -- moving through pending, processing and completed while
the user count went from 14 to 17. bin/ci green: 162 examples, 0 failures,
coverage 97.5% line / 86.6% branch, RuboCop and all three security checks
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS3oGqeR1PXScuK6gLr8Mi
The vectors each feature owns were already covered next to that feature.
What was left belonged to the application as a whole, and most of it was
still sitting at generator defaults.

Content Security Policy: the shipped initializer was commented out, so
injected markup had nothing standing in its way beyond escaping. The
policy now allows scripts only from this origin plus a per-response
nonce -- which the importmap tags carry on their own -- names the two
Google Fonts hosts explicitly, allows any https image because remote
avatars are a feature, and adds the websocket origin Action Cable needs.
frame-ancestors 'none' keeps the destructive admin forms out of a frame.

Production: assume_ssl and force_ssl were commented out, which left the
session cookie without its secure flag behind a TLS-terminating proxy.
Both are on, the health check is excluded from the redirect, and the
switch stays readable so the production image can still be smoke-tested
over plain http.

GET /admin/users/:id routed to an action that does not exist, so a path
the application itself advertises answered 404. The route is gone.

The specs cover the vectors by name: forged destructive requests, a
hostile name typed into the form and the same name arriving through an
import, the flags on the session cookie, the headers, credentials
kept out of the log, and a spreadsheet too heavy or too long to accept.
The production settings are read by booting a short-lived production
process, since Rails boots one environment per process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
config/deploy.yml describes the deployment Kamal 2 performs: the web role
running the image's `final` stage behind kamal-proxy, a second role
running bin/jobs so Solid Queue is a process of its own rather than a
thread inside Puma, a PostgreSQL accessory, and a named volume for the
uploaded avatars, which Active Storage keeps on disk. Hostnames and
account names are placeholders; secrets are named in .kamal/secrets and
read from the deploying machine's environment.

Building and running the image found a real fault: db:prepare runs the
seeds while the first container boots, and the seed file aborted in
production when SEED_ADMIN_PASSWORD was missing, which crash-looped the
deploy. Seeds no longer abort. In production they create one
administrator from SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD, or say they
have nothing to do -- the demonstration roster, thirteen accounts
sharing one password, has no business on a real installation.

Verified against the running image rather than by reading it: /up
answers 200, the assets are the digest-stamped ones compiled at build
time, the session cookie carries secure, httponly and samesite=lax, the
response sends HSTS and the content security policy, and bin/jobs starts
its supervisor, worker, dispatcher and scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
The two password views and both mailer templates were still the
generated scaffold: hardcoded English inside the markup, and the
framework's default blue on a page that looks nothing like the rest of
the application. The missing-translation guard could not catch it,
because there were no translation calls to miss.

They now use the same panel, field and button classes as the sign-in
screen, and every string is a key in the three locales. The reset email
gained a heading, a real action button, how long the link lasts and a
line for the person who never asked for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
System specs for the visitor, the regular user and the administrator,
each following a whole path rather than one action: signing up from the
root, correcting one's own details, keeping a chosen language across
sign outs, being turned away from the admin area, creating and promoting
and removing people, the last-administrator refusal, and an import
reporting what it did with each row.

Live delivery was the honest gap. The test cable adapter records
broadcasts without delivering them, so "the counter updates by itself"
was never actually observed. The test environment now takes its adapter
from CABLE_ADAPTER, and a second pass -- bin/test --live, and a CI step
-- runs the examples tagged :live with Solid Cable. They load a page,
never reload it, and then change the data from the example: the
dashboard counter moves and an import walks from waiting to finished in
a real browser, over a real websocket. That also exercises the
connect-src the content security policy allows.

bin/test --parallel had never been run. It called parallel:setup, whose
db:setup also runs the seeds, so every worker database started with the
demonstration roster in it and specs that count administrators failed.
It now calls parallel:prepare, the rake tasks are loaded in the
Rakefile, and the worker count defaults to four rather than one per
core. 193 examples across four workers, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
Three flags sitting side by side made the choice loud and the current
language ambiguous. The picker is now one button wearing the flag and
the name of the language in use; the alternatives live inside it.

It is a <details> element, so it opens with the keyboard and works with
no JavaScript at all. The Stimulus controller adds only what <details>
does not do on its own: closing when a click or the focus moves
elsewhere, and on Escape. The generated hello_controller went with it,
since it was the only other thing in that directory.

Two things found on the way in: the <progress> styling had been written
inside the prefers-reduced-motion block, so the import bar was only
styled for people who ask for less animation; and the test environment
read its cable adapter with ENV.fetch, which the wrapper's empty
CABLE_ADAPTER satisfied with an empty string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
Deleting an administrator who had ever run an import returned a 500.
The foreign key on user_imports.administrator_id raises rather than
returning false, so `if @user.destroy` never saw it. The import history
outlives the account that asked for it: the reference is now optional
and nullified, and the address is copied onto the row when the import is
created, so the list still says who requested it.

The users list had an N+1. It renders an avatar per row, and without
eager loading the attachment, blob and variant record were fetched once
per person: eleven queries for ten people, and worse once the variants
are rendered. Now four, whatever the page holds. The spec counts the
queries for two people and then for eight and expects the same number,
so this stays fixed rather than being fixed once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
…g it

The workflow that came with the repository was the Rails default: it
scanned for vulnerabilities and linted, and never ran a single test. It
now runs bin/ci inside the same container the application is developed
in -- style, three security scanners, the suite, the live-updates pass
-- and keeps the coverage report as an artifact. A workflow that
installed its own Ruby and its own PostgreSQL would be a second
definition of the environment, free to drift from the one in the
repository.

Accessibility is now a check rather than a sentence in a README: axe
runs against ten screens at WCAG 2.1 AA. The packaged matcher speaks
Selenium and these specs drive Chrome over CDP, so the few lines that
load the library and read the violations live in spec/support.

It found three real faults on the first run: the counts beside the role
filters used the 4:1 "dim" token, which the tokens file documents as
being for large text and decoration; and the inline links on the sign-up
and password-reset screens were distinguished by colour alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
An imported account had a random password nobody had ever seen and no
message went anywhere: the person existed in the system and had no way
of learning it, let alone of getting in. Every account an import creates
now receives an invitation.

The link carries a token generated for a new purpose rather than reusing
the password reset, whose fifteen minutes are right for somebody who
just asked and wrong for somebody who was imported at two in the
morning; an invitation lasts a week. Like the reset token it is derived
from the password salt, so it stops working the moment a password is
set. Both arrive at the same screen, which reads as a welcome rather
than a reset when the token is an invitation.

Two things fixed on the way: mail is delivered by a worker, long after
the request whose locale belonged to the reader, so every message went
out in English whatever the person had chosen -- the mailers now switch
to the recipient's language. And the from address was the generated
from@example.com; it reads MAIL_FROM now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
`ILIKE '%term%'` has a leading wildcard, so a B-tree has nothing to seek
on and PostgreSQL read the whole table for every search. Measured on
50,000 rows: 23.9 ms, growing with the roster.

A trigram index per column does not fix it -- the planner compares two
GIN scans against one sequential scan and takes the sequential scan. So
the two columns become one: a stored generated column that PostgreSQL
keeps in step with the name and the address, and a single GIN trigram
index over it. Same query, same results, 0.095 ms.

The index is built concurrently, outside a transaction, so a deploy
against a table with volume in it does not lock writes while it runs.

Terms shorter than three characters cannot use a trigram index and still
scan; script/benchmarks/search.rb reproduces both numbers, and the
README says so rather than claiming a speed-up that does not apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
A system whose whole purpose is administering accounts should be able to
say who promoted whom, and when. That question cannot be answered from
the users table: the row that would tell you is the row that changed.

Every administrative action now writes an audit event -- created,
updated, promoted, demoted, deleted, imported -- readable at
/admin/activity, newest first, administrators only, and read-only by
design: a trail that can be edited from the interface it records is not
a trail.

Written from the actions themselves rather than from a model callback.
The actor is a fact about the request; a callback would have to go
looking for it in thread-local state, and would fire for the seeds and
the console too, attributing everything to nobody.

Both sides are nullified rather than cascaded, and the two addresses are
copied onto the row at the moment of the event, so a trail still reads
after either account is gone -- which is exactly when it is read. A
deletion is recorded after the fact, when the row it refers to no longer
exists, so the reference is dropped and the address remains. The details
never carry the password digest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
/api/v1 covers what the administration screens cover: a token endpoint,
the account behind the token, and the five actions on users. The rules
are not restated -- the role is only assignable by an administrator and
the last administrator is protected, because both live in the model,
which is the point of their living there. Every action writes the same
audit event the HTML side writes.

Authentication is a signed bearer token derived from the password salt.
There is no table of secrets to leak, no revocation list to keep, and
changing a password invalidates every token already issued. It lasts a
day.

The OpenAPI document is generated by rswag from the request specs that
exercise the API, so it cannot describe an endpoint the application does
not have or a field it does not return; CI regenerates it and fails if
the committed copy has drifted. Swagger UI is at /api-docs.

Two things found on the way. Swagger UI ships its own content security
policy, and a browser enforces every policy it is sent, so ours and its
intersected into a blank page -- the application's policy now steps
aside for that mount alone. And `?page=` -- empty, negative, or not a
number -- reached Pagy as zero and raised a 500 on both faces of the
application; pagination now lives in one concern that decides what an
unreasonable page means, and the page size stays bounded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
The generated document describes a public API and is harmless to read,
but publishing the shape of an installation is a choice, not a default
somebody should have to edit code to change. Two environment variables
turn on basic auth; without them the page stays open, which is what
development wants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
The setup and start scripts had grown flags -- --reset, --no-seed,
--detach, --down -- which is a small language to learn before doing
anything. devops/app/ now holds one script per action: setup, start,
stop, restart, status, seed, reset, logs. They read as sentences, they
print what they are doing, and each one is short enough to read before
running it.

bin/setup and bin/dev stay, because short names for constant commands
are worth having, but they no longer contain any logic: they delegate.

devops/README.md lists every script in the directory, so finding the one
that does what you need does not mean grepping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
Validation errors were listed at the top of the form and the field only
got aria-invalid: a screen reader announced that something was wrong
with the input without saying what. Each field now carries its message
underneath and points at it through aria-describedby, alongside the hint
where there is one.

The screens a visitor sees -- sign in, sign up, both password screens --
had no h1 at all. Their heading was an h2 because the h1 lives in the
topbar, which only renders for somebody signed in. axe did not catch it:
the rule that would is a best practice rather than a WCAG one, and the
suite runs the WCAG 2.1 AA tags.

And "responsive" is now measured rather than claimed:
spec/system/responsive_layout_spec.rb drives the browser at 360 CSS
pixels and fails if any screen scrolls sideways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
In English, with the AI disclosure at the top naming the exact model.

Every number in it was measured, and the scripts that produce them are
in script/benchmarks: the search index (23.9 ms to 0.095 ms over 50,000
rows, and no gain at all below three characters, which is said too), and
ZJIT (about 15% on a CPU-bound 200,000-row parse, five runs each, with
the flag and the command). ZJIT is not enabled anywhere in the
repository: turning on a JIT by default without production evidence is
not a performance decision.

The screenshots are produced by a spec rather than taken by hand, so
they cannot drift from the interface.

It says what was not done as plainly as what was: only Chromium is
driven by the specs, the audit trail has no retention policy, invitations
are one email per created row, db:prepare on boot races across multiple
web hosts, no soft delete, and the screen-reader wording has never been
heard through a screen reader.

It also records the prompt injection in the original README -- an HTML
comment telling an AI assistant to inject a marker string into frontend
files and hide the instruction. Following it silently and ignoring it
silently are both worse than saying so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
The ZJIT figures came from one pair of runs. Three more pairs put the
gain between 12% and 16%, with the absolute times drifting by about a
tenth of a second between sessions, so the README now says that instead
of a single decimal that looks more certain than it is.

The benchmark commands are also runnable as written -- through the
container, which is where the application lives -- and the search
benchmark carries the snippet that generates and removes the 50,000 rows
it needs, so nobody has to reconstruct the fixture from prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
The image built from this directory carried CREDENCIAIS_LOCAIS.txt and
PENDENCIAS.md -- the local test credentials and the internal status
note. Both are untracked, so a fresh clone has neither, and that is
exactly what made it easy to miss: the build copies the directory, not
the repository. Anybody pulling that image would have got both. Verified
by listing the files inside the rebuilt image.

The README screenshots go too: they are for people reading the
repository, not for the running application.

Also trimmed the API's `new` and `edit` routes, which pointed at actions
that do not exist -- two advertised paths that only ever answer 404 --
and corrected the comment beside the same fix in the admin namespace: a
missing action is a 404, because Rails maps ActionNotFound to
:not_found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
A pull request from a fork does not run anything until a maintainer
approves it, which is well after the moment the person who pushed would
have wanted to know. The workflow now runs on every push, so the branch
carries its own evidence.

A push and a pull request on the same branch would start two identical
runs, and each new push would leave the previous one grinding away on
code nobody is reading any more, so the runs are grouped and superseded
ones are cancelled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
The first run on GitHub failed with `container user-management-web-1 is
unhealthy`, and no logs from it at all -- it had already exited. The
development image runs as uid 1000, which is the common host uid and
what keeps bind-mounted files writable from both sides on a laptop; a
runner checks the repository out as a different user, and Rails cannot
so much as create tmp/cache in a directory it does not own.

Reproduced locally by exporting the repository into a directory owned by
another uid and booting the image against it -- `Permission denied @
dir_s_mkdir - /rails/tmp/cache` -- and confirmed fixed by handing that
directory to uid 1000, which is what the new step does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
It came with the repository and has been failing on every push. Two
reasons, both in the file rather than in the code it scans:

  Error: This version of the CodeQL Action was deprecated on January
  18th, 2023, and is no longer updated or supported.

  RequestError [HttpError]: Resource not accessible by integration
  PUT /repos/.../code-scanning/analysis/status  403

The actions were pinned to v1, retired three years ago, and the job
asked for no permissions at all, so the default read-only token could
not write what CodeQL found. Now v3, with `security-events: write` and
nothing else, and the languages named -- Ruby and JavaScript, neither of
them compiled, so the Autobuild step and the `git checkout HEAD^2` dance
both go; the action itself reports that checkout as `CheckoutWrongHead`.

Touching a workflow that arrived with the test is worth a word: it is
not the application's own pipeline (that is ci.yml), and it was left
alone until it turned out to be red on every commit for a reason that
had nothing to do with the submission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
The second run got further and still failed: `container
user-management-web-1 is unhealthy`, this time after the full two and a
half minutes of health checks rather than instantly. The application was
up; /up was answering 500. `ActiveRecord::NoDatabaseError: Database not
found: user_management_development_cache` -- the health endpoint goes
through the cache store, and the cache store has a database of its own,
which nothing in the workflow had created. On a laptop the databases
have existed since the first bin/setup, which is why this only ever
appeared on a clean machine.

The workflow now runs devops/app/setup.sh --no-seed: the same script the
README tells a person to run. It writes .env, builds the image, creates
and migrates all four databases and waits for every service to report
healthy. A separate sequence of docker commands in the workflow was a
second description of how to start this application, free to drift from
the documented one -- and it had.

Rehearsed against a clean checkout in a throwaway compose project before
pushing, which is also how the missing databases were found.

The OpenAPI drift check moved into bin/openapi-current, because
`git diff` inside a directory that is not a work tree fails with a usage
error rather than saying anything useful. It now regenerates the
document either way and says plainly when there is no committed copy to
compare against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX7yhokJ4UWtAHDnBmr46W
antonioneto1 and others added 3 commits September 4, 2026 13:34
The run failed one step earlier than before: `cp: cannot create regular
file '.env': Permission denied`.

Handing the checkout to the container's user with `chown -R 1000:1000 .`
did what it was meant to -- Rails can create tmp/cache -- and one thing
it was not meant to: it locked the runner out of its own workspace. That
went unnoticed while .env was written by a step that ran *before* the
chown. Moving its creation into devops/app/setup.sh moved it *after*,
and the first thing the script does is copy .env.example.

So the tree is now owned by the container's user and grouped to the
runner, with group write: the arrangement a bind mount shared by two
users needs, rather than a handover from one to the other.

Checked both directions in a container before committing -- with
`chown 1000:1000` a uid 1001 runner cannot create .env, reproducing the
failure; with owner 1000, the runner's group and g+rwX, the runner
creates .env and uid 1000 still creates tmp/cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wRJyi2UPTnVPSMBZaELLH
Parallel testing is asked for twice in the brief -- once among the
requirements and once among what is expected to be seen -- and it was
implemented, documented and reachable as bin/test --parallel. The
pipeline did not use it: config/ci.rb ran `bundle exec rspec`, so
anybody reading the pipeline saw a serial suite and a capability sitting
in a side script.

The suite now runs on min(nproc, 4) workers, the same ceiling
devops/rails/test-parallel.sh uses, so the pipeline and a laptop shard
the suite identically. Each worker gets its own set of four databases
from parallel:prepare.

The coverage gate survives the change untouched: spec/spec_helper.rb
already sets a per-worker command_name and merging, and the merged
report reads the same 98.26% line and 90.30% branch as the serial run.
253 examples in 18.6s across four workers against 26s in series.

The live-updates pass stays serial and keeps its own database: it is two
examples, and sharding two examples buys nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wRJyi2UPTnVPSMBZaELLH
Two corrections to documents that had stopped describing the code.

The testing section said the parallel run merges its workers' coverage,
which was true of bin/test --parallel and silent about the pipeline. The
pipeline now runs it, so the section says so.

And a tenth known limitation: the import example in the live-updates
pass fails on a saturated machine. It is written the way the other nine
are -- what was measured, and what was ruled out. The job finishes and
both broadcasts reach the Solid Cable table; only the second one fails
to reach the browser. Raising the wait to 60 seconds failed identically,
so it is not merely slowness, and the root cause in the polling
adapter's delivery is not found. Recording an intermittent failure is
better than a reviewer meeting it with no warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wRJyi2UPTnVPSMBZaELLH
@antonioneto1
antonioneto1 force-pushed the feature/user-management-test branch from 5c21bb7 to 52dba24 Compare September 4, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant