// sentNotModified will check the provided modified timestamp against // either the X-If-Modified-Since or X-If-Unmodified-Since and return // true if it wrote to w func sentNotModified(w http.ResponseWriter, r *http.Request, modified int) (sentResponse bool) { ts, mHeaderType, err := extractModifiedTimestamp(r) if err != nil { sendRequestProblem(w, r, http.StatusBadRequest, err) return true } switch { case mHeaderType == X_IF_MODIFIED_SINCE && modified <= ts: w.Header().Set("X-Last-Modified", syncstorage.ModifiedToString(modified)) sendRequestProblem(w, r, http.StatusNotModified, errors.New("Not Modified")) return true case mHeaderType == X_IF_UNMODIFIED_SINCE && modified > ts: w.Header().Set("X-Last-Modified", syncstorage.ModifiedToString(modified)) sendRequestProblem(w, r, http.StatusPreconditionFailed, errors.Errorf("Condition requires %s, but modified at %s (△ %ss)", syncstorage.ModifiedToString(ts), syncstorage.ModifiedToString(modified), syncstorage.ModifiedToString(ts-modified))) return true } return false }
func (s *SyncUserHandler) hCollectionDELETE(w http.ResponseWriter, r *http.Request) { cId, err := s.getcid(r, false) if err != nil { if err == syncstorage.ErrNotFound { w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"modified":%s}`, syncstorage.ModifiedToString(syncstorage.Now())) return } else { InternalError(w, r, err) } return } cmodified, err := s.db.GetCollectionModified(cId) if err != nil { InternalError(w, r, err) return } else if sentNotModified(w, r, cmodified) { return } modified := syncstorage.Now() bids, idExists := r.URL.Query()["ids"] if idExists { bidlist := strings.Split(bids[0], ",") if len(bidlist) > s.config.MaxPOSTRecords { sendRequestProblem(w, r, http.StatusBadRequest, errors.New("Exceeded max batch size")) return } modified, err = s.db.DeleteBSOs(cId, bidlist...) if err != nil { InternalError(w, r, err) return } } else { err = s.db.DeleteCollection(cId) if err != nil { InternalError(w, r, err) return } } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"modified":%s}`, syncstorage.ModifiedToString(modified)) }
// hCollectionPOSTClassic is the historical POST handling logic prior to // the addition of atomic commits from multiple POST requests func (s *SyncUserHandler) hCollectionPOSTClassic(collectionId int, w http.ResponseWriter, r *http.Request) { bsoToBeProcessed, results, err := RequestToPostBSOInput(r, s.config.MaxRecordPayloadBytes) if err != nil { WeaveInvalidWBOError(w, r, errors.Wrap(err, "Failed turning POST body into BSO work list")) return } if len(bsoToBeProcessed) > s.config.MaxPOSTRecords { sendRequestProblem(w, r, http.StatusRequestEntityTooLarge, errors.Errorf("Exceed %d BSO per request", s.config.MaxPOSTRecords)) return } // Send the changes to the database and merge // with `results` above postResults, err := s.db.PostBSOs(collectionId, bsoToBeProcessed) if err != nil { InternalError(w, r, err) } else { for bsoId, failMessage := range postResults.Failed { results.Failed[bsoId] = failMessage } w.Header().Set("X-Last-Modified", syncstorage.ModifiedToString(postResults.Modified)) JsonNewline(w, r, &PostResults{ Modified: postResults.Modified, Success: postResults.Success, Failed: results.Failed, }) } }
func (s *SyncUserHandler) hInfoCollectionUsage(w http.ResponseWriter, r *http.Request) { if !AcceptHeaderOk(w, r) { return } modified, err := s.db.LastModified() if err != nil { InternalError(w, r, err) return } if sentNotModified(w, r, modified) { return } if results, err := s.db.InfoCollectionUsage(); err != nil { InternalError(w, r, err) return } else { // the sync 1.5 api says data should be in KB resultsKB := make(map[string]float64) for name, bytes := range results { resultsKB[name] = float64(bytes) / 1024 } m := syncstorage.ModifiedToString(modified) w.Header().Set("X-Last-Modified", m) JsonNewline(w, r, resultsKB) } }
// hInfoQuota calculates the total disk space used by the user by calculating // it based on the number of DB pages used * size of each page. // TODO actually implement quotas in the system. func (s *SyncUserHandler) hInfoQuota(w http.ResponseWriter, r *http.Request) { results, err := s.db.InfoCollectionUsage() if err != nil { InternalError(w, r, err) return } modified, err := s.db.LastModified() if err != nil { InternalError(w, r, err) return } if sentNotModified(w, r, modified) { return } used := 0 for _, bytes := range results { used += bytes } m := syncstorage.ModifiedToString(modified) w.Header().Set("X-Last-Modified", m) tmp := float64(used) / 1024 JsonNewline(w, r, []*float64{&tmp, nil}) // crazy pointer cause need the nil }
func (s *SyncUserHandler) hDeleteEverything(w http.ResponseWriter, r *http.Request) { err := s.db.DeleteEverything() if err != nil { InternalError(w, r, err) } else { m := syncstorage.ModifiedToString(syncstorage.Now()) w.Header().Set("Content-Type", "text/plain") w.Header().Set("X-Last-Modified", m) w.Write([]byte(m)) } }
func (s *SyncUserHandler) hBsoGET(w http.ResponseWriter, r *http.Request) { if !AcceptHeaderOk(w, r) { return } var ( bId string ok bool cId int err error bso *syncstorage.BSO ) if bId, ok = extractBsoIdFail(w, r); !ok { return } cId, err = s.getcid(r, false) if err != nil { if err == syncstorage.ErrNotFound { sendRequestProblem(w, r, http.StatusNotFound, errors.Wrap(err, "Collection Not Found")) } else { InternalError(w, r, err) } return } if bso, err = s.db.GetBSO(cId, bId); err == nil { if sentNotModified(w, r, bso.Modified) { return } log.WithFields(log.Fields{ "bso_t": bso.TTL, "now": syncstorage.Now(), "diff": syncstorage.Now() - bso.TTL, }).Debug("bso-expired") m := syncstorage.ModifiedToString(bso.Modified) w.Header().Set("X-Last-Modified", m) JsonNewline(w, r, bso) } else { if err == syncstorage.ErrNotFound { sendRequestProblem(w, r, http.StatusNotFound, errors.Wrap(err, "BSO Not Found")) } else { InternalError(w, r, err) } } }
func (s *SyncUserHandler) hInfoCollections(w http.ResponseWriter, r *http.Request) { if !AcceptHeaderOk(w, r) { return } if info, err := s.db.InfoCollections(); err != nil { InternalError(w, r, err) return } else { modified := 0 for _, modtime := range info { if modtime > modified { modified = modtime } } if sentNotModified(w, r, modified) { return } m := syncstorage.ModifiedToString(modified) w.Header().Set("X-Last-Modified", m) w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, "{") num := len(info) for name, modified := range info { fmt.Fprintf(w, `"%s":%s`, name, syncstorage.ModifiedToString(modified)) num-- if num != 0 { fmt.Fprint(w, ",") } } fmt.Fprint(w, "}") } }
func (s *SyncUserHandler) hBsoDELETE(w http.ResponseWriter, r *http.Request) { var ( bId string ok bool cId int modified int err error ) if bId, ok = extractBsoIdFail(w, r); !ok { return } cId, err = s.getcid(r, false) if err == syncstorage.ErrNotFound { sendRequestProblem(w, r, http.StatusNotAcceptable, errors.Wrap(err, "Could not find collection")) return } // Trying to delete a BSO that is not there // should 404 bso, err := s.db.GetBSO(cId, bId) if err != nil { if err == syncstorage.ErrNotFound { sendRequestProblem(w, r, http.StatusNotFound, errors.Errorf("BSO id: %s Not Found", bId)) } else { InternalError(w, r, err) } return } if sentNotModified(w, r, bso.Modified) { return } modified, err = s.db.DeleteBSO(cId, bso.Id) if err != nil { InternalError(w, r, err) } else { m := syncstorage.ModifiedToString(modified) w.Header().Set("Content-Type", "text/plain") w.Header().Set("X-Last-Modified", m) w.Write([]byte(m)) } }
// MarshalJSON manually creates the JSON string since the modified needs to be // converted in the python (ugh) timeformat required for sync 1.5. Which means no quotes func (p *PostResults) MarshalJSON() ([]byte, error) { buf := new(bytes.Buffer) buf.WriteString(`{"modified":`) buf.WriteString(syncstorage.ModifiedToString(p.Modified)) buf.WriteString(",") if len(p.Success) == 0 { buf.WriteString(`"success":[]`) } else { buf.WriteString(`"success":`) data, err := json.Marshal(p.Success) if err != nil { return nil, err } _, err = buf.Write(data) if err != nil { return nil, errors.Wrap(err, "Could not encode PostResults.Success") } } buf.WriteString(",") if len(p.Failed) == 0 { buf.WriteString(`"failed":{}`) } else { buf.WriteString(`"failed":`) data, err := json.Marshal(p.Failed) if err != nil { return nil, err } _, err = buf.Write(data) if err != nil { return nil, errors.Wrap(err, "Could not encode PostResults.Failed") } } if p.Batch != "" { buf.WriteString(`,"batch":"`) buf.WriteString(p.Batch) buf.WriteString(`"`) } buf.WriteString("}") return buf.Bytes(), nil }
func (s *SyncUserHandler) hInfoCollectionCounts(w http.ResponseWriter, r *http.Request) { if !AcceptHeaderOk(w, r) { return } results, err := s.db.InfoCollectionCounts() if err != nil { InternalError(w, r, err) return } modified, err := s.db.LastModified() if err != nil { InternalError(w, r, err) return } if sentNotModified(w, r, modified) { return } m := syncstorage.ModifiedToString(modified) w.Header().Set("X-Last-Modified", m) JsonNewline(w, r, results) }
// hCollectionPOSTBatch handles batch=? requests. It is called internally by hCollectionPOST // to handle batch request logic func (s *SyncUserHandler) hCollectionPOSTBatch(collectionId int, w http.ResponseWriter, r *http.Request) { // CHECK client provided headers to quickly determine if batch exceeds limits // this is meant to be a cheap(er) check without actually having to parse the // data provided by the user for _, headerName := range []string{"X-Weave-Total-Records", "X-Weave-Total-Bytes", "X-Weave-Records", "X-Weave-Bytes"} { if strVal := r.Header.Get(headerName); strVal != "" { if intVal, err := strconv.Atoi(strVal); err == nil { max := 0 switch headerName { case "X-Weave-Total-Records": max = s.config.MaxTotalRecords case "X-Weave-Total-Bytes": max = s.config.MaxTotalBytes case "X-Weave-Bytes": max = s.config.MaxPOSTBytes case "X-Weave-Records": max = s.config.MaxPOSTRecords } if intVal > max { WeaveSizeLimitExceeded(w, r, errors.Errorf("Limit %s exceed. %d/%d", headerName, intVal, max)) return } } else { // header value is invalid (not an int) sendRequestProblem(w, r, http.StatusBadRequest, errors.Errorf("Invalid integer value for %s", headerName)) return } } } // CHECK the POST size, if possible from client supplied data // hopefully shortcut a fail if this exceeds limits if r.ContentLength > 0 && r.ContentLength > int64(s.config.MaxPOSTBytes) { WeaveSizeLimitExceeded(w, r, errors.Errorf("MaxPOSTBytes exceeded in request.ContentLength(%d) > %d", r.ContentLength, s.config.MaxPOSTBytes)) return } // EXTRACT actual data to check bsoToBeProcessed, results, err := RequestToPostBSOInput(r, s.config.MaxRecordPayloadBytes) if err != nil { WeaveInvalidWBOError(w, r, errors.Wrap(err, "Failed turning POST body into BSO work list")) return } // CHECK actual BSOs sent to see if they exceed limits if len(bsoToBeProcessed) > s.config.MaxPOSTRecords { sendRequestProblem(w, r, http.StatusRequestEntityTooLarge, errors.Errorf("Exceeded %d BSO per request", s.config.MaxPOSTRecords)) return } // CHECK BSO decoding validation errors. Don't even start a Batch if there are. if len(results.Failed) > 0 { modified := syncstorage.Now() w.Header().Set("X-Last-Modified", syncstorage.ModifiedToString(modified)) JsonNewline(w, r, &PostResults{ Modified: modified, Success: nil, Failed: results.Failed, }) return } // Get batch id, commit command and internal collection Id _, batchId, batchCommit := GetBatchIdAndCommit(r) // CHECK batch id is valid for appends. Do this before loading and decoding // the body to be more efficient. if batchId != "true" { id, err := batchIdInt(batchId) if err != nil { sendRequestProblem(w, r, http.StatusBadRequest, errors.Wrap(err, "Invalid Batch ID Format")) return } if found, err := s.db.BatchExists(id, collectionId); err != nil { InternalError(w, r, err) } else if !found { sendRequestProblem(w, r, http.StatusBadRequest, errors.Errorf("Batch id: %s does not exist", batchId)) } } filteredBSOs := make([]*syncstorage.PutBSOInput, 0, len(bsoToBeProcessed)) failures := make(map[string][]string) for _, putInput := range bsoToBeProcessed { var failId string var failReason string if !syncstorage.BSOIdOk(putInput.Id) { failId = "na" failReason = fmt.Sprintf("Invalid BSO id %s", putInput.Id) } if putInput.SortIndex != nil && !syncstorage.SortIndexOk(*putInput.SortIndex) { failId = putInput.Id failReason = fmt.Sprintf("Invalid sort index for: %s", putInput.Id) } if putInput.TTL != nil && !syncstorage.TTLOk(*putInput.TTL) { failId = putInput.Id failReason = fmt.Sprintf("Invalid TTL for: %s", putInput.Id) } if failReason != "" { if failures[failId] == nil { failures[failId] = []string{failReason} } else { failures[failId] = append(failures[failId], failReason) } continue } filteredBSOs = append(filteredBSOs, putInput) } // JSON Serialize the data for storage in the DB buf := new(bytes.Buffer) if len(filteredBSOs) > 0 { encoder := json.NewEncoder(buf) for _, bso := range filteredBSOs { if err := encoder.Encode(bso); err != nil { // Note: this writes a newline after each record // whoa... presumably should never happen InternalError(w, r, errors.Wrap(err, "Failed encoding BSO for payload")) return } } } // Save either as a new batch or append to an existing batch var dbBatchId int // - batchIdInt used to track the internal batchId number in the database after // - the create || append appendedOkIds := make([]string, 0, len(filteredBSOs)) if batchId == "true" { newBatchId, err := s.db.BatchCreate(collectionId, buf.String()) if err != nil { InternalError(w, r, errors.Wrap(err, "Failed creating batch")) return } dbBatchId = newBatchId } else { id, err := batchIdInt(batchId) if err != nil { sendRequestProblem(w, r, http.StatusBadRequest, errors.Wrap(err, "Invalid Batch ID Format")) return } if len(filteredBSOs) > 0 { // append only if something to do if err := s.db.BatchAppend(id, collectionId, buf.String()); err != nil { InternalError(w, r, errors.Wrap(err, fmt.Sprintf("Failed append to batch id:%d", dbBatchId))) return } } dbBatchId = id } // The success list only includes the BSO Ids in the // POST request. These need to kept to be sent back in // the response. Matches the python server's behaviour / passes // the tests for _, bso := range filteredBSOs { appendedOkIds = append(appendedOkIds, bso.Id) } if batchCommit { batchRecord, err := s.db.BatchLoad(dbBatchId, collectionId) if err != nil { InternalError(w, r, errors.Wrap(err, "Failed Loading Batch to commit")) return } rawJSON := ReadNewlineJSON(bytes.NewBufferString(batchRecord.BSOS)) // CHECK final data before committing it to the database numInBatch := len(rawJSON) if numInBatch > s.config.MaxTotalRecords { s.db.BatchRemove(dbBatchId) WeaveSizeLimitExceeded(w, r, errors.Errorf("Too many BSOs (%d) in Batch(%d)", numInBatch, dbBatchId)) return } postData := make(syncstorage.PostBSOInput, len(rawJSON), len(rawJSON)) for i, bsoJSON := range rawJSON { var bso syncstorage.PutBSOInput if parseErr := parseIntoBSO(bsoJSON, &bso); parseErr != nil { // well there is definitely a bug somewhere if this happens InternalError(w, r, errors.Wrap(parseErr, "Could not decode batch data")) return } else { postData[i] = &bso } } // CHECK that actual Batch data size sum := 0 for _, bso := range postData { if bso.Payload == nil { continue } sum := sum + len(*bso.Payload) if sum > s.config.MaxTotalBytes { s.db.BatchRemove(dbBatchId) WeaveSizeLimitExceeded(w, r, errors.Errorf("Batch size(%d) exceeded MaxTotalBytes limit(%d)", sum, s.config.MaxTotalBytes)) return } } postResults, err := s.db.PostBSOs(collectionId, postData) if err != nil { InternalError(w, r, err) return } // merge failures for key, reasons := range postResults.Failed { if failures[key] == nil { failures[key] = reasons } else { failures[key] = append(failures[key], reasons...) } } // DELETE the batch from the DB s.db.BatchRemove(dbBatchId) w.Header().Set("X-Last-Modified", syncstorage.ModifiedToString(postResults.Modified)) JsonNewline(w, r, &PostResults{ Modified: postResults.Modified, Success: appendedOkIds, Failed: failures, }) } else { modified := syncstorage.Now() w.Header().Set("X-Last-Modified", syncstorage.ModifiedToString(modified)) JsonNewlineStatus(w, r, http.StatusAccepted, &PostResults{ Batch: batchIdString(dbBatchId), Modified: modified, Success: appendedOkIds, Failed: failures, }) } }
func (s *SyncUserHandler) hCollectionGET(w http.ResponseWriter, r *http.Request) { if !AcceptHeaderOk(w, r) { return } // query params that control searching var ( err error ids []string newer int older int full bool limit int offset int sort = syncstorage.SORT_NEWEST ) cId, err := s.getcid(r, false) if err != nil { if err == syncstorage.ErrNotFound { w.Header().Set("Content-Type", "application/json") w.Write([]byte("[]")) return } else { InternalError(w, r, err) return } } if err = r.ParseForm(); err != nil { sendRequestProblem(w, r, http.StatusBadRequest, errors.Wrap(err, "Bad query parameters")) return } if v := r.Form.Get("ids"); v != "" { ids = strings.Split(v, ",") if len(ids) > s.config.MaxPOSTRecords { sendRequestProblem(w, r, http.StatusBadRequest, errors.New("Exceeded max batch size")) return } for i, id := range ids { id = strings.TrimSpace(id) if syncstorage.BSOIdOk(id) { ids[i] = id } else { sendRequestProblem(w, r, http.StatusBadRequest, errors.Errorf("Invalid bso id %s", id)) return } } if len(ids) > 100 { sendRequestProblem(w, r, http.StatusBadRequest, errors.New("Too many ids provided")) return } } // we expect to get sync's two decimal timestamps, these need // to be converted to milliseconds if v := r.Form.Get("older"); v != "" { floatNew, err := strconv.ParseFloat(v, 64) if err != nil { sendRequestProblem(w, r, http.StatusBadRequest, errors.Wrap(err, "Invalid older param format")) return } older = int(floatNew * 1000) if !syncstorage.NewerOk(newer) { sendRequestProblem(w, r, http.StatusBadRequest, errors.New("Invalid older value")) return } } else { older = syncstorage.MaxTimestamp } if v := r.Form.Get("newer"); v != "" { floatNew, err := strconv.ParseFloat(v, 64) if err != nil { sendRequestProblem(w, r, http.StatusBadRequest, errors.Wrap(err, "Invalid newer param format")) return } newer = int(floatNew * 1000) if !syncstorage.NewerOk(newer) { sendRequestProblem(w, r, http.StatusBadRequest, errors.New("Invalid newer value")) return } } if v := r.Form.Get("full"); v != "" { full = true } if v := r.Form.Get("limit"); v != "" { limit, err = strconv.Atoi(v) if err != nil || !syncstorage.LimitOk(limit) { errMessage := "Invalid limit value" if err != nil { err = errors.Wrap(err, errMessage) } else { err = errors.New(errMessage) } sendRequestProblem(w, r, http.StatusBadRequest, err) return } } // assign a default value for limit if nothing is supplied if limit <= 0 || limit > s.config.MaxBSOGetLimit { limit = s.config.MaxBSOGetLimit } if v := r.Form.Get("offset"); v != "" { offset, err = strconv.Atoi(v) if err != nil || !syncstorage.OffsetOk(offset) { errMessage := "Invalid offset value" if err != nil { err = errors.Wrap(err, errMessage) } else { err = errors.New(errMessage) } sendRequestProblem(w, r, http.StatusBadRequest, err) return } } if v := r.Form.Get("sort"); v != "" { switch v { case "newest": sort = syncstorage.SORT_NEWEST case "oldest": sort = syncstorage.SORT_OLDEST case "index": sort = syncstorage.SORT_INDEX default: sendRequestProblem(w, r, http.StatusBadRequest, errors.New("Invalid sort value")) return } } // this is way down here since IO is more expensive // than parsing if the GET params are valid cmodified, err := s.db.GetCollectionModified(cId) if err != nil { InternalError(w, r, err) return } else if sentNotModified(w, r, cmodified) { return } results, err := s.db.GetBSOs(cId, ids, older, newer, sort, limit, offset) if err != nil { InternalError(w, r, err) return } m := syncstorage.ModifiedToString(cmodified) w.Header().Set("X-Last-Modified", m) w.Header().Set("X-Weave-Records", strconv.Itoa(results.Total)) if results.More { w.Header().Set("X-Weave-Next-Offset", strconv.Itoa(results.Offset)) } if full { JsonNewline(w, r, results.BSOs) } else { bsoIds := make([]string, len(results.BSOs)) for i, b := range results.BSOs { bsoIds[i] = b.Id } JsonNewline(w, r, bsoIds) } }
func (s *SyncUserHandler) hBsoPUT(w http.ResponseWriter, r *http.Request) { if !AcceptHeaderOk(w, r) { return } // accept text/plain from old (broken) clients ct := getMediaType(r.Header.Get("Content-Type")) if ct != "application/json" && ct != "text/plain" && ct != "application/newlines" { sendRequestProblem(w, r, http.StatusUnsupportedMediaType, errors.Errorf("Not acceptable Content-Type: %s", ct)) return } var ( bId string ok bool cId int modified int err error ) if bId, ok = extractBsoIdFail(w, r); !ok { return } cId, err = s.getcid(r, true) if err != nil { InternalError(w, r, err) return } modified, err = s.db.GetBSOModified(cId, bId) if err != nil { if err != syncstorage.ErrNotFound { InternalError(w, r, errors.Wrap(err, "Could not get Modified ts")) return } } if sentNotModified(w, r, modified) { return } body, err := ioutil.ReadAll(r.Body) if err != nil { InternalError(w, r, errors.New("PUT could not read JSON body")) return } var bso syncstorage.PutBSOInput if err := parseIntoBSO(body, &bso); err != nil { WeaveInvalidWBOError(w, r, errors.Wrap(err, "Could not parse body into BSO")) return } if bso.Payload != nil && len(*bso.Payload) > s.config.MaxRecordPayloadBytes { sendRequestProblem(w, r, http.StatusRequestEntityTooLarge, errors.New("Payload too big")) return } // change bso.TTL to milliseconds (what the db uses) // from seconds (what client's send) if bso.TTL != nil { tmp := *bso.TTL * 1000 bso.TTL = &tmp } modified, err = s.db.PutBSO(cId, bId, bso.Payload, bso.SortIndex, bso.TTL) if err != nil { sendRequestProblem(w, r, http.StatusBadRequest, err) return } m := syncstorage.ModifiedToString(modified) w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Last-Modified", m) w.Write([]byte(m)) }
"strings" "testing" "time" "github.com/mozilla-services/go-syncstorage/syncstorage" "github.com/mozilla-services/go-syncstorage/token" "github.com/stretchr/testify/assert" ) // generates a unique response each time so caching can be tested var cacheMockHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { resp := struct { Type string Value string }{"OK", syncstorage.ModifiedToString(syncstorage.Now())} w.Header().Set("X-Last-Modified", resp.Value) if infoCollectionsRoute.MatchString(req.URL.Path) { resp.Type = "info/collections" JSON(w, req, http.StatusOK, resp) } else if infoConfigurationRoute.MatchString(req.URL.Path) { resp.Type = "info/configuration" JSON(w, req, http.StatusOK, resp) } else { JSON(w, req, http.StatusOK, resp) } }) func TestCacheHandlerInfoCollections(t *testing.T) {