236 lines
15 KiB
Markdown
236 lines
15 KiB
Markdown
# QA: API keys screen
|
||
|
||
Two parts: the Go API change (HTTP-level, in `test/`) and the SPA (Vitest and visual).
|
||
|
||
## What changes in the backend
|
||
|
||
Only `GET /v1/api-key` called by an owner changes: each entry gains `created_by_name`, the creator's
|
||
display name (trimmed, non-empty) or else username, and `""` when the creator no longer exists.
|
||
Admin entries keep exactly the nine existing fields and never carry `created_by_name`.
|
||
`features/api_keys.feature` is not edited. Its exact-fields scenario is made as an admin and stays
|
||
true. No domain or persistence change: `ListAPIKeysUseCase` already holds `user.Repository` and
|
||
resolves each distinct creator once through `Get`.
|
||
|
||
## Go: created_by_name on GET /v1/api-key
|
||
|
||
Extend `test/api_keys_http_test.go`. Build the handler from `NewHandler(...)` over use cases wired
|
||
to the existing in-memory fakes (apikey.Repository, user.Repository, KeyGenerator, fake clock).
|
||
Mount it on a fresh `chi.NewRouter()` that mirrors `api/router.go`, wrapped in `jwtauth.Verifier`
|
||
and `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`; call
|
||
`validator.InitValidator()`; exercise it with `httptest`. No live server, no real SQLite, no real
|
||
SMTP. Users: owner 1 "marta" (display name "Marta Alves"), admin 2 "dev" ("Dev Two"), admin 3
|
||
"pat" (empty display name), all verified.
|
||
|
||
1. **Owner sees names** - keys a (user 1), b (user 2), c (user 3); owner cookie GET /v1/api-key;
|
||
assert 200, every entry has `created_by_name`, a "Marta Alves", b "Dev Two", c "pat", and the
|
||
other nine fields as before (newest first).
|
||
2. **Whitespace display name** - user 3 display name " "; assert "pat".
|
||
3. **Admin unchanged** - admin 2 cookie; assert every entry has exactly the nine existing fields
|
||
(use `apiKeyAssertExactFields`) and no `created_by_name`. Check that the existing exact-fields
|
||
test at line 341 is made as an admin; if it is made as an owner, update it and report it.
|
||
4. **Deleted creator** - key whose user was removed from the fake; owner GET; assert 200 and
|
||
`created_by_name` "" for it.
|
||
5. **User lookup fails** - the fake user repository returns an error on Get for a creator; assert
|
||
500 and body "Internal server error".
|
||
6. **One lookup per creator** - the fake counts Get calls; owner with 3 + 2 keys over 2 creators;
|
||
assert the creator lookups equal the distinct creators (plus the requester's own Get).
|
||
7. **Use case level** - ListAPIKeys for an owner returns entries with the creator name; for an
|
||
admin returns empty creator names; the other existing ListAPIKeys tests pass unchanged.
|
||
8. **Regression** - the existing api_keys tests still pass; list order, statuses, 401 and 403
|
||
behaviour are unchanged.
|
||
|
||
Run with `task test`; one test with `task test -- -run 'TestName'`.
|
||
|
||
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
|
||
|
||
Mock `useApi`'s `request` for GET /v1/api-key, POST /v1/api-key and DELETE /v1/api-key/{id} per
|
||
case. Mount ApiKeysPage inside DashboardShell with a resolved verified session (id 1, "marta",
|
||
display name "Marta Alves", role "owner") and a test router at /integration/api-keys; admin cases
|
||
use role "admin", username "jonas". Mocked owner entries carry `created_by_name`. Stub
|
||
`navigator.clipboard.writeText` per case. No test freezes the system date; specs assert on the
|
||
fixtures' absolute dates only, and the expiry cases build their dates from the real clock. Run
|
||
layout cases at desktop and below-md (mock `matchMedia`).
|
||
|
||
1. **Owner: list** - breadcrumb (with the Integration link), title, owner description, header
|
||
labels, rows in fixture order with every cell (prefix cut to 9 characters plus "…", Created by
|
||
from `created_by_name`), badge words, "Revoke" on rows 1 to 3 and not row 4, row 4 muted, the
|
||
note; the below-md cards. An entry with an empty `created_by_name` shows "—".
|
||
2. **Admin: own keys only** - no "Created by" header or cell, admin description, two rows, cards
|
||
without " by {name}"; a session with no role gets the same rendering.
|
||
3. **Loading** - unresolved GET; 4 skeleton rows, aria-busy "true", header "Create key"; resolve
|
||
and assert rows.
|
||
4. **Empty** - `[]`; the panel copy and its "Create key", no table.
|
||
5. **Error, Examples-driven** - ApiError(500), then NetworkError; alert copy as drawn, "Try again",
|
||
no rows; click "Try again" and assert the second request and the rows.
|
||
6. **401 and 403** - the app-wide redirects, no alert on the page.
|
||
7. **Create key: opened** - title, text, "0 / 60", helps, buttons, focus on Name.
|
||
8. **Create key: validation, Examples-driven** - the three Outline rows; error text, no request,
|
||
errors clear when fixed.
|
||
9. **Create key: name too long** - 75 characters; counter, error class and text, no request.
|
||
10. **Create key: success** - one POST with `{"name":"Nightly media import"}` and no expires_at
|
||
key; the reveal: title, text, warning alert, key equal to the mocked `key`, "Copy", note, only
|
||
"Done".
|
||
11. **Create key: expiry** - date D + 30 days; assert expires_at is 23:59:59 local on that day
|
||
with the local offset. Examples today (sent), yesterday (blocked), tomorrow (sent); build the
|
||
dates from `new Date()` in the test.
|
||
12. **Reveal: copied** - fake timers; click "Copy"; writeText with the full key, icon check,
|
||
"Copied", aria-live announcement; advance 2 s; "Copy" again.
|
||
13. **Reveal: Done and Escape discard** - assert the modal is gone, the key string appears nowhere
|
||
in `wrapper.html()`, the router URL, localStorage or sessionStorage; the new row shows the
|
||
prefix only, status "active", "Never", created by the session user; no second GET; a reopened
|
||
form is empty. A backdrop click leaves the reveal open.
|
||
14. **Create key: Cancel and Escape** - no request, empty on reopen, focus back on the opener;
|
||
a backdrop click does not close.
|
||
15. **Revoke: confirm and Keep key** - dialog copy with the 9-character prefix, both buttons;
|
||
"Keep key" closes with no request.
|
||
16. **Revoked** - confirm; one DELETE with the key's id; row "revoked", muted, no "Revoke"; toast
|
||
title and text; no second GET.
|
||
17. **Not drawn: Create sending** - pending POST; "Creating…", disabled fields and Cancel, Escape
|
||
ignored.
|
||
18. **Not drawn: Create failed, Examples-driven** - ApiError(500) "Couldn't create the key. Try
|
||
again in a moment.", NetworkError "Couldn't reach RenewCMS. Check your connection and try
|
||
again.", ApiError(429) "Too many requests. Wait a moment, then try again.", ApiError(400)
|
||
"Something in the form isn't valid. Check the highlighted fields."; values kept, form
|
||
re-enabled, no row added; submit again, resolve 201, assert the reveal.
|
||
19. **Not drawn: Revoke sending** - "Revoking…", both buttons disabled, Escape ignored.
|
||
20. **Not drawn: Revoke failed, Examples-driven** - ApiError(500) "Couldn't revoke the key. Try
|
||
again in a moment." and NetworkError "Couldn't reach RenewCMS. Check your connection and try
|
||
again."; the notice in the open dialog, row unchanged; retry succeeds.
|
||
21. **Not drawn: Revoke 404** - the dialog closes, the row is removed, the error toast "That key no
|
||
longer exists.".
|
||
22. **Not drawn: Last used "Never"** - null `last_used_at`, desktop cell and mobile card.
|
||
23. **Mobile pinned button** - below md the pinned 48 px "Create key" exists in list, loading and
|
||
error; hidden while a modal is open and in Empty; no header button below md.
|
||
24. **Integration link and navigation** - on /integration assert "Manage API keys" is a link to
|
||
/integration/api-keys below the three info notes; navigate; assert the sidebar and drawer
|
||
"Integration" item has aria-current="page" on /integration/api-keys. Update the existing
|
||
integration specs (features/integration.qa.md case 1) for the extra link.
|
||
25. **French** - `setLocale('fr')`; walk cases 1, 4, 7, 10, 15, 16, 17, 21; no visible English
|
||
source string remains; dates use the French format.
|
||
26. **Messages** - every new `t()` key has a French entry (extend the i18n spec pattern).
|
||
|
||
Un-run web tests are reported as NOT RUN, never as passing.
|
||
|
||
## Visual (`task visual:check -- integration-api-keys`, desktop and mobile, light and dark)
|
||
|
||
Reference: docs/design/screens/integration.md, board `docs/design/source/RenewCMS Screens API
|
||
keys.dc.html`, exact values in `docs/design/source/ApiKeysPage.dc.html`. Add each state's setup to
|
||
`web/visual/states.ts` with the board's own sample data (mocked GET /v1/api-key responses include
|
||
`created_by_name` and 12-character prefixes whose first 9 characters match the board). Hard gate:
|
||
every capture of every state within 10 %.
|
||
|
||
Sample data: the owner and admin fixtures from the feature's Background; Create modal name
|
||
"Nightly media import" (20 / 60) and expiry 31 Dec 2026; validation: empty name (0 / 60) and
|
||
expiry 12 Mar 2026; too long: "Nightly import of the new media library from the Brussels office
|
||
file server" (75 / 60); reveal key rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe for
|
||
"Nightly media import"; revoke and toast for "Nightly media import" (rcms_Mq7b…).
|
||
|
||
No state is clock-sensitive, since every date is fixed text. The Create sample expiry 31 Dec 2026
|
||
becomes a past date after that day, but the state only displays it, so it gets no skip.
|
||
|
||
States:
|
||
- [ ] Owner: list
|
||
- [ ] Admin: own keys only
|
||
- [ ] Loading (the setup holds the request)
|
||
- [ ] Empty
|
||
- [ ] Error
|
||
- [ ] Create key
|
||
- [ ] Create key: validation
|
||
- [ ] Create key: name too long
|
||
- [ ] One-time reveal
|
||
- [ ] One-time reveal: copied (timer-driven, so it is checked)
|
||
- [ ] Revoke: confirm
|
||
- [ ] Revoked (list with the revoked row and the toast)
|
||
- [ ] Layout from the page component, not components.md: 36 px header, 48 px rows, 20 px status
|
||
badge; desktop modal 480 px wide 120 px from the top; mobile modal full width, bottom-aligned,
|
||
buttons stacked with Cancel under the primary; mobile card Revoke 44 px; toast 320 px wide
|
||
bottom-right on desktop and 16 px inset on mobile.
|
||
- [ ] Not drawn on the board, no visual state, Vitest only: Create sending, Create failed (500,
|
||
network, 429, 400), Revoke sending, Revoke failed, Revoke 404, "Never", the dismissal rules,
|
||
and the Integration "Manage API keys" link.
|
||
|
||
Risk: the mobile pinned "Create key" button is not on the board, and the Integration link is not
|
||
on the board's frames for the current Integration page. Either may push a mobile or Integration
|
||
capture over 10 %. Re-run `task visual:check -- integration` and the API keys screen. If either
|
||
fails, the user must decide: an approved per-state exception needs `approvedBy`, or a design
|
||
update.
|
||
|
||
Manual checks screenshots cannot cover:
|
||
- Keyboard and focus order: Tab reaches "Create key", then each "Revoke" in row order; modal focus
|
||
starts on Name, is trapped, and returns to the opener on close; the reveal's initial focus lands
|
||
on "Copy".
|
||
- Hover and motion: outline and primary button hover; modal motion; skeleton pulse stopped under
|
||
reduced motion.
|
||
|
||
## Manual verification
|
||
|
||
- Real round trip against `task dev`: create a key, copy it, run
|
||
`curl -H "Authorization: Bearer <key>" <origin>/v1/auth/me` and get 200; revoke it from the
|
||
screen and get 401; confirm the key gets 403 "This action needs a real session." on
|
||
/v1/api-key.
|
||
- Owner and admin: as owner confirm the Created by names (display name, else username); as admin
|
||
confirm only your own keys and no Created by column.
|
||
- Reset-password revocation: reset a user's password and confirm their keys show "revoked" after
|
||
reload.
|
||
- Clipboard on a real HTTPS or localhost origin.
|
||
|
||
## French strings for web/src/i18n/messages.ts
|
||
|
||
Keyed by the English text. `{0}` and `{1}` are the `t()` placeholders.
|
||
|
||
- API keys = Clés d'API
|
||
- Integration › API keys = Intégration › Clés d'API
|
||
- Create key = Créer une clé
|
||
- Name = Nom
|
||
- Key = Clé
|
||
- Status = Statut
|
||
- Created by = Créée par
|
||
- Created = Créée le
|
||
- Last used = Dernière utilisation
|
||
- Expires = Expire le
|
||
- active = active
|
||
- expired = expirée
|
||
- revoked = révoquée
|
||
- Revoke = Révoquer
|
||
- None = Aucune
|
||
- Never = Jamais
|
||
- Keys act as the user who created them. As an owner you see everyone's keys. = Les clés agissent comme l'utilisateur qui les a créées. En tant que propriétaire, vous voyez les clés de tout le monde.
|
||
- Keys act as you, with your role. You only see your own keys. = Les clés agissent en votre nom, avec votre rôle. Vous ne voyez que vos propres clés.
|
||
- Resetting a user's password revokes all of their keys. = Réinitialiser le mot de passe d'un utilisateur révoque toutes ses clés.
|
||
- No API keys yet = Aucune clé d'API pour l'instant
|
||
- Create a key to call the API from a script or service. = Créez une clé pour appeler l'API depuis un script ou un service.
|
||
- Couldn't load your API keys = Impossible de charger vos clés d'API
|
||
- Check your connection and try again. = Vérifiez votre connexion et réessayez.
|
||
- Try again = Réessayer
|
||
- Create API key = Créer une clé d'API
|
||
- The key acts as you, with your role. It is shown in full once. = La clé agit en votre nom, avec votre rôle. Elle n'est affichée en entier qu'une seule fois.
|
||
- Required. Up to 60 characters. = Obligatoire. 60 caractères maximum.
|
||
- Expiry date (optional) = Date d'expiration (facultative)
|
||
- Optional. Must be in the future. Leave it empty for a key that does not expire. = Facultative. Doit être dans le futur. Laissez vide pour une clé qui n'expire pas.
|
||
- Enter a name. = Saisissez un nom.
|
||
- Use 60 characters or fewer. = Utilisez 60 caractères ou moins.
|
||
- Choose a date in the future. = Choisissez une date dans le futur.
|
||
- Cancel = Annuler
|
||
- Your new API key = Votre nouvelle clé d'API
|
||
- "{0}" is ready. Copy the key and store it somewhere safe. = « {0} » est prête. Copiez la clé et conservez-la en lieu sûr.
|
||
- You won't be able to see this key again. = Vous ne pourrez plus revoir cette clé.
|
||
- Copy = Copier
|
||
- Copied = Copié
|
||
- Closing this window discards the key from the screen. Copy it first. = Fermer cette fenêtre supprime la clé de l'écran. Copiez-la d'abord.
|
||
- Done = Terminé
|
||
- Revoke this key? = Révoquer cette clé ?
|
||
- "{0}" ({1}) will stop working right away. Requests that use it answer 401. This can't be undone. = « {0} » ({1}) cessera de fonctionner immédiatement. Les requêtes qui l'utilisent recevront une réponse 401. Cette action est irréversible.
|
||
- Keep key = Garder la clé
|
||
- Revoke key = Révoquer la clé
|
||
- Key revoked = Clé révoquée
|
||
- "{0}" can no longer be used. = « {0} » ne peut plus être utilisée.
|
||
- Creating… = Création…
|
||
- Revoking… = Révocation…
|
||
- Couldn't create the key. Try again in a moment. = Impossible de créer la clé. Réessayez dans un instant.
|
||
- Couldn't reach RenewCMS. Check your connection and try again. = Impossible de joindre RenewCMS. Vérifiez votre connexion et réessayez.
|
||
- Too many requests. Wait a moment, then try again. = Trop de requêtes. Patientez un instant, puis réessayez.
|
||
- Something in the form isn't valid. Check the highlighted fields. = Un élément du formulaire n'est pas valide. Vérifiez les champs signalés.
|
||
- Couldn't revoke the key. Try again in a moment. = Impossible de révoquer la clé. Réessayez dans un instant.
|
||
- That key no longer exists. = Cette clé n'existe plus.
|
||
- Manage API keys = Gérer les clés d'API
|