From f3f62f167a7e642720006251f693be4f687bc9a1 Mon Sep 17 00:00:00 2001 From: Aykut Bulut Date: Wed, 24 Jun 2026 12:47:26 -0400 Subject: [PATCH] Add workflow for validator test Fix typo in pvt-issuers.json. Add validator workflow that verifies pvt-issuers.json format and values. --- .github/workflows/ci.yml | 28 ++++++++ pvt-issuers.json | 2 +- validator/go.mod | 3 + validator/validator_test.go | 126 ++++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 validator/go.mod create mode 100644 validator/validator_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..65ef584 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI Validation + +on: + pull_request: + paths: + - 'pvt-issuers.json' + - 'validator/**' + - '.github/workflows/ci.yml' + push: + branches: + - main + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Set up Go + uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 + with: + go-version: '1.21' + cache-dependency-path: 'validator/go.sum' # Go caching is helpful + + - name: Run Tests + working-directory: ./validator + run: go test -v ./... diff --git a/pvt-issuers.json b/pvt-issuers.json index 1704a6b..b8aa1e5 100644 --- a/pvt-issuers.json +++ b/pvt-issuers.json @@ -3,7 +3,7 @@ "name": "Chromium PVT Demo Key Commitment Endpoint", "contact": "private-verification-tokens-chrome-team@google.com", "endpoint": "https://privatetokens.dev/.well-known/private-verification-token/key-commitment", - "deplotmentID": "PrivateTokens_Chrome_PVT-DEMO-2026", + "deploymentID": "PrivateTokens_Chrome_PVT-DEMO-2026", "batchSize": 10, "expiry": "1796860800" } diff --git a/validator/go.mod b/validator/go.mod new file mode 100644 index 0000000..08218f1 --- /dev/null +++ b/validator/go.mod @@ -0,0 +1,3 @@ +module github.com/GoogleChrome/private-tokens/validator + +go 1.21 diff --git a/validator/validator_test.go b/validator/validator_test.go new file mode 100644 index 0000000..ab9f468 --- /dev/null +++ b/validator/validator_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json" + "net/url" + "os" + "strconv" + "strings" + "testing" + "time" +) + +const ( + maxDeploymentIDLength = 100 + maxExpiryMonths = 6 + minBatchSize = 2 + maxBatchSize = 20 +) + +// PVTIssuer represents the structure of each entry in pvt-issuers.json +type PVTIssuer struct { + Name string `json:"name"` + Contact string `json:"contact"` + Endpoint string `json:"endpoint"` + DeploymentID string `json:"deploymentID"` + BatchSize int `json:"batchSize"` + Expiry string `json:"expiry"` +} + +// assertSecureURL verifies that a URL conforms to security requirements. +func assertSecureURL(t *testing.T, rawURL, label string) *url.URL { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("%s URL %q is invalid: %v", label, rawURL, err) + } + if parsed.Scheme != "https" || parsed.Host == "" { + t.Errorf("%s URL %q must be a valid HTTPS URL", label, rawURL) + } + if parsed.Opaque != "" { + t.Errorf("%s URL %q must not be opaque", label, rawURL) + } + if parsed.OmitHost { + t.Errorf("%s URL %q must not omit host", label, rawURL) + } + if parsed.User != nil { + t.Errorf("%s URL %q must not contain user info", label, rawURL) + } + if parsed.RawQuery != "" || parsed.ForceQuery { + t.Errorf("%s URL %q must not contain queries", label, rawURL) + } + if parsed.RawFragment != "" { + t.Errorf("%s URL %q must not contain fragments", label, rawURL) + } + return parsed +} + +func TestValidatePVTIssuers(t *testing.T) { + data, err := os.ReadFile("../pvt-issuers.json") + if err != nil { + t.Fatalf("Failed to read pvt-issuers.json: %v", err) + } + + var issuers map[string]PVTIssuer + if err := json.Unmarshal(data, &issuers); err != nil { + t.Fatalf("pvt-issuers.json is not valid JSON: %v", err) + } + + for origin, issuer := range issuers { + t.Run(origin, func(t *testing.T) { + // 1. Validate Origin URL + parsedOrigin := assertSecureURL(t, origin, "Origin") + + // 2. Validate Name + if strings.TrimSpace(issuer.Name) == "" { + t.Error("Name cannot be empty") + } + + // 3. Validate Contact + if !strings.Contains(issuer.Contact, "@") { + t.Errorf("Contact %q must be a valid email address", issuer.Contact) + } + + // 4. Validate Endpoint URL + parsedEndpoint := assertSecureURL(t, issuer.Endpoint, "Endpoint") + + // 5. Compare Origin and Endpoint + if parsedOrigin != nil && parsedEndpoint != nil { + if parsedEndpoint.Scheme != parsedOrigin.Scheme { + t.Errorf("Endpoint scheme %q does not match origin scheme %q", parsedEndpoint.Scheme, parsedOrigin.Scheme) + } + if parsedEndpoint.Host != parsedOrigin.Host { + t.Errorf("Endpoint host %q does not match origin host %q", parsedEndpoint.Host, parsedOrigin.Host) + } + } + + // 6. Validate DeploymentID + if strings.TrimSpace(issuer.DeploymentID) == "" { + t.Error("deplotmentID cannot be empty") + } else if len(issuer.DeploymentID) > maxDeploymentIDLength { + t.Errorf("deplotmentID %q must be at most %d characters, got %d", issuer.DeploymentID, maxDeploymentIDLength, len(issuer.DeploymentID)) + } + + // 7. Validate BatchSize + if issuer.BatchSize < minBatchSize || issuer.BatchSize > maxBatchSize { + t.Errorf("batchSize %d must be between %d and %d", issuer.BatchSize, minBatchSize, maxBatchSize) + } + + // 8. Validate Expiry + expiryUnix, err := strconv.ParseInt(issuer.Expiry, 10, 64) + if err != nil { + t.Errorf("Expiry %q is not a valid Unix timestamp", issuer.Expiry) + } else { + expiryTime := time.Unix(expiryUnix, 0) + now := time.Now() + if expiryTime.Before(now) { + t.Errorf("Issuer config has expired (Expiry: %s)", expiryTime.Format(time.RFC3339)) + } + maxExpiry := now.AddDate(0, maxExpiryMonths, 0) + if expiryTime.After(maxExpiry) { + t.Errorf("Expiry %s is too far in the future (max %d months from now)", expiryTime.Format(time.RFC3339), maxExpiryMonths) + } + } + }) + } +}