NewFromEnv now falls back to DISCOURSE_KEY when DISCOURSE_API_KEY is unset (the explicit name still wins), matching the shorter DISCOURSE_URL/DISCOURSE_KEY convention used across the MOPAC fleet scripts. Behavioral test proves both the alias and the precedence against the fake server. Part of Redmine 507 (Discourse Go client).
576 lines
17 KiB
Go
576 lines
17 KiB
Go
package discourse
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
const testKey = "test-api-key-0123456789"
|
|
|
|
// fakeDiscourse is a minimal in-memory Discourse for client tests: it
|
|
// enforces the Api-Key/Api-Username headers, serves the endpoints the
|
|
// client covers, and records every request for assertions.
|
|
type fakeDiscourse struct {
|
|
mu sync.Mutex
|
|
requests []string // "METHOD path"
|
|
uris []string // "METHOD path?query" — for query assertions
|
|
cats map[int]Category
|
|
nextCat int
|
|
posts map[int]postRec // post id -> record
|
|
nextPost int
|
|
topics map[int]topicRec
|
|
nextT int
|
|
searchFail int
|
|
pms []pmRec // private messages, in creation order
|
|
lastBody string // raw JSON body of the last POST /posts.json
|
|
srv *httptest.Server
|
|
}
|
|
|
|
type postRec struct {
|
|
Post
|
|
EditReason string
|
|
}
|
|
|
|
type topicRec struct {
|
|
ID int
|
|
Title string
|
|
Slug string
|
|
Cat int
|
|
}
|
|
|
|
type pmRec struct {
|
|
TopicID int
|
|
Title string
|
|
Targets []string
|
|
Raw string
|
|
}
|
|
|
|
func newFake(t *testing.T) *fakeDiscourse {
|
|
t.Helper()
|
|
f := &fakeDiscourse{
|
|
cats: map[int]Category{
|
|
1: {ID: 1, Name: "General", Slug: "general", Color: "0088CC", TextColor: "FFFFFF"},
|
|
2: {ID: 2, Name: "MOPAC Briefings", Slug: "mopac-briefings", Color: "3AB54A", TextColor: "FFFFFF"},
|
|
},
|
|
nextCat: 3,
|
|
posts: map[int]postRec{},
|
|
nextPost: 100,
|
|
topics: map[int]topicRec{},
|
|
nextT: 10,
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/session/current.json", func(w http.ResponseWriter, r *http.Request) {
|
|
if !f.auth(w, r) {
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{
|
|
"current_user": map[string]any{"id": 5, "username": "reachableceo", "admin": false},
|
|
})
|
|
})
|
|
mux.HandleFunc("/categories.json", func(w http.ResponseWriter, r *http.Request) {
|
|
if !f.auth(w, r) {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
cats := make([]Category, 0, len(f.cats))
|
|
for _, c := range f.cats {
|
|
cats = append(cats, c)
|
|
}
|
|
writeJSON(w, 200, map[string]any{"category_list": map[string]any{"categories": cats}})
|
|
case http.MethodPost:
|
|
var req CreateCategoryRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}})
|
|
return
|
|
}
|
|
if req.Name == "denied" { // simulate a non-admin key
|
|
writeJSON(w, 403, map[string]any{
|
|
"errors": []string{"You are not permitted to view the requested resource."},
|
|
"error_description": "Access denied",
|
|
})
|
|
return
|
|
}
|
|
f.mu.Lock()
|
|
cat := Category{
|
|
ID: f.nextCat, Name: req.Name,
|
|
Slug: strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")),
|
|
Color: req.Color, TextColor: req.TextColor, Position: req.Position,
|
|
}
|
|
f.nextCat++
|
|
f.cats[cat.ID] = cat
|
|
f.mu.Unlock()
|
|
writeJSON(w, 200, cat)
|
|
default:
|
|
writeJSON(w, 405, nil)
|
|
}
|
|
})
|
|
mux.HandleFunc("/posts.json", func(w http.ResponseWriter, r *http.Request) {
|
|
if !f.auth(w, r) {
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, 405, nil)
|
|
return
|
|
}
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeJSON(w, 400, map[string]any{"errors": []string{"bad body"}})
|
|
return
|
|
}
|
|
var req struct {
|
|
CreatePostRequest
|
|
TargetUsernames string `json:"target_usernames"`
|
|
TargetGroupNames string `json:"target_group_names"`
|
|
TargetEmails string `json:"target_emails"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}})
|
|
return
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.lastBody = string(body)
|
|
if req.TargetUsernames != "" || req.TargetGroupNames != "" || req.TargetEmails != "" {
|
|
// private-message creation
|
|
targets := splitCSV(req.TargetUsernames)
|
|
for _, name := range append(append(targets, splitCSV(req.TargetGroupNames)...), splitCSV(req.TargetEmails)...) {
|
|
if strings.HasPrefix(name, "denied") {
|
|
writeJSON(w, 403, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}})
|
|
return
|
|
}
|
|
}
|
|
if strings.TrimSpace(req.Title) == "" {
|
|
writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}})
|
|
return
|
|
}
|
|
tid := f.nextT
|
|
f.nextT++
|
|
slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-"))
|
|
f.pms = append(f.pms, pmRec{TopicID: tid, Title: req.Title, Targets: targets, Raw: req.Raw})
|
|
pid := f.nextPost
|
|
f.nextPost++
|
|
f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: tid, PostNumber: 1, Raw: req.Raw}}
|
|
writeJSON(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug})
|
|
return
|
|
}
|
|
if req.TopicID == 0 { // topic creation
|
|
if strings.TrimSpace(req.Title) == "" || req.Category == 0 {
|
|
writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}})
|
|
return
|
|
}
|
|
tid := f.nextT
|
|
f.nextT++
|
|
slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-"))
|
|
f.topics[tid] = topicRec{ID: tid, Title: req.Title, Slug: slug, Cat: req.Category}
|
|
pid := f.nextPost
|
|
f.nextPost++
|
|
f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: tid, PostNumber: 1, Raw: req.Raw, CreatedAt: "2026-08-29T06:30:00.000Z"}}
|
|
writeJSON(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug})
|
|
return
|
|
}
|
|
pid := f.nextPost
|
|
f.nextPost++
|
|
f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: req.TopicID, PostNumber: 2, Raw: req.Raw}}
|
|
writeJSON(w, 200, map[string]any{"id": pid, "topic_id": req.TopicID, "topic_slug": "existing"})
|
|
})
|
|
mux.HandleFunc("/posts/", func(w http.ResponseWriter, r *http.Request) {
|
|
if !f.auth(w, r) {
|
|
return
|
|
}
|
|
var id int
|
|
if _, err := fmt.Sscanf(strings.Split(r.URL.Path, "/")[2], "%d", &id); err != nil {
|
|
writeJSON(w, 404, nil)
|
|
return
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
p, ok := f.posts[id]
|
|
if !ok {
|
|
writeJSON(w, 404, map[string]any{"errors": []string{"The requested URL or resource could not be found."}})
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"post": p})
|
|
case http.MethodPut:
|
|
var body struct {
|
|
Post struct {
|
|
Raw string `json:"raw"`
|
|
EditReason string `json:"edit_reason"`
|
|
} `json:"post"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, 400, nil)
|
|
return
|
|
}
|
|
p, ok := f.posts[id]
|
|
if !ok {
|
|
writeJSON(w, 404, nil)
|
|
return
|
|
}
|
|
p.Raw = body.Post.Raw
|
|
p.EditReason = body.Post.EditReason
|
|
f.posts[id] = p
|
|
writeJSON(w, 200, map[string]any{"post": p})
|
|
default:
|
|
writeJSON(w, 405, nil)
|
|
}
|
|
})
|
|
mux.HandleFunc("/search.json", func(w http.ResponseWriter, r *http.Request) {
|
|
if !f.auth(w, r) {
|
|
return
|
|
}
|
|
if f.searchFail != 0 {
|
|
writeJSON(w, f.searchFail, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}})
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{
|
|
"posts": []map[string]any{{
|
|
"id": 55, "topic_id": 12, "username": "reachableceo", "post_number": 1,
|
|
"blurb": "the fleet briefing for September",
|
|
"created_at": "2026-08-29T10:00:00.000Z",
|
|
"topic_title_headline": "Fleet briefing 2026-09-01",
|
|
}},
|
|
"topics": []map[string]any{{"id": 12, "title": "Fleet briefing 2026-09-01", "slug": "fleet-briefing-2026-09-01"}},
|
|
"users": []map[string]any{{"id": 5, "username": "reachableceo", "name": "Charles"}},
|
|
"categories": []map[string]any{{"id": 2, "name": "MOPAC Briefings", "slug": "mopac-briefings"}},
|
|
})
|
|
})
|
|
mux.HandleFunc("/latest.json", func(w http.ResponseWriter, r *http.Request) {
|
|
if !f.auth(w, r) {
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"topic_list": map[string]any{"topics": []map[string]any{
|
|
{"id": 7, "title": "Latest one", "slug": "latest-one", "category_id": 1, "posts_count": 2},
|
|
}}})
|
|
})
|
|
mux.HandleFunc("/c/", func(w http.ResponseWriter, r *http.Request) {
|
|
if !f.auth(w, r) {
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"topic_list": map[string]any{"topics": []map[string]any{
|
|
{"id": 9, "title": "In category", "slug": "in-category", "category_id": 2, "posts_count": 1},
|
|
}}})
|
|
})
|
|
f.srv = httptest.NewServer(mux)
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeDiscourse) auth(w http.ResponseWriter, r *http.Request) bool {
|
|
f.mu.Lock()
|
|
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
|
f.uris = append(f.uris, r.Method+" "+r.URL.RequestURI())
|
|
f.mu.Unlock()
|
|
if r.Header.Get("Api-Key") != testKey || r.Header.Get("Api-Username") != "system" {
|
|
writeJSON(w, 403, map[string]any{"errors": []string{"Bad or missing API key"}})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (f *fakeDiscourse) saw(substr string) bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, r := range f.requests {
|
|
if strings.Contains(r, substr) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// sawQuery asserts a GET hit path with a query substring ("term=x",
|
|
// "page=2").
|
|
func (f *fakeDiscourse) sawQuery(path, param string) bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, u := range f.uris {
|
|
if strings.HasPrefix(u, "GET "+path+"?") && strings.Contains(u, param) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
// splitCSV splits a comma-joined target list ("ops,reachableceo").
|
|
func splitCSV(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
out := parts[:0]
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func testClient(t *testing.T, f *fakeDiscourse) *Client {
|
|
t.Helper()
|
|
c, err := New(f.srv.URL, testKey, "")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func TestNewRejectsBadInput(t *testing.T) {
|
|
if _, err := New("https://ok.example", "", ""); !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("empty key: want ErrInvalidRequest, got %v", err)
|
|
}
|
|
if _, err := New("not-a-url", testKey, ""); !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("bad url: want ErrInvalidRequest, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewFromEnvKeyAlias(t *testing.T) {
|
|
f := newFake(t)
|
|
// alias alone works: DISCOURSE_KEY is accepted for the API key
|
|
t.Setenv("DISCOURSE_URL", f.srv.URL)
|
|
t.Setenv("DISCOURSE_API_USERNAME", "system")
|
|
t.Setenv("DISCOURSE_API_KEY", "")
|
|
t.Setenv("DISCOURSE_KEY", testKey)
|
|
c, err := NewFromEnv()
|
|
if err != nil {
|
|
t.Fatalf("NewFromEnv with DISCOURSE_KEY: %v", err)
|
|
}
|
|
if _, err := c.CurrentUser(context.Background()); err != nil {
|
|
t.Fatalf("alias key must authenticate against the fake: %v", err)
|
|
}
|
|
// explicit DISCOURSE_API_KEY wins over the alias (fake 403s any other key)
|
|
t.Setenv("DISCOURSE_API_KEY", "not-the-key")
|
|
t.Setenv("DISCOURSE_KEY", testKey)
|
|
c, err = NewFromEnv()
|
|
if err != nil {
|
|
t.Fatalf("NewFromEnv: %v", err)
|
|
}
|
|
_, err = c.CurrentUser(context.Background())
|
|
if !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("primary key must take precedence over the alias, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCurrentUser(t *testing.T) {
|
|
f := newFake(t)
|
|
c := testClient(t, f)
|
|
u, err := c.CurrentUser(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("CurrentUser: %v", err)
|
|
}
|
|
if u.Username != "reachableceo" || u.Admin {
|
|
t.Fatalf("unexpected user %+v", u)
|
|
}
|
|
if !f.saw("GET /session/current.json") {
|
|
t.Fatal("wrong path hit")
|
|
}
|
|
}
|
|
|
|
func TestListCategoriesParsesAndAuths(t *testing.T) {
|
|
f := newFake(t)
|
|
c := testClient(t, f)
|
|
cats, err := c.ListCategories(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListCategories: %v", err)
|
|
}
|
|
if len(cats) != 2 || cats[0].Name == "" || cats[1].Slug != "mopac-briefings" && cats[0].Slug != "mopac-briefings" {
|
|
t.Fatalf("unexpected categories: %+v", cats)
|
|
}
|
|
found, err := c.FindCategory(context.Background(), 0, "MOPAC-Briefings")
|
|
if err != nil || found.ID != 2 {
|
|
t.Fatalf("FindCategory by slug: %v %+v", err, found)
|
|
}
|
|
found, err = c.FindCategory(context.Background(), 1, "")
|
|
if err != nil || found.ID != 1 {
|
|
t.Fatalf("FindCategory by id: %v %+v", err, found)
|
|
}
|
|
if _, err := c.FindCategory(context.Background(), 99, ""); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("missing category: want ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateCategory(t *testing.T) {
|
|
f := newFake(t)
|
|
c := testClient(t, f)
|
|
cat, err := c.CreateCategory(context.Background(), CreateCategoryRequest{
|
|
Name: "Ops Reports",
|
|
Color: "#25AAE2",
|
|
Permissions: map[string]int{"staff": 3},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateCategory: %v", err)
|
|
}
|
|
if cat.ID != 3 || cat.Slug != "ops-reports" {
|
|
t.Fatalf("unexpected created category: %+v", cat)
|
|
}
|
|
if cat.Color != "25AAE2" { // normalized: # stripped, uppercased
|
|
t.Fatalf("color not normalized: %q", cat.Color)
|
|
}
|
|
}
|
|
|
|
func TestCreateCategoryForbiddenIsTypedAndRedacted(t *testing.T) {
|
|
f := newFake(t)
|
|
c := testClient(t, f)
|
|
_, err := c.CreateCategory(context.Background(), CreateCategoryRequest{Name: "denied"})
|
|
if !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("want ErrForbidden, got %v", err)
|
|
}
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) || apiErr.Status != 403 {
|
|
t.Fatalf("want APIError 403, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), testKey) {
|
|
t.Fatalf("error leaks the api key: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "not permitted") {
|
|
t.Fatalf("error misses discourse reasons: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateTopicAndURL(t *testing.T) {
|
|
f := newFake(t)
|
|
c := testClient(t, f)
|
|
res, err := c.CreateTopic(context.Background(), CreateTopicRequest{
|
|
Title: "MOPAC briefing 2026-08-29", Raw: "body", Category: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTopic: %v", err)
|
|
}
|
|
if res.TopicID == 0 || res.PostID == 0 {
|
|
t.Fatalf("bad result: %+v", res)
|
|
}
|
|
want := f.srv.URL + "/t/mopac-briefing-2026-08-29/" + fmt.Sprint(res.TopicID)
|
|
if got := res.URL(f.srv.URL); got != want {
|
|
t.Fatalf("URL: want %s got %s", want, got)
|
|
}
|
|
if !f.saw("POST /posts.json") {
|
|
t.Fatal("topic create must go through POST /posts.json")
|
|
}
|
|
if _, err := c.CreateTopic(context.Background(), CreateTopicRequest{Title: "x", Raw: "y"}); !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("missing category: want ErrInvalidRequest, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestListTopicsByCategoryAndLatest(t *testing.T) {
|
|
f := newFake(t)
|
|
c := testClient(t, f)
|
|
topics, err := c.ListTopics(context.Background(), 2, "mopac-briefings")
|
|
if err != nil {
|
|
t.Fatalf("ListTopics id+slug: %v", err)
|
|
}
|
|
if len(topics) != 1 || topics[0].ID != 9 {
|
|
t.Fatalf("unexpected topics: %+v", topics)
|
|
}
|
|
topics, err = c.ListTopics(context.Background(), 2, "")
|
|
if err != nil || len(topics) != 1 {
|
|
t.Fatalf("ListTopics id only: %v %+v", err, topics)
|
|
}
|
|
topics, err = c.ListTopics(context.Background(), 0, "mopac-briefings")
|
|
if err != nil || len(topics) != 1 {
|
|
t.Fatalf("ListTopics slug only: %v %+v", err, topics)
|
|
}
|
|
if _, err := c.ListTopics(context.Background(), 0, ""); !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("no ref: want ErrInvalidRequest, got %v", err)
|
|
}
|
|
if _, err := c.LatestTopics(context.Background()); err != nil {
|
|
t.Fatalf("LatestTopics: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateAndUpdatePost(t *testing.T) {
|
|
f := newFake(t)
|
|
c := testClient(t, f)
|
|
res, err := c.CreatePost(context.Background(), CreatePostRequest{TopicID: 5, Raw: "reply body"})
|
|
if err != nil {
|
|
t.Fatalf("CreatePost: %v", err)
|
|
}
|
|
if res.TopicID != 5 {
|
|
t.Fatalf("bad result: %+v", res)
|
|
}
|
|
post, err := c.UpdatePost(context.Background(), res.PostID, "edited body", "typo")
|
|
if err != nil {
|
|
t.Fatalf("UpdatePost: %v", err)
|
|
}
|
|
if post.ID != res.PostID {
|
|
t.Fatalf("unexpected post: %+v", post)
|
|
}
|
|
got, err := c.GetPost(context.Background(), res.PostID)
|
|
if err != nil {
|
|
t.Fatalf("GetPost: %v", err)
|
|
}
|
|
if got.Raw != "edited body" {
|
|
t.Fatalf("update did not stick: %+v", got)
|
|
}
|
|
if _, err := c.CreatePost(context.Background(), CreatePostRequest{TopicID: 0, Raw: "x"}); !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("no topic: want ErrInvalidRequest, got %v", err)
|
|
}
|
|
if _, err := c.UpdatePost(context.Background(), 99999, "x", ""); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("missing post: want ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRawPassthrough(t *testing.T) {
|
|
f := newFake(t)
|
|
c := testClient(t, f)
|
|
var out map[string]any
|
|
if err := c.Get(context.Background(), "/latest.json", &out); err != nil {
|
|
t.Fatalf("raw get: %v", err)
|
|
}
|
|
if err := c.Do(context.Background(), http.MethodPost, "/echo.json", map[string]string{"a": "b"}, nil); err == nil || !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("raw post to unknown path: want ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUnreachableIsTypedAndRedacted(t *testing.T) {
|
|
c, err := New("http://127.0.0.1:1", testKey, "system")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
_, err = c.CurrentUser(context.Background())
|
|
if !errors.Is(err, ErrUnreachable) {
|
|
t.Fatalf("want ErrUnreachable, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), testKey) {
|
|
t.Fatalf("error leaks the api key: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBadKeyIsUnauthorized(t *testing.T) {
|
|
f := newFake(t)
|
|
c, err := New(f.srv.URL, "wrong-key", "system")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
_, err = c.CurrentUser(context.Background())
|
|
if !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("want ErrForbidden (auth header rejected), got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClientStringNeverLeaksKey(t *testing.T) {
|
|
c, err := New("https://forum.example.com", testKey, "system")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s := c.String(); strings.Contains(s, testKey) {
|
|
t.Fatalf("String leaks key: %s", s)
|
|
}
|
|
}
|