- BackendLinkConfirm reads the session's auth_time (bouncer.VerifyAuthTime);
a token without it, or a refreshed old session, answers 409 stale_session
- POST /_user/api/v1/backend-link/show takes the code in the body; GET
/backend-link/{code} stays, deprecated; both answer Cache-Control no-store
- My account answers <frontend_url><page_path>#code=<code>; new key
golem15.user.backend_links.page_path (default /link-admin)
- TestBackendLink now crafts auth_time tokens; TestBackendLinkAuthTimeSmoke
216 lines
8.4 KiB
Go
216 lines
8.4 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"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/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// requestCreatedAt reads when the administrator's open link request was
|
|
// created.
|
|
func (e *adminEnv) requestCreatedAt(t *testing.T, adminID uint) time.Time {
|
|
t.Helper()
|
|
var created time.Time
|
|
if err := e.gdb.Raw(`SELECT created_at FROM golem15_user_backend_link_requests WHERE backend_user_id = ? AND used_at IS NULL ORDER BY id DESC LIMIT 1`, adminID).Scan(&created).Error; err != nil || created.IsZero() {
|
|
t.Fatalf("link request created_at = %v, %v", created, err)
|
|
}
|
|
return created
|
|
}
|
|
|
|
// TestBackendLink (D-03..D-08) extends the link smokes: the fresh sign-in
|
|
// rule at the second (an auth_time equal to the request's created_at
|
|
// confirms, one second earlier is stale_session, and a token without
|
|
// auth_time is stale_session whatever its iat); 1:1 on the frontend side too (a
|
|
// linked user cannot confirm another administrator's request); the show
|
|
// route exposes the administrator's login and name and the expiry only;
|
|
// SetLink records linked_by and fires BackendLinkedEvent and both subject
|
|
// events after commit, nothing on rollback; RemoveLink fires
|
|
// BackendUnlinkedEvent and both subject events; LinkedBackendUser answers
|
|
// false for a deactivated or soft-deleted administrator; deactivating the
|
|
// frontend user removes the link and fires deleted. Skips under -short.
|
|
func TestBackendLink(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
spy := env.spySubjects()
|
|
links, _ := env.app.Lookup[*classes.BackendLinks]()
|
|
resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]()
|
|
ctx := context.Background()
|
|
|
|
t.Run("fresh sign-in at the second", func(t *testing.T) {
|
|
token := env.admin(t)
|
|
admin := adminID(t, token)
|
|
user := env.seedUser(t, "iat", nil)
|
|
sub := strconv.FormatUint(uint64(user.ID), 10)
|
|
code := env.startLink(t, token)
|
|
created := env.requestCreatedAt(t, admin).Truncate(time.Second)
|
|
early := craftSignInToken(t, sub, "early-"+adminStamp(), time.Now(), created.Add(-time.Second))
|
|
if status, body := env.confirmLink(t, early, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
|
|
t.Fatalf("auth_time one second early = %d %s", status, body)
|
|
}
|
|
noAuthTime := craftToken(t, sub, "legacy-"+adminStamp(), time.Now(), time.Now().Add(time.Hour))
|
|
if status, body := env.confirmLink(t, noAuthTime, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
|
|
t.Fatalf("no auth_time = %d %s", status, body)
|
|
}
|
|
exact := craftSignInToken(t, sub, "exact-"+adminStamp(), created, created)
|
|
if status, body := env.confirmLink(t, exact, code); status != http.StatusOK {
|
|
t.Fatalf("auth_time equal to created_at = %d %s", status, body)
|
|
}
|
|
})
|
|
|
|
t.Run("a linked frontend user cannot take a second administrator", func(t *testing.T) {
|
|
first, second := env.admin(t), env.admin(t)
|
|
user := env.seedUser(t, "one-to-one", nil)
|
|
code := env.startLink(t, first)
|
|
if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusOK {
|
|
t.Fatalf("first link = %d %s", status, body)
|
|
}
|
|
code = env.startLink(t, second)
|
|
if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusConflict || !strings.Contains(body, `"already_linked"`) {
|
|
t.Fatalf("second administrator = %d %s", status, body)
|
|
}
|
|
})
|
|
|
|
t.Run("show exposes login, name and expiry only", func(t *testing.T) {
|
|
token := env.admin(t)
|
|
code := env.startLink(t, token)
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
|
|
var shown map[string]map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys := func(m map[string]any) []string {
|
|
var out []string
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
admin, _ := shown["data"]["admin"].(map[string]any)
|
|
if len(shown) != 1 || !slices.Equal(keys(shown["data"]), []string{"admin", "expires_at"}) || !slices.Equal(keys(admin), []string{"login", "name"}) {
|
|
t.Fatalf("show = %s", rec.Body.String())
|
|
}
|
|
if strings.Contains(rec.Body.String(), "@") || strings.Contains(rec.Body.String(), code) {
|
|
t.Fatalf("show leaks an email or the code: %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("SetLink and RemoveLink events", func(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var linked, unlinked int
|
|
env.app.Events.Listen("test.link_events", func(context.Context, *classes.BackendLinkedEvent) error {
|
|
mu.Lock()
|
|
linked++
|
|
mu.Unlock()
|
|
return nil
|
|
})
|
|
env.app.Events.Listen("test.link_events", func(context.Context, *classes.BackendUnlinkedEvent) error {
|
|
mu.Lock()
|
|
unlinked++
|
|
mu.Unlock()
|
|
return nil
|
|
})
|
|
setter := adminID(t, env.admin(t))
|
|
target := adminID(t, env.admin(t))
|
|
user := env.seedUser(t, "set-link", nil)
|
|
|
|
// A rolled-back SetLink fires no subject event.
|
|
spy.reset()
|
|
_ = lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
|
|
if _, err := links.SetLink(ctx, tx, user.ID, target, setter); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return context.Canceled
|
|
})
|
|
if spy.has("user", user.ID, pact.MCPSubjectLink) || spy.has("backend", target, pact.MCPSubjectLink) {
|
|
t.Fatalf("a rolled-back SetLink fired %+v", spy.events)
|
|
}
|
|
|
|
var link *models.BackendLink
|
|
if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
|
|
var err error
|
|
link, err = links.SetLink(ctx, tx, user.ID, target, setter)
|
|
return err
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if link.LinkedBy == nil || *link.LinkedBy != setter {
|
|
t.Fatalf("linked_by = %v, want %d", link.LinkedBy, setter)
|
|
}
|
|
if !spy.has("user", user.ID, pact.MCPSubjectLink) || !spy.has("backend", target, pact.MCPSubjectLink) {
|
|
t.Fatalf("SetLink fired %+v", spy.events)
|
|
}
|
|
spy.reset()
|
|
if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
|
|
return links.RemoveLink(ctx, tx, link)
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !spy.has("user", user.ID, pact.MCPSubjectLink) || !spy.has("backend", target, pact.MCPSubjectLink) {
|
|
t.Fatalf("RemoveLink fired %+v", spy.events)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
// The rolled-back SetLink fired BackendLinkedEvent inside its
|
|
// transaction too (listeners run with the transaction).
|
|
if linked != 2 || unlinked != 1 {
|
|
t.Fatalf("BackendLinkedEvent %d, BackendUnlinkedEvent %d", linked, unlinked)
|
|
}
|
|
})
|
|
|
|
t.Run("deactivated or deleted administrator", func(t *testing.T) {
|
|
token := env.admin(t)
|
|
admin := adminID(t, token)
|
|
user := env.seedUser(t, "admin-gone", nil)
|
|
if status, body := env.confirmLink(t, freshUserToken(t, user), env.startLink(t, token)); status != http.StatusOK {
|
|
t.Fatalf("link = %d %s", status, body)
|
|
}
|
|
check := func(want bool, why string) {
|
|
t.Helper()
|
|
if _, found, err := resolver.LinkedBackendUser(ctx, user.ID); err != nil || found != want {
|
|
t.Fatalf("%s: found = %v, %v", why, found, err)
|
|
}
|
|
}
|
|
check(true, "active")
|
|
env.gdb.Exec(`UPDATE backend_users SET is_activated = FALSE WHERE id = ?`, admin)
|
|
check(false, "deactivated administrator")
|
|
env.gdb.Exec(`UPDATE backend_users SET is_activated = TRUE, deleted_at = NOW() WHERE id = ?`, admin)
|
|
check(false, "soft-deleted administrator")
|
|
env.gdb.Exec(`UPDATE backend_users SET deleted_at = NULL WHERE id = ?`, admin)
|
|
check(true, "restored administrator")
|
|
})
|
|
|
|
t.Run("deactivating the frontend user removes the link", func(t *testing.T) {
|
|
token := env.admin(t)
|
|
admin := adminID(t, token)
|
|
user := env.seedUser(t, "deactivated", nil)
|
|
if status, body := env.confirmLink(t, freshUserToken(t, user), env.startLink(t, token)); status != http.StatusOK {
|
|
t.Fatalf("link = %d %s", status, body)
|
|
}
|
|
spy.reset()
|
|
if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
|
|
return controllers.FireDeactivatingEvent(ctx, env.app, user, tx)
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if link, err := links.LinkOf(ctx, user.ID); err != nil || link != nil {
|
|
t.Fatalf("link after deactivation = %+v, %v", link, err)
|
|
}
|
|
if !spy.has("user", user.ID, pact.MCPSubjectDeleted) || !spy.has("backend", admin, pact.MCPSubjectLink) {
|
|
t.Fatalf("deactivation fired %+v", spy.events)
|
|
}
|
|
})
|
|
}
|