- 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)
273 lines
9.7 KiB
Go
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), §ions)
|
|
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)
|
|
}
|
|
}
|