Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
3 changes: 3 additions & 0 deletions validator/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/GoogleChrome/private-tokens/validator

go 1.21
126 changes: 126 additions & 0 deletions validator/validator_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
Loading