Files
sm-user-plugin/backend_link_test.go
Jakub Zych 72de822a84 test: backend link, frontend permission catalog and MCP guard matrices (phase 19.7 plan 05)
- TestBackendLink: the fresh sign-in rule at the second, 1:1 on the
  frontend side, the show route's three keys, SetLink and RemoveLink events
  (none after a rollback), deactivated or deleted administrators, and
  deactivating the frontend user
- TestFrontendPermissionCatalog: a second plugin's duplicate and an empty
  code dropped; a group saved with a declared code grants it
- TestUserMCPGuard: parser refusals, a soft-deleted user, an expired token,
  and the dispatch credential against the route's own user.scope:read
2026-10-23 14:09:36 +02:00

211 lines
8.0 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 iat equal to the request's created_at confirms,
// one second earlier is stale_session); 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 := craftToken(t, sub, "early-"+adminStamp(), created.Add(-time.Second), time.Now().Add(time.Hour))
if status, body := env.confirmLink(t, early, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
t.Fatalf("iat one second early = %d %s", status, body)
}
exact := craftToken(t, sub, "exact-"+adminStamp(), created, time.Now().Add(time.Hour))
if status, body := env.confirmLink(t, exact, code); status != http.StatusOK {
t.Fatalf("iat 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)
}
})
}