// Package fakeredmine is a stateful in-memory Redmine REST fake used by // the test suite and the smoke run. The real tracker is NEVER contacted. // It implements exactly the endpoints the redmine client uses, enforces // X-Redmine-API-Key auth on every one of them, and records every request // so tests can assert round-trips. Error responses deliberately echo the // presented key back in the body: any test that survives that proves the // client never surfaces response bodies (key-redaction guarantee). package fakeredmine import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "sort" "strconv" "strings" "sync" ) // Request is one recorded exchange (auth checked, body captured). type Request struct { Method string Path string // path without query Query string // raw query Body string } // Version is the roadmap-milestone shape. type Version struct { ID int `json:"id"` Name string `json:"name"` DueDate string `json:"due_date,omitempty"` Status string `json:"status"` // open|locked|closed Sharing string `json:"sharing,omitempty"` } // Category is the issue-category shape. type Category struct { ID int `json:"id"` Name string `json:"name"` } // JournalEntry is one history note. type JournalEntry struct { ID int `json:"id"` UserID int `json:"user_id"` Notes string `json:"notes"` CreatedOn string `json:"created_on"` } // Issue is the stored issue (server side, write-friendly scalars). type Issue struct { ID int `json:"id"` Subject string `json:"subject"` Description string `json:"description"` ProjectID string `json:"project_id"` TrackerID int `json:"tracker_id"` StatusID int `json:"status_id"` PriorityID int `json:"priority_id"` CategoryID int `json:"category_id,omitempty"` FixedVersionID int `json:"fixed_version_id,omitempty"` ParentIssueID int `json:"parent_issue_id,omitempty"` EstimatedHours *float64 `json:"estimated_hours,omitempty"` DoneRatio int `json:"done_ratio,omitempty"` DueDate string `json:"due_date,omitempty"` Notes string `json:"notes,omitempty"` // write-only: create/update note -> journal CreatedOn string `json:"created_on"` UpdatedOn string `json:"updated_on"` Journals []JournalEntry `json:"journals,omitempty"` } // Relation is the issue-relation shape. type Relation struct { ID int `json:"id"` IssueID int `json:"issue_id"` IssueToID int `json:"issue_to_id"` RelationType string `json:"relation_type"` Delay int `json:"delay,omitempty"` } // Server is the fake Redmine. type Server struct { URL string APIKey string apiKeySet bool mu sync.Mutex srv *httptest.Server requests []Request issues map[int]*Issue versions map[int]Version categories map[int]Category relations map[int]Relation nextID int // Fail, when non-nil, is returned instead of normal handling; it is // consumed by the first request that sees it. Fail *FailSpec Trackers []IDName Statuses []Status Priorities []IDName } // FailSpec pins the next response (error-mapping tests). type FailSpec struct { Status int Body string } // IDName is a bare enumeration entry. type IDName struct { ID int `json:"id"` Name string `json:"name"` } // Status adds the is_closed flag to an enumeration entry. type Status struct { ID int `json:"id"` Name string `json:"name"` IsClosed bool `json:"is_closed"` } // New starts a fake on a random port with Redmine's default // enumerations and an empty MOPAC project. func New(apiKey string) *Server { s := &Server{ APIKey: apiKey, apiKeySet: true, issues: map[int]*Issue{}, versions: map[int]Version{}, categories: map[int]Category{}, relations: map[int]Relation{}, nextID: 100, Trackers: []IDName{ {1, "Bug"}, {2, "Feature"}, {3, "Support"}, {4, "Task"}, }, Statuses: []Status{ {1, "New", false}, {2, "In Progress", false}, {3, "Done", true}, {4, "Rejected", true}, }, Priorities: []IDName{ {1, "Low"}, {2, "Normal"}, {3, "High"}, {4, "Urgent"}, {5, "Immediate"}, }, } mux := http.NewServeMux() mux.HandleFunc("/", s.handler) s.srv = httptest.NewServer(mux) s.URL = s.srv.URL return s } // Close shuts the fake down. func (s *Server) Close() { s.srv.Close() } // Requests returns a copy of the recorded exchanges. func (s *Server) Requests() []Request { s.mu.Lock() defer s.mu.Unlock() out := make([]Request, len(s.requests)) copy(out, s.requests) return out } // AddIssue seeds an issue directly (bypassing REST) and returns its id. func (s *Server) AddIssue(i Issue) int { s.mu.Lock() defer s.mu.Unlock() s.nextID++ i.ID = s.nextID now := "2026-08-29T12:00:00Z" if i.CreatedOn == "" { i.CreatedOn = now } if i.UpdatedOn == "" { i.UpdatedOn = now } s.issues[i.ID] = &i return i.ID } // Issue returns a deep-ish copy of a stored issue (for assertions). func (s *Server) Issue(id int) (Issue, bool) { s.mu.Lock() defer s.mu.Unlock() i, ok := s.issues[id] if !ok { return Issue{}, false } return *i, true } // AddVersion / AddCategory seed enumerations directly. func (s *Server) AddVersion(v Version) int { s.mu.Lock() defer s.mu.Unlock() s.nextID++ v.ID = s.nextID s.versions[v.ID] = v return v.ID } func (s *Server) AddCategory(name string) int { s.mu.Lock() defer s.mu.Unlock() s.nextID++ s.categories[s.nextID] = Category{ID: s.nextID, Name: name} return s.nextID } func (s *Server) handler(w http.ResponseWriter, r *http.Request) { key := "" if s.apiKeySet { key = r.Header.Get("X-Redmine-API-Key") } body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) s.mu.Lock() if s.Fail != nil { spec := *s.Fail s.Fail = nil s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)}) s.mu.Unlock() // The body deliberately embeds the presented key: surviving this // proves the client drops response bodies from error strings. w.WriteHeader(spec.Status) fmt.Fprintf(w, spec.Body, key) return } if s.apiKeySet && key != s.APIKey { s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, ""}) s.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) fmt.Fprintf(w, `{"errors":["Invalid HTTP auth: key %s was rejected"]}`, key) return } s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)}) var ( respStatus = http.StatusOK respBody string ) path := r.URL.Path q := r.URL.Query() switch { case r.Method == http.MethodGet && path == "/issues.json": respStatus, respBody = s.listIssues(q) case r.Method == http.MethodGet && strings.HasPrefix(path, "/issues/") && strings.HasSuffix(path, ".json"): id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), ".json")) respStatus, respBody = s.getIssue(id, q.Get("include")) case r.Method == http.MethodPost && path == "/issues.json": respStatus, respBody = s.createIssue(body) case r.Method == http.MethodPut && strings.HasPrefix(path, "/issues/") && strings.HasSuffix(path, ".json"): id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), ".json")) respStatus, respBody = s.updateIssue(id, body) case r.Method == http.MethodPost && strings.HasSuffix(path, "/relations.json"): from, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(path, "/issues/"), "/relations.json")) respStatus, respBody = s.createRelation(from, body) case r.Method == http.MethodGet && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/versions.json"): respStatus, respBody = s.listVersions() case r.Method == http.MethodPost && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/versions.json"): respStatus, respBody = s.createVersion(body) case r.Method == http.MethodGet && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/issue_categories.json"): respStatus, respBody = s.listCategories() case r.Method == http.MethodPost && strings.HasPrefix(path, "/projects/") && strings.HasSuffix(path, "/issue_categories.json"): respStatus, respBody = s.createCategory(body) case r.Method == http.MethodGet && path == "/trackers.json": respStatus, respBody = jsonReply(map[string]any{"trackers": s.Trackers}) case r.Method == http.MethodGet && path == "/issue_statuses.json": respStatus, respBody = jsonReply(map[string]any{"issue_statuses": s.Statuses}) case r.Method == http.MethodGet && path == "/enumerations/issue_priorities.json": respStatus, respBody = jsonReply(map[string]any{"issue_priorities": s.Priorities}) default: respStatus, respBody = http.StatusNotFound, `{"errors":["Not found"]}` } s.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(respStatus) if respBody != "" { io.WriteString(w, respBody) } } func jsonReply(v any) (int, string) { b, err := json.Marshal(v) if err != nil { return http.StatusInternalServerError, `{"errors":["marshal"]}` } return http.StatusOK, string(b) } func (s *Server) listIssues(q url.Values) (int, string) { // Redmine defaults to OPEN issues only; status_id=* lifts it. wantStatus := "open" if v := q.Get("status_id"); v != "" { wantStatus = v } var proj, ver string proj = q.Get("project_id") ver = q.Get("fixed_version_id") limit := 25 if v := q.Get("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil { limit = n } } type out struct { Issues []map[string]any `json:"issues"` Total int `json:"total_count"` Offset int `json:"offset"` Limit int `json:"limit"` } o := out{Limit: limit} var ids []int for id, i := range s.issues { if proj != "" && i.ProjectID != proj { continue } if wantStatus == "open" && s.statusIsClosed(i.StatusID) { continue } if wantStatus == "closed" && !s.statusIsClosed(i.StatusID) { continue } if ver != "" && strconv.Itoa(i.FixedVersionID) != ver { continue } ids = append(ids, id) } sort.Ints(ids) for _, id := range ids { o.Issues = append(o.Issues, issueView(s, *s.issues[id], false)) } o.Total = len(o.Issues) if len(o.Issues) > limit { o.Issues = o.Issues[:limit] } return jsonReply(o) } func (s *Server) statusIsClosed(id int) bool { for _, st := range s.Statuses { if st.ID == id { return st.IsClosed } } return false } func (s *Server) getIssue(id int, include string) (int, string) { i, ok := s.issues[id] if !ok { return http.StatusNotFound, `{"errors":["Issue not found"]}` } out := map[string]any{"issue": issueView(s, *i, include == "journals")} return jsonReply(out) } func (s *Server) createIssue(body []byte) (int, string) { var p struct { Issue Issue `json:"issue"` } if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, `{"errors":["bad json"]}` } if p.Issue.Subject == "" || p.Issue.ProjectID == "" { return http.StatusUnprocessableEntity, `{"errors":["Subject and project cannot be blank"]}` } s.nextID++ i := p.Issue i.ID = s.nextID i.CreatedOn = "2026-08-29T12:00:00Z" i.UpdatedOn = i.CreatedOn if i.Notes != "" { // notes on create become the first journal i.Journals = append(i.Journals, JournalEntry{ID: 1, UserID: 5, Notes: i.Notes, CreatedOn: i.CreatedOn}) } s.issues[i.ID] = &i return http.StatusCreated, `{"issue":` + mustJSON(issueView(s, i, false)) + `}` } func (s *Server) updateIssue(id int, body []byte) (int, string) { i, ok := s.issues[id] if !ok { return http.StatusNotFound, `{"errors":["Issue not found"]}` } var p struct { Issue Issue `json:"issue"` } if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, `{"errors":["bad json"]}` } if p.Issue.Subject != "" { i.Subject = p.Issue.Subject } if p.Issue.Description != "" { i.Description = p.Issue.Description } if p.Issue.TrackerID != 0 { i.TrackerID = p.Issue.TrackerID } if p.Issue.StatusID != 0 { i.StatusID = p.Issue.StatusID } if p.Issue.PriorityID != 0 { i.PriorityID = p.Issue.PriorityID } if p.Issue.CategoryID != 0 { i.CategoryID = p.Issue.CategoryID } if p.Issue.FixedVersionID != 0 { i.FixedVersionID = p.Issue.FixedVersionID } if p.Issue.EstimatedHours != nil { i.EstimatedHours = p.Issue.EstimatedHours } if p.Issue.DoneRatio != 0 { i.DoneRatio = p.Issue.DoneRatio } if p.Issue.DueDate != "" { i.DueDate = p.Issue.DueDate } if p.Issue.Notes != "" { i.Journals = append(i.Journals, JournalEntry{ ID: len(i.Journals) + 1, UserID: 5, Notes: p.Issue.Notes, CreatedOn: "2026-08-29T13:00:00Z", }) } i.UpdatedOn = "2026-08-29T13:00:00Z" return http.StatusNoContent, "" } func (s *Server) createRelation(from int, body []byte) (int, string) { var p struct { Relation Relation `json:"relation"` } if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, `{"errors":["bad json"]}` } if _, ok := s.issues[from]; !ok { return http.StatusNotFound, `{"errors":["Issue not found"]}` } if _, ok := s.issues[p.Relation.IssueToID]; !ok { return http.StatusUnprocessableEntity, `{"errors":["Issue not found"]}` } t := p.Relation.RelationType if t != "blocks" && t != "relates" && t != "precedes" && t != "copied_to" && t != "duplicates" { return http.StatusUnprocessableEntity, `{"errors":["relation_type is not included in the list"]}` } s.nextID++ rel := Relation{ID: s.nextID, IssueID: from, IssueToID: p.Relation.IssueToID, RelationType: t} s.relations[rel.ID] = rel return http.StatusCreated, `{"relation":` + mustJSON(rel) + `}` } func (s *Server) listVersions() (int, string) { var vs []Version for _, v := range s.versions { vs = append(vs, v) } return jsonReply(map[string]any{"versions": vs}) } func (s *Server) createVersion(body []byte) (int, string) { var p struct { Version Version `json:"version"` } if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, `{"errors":["bad json"]}` } if p.Version.Name == "" { return http.StatusUnprocessableEntity, `{"errors":["Name cannot be blank"]}` } for _, v := range s.versions { if v.Name == p.Version.Name { return http.StatusUnprocessableEntity, `{"errors":["Name has already been taken"]}` } } s.nextID++ v := p.Version v.ID = s.nextID if v.Status == "" { v.Status = "open" } s.versions[v.ID] = v return http.StatusCreated, `{"version":` + mustJSON(v) + `}` } func (s *Server) listCategories() (int, string) { var cs []Category for _, c := range s.categories { cs = append(cs, c) } return jsonReply(map[string]any{"issue_categories": cs}) } func (s *Server) createCategory(body []byte) (int, string) { var p struct { Category Category `json:"issue_category"` } if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, `{"errors":["bad json"]}` } if p.Category.Name == "" { return http.StatusUnprocessableEntity, `{"errors":["Name cannot be blank"]}` } for _, c := range s.categories { if c.Name == p.Category.Name { return http.StatusUnprocessableEntity, `{"errors":["Name has already been taken"]}` } } s.nextID++ c := Category{ID: s.nextID, Name: p.Category.Name} s.categories[c.ID] = c return http.StatusCreated, `{"issue_category":` + mustJSON(c) + `}` } // issueView projects the stored issue into Redmine's READ shape // (nested objects for project/tracker/status/priority/category/version). func issueView(s *Server, i Issue, withJournals bool) map[string]any { v := map[string]any{ "id": i.ID, "subject": i.Subject, "description": i.Description, "created_on": i.CreatedOn, "updated_on": i.UpdatedOn, } v["project"] = map[string]any{"id": 1, "name": i.ProjectID, "identifier": i.ProjectID} if n := enumName(s.Trackers, i.TrackerID); n != "" { v["tracker"] = map[string]any{"id": i.TrackerID, "name": n} } if st := statusByID(s.Statuses, i.StatusID); st != nil { v["status"] = map[string]any{"id": st.ID, "name": st.Name, "is_closed": st.IsClosed} } if n := enumName(s.Priorities, i.PriorityID); n != "" { v["priority"] = map[string]any{"id": i.PriorityID, "name": n} } if i.CategoryID != 0 { if c, ok := s.categories[i.CategoryID]; ok { v["category"] = map[string]any{"id": c.ID, "name": c.Name} } } if i.FixedVersionID != 0 { if ver, ok := s.versions[i.FixedVersionID]; ok { v["fixed_version"] = map[string]any{"id": ver.ID, "name": ver.Name} } } if i.ParentIssueID != 0 { v["parent"] = map[string]any{"id": i.ParentIssueID} } if i.EstimatedHours != nil { v["estimated_hours"] = *i.EstimatedHours } if i.DoneRatio != 0 { v["done_ratio"] = i.DoneRatio } if i.DueDate != "" { v["due_date"] = i.DueDate } if withJournals && len(i.Journals) > 0 { v["journals"] = i.Journals } return v } func enumName(list []IDName, id int) string { for _, e := range list { if e.ID == id { return e.Name } } return "" } func statusByID(list []Status, id int) *Status { for i := range list { if list[i].ID == id { return &list[i] } } return nil } func mustJSON(v any) string { b, err := json.Marshal(v) if err != nil { panic(err) } return string(b) }