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) } }