Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
67ce45c
Give every changeset error in core a code
stuartc Oct 23, 2026
1151e31
Decide access in the policies from one Lightning.Scope that carries t…
stuartc Oct 23, 2026
c296578
Check a credential owner's access in Credentials, record who acted, a…
stuartc Oct 23, 2026
15319cb
Answer a lost save race with concurrent_edit, and flash one message f…
stuartc Oct 23, 2026
1f82597
Answer API errors with one problem document, including errors raised …
stuartc Oct 23, 2026
1027440
Authorise an API request before finding its target or reading its bod…
stuartc Oct 23, 2026
5ddb9dc
Give the browser controllers their own BrowserFallbackController
stuartc Oct 23, 2026
1ebb4cf
Answer the credentials API through Target and Request, and add show a…
stuartc Oct 23, 2026
9e471c7
Check workflow graphs in GraphRules and answer a stale workflow save …
stuartc Oct 23, 2026
bc07fc0
Page every API list through typed filters built in the contexts, and …
stuartc Oct 23, 2026
6f08789
Create, replace and show a project by id, recording who created or ch…
stuartc Oct 23, 2026
d92afa2
Resolve collections through Scope, and route projects, credentials an…
stuartc Oct 23, 2026
aa0153f
Answer workflow health errors through Target, with a checked days win…
stuartc Oct 23, 2026
2656f24
Answer provisioning errors at their place in the request, refusing un…
stuartc Oct 23, 2026
a1be51c
Find an AI assistant session through Target and report every bad para…
stuartc Oct 23, 2026
6c986fe
Delete the old FallbackController and ChangesetView, now that nothing…
stuartc Oct 23, 2026
3e89c51
Answer a failed API login with a problem document and a Bearer challenge
stuartc Oct 23, 2026
a7fe03f
Answer webhook and channel proxy errors with problem documents
stuartc Oct 23, 2026
86cdf5b
Check with Credo that every changeset error carries a code, and add t…
stuartc Oct 23, 2026
9b6e852
Describe the API changes in the CHANGELOG and SERVICE_ACCOUNTS.md
stuartc Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .claude/rules/api-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
paths:
- "test/lightning_web/api/**/*.exs"
- "test/lightning_web/controllers/api/**/*.exs"
- "test/lightning_web/controllers/webhooks_controller*_test.exs"
- "test/lightning_web/controllers/project_mfa_enforcement_test.exs"
- "test/lightning_web/controllers/user_auth_test.exs"
- "test/lightning_web/controllers/adaptor_controller_test.exs"
- "test/lightning_web/collections_controller_test.exs"
- "test/lightning_web/collections/**/*.exs"
- "test/lightning_web/controllers/webhooks/**/*.exs"
- "test/lightning_web/plugs/channel_proxy/**/*.exs"
- "test/lightning_web/auth_test.exs"
- "test/lightning_web/plugs/access_token_auth_test.exs"
- "test/lightning_web/plugs/person_or_service_account_auth_test.exs"
- "test/lightning_web/plugs/webhook_auth_test.exs"
- "test/lightning_web/plugs/channel_proxy*_test.exs"
- "test/support/api_*.ex"
- "test/lightning/collections_test.exs"
- "test/lightning/collections_item*_test.exs"
- "test/lightning/invocation/query_test.exs"
- "test/lightning/projects/provisioner_test.exs"
- "test/lightning/workflows/graph_rules_test.exs"
---

# API error tests

These files test what clients of `/api`, `/collections`, `/i/*` webhooks and the `/channels/*` proxy get back, and the modules that build those answers. Each promise a client relies on is tested in exactly one home. Before writing a test, find that home.

## Where each promise is tested

- **No token, a garbage token, a token of the wrong kind:** `api/sentinel_sweep_test.exs`, which walks every route from the router, and `auth_test.exs`, which holds the token-kind table and the lockout example. Controller test files keep no "without a token" or "with an invalid token" test.
- **A rule in shared code** (`Filters`, `Request`, `Problem`'s Accept handling, pointer escaping, `Errors`): the module's unit test holds the cases. Each route that uses the module keeps one test that fails if the route stops calling it, for example one paging test per route that calls `Filters.page/1`.
- **Who may reach a resource, what role it needs:** one table test per route family.
- **Each code's status and `detail` wording:** the table in `api/codes_test.exs`, written out literally. Rewording a code means editing its row there.
- **Everything else a route does:** the route test, through the router (`put(conn, ~p"/api/...", body)`), asserting status, headers and the whole body.

A unit test of an API module exists for a promise no route can show (a 16-byte string as an id, the five-way Accept table) or a guard a developer hits. When a route test and a unit test both hold a promise, the unit test keeps the cases and the route keeps its one wiring test.

## Writing a test

- When a status or body changes, change the existing test, renaming it if its name stops being true. Add a test only for a promise that did not exist before. A new test with the same route and setup as an existing one means the existing one should have been edited.
- One operation per test, every assertion in it. Compare the whole body with `==`. Several inputs to one rule go in one table test (`for {input, expected} <- [...]`); different setups, or success against refusal, stay separate tests.
- Assert errors with `assert_problem(conn, status, code, entries)` by code, listing the entries exactly (`[]` when there are none). The response `detail` comes from `codes_test.exs`; each entry's `detail` is written out in the route test that produces it. Write challenge strings out in full with `assert_unauthenticated/3`.
- Every `/api` route that writes has its own stale-write test: another request deletes or changes the record mid-write (`Lightning.RaceHelpers.after_first_query/4`), and the route answers `assert_problem(409, :concurrent_edit, [])` and leaves the record as the other request left it. A new write route gets one too.
- `Lightning.Extensions.UsageLimiting.error_reason` lists four reasons, and the production limiter returns others as well. A test that stubs a limiter (`MockUsageLimiter`, `MockRateLimiter`) stubs at least one reason outside that type alongside the ones it checks. A stub that always returns `:exceeds_limit` passes even when every other reason crashes with a 500.
- When the same setup or assertion appears a third time, move it to `test/support`.
- Deleting or merging a test: name the test that still holds its promise, then break that behaviour in `lib` and watch the surviving test fail.

## Names and layout

- Describes and test names say what the client is doing: "reading", "creating", "refusals", "stale writes", "who may reach it". Put a new test beside the behaviour it refines.
- Names, describes and comments cite no spec section numbers, commit refs, ticket numbers or files outside this repo. Say the rule itself.
- Keep a file near 400 lines and always under 1,000. Split by behaviour (`workflows/creating_test.exs`, `workflows/stale_writes_test.exs`), not by position in the file.

## Core context tests

The `test/lightning/` files here alias and import only `Lightning` modules. Assert on `changeset.errors`, with each error's `code:` and key list. Pointer strings and problem bodies are a web promise, so they belong in a route test.
109 changes: 109 additions & 0 deletions .claude/rules/comments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
paths:
- "lib/**/*.ex"
- "test/**/*.exs"
- "assets/js/**/*.ts"
- "assets/js/**/*.tsx"
---

# Comments and docs

A comment promises the reader something the code can't tell them. Every one
that breaks the promise teaches people to skip the next, and agents read every
comment and act on it, so a wrong one misleads twice. Fewer, true, plain.

## When a comment earns its place

Keep a comment only if it carries one of these:

- a constraint the names don't show,
- a decision a competent reader would otherwise reverse ("we deliberately
don't retry here"),
- a workaround for something outside the codebase,
- a pointer to where a rule comes from.

Anything else goes. If a comment exists because the code is unclear, rename or
extract instead.

```elixir
# Ecto's :utc_datetime type rejects non-zero microseconds, so truncate
# before casting or the changeset fails.
scheduled_at = DateTime.truncate(DateTime.utc_now(), :second)
```

## Which kind of comment

- `@moduledoc`: what the module is for and when to reach for it. One or two
sentences is usually enough. Use `@moduledoc false` rather than a filler
sentence.
- `@doc`: how to call the function and what happens. Read in IEx, editor
hovers and by agents, usually without the source. The first line is one
sentence that stands alone.
- `#`: why this line is like this, for someone already in the body.

In TypeScript, JSDoc on an export plays the part of `@doc`.

## A `@doc` describes what the caller gets, not the steps inside

Say what the inputs must be, every return shape including error tuples, and
side effects the caller can't see (writes, broadcasts, audit rows). Don't walk
through the body in order: that goes wrong the moment someone reorders it.

```elixir
@doc """
Replaces a credential's body on behalf of `actor`.

Returns `{:error, :unauthorized}` if the actor can't write credentials in the
project, or `{:error, changeset}` if the body is invalid. The audit event is
written in the same transaction.
"""
```

## Explain a rule once, where it's enforced

When several modules rely on the same rule, the explanation lives in the
module that enforces it. The others say nothing, or one line pointing there if
the caller would otherwise look wrong. Don't describe another module's
behaviour in a comment; it goes wrong when that module changes and nothing
flags it.

## Write what will still be true next month

| Goes stale | Lasts |
|---|---|
| Step-by-step account of the body | What the function promises |
| Callers, routes ("used by the API controller") | Constraints on inputs |
| Counts, line numbers, file paths | Why a rule exists |
| "Currently", "for now", "new", "now also" | Error shapes and side effects |
| Ticket, PR and refactor history | Link to the external source of a rule |

Never narrate the change ("added for CON-331", "now also handles service
accounts"). That is the commit message's job. Code reads as if it was always
written that way.

When you change behaviour, re-read the comments in that function and its
`@doc`, and fix anything the change made false in the same commit.

A TODO says what is left and why it isn't done now. "TODO: fix" is not a TODO.

## Plain words

- Full sentences a colleague would say out loud. If a reader would need a
follow-up question to decode it, rewrite it.
- No coined shorthand: "the state rule", "project-less scope", "raw-actor
clause". Say the thing it stands for.
- No filler: "simply", "note that", "it's worth noting", "this ensures that".
- Don't weld clauses together with colons and semicolons. Two sentences, or
one simpler one.
- No spaced em dashes; prefer a full stop.
- `mix format` doesn't wrap comments. Reflow a paragraph after editing it so
every line but the last runs to 80 columns. In a `@doc` or `@moduledoc`, a
blank line separates ideas, and three or more parallel items go in a list.

## Tests

- Put the intent in the test name, not a comment above it.
- Comment setup only when the reason isn't visible ("inserted out of order so
the query has to sort").
- Don't restate an assertion. Do say why when it pins deliberate behaviour a
reader might "fix" (a refusal that looks like a bug).
6 changes: 4 additions & 2 deletions .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [],
requires: ["credo/checks/*.ex"],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
Expand Down Expand Up @@ -171,7 +171,9 @@
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.StrictModuleLayout, []}
{Credo.Check.Readability.StrictModuleLayout, []},
# Lightning's own, from credo/checks/
{Lightning.Credo.Check.ChangesetErrorCode, []}
],
disabled: [
#
Expand Down
2 changes: 1 addition & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
inputs: [
"*.{heex,ex,exs}",
"priv/*/seeds.exs",
"{config,lib,test}/**/*.{heex,ex,exs}",
"{config,credo,lib,test}/**/*.{heex,ex,exs}",
"tooling/adaptor_cache/lib/**/*.ex",
"bin/adaptor_cache"
],
Expand Down
Loading