Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35cf16689d | ||
|
|
a99cb467b7 | ||
|
|
add1f7a806 | ||
|
|
c7635c56e3 | ||
|
|
d6d8f117ea |
@@ -31,6 +31,7 @@ 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.
|
- **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`.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **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.
|
||||||
@@ -158,6 +159,12 @@ Migrations added for @mention handles, all additive:
|
|||||||
|----|------|
|
|----|------|
|
||||||
| `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) |
|
| `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:
|
Packages:
|
||||||
|
|
||||||
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
|
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
|
||||||
|
|||||||
@@ -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.
|
// 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) {
|
func VerifyActivationCode(ctx context.Context, db *gorm.DB, userID uint, code string) (*models.User, bool) {
|
||||||
user, err := takeUser(ctx, db, userID, true)
|
user, err := takeUser(ctx, db, userID, true)
|
||||||
if err != nil || user == nil || user.IsActivated || user.ActivationCode == nil {
|
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,
|
"activated_at": now,
|
||||||
"activation_code": nil,
|
"activation_code": nil,
|
||||||
"activation_code_issued_at": nil,
|
"activation_code_issued_at": nil,
|
||||||
|
"email_verified_at": now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
user.IsActivated = true
|
user.IsActivated = true
|
||||||
user.ActivatedAt = &now
|
user.ActivatedAt = &now
|
||||||
|
user.EmailVerifiedAt = &now
|
||||||
user.ActivationCode = nil
|
user.ActivationCode = nil
|
||||||
user.ActivationCodeIssuedAt = nil
|
user.ActivationCodeIssuedAt = nil
|
||||||
return user, true
|
return user, true
|
||||||
|
|||||||
@@ -329,10 +329,14 @@ func ResetPassword(app *backpack.App) http.HandlerFunc {
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
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()
|
now := time.Now()
|
||||||
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{
|
||||||
"password": hashed,
|
"password": hashed,
|
||||||
"tokens_valid_after": now,
|
"tokens_valid_after": now,
|
||||||
|
"email_verified_at": now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
@@ -467,17 +471,29 @@ func Update(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
name := strField(fields, "name")
|
name := strField(fields, "name")
|
||||||
surname := strField(fields, "surname")
|
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,
|
"name": name,
|
||||||
"surname": surname,
|
"surname": surname,
|
||||||
"email": strField(fields, "email"),
|
"email": email,
|
||||||
}).Error; err != nil {
|
}
|
||||||
|
// 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)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.Name = &name
|
user.Name = &name
|
||||||
user.Surname = &surname
|
user.Surname = &surname
|
||||||
user.Email = strField(fields, "email")
|
user.Email = email
|
||||||
|
if emailChanged {
|
||||||
|
user.EmailVerifiedAt = nil
|
||||||
|
}
|
||||||
payload, err := apiArray(r.Context(), app, user)
|
payload, err := apiArray(r.Context(), app, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
"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
|
// FormBeforeUpdate starts with the takeover guard: a changed email or a
|
||||||
// submitted password on a member of a privileged group needs
|
// submitted password on a member of a privileged group needs
|
||||||
// golem15.users.manage_privileged_groups. Everything else an update does
|
// 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 {
|
func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||||
user, ok := model.(*models.User)
|
user, ok := model.(*models.User)
|
||||||
if !ok || user == nil {
|
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 {
|
if err := c.requireFreeEmail(ctx, user); err != nil {
|
||||||
return err
|
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)
|
changed, err := c.storePassword(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -462,26 +474,36 @@ func (usersAdminController) AdminSetPermissionValues(_ context.Context, field st
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// guardCredentials compares the email the save would write with the stored
|
// storedEmail reads the stored email of the user being updated, before the
|
||||||
// one and looks for a submitted password, then asks guardPrivilegedMember
|
// form's values are saved.
|
||||||
// for the fields that would change.
|
func (c usersAdminController) storedEmail(ctx context.Context, user *models.User) (string, error) {
|
||||||
func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error {
|
|
||||||
db := requestDB(ctx, c.db)
|
db := requestDB(ctx, c.db)
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return errors.New("users admin: gorm handle is missing")
|
return "", errors.New("users admin: gorm handle is missing")
|
||||||
}
|
}
|
||||||
var stored []string
|
var stored []string
|
||||||
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||||
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
|
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if len(stored) != 1 {
|
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"
|
const message = usersLang + "privileged_member_forbidden"
|
||||||
details := map[string]any{}
|
details := map[string]any{}
|
||||||
if stored[0] != user.Email {
|
if stored != user.Email {
|
||||||
details["email"] = []string{message}
|
details["email"] = []string{message}
|
||||||
}
|
}
|
||||||
if values, ok := cabana.VirtualFieldsFromContext(ctx); ok {
|
if values, ok := cabana.VirtualFieldsFromContext(ctx); ok {
|
||||||
|
|||||||
377
email_verified_test.go
Normal file
377
email_verified_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -41,6 +41,13 @@ type User struct {
|
|||||||
// minutes (classes.TouchLastSeen). It is shown in the admin list and is
|
// minutes (classes.TouchLastSeen). It is shown in the admin list and is
|
||||||
// never part of a user API payload.
|
// never part of a user API payload.
|
||||||
LastSeen *time.Time `gorm:"column:last_seen" json:"-"`
|
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
|
// Permissions are the user's own frontend permissions (1 allows, -1
|
||||||
// denies). classes.MergedPermissions combines them with the groups'. They
|
// denies). classes.MergedPermissions combines them with the groups'. They
|
||||||
// are written by the admin permission editor only and are never part of
|
// are written by the admin permission editor only and are never part of
|
||||||
|
|||||||
331
oauth_identities_rule_test.go
Normal file
331
oauth_identities_rule_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
136
oauth_providers_test.go
Normal file
136
oauth_providers_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -82,6 +82,29 @@ func TestChangePassword(t *testing.T) {
|
|||||||
t.Fatalf("same password %d %s", same.Code, same.Body.String())
|
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")
|
locked := insertUser(t, gdb, "locked-password@example.test", "correct-horse")
|
||||||
if err := gdb.Model(locked).UpdateColumn("has_self_set_password", false).Error; err != nil {
|
if err := gdb.Model(locked).UpdateColumn("has_self_set_password", false).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
332
social_hooks_test.go
Normal file
332
social_hooks_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
33
updates/202610210001_add_users_email_verified_at.go
Normal file
33
updates/202610210001_add_users_email_verified_at.go
Normal 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...)
|
||||||
|
}
|
||||||
143
updates/email_verified_at_test.go
Normal file
143
updates/email_verified_at_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
|||||||
"202610040003_create_frontend_permissions",
|
"202610040003_create_frontend_permissions",
|
||||||
"202610050001_create_oauth_identities",
|
"202610050001_create_oauth_identities",
|
||||||
"202610160002_add_handle_to_users",
|
"202610160002_add_handle_to_users",
|
||||||
|
"202610210001_add_users_email_verified_at",
|
||||||
}
|
}
|
||||||
if len(ids) != len(want) {
|
if len(ids) != len(want) {
|
||||||
t.Fatalf("history = %v", ids)
|
t.Fatalf("history = %v", ids)
|
||||||
@@ -61,9 +62,9 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle, OAuth identities, 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.
|
// throttle, extend-users, then organisations.
|
||||||
for range 11 {
|
for range 12 {
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackLast(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user