package cabana import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" ) // contributor is a plugin that contributes one My account section (phase // 19.7 D-04 part 1, planner decision P-02). type contributor struct { id string sections []pact.AccountContribution } func (p contributor) ID() string { return p.id } func (contributor) Requires() []string { return nil } func (contributor) Register(*backpack.App) error { return nil } func (contributor) Boot(*backpack.App) error { return nil } func (p contributor) AccountContributions() []pact.AccountContribution { return p.sections } // TestAccountContributionsSmoke: a contributed section is listed with its // status and the actions that apply, an action runs for the signed-in // administrator and answers its URL, personal tokens are refused, and a // duplicate section name across plugins fails boot. func TestAccountContributionsSmoke(t *testing.T) { var ran []uint section := pact.AccountContribution{ Name: "site_link", Label: "acme::lang.site_link", Description: "acme::lang.site_link_hint", Status: func(_ context.Context, adminID uint) (string, error) { return "linked as admin", nil }, Actions: []pact.AccountAction{ { Name: "open", Label: "acme::lang.open", Confirm: "acme::lang.open_confirm", Danger: true, Run: func(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) { ran = append(ran, in.AdminID) return pact.AccountActionResult{Message: "acme::lang.opened", URL: "https://blog.example.com/link"}, nil }, }, { Name: "hidden", Label: "acme::lang.hidden", Applies: func(context.Context, uint) (bool, error) { return false, nil }, Run: func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) { return pact.AccountActionResult{}, errors.New("must not run") }, }, { Name: "locked", Label: "acme::lang.locked", Permissions: []string{"acme.links.manage"}, Run: func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) { return pact.AccountActionResult{Message: "acme::lang.locked_done"}, nil }, }, }, } reg := &Registry{byID: map[string]*CompiledController{}} if err := compileAccountContributions(reg, []party.Plugin{contributor{id: "acme.links", sections: []pact.AccountContribution{section}}}); err != nil { t.Fatal(err) } svc := &service{app: backpack.New(nil), reg: reg} admin := &bouncer.Principal{ID: 42, Backend: true} request := func(method, path string, principal *bouncer.Principal) *http.Request { req := httptest.NewRequest(method, path, strings.NewReader("{}")) req.Header.Set("X-Requested-With", "XMLHttpRequest") req.SetPathValue("name", "site_link") if strings.Contains(path, "/actions/") { req.SetPathValue("action", path[strings.LastIndex(path, "/")+1:]) } if principal != nil { req = req.WithContext(bouncer.WithUser(req.Context(), principal)) } return req } list := func(principal *bouncer.Principal) *httptest.ResponseRecorder { rec := httptest.NewRecorder() interactiveOnly(svc.accountContributionsList)(rec, request(http.MethodGet, "/account/contributions", principal)) return rec } run := func(action string, principal *bouncer.Principal) *httptest.ResponseRecorder { rec := httptest.NewRecorder() interactiveOnly(requireAjax(svc.accountContributionAction))(rec, request(http.MethodPost, "/account/contributions/site_link/actions/"+action, principal)) return rec } // The list: one section, its status, the open action; hidden (Applies // false) and locked (missing permission) are left out. rec := list(admin) if rec.Code != http.StatusOK { t.Fatalf("list = %d %s", rec.Code, rec.Body.String()) } var listed struct { Data []AccountContributionView `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil { t.Fatal(err) } if len(listed.Data) != 1 || listed.Data[0].Name != "site_link" || listed.Data[0].Label != "acme::lang.site_link" || listed.Data[0].Status != "linked as admin" { t.Fatalf("listed = %+v", listed.Data) } if actions := listed.Data[0].Actions; len(actions) != 1 || actions[0].Name != "open" || !actions[0].Danger || actions[0].Confirm != "acme::lang.open_confirm" { t.Fatalf("actions = %+v", listed.Data[0].Actions) } // A superuser holds every permission: locked appears for them. super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true} if err := json.Unmarshal(list(super).Body.Bytes(), &listed); err != nil || len(listed.Data[0].Actions) != 2 { t.Fatalf("superuser actions = %+v err=%v", listed.Data, err) } // Running the action answers its message and URL for the signed-in admin. rec = run("open", admin) if rec.Code != http.StatusOK { t.Fatalf("run = %d %s", rec.Code, rec.Body.String()) } var result struct { Data AccountActionResultView `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { t.Fatal(err) } if result.Data.URL != "https://blog.example.com/link" || result.Data.Message != "acme::lang.opened" || len(ran) != 1 || ran[0] != 42 { t.Fatalf("result = %+v ran=%v", result.Data, ran) } if rec := run("hidden", admin); rec.Code != http.StatusConflict { t.Fatalf("action that does not apply = %d %s", rec.Code, rec.Body.String()) } if rec := run("locked", admin); rec.Code != http.StatusForbidden { t.Fatalf("action without its permission = %d %s", rec.Code, rec.Body.String()) } if rec := run("missing", admin); rec.Code != http.StatusNotFound { t.Fatalf("unknown action = %d %s", rec.Code, rec.Body.String()) } if rec := run("open", nil); rec.Code != http.StatusUnauthorized { t.Fatalf("no principal = %d %s", rec.Code, rec.Body.String()) } if rec := run("open", &bouncer.Principal{ID: 7}); rec.Code != http.StatusUnauthorized { t.Fatalf("frontend principal = %d %s", rec.Code, rec.Body.String()) } // Personal API tokens are refused: a token credential and a raw g15_ bearer. tokenReq := request(http.MethodGet, "/account/contributions", admin) tokenReq = tokenReq.WithContext(bouncer.WithCredential(tokenReq.Context(), &BackendAPIToken{ID: 3})) rec = httptest.NewRecorder() interactiveOnly(svc.accountContributionsList)(rec, tokenReq) if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "interactive_only") { t.Fatalf("token credential = %d %s", rec.Code, rec.Body.String()) } bearerReq := request(http.MethodPost, "/account/contributions/site_link/actions/open", admin) bearerReq.Header.Set("Authorization", "Bearer "+APITokenPrefix+strings.Repeat("0", 40)) rec = httptest.NewRecorder() interactiveOnly(requireAjax(svc.accountContributionAction))(rec, bearerReq) if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "interactive_only") || len(ran) != 1 { t.Fatalf("raw bearer = %d %s ran=%v", rec.Code, rec.Body.String(), ran) } // The POST needs the AJAX header (CSRF). plain := request(http.MethodPost, "/account/contributions/site_link/actions/open", admin) plain.Header.Del("X-Requested-With") rec = httptest.NewRecorder() interactiveOnly(requireAjax(svc.accountContributionAction))(rec, plain) if rec.Code != http.StatusForbidden || len(ran) != 1 { t.Fatalf("POST without the AJAX header = %d ran=%v", rec.Code, ran) } // Boot refuses a section name another plugin already contributed, and // the other shapes the contract names. dup := []party.Plugin{ contributor{id: "acme.links", sections: []pact.AccountContribution{section}}, contributor{id: "acme.other", sections: []pact.AccountContribution{{Name: "site_link", Label: "x", Actions: nil}}}, } err := compileAccountContributions(&Registry{}, dup) if err == nil || !strings.Contains(err.Error(), "acme.other") || !strings.Contains(err.Error(), "acme.links") { t.Fatalf("duplicate section: %v", err) } for name, bad := range map[string]pact.AccountContribution{ "non-identifier name": {Name: "site-link", Label: "x"}, "empty label": {Name: "site_link", Label: " "}, "bad action name": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "no!", Label: "x", Run: section.Actions[0].Run}}}, "nil run": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "open", Label: "x"}}}, "empty action label": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "open", Run: section.Actions[0].Run}}}, "duplicate action": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "open", Label: "x", Run: section.Actions[0].Run}, {Name: "open", Label: "y", Run: section.Actions[0].Run}}}, } { err := compileAccountContributions(&Registry{}, []party.Plugin{contributor{id: "acme.bad", sections: []pact.AccountContribution{bad}}}) if err == nil || !strings.HasPrefix(err.Error(), "cabana: plugin acme.bad contributes account section") { t.Fatalf("%s: err = %v", name, err) } } // No contributor: an empty list, never null, and nothing else changes. empty := &service{app: backpack.New(nil), reg: &Registry{}} rec = httptest.NewRecorder() empty.accountContributionsList(rec, request(http.MethodGet, "/account/contributions", admin)) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"data":[]`) { t.Fatalf("empty list = %d %s", rec.Code, rec.Body.String()) } }