Files
sm-user-plugin/backend_link_smoke_test.go
Jakub Zych 7ac80c9aea feat: verified frontend account link for administrators
- golem15_user_backend_links (1:1, both sides unique) and one-shot hashed
  golem15_user_backend_link_requests (golem15.user.backend_links.request_ttl, 10m)
- classes.BackendLinks: CreateLinkRequest, ConfirmLinkRequest (row lock,
  JWT iat not before the request), SetLink, RemoveLink, LinkedBackendUser;
  published as *classes.BackendLinks and pact.MCPLinkedBackendResolver
- GET /_user/api/v1/backend-link/{code} and POST /backend-link/confirm (jwt.auth)
- My account section frontend_account with link and unlink
- BackendLinkedEvent, BackendUnlinkedEvent and pact.MCPSubjectChanged after commit;
  deleting or deactivating a frontend user removes its link
- golem15.user.frontend_url (falls back to app.url)
2026-10-23 12:03:26 +02:00

273 lines
9.7 KiB
Go

package user
import (
"context"
"encoding/json"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
)
// subjectSpy records the pact.MCPSubjectChanged events fired on an app.
type subjectSpy struct {
mu sync.Mutex
events []pact.MCPSubjectChanged
}
func (s *subjectSpy) has(guard string, id uint, kind string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Contains(s.events, pact.MCPSubjectChanged{Guard: guard, UserID: id, Kind: kind})
}
func (s *subjectSpy) reset() {
s.mu.Lock()
s.events = nil
s.mu.Unlock()
}
// spySubjects listens for the subject events the plugin fires.
func (e *adminEnv) spySubjects() *subjectSpy {
spy := &subjectSpy{}
e.app.Events.Listen("test.subjects", func(_ context.Context, ev *pact.MCPSubjectChanged) error {
spy.mu.Lock()
spy.events = append(spy.events, *ev)
spy.mu.Unlock()
return nil
})
return spy
}
// adminID is the backend user id inside an admin session token.
func adminID(t *testing.T, token string) uint {
t.Helper()
sub, _, _, _, err := bouncer.VerifyClaimsAudience(token, adminHarnessSecret, bouncer.AudienceBackend)
if err != nil {
t.Fatal(err)
}
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil {
t.Fatal(err)
}
return uint(id)
}
// adminLogin reads an administrator's login.
func (e *adminEnv) adminLogin(t *testing.T, id uint) string {
t.Helper()
var login string
if err := e.gdb.Raw(`SELECT login FROM backend_users WHERE id = ?`, id).Scan(&login).Error; err != nil {
t.Fatal(err)
}
return login
}
// freshUserToken mints a frontend session token issued now.
func freshUserToken(t *testing.T, user *models.User) string {
t.Helper()
token, _, err := bouncer.Mint(sessionSecret, strconv.FormatUint(uint64(user.ID), 10), "https://app.example.test", time.Hour)
if err != nil {
t.Fatal(err)
}
return token
}
// accountSection is one My account contribution as the admin API lists it.
type accountSection struct {
Name string `json:"name"`
Status string `json:"status"`
Actions []struct {
Name string `json:"name"`
} `json:"actions"`
}
func (s accountSection) actions() []string {
out := []string{}
for _, a := range s.Actions {
out = append(out, a.Name)
}
return out
}
// frontendSection lists My account and returns the frontend_account section.
func (e *adminEnv) frontendSection(t *testing.T, token string) accountSection {
t.Helper()
var sections []accountSection
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI("/account/contributions"), token, nil), &sections)
for _, s := range sections {
if s.Name == "frontend_account" {
return s
}
}
t.Fatalf("My account has no frontend_account section: %+v", sections)
return accountSection{}
}
var linkURL = regexp.MustCompile(`^https://app\.example\.test/link-admin\?code=([a-f0-9]{64})$`)
// startLink runs My account's link action and returns the code in its URL.
func (e *adminEnv) startLink(t *testing.T, token string) string {
t.Helper()
var out struct {
Message string `json:"message"`
URL string `json:"url"`
}
adminInto(t, e.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), token, map[string]any{}), &out)
m := linkURL.FindStringSubmatch(out.URL)
if m == nil || out.Message == "" {
t.Fatalf("link action answered %+v", out)
}
return m[1]
}
// confirmLink posts the code as the frontend session token.
func (e *adminEnv) confirmLink(t *testing.T, userToken, code string) (int, string) {
t.Helper()
rec := e.call(t, http.MethodPost, "/_user/api/v1/backend-link/confirm", userToken, map[string]string{"code": code})
return rec.Code, rec.Body.String()
}
// TestBackendLinkSmoke: an administrator starts a link on My account, the
// site shows who asked, a session older than the request is refused, a
// fresh one links the accounts (the published resolver answers), the code is
// one-shot, the 1:1 rule holds, unlink and deleting the frontend user remove
// the link, and the subject events fire.
func TestBackendLinkSmoke(t *testing.T) {
env := newAdminEnv(t)
spy := env.spySubjects()
frontend := env.seedUser(t, "linker", nil)
other := env.seedUser(t, "linker-other", nil)
token := env.admin(t)
id := adminID(t, token)
login := env.adminLogin(t, id)
links, ok := env.app.Lookup[*classes.BackendLinks]()
if !ok || links == nil {
t.Fatal("*classes.BackendLinks is not published")
}
resolver, ok := env.app.Lookup[pact.MCPLinkedBackendResolver]()
if !ok || resolver == nil {
t.Fatal("pact.MCPLinkedBackendResolver is not published")
}
ctx := context.Background()
section := env.frontendSection(t, token)
if section.Status != "Not linked" || !slices.Equal(section.actions(), []string{"link"}) {
t.Fatalf("unlinked section = %+v", section)
}
code := env.startLink(t, token)
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
var shown struct {
Data struct {
Admin map[string]any `json:"admin"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil {
t.Fatal(err)
}
if shown.Data.Admin["login"] != login || shown.Data.Admin["name"] != login || len(shown.Data.Admin) != 2 {
t.Fatalf("show = %s", rec.Body.String())
}
if expires, err := time.Parse(time.RFC3339, shown.Data.ExpiresAt); err != nil || time.Until(expires) < 9*time.Minute || time.Until(expires) > 11*time.Minute {
t.Fatalf("expires_at = %q (%v)", shown.Data.ExpiresAt, err)
}
env.expect(t, http.StatusNotFound, http.MethodGet, "/_user/api/v1/backend-link/"+strings.Repeat("0", 64), "", nil)
// A session signed in before the request is refused: sign in again.
stale := craftToken(t, strconv.FormatUint(uint64(frontend.ID), 10), "stale-"+adminStamp(), time.Now().Add(-time.Hour), time.Now().Add(time.Hour))
if status, body := env.confirmLink(t, stale, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
t.Fatalf("stale confirm = %d %s", status, body)
}
if status, _ := env.confirmLink(t, "", code); status != http.StatusUnauthorized {
t.Fatalf("anonymous confirm = %d", status)
}
spy.reset()
status, body := env.confirmLink(t, freshUserToken(t, frontend), code)
if status != http.StatusOK || !strings.Contains(body, `"login":"`+login+`"`) {
t.Fatalf("fresh confirm = %d %s", status, body)
}
linked, found, err := resolver.LinkedBackendUser(ctx, frontend.ID)
if err != nil || !found || linked.ID != id || linked.Login != login || linked.DisplayName != login {
t.Fatalf("LinkedBackendUser = %+v %v %v", linked, found, err)
}
if !spy.has("user", frontend.ID, pact.MCPSubjectLink) || !spy.has("backend", id, pact.MCPSubjectLink) {
t.Fatalf("confirm fired %+v", spy.events)
}
var row models.BackendLink
if err := env.gdb.Where("user_id = ?", frontend.ID).Take(&row).Error; err != nil || row.BackendUserID != id || row.LinkedBy != nil {
t.Fatalf("link row = %+v %v", row, err)
}
section = env.frontendSection(t, token)
if !strings.Contains(section.Status, frontend.Email) || !slices.Equal(section.actions(), []string{"unlink"}) {
t.Fatalf("linked section = %+v", section)
}
// The code is one-shot.
if status, _ := env.confirmLink(t, freshUserToken(t, frontend), code); status != http.StatusNotFound {
t.Fatalf("replayed confirm = %d", status)
}
env.expect(t, http.StatusNotFound, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
// One link per administrator: another frontend user cannot take it.
second, _, err := links.CreateLinkRequest(ctx, id)
if err != nil {
t.Fatal(err)
}
if status, body := env.confirmLink(t, freshUserToken(t, other), second); status != http.StatusConflict || !strings.Contains(body, `"already_linked"`) {
t.Fatalf("second link = %d %s", status, body)
}
// Unlink on My account.
spy.reset()
env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/unlink"), token, map[string]any{})
if _, found, err := resolver.LinkedBackendUser(ctx, frontend.ID); err != nil || found {
t.Fatalf("after unlink LinkedBackendUser found=%v err=%v", found, err)
}
if !spy.has("user", frontend.ID, pact.MCPSubjectLink) || !spy.has("backend", id, pact.MCPSubjectLink) {
t.Fatalf("unlink fired %+v", spy.events)
}
if section := env.frontendSection(t, token); !slices.Equal(section.actions(), []string{"link"}) {
t.Fatalf("section after unlink = %+v", section)
}
// An expired request answers 404 like an unknown one.
expired, _, err := links.CreateLinkRequest(ctx, id)
if err != nil {
t.Fatal(err)
}
if err := env.gdb.Exec(`UPDATE golem15_user_backend_link_requests SET expires_at = NOW() - INTERVAL '1 minute' WHERE backend_user_id = ? AND used_at IS NULL`, id).Error; err != nil {
t.Fatal(err)
}
if status, _ := env.confirmLink(t, freshUserToken(t, frontend), expired); status != http.StatusNotFound {
t.Fatalf("expired confirm = %d", status)
}
// Deleting the frontend user removes the link and ends its subject.
code = env.startLink(t, token)
if status, body := env.confirmLink(t, freshUserToken(t, frontend), code); status != http.StatusOK {
t.Fatalf("relink = %d %s", status, body)
}
spy.reset()
manager := env.admin(t, permAccessUsers)
env.expect(t, http.StatusOK, http.MethodDelete, userPath(frontend.ID, ""), manager, nil)
var left int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_backend_links WHERE backend_user_id = ?`, id).Scan(&left).Error; err != nil || left != 0 {
t.Fatalf("links left after delete = %d %v", left, err)
}
if !spy.has("user", frontend.ID, pact.MCPSubjectDeleted) || !spy.has("backend", id, pact.MCPSubjectLink) {
t.Fatalf("delete fired %+v", spy.events)
}
}