20 Commits

Author SHA1 Message Date
Jakub Zych
9986342c23 feat: console help, examples and named loggers (SummerCMS v0.4.0)
Every command gets long help and examples, descriptions in imperative
sentence case, and a warn-only TestCommandsDocumented over bonfire.CheckDocs.
The plugin logs through a diary component logger instead of the process
default logger. Requires SummerCMS v0.4.0; command names and flags unchanged.

user:token:create and user:token:list gain --json, user:backfill-handles a
progress bar; planning IDs leave the descriptions.
2026-10-21 18:03:50 +02:00
Jakub Zych
35cf16689d test: email_verified_at writers, non-writers and the migration
- TestEmailVerifiedAt: register (auto, admin), activate, activate-by-code, reset-password, change-password, update
- TestAdminEmailVerifiedAt: admin create, activate action, invitation link, email change, JSON
- TestEmailVerifiedAtMigration: column shape, no backfill, existing column kept, rollback keeps column
2026-10-21 14:01:52 +02:00
Jakub Zych
a99cb467b7 fix: an email change drops the address proof
- /update clears email_verified_at in the same write when the email changes (case-only changes keep it)
- the admin Users form clears it in FormBeforeUpdate; storedEmail helper shared with guardCredentials
- README: the proof follows the address, never mass-assigned, no backfill
2026-10-21 14:01:52 +02:00
Jakub Zych
add1f7a806 feat: users.email_verified_at records a proved address (mail activation, reset code)
- additive migration 202610210001_add_users_email_verified_at (ADD COLUMN IF NOT EXISTS, no backfill, no-op rollback)
- User.EmailVerifiedAt (json:"-", not fillable)
- VerifyActivationCode and ResetPassword stamp it in the write that activates or stores the password
2026-10-21 13:53:19 +02:00
Jakub Zych
c7635c56e3 test: change-password still requires the current password (PHP map)
TestChangePassword/real_password_account_still_requires_current_password
ports the PHP OAuthPasswordBootstrapTest case: a missing or wrong
current_password is a 422 with the unchanged body, and the password stays.
2026-10-21 01:23:13 +02:00
Jakub Zych
d6d8f117ea test: provider source, unlink rule and race, sign-in helpers
- TestOAuthProviders: old bytes without a source, order, per-request lookup
- TestOAuthIdentitiesDestroyRule: the PHP last-identity rule table, 404s,
  microsoft by default, source names and opts.Providers precedence
- TestOAuthIdentitiesDestroyRace: two row-locked transactions in both orders,
  the handler waiting for the lock, concurrent handlers
- TestRegisterUserPasswordLess, TestFinishLogin, TestRegistrationExports
2026-10-21 00:49:00 +02:00
Jakub Zych
c43ba87371 fix: user:backfill-handles opens its own database
- without a published *gorm.DB the command opens one with lagoon.OpenFromApp
  for its run and closes it, as the plugin's other commands do (withDB)
- TestBackfillHandlesOpensFromConfigWhenUnpublished; README CLI note
2026-10-20 23:07:14 +02:00
Jakub Zych
174161895e fix: atomic unlink with the PHP last-identity rule
- OAuthIdentitiesDestroy locks the users row (SELECT ... FOR UPDATE) and runs
  the PHP guarded DELETE in one transaction: a self-set password or another
  identity allows the unlink; otherwise 409 with the unchanged body
- the default allow-list adds microsoft, and an OAuthProviderSource's known
  names are used when the host passes no Providers
- TestOAuthIdentitiesDestroyLastIdentityWithPassword (204),
  TestOAuthIdentitiesDestroyConcurrentSmoke; README
- gofmt of the forgotPasswordBody line touched in the previous commit
2026-10-20 23:05:14 +02:00
Jakub Zych
78a867bccb feat: provider source for oauth-providers and sign-in helpers for social login
- classes.OAuthProvider and classes.OAuthProviderSource; GET /oauth-providers
  looks the source up per request and keeps the empty body without one
- exported RegistrationSettings, RegistrationsFromIP, RefreshTTL, RemoteIP
- RegisterOptions.PasswordLess (has_self_set_password=false) and FinishLogin
  (restore + reactivate mail, last_ip_address, last_seen; no event)
- TestOAuthProvidersSourceSmoke, TestSocialHooksSmoke; README
2026-10-20 23:02:37 +02:00
Jakub Zych
1fc0ce7ed0 test: TestTurnstileRoutes pins golem15.user.turnstile per mode
Replaces TestTurnstileRoutesSmoke: empty adds nothing; open, closed and a
space-padded closed add turnstile:{mode} to POST login and register only,
every other route keeps its middleware; invalid values fail naming the key.
2026-10-20 19:19:36 +02:00
Jakub Zych
7b9faa3419 feat: opt-in Cloudflare Turnstile on login and register (golem15.user.turnstile)
- empty (default) adds nothing; open or closed appends turnstile:{mode} to
  POST /login and /register only; any other value fails boot naming the key
- TestTurnstileRoutesSmoke records the declared routes with a recording router
2026-10-20 14:45:22 +02:00
4c5d3b8935 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	plugin.go
2026-10-19 22:35:54 +02:00
9335e80342 test: RequestUser, EndRequestSession, AvatarURL and user.Deactivating
- TestRequestUser: bearer, token and auth_token cookies in guard order;
  expired, foreign, blacklisted, revoked by tokens_valid_after and
  deactivated tokens are anonymous; only infrastructure failures error
- TestEndRequestSession (no database): jti blacklisted until the token
  expires, forget cookie with the configured Domain and Path, best effort
- TestAvatarURL: thumbnail, broken-image and storage-failure fallbacks,
  gravatar with the size, the user payload's avatar_url unchanged
- TestDeactivatingEvent: one event per newly deactivated user inside the
  bulk action's transaction, a listener error rolls the action back
2026-10-19 21:40:41 +02:00
80b436d3d9 feat: fire user.Deactivating from the admin deactivate action
- DeactivatingEvent and DeactivatingEventName in classes
- FireDeactivatingEvent modelled on FireDeletingEvent
- the deactivate bulk action runs DeactivateUsers unchanged, then fires
  the event per newly deactivated user in the action's transaction
2026-10-19 19:06:20 +02:00
ee474401cb feat: export AvatarURL for the display picture at any size
- AvatarURL returns the avatar thumbnail, the file path when the thumbnail
  fails, or the gravatar URL, the rule of the user payload
- applyAvatar takes avatar_url from it; the payload is unchanged
2026-10-19 18:55:57 +02:00
b82d85f440 feat: export RequestUser and EndRequestSession for OAuth providers
- RequestUser resolves the signed-in user exactly like the jwt guard
  (bearer, token and auth_token cookies, blacklist, tokens_valid_after)
  and returns nil for anonymous or invalid requests
- EndRequestSession blacklists the session JWT and forgets the token
  cookie with the configured cookie attributes, best effort
2026-10-19 18:42:38 +02:00
Jakub Zych
c0770294a5 feat(19-04): user authorizer, user.Deleting and handle backfill
Register lighthouse user:{id} if the namespace is free, fire user.Deleting before unscoped delete, and backfill missing handles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:53:21 +02:00
Jakub Zych
15b19c7021 feat(19-04): add users.handle, HandleGenerator and user.Created
Generate a unique mention handle on API register and admin create, and fire user.Created from both paths so listeners run in the same write.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 21:44:59 +02:00
569768e657 test(updates): account for the oauth-identities migration in history and rollback counts 2026-10-11 16:35:33 +02:00
Jakub Zych
24b16b1f10 fix: open Postgres for one-shot console commands 2026-10-07 13:35:59 +02:00
48 changed files with 3922 additions and 148 deletions

View File

@@ -31,11 +31,15 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
- **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code.
- **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`.
- **Last seen.** `users.last_seen` is stamped by `POST /login` and `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload.
- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set.
- **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none.
- **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name).
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created.
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never). `GetApiArrayEvent` lets other plugins merge keys into the user payload.
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never). `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `GetApiArrayEvent` lets other plugins merge keys into the user payload.
- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload.
- **OAuth providers.** `GET /oauth-providers` lists configured social login providers (none yet: it returns an empty list).
- **OAuth providers.** `GET /oauth-providers` lists the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before.
- **Sign-in helpers for social login.** `RegisterOptions.PasswordLess`, `FinishLogin`, `RegistrationSettings`, `RegistrationsFromIP`, `RefreshTTL` and `RemoteIP` let a social-login plugin create accounts and finish logins with the user plugin's own rules instead of copies.
## Usage
@@ -105,12 +109,12 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
### Host-mounted OAuth identities
The host application mounts these constructors on a path it chooses. They are not registered under `/_user/api/v1`. `OAuthIdentitiesOptions.WriteNotFound` is the host's 404 writer so missing, foreign and unknown providers share one body. `OAuthIdentitiesOptions.Providers` defaults to `google`, `facebook` and `github` when empty. The 409 last-method phrase is `golem15.user::lang.oauth.last_method_blocked`.
The host application mounts these constructors on a path it chooses. They are not registered under `/_user/api/v1`. `OAuthIdentitiesOptions.WriteNotFound` is the host's 404 writer so missing, foreign and unknown providers share one body. `OAuthIdentitiesOptions.Providers` is the unlink allow-list; when empty it is read on every request from a published `classes.OAuthProviderSource` (its `KnownOAuthProviders`), else it is `google`, `facebook`, `github` and `microsoft`. Unlinking follows the PHP rule: it succeeds when the user has a self-set password (`has_self_set_password`) or another identity remains, and otherwise answers 409 with the phrase `golem15.user::lang.oauth.last_method_blocked` (body unchanged). The guarded delete runs in a transaction that first locks the user's `users` row, so two concurrent unlinks never remove the last sign-in method.
| Constructor | Role |
|-------------|------|
| `controllers.OAuthIdentitiesIndex` | Lists the caller's linked providers as `{provider, linked_at}` only. |
| `controllers.OAuthIdentitiesDestroy` | Unlinks one provider for the caller. |
| `controllers.OAuthIdentitiesDestroy` | Unlinks one provider for the caller (204), or answers 409 when it is the last sign-in method of a user without a self-set password. |
| `controllers.OAuthIdentitiesOptions` | Host injects `Providers` and `WriteNotFound`. |
### Admin
@@ -149,22 +153,44 @@ Migrations added for linked OAuth identities, all additive:
|----|------|
| `202610050001_create_oauth_identities` | the table `golem15_user_oauth_identities` (`user_id` cascade FK to `users`, unique `(user_id, provider)` and `(provider, provider_id)`, jsonb `profile_data`) |
Migrations added for @mention handles, all additive:
| ID | Adds |
|----|------|
| `202610160002_add_handle_to_users` | `users.handle` (`VARCHAR(255)` NULL, unique index `users_handle_unique`, `ADD COLUMN IF NOT EXISTS`; rollback is a no-op) |
Migrations added for address proof, all additive:
| ID | Adds |
|----|------|
| `202610210001_add_users_email_verified_at` | `users.email_verified_at` (`TIMESTAMPTZ` NULL, `ADD COLUMN IF NOT EXISTS`, no backfill; rollback is a no-op) |
Packages:
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
- `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP.
- `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP; `RegisterOptions.PasswordLess` stores `has_self_set_password=false` for an account whose random password the user never knew (a social sign-in), while the zero value keeps `true`.
- `FireRegisterEvent(ctx, app, user, fields)` fires `RegisterEvent` with `Payload` set to a copy of `fields` without `password` and `password_confirmation`, and returns the listeners' error (`/register` ignores it). Fire it after the user row is committed.
- `FireCreatedEvent(ctx, app, user)` fires `CreatedEvent` (`user.Created`). `/register` ignores that error.
- `FireDeletingEvent(ctx, app, user, tx)` fires `DeletingEvent` (`user.Deleting`) inside the delete transaction.
- `FireDeactivatingEvent(ctx, app, user, tx)` fires `DeactivatingEvent` (`user.Deactivating`) inside the deactivation transaction. The admin deactivate bulk action fires it after `DeactivateUsers` for every selected user that was not deactivated yet; a listener error rolls the action back.
- `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do.
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
- `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks.
- `RegistrationSettings(app)` returns `allow_registration`, `use_register_throttle`, `activate_mode` and `require_activation` with their defaults (`true`, `true`, `auto`, `true`), and `RegistrationsFromIP(ctx, db, ip)` counts the accounts created from an IP in the last 60 minutes (`/register` refuses at 3 with the throttle on).
- `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr` that `/login` uses for its throttle.
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
- `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code.
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
- `TouchLastSeen(ctx, db, userID)`.
- `OAuthProvider` (`Name`, `Label`) and `OAuthProviderSource` (`EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), the seam a social-login plugin publishes with an explicit type argument, `app.Publish[classes.OAuthProviderSource](src)`, so the lookup finds it. `GET /oauth-providers` lists its enabled providers in order; the unlink routes accept its known names.
- `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` blacklists the request's JWT until it expires (as `POST /logout` does) and writes the forget cookie of `bouncer.LoadCookieConfig`, best effort: a blacklist failure is logged, never returned.
- `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128.
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`).
- `updates`: the gormigrate migrations, returned by `updates.All()`.
- `console`: `RequirePasswordChange` and `APITokenCommands`.
- `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`.
## Configuration
@@ -191,19 +217,23 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
| `golem15.user.password.min_length` | `8` | Minimum password length. |
| `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. |
| `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. |
| `golem15.user.turnstile` | `""` | Cloudflare Turnstile on `POST /login` and `POST /register`. Empty adds nothing; `open` or `closed` appends the framework's `turnstile:open` or `turnstile:closed` middleware to those two routes only (summercms v0.3.0 and later; secret in `turnstile.secret`, token in the `turnstile_token` field). `open` lets requests pass when Cloudflare is unreachable, `closed` refuses them. Any other value fails boot. |
## CLI commands
| Command | Purpose |
|---------|---------|
| `user:require-password-change <email>` | Flag an account so its next session must set a new password. |
| `user:token:create <email> [--name api] [--scopes read,write] [--expires RFC3339]` | Create an application-wide API token and print its secret once. |
| `user:token:list` | List token metadata without secrets or hashes. |
| `user:token:revoke <id>` | Revoke a shared API token immediately. |
| Command | Purpose | Example |
|---------|---------|---------|
| `user:require-password-change <email>` | Flag an account so its next session must set a new password. Ends with `Password change required for <email>`. | `bin/<app> user:require-password-change jane@example.com` |
| `user:backfill-handles` | Assign a unique handle to every user row that still has none, with a progress bar. Ends with `Assigned handles to N user(s)`. | `bin/<app> user:backfill-handles` |
| `user:token:create <email> [--name api] [--scopes read,write] [--expires RFC3339] [--json]` | Create an application-wide API token and print its secret once (also under `-q`); `--json` prints the token and its details as one object. | `bin/<app> user:token:create jane@example.com --scopes read --json` |
| `user:token:list` | List token metadata without secrets or hashes; `--json` prints a JSON array with the expiry, revocation and last-use times. | `bin/<app> user:token:list --json` |
| `user:token:revoke <id>` | Revoke a shared API token immediately. | `bin/<app> user:token:revoke 12` |
Each command uses the database the serve command published or, run from the console, opens one from `database.dsn` (and loads the encryption keys from `app.key`) for its duration. `bin/<app> help <command>` shows each command's long help and examples. The plugin logs through the component `user` (`log.components.user` in `config/log.yaml`).
## Dependencies
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `pact`, `party`, `surf` and others).
- The SummerCMS framework v0.4.0, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `diary`, `pact`, `party`, `surf` and others).
- gormigrate, GORM, golang-jwt and gocloud.dev, as pinned in `go.mod`.
- testcontainers-go for the Postgres-backed tests.
@@ -216,4 +246,6 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
`TestRegisterCORSPath` reads the host application's `config/http.yaml`, so run the tests inside an application checkout where the plugin is mounted at `plugins/golem15/user`.
`TestCommandsDocumented` (in `-short`) logs, without failing, each command whose help lacks a description, long help, examples or an option description (`bonfire.CheckDocs`).
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.

136
avatar_url_test.go Normal file
View File

@@ -0,0 +1,136 @@
package user
import (
"context"
"crypto/md5"
"fmt"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob/memblob"
)
// jpegAvatar stores a real JPEG avatar for user (a system_files row and its
// blob) and returns the row.
func (e *adminEnv) jpegAvatar(t *testing.T, user *models.User) attach.File {
t.Helper()
disk := "face" + adminStamp() + ".jpg"
data := jpegBytes(t)
if err := e.bucket.WriteAll(context.Background(), attach.BlobKey(disk), data, nil); err != nil {
t.Fatal(err)
}
row := attach.File{
DiskName: disk, FileName: "face.jpg", FileSize: int64(len(data)), ContentType: "image/jpeg",
Field: models.AvatarField, AttachmentID: strconv.FormatUint(uint64(user.ID), 10), AttachmentType: user.MorphName(),
}
if err := e.gdb.Create(&row).Error; err != nil {
t.Fatal(err)
}
return row
}
func gravatarFor(email string, size int) string {
return fmt.Sprintf("//www.gravatar.com/avatar/%x?s=%d&d=mm", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email)))), size)
}
// TestAvatarURL pins classes.AvatarURL (D-05): the avatar thumbnail at the
// asked size, the file path when the thumbnail fails, the gravatar
// fallback with the size, errors only for a failing lookup, and the user
// API payload (applyAvatar) unchanged: its avatar_url is AvatarURL at 128.
func TestAvatarURL(t *testing.T) {
env := newAdminEnv(t)
ctx := t.Context()
if _, err := classes.AvatarURL(ctx, env.app, nil, 128); err == nil {
t.Fatal("a nil user must be refused")
}
// Gravatar: no app, an unsaved user, no attachment; the email is
// trimmed and lower-cased, the size is the one asked for.
plain := env.seedUser(t, "plain", func(u *models.User) { u.Email = " Mixed.Case-" + adminStamp() + "@Example.TEST " })
for _, size := range []int{32, 128, 256} {
for name, call := range map[string]func() (string, error){
"no app": func() (string, error) { return classes.AvatarURL(ctx, nil, plain, size) },
"unsaved user": func() (string, error) { return classes.AvatarURL(ctx, env.app, &models.User{Email: plain.Email}, size) },
"no attachment": func() (string, error) { return classes.AvatarURL(ctx, env.app, plain, size) },
} {
got, err := call()
if err != nil || got != gravatarFor(plain.Email, size) {
t.Fatalf("%s at %d = %q %v, want %q", name, size, got, err, gravatarFor(plain.Email, size))
}
}
}
if gravatarFor(plain.Email, 1) != gravatarFor(strings.ToLower(strings.TrimSpace(plain.Email)), 1) {
t.Fatal("the gravatar hash is not of the normalised email")
}
// A file avatar: the thumbnail at size by size, mode auto.
withFile := env.seedUser(t, "withfile", nil)
row := env.jpegAvatar(t, withFile)
thumb, err := classes.AvatarURL(ctx, env.app, withFile, 64)
want := attach.ThumbFilename(row.ID, 64, 64, 0, 0, "auto", "jpg")
if err != nil || !strings.HasSuffix(thumb, want) || strings.HasPrefix(thumb, "//") {
t.Fatalf("thumbnail = %q %v, want a path ending in %s", thumb, err, want)
}
// Without a published bucket the gravatar is used even with a file.
noBucket := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
if err := noBucket.Publish(env.gdb); err != nil {
t.Fatal(err)
}
if got, err := classes.AvatarURL(ctx, noBucket, withFile, 64); err != nil || got != gravatarFor(withFile.Email, 64) {
t.Fatalf("no bucket = %q %v", got, err)
}
// An original that does not decode gets WinterCMS's broken-image
// thumbnail, still a thumbnail path.
notImage := env.seedUser(t, "notimage", nil)
env.seedAvatar(t, notImage) // its blob is not an image
if got, err := classes.AvatarURL(ctx, env.app, notImage, 128); err != nil || !strings.Contains(got, "thumb_") {
t.Fatalf("non-image avatar = %q %v", got, err)
}
// A thumbnail that cannot be made (here: the storage fails) falls back
// to the file path.
closed := memblob.OpenBucket(nil)
_ = closed.Close()
failing := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
if err := failing.Publish(env.gdb); err != nil {
t.Fatal(err)
}
if err := attach.Publish(failing, closed); err != nil {
t.Fatal(err)
}
if got, err := classes.AvatarURL(ctx, failing, withFile, 128); err != nil || got != "/"+attach.BlobKey(row.DiskName) {
t.Fatalf("failing thumbnail = %q %v, want /%s", got, err, attach.BlobKey(row.DiskName))
}
// A failing lookup is the only error.
canceled, cancel := context.WithCancel(ctx)
cancel()
if got, err := classes.AvatarURL(canceled, env.app, withFile, 128); err == nil {
t.Fatalf("failing lookup = %q, want an error", got)
}
// The user API payload is unchanged: avatar_url is AvatarURL at 128,
// next to has_avatar and the disk name.
payload, err := controllers.APIArray(ctx, env.app, withFile)
if err != nil {
t.Fatal(err)
}
at128, _ := classes.AvatarURL(ctx, env.app, withFile, 128)
if payload["has_avatar"] != true || payload["avatar"] != row.DiskName || payload["avatar_url"] != at128 {
t.Fatalf("payload avatar fields = %v %v %v, want true %s %s", payload["has_avatar"], payload["avatar"], payload["avatar_url"], row.DiskName, at128)
}
without, err := controllers.APIArray(ctx, env.app, plain)
if err != nil {
t.Fatal(err)
}
// Without one the payload keeps its gravatar default (size 25).
if without["has_avatar"] != false || without["avatar"] != gravatarFor(plain.Email, 25) {
t.Fatalf("payload without an avatar = %v %v", without["has_avatar"], without["avatar"])
}
}

View File

@@ -3,7 +3,6 @@ package classes
import (
"context"
"fmt"
"log/slog"
"strconv"
"time"
@@ -241,7 +240,7 @@ func ForceDeleteCleanup(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, u
// The rows are gone by now, so a blob that cannot be removed is
// orphaned, not lost data: log it and carry on.
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
slog.Default().Warn("golem15.user: remove attachment blobs of a deleted user", "user_id", userID, "err", err)
pluginLog.Warn("golem15.user: remove attachment blobs of a deleted user", "user_id", userID, "err", err)
}
})
return nil

48
classes/authorizer.go Normal file
View File

@@ -0,0 +1,48 @@
package classes
import (
"context"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lighthouse"
)
// AuthorizeUser is the lighthouse authorizer for the user namespace
// (D-17, T-19-10). It allows only user:{id} for that user. ChannelID is
// the two-segment reader (segment 1 is the id). A second presence: prefix
// or extra segments are denied.
func AuthorizeUser() lighthouse.AuthorizerFunc {
return func(_ context.Context, userID uint, channel string) lighthouse.Result {
name := strings.TrimPrefix(channel, "presence:")
if strings.HasPrefix(name, "presence:") {
return lighthouse.Denied("invalid user channel")
}
parts := strings.Split(name, ":")
if len(parts) != 2 || parts[0] != "user" {
return lighthouse.Denied("invalid user channel")
}
if userID == 0 || lighthouse.ChannelID(name) != int64(userID) {
return lighthouse.Denied("not that user")
}
return lighthouse.Allowed(nil)
}
}
// RegisterUserAuthorizer registers the user namespace if it is free. A
// missing lighthouse service is skipped so Boot stays additive on hosts
// that never mount realtime.
func RegisterUserAuthorizer(app *backpack.App) error {
svc, err := lighthouse.From(app)
if err != nil {
pluginLog.Warn("golem15.user: lighthouse unavailable; user authorizer not registered", "err", err)
return nil
}
if svc == nil || svc.Registry() == nil {
return nil
}
if _, ok := svc.Registry().Get("user"); ok {
return nil
}
return svc.Registry().Register("user", AuthorizeUser())
}

View File

@@ -0,0 +1,36 @@
package classes
import (
"context"
"testing"
"git.golem15.com/golem15/summercms/modules/lighthouse"
)
// TestUserAuthorizer: user:9 is allowed only for user 9 (T-19-10).
func TestUserAuthorizer(t *testing.T) {
auth := AuthorizeUser()
ctx := context.Background()
if got := auth.Authorize(ctx, 9, "user:9"); !got.Allowed {
t.Fatalf("user:9 for 9 denied: %s", got.Reason())
}
if got := auth.Authorize(ctx, 9, "presence:user:9"); !got.Allowed {
t.Fatalf("presence:user:9 for 9 denied: %s", got.Reason())
}
if lighthouse.ChannelID("user:9") != 9 {
t.Fatalf("ChannelID(user:9) = %d, want 9", lighthouse.ChannelID("user:9"))
}
for _, channel := range []string{"user:8", "user:9:extra", "chat:9", "user:", "user", "presence:presence:user:9"} {
if got := auth.Authorize(ctx, 9, channel); got.Allowed {
t.Errorf("%q for 9 allowed, want deny", channel)
}
}
if got := auth.Authorize(ctx, 8, "user:9"); got.Allowed {
t.Fatal("user:9 for 8 allowed, want deny")
}
if got := auth.Authorize(ctx, 0, "user:0"); got.Allowed {
t.Fatal("user:0 for 0 allowed, want deny")
}
}

64
classes/avatar.go Normal file
View File

@@ -0,0 +1,64 @@
package classes
import (
"context"
"crypto/md5"
"errors"
"fmt"
"strconv"
"strings"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// AvatarURL returns the display picture URL of user at size pixels, the
// rule the user API payload follows: the thumbnail (size by size, mode
// auto) of the user's avatar attachment when there is one and a file
// bucket is published, the attachment's file path when the thumbnail
// fails (logged), and otherwise the gravatar URL
// //www.gravatar.com/avatar/{md5 of the email}?s={size}&d=mm. The URL may
// be protocol-relative or relative; a caller that needs an absolute URL
// resolves it. Only a failing database lookup is an error.
func AvatarURL(ctx context.Context, app *backpack.App, user *models.User, size int) (string, error) {
if user == nil {
return "", errors.New("golem15.user: avatar needs a user")
}
fallback := gravatar(user.Email, size)
if app == nil || user.ID == 0 {
return fallback, nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return fallback, nil
}
var f attach.File
err := gdb.WithContext(ctx).
Where("attachment_type = ? AND attachment_id = ? AND field = ?", user.MorphName(), strconv.FormatUint(uint64(user.ID), 10), models.AvatarField).
Take(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return fallback, nil
}
if err != nil {
return "", err
}
bucket, ok := app.Lookup[*blob.Bucket]()
if !ok || bucket == nil {
return fallback, nil
}
url, err := f.Thumb(ctx, bucket, size, size, "auto")
if err != nil {
pluginLog.Error("golem15.user: avatar thumb", "err", err)
return "/" + attach.BlobKey(f.DiskName), nil
}
return url, nil
}
// gravatar is the gravatar URL of an email at size pixels.
func gravatar(email string, size int) string {
sum := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
return fmt.Sprintf("//www.gravatar.com/avatar/%x?s=%d&d=mm", sum, size)
}

View File

@@ -113,7 +113,9 @@ func VerifyResetCode(ctx context.Context, db *gorm.DB, userID uint, code string)
}
// VerifyActivationCode accepts the stored code inside the activation TTL.
// A soft-deleted row is visible and is restored on success.
// A soft-deleted row is visible and is restored on success. The code was
// mailed to the account's address, so success also records the proof of that
// address (email_verified_at, D-27) in the same write that activates it.
func VerifyActivationCode(ctx context.Context, db *gorm.DB, userID uint, code string) (*models.User, bool) {
user, err := takeUser(ctx, db, userID, true)
if err != nil || user == nil || user.IsActivated || user.ActivationCode == nil {
@@ -134,11 +136,13 @@ func VerifyActivationCode(ctx context.Context, db *gorm.DB, userID uint, code st
"activated_at": now,
"activation_code": nil,
"activation_code_issued_at": nil,
"email_verified_at": now,
}).Error; err != nil {
return nil, false
}
user.IsActivated = true
user.ActivatedAt = &now
user.EmailVerifiedAt = &now
user.ActivationCode = nil
user.ActivationCodeIssuedAt = nil
return user, true

View File

@@ -1,6 +1,18 @@
package classes
import "git.golem15.com/golem15/sm-user-plugin/models"
import (
"git.golem15.com/golem15/sm-user-plugin/models"
"gorm.io/gorm"
)
// CreatedEventName is the festival name of CreatedEvent (D-13 / RESEARCH A6).
const CreatedEventName = "user.Created"
// DeletingEventName is the festival name of DeletingEvent (D-13 / RESEARCH A6).
const DeletingEventName = "user.Deleting"
// DeactivatingEventName is the festival name of DeactivatingEvent.
const DeactivatingEventName = "user.Deactivating"
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
type GetApiArrayEvent struct {
@@ -27,3 +39,29 @@ type RegisterEvent struct {
// or hashed. Listeners must treat it as read-only input.
Payload map[string]any
}
// CreatedEvent fires from every user create path (API register and admin
// create) so listeners can run inside the same write, including handle
// generation (D-13, D-15). Tx is the create transaction when there is one.
type CreatedEvent struct {
User *models.User
Tx *gorm.DB
}
// DeletingEvent fires from FormAfterDelete before ForceDeleteCleanup /
// unscoped delete, inside the same transaction, so listeners (chatchannels
// GDPR) can sever rows before the user row is gone (D-13, T-19-07).
type DeletingEvent struct {
User *models.User
Tx *gorm.DB
}
// DeactivatingEvent fires after a user is soft-deleted (deactivated, the
// PHP $user->delete()) by the admin deactivate action, inside the same
// transaction, so listeners can revoke what a deactivated account must not
// keep (OAuth grants, for example) while leaving what a restore needs. A
// listener error rolls the deactivation back.
type DeactivatingEvent struct {
User *models.User
Tx *gorm.DB
}

View File

@@ -46,3 +46,41 @@ func TestRegisterEventFires(t *testing.T) {
t.Fatalf("event user = %#v", seen)
}
}
func TestCreatedEventFires(t *testing.T) {
if CreatedEventName != "user.Created" {
t.Fatalf("CreatedEventName = %q, want user.Created", CreatedEventName)
}
bus := festival.New()
var seen *models.User
bus.Listen[*CreatedEvent]("golem15.user", func(_ context.Context, e *CreatedEvent) error {
seen = e.User
return nil
})
user := &models.User{ID: 9, Email: "created@example.test"}
if err := bus.Fire(context.Background(), &CreatedEvent{User: user}); err != nil {
t.Fatal(err)
}
if seen == nil || seen.Email != user.Email {
t.Fatalf("created event user = %#v", seen)
}
}
func TestDeletingEventFires(t *testing.T) {
if DeletingEventName != "user.Deleting" {
t.Fatalf("DeletingEventName = %q, want user.Deleting", DeletingEventName)
}
bus := festival.New()
var seen *models.User
bus.Listen[*DeletingEvent]("golem15.user", func(_ context.Context, e *DeletingEvent) error {
seen = e.User
return nil
})
user := &models.User{ID: 9, Email: "gone@example.test"}
if err := bus.Fire(context.Background(), &DeletingEvent{User: user}); err != nil {
t.Fatal(err)
}
if seen == nil || seen.Email != user.Email {
t.Fatalf("deleting event user = %#v", seen)
}
}

160
classes/handle_generator.go Normal file
View File

@@ -0,0 +1,160 @@
package classes
import (
"context"
"errors"
"strconv"
"strings"
"unicode"
"git.golem15.com/golem15/sm-user-plugin/models"
"gorm.io/gorm"
)
const (
handleMinLen = 2
handleMaxLen = 255
)
// HandleGenerator ports the PHP ChatChannels HandleGenerator. The emitted
// charset [a-z0-9._-] is a strict subset of MentionParser [A-Za-z0-9._-],
// length 2–255, so every generated handle is matchable as an @-token.
type HandleGenerator struct{}
// Slug transliterates name to ASCII, lowercases, drops every rune outside
// [a-z0-9._-], then trims leading/trailing `._-`. A result shorter than 2
// is not a valid base (Generate falls back to user{id}).
func (HandleGenerator) Slug(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
b.WriteString(foldHandleRune(r))
}
return strings.Trim(b.String(), "._-")
}
// Generate returns a unique handle for name. The base is Slug(name); if
// that is shorter than 2 it falls back to "user{fallbackID}" (or "user"
// when no id is known). The smallest free integer suffix is then appended
// until isTaken returns false: first "James" → "james", second → "james1".
func (g HandleGenerator) Generate(name string, isTaken func(string) bool, fallbackID *uint) string {
base := g.Slug(name)
if len(base) < handleMinLen {
if fallbackID != nil {
base = "user" + strconv.FormatUint(uint64(*fallbackID), 10)
} else {
base = "user"
}
}
base = clipHandle(base)
candidate := base
i := 0
for isTaken != nil && isTaken(candidate) {
i++
candidate = clipHandle(base + strconv.Itoa(i))
}
return candidate
}
// AssignHandle writes a unique handle onto user when it has none, using
// the user's display name and id. It is idempotent when handle is already
// set. The write uses db (the create transaction when there is one).
func AssignHandle(ctx context.Context, db *gorm.DB, user *models.User) error {
if user == nil {
return errors.New("golem15.user: a handle needs a user")
}
if user.Handle != nil && strings.TrimSpace(*user.Handle) != "" {
return nil
}
if db == nil {
return errors.New("golem15.user: a handle needs a database")
}
name := ""
if user.Name != nil {
name = *user.Name
}
var fallback *uint
if user.ID != 0 {
id := user.ID
fallback = &id
}
gen := HandleGenerator{}
for attempt := 0; attempt < 8; attempt++ {
handle := gen.Generate(name, func(candidate string) bool {
var n int64
q := db.WithContext(ctx).Model(&models.User{}).Where("handle = ?", candidate)
if user.ID != 0 {
q = q.Where("id <> ?", user.ID)
}
if err := q.Count(&n).Error; err != nil {
return true
}
return n > 0
}, fallback)
user.Handle = &handle
res := db.WithContext(ctx).Model(user).Where("id = ?", user.ID).Update("handle", handle)
if res.Error == nil {
return nil
}
if !isUniqueHandleConflict(res.Error) {
return res.Error
}
}
return errors.New("golem15.user: could not allocate a unique handle")
}
func clipHandle(s string) string {
if len(s) <= handleMaxLen {
return s
}
return s[:handleMaxLen]
}
func isUniqueHandleConflict(err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "users_handle_unique") || strings.Contains(msg, "duplicate key")
}
// foldHandleRune maps r into the handle charset [a-z0-9._-] or "".
func foldHandleRune(r rune) string {
r = unicode.ToLower(r)
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
return string(r)
}
if mapped, ok := latinHandleFold[r]; ok {
return mapped
}
return ""
}
// latinHandleFold covers Latin-1 / Latin Extended-A letters PHP Str::ascii
// would emit for the names we generate handles from (Polish, French, German).
var latinHandleFold = map[rune]string{
'à': "a", 'á': "a", 'â': "a", 'ã': "a", 'ä': "a", 'å': "a", 'ą': "a", 'ā': "a",
'æ': "ae",
'ç': "c", 'ć': "c", 'č': "c",
'ď': "d", 'đ': "d", 'ð': "d",
'è': "e", 'é': "e", 'ê': "e", 'ë': "e", 'ę': "e", 'ē': "e", 'ě': "e",
'ì': "i", 'í': "i", 'î': "i", 'ï': "i", 'ī': "i",
'ł': "l",
'ñ': "n", 'ń': "n", 'ň': "n",
'ò': "o", 'ó': "o", 'ô': "o", 'õ': "o", 'ö': "o", 'ø': "o", 'ō': "o",
'œ': "oe",
'ř': "r",
'ś': "s", 'š': "s", 'ş': "s", 'ß': "ss",
'ť': "t", 'þ': "th",
'ù': "u", 'ú': "u", 'û': "u", 'ü': "u", 'ū': "u",
'ý': "y", 'ÿ': "y",
'ź': "z", 'ż': "z", 'ž': "z",
}

View File

@@ -0,0 +1,78 @@
package classes
import "testing"
// TestHandle: generator charset is a subset of MentionParser, uniqueness
// uses the smallest free integer suffix, and short names fall back to user{id}.
func TestHandle(t *testing.T) {
t.Parallel()
g := HandleGenerator{}
if got := g.Slug("Paweł"); got != "pawel" {
t.Fatalf("slug Paweł = %q, want pawel", got)
}
if got := g.Slug("John Doe"); got != "johndoe" {
t.Fatalf("slug John Doe = %q, want johndoe", got)
}
if got := g.Slug("Zoë"); got != "zoe" {
t.Fatalf("slug Zoë = %q, want zoe", got)
}
handle := g.Generate("James", func(string) bool { return false }, nil)
if handle != "james" {
t.Fatalf("generate James = %q, want james", handle)
}
if !validHandle(handle) {
t.Fatalf("generated %q is outside [a-z0-9._-]{2,255}", handle)
}
taken := map[string]struct{}{}
isTaken := func(c string) bool {
_, ok := taken[c]
return ok
}
first := g.Generate("James", isTaken, nil)
taken[first] = struct{}{}
second := g.Generate("James", isTaken, nil)
taken[second] = struct{}{}
third := g.Generate("James", isTaken, nil)
if first != "james" || second != "james1" || third != "james2" {
t.Fatalf("suffixes = %q %q %q, want james james1 james2", first, second, third)
}
id := uint(42)
if got := g.Generate("", func(string) bool { return false }, &id); got != "user42" {
t.Fatalf("empty name = %q, want user42", got)
}
if got := g.Generate("x", func(string) bool { return false }, &id); got != "user42" {
t.Fatalf("short name = %q, want user42", got)
}
if got := g.Generate("???", func(string) bool { return false }, &id); got != "user42" {
t.Fatalf("punctuation name = %q, want user42", got)
}
if got := g.Generate("", func(string) bool { return false }, nil); got != "user" {
t.Fatalf("no id = %q, want user", got)
}
names := []string{"James", "Paweł", "John Doe", "???", "a", "Zoë", " ", "....", "O'Brien"}
for _, name := range names {
got := g.Generate(name, func(string) bool { return false }, &id)
if !validHandle(got) {
t.Errorf("generated %q for %q is outside the MentionParser subset", got, name)
}
}
}
func validHandle(s string) bool {
if len(s) < 2 || len(s) > 255 {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
default:
return false
}
}
return true
}

7
classes/logger.go Normal file
View File

@@ -0,0 +1,7 @@
package classes
import "git.golem15.com/golem15/summercms/modules/diary"
// pluginLog is the plugin's logger: its records carry component=user, so
// config/log.yaml can raise or lower them (log.components.user).
var pluginLog = diary.For("user")

View File

@@ -0,0 +1,31 @@
package classes
import "context"
// OAuthProvider is one social sign-in provider as GET
// /_user/api/v1/oauth-providers lists it: Name is the route segment
// (/oauth/{name}) and Label the button text.
type OAuthProvider struct {
Name string `json:"name"`
Label string `json:"label"`
}
// OAuthProviderSource is what a social-login plugin publishes so the user
// plugin can list its providers and accept them in the unlink routes. The
// user plugin looks it up on every request, so a plugin that boots later is
// still found.
//
// Publish it with an explicit type argument,
//
// app.Publish[classes.OAuthProviderSource](src)
//
// because a bare app.Publish(src) keys the service on its concrete type and
// the lookup misses it (the list then stays empty).
type OAuthProviderSource interface {
// EnabledOAuthProviders returns the providers that are configured right
// now, in display order.
EnabledOAuthProviders(ctx context.Context) []OAuthProvider
// KnownOAuthProviders returns the name of every registered provider,
// enabled or not: an identity of any of them may be unlinked.
KnownOAuthProviders() []string
}

151
classes/session_user.go Normal file
View File

@@ -0,0 +1,151 @@
package classes
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
// sessionCookieNames are the cookies the plugin's "jwt" guard reads after
// the Authorization header, in the same order (plugin.go Boot).
var sessionCookieNames = []string{"token", "auth_token"}
// RequestUser returns the signed-in user of r for handlers that run without
// the jwt.auth middleware and must also serve anonymous requests (an OAuth
// provider's authorize page, for example). The token is read and checked
// exactly as the plugin's "jwt" guard does: the Authorization bearer header
// first, then the token and auth_token cookies; the plugin's JWT secret;
// the published bouncer.BlacklistStore when there is one; and the user's
// tokens_valid_after cutoff. A soft-deleted user is not returned.
//
// An anonymous request, or a token that is invalid, expired, blacklisted or
// names no user, returns (nil, nil). Only an infrastructure failure (no
// database handle, a failing query or blacklist lookup) returns an error.
func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*models.User, error) {
if r == nil {
return nil, nil
}
raw := sessionToken(r)
if raw == "" {
return nil, nil
}
secret, err := JWTSecret(app)
if err != nil {
return nil, err
}
sub, iat, _, jti, err := bouncer.VerifyClaims(raw, secret)
if err != nil {
return nil, nil
}
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil || id == 0 {
return nil, nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
}
var user models.User
err = gdb.WithContext(ctx).Where("id = ?", uint(id)).Take(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil {
blocked, err := bl.IsBlacklisted(ctx, jti)
if err != nil {
return nil, err
}
if blocked {
return nil, nil
}
}
if user.TokensValidAfter != nil && iat.Before(*user.TokensValidAfter) {
return nil, nil
}
return &user, nil
}
// EndRequestSession signs the request's user out the way Logout does and
// forgets the token cookie. When r carries a valid session JWT its jti is
// added to the published bouncer.BlacklistStore until the token expires;
// the response always gets the forget cookie of
// bouncer.LoadCookieConfig(app.Config), so its Domain and Path match the
// cookie the login wrote. It is best effort, as in the PHP provider: a
// blacklist failure is logged and never returned, and the function writes
// no status or body.
func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) error {
if r != nil {
if raw := sessionToken(r); raw != "" {
revokeSessionToken(ctx, app, raw)
}
}
if w == nil {
return nil
}
cfg := bouncer.CookieConfig{Name: bouncer.DefaultCookieName, Path: "/", SameSite: http.SameSiteLaxMode}
if app != nil && app.Config != nil {
loaded, err := bouncer.LoadCookieConfig(app.Config)
if err != nil {
pluginLog.Warn("golem15.user: end session: cookie config", "err", err)
} else {
cfg = loaded
}
}
http.SetCookie(w, cfg.ForgetTokenCookie())
return nil
}
// revokeSessionToken blacklists raw's jti until its expiry (best effort).
func revokeSessionToken(ctx context.Context, app *backpack.App, raw string) {
if app == nil {
return
}
secret, err := JWTSecret(app)
if err != nil {
return
}
_, _, exp, jti, err := bouncer.VerifyClaims(raw, secret)
if err != nil || jti == "" {
return
}
bl, ok := app.Lookup[bouncer.BlacklistStore]()
if !ok || bl == nil {
return
}
if err := bl.Add(ctx, jti, exp, time.Now()); err != nil {
pluginLog.Warn("golem15.user: end session: blacklist token", "err", err)
}
}
// sessionToken extracts the session JWT as the "jwt" guard does: the
// Authorization bearer header, then each session cookie in order.
func sessionToken(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization"))
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
if token = strings.TrimSpace(token); token != "" {
return token
}
}
for _, name := range sessionCookieNames {
c, err := r.Cookie(name)
if err != nil {
continue
}
if v := strings.TrimSpace(c.Value); v != "" {
return v
}
}
return ""
}

View File

@@ -0,0 +1,21 @@
package user
import (
"testing"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
// TestCommandsDocumented logs every console command whose help lacks a
// description, long help, examples or an option description
// (bonfire.CheckDocs). It only warns: the framework's own coverage test
// fails on a gap, plugin repositories report it.
func TestCommandsDocumented(t *testing.T) {
commands := (&Plugin{}).Commands()
if len(commands) == 0 {
t.Fatal("the plugin declares no commands")
}
for _, problem := range bonfire.CheckDocs(commands) {
t.Logf("%s", problem)
}
}

View File

@@ -24,3 +24,8 @@ api_tokens:
prefix: usr_
privileged_groups:
- admin
# Cloudflare Turnstile on POST /login and /register. Empty (the default) adds
# nothing. "open" lets requests pass when Cloudflare is unreachable, "closed"
# refuses them. The turnstile:* middleware comes from the framework (summercms
# v0.3.0 and later); its secret is turnstile.secret (SUMMER_TURNSTILE__SECRET).
turnstile: ""

View File

@@ -2,6 +2,7 @@ package console
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -16,25 +17,37 @@ import (
// APITokenCommands are the application-wide token create/list/revoke
// commands. Domain plugins deliberately do not mint their own credentials.
func APITokenCommands(app *backpack.App) []bonfire.Command {
withDB := func(ctx context.Context, fn func(*gorm.DB) error) error {
if app == nil {
return fmt.Errorf("golem15.user: app is nil")
}
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
// withDB is the plugin's database accessor so the commands work both during
// serve and as one-shot CLI runs that open Postgres from config.
func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm.DB) error) error) []bonfire.Command {
open := func(ctx context.Context, fn func(*gorm.DB) error) error {
if withDB == nil {
return fmt.Errorf("golem15.user: database is not configured")
}
return fn(db.WithContext(ctx))
return withDB(ctx, fn)
}
return []bonfire.Command{
{
Name: "user:token:create", Description: "Create one API token usable by application plugins",
Long: `Mints a personal API token that acts as the user with this e-mail, for
scripts and integrations that call the application's API. --scopes limits
what it may do (read, write, ai); --expires sets an RFC3339 expiry, and
without it the token does not expire. The secret is printed once, on its own
line (also under -q), and only its hash is stored, so keep it now; with
--json the token and its details are printed as one JSON object instead.
Revoke a token with user:token:revoke and list them with user:token:list.`,
Args: []bonfire.Arg{{Name: "email", Description: "E-mail of the user the token acts as", Required: true}},
Flags: []bonfire.Flag{
{Name: "name", Description: "What the token is for", Default: "api"},
{Name: "scopes", Description: "Comma-separated scopes: read, write, ai", Default: "read,write"},
{Name: "expires", Description: "RFC3339 expiry; empty means no expiry"},
{Name: "json", Description: "Print the token and its details as JSON", Bare: true},
},
Examples: []bonfire.Example{
{Command: "user:token:create jane@example.com --name backup-script --scopes read", Description: "A read-only token for a script"},
{Command: "user:token:create jane@example.com --expires 2027-01-01T00:00:00Z", Description: "A read and write token that expires"},
{Command: "user:token:create jane@example.com --json", Description: "Print the token and its details as JSON"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
email, _ := in.Argument("email")
@@ -54,7 +67,7 @@ func APITokenCommands(app *backpack.App) []bonfire.Command {
}
expiresAt = &parsed
}
return withDB(ctx, func(db *gorm.DB) error {
return open(ctx, func(db *gorm.DB) error {
user, err := classes.LookupByEmail(ctx, db, strings.TrimSpace(email))
if err != nil {
return err
@@ -73,6 +86,17 @@ func APITokenCommands(app *backpack.App) []bonfire.Command {
if err := db.Create(token).Error; err != nil {
return fmt.Errorf("user:token:create: %w", err)
}
if flagOn(in, "json") {
return printJSON(out, map[string]any{
"id": token.ID,
"user_id": token.UserID,
"email": user.Email,
"name": token.Name,
"token": secret,
"scopes": token.Scopes.Get(),
"expires_at": token.ExpiresAt,
})
}
out.Printf("token %d for %s, scopes %s\n", token.ID, email, strings.Join(token.Scopes.Get(), ","))
out.Println(secret)
out.Success("user:token:create: store the secret now; it is not shown again")
@@ -82,13 +106,25 @@ func APITokenCommands(app *backpack.App) []bonfire.Command {
},
{
Name: "user:token:list", Description: "List API tokens",
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
return withDB(ctx, func(db *gorm.DB) error {
Long: `Lists every personal API token: its id, the user it acts as, its name, its
scopes and its state (active, expired or revoked). Secrets are never shown;
only their hashes are stored. --json prints the tokens as a JSON array with
their expiry, revocation and last use times.`,
Flags: []bonfire.Flag{
{Name: "json", Description: "Print the tokens as JSON", Bare: true},
},
Examples: []bonfire.Example{
{Command: "user:token:list", Description: "Show every token as a table"},
{Command: "user:token:list --json", Description: "Print the tokens as JSON for a script"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return open(ctx, func(db *gorm.DB) error {
var tokens []models.APIToken
if err := db.Order("id").Find(&tokens).Error; err != nil {
return fmt.Errorf("user:token:list: %w", err)
}
rows := make([][]string, 0, len(tokens))
list := make([]map[string]any, 0, len(tokens))
for _, token := range tokens {
state := "active"
if token.RevokedAt != nil {
@@ -101,6 +137,20 @@ func APITokenCommands(app *backpack.App) []bonfire.Command {
name = *token.Name
}
rows = append(rows, []string{strconv.FormatUint(uint64(token.ID), 10), strconv.FormatUint(uint64(token.UserID), 10), name, strings.Join(token.Scopes.Get(), ","), state})
list = append(list, map[string]any{
"id": token.ID,
"user_id": token.UserID,
"name": token.Name,
"scopes": token.Scopes.Get(),
"state": state,
"expires_at": token.ExpiresAt,
"revoked_at": token.RevokedAt,
"last_used_at": token.LastUsedAt,
"created_at": token.CreatedAt,
})
}
if flagOn(in, "json") {
return printJSON(out, list)
}
out.Table([]string{"id", "user", "name", "scopes", "state"}, rows)
return nil
@@ -109,14 +159,20 @@ func APITokenCommands(app *backpack.App) []bonfire.Command {
},
{
Name: "user:token:revoke", Description: "Revoke an API token",
Long: `Revokes the active API token with this id (see user:token:list): requests
that present it are refused from now on. The token row stays, marked
revoked; a token that is already revoked or does not exist is an error.`,
Args: []bonfire.Arg{{Name: "id", Description: "Token id", Required: true}},
Examples: []bonfire.Example{
{Command: "user:token:revoke 12", Description: "Revoke the token with id 12"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
raw, _ := in.Argument("id")
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id == 0 {
return fmt.Errorf("user:token:revoke: %q is not a token id", raw)
}
return withDB(ctx, func(db *gorm.DB) error {
return open(ctx, func(db *gorm.DB) error {
res := db.Model(&models.APIToken{}).Where("id = ? AND revoked_at IS NULL", id).UpdateColumn("revoked_at", time.Now())
if res.Error != nil {
return fmt.Errorf("user:token:revoke: %w", res.Error)
@@ -131,3 +187,24 @@ func APITokenCommands(app *backpack.App) []bonfire.Command {
},
}
}
// printJSON writes v as indented JSON and a newline on out: data, printed
// under -q too.
func printJSON(out bonfire.Output, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
out.Println(string(b))
return nil
}
// flagOn reads a bare flag ("true" when given).
func flagOn(in bonfire.Input, name string) bool {
v, _ := in.Flag(name)
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes":
return true
}
return false
}

View File

@@ -0,0 +1,71 @@
package console
import (
"context"
"fmt"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// BackfillHandles fills a unique handle on every user row that still has none.
// It uses the database the serve command published or, when it runs as a
// console command, opens one from the config for the duration of the run,
// as the plugin's other commands do.
func BackfillHandles(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "user:backfill-handles",
Description: "Assign unique handles to users that do not have one",
Long: `Gives every user without a handle a unique one, derived the same way as
for a new registration, with a progress bar over the users (hidden under
-q). Users that already have a handle are not touched, so running it again
changes nothing.
Run it once after copying users from WinterCMS, which had no handles. It
ends with "Assigned handles to N user(s)".`,
Examples: []bonfire.Example{
{Command: "user:backfill-handles", Description: "Assign a handle to every user that has none"},
{Command: "user:backfill-handles -q", Description: "Run it quietly, printing only the result line"},
},
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
if app == nil {
return fmt.Errorf("user:backfill-handles: app is nil")
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
sqlDB, opened, err := lagoon.OpenFromApp(ctx, app)
if err != nil {
return fmt.Errorf("user:backfill-handles: %w", err)
}
defer sqlDB.Close()
gdb = opened
}
var users []models.User
if err := gdb.WithContext(ctx).Where("handle IS NULL OR handle = ''").Find(&users).Error; err != nil {
return err
}
n := 0
if len(users) > 0 {
err := out.Progress(len(users), func(p bonfire.Progress) error {
for i := range users {
if err := classes.AssignHandle(ctx, gdb, &users[i]); err != nil {
return err
}
n++
p.Advance(1)
}
return nil
})
if err != nil {
return err
}
}
out.Success(fmt.Sprintf("Assigned handles to %d user(s)", n))
return nil
},
}
}

View File

@@ -7,16 +7,22 @@ import (
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"gorm.io/gorm"
)
// RequirePasswordChange forces the named account to set a new password on next login.
func RequirePasswordChange(app *backpack.App) bonfire.Command {
func RequirePasswordChange(withDB func(context.Context, func(*gorm.DB) error) error) bonfire.Command {
return bonfire.Command{
Name: "user:require-password-change",
Description: "Force a user to change their password on next login (D-21)",
Description: "Force a user to change their password on next login",
Long: `Marks the account with this e-mail so that it must set a new password at its
next sign-in; setting the password clears the mark. Use it after a suspected
leak or when handing over an account created with a temporary password. It
ends with "Password change required for <email>".`,
Examples: []bonfire.Example{
{Command: "user:require-password-change jane@example.com", Description: "Make Jane set a new password at her next sign-in"},
},
Args: []bonfire.Arg{{
Name: "email",
Description: "Account email",
@@ -28,29 +34,27 @@ func RequirePasswordChange(app *backpack.App) bonfire.Command {
if !ok || email == "" {
return fmt.Errorf("email is required")
}
if app == nil {
return fmt.Errorf("user:require-password-change: app is nil")
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
if withDB == nil {
return fmt.Errorf("user:require-password-change: database is not configured")
}
user, err := classes.LookupByEmail(ctx, gdb, email)
if err != nil {
return err
}
if user == nil {
return fmt.Errorf("user %s not found", email)
}
res := gdb.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).Update("must_change_password", true)
if res.Error != nil {
return res.Error
}
if res.RowsAffected != 1 {
return fmt.Errorf("user %s was not updated", email)
}
out.Success("Password change required for " + user.Email)
return nil
return withDB(ctx, func(gdb *gorm.DB) error {
user, err := classes.LookupByEmail(ctx, gdb, email)
if err != nil {
return err
}
if user == nil {
return fmt.Errorf("user %s not found", email)
}
res := gdb.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).Update("must_change_password", true)
if res.Error != nil {
return res.Error
}
if res.RowsAffected != 1 {
return fmt.Errorf("user %s was not updated", email)
}
out.Success("Password change required for " + user.Email)
return nil
})
},
}
}

View File

@@ -3,12 +3,15 @@ package user
import (
"bytes"
"context"
"encoding/base64"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
func TestRequirePasswordChange(t *testing.T) {
@@ -16,17 +19,7 @@ func TestRequirePasswordChange(t *testing.T) {
user := insertUser(t, gdb, "alice@example.com", "correct-horse")
p := &Plugin{app: app}
var _ pact.HasCommands = p
cmds := p.Commands()
var cmd bonfire.Command
for _, candidate := range cmds {
if candidate.Name == "user:require-password-change" {
cmd = candidate
break
}
}
if cmd.Name == "" {
t.Fatalf("commands = %#v", cmds)
}
cmd := commandNamed(t, p.Commands(), "user:require-password-change")
var buf bytes.Buffer
out := bonfire.NewOutput(nil, &buf, &buf)
if err := cmd.Run(context.Background(), emailArg{email: "alice@example.com"}, out); err != nil {
@@ -47,6 +40,89 @@ func TestRequirePasswordChange(t *testing.T) {
}
}
func TestAPITokenCreateCommand(t *testing.T) {
app, gdb := sessionApp(t, "0s")
user := insertUser(t, gdb, "pat@example.com", "correct-horse")
cmd := commandNamed(t, (&Plugin{app: app}).Commands(), "user:token:create")
var buf bytes.Buffer
out := bonfire.NewOutput(nil, &buf, &buf)
if err := cmd.Run(context.Background(), tokenCreateArg{email: user.Email, name: "rome scripts", scopes: "read,write"}, out); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "usr_") {
t.Fatalf("secret missing from output %q", buf.String())
}
var n int64
if err := gdb.Model(&models.APIToken{}).Where("user_id = ? AND revoked_at IS NULL", user.ID).Count(&n).Error; err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("tokens = %d", n)
}
}
func TestConsoleWithDBOpensFromConfigWhenUnpublished(t *testing.T) {
p := &Plugin{app: backpack.New(sessionConfig(t))}
err := p.withDB(context.Background(), func(*gorm.DB) error {
t.Fatal("fn should not run without a DSN")
return nil
})
if err == nil {
t.Fatal("expected OpenFromApp to fail")
}
if strings.Contains(err.Error(), "database is not configured") {
t.Fatalf("still the Lookup-only error: %v", err)
}
if !strings.Contains(err.Error(), "database.dsn is empty") {
t.Fatalf("got %v", err)
}
}
// TestBackfillHandlesOpensFromConfigWhenUnpublished runs user:backfill-handles
// as the console does: no published database, only database.dsn in the config.
func TestBackfillHandlesOpensFromConfigWhenUnpublished(t *testing.T) {
_, gdb := sessionApp(t, "0s")
user := insertUser(t, gdb, "backfill-unpublished@example.test", "correct-horse")
if err := gdb.Model(user).Update("handle", nil).Error; err != nil {
t.Fatal(err)
}
app := backpack.New(sessionConfig(t))
if err := app.Config.Set("database.dsn", sessionDSN); err != nil {
t.Fatal(err)
}
// Opening a database also loads the encryption keys from app.key.
if err := app.Config.Set("app.key", base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))); err != nil {
t.Fatal(err)
}
if _, ok := app.Lookup[*gorm.DB](); ok {
t.Fatal("setup: a database is published")
}
cmd := commandNamed(t, (&Plugin{app: app}).Commands(), "user:backfill-handles")
var buf bytes.Buffer
out := bonfire.NewOutput(nil, &buf, &buf)
if err := cmd.Run(context.Background(), emailArg{}, out); err != nil {
t.Fatalf("run: %v (output %q)", err, buf.String())
}
var fresh models.User
if err := gdb.First(&fresh, user.ID).Error; err != nil {
t.Fatal(err)
}
if fresh.Handle == nil || *fresh.Handle == "" {
t.Fatal("the user still has no handle")
}
}
func commandNamed(t *testing.T, cmds []bonfire.Command, name string) bonfire.Command {
t.Helper()
for _, candidate := range cmds {
if candidate.Name == name {
return candidate
}
}
t.Fatalf("commands = %#v", cmds)
return bonfire.Command{}
}
type emailArg struct{ email string }
func (e emailArg) Args() []string { return []string{e.email} }
@@ -61,3 +137,27 @@ func (e emailArg) Argument(name string) (string, bool) {
func (e emailArg) Flag(string) (string, bool) { return "", false }
func (e emailArg) Flags(string) []string { return nil }
type tokenCreateArg struct{ email, name, scopes string }
func (a tokenCreateArg) Args() []string { return []string{a.email} }
func (a tokenCreateArg) Argument(name string) (string, bool) {
if name == "email" {
return a.email, true
}
return "", false
}
func (a tokenCreateArg) Flag(name string) (string, bool) {
switch name {
case "name":
return a.name, true
case "scopes":
return a.scopes, true
default:
return "", false
}
}
func (a tokenCreateArg) Flags(string) []string { return nil }

View File

@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
@@ -110,7 +109,7 @@ func Login(app *backpack.App) http.HandlerFunc {
}
// A failed last_seen write is logged and never fails the login.
if err := classes.TouchLastSeen(ctx, gdb, user.ID); err != nil {
slog.Default().Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err)
pluginLog.Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err)
}
token, err := mintFor(app, secret, user, requestURL(r))
if err != nil {
@@ -204,7 +203,7 @@ func Refresh(app *backpack.App) http.HandlerFunc {
if sub, _, _, _, err := bouncer.VerifyClaims(next, secret); err == nil {
if id, err := strconv.ParseUint(sub, 10, 64); err == nil && id != 0 {
if err := classes.TouchLastSeen(r.Context(), gdb, uint(id)); err != nil {
slog.Default().Warn("golem15.user: stamp last_seen on refresh", "user_id", id, "err", err)
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", id, "err", err)
}
}
}
@@ -212,16 +211,35 @@ func Refresh(app *backpack.App) http.HandlerFunc {
}
}
// OAuthProviders reports that this app configures no social providers.
// OAuthProviders lists the social sign-in providers a social-login plugin
// has enabled, as {success: true, providers: [{name, label}]}. The plugin
// publishes a classes.OAuthProviderSource, looked up on every request
// because it boots after this plugin; without one the list is empty.
func OAuthProviders(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
providers := []any{}
if src, ok := oauthProviderSource(app); ok {
for _, p := range src.EnabledOAuthProviders(r.Context()) {
providers = append(providers, map[string]any{"name": p.Name, "label": p.Label})
}
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"providers": wire.Slice([]any{}),
"providers": wire.Slice(providers),
})
}
}
// oauthProviderSource returns the social-login plugin's published provider
// source, if any.
func oauthProviderSource(app *backpack.App) (classes.OAuthProviderSource, bool) {
if app == nil {
return nil, false
}
src, ok := app.Lookup[classes.OAuthProviderSource]()
return src, ok && src != nil
}
const forgotPasswordBody = "If that email exists, a reset link has been sent."
// ForgotPassword always returns the same 200 body. A real account also gets a reset mail.
@@ -250,14 +268,14 @@ func ForgotPassword(app *backpack.App) http.HandlerFunc {
ctx := codeContext(r.Context(), app)
user, err := classes.LookupByEmail(ctx, gdb, strField(fields, "email"))
if err != nil {
slog.Default().Error("golem15.user: forgot-password lookup", "err", err)
pluginLog.Error("golem15.user: forgot-password lookup", "err", err)
writeJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
return
}
if user != nil {
code, err := classes.IssueResetCode(ctx, gdb, user)
if err != nil {
slog.Default().Error("golem15.user: issue reset code", "err", err)
pluginLog.Error("golem15.user: issue reset code", "err", err)
} else {
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed))
@@ -310,10 +328,14 @@ func ResetPassword(app *backpack.App) http.HandlerFunc {
writeOpaque500(w)
return
}
// The reset code was mailed to the account's address, so the write
// that stores the new password also records the proof of that
// address (D-27). A reset that fails here records nothing.
now := time.Now()
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
"password": hashed,
"tokens_valid_after": now,
"email_verified_at": now,
}).Error; err != nil {
writeOpaque500(w)
return
@@ -448,17 +470,29 @@ func Update(app *backpack.App) http.HandlerFunc {
}
name := strField(fields, "name")
surname := strField(fields, "surname")
if err := gdb.WithContext(r.Context()).Model(user).Updates(map[string]any{
email := strField(fields, "email")
columns := map[string]any{
"name": name,
"surname": surname,
"email": strField(fields, "email"),
}).Error; err != nil {
"email": email,
}
// The proof of the address follows the address (D-27): a new email
// is unproven until its owner proves it. A change of letter case
// only keeps it, because email matching is case-insensitive.
emailChanged := !strings.EqualFold(email, user.Email)
if emailChanged {
columns["email_verified_at"] = nil
}
if err := gdb.WithContext(r.Context()).Model(user).Updates(columns).Error; err != nil {
writeOpaque500(w)
return
}
user.Name = &name
user.Surname = &surname
user.Email = strField(fields, "email")
user.Email = email
if emailChanged {
user.EmailVerifiedAt = nil
}
payload, err := apiArray(r.Context(), app, user)
if err != nil {
writeOpaque500(w)
@@ -501,7 +535,7 @@ func ChangePassword(app *backpack.App) http.HandlerFunc {
return
}
if !user.HasSelfSetPassword {
slog.Default().Error("golem15.user: change-password requires a self-set password", "user_id", user.ID)
pluginLog.Error("golem15.user: change-password requires a self-set password", "user_id", user.ID)
writeOpaque500(w)
return
}
@@ -649,12 +683,13 @@ func Register(app *backpack.App) http.HandlerFunc {
return
}
_ = FireRegisterEvent(r.Context(), app, user, fields)
_ = FireCreatedEvent(r.Context(), app, user)
ctx := r.Context()
if mode == "user" {
vars := mailVars(displayName(user), "", "")
code, err := classes.IssueActivationCode(ctx, gdb, user)
if err != nil {
slog.Default().Error("golem15.user: issue activation code", "err", err)
pluginLog.Error("golem15.user: issue activation code", "err", err)
} else {
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
vars = mailVars(displayName(user), activationLink(app, user.ID, code), packed)
@@ -681,6 +716,23 @@ func Register(app *backpack.App) http.HandlerFunc {
}
}
// RegistrationSettings returns the registration settings /register
// applies: allow is golem15.user.registration.allow_registration (default
// true), throttle is golem15.user.registration.use_register_throttle
// (default true), mode is golem15.user.activation.activate_mode (auto, user
// or admin; default auto) and require is
// golem15.user.activation.require_activation (default true).
func RegistrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
return registrationSettings(app)
}
// RegistrationsFromIP counts the users created from ip in the last 60
// minutes (created_ip_address). /register refuses a registration when the
// throttle is on and the count is 3 or more. A query error counts as 0.
func RegistrationsFromIP(ctx context.Context, gdb *gorm.DB, ip string) int64 {
return registrationsFromIP(ctx, gdb, ip)
}
func registrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
allow, throttle, require = true, true, true
mode = "auto"
@@ -830,6 +882,13 @@ func mintFor(app *backpack.App, secret string, user *models.User, issuer string)
return token, err
}
// RefreshTTL is the refresh window of a session token:
// golem15.user.jwt.refresh_ttl minutes, default 20160 (two weeks). A cookie
// carrying the token should live this long.
func RefreshTTL(app *backpack.App) time.Duration {
return refreshTTL(app)
}
func refreshTTL(app *backpack.App) time.Duration {
mins := 20160
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 {
@@ -875,11 +934,11 @@ func sendTemplateVars(ctx context.Context, app *backpack.App, template, to strin
}
mailer, ok := app.Lookup[postcard.Mailer]()
if !ok || mailer == nil {
slog.Default().Error("golem15.user: mailer is not configured", "template", template)
pluginLog.Error("golem15.user: mailer is not configured", "template", template)
return
}
if err := mailer.Send(ctx, postcard.Message{Template: template, To: []string{to}, Vars: vars}); err != nil {
slog.Default().Error("golem15.user: send mail", "template", template, "err", err)
pluginLog.Error("golem15.user: send mail", "template", template, "err", err)
}
}
@@ -1011,6 +1070,13 @@ func requestURL(r *http.Request) string {
return scheme + "://" + r.Host + r.URL.Path
}
// RemoteIP returns the host part of r.RemoteAddr, the address /login uses
// for its throttle (r.RemoteAddr itself when it has no port, "" when it is
// empty). /register records surf.ClientIP with the trusted proxies instead.
func RemoteIP(r *http.Request) string {
return remoteIP(r)
}
func remoteIP(r *http.Request) string {
if r.RemoteAddr == "" {
return ""
@@ -1131,7 +1197,7 @@ func UploadAvatar(app *backpack.App) http.HandlerFunc {
}
bucket, ok := app.Lookup[*blob.Bucket]()
if !ok || bucket == nil {
slog.Default().Error("golem15.user: avatar bucket is not configured")
pluginLog.Error("golem15.user: avatar bucket is not configured")
writeOpaque500(w)
return
}
@@ -1282,17 +1348,9 @@ func applyAvatar(ctx context.Context, app *backpack.App, user *models.User, payl
}
payload["has_avatar"] = true
payload["avatar"] = f.DiskName
bucket, ok := app.Lookup[*blob.Bucket]()
if !ok || bucket == nil {
return
if url, err := classes.AvatarURL(ctx, app, user, 128); err == nil && url != "" {
payload["avatar_url"] = url
}
url, err := f.Thumb(ctx, bucket, 128, 128, "auto")
if err != nil {
slog.Default().Error("golem15.user: avatar thumb", "err", err)
payload["avatar_url"] = "/" + attach.BlobKey(f.DiskName)
return
}
payload["avatar_url"] = url
}
func avatarExt(mime string) (string, bool) {

7
controllers/logger.go Normal file
View File

@@ -0,0 +1,7 @@
package controllers
import "git.golem15.com/golem15/summercms/modules/diary"
// pluginLog is the plugin's logger: its records carry component=user, so
// config/log.yaml can raise or lower them (log.components.user).
var pluginLog = diary.For("user")

View File

@@ -13,13 +13,27 @@ import (
)
// OAuthIdentitiesOptions configures host-mounted list/unlink handlers.
// Providers defaults to google, facebook, github when nil or empty.
// Providers is the unlink allow-list. When nil or empty, the allow-list is
// read on every request: the KnownOAuthProviders of a published
// classes.OAuthProviderSource, else google, facebook, github and microsoft.
// WriteNotFound is injected by the host so 404 bodies match that app.
type OAuthIdentitiesOptions struct {
Providers []string
WriteNotFound func(http.ResponseWriter, *http.Request)
}
// defaultOAuthProviders is the unlink allow-list without options and without
// a published provider source.
var defaultOAuthProviders = []string{"google", "facebook", "github", "microsoft"}
// oauthGuardedDelete is the PHP unlink rule (User.php unlinkOAuthProvider):
// the identity goes only when the user has a self-set password or another
// identity remains.
const oauthGuardedDelete = `DELETE FROM golem15_user_oauth_identities
WHERE user_id = ? AND provider = ?
AND (EXISTS (SELECT 1 FROM users u WHERE u.id = golem15_user_oauth_identities.user_id AND u.has_self_set_password)
OR (SELECT COUNT(*) FROM golem15_user_oauth_identities oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`
// OAuthIdentitiesIndex lists the caller's linked OAuth providers.
func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -50,19 +64,18 @@ func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc {
}
}
// OAuthIdentitiesDestroy unlinks one provider for the caller.
// OAuthIdentitiesDestroy unlinks one provider for the caller, under the PHP
// rule: it succeeds (204) when the user has a self-set password or another
// identity remains, and answers 409 with the last_method_blocked phrase
// otherwise. An unknown provider or a missing identity goes to WriteNotFound.
func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http.HandlerFunc {
providers := opts.Providers
if len(providers) == 0 {
providers = []string{"google", "facebook", "github"}
}
return func(w http.ResponseWriter, r *http.Request) {
db, user, ok := oauthIdentityDeps(w, r, app)
if !ok {
return
}
provider := r.PathValue("provider")
if !slices.Contains(providers, provider) {
if !slices.Contains(unlinkProviders(app, opts), provider) {
writeOAuthIdentityNotFound(w, r, opts)
return
}
@@ -78,28 +91,49 @@ func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http
writeOpaque500(w)
return
}
var n int64
if err := db.WithContext(r.Context()).
Model(&models.OAuthIdentity{}).
Where("user_id = ?", user.ID).
Count(&n).Error; err != nil {
// D-13: the guarded DELETE alone is not race-safe on Postgres. Under
// READ COMMITTED two unlinks of different identities each see the
// other's row in their COUNT and do not wait for each other, so both
// succeed and a password-less user loses every sign-in method. The
// lock on the users row serialises them: the second DELETE runs after
// the first commits and sees one identity left.
var deleted int64
err = db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, user.ID).Error; err != nil {
return err
}
res := tx.Exec(oauthGuardedDelete, user.ID, provider)
if res.Error != nil {
return res.Error
}
deleted = res.RowsAffected
return nil
})
if err != nil {
writeOpaque500(w)
return
}
if n == 1 {
if deleted == 0 {
writeJSON(w, http.StatusConflict, map[string]any{
"error": oauthLastMethodBlocked(r, app),
})
return
}
if err := db.WithContext(r.Context()).Delete(&row).Error; err != nil {
writeOpaque500(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// unlinkProviders is the unlink allow-list for one request.
func unlinkProviders(app *backpack.App, opts OAuthIdentitiesOptions) []string {
if len(opts.Providers) > 0 {
return opts.Providers
}
if src, ok := oauthProviderSource(app); ok {
return src.KnownOAuthProviders()
}
return defaultOAuthProviders
}
func writeOAuthIdentityNotFound(w http.ResponseWriter, r *http.Request, opts OAuthIdentitiesOptions) {
if opts.WriteNotFound != nil {
opts.WriteNotFound(w, r)

View File

@@ -26,6 +26,11 @@ import (
type RegisterOptions struct {
Activate bool
IP string
// PasswordLess creates the account with has_self_set_password=false: its
// password is random and was never known to the user (social sign-in,
// PHP password_bootstrap_source oauth_registration). The zero value
// keeps true.
PasswordLess bool
}
// RegisterUser validates fields against the user model's rules, hashes the
@@ -68,7 +73,7 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
if err := lagoon.Fill(user, user.Fillable(), input, production); err != nil {
return nil, nil, err
}
user.HasSelfSetPassword = true
user.HasSelfSetPassword = !opts.PasswordLess
if opts.Activate {
user.IsActivated = true
now := time.Now()
@@ -77,9 +82,46 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
if err := db.WithContext(ctx).Create(user).Error; err != nil {
return nil, nil, err
}
if err := classes.AssignHandle(ctx, db, user); err != nil {
return nil, nil, err
}
return user, nil, nil
}
// FinishLogin runs the steps password Login takes after it has
// authenticated a user, for a caller that authenticated the user another way
// (a social sign-in). It restores a soft-deleted account and sends the
// reactivate mail, records ip as last_ip_address, and touches last_seen. It
// checks no password, throttle, activation or ban and fires no event; the
// caller does those checks (Go has no login event). A failed last_seen
// write is logged and does not fail it, as in Login.
func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *models.User, ip string) error {
if db == nil {
return errors.New("golem15.user: gorm handle is missing")
}
if user == nil || user.ID == 0 {
return errors.New("golem15.user: finishing a login needs a stored user")
}
if user.DeletedAt.Valid {
if err := db.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
return err
}
user.DeletedAt = gorm.DeletedAt{}
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
}
if ip != "" {
if err := db.WithContext(ctx).Unscoped().Model(user).Update("last_ip_address", ip).Error; err != nil {
return err
}
addr := ip
user.LastIPAddress = &addr
}
if err := classes.TouchLastSeen(ctx, db, user.ID); err != nil {
pluginLog.Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err)
}
return nil
}
// FireRegisterEvent fires classes.RegisterEvent for user with Payload set
// to a copy of fields without password and password_confirmation, and
// returns the listeners' error. /register ignores that error, as Winter
@@ -97,6 +139,36 @@ func FireRegisterEvent(ctx context.Context, app *backpack.App, user *models.User
return app.Events.Fire(ctx, &classes.RegisterEvent{User: user, Payload: payload})
}
// FireCreatedEvent fires classes.CreatedEvent (user.Created) for user and
// returns the listeners' error. /register ignores that error, as it does
// for FireRegisterEvent.
func FireCreatedEvent(ctx context.Context, app *backpack.App, user *models.User) error {
if app == nil || app.Events == nil {
return nil
}
return app.Events.Fire(ctx, &classes.CreatedEvent{User: user})
}
// FireDeletingEvent fires classes.DeletingEvent (user.Deleting) for user
// inside the delete transaction. FormAfterDelete returns the listeners'
// error so a failed cascade rolls the delete back.
func FireDeletingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error {
if app == nil || app.Events == nil {
return nil
}
return app.Events.Fire(ctx, &classes.DeletingEvent{User: user, Tx: tx})
}
// FireDeactivatingEvent fires classes.DeactivatingEvent (user.Deactivating)
// for user inside the deactivation transaction and returns the listeners'
// error, so a failed listener rolls the deactivation back.
func FireDeactivatingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error {
if app == nil || app.Events == nil {
return nil
}
return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx})
}
// IssueToken mints the session JWT for user exactly as /register and
// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request
// URL as the issuer.

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
@@ -211,7 +212,8 @@ func (c usersAdminController) guardPrivilegedMember(ctx context.Context, userID
// FormBeforeUpdate starts with the takeover guard: a changed email or a
// submitted password on a member of a privileged group needs
// golem15.users.manage_privileged_groups. Everything else an update does
// comes after it.
// comes after it, including clearing the proof of the address
// (email_verified_at) when the email changes.
func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
user, ok := model.(*models.User)
if !ok || user == nil {
@@ -223,6 +225,16 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e
if err := c.requireFreeEmail(ctx, user); err != nil {
return err
}
// The proof of the address follows the address (D-27): a changed email
// is unproven until its owner proves it; a change of letter case only
// keeps it. cabana's Save writes the cleared column.
stored, err := c.storedEmail(ctx, user)
if err != nil {
return err
}
if !strings.EqualFold(stored, user.Email) {
user.EmailVerifiedAt = nil
}
changed, err := c.storePassword(ctx, user)
if err != nil {
return err
@@ -302,23 +314,33 @@ func (c usersAdminController) storePassword(ctx context.Context, user *models.Us
return true, nil
}
// FormAfterCreate sends the invitation when the send_invite checkbox was
// set: an activation code is issued inside the create's transaction, and
// exactly one golem15.user::mail.invite message with the activation link
// leaves after the commit. A create that rolls back sends nothing.
// FormAfterCreate assigns a unique handle, fires user.Created
// (CreatedEventName) inside the create transaction, and sends the
// invitation when the send_invite checkbox was set: an activation code is
// issued inside the same transaction, and exactly one
// golem15.user::mail.invite message with the activation link leaves after
// the commit. A create that rolls back sends nothing.
func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) error {
user, ok := model.(*models.User)
if !ok || user == nil {
return errors.New("users admin: form model is not a user")
}
values, _ := cabana.VirtualFieldsFromContext(ctx)
if invite, _ := values["send_invite"].(bool); !invite {
return nil
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return errors.New("users admin: a create needs its transaction")
}
if err := classes.AssignHandle(ctx, tx.Session(&gorm.Session{NewDB: true}), user); err != nil {
return err
}
if app := c.application(); app != nil && app.Events != nil {
if err := app.Events.Fire(ctx, &classes.CreatedEvent{User: user, Tx: tx}); err != nil {
return fmt.Errorf("%s: %w", classes.CreatedEventName, err)
}
}
values, _ := cabana.VirtualFieldsFromContext(ctx)
if invite, _ := values["send_invite"].(bool); !invite {
return nil
}
code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user)
if err != nil {
return err
@@ -452,26 +474,36 @@ func (usersAdminController) AdminSetPermissionValues(_ context.Context, field st
return nil
}
// guardCredentials compares the email the save would write with the stored
// one and looks for a submitted password, then asks guardPrivilegedMember
// for the fields that would change.
func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error {
// storedEmail reads the stored email of the user being updated, before the
// form's values are saved.
func (c usersAdminController) storedEmail(ctx context.Context, user *models.User) (string, error) {
db := requestDB(ctx, c.db)
if db == nil {
return errors.New("users admin: gorm handle is missing")
return "", errors.New("users admin: gorm handle is missing")
}
var stored []string
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
if err != nil {
return err
return "", err
}
if len(stored) != 1 {
return errors.New("users admin: the user being updated was not found")
return "", errors.New("users admin: the user being updated was not found")
}
return stored[0], nil
}
// guardCredentials compares the email the save would write with the stored
// one and looks for a submitted password, then asks guardPrivilegedMember
// for the fields that would change.
func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error {
stored, err := c.storedEmail(ctx, user)
if err != nil {
return err
}
const message = usersLang + "privileged_member_forbidden"
details := map[string]any{}
if stored[0] != user.Email {
if stored != user.Email {
details["email"] = []string{message}
}
if values, ok := cabana.VirtualFieldsFromContext(ctx); ok {
@@ -512,6 +544,9 @@ func (c usersAdminController) FormAfterDelete(ctx context.Context, model any) er
if !ok {
return errors.New("users admin: a delete needs its transaction")
}
if err := FireDeletingEvent(ctx, c.application(), user, tx); err != nil {
return fmt.Errorf("%s: %w", classes.DeletingEventName, err)
}
if err := classes.ForceDeleteCleanup(ctx, tx, c.bucket(), user); err != nil {
return err
}
@@ -581,13 +616,35 @@ func (c usersAdminController) AdminBulkActions() []pact.AdminBulkAction {
}
return []pact.AdminBulkAction{
bulk("activate", classes.ActivateUsers),
bulk("deactivate", classes.DeactivateUsers),
bulk("deactivate", c.deactivateUsers),
bulk("restore", classes.RestoreUsers),
bulk("ban", classes.BanUsers),
bulk("unban", classes.UnbanUsers),
}
}
// deactivateUsers runs classes.DeactivateUsers unchanged and then fires
// user.Deactivating, in the action's transaction, for every selected user
// that was not deactivated before. A listener error rolls the action back.
func (c usersAdminController) deactivateUsers(ctx context.Context, tx *gorm.DB, users []*models.User) (int, error) {
pending := make([]*models.User, 0, len(users))
for _, user := range users {
if user != nil && user.ID != 0 && !user.DeletedAt.Valid {
pending = append(pending, user)
}
}
changed, err := classes.DeactivateUsers(ctx, tx, users)
if err != nil {
return 0, err
}
for _, user := range pending {
if err := FireDeactivatingEvent(ctx, c.application(), user, tx); err != nil {
return 0, err
}
}
return changed, nil
}
// throttleState reads whether one user is banned and whether they are
// suspended, through the write's transaction when there is one.
func (c usersAdminController) throttleState(ctx context.Context, userID uint) (banned, suspended bool, err error) {

110
deactivating_event_test.go Normal file
View File

@@ -0,0 +1,110 @@
package user
import (
"context"
"errors"
"net/http"
"sync"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"gorm.io/gorm"
)
// TestDeactivatingEvent pins user.Deactivating (D-05): the admin deactivate
// bulk action fires one event per newly deactivated user inside the
// action's transaction, already deactivated users fire none, a listener
// error rolls the whole action back, and the event name is the constant.
func TestDeactivatingEvent(t *testing.T) {
if classes.DeactivatingEventName != "user.Deactivating" {
t.Fatalf("DeactivatingEventName = %q", classes.DeactivatingEventName)
}
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
var mu sync.Mutex
var fired []uint
var failFor uint
var txSawDeactivation = map[uint]bool{}
env.app.Events.Listen[*classes.DeactivatingEvent]("test.deactivating", func(ctx context.Context, e *classes.DeactivatingEvent) error {
mu.Lock()
defer mu.Unlock()
if e.User == nil || e.Tx == nil {
return errors.New("event without user or transaction")
}
fired = append(fired, e.User.ID)
// Inside the action's transaction the row is already deactivated.
var deleted int64
if err := e.Tx.Session(&gorm.Session{NewDB: true}).Unscoped().Model(&models.User{}).
Where("id = ? AND deleted_at IS NOT NULL", e.User.ID).Count(&deleted).Error; err != nil {
return err
}
txSawDeactivation[e.User.ID] = deleted == 1
if e.User.ID == failFor {
return errors.New("listener refuses")
}
return nil
})
reset := func() {
mu.Lock()
defer mu.Unlock()
fired, txSawDeactivation = nil, map[uint]bool{}
}
a, b := env.seedUser(t, "deact-a", nil), env.seedUser(t, "deact-b", nil)
if rec := env.bulk(t, token, "deactivate", a, b); rec.Code != http.StatusOK {
t.Fatalf("deactivate: %d %s", rec.Code, rec.Body.String())
}
mu.Lock()
if len(fired) != 2 || !txSawDeactivation[a.ID] || !txSawDeactivation[b.ID] {
t.Fatalf("events %v, seen in the transaction %v", fired, txSawDeactivation)
}
mu.Unlock()
for _, u := range []*models.User{a, b} {
if row := env.reload(t, u.ID); row == nil || !row.DeletedAt.Valid {
t.Fatalf("user %d not deactivated", u.ID)
}
}
// Already deactivated users fire nothing; a mixed selection fires only
// for the newly deactivated one.
reset()
c := env.seedUser(t, "deact-c", nil)
if rec := env.bulk(t, token, "deactivate", a, c); rec.Code != http.StatusOK {
t.Fatalf("mixed deactivate: %d %s", rec.Code, rec.Body.String())
}
mu.Lock()
if len(fired) != 1 || fired[0] != c.ID {
t.Fatalf("mixed selection fired %v, want only %d", fired, c.ID)
}
mu.Unlock()
// A listener error rolls the whole action back.
reset()
d, e := env.seedUser(t, "deact-d", nil), env.seedUser(t, "deact-e", nil)
failFor = e.ID
if rec := env.bulk(t, token, "deactivate", d, e); rec.Code < 400 {
t.Fatalf("deactivate with a refusing listener: %d %s", rec.Code, rec.Body.String())
}
failFor = 0
for _, u := range []*models.User{d, e} {
if row := env.reload(t, u.ID); row == nil || row.DeletedAt.Valid {
t.Fatalf("user %d deactivated although a listener refused", u.ID)
}
}
// Other bulk actions fire nothing.
reset()
if rec := env.bulk(t, token, "restore", a); rec.Code != http.StatusOK {
t.Fatalf("restore: %d %s", rec.Code, rec.Body.String())
}
if rec := env.bulk(t, token, "activate", d); rec.Code != http.StatusOK {
t.Fatalf("activate: %d %s", rec.Code, rec.Body.String())
}
mu.Lock()
if len(fired) != 0 {
t.Fatalf("restore and activate fired %v", fired)
}
mu.Unlock()
}

377
email_verified_test.go Normal file
View File

@@ -0,0 +1,377 @@
package user
import (
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/postcard"
"gorm.io/gorm"
)
// proofOf reads users.email_verified_at of the user id.
func proofOf(t *testing.T, gdb *gorm.DB, id uint) *time.Time {
t.Helper()
var got *time.Time
if err := gdb.Raw(`SELECT email_verified_at FROM users WHERE id = ?`, id).Row().Scan(&got); err != nil {
t.Fatal(err)
}
return got
}
// userIDByEmail returns the id of the user with email.
func userIDByEmail(t *testing.T, gdb *gorm.DB, email string) uint {
t.Helper()
var u models.User
if err := gdb.Unscoped().Where("lower(email) = lower(?)", email).Take(&u).Error; err != nil {
t.Fatal(err)
}
return u.ID
}
// setProof stores a proof time on the user id and returns it.
func setProof(t *testing.T, gdb *gorm.DB, id uint) time.Time {
t.Helper()
at := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
if err := gdb.Exec(`UPDATE users SET email_verified_at = ? WHERE id = ?`, at, id).Error; err != nil {
t.Fatal(err)
}
return at
}
// assertStamped requires got to be set between before and after (with a
// millisecond of slack for the database's microsecond precision).
func assertStamped(t *testing.T, got *time.Time, before, after time.Time) {
t.Helper()
if got == nil {
t.Fatal("email_verified_at is NULL, want the time of the proof")
}
if got.Before(before.Add(-time.Millisecond)) || got.After(after.Add(time.Millisecond)) {
t.Fatalf("email_verified_at = %v, want between %v and %v", got, before, after)
}
}
// assertNoProofKey requires a response body without the proof column.
func assertNoProofKey(t *testing.T, body string) {
t.Helper()
if strings.Contains(body, "email_verified_at") {
t.Fatalf("the payload carries email_verified_at: %s", body)
}
}
// TestEmailVerifiedAt pins the user API side of D-26 and D-27: only the
// mailed activation code and a completed reset-code password reset record
// the proof of the address, an email change clears it, and the column is
// never mass-assigned or part of a payload.
func TestEmailVerifiedAt(t *testing.T) {
app, gdb := sessionApp(t, "0s")
disableRegisterThrottle(t, app)
if err := app.Publish[postcard.Mailer](&captureMail{}); err != nil {
t.Fatal(err)
}
stamp := adminStamp()
email := func(label string) string { return "proof-" + label + "-" + stamp + "@example.test" }
mode := func(t *testing.T, m string) {
t.Helper()
if err := app.Config.Set("golem15.user.activation.activate_mode", m); err != nil {
t.Fatal(err)
}
}
register := func(t *testing.T, body string) string {
t.Helper()
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", body)
if rec.Code != http.StatusOK {
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
}
assertNoProofKey(t, rec.Body.String())
return rec.Body.String()
}
t.Run("register_auto_no_proof", func(t *testing.T) {
mode(t, "auto")
addr := email("register-auto")
body := register(t, `{"email":"`+addr+`","password":"correct-horse"}`)
if !strings.Contains(body, `"token":`) {
t.Fatalf("auto-mode register answered %s", body)
}
id := userIDByEmail(t, gdb, addr)
var activated bool
if err := gdb.Raw(`SELECT is_activated FROM users WHERE id = ?`, id).Row().Scan(&activated); err != nil {
t.Fatal(err)
}
if !activated || proofOf(t, gdb, id) != nil {
t.Fatalf("auto-mode account: activated=%v proof=%v", activated, proofOf(t, gdb, id))
}
})
t.Run("register_admin_mode_no_proof", func(t *testing.T) {
mode(t, "admin")
t.Cleanup(func() { mode(t, "auto") })
addr := email("register-admin")
if body := register(t, `{"email":"`+addr+`","password":"correct-horse"}`); body != `{}` {
t.Fatalf("admin-mode register answered %s", body)
}
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
t.Fatalf("admin-mode account has proof %v", got)
}
})
t.Run("register_ignores_submitted_proof", func(t *testing.T) {
mode(t, "auto")
addr := email("register-submitted")
register(t, `{"email":"`+addr+`","password":"correct-horse","email_verified_at":"2020-01-01T00:00:00Z"}`)
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
t.Fatalf("a submitted email_verified_at was stored: %v", got)
}
})
t.Run("activate_by_code_stamps", func(t *testing.T) {
user := insertUser(t, gdb, email("activate-by-code"), "correct-horse")
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
if err != nil {
t.Fatal(err)
}
before := time.Now()
rec := postJSON(t, controllers.ActivateByCode(app), "/_user/api/v1/activate-by-code",
`{"code":"`+strconv.FormatUint(uint64(user.ID), 10)+"!"+code+`"}`)
after := time.Now()
if rec.Code != http.StatusOK {
t.Fatalf("activate-by-code %d %s", rec.Code, rec.Body.String())
}
assertNoProofKey(t, rec.Body.String())
assertStamped(t, proofOf(t, gdb, user.ID), before, after)
})
t.Run("activate_stamps", func(t *testing.T) {
addr := email("activate")
token := loginToken(t, app, gdb, addr)
user := &models.User{ID: userIDByEmail(t, gdb, addr)}
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
if err != nil {
t.Fatal(err)
}
before := time.Now()
rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"`+code+`"}`)
after := time.Now()
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":true`) {
t.Fatalf("activate %d %s", rec.Code, rec.Body.String())
}
assertNoProofKey(t, rec.Body.String())
assertStamped(t, proofOf(t, gdb, user.ID), before, after)
})
t.Run("activate_wrong_code_no_proof", func(t *testing.T) {
addr := email("activate-wrong")
token := loginToken(t, app, gdb, addr)
user := &models.User{ID: userIDByEmail(t, gdb, addr)}
if _, err := classes.IssueActivationCode(t.Context(), gdb, user); err != nil {
t.Fatal(err)
}
rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"wrong"}`)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":false`) {
t.Fatalf("activate %d %s", rec.Code, rec.Body.String())
}
assertNoProofKey(t, rec.Body.String())
if got := proofOf(t, gdb, user.ID); got != nil {
t.Fatalf("a wrong activation code recorded proof %v", got)
}
})
t.Run("reset_password_stamps", func(t *testing.T) {
user := insertUser(t, gdb, email("reset"), "correct-horse")
code, err := classes.IssueResetCode(t.Context(), gdb, user)
if err != nil {
t.Fatal(err)
}
before := time.Now()
rec := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password",
`{"code":"`+strconv.FormatUint(uint64(user.ID), 10)+"!"+code+`","password":"new-password","password_confirmation":"new-password"}`)
after := time.Now()
if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Password has been reset"}` {
t.Fatalf("reset %d %s", rec.Code, rec.Body.String())
}
assertStamped(t, proofOf(t, gdb, user.ID), before, after)
})
t.Run("reset_wrong_code_no_proof", func(t *testing.T) {
user := insertUser(t, gdb, email("reset-wrong"), "correct-horse")
if _, err := classes.IssueResetCode(t.Context(), gdb, user); err != nil {
t.Fatal(err)
}
for _, code := range []string{strconv.FormatUint(uint64(user.ID), 10) + "!nope", "not-a-code"} {
rec := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password",
`{"code":"`+code+`","password":"new-password","password_confirmation":"new-password"}`)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("reset with %q: %d %s", code, rec.Code, rec.Body.String())
}
}
if got := proofOf(t, gdb, user.ID); got != nil {
t.Fatalf("a refused reset recorded proof %v", got)
}
})
t.Run("change_password_no_proof", func(t *testing.T) {
addr := email("change-password")
token := loginToken(t, app, gdb, addr)
// change-password replaces a self-set password only.
if err := gdb.Exec(`UPDATE users SET has_self_set_password = TRUE WHERE id = ?`, userIDByEmail(t, gdb, addr)).Error; err != nil {
t.Fatal(err)
}
rec := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token,
`{"current_password":"correct-horse","password":"brand-new-password","password_confirmation":"brand-new-password"}`)
if rec.Code != http.StatusOK {
t.Fatalf("change-password %d %s", rec.Code, rec.Body.String())
}
assertNoProofKey(t, rec.Body.String())
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
t.Fatalf("change-password recorded proof %v", got)
}
})
update := func(t *testing.T, token, body string) string {
t.Helper()
rec := bearerJSON(t, controllers.Update(app), "/_user/api/v1/update", token, body)
if rec.Code != http.StatusOK {
t.Fatalf("update %d %s", rec.Code, rec.Body.String())
}
assertNoProofKey(t, rec.Body.String())
return rec.Body.String()
}
t.Run("update_email_change_clears", func(t *testing.T) {
addr := email("update-change")
token := loginToken(t, app, gdb, addr)
id := userIDByEmail(t, gdb, addr)
setProof(t, gdb, id)
moved := email("update-changed-to")
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+moved+`"}`)
if got := proofOf(t, gdb, id); got != nil {
t.Fatalf("an email change kept the proof %v", got)
}
})
t.Run("update_same_email_keeps", func(t *testing.T) {
addr := email("update-same")
token := loginToken(t, app, gdb, addr)
id := userIDByEmail(t, gdb, addr)
want := setProof(t, gdb, id)
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+addr+`"}`)
if got := proofOf(t, gdb, id); got == nil || !got.Equal(want) {
t.Fatalf("the same email changed the proof to %v", got)
}
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+strings.ToUpper(addr)+`"}`)
if got := proofOf(t, gdb, id); got == nil || !got.Equal(want) {
t.Fatalf("a case-only change dropped the proof: %v", got)
}
})
t.Run("update_ignores_submitted_proof", func(t *testing.T) {
addr := email("update-submitted")
token := loginToken(t, app, gdb, addr)
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+addr+`","email_verified_at":"2020-01-01T00:00:00Z"}`)
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
t.Fatalf("a submitted email_verified_at was stored: %v", got)
}
})
}
// TestAdminEmailVerifiedAt pins the admin side of D-26 and D-27: an
// administrator's create and Activate action record no proof, the
// invitation link (the mailed activation code) does, an email change on the
// form clears it, and the admin JSON never carries it.
func TestAdminEmailVerifiedAt(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
stamp := adminStamp()
const password = "proof-admin-password-1"
create := func(t *testing.T, email string, extra map[string]any) uint {
t.Helper()
body := map[string]any{"name": "Proof", "email": email, "password": password, "password_confirmation": password}
for k, v := range extra {
body[k] = v
}
return createdID(t, env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, body))
}
proven := func(t *testing.T, label string) *models.User {
t.Helper()
at := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
return env.seedUser(t, label, func(u *models.User) { u.EmailVerifiedAt = &at })
}
t.Run("admin_create_no_proof", func(t *testing.T) {
id := create(t, "admin-proof-create-"+stamp+"@example.test", nil)
if got := env.reload(t, id); got == nil || got.EmailVerifiedAt != nil {
t.Fatalf("admin-created user = %+v", got)
}
})
t.Run("admin_activate_action_no_proof", func(t *testing.T) {
user := env.seedUser(t, "admin-proof-activate", func(u *models.User) { u.IsActivated = false })
if rec := env.bulk(t, token, "activate", user); rec.Code != http.StatusOK {
t.Fatalf("activate action %d %s", rec.Code, rec.Body.String())
}
got := env.reload(t, user.ID)
if !got.IsActivated || got.EmailVerifiedAt != nil {
t.Fatalf("after the activate action: activated=%v proof=%v", got.IsActivated, got.EmailVerifiedAt)
}
})
t.Run("admin_invite_link_stamps", func(t *testing.T) {
invited := "admin-proof-invite-" + stamp + "@example.test"
id := create(t, invited, map[string]any{"send_invite": true})
if got := env.reload(t, id); got.EmailVerifiedAt != nil {
t.Fatalf("the invitation recorded proof before the link: %v", got.EmailVerifiedAt)
}
mails := env.mails(invited)
if len(mails) != 1 {
t.Fatalf("invitations sent = %d", len(mails))
}
start := strings.Index(mails[0].Text, "https://app.example.test/activate?code=")
if start < 0 {
t.Fatalf("no activation link in %q", mails[0].Text)
}
link := mails[0].Text[start:]
link = link[:strings.IndexAny(link, ")\n ")]
parsed, err := url.Parse(link)
if err != nil {
t.Fatal(err)
}
before := time.Now()
rec := env.call(t, http.MethodPost, "/_user/api/v1/activate-by-code", "", map[string]string{"code": parsed.Query().Get("code")})
after := time.Now()
if rec.Code != http.StatusOK {
t.Fatalf("activate-by-code %d %s", rec.Code, rec.Body.String())
}
got := env.reload(t, id)
if !got.IsActivated {
t.Fatal("the invitation link did not activate")
}
assertStamped(t, got.EmailVerifiedAt, before, after)
})
t.Run("admin_email_change_clears", func(t *testing.T) {
user := proven(t, "admin-proof-change")
moved := "admin-proof-moved-" + stamp + "@example.test"
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Moved", "email": moved})
got := env.reload(t, user.ID)
if got.Email != moved || got.EmailVerifiedAt != nil {
t.Fatalf("after the email change: email=%s proof=%v", got.Email, got.EmailVerifiedAt)
}
})
t.Run("admin_save_keeps_proof", func(t *testing.T) {
user := proven(t, "admin-proof-keep")
want := *user.EmailVerifiedAt
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Renamed", "email": user.Email})
if got := env.reload(t, user.ID); got.EmailVerifiedAt == nil || !got.EmailVerifiedAt.Equal(want) {
t.Fatalf("a save with the same email changed the proof to %v", got.EmailVerifiedAt)
}
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Renamed", "email": strings.ToUpper(user.Email)})
if got := env.reload(t, user.ID); got.EmailVerifiedAt == nil || !got.EmailVerifiedAt.Equal(want) {
t.Fatalf("a case-only change dropped the proof: %v", got.EmailVerifiedAt)
}
})
t.Run("admin_json_hides_proof", func(t *testing.T) {
user := proven(t, "admin-proof-json")
show := env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), token, nil)
assertNoProofKey(t, show.Body.String())
form := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil)
assertNoProofKey(t, form.Body.String())
list := env.users(t, token, "search="+url.QueryEscape(user.Email))
if !list.has(user.ID) {
t.Fatal("the proven user is not in the list")
}
assertNoProofKey(t, list.Raw)
})
}

58
go.mod
View File

@@ -3,7 +3,7 @@ module git.golem15.com/golem15/sm-user-plugin
go 1.27.0
require (
git.golem15.com/golem15/summercms v0.1.9
git.golem15.com/golem15/summercms v0.4.0
github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.10.0
@@ -14,11 +14,50 @@ require (
)
require (
charm.land/bubbles/v2 v2.2.1 // indirect
charm.land/bubbletea/v2 v2.1.0 // indirect
charm.land/fang/v2 v2.0.1 // indirect
charm.land/huh/v2 v2.0.3 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
github.com/aws/smithy-go v1.26.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
@@ -28,6 +67,7 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
@@ -41,7 +81,9 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/wire v0.7.0 // indirect
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -57,9 +99,14 @@ require (
github.com/knadh/koanf/v2 v2.3.6 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/maypok86/otter/v2 v2.3.0 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
@@ -70,16 +117,23 @@ require (
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/redis/go-redis/v9 v9.23.0 // indirect
github.com/riverqueue/river v0.47.0 // indirect
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
github.com/riverqueue/river/rivershared v0.47.0 // indirect
github.com/riverqueue/river/rivertype v0.47.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cobra v1.10.2 // indirect
@@ -92,6 +146,7 @@ require (
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/wneessen/go-mail v0.8.1 // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/yuin/goldmark v1.8.6 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
@@ -101,6 +156,7 @@ require (
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/atomic v1.12.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/image v0.46.0 // indirect

104
go.sum
View File

@@ -1,5 +1,15 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
@@ -28,8 +38,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc=
github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
@@ -68,10 +84,52 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -96,6 +154,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
@@ -139,6 +199,12 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo=
github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI=
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -149,6 +215,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
@@ -167,6 +235,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
@@ -183,16 +253,26 @@ github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -213,6 +293,16 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -224,6 +314,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc=
github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ=
github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60=
@@ -236,6 +328,8 @@ github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOt
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -277,10 +371,16 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
@@ -301,6 +401,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@@ -310,6 +412,8 @@ gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=

7
logger.go Normal file
View File

@@ -0,0 +1,7 @@
package user
import "git.golem15.com/golem15/summercms/modules/diary"
// pluginLog is the plugin's logger: its records carry component=user, so
// config/log.yaml can raise or lower them (log.components.user).
var pluginLog = diary.For("user")

View File

@@ -33,10 +33,21 @@ type User struct {
TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"`
CreatedIPAddress *string `gorm:"column:created_ip_address"`
LastIPAddress *string `gorm:"column:last_ip_address"`
// Handle is the unique @mention identity (D-15). Generated on create,
// never mass-assigned, and omitted from user API payloads unless a
// golden requires it.
Handle *string `gorm:"column:handle" json:"-"`
// LastSeen is stamped by login and token refresh, at most once per five
// minutes (classes.TouchLastSeen). It is shown in the admin list and is
// never part of a user API payload.
LastSeen *time.Time `gorm:"column:last_seen" json:"-"`
// EmailVerifiedAt records when the owner proved control of Email (D-27).
// Only two flows write it: the mailed activation code
// (classes.VerifyActivationCode) and a completed reset-code password
// reset (controllers.ResetPassword). An email change through /update or
// the admin form clears it. It is never mass-assigned and never part of a
// user API payload; social sign-in matching reads it.
EmailVerifiedAt *time.Time `gorm:"column:email_verified_at" json:"-"`
// Permissions are the user's own frontend permissions (1 allows, -1
// denies). classes.MergedPermissions combines them with the groups'. They
// are written by the admin permission editor only and are never part of

View File

@@ -0,0 +1,331 @@
package user
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
// guardedUnlinkSQL is the DELETE OAuthIdentitiesDestroy runs (PHP User.php
// unlinkOAuthProvider), copied so the race test drives the same statement
// under its own transactions.
const guardedUnlinkSQL = `DELETE FROM golem15_user_oauth_identities
WHERE user_id = ? AND provider = ?
AND (EXISTS (SELECT 1 FROM users u WHERE u.id = golem15_user_oauth_identities.user_id AND u.has_self_set_password)
OR (SELECT COUNT(*) FROM golem15_user_oauth_identities oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`
// ruleUser creates a user named after the test with selfSet as
// has_self_set_password and links the given providers.
func ruleUser(t *testing.T, db *gorm.DB, selfSet bool, providers ...string) *models.User {
t.Helper()
slug := strings.NewReplacer("/", "-", " ", "-").Replace(strings.ToLower(t.Name()))
email := fmt.Sprintf("%s-%d@example.test", slug, time.Now().UnixNano())
u := insertUser(t, db, email, "correct-horse")
if err := db.Model(u).Update("has_self_set_password", selfSet).Error; err != nil {
t.Fatal(err)
}
for _, p := range providers {
seedOAuthIdentity(t, db, u.ID, p, p+"-"+email)
}
return u
}
func linkedProviders(t *testing.T, db *gorm.DB, userID uint) []string {
t.Helper()
var rows []models.OAuthIdentity
if err := db.Where("user_id = ?", userID).Order("provider").Find(&rows).Error; err != nil {
t.Fatal(err)
}
out := make([]string, 0, len(rows))
for _, r := range rows {
out = append(out, r.Provider)
}
return out
}
// unlinkWith runs the destroy handler for userID and provider with opts.
func unlinkWith(t *testing.T, app *backpack.App, opts controllers.OAuthIdentitiesOptions, userID uint, provider, locale string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodDelete, "/_user/api/v1/oauth-identities/"+provider, nil)
req.SetPathValue("provider", provider)
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: userID}), locale))
rec := httptest.NewRecorder()
controllers.OAuthIdentitiesDestroy(app, opts).ServeHTTP(rec, req)
return rec
}
func notFound404(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("host 404"))
}
// TestOAuthIdentitiesDestroyRule is the D-13 unlink rule table: an identity
// goes when the user has a self-set password or another identity remains;
// otherwise 409 with the unchanged EN and PL bodies. Unknown providers and
// identities of other users are 404 through the host's WriteNotFound;
// microsoft is in the default allow-list; without opts.Providers a
// published source's known names decide, and opts.Providers wins over it.
func TestOAuthIdentitiesDestroyRule(t *testing.T) {
skipShortPostgres(t)
app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app)
opts := controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}
cases := []struct {
name string
selfSet bool
linked []string
unlink string
locale string
status int
body string
remaining []string
}{
{name: "self_set_password_one_identity", selfSet: true, linked: []string{"google"}, unlink: "google", status: http.StatusNoContent, remaining: []string{}},
{name: "passwordless_one_identity_en", linked: []string{"facebook"}, unlink: "facebook", locale: "en", status: http.StatusConflict, body: `{"error":"` + lastMethodBlockedEN + `"}`, remaining: []string{"facebook"}},
{name: "passwordless_one_identity_pl", linked: []string{"github"}, unlink: "github", locale: "pl", status: http.StatusConflict, body: `{"error":"` + lastMethodBlockedPL + `"}`, remaining: []string{"github"}},
{name: "passwordless_two_identities", linked: []string{"facebook", "google"}, unlink: "google", status: http.StatusNoContent, remaining: []string{"facebook"}},
{name: "self_set_password_two_identities", selfSet: true, linked: []string{"facebook", "google"}, unlink: "facebook", status: http.StatusNoContent, remaining: []string{"google"}},
{name: "unknown_provider", selfSet: true, linked: []string{"google"}, unlink: "linkedin", status: http.StatusNotFound, body: "host 404", remaining: []string{"google"}},
{name: "not_linked", selfSet: true, linked: []string{"google"}, unlink: "github", status: http.StatusNotFound, body: "host 404", remaining: []string{"google"}},
{name: "microsoft_allowed_by_default", selfSet: true, linked: []string{"microsoft"}, unlink: "microsoft", status: http.StatusNoContent, remaining: []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
u := ruleUser(t, db, tc.selfSet, tc.linked...)
locale := tc.locale
if locale == "" {
locale = "en"
}
rec := unlinkWith(t, app, opts, u.ID, tc.unlink, locale)
if rec.Code != tc.status {
t.Fatalf("status %d, want %d (body %s)", rec.Code, tc.status, rec.Body.String())
}
if got := strings.TrimRight(rec.Body.String(), "\n"); got != tc.body {
t.Fatalf("body %q, want %q", got, tc.body)
}
if got := linkedProviders(t, db, u.ID); strings.Join(got, ",") != strings.Join(tc.remaining, ",") {
t.Fatalf("remaining %v, want %v", got, tc.remaining)
}
})
}
t.Run("foreign_identity", func(t *testing.T) {
owner := ruleUser(t, db, true, "google")
stranger := ruleUser(t, db, true, "facebook")
rec := unlinkWith(t, app, opts, stranger.ID, "google", "en")
if rec.Code != http.StatusNotFound || rec.Body.String() != "host 404" {
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
}
if got := linkedProviders(t, db, owner.ID); len(got) != 1 || got[0] != "google" {
t.Fatalf("owner's identities %v", got)
}
if got := linkedProviders(t, db, stranger.ID); len(got) != 1 || got[0] != "facebook" {
t.Fatalf("stranger's identities %v", got)
}
})
t.Run("source_known_names_when_no_options", func(t *testing.T) {
srcApp, srcDB := sessionApp(t, "0s")
if err := srcApp.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google", "custom"}}); err != nil {
t.Fatal(err)
}
u := ruleUser(t, srcDB, true, "custom", "github")
if rec := unlinkWith(t, srcApp, opts, u.ID, "custom", "en"); rec.Code != http.StatusNoContent {
t.Fatalf("custom: status %d body %s", rec.Code, rec.Body.String())
}
if rec := unlinkWith(t, srcApp, opts, u.ID, "github", "en"); rec.Code != http.StatusNotFound {
t.Fatalf("github outside the source's names: status %d body %s", rec.Code, rec.Body.String())
}
if got := linkedProviders(t, srcDB, u.ID); len(got) != 1 || got[0] != "github" {
t.Fatalf("remaining %v, want [github]", got)
}
})
t.Run("options_win_over_source", func(t *testing.T) {
srcApp, srcDB := sessionApp(t, "0s")
if err := srcApp.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google", "custom"}}); err != nil {
t.Fatal(err)
}
withList := controllers.OAuthIdentitiesOptions{Providers: []string{"github"}, WriteNotFound: notFound404}
u := ruleUser(t, srcDB, true, "custom", "github")
if rec := unlinkWith(t, srcApp, withList, u.ID, "custom", "en"); rec.Code != http.StatusNotFound {
t.Fatalf("custom outside opts.Providers: status %d body %s", rec.Code, rec.Body.String())
}
if rec := unlinkWith(t, srcApp, withList, u.ID, "github", "en"); rec.Code != http.StatusNoContent {
t.Fatalf("github in opts.Providers: status %d body %s", rec.Code, rec.Body.String())
}
if got := linkedProviders(t, srcDB, u.ID); len(got) != 1 || got[0] != "custom" {
t.Fatalf("remaining %v, want [custom]", got)
}
})
}
// TestOAuthIdentitiesDestroyRace forces the D-13 race deterministically:
// two transactions unlink the two identities of a password-less user, each
// taking the users-row lock (SELECT ... FOR UPDATE) and running the guarded
// DELETE the handler runs. The second blocks until the first commits and
// then deletes nothing, in both orders; exactly one identity is left. The
// handler variants prove the handler takes the same lock.
func TestOAuthIdentitiesDestroyRace(t *testing.T) {
skipShortPostgres(t)
app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app)
for _, order := range [][2]string{{"google", "facebook"}, {"facebook", "google"}} {
first, second := order[0], order[1]
t.Run("transactions_"+first+"_first", func(t *testing.T) {
u := ruleUser(t, db, false, "google", "facebook")
tx1 := db.Begin()
if tx1.Error != nil {
t.Fatal(tx1.Error)
}
committed := false
t.Cleanup(func() {
if !committed {
tx1.Rollback()
}
})
if err := tx1.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
t.Fatal(err)
}
res := tx1.Exec(guardedUnlinkSQL, u.ID, first)
if res.Error != nil || res.RowsAffected != 1 {
t.Fatalf("first delete: rows %d err %v", res.RowsAffected, res.Error)
}
type outcome struct {
rows int64
err error
}
done := make(chan outcome, 1)
go func() {
var rows int64
err := db.Transaction(func(tx2 *gorm.DB) error {
if err := tx2.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
return err
}
r := tx2.Exec(guardedUnlinkSQL, u.ID, second)
rows = r.RowsAffected
return r.Error
})
done <- outcome{rows, err}
}()
select {
case got := <-done:
t.Fatalf("the second transaction finished (rows %d, err %v) while the first held the users row lock", got.rows, got.err)
case <-time.After(300 * time.Millisecond):
}
if err := tx1.Commit().Error; err != nil {
t.Fatal(err)
}
committed = true
select {
case got := <-done:
if got.err != nil || got.rows != 0 {
t.Fatalf("second delete after the first committed: rows %d err %v, want 0 rows", got.rows, got.err)
}
case <-time.After(10 * time.Second):
t.Fatal("the second transaction did not finish after the first committed")
}
if got := linkedProviders(t, db, u.ID); len(got) != 1 || got[0] != second {
t.Fatalf("remaining %v, want [%s]", got, second)
}
})
t.Run("handler_waits_"+first+"_first", func(t *testing.T) {
u := ruleUser(t, db, false, "google", "facebook")
tx1 := db.Begin()
committed := false
t.Cleanup(func() {
if !committed {
tx1.Rollback()
}
})
if err := tx1.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
t.Fatal(err)
}
if res := tx1.Exec(guardedUnlinkSQL, u.ID, first); res.Error != nil || res.RowsAffected != 1 {
t.Fatalf("first delete: rows %d err %v", res.RowsAffected, res.Error)
}
recCh := make(chan *httptest.ResponseRecorder, 1)
go func() {
recCh <- unlinkWith(t, app, controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}, u.ID, second, "en")
}()
select {
case rec := <-recCh:
t.Fatalf("the handler answered %d while the users row was locked", rec.Code)
case <-time.After(300 * time.Millisecond):
}
if err := tx1.Commit().Error; err != nil {
t.Fatal(err)
}
committed = true
var rec *httptest.ResponseRecorder
select {
case rec = <-recCh:
case <-time.After(10 * time.Second):
t.Fatal("the handler did not answer after the lock was released")
}
if rec.Code != http.StatusConflict {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body["error"] != lastMethodBlockedEN {
t.Fatalf("409 body %s (%v)", rec.Body.String(), err)
}
if got := linkedProviders(t, db, u.ID); len(got) != 1 || got[0] != second {
t.Fatalf("remaining %v, want [%s]", got, second)
}
})
}
t.Run("concurrent_handlers", func(t *testing.T) {
opts := controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}
for round := range 10 {
u := ruleUser(t, db, false, "google", "facebook")
var wg sync.WaitGroup
start := make(chan struct{})
codes := make([]int, 2)
for i, p := range []string{"google", "facebook"} {
wg.Add(1)
go func() {
defer wg.Done()
<-start
codes[i] = unlinkWith(t, app, opts, u.ID, p, "en").Code
}()
}
close(start)
wg.Wait()
ok, conflict := 0, 0
for _, c := range codes {
switch c {
case http.StatusNoContent:
ok++
case http.StatusConflict:
conflict++
}
}
if ok != 1 || conflict != 1 {
t.Fatalf("round %d: statuses %v, want one 204 and one 409", round, codes)
}
if got := linkedProviders(t, db, u.ID); len(got) != 1 {
t.Fatalf("round %d: remaining %v, want exactly one identity", round, got)
}
}
})
}

View File

@@ -109,6 +109,11 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL(t *testing.T) {
} {
t.Run(tc.locale, func(t *testing.T) {
user := insertUser(t, db, tc.email, "correct-horse")
// The column defaults to TRUE; a user without a self-set
// password is the one the last identity is kept for.
if err := db.Model(user).Update("has_self_set_password", false).Error; err != nil {
t.Fatal(err)
}
seedOAuthIdentity(t, db, user.ID, "facebook", "fb-only-"+tc.locale)
rec := destroyOAuthIdentity(t, app, user.ID, "facebook", tc.locale, winter404OrFatal(t))
if rec.Code != http.StatusConflict {
@@ -128,7 +133,9 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL(t *testing.T) {
}
}
func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword(t *testing.T) {
// TestOAuthIdentitiesDestroyLastIdentityWithPassword: under the PHP rule a
// user with a self-set password may unlink the last identity (D-13).
func TestOAuthIdentitiesDestroyLastIdentityWithPassword(t *testing.T) {
app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app)
user := insertUser(t, db, "oauth-last-password@example.test", "correct-horse")
@@ -137,6 +144,80 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword(t *testing.T) {
}
seedOAuthIdentity(t, db, user.ID, "google", "g-only")
rec := destroyOAuthIdentity(t, app, user.ID, "google", "en", winter404OrFatal(t))
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.Len() != 0 {
t.Fatalf("body = %q", rec.Body.String())
}
if remaining := countOAuthIdentities(t, db, user.ID); remaining != 0 {
t.Fatalf("identities left = %d, want 0", remaining)
}
}
// TestOAuthIdentitiesDestroyConcurrentSmoke: two unlinks on a password-less
// user with two identities never remove both (D-13, RESEARCH Pitfall 1).
// Transaction A holds the users row lock and has deleted google; the
// handler's unlink of facebook must wait for A, then see one identity left
// and answer 409.
func TestOAuthIdentitiesDestroyConcurrentSmoke(t *testing.T) {
app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app)
user := insertUser(t, db, "oauth-concurrent@example.test", "correct-horse")
if err := db.Model(user).Update("has_self_set_password", false).Error; err != nil {
t.Fatal(err)
}
seedOAuthIdentity(t, db, user.ID, "google", "g-concurrent")
seedOAuthIdentity(t, db, user.ID, "facebook", "fb-concurrent")
txA := db.Begin()
if txA.Error != nil {
t.Fatal(txA.Error)
}
committed := false
t.Cleanup(func() {
if !committed {
txA.Rollback()
}
})
if err := txA.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, user.ID).Error; err != nil {
t.Fatal(err)
}
res := txA.Exec(`DELETE FROM golem15_user_oauth_identities
WHERE user_id = ? AND provider = ?
AND (EXISTS (SELECT 1 FROM users u WHERE u.id = golem15_user_oauth_identities.user_id AND u.has_self_set_password)
OR (SELECT COUNT(*) FROM golem15_user_oauth_identities oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`,
user.ID, "google")
if res.Error != nil || res.RowsAffected != 1 {
t.Fatalf("transaction A delete: rows %d err %v", res.RowsAffected, res.Error)
}
req := httptest.NewRequest(http.MethodDelete, "/_fonoteka/api/v1/oauth-identities/facebook", nil)
req.SetPathValue("provider", "facebook")
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID}), "en"))
rec := httptest.NewRecorder()
h := controllers.OAuthIdentitiesDestroy(app, controllers.OAuthIdentitiesOptions{
WriteNotFound: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) },
})
done := make(chan struct{})
go func() {
defer close(done)
h.ServeHTTP(rec, req)
}()
select {
case <-done:
t.Fatalf("the unlink answered %d while transaction A held the user row lock", rec.Code)
case <-time.After(300 * time.Millisecond):
}
if err := txA.Commit().Error; err != nil {
t.Fatal(err)
}
committed = true
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the unlink did not answer after transaction A committed")
}
if rec.Code != http.StatusConflict {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
@@ -145,10 +226,14 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword(t *testing.T) {
t.Fatal(err)
}
if body["error"] != lastMethodBlockedEN {
t.Fatalf("last_method_blocked with password = %#v", body["error"])
t.Fatalf("409 body = %s", rec.Body.String())
}
if remaining := countOAuthIdentities(t, db, user.ID); remaining != 1 {
t.Fatalf("row still present = %d", remaining)
var left []models.OAuthIdentity
if err := db.Where("user_id = ?", user.ID).Find(&left).Error; err != nil {
t.Fatal(err)
}
if len(left) != 1 || left[0].Provider != "facebook" {
t.Fatalf("remaining = %#v, want facebook only", left)
}
}

View File

@@ -0,0 +1,76 @@
package user
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// fakeProviderSource is a social-login plugin's provider source.
type fakeProviderSource struct {
enabled []classes.OAuthProvider
known []string
}
func (f fakeProviderSource) EnabledOAuthProviders(context.Context) []classes.OAuthProvider {
return f.enabled
}
func (f fakeProviderSource) KnownOAuthProviders() []string { return f.known }
// TestOAuthProvidersSourceSmoke checks that oauth-providers keeps the empty
// body without a published source and lists the source's enabled providers,
// looked up per request, once one is published.
func TestOAuthProvidersSourceSmoke(t *testing.T) {
app := backpack.New(sessionConfig(t))
h := controllers.OAuthProviders(app)
get := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/oauth-providers", nil)
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
empty := get()
if empty.Code != http.StatusOK {
t.Fatalf("status without a source = %d", empty.Code)
}
// The bytes the handler answered before sources existed (wire encodes
// map keys sorted; the PHP recording has the same JSON value).
if got := strings.TrimRight(empty.Body.String(), "\n"); got != `{"providers":[],"success":true}` {
t.Fatalf("body without a source = %s", got)
}
src := fakeProviderSource{
enabled: []classes.OAuthProvider{{Name: "google", Label: "Google"}},
known: []string{"google", "facebook"},
}
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
t.Fatal(err)
}
rec := get()
if rec.Code != http.StatusOK {
t.Fatalf("status with a source = %d", rec.Code)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
t.Fatalf("Cache-Control = %q", cc)
}
var body struct {
Success bool `json:"success"`
Providers []classes.OAuthProvider `json:"providers"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
if !body.Success || !reflect.DeepEqual(body.Providers, src.enabled) {
t.Fatalf("body with a source = %s", rec.Body.String())
}
}

136
oauth_providers_test.go Normal file
View File

@@ -0,0 +1,136 @@
package user
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// countingProviderSource answers a mutable provider list and counts the
// lookups, so a test can prove the handler asks on every request.
type countingProviderSource struct {
enabled atomic.Pointer[[]classes.OAuthProvider]
calls atomic.Int32
ctxSeen atomic.Bool
}
type ctxProbeKey struct{}
func (s *countingProviderSource) EnabledOAuthProviders(ctx context.Context) []classes.OAuthProvider {
s.calls.Add(1)
if ctx.Value(ctxProbeKey{}) == "probe" {
s.ctxSeen.Store(true)
}
if p := s.enabled.Load(); p != nil {
return *p
}
return nil
}
func (s *countingProviderSource) KnownOAuthProviders() []string { return nil }
// TestOAuthProviders pins GET /_user/api/v1/oauth-providers (D-15) without a
// database: the old bytes without a source, an empty list from a source
// with nothing enabled, the source's order, and a lookup on every request.
func TestOAuthProviders(t *testing.T) {
get := func(t *testing.T, h http.HandlerFunc, ctx context.Context) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/oauth-providers", nil)
if ctx != nil {
req = req.WithContext(ctx)
}
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
t.Fatalf("Cache-Control = %q", cc)
}
return rec
}
body := func(rec *httptest.ResponseRecorder) string {
return strings.TrimRight(rec.Body.String(), "\n")
}
t.Run("no_source_old_bytes", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("body = %s", got)
}
})
t.Run("source_without_enabled_providers", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
if err := app.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google"}}); err != nil {
t.Fatal(err)
}
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("body = %s", got)
}
})
t.Run("order_preserved", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
src := fakeProviderSource{enabled: []classes.OAuthProvider{
{Name: "microsoft", Label: "Microsoft"},
{Name: "github", Label: "GitHub"},
{Name: "google", Label: "Google"},
}}
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
t.Fatal(err)
}
want := `{"providers":[{"label":"Microsoft","name":"microsoft"},{"label":"GitHub","name":"github"},{"label":"Google","name":"google"}],"success":true}`
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != want {
t.Fatalf("body = %s\nwant %s", got, want)
}
})
t.Run("lookup_per_request", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
h := controllers.OAuthProviders(app)
if got := body(get(t, h, nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("before publishing: %s", got)
}
src := &countingProviderSource{}
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
t.Fatal(err)
}
first := []classes.OAuthProvider{{Name: "google", Label: "Google"}}
src.enabled.Store(&first)
probe := context.WithValue(context.Background(), ctxProbeKey{}, "probe")
if got := body(get(t, h, probe)); got != `{"providers":[{"label":"Google","name":"google"}],"success":true}` {
t.Fatalf("after publishing: %s", got)
}
second := []classes.OAuthProvider{{Name: "github", Label: "GitHub"}}
src.enabled.Store(&second)
if got := body(get(t, h, nil)); got != `{"providers":[{"label":"GitHub","name":"github"}],"success":true}` {
t.Fatalf("after the source changed: %s", got)
}
if n := src.calls.Load(); n != 2 {
t.Fatalf("EnabledOAuthProviders called %d times for 2 requests", n)
}
if !src.ctxSeen.Load() {
t.Fatal("EnabledOAuthProviders did not receive the request context")
}
})
t.Run("bare_publish_is_not_found", func(t *testing.T) {
// A source published without the explicit type argument is keyed on
// its concrete type; the list stays empty (the documented pitfall).
app := backpack.New(sessionConfig(t))
if err := app.Publish(fakeProviderSource{enabled: []classes.OAuthProvider{{Name: "google", Label: "Google"}}}); err != nil {
t.Fatal(err)
}
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("body = %s", got)
}
})
}

View File

@@ -4,8 +4,8 @@ import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
@@ -18,6 +18,7 @@ import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
@@ -103,10 +104,13 @@ func (p *Plugin) Boot(app *backpack.App) error {
return err
}
trusted := surf.TrustedProxies(app.Config)
return reg.Register(p.ID(), "api_token", &usermiddleware.APITokenGuard{
if err := reg.Register(p.ID(), "api_token", &usermiddleware.APITokenGuard{
App: app, Prefix: prefix,
ClientIP: func(r *http.Request) string { return surf.ClientIP(r, trusted) },
})
}); err != nil {
return err
}
return classes.RegisterUserAuthorizer(app)
}
func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
@@ -133,7 +137,7 @@ func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
select {
case <-ticker.C:
if err := bl.Sweep(context.Background(), time.Now()); err != nil {
slog.Default().Error("golem15.user: jwt blacklist sweep", "err", err)
pluginLog.Error("golem15.user: jwt blacklist sweep", "err", err)
}
case <-stop:
return
@@ -201,7 +205,25 @@ func (p *Plugin) MailLayouts() map[string]string {
}
func (p *Plugin) Commands() []bonfire.Command {
return append([]bonfire.Command{console.RequirePasswordChange(p.app)}, console.APITokenCommands(p.app)...)
return append([]bonfire.Command{console.RequirePasswordChange(p.withDB), console.BackfillHandles(p.app)}, console.APITokenCommands(p.app, p.withDB)...)
}
// withDB runs fn with the application's database: the one the serve command
// published, or, when a console command runs, one opened from the config for
// the duration of fn.
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
if p == nil || p.app == nil {
return fmt.Errorf("golem15.user: app is nil")
}
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil {
return fn(gdb)
}
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
if err != nil {
return err
}
defer sqlDB.Close()
return fn(gdb)
}
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }

View File

@@ -82,6 +82,29 @@ func TestChangePassword(t *testing.T) {
t.Fatalf("same password %d %s", same.Code, same.Body.String())
}
// PHP OAuthPasswordBootstrapTest::test_real_password_account_still_requires_current_password:
// a self-set password still needs current_password, and a wrong one is
// refused with the unchanged invalid_login body; the password stays.
t.Run("real_password_account_still_requires_current_password", func(t *testing.T) {
missing := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"password":"another-password-2","password_confirmation":"another-password-2"}`)
var body map[string]any
if err := json.Unmarshal(missing.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
errs, _ := body["errors"].(map[string]any)
if missing.Code != http.StatusUnprocessableEntity || errs["current_password"] == nil {
t.Fatalf("missing current_password %d %s", missing.Code, missing.Body.String())
}
wrong := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"current_password":"definitely-wrong","password":"another-password-2","password_confirmation":"another-password-2"}`)
if wrong.Code != http.StatusUnprocessableEntity || wrong.Body.String() != `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}` {
t.Fatalf("wrong current_password %d %s", wrong.Code, wrong.Body.String())
}
again := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"change@example.test","password":"brand-new-password"}`)
if again.Code != http.StatusOK {
t.Fatalf("the password changed: login %d %s", again.Code, again.Body.String())
}
})
locked := insertUser(t, gdb, "locked-password@example.test", "correct-horse")
if err := gdb.Model(locked).UpdateColumn("has_self_set_password", false).Error; err != nil {
t.Fatal(err)

View File

@@ -1,18 +1,25 @@
package user
import (
"fmt"
"strings"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/surf"
)
func (p *Plugin) Routes(r pact.Router) error {
guard, err := p.turnstileGuard()
if err != nil {
return err
}
r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) {
g.Post("/login", controllers.Login(p.app))
g.Post("/login", controllers.Login(p.app), guard...)
g.Post("/logout", controllers.Logout(p.app))
g.Get("/fetch", controllers.Fetch(p.app))
g.Post("/refresh", controllers.Refresh(p.app))
g.Post("/register", controllers.Register(p.app))
g.Post("/register", controllers.Register(p.app), guard...)
g.Get("/oauth-providers", controllers.OAuthProviders(p.app))
g.Post("/forgot-password", controllers.ForgotPassword(p.app))
g.Post("/reset-password", controllers.ResetPassword(p.app))
@@ -30,3 +37,22 @@ func (p *Plugin) Routes(r pact.Router) error {
})
return nil
}
// turnstileGuard returns the middleware that golem15.user.turnstile adds to
// POST /login and /register. The key is opt-in: empty (the default) adds
// nothing, "open" and "closed" select the framework's turnstile:{mode}
// middleware (summercms v0.3.0 and later), and any other value is refused so
// a typo cannot silently leave the forms unprotected.
func (p *Plugin) turnstileGuard() ([]string, error) {
if p.app == nil || p.app.Config == nil {
return nil, nil
}
switch mode := strings.TrimSpace(p.app.Config.String("golem15.user.turnstile")); mode {
case "":
return nil, nil
case "open", "closed":
return []string{"turnstile:" + mode}, nil
default:
return nil, fmt.Errorf("golem15.user: golem15.user.turnstile must be empty, open or closed, got %q", mode)
}
}

297
session_user_test.go Normal file
View File

@@ -0,0 +1,297 @@
package user
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"gorm.io/gorm"
)
// failingBlacklist fails every call, as an unreachable blacklist table does.
type failingBlacklist struct{}
var errBlacklistDown = errors.New("blacklist down")
func (failingBlacklist) Add(context.Context, string, time.Time, time.Time) error {
return errBlacklistDown
}
func (failingBlacklist) IsBlacklisted(context.Context, string) (bool, error) {
return false, errBlacklistDown
}
func (failingBlacklist) Sweep(context.Context, time.Time) error { return errBlacklistDown }
// sessionUserApp is an application without a database: the plugin's JWT secret
// and, when cookie is set, the 19.3 cookie settings.
func sessionUserApp(t *testing.T, environ ...string) *backpack.App {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: session-user\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
// sessionRequest is a GET carrying the session token the way kind says:
// "bearer", "token" (cookie) or "auth_token" (cookie).
func sessionRequest(kind, token string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/oauth/authorize", nil)
switch kind {
case "bearer":
r.Header.Set("Authorization", "Bearer "+token)
case "token", "auth_token":
r.AddCookie(&http.Cookie{Name: kind, Value: token})
}
return r
}
// TestRequestUser pins classes.RequestUser (D-05): the session JWT from the
// bearer header, then the token and auth_token cookies, checked like the
// plugin's jwt guard; every rejected token is anonymous and only an
// infrastructure failure is an error.
func TestRequestUser(t *testing.T) {
env := newAdminEnv(t)
ctx := t.Context()
issue := func(u *models.User) string {
t.Helper()
token, err := controllers.IssueToken(env.app, u, httptest.NewRequest(http.MethodPost, "https://app.example.test/login", nil))
if err != nil {
t.Fatal(err)
}
return token
}
expectUser := func(name string, r *http.Request, want *models.User) {
t.Helper()
got, err := classes.RequestUser(ctx, env.app, r)
if err != nil {
t.Fatalf("%s: error %v", name, err)
}
switch {
case want == nil && got != nil:
t.Fatalf("%s: signed in user %d, want anonymous", name, got.ID)
case want != nil && (got == nil || got.ID != want.ID || got.Email != want.Email):
t.Fatalf("%s: user %+v, want %d", name, got, want.ID)
}
}
ada, bea := env.seedUser(t, "ada", nil), env.seedUser(t, "bea", nil)
adaToken, beaToken := issue(ada), issue(bea)
expectUser("bearer", sessionRequest("bearer", adaToken), ada)
expectUser("token cookie", sessionRequest("token", adaToken), ada)
expectUser("auth_token cookie", sessionRequest("auth_token", adaToken), ada)
both := sessionRequest("bearer", adaToken)
both.AddCookie(&http.Cookie{Name: "token", Value: beaToken})
expectUser("bearer before cookie", both, ada)
cookies := sessionRequest("token", beaToken)
cookies.AddCookie(&http.Cookie{Name: "auth_token", Value: adaToken})
expectUser("token before auth_token", cookies, bea)
blankBearer := sessionRequest("token", beaToken)
blankBearer.Header.Set("Authorization", "Bearer ")
expectUser("blank bearer falls back to the cookie", blankBearer, bea)
expectUser("anonymous", httptest.NewRequest(http.MethodGet, "/", nil), nil)
if u, err := classes.RequestUser(ctx, env.app, nil); u != nil || err != nil {
t.Fatalf("nil request: %v %v", u, err)
}
sub := strconv.FormatUint(uint64(ada.ID), 10)
expired, _, _ := bouncer.Mint(sessionSecret, sub, "https://app.example.test", -time.Minute)
expectUser("expired", sessionRequest("bearer", expired), nil)
forged, _, _ := bouncer.Mint("another-secret-of-the-right-length-xyz1", sub, "https://app.example.test", time.Hour)
expectUser("another secret", sessionRequest("bearer", forged), nil)
expectUser("garbage", sessionRequest("bearer", "not-a-jwt"), nil)
for _, s := range []string{"abc", "0", "-1"} {
odd, _, _ := bouncer.Mint(sessionSecret, s, "https://app.example.test", time.Hour)
expectUser("sub "+s, sessionRequest("bearer", odd), nil)
}
gone, _, _ := bouncer.Mint(sessionSecret, strconv.FormatUint(uint64(bea.ID+1_000_000), 10), "https://app.example.test", time.Hour)
expectUser("unknown user", sessionRequest("bearer", gone), nil)
// Blacklisted: the jti EndRequestSession revoked signs nobody in.
carl := env.seedUser(t, "carl", nil)
carlToken := issue(carl)
expectUser("carl before sign-out", sessionRequest("bearer", carlToken), carl)
if err := classes.EndRequestSession(ctx, env.app, httptest.NewRecorder(), sessionRequest("bearer", carlToken)); err != nil {
t.Fatal(err)
}
expectUser("blacklisted", sessionRequest("bearer", carlToken), nil)
// tokens_valid_after after the token's iat (a password change).
dora := env.seedUser(t, "dora", nil)
doraToken := issue(dora)
cutoff := time.Now().Add(time.Minute)
if err := env.gdb.Model(&models.User{}).Where("id = ?", dora.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
t.Fatal(err)
}
expectUser("tokens_valid_after", sessionRequest("bearer", doraToken), nil)
// A soft-deleted (deactivated) user.
eve := env.seedUser(t, "eve", nil)
eveToken := issue(eve)
if err := env.gdb.Model(&models.User{}).Where("id = ?", eve.ID).Update("deleted_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
expectUser("soft-deleted", sessionRequest("bearer", eveToken), nil)
// Only infrastructure failures are errors.
noDB := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
if u, err := classes.RequestUser(ctx, noDB, sessionRequest("bearer", adaToken)); u != nil || err == nil {
t.Fatalf("no database handle: %v %v", u, err)
}
noSecret := sessionUserApp(t)
if u, err := classes.RequestUser(ctx, noSecret, sessionRequest("bearer", adaToken)); u != nil || err == nil {
t.Fatalf("no JWT secret: %v %v", u, err)
}
if u, err := classes.RequestUser(ctx, noSecret, httptest.NewRequest(http.MethodGet, "/", nil)); u != nil || err != nil {
t.Fatalf("anonymous without a secret: %v %v", u, err)
}
broken := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
if err := broken.Publish(env.gdb); err != nil {
t.Fatal(err)
}
if err := broken.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil {
t.Fatal(err)
}
if u, err := classes.RequestUser(ctx, broken, sessionRequest("bearer", adaToken)); u != nil || !errors.Is(err, errBlacklistDown) {
t.Fatalf("failing blacklist: %v %v", u, err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if u, err := classes.RequestUser(canceled, env.app, sessionRequest("bearer", adaToken)); u != nil || err == nil {
t.Fatalf("failing query: %v %v", u, err)
}
// Without a published blacklist the token is accepted on its own checks.
plain := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
if err := plain.Publish[*gorm.DB](env.gdb); err != nil {
t.Fatal(err)
}
if u, err := classes.RequestUser(ctx, plain, sessionRequest("bearer", adaToken)); err != nil || u == nil || u.ID != ada.ID {
t.Fatalf("without a blacklist: %v %v", u, err)
}
}
// TestEndRequestSession pins classes.EndRequestSession (D-04, D-05) without
// a database: the session's jti is blacklisted until the token expires, the
// forget cookie carries the configured Domain and Path, a request without a
// token still forgets the cookie, and a blacklist failure never fails the
// call.
func TestEndRequestSession(t *testing.T) {
ctx := t.Context()
app := sessionUserApp(t,
"SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret,
"SUMMER_AUTH__COOKIE__DOMAIN=app.example.test",
"SUMMER_AUTH__COOKIE__PATH=/app",
)
bl := bouncer.NewMemoryBlacklist()
if err := app.Publish[bouncer.BlacklistStore](bl); err != nil {
t.Fatal(err)
}
forgot := func(t *testing.T, rec *httptest.ResponseRecorder, domain, path string) {
t.Helper()
res := rec.Result()
var found *http.Cookie
for _, c := range res.Cookies() {
if c.Name == bouncer.DefaultCookieName {
found = c
}
}
if found == nil || found.Value != "" || found.MaxAge >= 0 || found.Domain != domain || found.Path != path {
t.Fatalf("forget cookie = %+v (Set-Cookie %q)", found, res.Header.Values("Set-Cookie"))
}
if rec.Body.Len() != 0 {
t.Fatalf("EndRequestSession wrote a body: %q", rec.Body.String())
}
}
for _, kind := range []string{"bearer", "token", "auth_token"} {
token, jti, err := bouncer.Mint(sessionSecret, "7", "https://app.example.test", time.Hour)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
if err := classes.EndRequestSession(ctx, app, rec, sessionRequest(kind, token)); err != nil {
t.Fatalf("%s: %v", kind, err)
}
forgot(t, rec, "app.example.test", "/app")
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
t.Fatalf("%s: the jti is not blacklisted", kind)
}
// Until the token's expiry, and no longer.
_ = bl.Sweep(ctx, time.Now())
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
t.Fatalf("%s: the entry left before the token expired", kind)
}
_ = bl.Sweep(ctx, time.Now().Add(time.Hour+time.Minute))
if blocked, _ := bl.IsBlacklisted(ctx, jti); blocked {
t.Fatalf("%s: the entry outlived the token", kind)
}
}
// No token, a token of another secret, garbage: the cookie is forgotten
// and nothing is blacklisted.
foreign, foreignJTI, _ := bouncer.Mint("another-secret-of-the-right-length-xyz1", "7", "https://app.example.test", time.Hour)
for name, r := range map[string]*http.Request{
"no token": httptest.NewRequest(http.MethodPost, "/", nil),
"another secret": sessionRequest("bearer", foreign),
"garbage": sessionRequest("token", "not-a-jwt"),
} {
rec := httptest.NewRecorder()
if err := classes.EndRequestSession(ctx, app, rec, r); err != nil {
t.Fatalf("%s: %v", name, err)
}
forgot(t, rec, "app.example.test", "/app")
}
if blocked, _ := bl.IsBlacklisted(ctx, foreignJTI); blocked {
t.Fatal("a token of another secret was blacklisted")
}
// A blacklist failure is logged, never returned; the cookie still goes.
down := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
if err := down.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil {
t.Fatal(err)
}
token, _, _ := bouncer.Mint(sessionSecret, "7", "https://app.example.test", time.Hour)
rec := httptest.NewRecorder()
if err := classes.EndRequestSession(ctx, down, rec, sessionRequest("bearer", token)); err != nil {
t.Fatalf("failing blacklist: %v", err)
}
forgot(t, rec, "", "/")
// Without an app or a writer it does what it can.
rec = httptest.NewRecorder()
if err := classes.EndRequestSession(ctx, nil, rec, sessionRequest("bearer", token)); err != nil {
t.Fatal(err)
}
forgot(t, rec, "", "/")
if err := classes.EndRequestSession(ctx, app, nil, nil); err != nil {
t.Fatal(err)
}
// An invalid cookie configuration falls back to the defaults.
badCookie := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret, "SUMMER_AUTH__COOKIE__PATH=relative")
rec = httptest.NewRecorder()
if err := classes.EndRequestSession(ctx, badCookie, rec, nil); err != nil {
t.Fatal(err)
}
forgot(t, rec, "", "/")
if !strings.Contains(strings.Join(rec.Result().Header.Values("Set-Cookie"), ";"), "token=") {
t.Fatal("no Set-Cookie for the token cookie")
}
}

View File

@@ -0,0 +1,86 @@
package user
import (
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/postcard"
)
// TestSocialHooksSmoke drives the helpers a social-login plugin calls: a
// password-less registration and FinishLogin on a soft-deleted account.
func TestSocialHooksSmoke(t *testing.T) {
if testing.Short() {
t.Skip("Postgres-backed; skipped under -short")
}
app, gdb := sessionApp(t, "0s")
ctx := t.Context()
fields := map[string]any{
"email": "social-hooks@example.test",
"password": "random-password-never-shown",
"password_confirmation": "random-password-never-shown",
}
user, errs, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{
Activate: true, IP: "203.0.113.7", PasswordLess: true,
})
if err != nil || len(errs) > 0 {
t.Fatalf("RegisterUser: %v %v", err, errs)
}
var stored models.User
if err := gdb.First(&stored, user.ID).Error; err != nil {
t.Fatal(err)
}
if stored.HasSelfSetPassword {
t.Fatal("a password-less registration stored has_self_set_password=true")
}
if !stored.IsActivated {
t.Fatal("the registration was not activated")
}
if stored.Handle == nil || *stored.Handle == "" {
t.Fatal("the registration got no handle")
}
// FinishLogin restores a soft-deleted account, records the IP, stamps
// last_seen and sends exactly one reactivate mail.
loc := "en"
soft := &models.User{Email: "social-soft@example.test", Password: "x", PreferredLocale: &loc}
if err := gdb.Create(soft).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Model(soft).Update("deleted_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
var trashed models.User
if err := gdb.Unscoped().First(&trashed, soft.ID).Error; err != nil {
t.Fatal(err)
}
if !trashed.DeletedAt.Valid {
t.Fatal("setup: the user is not soft-deleted")
}
mail := &captureMail{}
if err := app.Publish[postcard.Mailer](mail); err != nil {
t.Fatal(err)
}
if err := controllers.FinishLogin(ctx, app, gdb, &trashed, "198.51.100.4"); err != nil {
t.Fatal(err)
}
var after models.User
if err := gdb.Unscoped().First(&after, soft.ID).Error; err != nil {
t.Fatal(err)
}
if after.DeletedAt.Valid {
t.Fatal("FinishLogin did not restore the account")
}
if after.LastIPAddress == nil || *after.LastIPAddress != "198.51.100.4" {
t.Fatalf("last_ip_address = %v", after.LastIPAddress)
}
if after.LastSeen == nil {
t.Fatal("FinishLogin did not stamp last_seen")
}
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
t.Fatalf("mails = %#v", mail.got)
}
}

332
social_hooks_test.go Normal file
View File

@@ -0,0 +1,332 @@
package user
import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/postcard"
"gorm.io/gorm"
)
// skipShortPostgres skips a Postgres-backed test under -short (the phase
// gate runs these by name without -short on a Docker host).
func skipShortPostgres(t *testing.T) {
t.Helper()
if testing.Short() {
t.Skip("Postgres-backed; skipped under -short")
}
}
// hookEmail is a unique address for one test.
func hookEmail(prefix string) string {
return fmt.Sprintf("%s-%d@example.test", prefix, time.Now().UnixNano())
}
func registerFields(email string) map[string]any {
return map[string]any{
"email": email,
"name": "Social Person",
"password": "random-password-never-shown",
"password_confirmation": "random-password-never-shown",
}
}
// TestRegisterUserPasswordLess pins RegisterOptions.PasswordLess (D-22): the
// zero value keeps has_self_set_password true, true stores false; the handle
// is assigned either way; validation errors are the same with and without
// it and nothing is written.
func TestRegisterUserPasswordLess(t *testing.T) {
skipShortPostgres(t)
app, gdb := sessionApp(t, "0s")
ctx := t.Context()
stored := func(t *testing.T, id uint) models.User {
t.Helper()
var u models.User
if err := gdb.First(&u, id).Error; err != nil {
t.Fatal(err)
}
return u
}
t.Run("password_less_false_keeps_self_set", func(t *testing.T) {
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail("pwless-false")), controllers.RegisterOptions{Activate: true})
if err != nil || len(errs) > 0 {
t.Fatalf("RegisterUser: %v %v", err, errs)
}
if got := stored(t, u.ID); !got.HasSelfSetPassword || !u.HasSelfSetPassword {
t.Fatalf("has_self_set_password stored %v (returned %v), want true", got.HasSelfSetPassword, u.HasSelfSetPassword)
}
})
t.Run("password_less_true_stores_false", func(t *testing.T) {
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail("pwless-true")), controllers.RegisterOptions{Activate: true, PasswordLess: true, IP: "198.18.7.1"})
if err != nil || len(errs) > 0 {
t.Fatalf("RegisterUser: %v %v", err, errs)
}
got := stored(t, u.ID)
if got.HasSelfSetPassword || u.HasSelfSetPassword {
t.Fatalf("has_self_set_password stored %v (returned %v), want false", got.HasSelfSetPassword, u.HasSelfSetPassword)
}
if !got.IsActivated || got.ActivatedAt == nil {
t.Fatalf("activation %v %v", got.IsActivated, got.ActivatedAt)
}
if got.CreatedIPAddress == nil || *got.CreatedIPAddress != "198.18.7.1" || got.LastIPAddress == nil || *got.LastIPAddress != "198.18.7.1" {
t.Fatalf("ip columns %v %v", got.CreatedIPAddress, got.LastIPAddress)
}
})
t.Run("handle_assigned", func(t *testing.T) {
for _, pl := range []bool{false, true} {
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail(fmt.Sprintf("pwless-handle-%v", pl))), controllers.RegisterOptions{PasswordLess: pl})
if err != nil || len(errs) > 0 {
t.Fatalf("RegisterUser(PasswordLess=%v): %v %v", pl, err, errs)
}
if got := stored(t, u.ID); got.Handle == nil || *got.Handle == "" {
t.Fatalf("PasswordLess=%v: no handle", pl)
}
}
})
t.Run("validation_errors_unchanged", func(t *testing.T) {
email := "not-an-email"
fields := map[string]any{"email": email, "password": "short", "password_confirmation": "other"}
_, plain, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{})
if err != nil || len(plain) == 0 {
t.Fatalf("no validation errors without PasswordLess: %v %v", err, plain)
}
u, less, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{PasswordLess: true})
if err != nil || u != nil {
t.Fatalf("PasswordLess registration with invalid fields: user %v err %v", u, err)
}
if !reflect.DeepEqual(plain, less) {
t.Fatalf("validation errors differ:\n without %v\n with %v", plain, less)
}
if _, ok := less["email"]; !ok {
t.Fatalf("no email error: %v", less)
}
var n int64
if err := gdb.Unscoped().Model(&models.User{}).Where("email = ?", email).Count(&n).Error; err != nil || n != 0 {
t.Fatalf("an invalid registration wrote %d rows (%v)", n, err)
}
})
}
// TestFinishLogin pins the social sign-in tail of a password login: a
// trashed account is restored with exactly one reactivate mail, an active
// one gets none; last_ip_address and last_seen are written, and an empty
// ip leaves last_ip_address alone.
func TestFinishLogin(t *testing.T) {
skipShortPostgres(t)
newApp := func(t *testing.T) (*backpack.App, *gorm.DB, *captureMail) {
t.Helper()
app, gdb := sessionApp(t, "0s")
mail := &captureMail{}
if err := app.Publish[postcard.Mailer](mail); err != nil {
t.Fatal(err)
}
return app, gdb, mail
}
load := func(t *testing.T, gdb *gorm.DB, id uint) models.User {
t.Helper()
var u models.User
if err := gdb.Unscoped().First(&u, id).Error; err != nil {
t.Fatal(err)
}
return u
}
newUser := func(t *testing.T, gdb *gorm.DB, prefix string) *models.User {
t.Helper()
loc := "en"
u := &models.User{Email: hookEmail(prefix), Password: "x", PreferredLocale: &loc, IsActivated: true}
if err := gdb.Create(u).Error; err != nil {
t.Fatal(err)
}
return u
}
t.Run("trashed_user_restored_with_one_mail", func(t *testing.T) {
app, gdb, mail := newApp(t)
u := newUser(t, gdb, "finish-trashed")
if err := gdb.Model(u).Update("deleted_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
trashed := load(t, gdb, u.ID)
if !trashed.DeletedAt.Valid {
t.Fatal("setup: not soft-deleted")
}
if err := controllers.FinishLogin(t.Context(), app, gdb, &trashed, "198.18.8.1"); err != nil {
t.Fatal(err)
}
if after := load(t, gdb, u.ID); after.DeletedAt.Valid {
t.Fatal("not restored")
}
if trashed.DeletedAt.Valid {
t.Fatal("the passed user still reads as trashed")
}
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
t.Fatalf("mails %#v, want one reactivate-en", mail.got)
}
// A second sign-in of the restored user sends no further mail.
if err := controllers.FinishLogin(t.Context(), app, gdb, &trashed, "198.18.8.1"); err != nil {
t.Fatal(err)
}
if len(mail.got) != 1 {
t.Fatalf("mails after a second sign-in %#v", mail.got)
}
})
t.Run("active_user_no_mail", func(t *testing.T) {
app, gdb, mail := newApp(t)
u := newUser(t, gdb, "finish-active")
if err := controllers.FinishLogin(t.Context(), app, gdb, u, "198.18.8.2"); err != nil {
t.Fatal(err)
}
if len(mail.got) != 0 {
t.Fatalf("mails %#v, want none", mail.got)
}
})
t.Run("ip_and_last_seen", func(t *testing.T) {
app, gdb, _ := newApp(t)
u := newUser(t, gdb, "finish-ip")
before := time.Now().Add(-time.Second)
if err := controllers.FinishLogin(t.Context(), app, gdb, u, "198.18.8.3"); err != nil {
t.Fatal(err)
}
after := load(t, gdb, u.ID)
if after.LastIPAddress == nil || *after.LastIPAddress != "198.18.8.3" {
t.Fatalf("last_ip_address %v", after.LastIPAddress)
}
if u.LastIPAddress == nil || *u.LastIPAddress != "198.18.8.3" {
t.Fatalf("the passed user's LastIPAddress %v", u.LastIPAddress)
}
if after.LastSeen == nil || after.LastSeen.Before(before) {
t.Fatalf("last_seen %v, want at or after %v", after.LastSeen, before)
}
})
t.Run("empty_ip_keeps_last_ip", func(t *testing.T) {
app, gdb, _ := newApp(t)
u := newUser(t, gdb, "finish-noip")
if err := gdb.Model(u).Update("last_ip_address", "198.18.8.4").Error; err != nil {
t.Fatal(err)
}
if err := controllers.FinishLogin(t.Context(), app, gdb, u, ""); err != nil {
t.Fatal(err)
}
if after := load(t, gdb, u.ID); after.LastIPAddress == nil || *after.LastIPAddress != "198.18.8.4" {
t.Fatalf("last_ip_address %v, want the old 198.18.8.4", after.LastIPAddress)
}
})
t.Run("refuses_unsaved_user", func(t *testing.T) {
app, gdb, _ := newApp(t)
if err := controllers.FinishLogin(t.Context(), app, gdb, &models.User{}, "198.18.8.5"); err == nil {
t.Fatal("FinishLogin accepted a user without an id")
}
if err := controllers.FinishLogin(t.Context(), app, nil, &models.User{ID: 1}, ""); err == nil {
t.Fatal("FinishLogin accepted a nil database")
}
})
}
// TestRegistrationExports pins the helpers golem15.user exports for a social
// login plugin: RegistrationSettings (defaults and config), RegistrationsFromIP
// (the last 60 minutes, that IP only), RefreshTTL (default 20160 minutes and
// config) and RemoteIP (with and without a port).
func TestRegistrationExports(t *testing.T) {
t.Run("registration_settings", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
allow, throttle, mode, require := controllers.RegistrationSettings(app)
if !allow || !throttle || mode != "auto" || !require {
t.Fatalf("defaults = %v %v %q %v, want true true auto true", allow, throttle, mode, require)
}
for key, value := range map[string]any{
"golem15.user.registration.allow_registration": false,
"golem15.user.registration.use_register_throttle": false,
"golem15.user.activation.activate_mode": "admin",
"golem15.user.activation.require_activation": false,
} {
if err := app.Config.Set(key, value); err != nil {
t.Fatal(err)
}
}
allow, throttle, mode, require = controllers.RegistrationSettings(app)
if allow || throttle || mode != "admin" || require {
t.Fatalf("configured = %v %v %q %v, want false false admin false", allow, throttle, mode, require)
}
if a, th, m, r := controllers.RegistrationSettings(nil); !a || !th || m != "auto" || !r {
t.Fatalf("nil app = %v %v %q %v", a, th, m, r)
}
})
t.Run("registrations_from_ip", func(t *testing.T) {
skipShortPostgres(t)
_, gdb := sessionApp(t, "0s")
ip := fmt.Sprintf("198.18.%d.%d", time.Now().UnixNano()%200+20, time.Now().UnixNano()%250+1)
other := "198.19.0.1"
mk := func(addr string, age time.Duration) {
t.Helper()
u := &models.User{Email: hookEmail("regip"), Password: "x", CreatedIPAddress: &addr}
if err := gdb.Create(u).Error; err != nil {
t.Fatal(err)
}
if age > 0 {
if err := gdb.Exec(`UPDATE users SET created_at = ? WHERE id = ?`, time.Now().Add(-age), u.ID).Error; err != nil {
t.Fatal(err)
}
}
}
before := controllers.RegistrationsFromIP(t.Context(), gdb, ip)
mk(ip, 0)
mk(ip, 30*time.Minute)
mk(ip, 61*time.Minute)
mk(ip, 3*time.Hour)
mk(other, 0)
if got := controllers.RegistrationsFromIP(t.Context(), gdb, ip); got-before != 2 {
t.Fatalf("RegistrationsFromIP = %d (before %d), want 2 more: the last 60 minutes only", got, before)
}
if got := controllers.RegistrationsFromIP(t.Context(), gdb, "198.19.255.254"); got != 0 {
t.Fatalf("an unused ip counts %d", got)
}
})
t.Run("refresh_ttl", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
if got := controllers.RefreshTTL(app); got != 20160*time.Minute {
t.Fatalf("default RefreshTTL = %s, want 20160m", got)
}
if err := app.Config.Set("golem15.user.jwt.refresh_ttl", 90); err != nil {
t.Fatal(err)
}
if got := controllers.RefreshTTL(app); got != 90*time.Minute {
t.Fatalf("configured RefreshTTL = %s, want 90m", got)
}
if got := controllers.RefreshTTL(nil); got != 20160*time.Minute {
t.Fatalf("nil app RefreshTTL = %s", got)
}
})
t.Run("remote_ip", func(t *testing.T) {
for remote, want := range map[string]string{
"203.0.113.9:4242": "203.0.113.9",
"203.0.113.9": "203.0.113.9",
"[2001:db8::1]:443": "2001:db8::1",
"": "",
} {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remote
req.Header.Set("X-Forwarded-For", "192.0.2.200")
if got := controllers.RemoteIP(req); got != want {
t.Fatalf("RemoteIP(%q) = %q, want %q", remote, got, want)
}
}
})
}

158
turnstile_routes_test.go Normal file
View File

@@ -0,0 +1,158 @@
package user
import (
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
// recordedRoute is one registration seen by recordingRouter.
type recordedRoute struct {
method string
path string
middleware []string
}
// recordingRouter is a pact.Router that records each registration with its
// full path and the group plus route middleware. The plugin requires a
// framework whose surf does not know the turnstile:* names, so the smoke test
// checks the declared middleware instead of assembling a real router.
type recordingRouter struct {
prefix string
middleware []string
routes *[]recordedRoute
}
func newRecordingRouter() *recordingRouter {
return &recordingRouter{routes: &[]recordedRoute{}}
}
func (r *recordingRouter) Group(prefix string, middleware []string, fn func(pact.Router)) {
fn(&recordingRouter{
prefix: r.prefix + prefix,
middleware: append(append([]string{}, r.middleware...), middleware...),
routes: r.routes,
})
}
func (r *recordingRouter) GroupRaw(prefix string, middleware []string, fn func(pact.Router)) {
r.Group(prefix, middleware, fn)
}
func (r *recordingRouter) add(method, path string, middleware []string) {
*r.routes = append(*r.routes, recordedRoute{
method: method,
path: r.prefix + path,
middleware: append(append([]string{}, r.middleware...), middleware...),
})
}
func (r *recordingRouter) Get(path string, _ http.HandlerFunc, mw ...string) {
r.add(http.MethodGet, path, mw)
}
func (r *recordingRouter) Post(path string, _ http.HandlerFunc, mw ...string) {
r.add(http.MethodPost, path, mw)
}
func (r *recordingRouter) Put(path string, _ http.HandlerFunc, mw ...string) {
r.add(http.MethodPut, path, mw)
}
func (r *recordingRouter) Patch(path string, _ http.HandlerFunc, mw ...string) {
r.add(http.MethodPatch, path, mw)
}
func (r *recordingRouter) Delete(path string, _ http.HandlerFunc, mw ...string) {
r.add(http.MethodDelete, path, mw)
}
func (r *recordingRouter) Where(string, string) {}
func (r *recordingRouter) WhereIn(string, ...string) {}
func recordUserRoutes(t *testing.T, mode string) ([]recordedRoute, error) {
t.Helper()
app := bareApp(t)
if err := app.Config.Set("golem15.user.turnstile", mode); err != nil {
t.Fatal(err)
}
rec := newRecordingRouter()
err := (&Plugin{app: app}).Routes(rec)
return *rec.routes, err
}
func turnstileOf(route recordedRoute) []string {
var out []string
for _, name := range route.middleware {
if strings.HasPrefix(name, "turnstile") {
out = append(out, name)
}
}
return out
}
// TestTurnstileRoutes pins golem15.user.turnstile (D-07): empty adds no
// middleware; open and closed (surrounding spaces trimmed) append
// turnstile:{mode} to POST /_user/api/v1/login and /register only, leaving
// every other route's middleware as it is with the key empty; any other
// value fails Routes naming the key.
func TestTurnstileRoutes(t *testing.T) {
off, err := recordUserRoutes(t, "")
if err != nil {
t.Fatalf("empty turnstile: Routes error %v", err)
}
if len(off) == 0 {
t.Fatal("no routes recorded")
}
guarded := map[string]bool{
http.MethodPost + " /_user/api/v1/login": true,
http.MethodPost + " /_user/api/v1/register": true,
}
seen := 0
for _, route := range off {
if got := turnstileOf(route); len(got) > 0 {
t.Errorf("empty turnstile: %s %s carries %v", route.method, route.path, got)
}
if guarded[route.method+" "+route.path] {
seen++
}
}
if seen != len(guarded) {
t.Fatalf("found %d of the guarded routes, want %d", seen, len(guarded))
}
cases := []struct {
name, mode, want string
}{
{name: "open", mode: "open", want: "turnstile:open"},
{name: "closed", mode: "closed", want: "turnstile:closed"},
{name: "closed with spaces", mode: " closed ", want: "turnstile:closed"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := recordUserRoutes(t, c.mode)
if err != nil {
t.Fatalf("Routes error %v", err)
}
if len(got) != len(off) {
t.Fatalf("%d routes, want %d", len(got), len(off))
}
for i, route := range got {
base := off[i]
if route.method != base.method || route.path != base.path {
t.Fatalf("route %d is %s %s, want %s %s", i, route.method, route.path, base.method, base.path)
}
want := base.middleware
if guarded[route.method+" "+route.path] {
want = append(append([]string{}, base.middleware...), c.want)
}
if strings.Join(route.middleware, ",") != strings.Join(want, ",") {
t.Errorf("%s %s middleware %v, want %v", route.method, route.path, route.middleware, want)
}
}
})
}
for _, bad := range []string{"bogus", "Closed", "on", "true"} {
if _, err := recordUserRoutes(t, bad); err == nil || !strings.Contains(err.Error(), "golem15.user.turnstile") {
t.Errorf("turnstile %q: error %v, want one naming golem15.user.turnstile", bad, err)
}
}
}

View File

@@ -0,0 +1,30 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// addHandleToUsers adds users.handle, the unique @mention identity (D-15),
// only when it is missing: an application whose users table already has the
// column (added on PHP by chatchannels) keeps it and its data. The rollback
// is a no-op for the same reason: the column may predate this plugin.
var addHandleToUsers = []*gormigrate.Migration{
{
ID: "202610160002_add_handle_to_users",
Migrate: func(tx *gorm.DB) error {
if err := tx.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS handle VARCHAR(255) NULL`).Error; err != nil {
return err
}
return tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS users_handle_unique ON users (handle)`).Error
},
Rollback: func(*gorm.DB) error {
// No-op: the shared users column may predate the plugin.
return nil
},
},
}
func init() {
Register(addHandleToUsers...)
}

View File

@@ -0,0 +1,33 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// usersEmailVerifiedAtMigrations adds users.email_verified_at, the time the
// account's owner proved control of the address: a mailed activation code or
// a completed reset-code password reset (D-27). Social sign-in matching links
// a verified provider's identity onto an existing account only when it is set.
//
// The migration writes no rows: accounts that exist before it runs have no
// recorded proof (no backfill, D-26). A users table that already carries the
// column (a Laravel-derived schema) keeps it and its values, which mean the
// same thing. The rollback is a no-op for the same reason: the shared users
// column may predate this plugin.
var usersEmailVerifiedAtMigrations = []*gormigrate.Migration{
{
ID: "202610210001_add_users_email_verified_at",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL`).Error
},
Rollback: func(*gorm.DB) error {
// No-op: the shared users column may predate the plugin.
return nil
},
},
}
func init() {
Register(usersEmailVerifiedAtMigrations...)
}

View File

@@ -0,0 +1,143 @@
package updates
import (
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
const emailVerifiedAtID = "202610210001_add_users_email_verified_at"
// emailVerifiedDB opens a dedicated database and a migrator over every
// registered migration.
func emailVerifiedDB(t *testing.T, name string) (*gorm.DB, *gormigrate.Gormigrate) {
t.Helper()
gdb, err := lagoon.Use(t.Context(), dedicatedDB(t, name))
if err != nil {
t.Fatal(err)
}
return gdb, gormigrate.New(gdb, &gormigrate.Options{
TableName: "summer_migrations_golem15_user",
UseTransaction: true,
}, All())
}
// TestEmailVerifiedAtMigration pins D-27: users.email_verified_at is a
// nullable TIMESTAMPTZ added only when missing, the migration writes no
// rows (no backfill, D-26), a column that already exists keeps its values
// and the rollback leaves it in place.
func TestEmailVerifiedAtMigration(t *testing.T) {
t.Run("column_shape", func(t *testing.T) {
gdb, m := emailVerifiedDB(t, "user_email_verified_shape")
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
var row struct {
DataType string
IsNullable string
}
if err := gdb.Raw(`SELECT data_type, is_nullable FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'email_verified_at'`).Scan(&row).Error; err != nil {
t.Fatal(err)
}
if row.DataType != "timestamp with time zone" || row.IsNullable != "YES" {
t.Fatalf("email_verified_at = %+v", row)
}
})
t.Run("no_backfill", func(t *testing.T) {
gdb, m := emailVerifiedDB(t, "user_email_verified_backfill")
if err := m.MigrateTo("202610160002_add_handle_to_users"); err != nil {
t.Fatal(err)
}
if gdb.Migrator().HasColumn("users", "email_verified_at") {
t.Fatal("setup: the column exists before the migration")
}
for _, email := range []string{"legacy-a@example.test", "legacy-b@example.test"} {
if err := gdb.Exec(`INSERT INTO users (email, password, is_activated, has_self_set_password, activated_at)
VALUES (?, 'hash', TRUE, TRUE, NOW())`, email).Error; err != nil {
t.Fatal(err)
}
}
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
var total, proven int64
if err := gdb.Raw(`SELECT COUNT(*), COUNT(email_verified_at) FROM users`).Row().Scan(&total, &proven); err != nil {
t.Fatal(err)
}
if total != 2 || proven != 0 {
t.Fatalf("users %d, with proof %d: the migration must write no rows", total, proven)
}
})
t.Run("existing_column_kept", func(t *testing.T) {
gdb, m := emailVerifiedDB(t, "user_email_verified_existing")
if err := m.MigrateTo("202610160002_add_handle_to_users"); err != nil {
t.Fatal(err)
}
// A Laravel-derived schema already carries the column and its values.
if err := gdb.Exec(`ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ NULL`).Error; err != nil {
t.Fatal(err)
}
want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
if err := gdb.Exec(`INSERT INTO users (email, email_verified_at) VALUES ('imported@example.test', ?)`, want).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`INSERT INTO users (email) VALUES ('imported-unproven@example.test')`).Error; err != nil {
t.Fatal(err)
}
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
var ran int64
if err := gdb.Raw(`SELECT COUNT(*) FROM summer_migrations_golem15_user WHERE id = ?`, emailVerifiedAtID).Scan(&ran).Error; err != nil {
t.Fatal(err)
}
if ran != 1 {
t.Fatal("the migration did not run over the existing column")
}
var got *time.Time
if err := gdb.Raw(`SELECT email_verified_at FROM users WHERE email = 'imported@example.test'`).Row().Scan(&got); err != nil {
t.Fatal(err)
}
if got == nil || !got.Equal(want) {
t.Fatalf("existing value = %v, want %v", got, want)
}
var proven int64
if err := gdb.Raw(`SELECT COUNT(email_verified_at) FROM users`).Row().Scan(&proven); err != nil {
t.Fatal(err)
}
if proven != 1 {
t.Fatalf("rows with proof = %d, want only the imported one", proven)
}
})
t.Run("rollback_keeps_column", func(t *testing.T) {
gdb, m := emailVerifiedDB(t, "user_email_verified_rollback")
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
if err := m.RollbackMigration(usersEmailVerifiedAtMigrations[0]); err != nil {
t.Fatal(err)
}
if !gdb.Migrator().HasColumn("users", "email_verified_at") {
t.Fatal("the rollback dropped the shared users column")
}
})
t.Run("history_once", func(t *testing.T) {
n := 0
for _, mig := range All() {
if mig.ID == emailVerifiedAtID {
n++
}
}
if n != 1 || len(usersEmailVerifiedAtMigrations) != 1 || usersEmailVerifiedAtMigrations[0].ID != emailVerifiedAtID {
t.Fatalf("%s registered %d times", emailVerifiedAtID, n)
}
})
}

View File

@@ -49,6 +49,9 @@ func TestCreateOrganisationsMigration(t *testing.T) {
"202610040001_create_user_api_tokens",
"202610040002_add_users_last_seen",
"202610040003_create_frontend_permissions",
"202610050001_create_oauth_identities",
"202610160002_add_handle_to_users",
"202610210001_add_users_email_verified_at",
}
if len(ids) != len(want) {
t.Fatalf("history = %v", ids)
@@ -59,9 +62,9 @@ func TestCreateOrganisationsMigration(t *testing.T) {
}
}
// The three admin migrations, API tokens, user groups, jwt blacklist,
// Email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// throttle, extend-users, then organisations.
for range 9 {
for range 12 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -54,8 +54,8 @@ func TestUserGroupsMigration(t *testing.T) {
t.Fatalf("users_groups primary key = %q", pk)
}
// Frontend permissions, last seen, API tokens, user permissions, then user groups.
for range 5 {
// Handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
for range 7 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}

View File

@@ -47,9 +47,9 @@ func TestExtendUsersMigration(t *testing.T) {
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
t.Fatalf("defaults = %+v", row)
}
// The three admin migrations, API tokens, user groups, jwt blacklist,
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
// throttle, then extend-users.
for range 8 {
for range 10 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
@@ -88,8 +88,8 @@ func TestCreateUserThrottleMigration(t *testing.T) {
t.Fatalf("missing column %s", col)
}
}
// The three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
for range 7 {
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
for range 9 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
@@ -120,8 +120,8 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
t.Fatalf("missing column %s", col)
}
}
// The three admin migrations, API tokens, user groups, then jwt blacklist.
for range 6 {
// Handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
for range 8 {
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}