List every provider kind with direct settings and admin links, move FedWiki under the integrations route, and add in-shell operator 404s. Report sync health from Temporal schedule executions and clear one-shot settings feedback parameters after display.
188 lines
6.6 KiB
Go
188 lines
6.6 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/client"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/discoursetest"
|
|
dcmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/store"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/web"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// newOperatorFixture builds the operator handler over committed fixtures
|
|
// with per-test cleanup (the handler reads through the DB pool).
|
|
func newOperatorFixture(t *testing.T) (*http.ServeMux, *discoursetest.Fake, *testing.T) {
|
|
t.Helper()
|
|
database := testDB(t)
|
|
fake := discoursetest.NewFake()
|
|
t.Cleanup(fake.Close)
|
|
|
|
handler, err := web.NewDiscourseOperatorHandler(web.DiscourseOperatorHandlerConfig{
|
|
DB: database,
|
|
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})),
|
|
Client: client.New(client.Config{
|
|
BaseURL: fake.URL(), APIKey: "k", APIUsername: "system", RatePerMinute: 60_000,
|
|
}),
|
|
Configured: true,
|
|
TemplatesFS: os.DirFS("../templates"),
|
|
SweepScheduleID: "discourse-group-sync",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct operator handler: %v", err)
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /partials/operator/discourse/mappings", handler.CreateMapping)
|
|
mux.HandleFunc("DELETE /partials/operator/discourse/mappings/{mappingID}", handler.DeleteMapping)
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = database.ExecContext(context.Background(),
|
|
"DELETE FROM discourse.group_mappings")
|
|
})
|
|
return mux, fake, t
|
|
}
|
|
|
|
func postMapping(t *testing.T, mux *http.ServeMux, resourceKey, groupName string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
form := url.Values{"resource_key": {resourceKey}, "group_name": {groupName}}
|
|
req := httptest.NewRequest(http.MethodPost, "/partials/operator/discourse/mappings",
|
|
strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// Scenario: mapping a valid non-automatic group succeeds and becomes managed.
|
|
func TestOperatorCreateMappingValid(t *testing.T) {
|
|
mux, fake, _ := newOperatorFixture(t)
|
|
groupName := "valid-" + uuid.New().String()[:8]
|
|
gid := fake.AddGroup(groupName, false)
|
|
|
|
rec := postMapping(t, mux, "discourse_posting", groupName)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("create = %d, want 200; body: %s", rec.Code, rec.Body.String()[:min(200, rec.Body.Len())])
|
|
}
|
|
if !strings.Contains(rec.Header().Get("HX-Trigger"), "showSuccessToast") {
|
|
t.Error("expected success toast header")
|
|
}
|
|
database := testDB(t)
|
|
mapping, err := dcmod.New(database).GetGroupMappingByGroupName(context.Background(), groupName)
|
|
if err != nil {
|
|
t.Fatalf("mapping not stored: %v", err)
|
|
}
|
|
if mapping.DiscourseGroupID != gid || mapping.ResourceKey != "discourse_posting" {
|
|
t.Errorf("stored mapping %+v", mapping)
|
|
}
|
|
// The re-rendered body lists the mapping.
|
|
if !strings.Contains(rec.Body.String(), groupName) {
|
|
t.Error("response body does not show the new mapping")
|
|
}
|
|
}
|
|
|
|
// Scenario: mapping a nonexistent group is rejected with an error naming the
|
|
// problem and stores nothing.
|
|
func TestOperatorCreateMappingUnknownGroup(t *testing.T) {
|
|
mux, _, _ := newOperatorFixture(t)
|
|
groupName := "ghost-" + uuid.New().String()[:8]
|
|
|
|
rec := postMapping(t, mux, "discourse_posting", groupName)
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("create = %d, want 422", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "No group with that name") {
|
|
t.Error("422 body lacks the unknown-group message")
|
|
}
|
|
database := testDB(t)
|
|
if _, err := dcmod.New(database).GetGroupMappingByGroupName(context.Background(), groupName); err == nil {
|
|
t.Error("mapping was stored despite validation failure")
|
|
}
|
|
}
|
|
|
|
// Automatic groups are rejected (their membership is Discourse-computed).
|
|
func TestOperatorCreateMappingAutomaticGroup(t *testing.T) {
|
|
mux, fake, _ := newOperatorFixture(t)
|
|
groupName := "auto-" + uuid.New().String()[:8]
|
|
fake.AddGroup(groupName, true)
|
|
|
|
rec := postMapping(t, mux, "discourse_posting", groupName)
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("create = %d, want 422", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "automatic") {
|
|
t.Error("422 body lacks the automatic-group message")
|
|
}
|
|
}
|
|
|
|
// Duplicate group mapping translates the unique violation to a field error.
|
|
func TestOperatorCreateMappingDuplicate(t *testing.T) {
|
|
mux, fake, _ := newOperatorFixture(t)
|
|
groupName := "dup-" + uuid.New().String()[:8]
|
|
fake.AddGroup(groupName, false)
|
|
|
|
if rec := postMapping(t, mux, "discourse_posting", groupName); rec.Code != http.StatusOK {
|
|
t.Fatalf("first create = %d, want 200", rec.Code)
|
|
}
|
|
rec := postMapping(t, mux, "discourse_posting", groupName)
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("duplicate create = %d, want 422", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "already managed") {
|
|
t.Error("422 body lacks the duplicate-mapping message")
|
|
}
|
|
}
|
|
|
|
// Scenario: unmapping stops management and leaves forum membership as-is.
|
|
func TestOperatorDeleteMapping(t *testing.T) {
|
|
mux, fake, _ := newOperatorFixture(t)
|
|
groupName := "del-" + uuid.New().String()[:8]
|
|
gid := fake.AddGroup(groupName, false)
|
|
memberUID := fake.AddUser(discoursetest.FakeUser{Username: "stays", Email: "s@example.com", Active: true})
|
|
fake.AddMember(gid, memberUID)
|
|
|
|
if rec := postMapping(t, mux, "discourse_posting", groupName); rec.Code != http.StatusOK {
|
|
t.Fatalf("create = %d, want 200", rec.Code)
|
|
}
|
|
database := testDB(t)
|
|
mapping, err := dcmod.New(database).GetGroupMappingByGroupName(context.Background(), groupName)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/partials/operator/discourse/mappings/"+mapping.MappingID, nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("delete = %d, want 200", rec.Code)
|
|
}
|
|
if _, err := dcmod.New(database).GetGroupMappingByGroupName(context.Background(), groupName); err == nil {
|
|
t.Error("mapping still present after delete")
|
|
}
|
|
// Forum-side membership untouched by unmapping.
|
|
if len(fake.Members(gid)) != 1 {
|
|
t.Error("unmapping modified forum-side membership")
|
|
}
|
|
|
|
// Deleting a missing mapping is a 404.
|
|
req = httptest.NewRequest(http.MethodDelete, "/partials/operator/discourse/mappings/"+mapping.MappingID, nil)
|
|
rec = httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("re-delete = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|