You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by ro...@apache.org on 2019/03/21 19:17:56 UTC

[trafficcontrol] branch master updated: Cache Config Parser (#3360)

This is an automated email from the ASF dual-hosted git repository.

rob pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new ab35846  Cache Config Parser (#3360)
ab35846 is described below

commit ab35846ef711a26c69280dd654fd1594054ce7c7
Author: Matthew Allen Moltzau <Ma...@comcast.com>
AuthorDate: Thu Mar 21 13:17:51 2019 -0600

    Cache Config Parser (#3360)
    
    * Initial work of cache config parser
    
    * Updated comments, renamed constants, and other minor edits
    
    * Cache config parser is turning out. Restructured the config error. I have comments to check a few things in ATS. The cache_config_test is broken atm.
    
    * Implemented time validators and cleaned up. Only a few comments now. Still fleshing out tests
    
    * Some changes required after referring to ATS
    
    The whole config line is case insensitive now.
    
    Made action mandantory
    
    * Finished positive and negative config tests
    
    * 100% test coverage!
    
    Finished test cases for cache config
    
    Wrote tests for time format validators
    
    * Added license headers
    
    * Added case where config has comment
    
    * Added some comments for go doc.
    
    Added PURGE and PUSH methods.
    
    Edited switch to allow fallthrough.
    
    * Updated according to PR comments
    
    * Removed dot imports
    * Added additional test cases
    * Fixed new test cases by using ^ and $ tokens in the regex
    * Using new package name to be more idiomatic to go
    * Tried to make a few things clearer
---
 .../testing/api/v14/config/cachecfg/cachecfg.go    | 260 +++++++++++++++++++++
 .../api/v14/config/cachecfg/cachecfg_test.go       | 222 ++++++++++++++++++
 traffic_ops/testing/api/v14/config/common.go       |  87 +++++++
 traffic_ops/testing/api/v14/config/common_test.go  | 110 +++++++++
 traffic_ops/testing/api/v14/config/error.go        |  79 +++++++
 .../testing/api/v14/config/table_test_structs.go   |  28 +++
 6 files changed, 786 insertions(+)

diff --git a/traffic_ops/testing/api/v14/config/cachecfg/cachecfg.go b/traffic_ops/testing/api/v14/config/cachecfg/cachecfg.go
new file mode 100644
index 0000000..af632e5
--- /dev/null
+++ b/traffic_ops/testing/api/v14/config/cachecfg/cachecfg.go
@@ -0,0 +1,260 @@
+/*
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package cachecfg
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/apache/trafficcontrol/traffic_ops/testing/api/v14/config"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/test"
+	"github.com/go-ozzo/ozzo-validation/is"
+)
+
+// Parse takes a string presumed to be an ATS cache.config and validates that it is
+// syntatically correct.
+//
+// The general format of a cache config is three types of labels separated by spaces:
+//
+//  primary_destination=value secondary_specifier=value action=value
+//
+// For a full description of how to format a cache config, refer to the ATS documentation
+// for the cache config:
+// https://docs.trafficserver.apache.org/en/latest/admin-guide/files/cache.config.en.html
+//
+func Parse(config string) test.Error {
+	lines := strings.Split(config, "\n")
+
+	if len(lines) == 1 {
+		return parseConfigRule(lines[0])
+	}
+
+	for i, ln := range lines {
+		err := parseConfigRule(ln)
+		if err != nil {
+			return err.Prepend("error on line %d: ", i+1)
+		}
+	}
+
+	return nil
+}
+
+func parsePrimaryDestinations(lhs string, rhs string) test.Error {
+
+	switch lhs {
+	case "dest_domain":
+		// dest_host is an alias for dest_domain
+		fallthrough
+	case "dest_host":
+		if err := is.Host.Validate(rhs); err != nil {
+			return config.ErrorContext.NewError(config.InvalidHost, `"%s" %v`, rhs, err)
+		}
+	case "dest_ip":
+		if err := is.IP.Validate(rhs); err != nil {
+			return config.ErrorContext.NewError(config.InvalidIP, `"%s" %v`, rhs, err)
+		}
+	case "host_regex":
+		fallthrough
+	case "url_regex":
+		// only makes sure the regex compiles, not that the regex generates anything valid
+		if _, err := regexp.Compile(rhs); err != nil {
+			return config.ErrorContext.NewError(config.InvalidRegex, "%v", err)
+		}
+	default:
+		return config.ErrorContext.NewError(config.InvalidLabel)
+	}
+
+	return nil
+}
+
+func parseSecondarySpecifiers(lhs string, rhs string) test.Error {
+
+	switch lhs {
+	case "port":
+		if err := is.Port.Validate(rhs); err != nil {
+			return config.ErrorContext.AddErrorCode(config.InvalidPort, err)
+		}
+	case "scheme":
+		if rhs != "http" && rhs != "https" {
+			return config.ErrorContext.NewError(config.InvalidHTTPScheme)
+		}
+	case "prefix":
+		// no clear validation to perform
+	case "suffix":
+		// examples: gif jpeg
+		// no clear validation to perform
+	case "method":
+		// assuming all methods are valid
+		// see RFC 2616-9 for list of all methods
+		// PURGE and PUSH are specific to ATS
+		switch rhs {
+		case "get":
+		case "put":
+		case "post":
+		case "delete":
+		case "trace":
+		case "options":
+		case "head":
+		case "connect":
+		case "patch":
+		case "purge":
+		case "push":
+		default:
+			return config.ErrorContext.NewError(config.UnknownMethod, `unknown method "%v"`, rhs)
+		}
+
+	case "time":
+		if err := config.Validate24HrTimeRange(rhs); err != nil {
+			return config.ErrorContext.AddErrorCode(config.InvalidTimeRange24Hr, err)
+		}
+	case "src_ip":
+		if err := is.IP.Validate(rhs); err != nil {
+			return config.ErrorContext.AddErrorCode(config.InvalidIP, err)
+		}
+	case "internal":
+		if rhs != "true" && rhs != "false" {
+			return config.ErrorContext.NewError(config.InvalidBool)
+		}
+
+	default:
+		return config.ErrorContext.NewError(config.InvalidLabel)
+	}
+
+	return nil
+}
+
+func parseActions(lhs string, rhs string) test.Error {
+
+	switch lhs {
+	case "action":
+		switch rhs {
+		case "never-cache":
+		case "ignore-no-cache":
+		case "ignore-client-no-cache":
+		case "ignore-server-no-cache":
+		default:
+			return config.ErrorContext.NewError(config.InvalidAction)
+		}
+
+	case "cache-responses-to-cookies":
+		digit := rhs[0]
+		if digit < '0' || '4' > digit || len(rhs) > 1 {
+			return config.ErrorContext.NewError(config.InvalidCacheCookieResponse)
+		}
+
+	// All of these are time formats
+	case "pin-in-cache":
+		fallthrough
+	case "revalidate":
+		fallthrough
+	case "ttl-in-cache":
+		err := config.ValidateDHMSTimeFormat(rhs)
+		if err != nil {
+			return config.ErrorContext.AddErrorCode(config.InvalidTimeFormatDHMS, err)
+		}
+	default:
+		return config.ErrorContext.NewError(config.InvalidLabel)
+	}
+
+	return nil
+}
+
+func parseConfigRule(rule string) test.Error {
+
+	var err test.Error
+
+	rule = strings.TrimSpace(rule)
+	if rule == "" || strings.HasPrefix(rule, "#") {
+		return nil
+	}
+
+	assignments := strings.Fields(rule)
+	last := len(assignments) - 1
+	if last < 1 {
+		return config.ErrorContext.NewError(config.NotEnoughAssignments)
+	}
+
+	// no individual secondary specifier label can be used twice
+	count := map[string]int{
+		"port":     0,
+		"scheme":   0,
+		"prefix":   0,
+		"suffix":   0,
+		"method":   0,
+		"time":     0,
+		"src_ip":   0,
+		"internal": 0,
+	}
+
+	// neither the rhs or lhs can contain any whitespace
+	assignment := regexp.MustCompile(`([a-z_\-\d]+)=(\S+)`)
+
+	destination := false
+	action := false
+
+	for _, elem := range assignments {
+		match := assignment.FindStringSubmatch(strings.ToLower(elem))
+		if match == nil {
+			return config.ErrorContext.NewError(config.BadAssignmentMatch, `could not match assignment: "%v"`, elem)
+		}
+
+		err = parsePrimaryDestinations(match[1], match[2])
+		if err == nil {
+			if destination {
+				return config.ErrorContext.NewError(config.ExcessLabel, "too many primary destination labels")
+			} else {
+				destination = true
+				continue
+			}
+		}
+		if err.Code() != config.InvalidLabel {
+			return err.Prepend(`coult not parse primary destination from "%s": `, match[0])
+		}
+
+		err = parseSecondarySpecifiers(match[1], match[2])
+		if err == nil {
+			if count[match[1]]++; count[match[1]] == 2 {
+				return config.ErrorContext.NewError(config.ExcessLabel, `the label "%s" can only be used once per rule`, match[1])
+			}
+			continue
+		}
+		if err.Code() != config.InvalidLabel {
+			return err.Prepend(`could not parse secondary specifier from "%s": `, match[0])
+		}
+
+		err = parseActions(match[1], match[2])
+		if err == nil {
+			action = true
+			continue
+		}
+
+		if err.Code() == config.InvalidLabel {
+			return err
+		} else {
+			return err.Prepend(`could not parse action from "%s": `, match[0])
+		}
+
+	}
+
+	if !destination {
+		return config.ErrorContext.NewError(config.MissingLabel, "missing primary destination label")
+	}
+
+	if !action {
+		return config.ErrorContext.NewError(config.MissingLabel, "missing action lablel")
+	}
+
+	return nil
+}
diff --git a/traffic_ops/testing/api/v14/config/cachecfg/cachecfg_test.go b/traffic_ops/testing/api/v14/config/cachecfg/cachecfg_test.go
new file mode 100644
index 0000000..db684a9
--- /dev/null
+++ b/traffic_ops/testing/api/v14/config/cachecfg/cachecfg_test.go
@@ -0,0 +1,222 @@
+/*
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package cachecfg_test
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/apache/trafficcontrol/traffic_ops/testing/api/v14/config"
+	"github.com/apache/trafficcontrol/traffic_ops/testing/api/v14/config/cachecfg"
+)
+
+var commonNegativeTests = []config.NegativeTest{
+	{
+		"too few assignments",
+		"foo1=foo",
+		config.NotEnoughAssignments,
+	},
+	{
+		"empty assignment",
+		"dest_domain= foo1=foo",
+		config.BadAssignmentMatch,
+	},
+	{
+		"missing equals in assignment",
+		"dest_domain foo1=foo",
+		config.BadAssignmentMatch,
+	},
+	{
+		"more than one primary destination",
+		"dest_domain=example dest_domain=example",
+		config.ExcessLabel,
+	},
+	{
+		"using a single secondary specifier twice",
+		"dest_domain=example scheme=http scheme=https",
+		config.ExcessLabel,
+	},
+	{
+		"missing primary destination label",
+		"port=80 method=get time=08:00-14:00",
+		config.MissingLabel,
+	},
+	{
+		"unknown primary field",
+		"foo1=foo foo2=foo foo3=foo",
+		config.InvalidLabel,
+	},
+	{
+		"good first line, but bad second",
+		fmt.Sprintf("%s\n\n%s",
+			"dest_domain=example.com suffix=js revalidate=1d",
+			"foo1=foo foo2=foo foo3=foo"),
+		config.InvalidLabel,
+	},
+}
+
+var primaryDestinationNegativeTests = []config.NegativeTest{
+	{
+		"bad url regex",
+		"url_regex=(example.com/.* foo2=foo",
+		config.InvalidRegex,
+	},
+	{
+		"bad host regex",
+		"host_regex=(example.* foo2=foo",
+		config.InvalidRegex,
+	},
+	{
+		"bad hostname",
+		"dest_domain=my%20bad%20domain.com foo1=foo",
+		config.InvalidHost,
+	},
+	{
+		"bad ip",
+		"dest_ip=bad_ip foo1=foo",
+		config.InvalidIP,
+	},
+}
+
+var secondarySpecifierNegativeTests = []config.NegativeTest{
+	{
+		"bad port",
+		"dest_domain=example port=90009000",
+		config.InvalidPort,
+	},
+	{
+		"bad scheme",
+		"dest_domain=example scheme=httpz",
+		config.InvalidHTTPScheme,
+	},
+	{
+		"bad method",
+		"dest_domain=example method=xxx",
+		config.UnknownMethod,
+	},
+	{
+		"bad time range",
+		"dest_domain=example time=16:00",
+		config.InvalidTimeRange24Hr,
+	},
+	{
+		"bad src ip",
+		"dest_domain=example src_ip=bad_ip",
+		config.InvalidIP,
+	},
+	{
+		"bad boolean value",
+		"dest_domain=example internal=xxx",
+		config.InvalidBool,
+	},
+}
+
+var actionNegativeTests = []config.NegativeTest{
+	{
+		"bad action value",
+		"dest_domain=example action=xxx",
+		config.InvalidAction,
+	},
+	{
+		"bad cache-responses-to-cookies",
+		"dest_domain=example cache-responses-to-cookies=42",
+		config.InvalidCacheCookieResponse,
+	},
+	{
+		"bad time format",
+		"dest_domain=example pin-in-cache=xxx",
+		config.InvalidTimeFormatDHMS,
+	},
+	{
+		"missing action label",
+		"dest_domain=example scheme=http",
+		config.MissingLabel,
+	},
+}
+
+var positiveTests = []config.PositiveTest{
+	{
+		"empty config",
+		"",
+	},
+	{
+		"empty multiline config",
+		"\n",
+	},
+	{
+		"empty config with whitespace",
+		"\t ",
+	},
+	{
+		"normal config returned from traffic ops",
+		fmt.Sprintf("%s\n%s",
+			"# DO NOT EDIT - Generated for ATS_EDGE_TIER_CACHE by Traffic Ops on Fri Feb 15 22:01:53 UTC 2019",
+			"dest_domain=origin.infra.ciab.test port=80 scheme=http action=never-cache"),
+	},
+	{
+		"tab-delimitted config",
+		"dest_domain=origin.infra.ciab.test\tport=80\tscheme=http\taction=never-cache",
+	},
+	{
+		"multi-space delimitted config",
+		"dest_domain=origin.infra.ciab.test  port=80  scheme=http  action=never-cache",
+	},
+	{
+		"multiline config with empty line",
+		fmt.Sprintf("%s\n\n%s",
+			"dest_domain=example.com suffix=js revalidate=1d",
+			"dest_domain=example.com prefix=foo suffix=js revalidate=7d"),
+	},
+	{
+		"many empty lines in config",
+		fmt.Sprintf("\n%s\n\n%s",
+			"dest_domain=example.com suffix=js revalidate=1d\n",
+			"dest_domain=example.com prefix=foo suffix=js revalidate=7d\n"),
+	},
+}
+
+func negativeTestDriver(tests []config.NegativeTest, t *testing.T) {
+	for _, test := range tests {
+		actual := cachecfg.Parse(test.Config)
+		if actual == nil || actual.Code() != test.Expected {
+			t.Errorf(`
+  config: "%v"
+  returned error: "%v"
+  error should be related to: %v`, test.Config, actual, test.Description)
+		}
+	}
+}
+
+func TestCacheConfig(t *testing.T) {
+
+	// Negative Tests
+	negativeTestDriver(commonNegativeTests, t)
+	negativeTestDriver(primaryDestinationNegativeTests, t)
+	negativeTestDriver(secondarySpecifierNegativeTests, t)
+	negativeTestDriver(actionNegativeTests, t)
+
+	// Positive Tests
+	for _, test := range positiveTests {
+		actual := cachecfg.Parse(test.Config)
+		if actual != nil {
+			t.Errorf(`
+  config: "%v"
+  returned error: "%v"
+  error should be nil
+  description: %v`, test.Config, actual, test.Description)
+		}
+
+	}
+}
diff --git a/traffic_ops/testing/api/v14/config/common.go b/traffic_ops/testing/api/v14/config/common.go
new file mode 100644
index 0000000..b21931e
--- /dev/null
+++ b/traffic_ops/testing/api/v14/config/common.go
@@ -0,0 +1,87 @@
+/*
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package config
+
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"time"
+)
+
+// militaryTimeFmt defines the 24hr format
+// see https://golang.org/pkg/time/#Parse
+const militaryTimeFmt = "15:04"
+
+// Validate24HrTimeRange determines whether the provided
+// string fits in a format such as "08:00-16:00".
+func Validate24HrTimeRange(rng string) error {
+	rangeFormat := regexp.MustCompile(`^(\S+)-(\S+)$`)
+	match := rangeFormat.FindStringSubmatch(rng)
+	if match == nil {
+		return fmt.Errorf("string %v is not a range", rng)
+	}
+
+	t1, err := time.Parse(militaryTimeFmt, match[1])
+	if err != nil {
+		return fmt.Errorf("time range must be a 24Hr format")
+	}
+
+	t2, err := time.Parse(militaryTimeFmt, match[2])
+	if err != nil {
+		return fmt.Errorf("second time range must be a 24Hr format")
+	}
+
+	if t1.After(t2) {
+		return fmt.Errorf("first time should be smaller than the second")
+	}
+
+	return nil
+}
+
+// ValidateDHMSTimeFormat determines whether the provided
+// string fits in a format such as "1d8h", where the valid
+// units are days, hours, minutes, and seconds.
+func ValidateDHMSTimeFormat(time string) error {
+
+	if time == "" {
+		return fmt.Errorf("time string cannot be empty")
+	}
+
+	dhms := regexp.MustCompile(`^(\d+)([dhms])(\S*)$`)
+	match := dhms.FindStringSubmatch(time)
+
+	if match == nil {
+		return fmt.Errorf("invalid time format")
+	}
+
+	var count = map[string]int{
+		"d": 0,
+		"h": 0,
+		"m": 0,
+		"s": 0,
+	}
+	for match != nil {
+		if _, err := strconv.Atoi(match[1]); err != nil {
+			return err
+		}
+		if count[match[2]]++; count[match[2]] == 2 {
+			return fmt.Errorf("%s unit specified multiple times", match[2])
+		}
+		match = dhms.FindStringSubmatch(match[3])
+	}
+
+	return nil
+}
diff --git a/traffic_ops/testing/api/v14/config/common_test.go b/traffic_ops/testing/api/v14/config/common_test.go
new file mode 100644
index 0000000..c03947d
--- /dev/null
+++ b/traffic_ops/testing/api/v14/config/common_test.go
@@ -0,0 +1,110 @@
+/*
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package config
+
+import "testing"
+
+func Test24HrTimeRange(t *testing.T) {
+
+	var tests = []struct {
+		time string
+		ok   bool
+	}{
+		{"15:04", false},
+		{"16:00-08:00", false},
+		{"16:00-8:00", false},
+		{"xxx-8:00", false},
+		{"8:00-xxx", false},
+		{"8:00-16:00", true},
+		{"08:00-16:00", true},
+		{"", false},
+		{" ", false},
+		{"-", false},
+		{"--", false},
+		{" - ", false},
+		{"asdf", false},
+		{"08:00-asdf", false},
+		{"asdf-08:00", false},
+		{"-08:00", false},
+		{"08:00-", false},
+		{"08-09:00", false},
+		{"09:00-10", false},
+		{"09:00-10:0", false},
+		{"9:00-10:0", false},
+		{"08:00-32:00", false},
+		{"32:00-33:00", false},
+		{"08:00--16:00", false},
+		{"08:00-16:00-", false},
+		{"08:00-16:00-17:00", false},
+		{"08:00-09:00 16:00-17:00", false},
+		{"foo 16:00-17:00", false},
+		{"16:00-17:00 foo", false},
+	}
+
+	for _, test := range tests {
+		if err := Validate24HrTimeRange(test.time); (err == nil) != test.ok {
+			if test.ok {
+				t.Errorf(`
+  test should have passed
+  time: %v
+  err: %v`, test.time, err)
+			} else {
+				t.Errorf(`
+  test should not have passed
+  time: %v`, test.time)
+			}
+		}
+	}
+
+}
+
+func TestDHMSTimeFormat(t *testing.T) {
+
+	var tests = []struct {
+		time string
+		ok   bool
+	}{
+		{"1s", true},
+		{"1m", true},
+		{"1h", true},
+		{"1d", true},
+		{"1d2h", true},
+		{"1m2s", true},
+		{"1d2h3m4s", true},
+		{"1s2h3m4d", true},
+		{"10000000000000000000000s", false},
+		{"1s2s", false},
+		{"1x", false},
+		{"1", false},
+		{"x", false},
+		{"", false},
+	}
+
+	for _, test := range tests {
+		if err := ValidateDHMSTimeFormat(test.time); (err == nil) != test.ok {
+			if test.ok {
+				t.Errorf(`
+  test should have passed
+  time: %v
+  err: %v`, test.time, err)
+			} else {
+				t.Errorf(`
+  test should not have passed
+  time: %v`, test.time)
+			}
+		}
+	}
+
+}
diff --git a/traffic_ops/testing/api/v14/config/error.go b/traffic_ops/testing/api/v14/config/error.go
new file mode 100644
index 0000000..708c821
--- /dev/null
+++ b/traffic_ops/testing/api/v14/config/error.go
@@ -0,0 +1,79 @@
+/*
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package config
+
+import "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/test"
+
+// Error codes:
+const (
+	BadAssignmentMatch = iota + 1
+	NotEnoughAssignments
+	ExcessLabel
+	InvalidLabel
+	MissingLabel
+	InvalidAction
+	InvalidBool
+	InvalidCacheCookieResponse
+	InvalidHTTPScheme
+	InvalidHost
+	InvalidIP
+	UnknownMethod
+	InvalidPort
+	InvalidRegex
+	InvalidTimeFormatDHMS
+	InvalidTimeRange24Hr
+)
+
+// ErrorContext contains the error codes mentioned above.
+// Any error made must have one of those error codes.
+var ErrorContext *test.ErrorContext
+
+func init() {
+	iterableErrorCodes := []uint{
+		BadAssignmentMatch,
+		NotEnoughAssignments,
+		ExcessLabel,
+		InvalidLabel,
+		MissingLabel,
+		InvalidAction,
+		InvalidBool,
+		InvalidCacheCookieResponse,
+		InvalidHTTPScheme,
+		InvalidHost,
+		InvalidIP,
+		UnknownMethod,
+		InvalidPort,
+		InvalidRegex,
+		InvalidTimeFormatDHMS,
+		InvalidTimeRange24Hr,
+	}
+
+	ErrorContext = test.NewErrorContext("cache config", iterableErrorCodes)
+
+	ErrorContext.SetDefaultMessageForCode(InvalidLabel,
+		"invalid label")
+	ErrorContext.SetDefaultMessageForCode(InvalidAction,
+		"invalid action")
+	ErrorContext.SetDefaultMessageForCode(NotEnoughAssignments,
+		"not enough assignments in rule")
+	ErrorContext.SetDefaultMessageForCode(InvalidHTTPScheme,
+		"invalid scheme (must be either http or https)")
+	ErrorContext.SetDefaultMessageForCode(InvalidBool,
+		"label must have a value of 'true' or 'false'")
+	ErrorContext.SetDefaultMessageForCode(InvalidCacheCookieResponse,
+		"Value for cache-responses-to-cookies must be an integer in the range 0..4")
+
+	ErrorContext.TurnPanicOn()
+}
diff --git a/traffic_ops/testing/api/v14/config/table_test_structs.go b/traffic_ops/testing/api/v14/config/table_test_structs.go
new file mode 100644
index 0000000..8ea3a0a
--- /dev/null
+++ b/traffic_ops/testing/api/v14/config/table_test_structs.go
@@ -0,0 +1,28 @@
+/*
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package config
+
+// NegativeTest is a struct for table tests.
+type NegativeTest struct {
+	Description string
+	Config      string
+	Expected    int
+}
+
+// PositiveTest is a struct for table tests.
+type PositiveTest struct {
+	Description string
+	Config      string
+}