package user import ( "context" "encoding/json" "net/http" "slices" "strconv" "strings" "sync" "testing" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" ) const permManageBackendLinks = "golem15.users.manage_backend_links" // administratorPath is the admin API path of one administrator on the // Administrators screen, with an optional suffix. func administratorPath(id uint, suffix string) string { return adminAPI("/summercms/cabana/users/" + strconv.FormatUint(uint64(id), 10) + suffix) } // superAdmin is admin with the superuser flag set. func (e *adminEnv) superAdmin(t *testing.T) string { t.Helper() token := e.admin(t) if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = TRUE WHERE id = ?`, adminID(t, token)).Error; err != nil { t.Fatal(err) } return token } // administratorActions returns the record actions the Administrators screen // offers on an administrator. func (e *adminEnv) administratorActions(t *testing.T, token string, id uint) []string { t.Helper() rec := e.expect(t, http.StatusOK, http.MethodGet, administratorPath(id, ""), token, nil) var meta struct { Actions []struct { Name string `json:"name"` } `json:"actions"` } if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil { t.Fatal(err) } names := []string{} for _, action := range meta.Actions { if strings.HasSuffix(action.Name, "frontend_account") { names = append(names, action.Name) } } return names } // TestBackendLinkAdminSmoke: on the Administrators screen an administrator // with golem15.users.manage_backend_links links a frontend account to // another administrator by its exact email (any letter case) and unlinks // it; linked_by records the caller, the event fires, a taken or unknown // account is a 422 on the prompt, and an administrator without the // permission is neither offered the actions nor allowed to run them. func TestBackendLinkAdminSmoke(t *testing.T) { env := newAdminEnv(t) var mu sync.Mutex var linked []models.BackendLink env.app.Events.Listen("test.backend_linked", func(_ context.Context, e *classes.BackendLinkedEvent) error { mu.Lock() linked = append(linked, *e.Link) mu.Unlock() return nil }) stamp := adminStamp() editor := env.seedUser(t, "editor", func(u *models.User) { u.Email = "editor-" + stamp + "@example.test" }) second := env.seedUser(t, "second", nil) targetToken := env.admin(t) target := adminID(t, targetToken) super := env.superAdmin(t) superID := adminID(t, super) resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]() if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) { t.Fatalf("offered before the link = %v", got) } link := func(token, value string) *http.Response { t.Helper() rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), token, map[string]any{"payload": value}) return rec.Result() } // Unknown email: 422 on payload with the unknown_user text. rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), super, map[string]any{"payload": "nobody-" + stamp + "@example.test"}) if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "No active frontend user has this email.") { t.Fatalf("unknown email = %d %s", rec.Code, rec.Body.String()) } // The email matches whatever its letter case. if res := link(super, " Editor-"+stamp+"@Example.TEST "); res.StatusCode != http.StatusOK { t.Fatalf("set = %d", res.StatusCode) } var row models.BackendLink if err := env.gdb.Where("backend_user_id = ?", target).Take(&row).Error; err != nil || row.UserID != editor.ID || row.LinkedBy == nil || *row.LinkedBy != superID { t.Fatalf("link row = %+v %v", row, err) } if got, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || !found || got.ID != target { t.Fatalf("LinkedBackendUser = %+v %v %v", got, found, err) } mu.Lock() if len(linked) != 1 || linked[0].UserID != editor.ID || linked[0].BackendUserID != target { t.Fatalf("BackendLinkedEvent saw %+v", linked) } mu.Unlock() if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"unlink_frontend_account"}) { t.Fatalf("offered after the link = %v", got) } // The administrator is linked: a second set no longer applies (409), // and the same frontend user cannot be linked to another administrator. if res := link(super, second.Email); res.StatusCode != http.StatusConflict { t.Fatalf("second set on a linked administrator = %d", res.StatusCode) } otherToken := env.admin(t) other := adminID(t, otherToken) rec = env.call(t, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), super, map[string]any{"payload": editor.Email}) if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "This administrator or this frontend user is already linked.") { t.Fatalf("frontend user linked twice = %d %s", rec.Code, rec.Body.String()) } // Without the permission: not offered, and refused. limited := env.admin(t, cabana.PermissionManageUsers) if got := env.administratorActions(t, limited, target); len(got) != 0 { t.Fatalf("limited administrator is offered %v", got) } env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), limited, map[string]any{}) env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), limited, map[string]any{"payload": second.Email}) manager := env.admin(t, cabana.PermissionManageUsers, permManageBackendLinks) if got := env.administratorActions(t, manager, target); !slices.Equal(got, []string{"unlink_frontend_account"}) { t.Fatalf("manager is offered %v", got) } // Unlink. env.expect(t, http.StatusOK, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), manager, map[string]any{}) if _, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || found { t.Fatalf("after unlink found=%v err=%v", found, err) } if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) { t.Fatalf("offered after the unlink = %v", got) } }