Modern Fullstack Developer Test — Roster (user management) - #33
Open
antonioneto1 wants to merge 33 commits into
Open
Modern Fullstack Developer Test — Roster (user management)#33antonioneto1 wants to merge 33 commits into
antonioneto1 wants to merge 33 commits into
Conversation
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
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
force-pushed
the
feature/user-management-test
branch
from
September 4, 2026 17:00
5c21bb7 to
52dba24
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/admin/activity/api/v1whose OpenAPI document is generated from the specs that exercise it — Swagger UI at/api-docsdeploy.yml, and a four-service Compose stack where the worker really is its own processVerification
bin/ciruns 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.Running it
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