Files
sm-user-plugin/backend_link_test.go
Jakub Zych 0428093d33 test: start the link request before minting the session in TestBackendLink
Go evaluates call arguments left to right, so the two subtests minted the
frontend session before the administrator created the link request. Across a
second boundary (a loaded host) the confirm correctly answered 409
stale_session, because the sign-in predated the request.
2026-10-23 21:45:47 +02:00

218 lines
8.5 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)
code := env.startLink(t, token) // the request first: the sign-in must not predate it
if status, body := env.confirmLink(t, freshUserToken(t, user), code); 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)
code := env.startLink(t, token) // the request first: the sign-in must not predate it
if status, body := env.confirmLink(t, freshUserToken(t, user), code); 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)
}
})
}