github.com/letsencrypt/boulder@v0.20251208.0/test/zendeskfake/zendeskfake.go (about) 1 package zendeskfake 2 3 import ( 4 "encoding/base64" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "log" 9 "maps" 10 "net/http" 11 "net/url" 12 "regexp" 13 "slices" 14 "sort" 15 "strconv" 16 "strings" 17 "sync" 18 ) 19 20 const ( 21 defaultTicketCapacity = 200 22 apiPrefix = "/api/v2" 23 TicketsJSONPath = apiPrefix + "/tickets.json" 24 SearchJSONPath = apiPrefix + "/search.json" 25 TicketsPath = apiPrefix + "/tickets/" 26 ) 27 28 var ( 29 // ticketPathRegexp matches the tickets path with an ID at the end, e.g. 30 // /api/v2/tickets/123.json. It captures the ID as the first group. 31 ticketPathRegexp = regexp.MustCompile("^" + regexp.QuoteMeta(TicketsPath) + `(\d+)\.json$`) 32 33 // customFieldRegexp matches custom fields in the format 34 // custom_field_<id>:"value" or custom_field_<id>:value where <id> is the 35 // field ID and "value" is the field value, allowing for both quoted and 36 // unquoted values. It captures the field ID as the first group and the 37 // value as the second group. 38 customFieldRegexp = regexp.MustCompile(`custom_field_(\d+):("[^"]+"|\S+)`) 39 40 // statusRegexp matches the status in the format status:<status>, where 41 // <status> is one of the valid statuses. It captures the status as the 42 // first group. It is used to validate the status in search queries. 43 statusRegexp = regexp.MustCompile(`\bstatus:(\w+)\b`) 44 45 // validStatuses is the list of valid default Zendesk ticket statuses. 46 validStatuses = []string{"new", "open", "pending", "hold", "solved", "closed"} 47 ) 48 49 // requester represents a requester in a Zendesk ticket. 50 type requester struct { 51 Name string `json:"name"` 52 Email string `json:"email"` 53 } 54 55 // comment represents a comment in a Zendesk ticket. 56 type comment struct { 57 Body string `json:"body"` 58 Public bool `json:"public"` 59 } 60 61 // ticket represents all the fields of a Zendesk ticket. 62 type ticket struct { 63 ID int64 `json:"id"` 64 Status string `json:"status"` 65 Requester requester `json:"requester"` 66 Subject string `json:"subject"` 67 Comments []comment `json:"comments"` 68 CustomFields map[int64]string `json:"custom_fields"` 69 } 70 71 // Store is a thread-safe in-memory store for tickets. It uses a stack to store 72 // the tickets and a map to quickly access them by ID. The stack has a fixed 73 // capacity, and when it is full, the oldest ticket is removed to make room. 74 type Store struct { 75 sync.Mutex 76 nextID int64 77 cap int 78 stack []*ticket 79 byID map[int64]*ticket 80 } 81 82 // NewStore creates a new Store with the specified capacity. If no capacity is 83 // specified, it defaults to 200 tickets. 84 func NewStore(capacity int) *Store { 85 if capacity == 0 { 86 capacity = defaultTicketCapacity 87 } 88 return &Store{ 89 nextID: 1, 90 cap: capacity, 91 stack: make([]*ticket, 0, defaultTicketCapacity), 92 byID: make(map[int64]*ticket, defaultTicketCapacity), 93 } 94 } 95 96 func (s *Store) push(t *ticket) int64 { 97 s.Lock() 98 defer s.Unlock() 99 100 if len(s.stack) >= s.cap { 101 oldest := s.stack[0] 102 delete(s.byID, oldest.ID) 103 s.stack = s.stack[1:] 104 } 105 106 t.ID = s.nextID 107 s.nextID++ 108 109 s.stack = append(s.stack, t) 110 s.byID[t.ID] = t 111 return t.ID 112 } 113 114 func (s *Store) setStatus(id int64, status string) error { 115 s.Lock() 116 defer s.Unlock() 117 118 t, ok := s.byID[id] 119 if !ok { 120 return errors.New("ticket not found") 121 } 122 t.Status = status 123 return nil 124 } 125 126 func (s *Store) addComment(id int64, c comment) error { 127 s.Lock() 128 defer s.Unlock() 129 130 current, ok := s.byID[id] 131 if !ok { 132 return errors.New("ticket not found") 133 } 134 135 current.Comments = append(current.Comments, c) 136 return nil 137 } 138 139 func checkBasicAuth(r *http.Request, wantEmail, wantToken string) bool { 140 auth := r.Header.Get("Authorization") 141 if !strings.HasPrefix(auth, "Basic ") { 142 return false 143 } 144 decodedBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")) 145 if err != nil { 146 return false 147 } 148 decoded := string(decodedBytes) 149 expected := fmt.Sprintf("%s/token:%s", wantEmail, wantToken) 150 return decoded == expected 151 } 152 153 func writeJSON(w http.ResponseWriter, status int, payload any) { 154 bytes, err := json.Marshal(payload) 155 if err != nil { 156 log.Printf("failed to marshal response: %s", err) 157 http.Error(w, "marshal error", http.StatusInternalServerError) 158 return 159 } 160 w.Header().Set("Content-Type", "application/json") 161 w.WriteHeader(status) 162 _, err = w.Write(bytes) 163 if err != nil { 164 log.Printf("failed to write response: %s", err) 165 http.Error(w, "write error", http.StatusInternalServerError) 166 return 167 } 168 } 169 170 type Server struct { 171 tokenUser string 172 token string 173 store *Store 174 } 175 176 // NewServer creates a new Server with the specified user and token. If no store 177 // is provided, it creates a new Store with the default capacity. 178 func NewServer(tokenEmail, apiToken string, s *Store) *Server { 179 if s == nil { 180 s = NewStore(0) 181 } 182 return &Server{ 183 tokenUser: tokenEmail, 184 token: apiToken, 185 store: s, 186 } 187 } 188 189 func (s *Server) auth(next http.Handler) http.Handler { 190 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 191 ok := checkBasicAuth(r, s.tokenUser, s.token) 192 if !ok { 193 http.Error(w, "Unauthorized", http.StatusUnauthorized) 194 return 195 } 196 next.ServeHTTP(w, r) 197 }) 198 } 199 200 // POST /api/v2/tickets.json 201 func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) { 202 var req struct { 203 Ticket struct { 204 Requester requester `json:"requester"` 205 Subject string `json:"subject"` 206 Comment comment `json:"comment"` 207 Custom []struct { 208 ID int64 `json:"id"` 209 Value any `json:"value"` 210 } `json:"custom_fields"` 211 } `json:"ticket"` 212 } 213 214 err := json.NewDecoder(r.Body).Decode(&req) 215 if err != nil { 216 http.Error(w, "bad json", http.StatusBadRequest) 217 return 218 } 219 220 if req.Ticket.Subject == "" || req.Ticket.Comment.Body == "" || req.Ticket.Requester.Email == "" { 221 writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ 222 "error": "RecordInvalid", 223 "description": "Record validation errors", 224 }) 225 return 226 } 227 228 newTicket := &ticket{ 229 Status: "new", 230 Requester: req.Ticket.Requester, 231 Subject: req.Ticket.Subject, 232 Comments: []comment{req.Ticket.Comment}, 233 CustomFields: make(map[int64]string), 234 } 235 236 for _, cf := range req.Ticket.Custom { 237 newTicket.CustomFields[cf.ID] = fmt.Sprint(cf.Value) 238 } 239 240 ticketID := s.store.push(newTicket) 241 242 writeJSON(w, http.StatusCreated, map[string]any{ 243 "ticket": map[string]int64{"id": ticketID}, 244 }) 245 } 246 247 // PUT /api/v2/tickets/{id}.json 248 func (s *Server) updateTicket(w http.ResponseWriter, r *http.Request) { 249 match := ticketPathRegexp.FindStringSubmatch(r.URL.Path) 250 if len(match) != 2 { 251 writeJSON(w, http.StatusNotFound, map[string]any{ 252 "error": "RecordNotFound", 253 "description": "Not found", 254 }) 255 return 256 } 257 258 id, err := strconv.ParseInt(match[1], 10, 64) 259 if err != nil { 260 writeJSON(w, http.StatusNotFound, map[string]any{ 261 "error": "RecordNotFound", 262 "description": "Not found", 263 }) 264 return 265 } 266 267 var req struct { 268 Ticket struct { 269 Status string `json:"status"` 270 Comment comment `json:"comment"` 271 } `json:"ticket"` 272 } 273 274 err = json.NewDecoder(r.Body).Decode(&req) 275 if err != nil { 276 http.Error(w, "bad json", http.StatusBadRequest) 277 return 278 } 279 280 updateComment := req.Ticket.Comment.Body != "" 281 updateStatus := req.Ticket.Status != "" 282 283 if !updateComment && !updateStatus { 284 writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ 285 "error": "RecordInvalid", 286 "description": "Record validation errors", 287 "details": map[string]any{ 288 "comment": []map[string]string{ 289 {"description": "Comment body can't be blank"}, 290 }, 291 }, 292 }) 293 return 294 } 295 296 if updateComment { 297 err = s.store.addComment(id, req.Ticket.Comment) 298 if err != nil { 299 writeJSON(w, http.StatusNotFound, map[string]any{ 300 "error": "RecordNotFound", 301 "description": "Not found", 302 }) 303 return 304 } 305 } 306 307 if updateStatus && !slices.Contains(validStatuses, req.Ticket.Status) { 308 writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ 309 "error": "RecordInvalid", 310 "description": "invalid status", 311 }) 312 return 313 } 314 315 if updateStatus { 316 err = s.store.setStatus(id, strings.ToLower(req.Ticket.Status)) 317 if err != nil { 318 writeJSON(w, http.StatusNotFound, map[string]any{ 319 "error": "RecordNotFound", 320 "description": "Not found", 321 }) 322 return 323 } 324 } 325 326 writeJSON(w, http.StatusOK, map[string]any{ 327 "ticket": map[string]int64{"id": id}, 328 }) 329 } 330 331 // GET /api/v2/search.json?query=...&page=... 332 func (s *Server) search(w http.ResponseWriter, r *http.Request) { 333 queryParam := r.URL.Query().Get("query") 334 335 if !strings.Contains(queryParam, "type:ticket") { 336 writeJSON(w, http.StatusOK, map[string]any{ 337 "results": []any{}, 338 "next_page": nil, 339 "count": 0, 340 }) 341 return 342 } 343 344 var wantStatus string 345 if statusRegexp.MatchString(queryParam) { 346 m := statusRegexp.FindStringSubmatch(queryParam) 347 if len(m) == 2 { 348 wantStatus = strings.ToLower(m[1]) 349 if !slices.Contains(validStatuses, wantStatus) { 350 http.Error(w, "invalid status", http.StatusBadRequest) 351 return 352 } 353 } 354 } 355 356 type criterion struct { 357 fieldID int64 358 value string 359 } 360 361 if strings.Contains(queryParam, "custom_field_") && !customFieldRegexp.MatchString(queryParam) { 362 http.Error(w, "invalid custom field id", http.StatusBadRequest) 363 return 364 } 365 366 var criteria []criterion 367 matches := customFieldRegexp.FindAllStringSubmatch(queryParam, -1) 368 for _, match := range matches { 369 fieldID, err := strconv.ParseInt(match[1], 10, 64) 370 if err != nil { 371 http.Error(w, "invalid custom field id", http.StatusBadRequest) 372 return 373 } 374 criteria = append(criteria, criterion{ 375 fieldID: fieldID, 376 value: strings.Trim(match[2], `"`), 377 }) 378 } 379 380 s.store.Lock() 381 defer s.store.Unlock() 382 383 type resultRow struct { 384 id int64 385 fields []map[string]any 386 } 387 388 var resultRows []resultRow 389 resultRows = make([]resultRow, 0, len(s.store.stack)) 390 391 for _, ticket := range s.store.stack { 392 allMatch := true 393 if wantStatus != "" && strings.ToLower(ticket.Status) != wantStatus { 394 continue 395 } 396 for _, c := range criteria { 397 curr, ok := ticket.CustomFields[c.fieldID] 398 if !ok || curr != c.value { 399 allMatch = false 400 break 401 } 402 } 403 if !allMatch { 404 continue 405 } 406 407 var cf []map[string]any 408 for id, v := range ticket.CustomFields { 409 cf = append(cf, map[string]any{"id": id, "value": v}) 410 } 411 resultRows = append(resultRows, resultRow{id: ticket.ID, fields: cf}) 412 } 413 414 sort.Slice(resultRows, func(i, j int) bool { 415 return resultRows[i].id > resultRows[j].id 416 }) 417 418 const pageSize = 2 419 420 page := 1 421 pageStr := r.URL.Query().Get("page") 422 if pageStr != "" { 423 pageNum, err := strconv.Atoi(pageStr) 424 if err == nil && pageNum > 0 { 425 page = pageNum 426 } 427 } 428 429 total := len(resultRows) 430 start := min((page-1)*pageSize, total) 431 end := min(start+pageSize, total) 432 433 buildNextPageURL := func(currPage int) *string { 434 nextPage := currPage + 1 435 if (nextPage-1)*pageSize >= total { 436 return nil 437 } 438 u := url.URL{ 439 Scheme: "http", 440 Host: r.Host, 441 Path: r.URL.Path, 442 } 443 q := url.Values{} 444 q.Set("query", queryParam) 445 q.Set("page", strconv.Itoa(nextPage)) 446 u.RawQuery = q.Encode() 447 s := u.String() 448 return &s 449 } 450 451 encodedResults := make([]any, 0, end-start) 452 for _, row := range resultRows[start:end] { 453 encodedResults = append(encodedResults, map[string]any{ 454 "id": row.id, 455 "status": s.store.byID[row.id].Status, 456 "custom_fields": row.fields, 457 }) 458 } 459 460 writeJSON(w, http.StatusOK, map[string]any{ 461 "results": encodedResults, 462 "next_page": buildNextPageURL(page), 463 "count": total, 464 }) 465 } 466 467 // Handler returns an HTTP handler that serves the Zendesk fake API. 468 func (s *Server) Handler() http.Handler { 469 mux := http.NewServeMux() 470 mux.Handle(TicketsJSONPath, s.auth(http.HandlerFunc(s.createTicket))) 471 mux.Handle(SearchJSONPath, s.auth(http.HandlerFunc(s.search))) 472 mux.Handle(TicketsPath, s.auth(http.HandlerFunc(s.updateTicket))) 473 return mux 474 } 475 476 // GetTicket retrieves a ticket by its ID directly from the inner store. It 477 // returns a copy of the ticket to ensure that the original ticket in the store 478 // is never modified. If the ticket does not exist, it returns false. 479 func (s *Server) GetTicket(id int64) (ticket, bool) { 480 s.store.Lock() 481 defer s.store.Unlock() 482 t, ok := s.store.byID[id] 483 if !ok { 484 return ticket{}, false 485 } 486 cp := *t 487 cp.CustomFields = make(map[int64]string, len(t.CustomFields)) 488 maps.Copy(cp.CustomFields, t.CustomFields) 489 cp.Comments = append([]comment(nil), t.Comments...) 490 return cp, true 491 }