UpdateVersion issues PUT /versions/N.json sending only the fields the
caller set (untouched attributes never travel), and never parses the
empty 204 body Redmine answers with. GetVersion backs re-fetching one
milestone. The fake serves both endpoints with Redmine's semantics
(partial apply, 204 empty, 404 for unknown ids), and table tests pin
the exact request bodies, partiality, and error mapping.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
535 lines
19 KiB
Go
535 lines
19 KiB
Go
package redmine_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.knownelement.com/ukrrs/mopac-redmine-go/internal/fakeredmine"
|
|
"git.knownelement.com/ukrrs/mopac-redmine-go/redmine"
|
|
)
|
|
|
|
const fakeKey = "fake-redmine-key-0123456789"
|
|
|
|
func newClient(t *testing.T) (*redmine.Client, *fakeredmine.Server) {
|
|
t.Helper()
|
|
srv := fakeredmine.New(fakeKey)
|
|
t.Cleanup(srv.Close)
|
|
c := redmine.New(redmine.Config{BaseURL: srv.URL, APIKey: fakeKey, Timeout: 5 * time.Second})
|
|
return c, srv
|
|
}
|
|
|
|
func f64(v float64) *float64 { return &v }
|
|
|
|
// --- issue create/read round-trip -----------------------------------------
|
|
|
|
func TestCreateIssueRoundTrip(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
est := 8.0
|
|
created, err := c.CreateIssue(context.Background(), redmine.IssueParams{
|
|
Project: "MOPAC",
|
|
Subject: "Quota: per-identity usage accounting",
|
|
Description: "## Scope\n- usage accounting per acting identity",
|
|
TrackerID: 2, // Feature
|
|
PriorityID: 5, // Immediate
|
|
CategoryID: srv.AddCategory("Quota & Backpressure"),
|
|
FixedVersionID: srv.AddVersion(fakeredmine.Version{Name: "Beta", Status: "open"}),
|
|
ParentIssueID: srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "Parent", StatusID: 1, TrackerID: 4, PriorityID: 2}),
|
|
EstimatedHours: f64(est),
|
|
DueDate: "2026-08-31",
|
|
Notes: "initial note",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue: %v", err)
|
|
}
|
|
if created.ID == 0 || created.Subject != "Quota: per-identity usage accounting" {
|
|
t.Fatalf("created = %+v", created)
|
|
}
|
|
if created.Tracker.Name != "Feature" || created.Priority.Name != "Immediate" {
|
|
t.Errorf("enums not projected: tracker=%+v priority=%+v", created.Tracker, created.Priority)
|
|
}
|
|
if created.EstimatedHours == nil || *created.EstimatedHours != 8 {
|
|
t.Errorf("estimated_hours = %v", created.EstimatedHours)
|
|
}
|
|
if created.FixedVersion.Name != "Beta" || created.Category.Name != "Quota & Backpressure" {
|
|
t.Errorf("category/version not projected: %+v %+v", created.Category, created.FixedVersion)
|
|
}
|
|
|
|
got, err := c.GetIssue(context.Background(), created.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue: %v", err)
|
|
}
|
|
if got.Description != "## Scope\n- usage accounting per acting identity" || got.DueDate != "2026-08-31" {
|
|
t.Errorf("round-trip mismatch: %+v", got)
|
|
}
|
|
|
|
// The POST body must carry scalar ids (Redmine write shape).
|
|
var post map[string]any
|
|
if err := json.Unmarshal([]byte(srv.Requests()[0].Body), &post); err != nil {
|
|
t.Fatalf("POST body not json: %v", err)
|
|
}
|
|
issue := post["issue"].(map[string]any)
|
|
for _, k := range []string{"project_id", "tracker_id", "priority_id", "category_id", "fixed_version_id", "parent_issue_id", "estimated_hours", "due_date", "notes"} {
|
|
if _, ok := issue[k]; !ok {
|
|
t.Errorf("POST body missing %q: %v", k, issue)
|
|
}
|
|
}
|
|
if issue["project_id"] != "MOPAC" {
|
|
t.Errorf("project_id = %v, want identifier string", issue["project_id"])
|
|
}
|
|
}
|
|
|
|
// --- issue list filters ----------------------------------------------------
|
|
|
|
func TestListIssuesFilters(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
open := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "open MOPAC", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
|
done := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "done MOPAC", StatusID: 3, TrackerID: 2, PriorityID: 2})
|
|
other := srv.AddIssue(fakeredmine.Issue{ProjectID: "OTHER", Subject: "elsewhere", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
|
_ = done
|
|
|
|
tests := []struct {
|
|
name string
|
|
filter redmine.IssueFilter
|
|
wantIDs []int
|
|
wantQ string
|
|
}{
|
|
{
|
|
name: "project default open",
|
|
filter: redmine.IssueFilter{Project: "MOPAC"},
|
|
wantIDs: []int{open},
|
|
wantQ: "project_id=MOPAC&status_id=open",
|
|
},
|
|
{
|
|
name: "status all",
|
|
filter: redmine.IssueFilter{Project: "MOPAC", Status: redmine.StatusAll},
|
|
wantIDs: []int{open, done},
|
|
wantQ: "project_id=MOPAC&status_id=%2A",
|
|
},
|
|
{
|
|
name: "status closed",
|
|
filter: redmine.IssueFilter{Project: "MOPAC", Status: "closed"},
|
|
wantIDs: []int{done},
|
|
wantQ: "project_id=MOPAC&status_id=closed",
|
|
},
|
|
{
|
|
name: "no project lists across projects",
|
|
filter: redmine.IssueFilter{Status: redmine.StatusAll},
|
|
wantIDs: []int{open, done, other},
|
|
wantQ: "status_id=%2A",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, total, err := c.ListIssues(context.Background(), tt.filter)
|
|
if err != nil {
|
|
t.Fatalf("ListIssues: %v", err)
|
|
}
|
|
if len(got) != len(tt.wantIDs) || total != len(tt.wantIDs) {
|
|
t.Fatalf("got %d issues (total %d), want %v", len(got), total, tt.wantIDs)
|
|
}
|
|
for i, id := range tt.wantIDs {
|
|
if got[i].ID != id {
|
|
t.Errorf("issue[%d] = %d, want %d", i, got[i].ID, id)
|
|
}
|
|
}
|
|
last := srv.Requests()[len(srv.Requests())-1]
|
|
if last.Query != tt.wantQ {
|
|
t.Errorf("query = %q, want %q", last.Query, tt.wantQ)
|
|
}
|
|
})
|
|
}
|
|
|
|
// limit is passed through
|
|
if _, _, err := c.ListIssues(context.Background(), redmine.IssueFilter{Project: "MOPAC", Limit: 5}); err != nil {
|
|
t.Fatalf("ListIssues limit: %v", err)
|
|
}
|
|
last := srv.Requests()[len(srv.Requests())-1]
|
|
if !strings.Contains(last.Query, "limit=5") {
|
|
t.Errorf("query %q missing limit=5", last.Query)
|
|
}
|
|
}
|
|
|
|
// --- update + note-as-journal ---------------------------------------------
|
|
|
|
func TestUpdateIssueNoteBecomesJournal(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
id := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "seeded", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
|
|
|
if err := c.UpdateIssue(context.Background(), id, redmine.IssueParams{
|
|
StatusID: 3, // Done
|
|
DoneRatio: 100,
|
|
Notes: "REPORT delivered: see inbox",
|
|
}); err != nil {
|
|
t.Fatalf("UpdateIssue: %v", err)
|
|
}
|
|
|
|
// PUT body: notes + status_id only (omitempty zero fields).
|
|
reqs := srv.Requests()
|
|
put := reqs[len(reqs)-1]
|
|
var p struct {
|
|
Issue struct {
|
|
StatusID int `json:"status_id"`
|
|
DoneRatio int `json:"done_ratio"`
|
|
Notes string `json:"notes"`
|
|
Subject string `json:"subject"`
|
|
} `json:"issue"`
|
|
}
|
|
if err := json.Unmarshal([]byte(put.Body), &p); err != nil {
|
|
t.Fatalf("PUT body: %v", err)
|
|
}
|
|
if p.Issue.StatusID != 3 || p.Issue.DoneRatio != 100 || p.Issue.Notes != "REPORT delivered: see inbox" {
|
|
t.Errorf("PUT body = %+v", p.Issue)
|
|
}
|
|
if p.Issue.Subject != "" {
|
|
t.Errorf("PUT body carried subject; partial update must omit untouched fields")
|
|
}
|
|
|
|
got, err := c.GetIssue(context.Background(), id, true)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue: %v", err)
|
|
}
|
|
if got.Status.Name != "Done" || got.Status.IsClosed != true {
|
|
t.Errorf("status = %+v", got.Status)
|
|
}
|
|
if len(got.Journals) != 1 || got.Journals[0].Notes != "REPORT delivered: see inbox" {
|
|
t.Errorf("journals = %+v, want the note recorded", got.Journals)
|
|
}
|
|
if got, _ := srv.Issue(id); got.DoneRatio != 100 {
|
|
t.Errorf("server done_ratio = %d", got.DoneRatio)
|
|
}
|
|
}
|
|
|
|
func TestGetIssueWithoutJournalsOmitsThem(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
id := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "s", StatusID: 1, TrackerID: 2, PriorityID: 2,
|
|
Journals: []fakeredmine.JournalEntry{{ID: 1, Notes: "old"}}})
|
|
got, err := c.GetIssue(context.Background(), id, false)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue: %v", err)
|
|
}
|
|
if len(got.Journals) != 0 {
|
|
t.Errorf("journals fetched without include: %+v", got.Journals)
|
|
}
|
|
last := srv.Requests()[len(srv.Requests())-1]
|
|
if strings.Contains(last.Query, "include") {
|
|
t.Errorf("query %q must not request include for plain show", last.Query)
|
|
}
|
|
}
|
|
|
|
// --- versions and categories ----------------------------------------------
|
|
|
|
func TestVersionRoundTrip(t *testing.T) {
|
|
c, _ := newClient(t)
|
|
v, err := c.CreateVersion(context.Background(), "MOPAC", redmine.VersionParams{
|
|
Name: "Beta", DueDate: "2026-08-31", Status: "open", Sharing: "descendants",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateVersion: %v", err)
|
|
}
|
|
if v.ID == 0 || v.Status != "open" {
|
|
t.Fatalf("version = %+v", v)
|
|
}
|
|
list, err := c.ListVersions(context.Background(), "MOPAC")
|
|
if err != nil {
|
|
t.Fatalf("ListVersions: %v", err)
|
|
}
|
|
found := false
|
|
for _, got := range list {
|
|
if got.ID == v.ID && got.Name == "Beta" && got.DueDate == "2026-08-31" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Beta not in list: %+v", list)
|
|
}
|
|
}
|
|
|
|
// TestUpdateVersionPartial drives PUT /versions/N.json (Redmine answers
|
|
// 204 with an EMPTY body — the client must not try to parse it) and
|
|
// asserts the partial-update discipline: only provided fields travel.
|
|
func TestUpdateVersionPartial(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
params redmine.VersionParams
|
|
wantBody string
|
|
}{
|
|
{"status flip", redmine.VersionParams{Status: "closed"}, `{"version":{"status":"closed"}}`},
|
|
{"due change", redmine.VersionParams{DueDate: "2026-09-15"}, `{"version":{"due_date":"2026-09-15"}}`},
|
|
{"name change", redmine.VersionParams{Name: "Beta 2"}, `{"version":{"name":"Beta 2"}}`},
|
|
{"combined flags", redmine.VersionParams{Name: "Beta 2", DueDate: "2026-09-15", Status: "closed"},
|
|
`{"version":{"due_date":"2026-09-15","name":"Beta 2","status":"closed"}}`},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
id := srv.AddVersion(fakeredmine.Version{Name: "Beta", DueDate: "2026-08-31", Status: "open"})
|
|
|
|
if err := c.UpdateVersion(context.Background(), id, tt.params); err != nil {
|
|
t.Fatalf("UpdateVersion: %v", err)
|
|
}
|
|
got, ok := srv.Version(id)
|
|
if !ok {
|
|
t.Fatalf("version %d vanished", id)
|
|
}
|
|
for k, v := range map[string]string{
|
|
"name": got.Name, "due": got.DueDate, "status": got.Status,
|
|
} {
|
|
if v == "" {
|
|
t.Errorf("field %q wiped by partial update: %+v", k, got)
|
|
}
|
|
}
|
|
if tt.params.Name != "" && got.Name != tt.params.Name {
|
|
t.Errorf("name = %q, want %q", got.Name, tt.params.Name)
|
|
}
|
|
if tt.params.DueDate != "" && got.DueDate != tt.params.DueDate {
|
|
t.Errorf("due = %q, want %q", got.DueDate, tt.params.DueDate)
|
|
}
|
|
if tt.params.Status != "" && got.Status != tt.params.Status {
|
|
t.Errorf("status = %q, want %q", got.Status, tt.params.Status)
|
|
}
|
|
// only provided flags are sent — assert the exact PUT body
|
|
last := srv.Requests()[len(srv.Requests())-1]
|
|
if last.Method != "PUT" || last.Path != "/versions/"+itoa(id)+".json" {
|
|
t.Errorf("request = %+v", last)
|
|
}
|
|
if last.Body != tt.wantBody {
|
|
t.Errorf("body = %s, want %s", last.Body, tt.wantBody)
|
|
}
|
|
// untouched attributes survive
|
|
if tt.params.Name == "" && got.Name != "Beta" {
|
|
t.Errorf("untouched name clobbered: %+v", got)
|
|
}
|
|
if tt.params.DueDate == "" && got.DueDate != "2026-08-31" {
|
|
t.Errorf("untouched due clobbered: %+v", got)
|
|
}
|
|
if tt.params.Status == "" && got.Status != "open" {
|
|
t.Errorf("untouched status clobbered: %+v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetVersion(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
id := srv.AddVersion(fakeredmine.Version{Name: "Beta", DueDate: "2026-08-31", Status: "open"})
|
|
|
|
v, err := c.GetVersion(context.Background(), id)
|
|
if err != nil {
|
|
t.Fatalf("GetVersion: %v", err)
|
|
}
|
|
if v.ID != id || v.Name != "Beta" || v.DueDate != "2026-08-31" || v.Status != "open" {
|
|
t.Errorf("version = %+v", v)
|
|
}
|
|
if _, err := c.GetVersion(context.Background(), 424242); !errors.Is(err, redmine.ErrNotFound) {
|
|
t.Errorf("missing version err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateVersionMissing(t *testing.T) {
|
|
c, _ := newClient(t)
|
|
err := c.UpdateVersion(context.Background(), 424242, redmine.VersionParams{Status: "closed"})
|
|
if !errors.Is(err, redmine.ErrNotFound) {
|
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "http 404") {
|
|
t.Fatalf("err = %q, want embedded http 404", err)
|
|
}
|
|
}
|
|
|
|
func TestCategoryRoundTrip(t *testing.T) {
|
|
c, _ := newClient(t)
|
|
cat, err := c.CreateCategory(context.Background(), "MOPAC", "Secrets")
|
|
if err != nil {
|
|
t.Fatalf("CreateCategory: %v", err)
|
|
}
|
|
if cat.ID == 0 || cat.Name != "Secrets" {
|
|
t.Fatalf("category = %+v", cat)
|
|
}
|
|
list, err := c.ListCategories(context.Background(), "MOPAC")
|
|
if err != nil {
|
|
t.Fatalf("ListCategories: %v", err)
|
|
}
|
|
if len(list) == 0 || list[0].Name != "Secrets" {
|
|
t.Errorf("list = %+v", list)
|
|
}
|
|
}
|
|
|
|
// --- relations --------------------------------------------------------------
|
|
|
|
func TestCreateRelation(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
from := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "quota", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
|
to := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "dispatcher", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
|
|
|
rel, err := c.CreateRelation(context.Background(), from, to, "blocks")
|
|
if err != nil {
|
|
t.Fatalf("CreateRelation: %v", err)
|
|
}
|
|
if rel.IssueID != from || rel.IssueToID != to || rel.RelationType != "blocks" {
|
|
t.Errorf("relation = %+v", rel)
|
|
}
|
|
last := srv.Requests()[len(srv.Requests())-1]
|
|
if last.Method != "POST" || last.Path != "/issues/"+itoa(from)+"/relations.json" {
|
|
t.Errorf("request = %+v", last)
|
|
}
|
|
var body struct {
|
|
Relation struct {
|
|
IssueToID int `json:"issue_to_id"`
|
|
RelationType string `json:"relation_type"`
|
|
} `json:"relation"`
|
|
}
|
|
if err := json.Unmarshal([]byte(last.Body), &body); err != nil {
|
|
t.Fatalf("body: %v", err)
|
|
}
|
|
if body.Relation.IssueToID != to || body.Relation.RelationType != "blocks" {
|
|
t.Errorf("body = %+v", body.Relation)
|
|
}
|
|
}
|
|
|
|
func TestCreateRelationRejectsUnknownType(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
a := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "a", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
|
b := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "b", StatusID: 1, TrackerID: 2, PriorityID: 2})
|
|
_, err := c.CreateRelation(context.Background(), a, b, "destroys")
|
|
if !errors.Is(err, redmine.ErrValidation) {
|
|
t.Fatalf("err = %v, want ErrValidation", err)
|
|
}
|
|
}
|
|
|
|
// --- name resolution ---------------------------------------------------------
|
|
|
|
func TestNameResolution(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
verID := srv.AddVersion(fakeredmine.Version{Name: "Phase 3 - Integrations", Status: "open"})
|
|
catID := srv.AddCategory("Quota & Backpressure")
|
|
|
|
tests := []struct {
|
|
name string
|
|
fn func() (int, error)
|
|
want int
|
|
}{
|
|
{"status done", func() (int, error) { return c.StatusIDByName(context.Background(), "done") }, 3},
|
|
{"status case-insensitive", func() (int, error) { return c.StatusIDByName(context.Background(), "In Progress") }, 2},
|
|
{"tracker feature", func() (int, error) { return c.TrackerIDByName(context.Background(), "feature") }, 2},
|
|
{"priority immediate", func() (int, error) { return c.PriorityIDByName(context.Background(), "immediate") }, 5},
|
|
{"version by name", func() (int, error) { return c.VersionIDByName(context.Background(), "MOPAC", "phase 3 - integrations") }, verID},
|
|
{"category by name", func() (int, error) { return c.CategoryIDByName(context.Background(), "MOPAC", "quota & backpressure") }, catID},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := tt.fn()
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if got != tt.want {
|
|
t.Fatalf("id = %d, want %d", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
if _, err := c.StatusIDByName(context.Background(), "Nonexistent"); !errors.Is(err, redmine.ErrNotFound) {
|
|
t.Fatalf("unknown status err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// --- error mapping ------------------------------------------------------------
|
|
|
|
func TestErrorMapping(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fail fakeredmine.FailSpec
|
|
want error
|
|
}{
|
|
{"401", fakeredmine.FailSpec{Status: 401, Body: `{"errors":["bad key %s"]}`}, redmine.ErrAuth},
|
|
{"403", fakeredmine.FailSpec{Status: 403, Body: `{"errors":["forbidden %s"]}`}, redmine.ErrAuth},
|
|
{"404", fakeredmine.FailSpec{Status: 404, Body: `{"errors":["missing %s"]}`}, redmine.ErrNotFound},
|
|
{"422", fakeredmine.FailSpec{Status: 422, Body: `{"errors":["Name has already been taken","%s"]}`}, redmine.ErrValidation},
|
|
{"500", fakeredmine.FailSpec{Status: 500, Body: `boom %s`}, redmine.ErrServer},
|
|
{"502", fakeredmine.FailSpec{Status: 502, Body: `bad gateway %s`}, redmine.ErrServer},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
srv.Fail = &tt.fail
|
|
_, err := c.GetIssue(context.Background(), 42, false)
|
|
if !errors.Is(err, tt.want) {
|
|
t.Fatalf("err = %v, want %v", err, tt.want)
|
|
}
|
|
if !strings.Contains(err.Error(), "http "+strings.TrimPrefix(itoa(tt.fail.Status), "")) {
|
|
// status code must appear for machine parsing
|
|
t.Fatalf("err = %q, want embedded http status %d", err.Error(), tt.fail.Status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnreachable(t *testing.T) {
|
|
srv := fakeredmine.New(fakeKey)
|
|
url := srv.URL
|
|
srv.Close() // port now dead
|
|
c := redmine.New(redmine.Config{BaseURL: url, APIKey: fakeKey, Timeout: 2 * time.Second})
|
|
_, err := c.GetIssue(context.Background(), 1, false)
|
|
if !errors.Is(err, redmine.ErrUnreachable) {
|
|
t.Fatalf("err = %v, want ErrUnreachable", err)
|
|
}
|
|
if strings.Contains(err.Error(), "http") {
|
|
t.Fatalf("unreachable err should not fake an http status: %q", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestMalformedResponse(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
srv.Fail = &fakeredmine.FailSpec{Status: 200, Body: `<<not json>>`}
|
|
_, err := c.GetIssue(context.Background(), 1, false)
|
|
if !errors.Is(err, redmine.ErrMalformedResponse) {
|
|
t.Fatalf("err = %v, want ErrMalformedResponse", err)
|
|
}
|
|
}
|
|
|
|
// --- key redaction ---------------------------------------------------------------
|
|
|
|
// The fake embeds the PRESENTED key in every error body (see FailSpec and
|
|
// the 401 path). If any error string or recorded request ever leaks the
|
|
// key, these assertions fail. The API key must exist only in the
|
|
// X-Redmine-API-Key header.
|
|
func TestKeyNeverLeaks(t *testing.T) {
|
|
t.Run("wrong key rejected without echo", func(t *testing.T) {
|
|
srv := fakeredmine.New(fakeKey)
|
|
t.Cleanup(srv.Close)
|
|
c := redmine.New(redmine.Config{BaseURL: srv.URL, APIKey: "wrong-key-abcdef", Timeout: 2 * time.Second})
|
|
_, err := c.GetIssue(context.Background(), 1, false)
|
|
if !errors.Is(err, redmine.ErrAuth) {
|
|
t.Fatalf("err = %v, want ErrAuth", err)
|
|
}
|
|
if strings.Contains(err.Error(), "wrong-key-abcdef") || strings.Contains(err.Error(), fakeKey) {
|
|
t.Fatalf("error leaks a key: %q", err.Error())
|
|
}
|
|
})
|
|
t.Run("error bodies never surfaced", func(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
srv.Fail = &fakeredmine.FailSpec{Status: 500, Body: `crashed while holding key %s`}
|
|
_, err := c.GetIssue(context.Background(), 1, false)
|
|
if strings.Contains(err.Error(), "crashed") || strings.Contains(err.Error(), fakeKey) {
|
|
t.Fatalf("err = %q, want sanitized one-liner", err.Error())
|
|
}
|
|
})
|
|
t.Run("key travels only in the header", func(t *testing.T) {
|
|
c, srv := newClient(t)
|
|
if _, _, err := c.ListIssues(context.Background(), redmine.IssueFilter{Project: "MOPAC"}); err != nil {
|
|
t.Fatalf("ListIssues: %v", err)
|
|
}
|
|
for _, req := range srv.Requests() {
|
|
if strings.Contains(req.Body, fakeKey) || strings.Contains(req.Query, fakeKey) || strings.Contains(req.Path, fakeKey) {
|
|
t.Fatalf("key outside header: %+v", req)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func itoa(n int) string { return strconv.Itoa(n) }
|