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  }