github.com/letsencrypt/boulder@v0.20251208.0/ratelimits/limit_test.go (about)

     1  package ratelimits
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"net/netip"
     8  	"os"
     9  	"path/filepath"
    10  	"slices"
    11  	"strings"
    12  	"testing"
    13  	"time"
    14  
    15  	"github.com/prometheus/client_golang/prometheus"
    16  	io_prometheus_client "github.com/prometheus/client_model/go"
    17  
    18  	"github.com/letsencrypt/boulder/config"
    19  	"github.com/letsencrypt/boulder/core"
    20  	"github.com/letsencrypt/boulder/identifier"
    21  	blog "github.com/letsencrypt/boulder/log"
    22  	"github.com/letsencrypt/boulder/metrics"
    23  	"github.com/letsencrypt/boulder/test"
    24  )
    25  
    26  // loadAndParseDefaultLimits is a helper that calls both loadDefaults and
    27  // parseDefaultLimits to handle a YAML file.
    28  //
    29  // TODO(#7901): Update the tests to test these functions individually.
    30  func loadAndParseDefaultLimits(path string) (Limits, error) {
    31  	fromFile, err := loadDefaultsFromFile(path)
    32  	if err != nil {
    33  		return nil, err
    34  	}
    35  
    36  	return parseDefaultLimits(fromFile)
    37  }
    38  
    39  // loadAndParseOverrideLimitsFromFile is a helper that calls both
    40  // loadOverridesFromFile and parseOverrideLimits to handle a YAML file.
    41  //
    42  // TODO(#7901): Update the tests to test these functions individually.
    43  func loadAndParseOverrideLimitsFromFile(path string) (Limits, error) {
    44  	fromFile, err := loadOverridesFromFile(path)
    45  	if err != nil {
    46  		return nil, err
    47  	}
    48  
    49  	return parseOverrideLimits(fromFile)
    50  }
    51  
    52  func TestParseOverrideNameId(t *testing.T) {
    53  	// 'enum:ipv4'
    54  	// Valid IPv4 address.
    55  	name, id, err := parseOverrideNameId(NewRegistrationsPerIPAddress.String() + ":10.0.0.1")
    56  	test.AssertNotError(t, err, "should not error")
    57  	test.AssertEquals(t, name, NewRegistrationsPerIPAddress)
    58  	test.AssertEquals(t, id, "10.0.0.1")
    59  
    60  	// 'enum:ipv6range'
    61  	// Valid IPv6 address range.
    62  	name, id, err = parseOverrideNameId(NewRegistrationsPerIPv6Range.String() + ":2602:80a:6000::/48")
    63  	test.AssertNotError(t, err, "should not error")
    64  	test.AssertEquals(t, name, NewRegistrationsPerIPv6Range)
    65  	test.AssertEquals(t, id, "2602:80a:6000::/48")
    66  
    67  	// Missing colon (this should never happen but we should avoid panicking).
    68  	_, _, err = parseOverrideNameId(NewRegistrationsPerIPAddress.String() + "10.0.0.1")
    69  	test.AssertError(t, err, "missing colon")
    70  
    71  	// Empty string.
    72  	_, _, err = parseOverrideNameId("")
    73  	test.AssertError(t, err, "empty string")
    74  
    75  	// Only a colon.
    76  	_, _, err = parseOverrideNameId(NewRegistrationsPerIPAddress.String() + ":")
    77  	test.AssertError(t, err, "only a colon")
    78  
    79  	// Invalid enum.
    80  	_, _, err = parseOverrideNameId("lol:noexist")
    81  	test.AssertError(t, err, "invalid enum")
    82  }
    83  
    84  func TestParseOverrideNameEnumId(t *testing.T) {
    85  	t.Parallel()
    86  
    87  	tests := []struct {
    88  		name        string
    89  		input       string
    90  		wantLimit   Name
    91  		wantId      string
    92  		expectError bool
    93  	}{
    94  		{
    95  			name:        "valid IPv4 address",
    96  			input:       NewRegistrationsPerIPAddress.EnumString() + ":10.0.0.1",
    97  			wantLimit:   NewRegistrationsPerIPAddress,
    98  			wantId:      "10.0.0.1",
    99  			expectError: false,
   100  		},
   101  		{
   102  			name:        "valid IPv6 address range",
   103  			input:       NewRegistrationsPerIPv6Range.EnumString() + ":2001:0db8:0000::/48",
   104  			wantLimit:   NewRegistrationsPerIPv6Range,
   105  			wantId:      "2001:0db8:0000::/48",
   106  			expectError: false,
   107  		},
   108  		{
   109  			name:        "missing colon",
   110  			input:       NewRegistrationsPerIPAddress.EnumString() + "10.0.0.1",
   111  			expectError: true,
   112  		},
   113  		{
   114  			name:        "empty string",
   115  			input:       "",
   116  			expectError: true,
   117  		},
   118  		{
   119  			name:        "only a colon",
   120  			input:       NewRegistrationsPerIPAddress.EnumString() + ":",
   121  			expectError: true,
   122  		},
   123  		{
   124  			name:        "invalid enum",
   125  			input:       "lol:noexist",
   126  			expectError: true,
   127  		},
   128  	}
   129  
   130  	for _, tc := range tests {
   131  		t.Run(tc.name, func(t *testing.T) {
   132  			limit, id, err := parseOverrideNameEnumId(tc.input)
   133  			if tc.expectError {
   134  				if err == nil {
   135  					t.Errorf("expected error for input %q, but got none", tc.input)
   136  				}
   137  			} else {
   138  				test.AssertNotError(t, err, tc.name)
   139  				test.AssertEquals(t, limit, tc.wantLimit)
   140  				test.AssertEquals(t, id, tc.wantId)
   141  			}
   142  		})
   143  	}
   144  }
   145  
   146  func TestValidateLimit(t *testing.T) {
   147  	err := ValidateLimit(&Limit{Burst: 1, Count: 1, Period: config.Duration{Duration: time.Second}})
   148  	test.AssertNotError(t, err, "valid limit")
   149  
   150  	// All of the following are invalid.
   151  	for _, l := range []*Limit{
   152  		{Burst: 0, Count: 1, Period: config.Duration{Duration: time.Second}},
   153  		{Burst: 1, Count: 0, Period: config.Duration{Duration: time.Second}},
   154  		{Burst: 1, Count: 1, Period: config.Duration{Duration: 0}},
   155  	} {
   156  		err = ValidateLimit(l)
   157  		test.AssertError(t, err, "limit should be invalid")
   158  	}
   159  }
   160  
   161  func TestLoadAndParseOverrideLimitsFromFile(t *testing.T) {
   162  	// Load a single valid override limit with Id formatted as 'enum:RegId'.
   163  	l, err := loadAndParseOverrideLimitsFromFile("testdata/working_override.yml")
   164  	test.AssertNotError(t, err, "valid single override limit")
   165  	expectKey := joinWithColon(NewRegistrationsPerIPAddress.EnumString(), "64.112.117.1")
   166  	test.AssertEquals(t, l[expectKey].Burst, int64(40))
   167  	test.AssertEquals(t, l[expectKey].Count, int64(40))
   168  	test.AssertEquals(t, l[expectKey].Period.Duration, time.Second)
   169  
   170  	// Load single valid override limit with a 'domainOrCIDR' Id.
   171  	l, err = loadAndParseOverrideLimitsFromFile("testdata/working_override_regid_domainorcidr.yml")
   172  	test.AssertNotError(t, err, "valid single override limit with Id of regId:domainOrCIDR")
   173  	expectKey = joinWithColon(CertificatesPerDomain.EnumString(), "example.com")
   174  	test.AssertEquals(t, l[expectKey].Burst, int64(40))
   175  	test.AssertEquals(t, l[expectKey].Count, int64(40))
   176  	test.AssertEquals(t, l[expectKey].Period.Duration, time.Second)
   177  
   178  	// Load multiple valid override limits with 'regId' Ids.
   179  	l, err = loadAndParseOverrideLimitsFromFile("testdata/working_overrides.yml")
   180  	test.AssertNotError(t, err, "multiple valid override limits")
   181  	expectKey1 := joinWithColon(NewRegistrationsPerIPAddress.EnumString(), "64.112.117.1")
   182  	test.AssertEquals(t, l[expectKey1].Burst, int64(40))
   183  	test.AssertEquals(t, l[expectKey1].Count, int64(40))
   184  	test.AssertEquals(t, l[expectKey1].Period.Duration, time.Second)
   185  	expectKey2 := joinWithColon(NewRegistrationsPerIPv6Range.EnumString(), "2602:80a:6000::/48")
   186  	test.AssertEquals(t, l[expectKey2].Burst, int64(50))
   187  	test.AssertEquals(t, l[expectKey2].Count, int64(50))
   188  	test.AssertEquals(t, l[expectKey2].Period.Duration, time.Second*2)
   189  
   190  	// Load multiple valid override limits with 'fqdnSet' Ids, as follows:
   191  	//   - CertificatesPerFQDNSet:example.com
   192  	//   - CertificatesPerFQDNSet:example.com,example.net
   193  	//   - CertificatesPerFQDNSet:example.com,example.net,example.org
   194  	entryKey1 := newFQDNSetBucketKey(CertificatesPerFQDNSet, identifier.NewDNSSlice([]string{"example.com"}))
   195  	entryKey2 := newFQDNSetBucketKey(CertificatesPerFQDNSet, identifier.NewDNSSlice([]string{"example.com", "example.net"}))
   196  	entryKey3 := newFQDNSetBucketKey(CertificatesPerFQDNSet, identifier.NewDNSSlice([]string{"example.com", "example.net", "example.org"}))
   197  	entryKey4 := newFQDNSetBucketKey(CertificatesPerFQDNSet, identifier.ACMEIdentifiers{
   198  		identifier.NewIP(netip.MustParseAddr("2602:80a:6000::1")),
   199  		identifier.NewIP(netip.MustParseAddr("9.9.9.9")),
   200  		identifier.NewDNS("example.com"),
   201  	})
   202  
   203  	l, err = loadAndParseOverrideLimitsFromFile("testdata/working_overrides_regid_fqdnset.yml")
   204  	test.AssertNotError(t, err, "multiple valid override limits with 'fqdnSet' Ids")
   205  	test.AssertEquals(t, l[entryKey1].Burst, int64(40))
   206  	test.AssertEquals(t, l[entryKey1].Count, int64(40))
   207  	test.AssertEquals(t, l[entryKey1].Period.Duration, time.Second)
   208  	test.AssertEquals(t, l[entryKey2].Burst, int64(50))
   209  	test.AssertEquals(t, l[entryKey2].Count, int64(50))
   210  	test.AssertEquals(t, l[entryKey2].Period.Duration, time.Second*2)
   211  	test.AssertEquals(t, l[entryKey3].Burst, int64(60))
   212  	test.AssertEquals(t, l[entryKey3].Count, int64(60))
   213  	test.AssertEquals(t, l[entryKey3].Period.Duration, time.Second*3)
   214  	test.AssertEquals(t, l[entryKey4].Burst, int64(60))
   215  	test.AssertEquals(t, l[entryKey4].Count, int64(60))
   216  	test.AssertEquals(t, l[entryKey4].Period.Duration, time.Second*4)
   217  
   218  	// Path is empty string.
   219  	_, err = loadAndParseOverrideLimitsFromFile("")
   220  	test.AssertError(t, err, "path is empty string")
   221  	test.Assert(t, os.IsNotExist(err), "path is empty string")
   222  
   223  	// Path to file which does not exist.
   224  	_, err = loadAndParseOverrideLimitsFromFile("testdata/file_does_not_exist.yml")
   225  	test.AssertError(t, err, "a file that does not exist ")
   226  	test.Assert(t, os.IsNotExist(err), "test file should not exist")
   227  
   228  	// Burst cannot be 0.
   229  	_, err = loadAndParseOverrideLimitsFromFile("testdata/busted_override_burst_0.yml")
   230  	test.AssertError(t, err, "single override limit with burst=0")
   231  	test.AssertContains(t, err.Error(), "invalid burst")
   232  
   233  	// Id cannot be empty.
   234  	_, err = loadAndParseOverrideLimitsFromFile("testdata/busted_override_empty_id.yml")
   235  	test.AssertError(t, err, "single override limit with empty id")
   236  	test.Assert(t, !os.IsNotExist(err), "test file should exist")
   237  
   238  	// Name cannot be empty.
   239  	_, err = loadAndParseOverrideLimitsFromFile("testdata/busted_override_empty_name.yml")
   240  	test.AssertError(t, err, "single override limit with empty name")
   241  	test.Assert(t, !os.IsNotExist(err), "test file should exist")
   242  
   243  	// Name must be a string representation of a valid Name enumeration.
   244  	_, err = loadAndParseOverrideLimitsFromFile("testdata/busted_override_invalid_name.yml")
   245  	test.AssertError(t, err, "single override limit with invalid name")
   246  	test.Assert(t, !os.IsNotExist(err), "test file should exist")
   247  
   248  	// Multiple entries, second entry has a bad name.
   249  	_, err = loadAndParseOverrideLimitsFromFile("testdata/busted_overrides_second_entry_bad_name.yml")
   250  	test.AssertError(t, err, "multiple override limits, second entry is bad")
   251  	test.Assert(t, !os.IsNotExist(err), "test file should exist")
   252  
   253  	// Multiple entries, third entry has id of "lol", instead of an IPv4 address.
   254  	_, err = loadAndParseOverrideLimitsFromFile("testdata/busted_overrides_third_entry_bad_id.yml")
   255  	test.AssertError(t, err, "multiple override limits, third entry has bad Id value")
   256  	test.Assert(t, !os.IsNotExist(err), "test file should exist")
   257  }
   258  
   259  func TestLoadOverrides(t *testing.T) {
   260  	mockLog := blog.NewMock()
   261  
   262  	tb, err := NewTransactionBuilderFromFiles("../test/config-next/ratelimit-defaults.yml", "../test/config-next/ratelimit-overrides.yml", metrics.NoopRegisterer, mockLog)
   263  	test.AssertNotError(t, err, "creating TransactionBuilder")
   264  	err = tb.loadOverrides(context.Background())
   265  	test.AssertNotError(t, err, "loading overrides in TransactionBuilder")
   266  	overridesData, err := loadOverridesFromFile("../test/config-next/ratelimit-overrides.yml")
   267  	test.AssertNotError(t, err, "loading overrides from file")
   268  	testOverrides, err := parseOverrideLimits(overridesData)
   269  	test.AssertNotError(t, err, "parsing overrides")
   270  
   271  	newOverridesPerLimit := make(map[Name]float64)
   272  	for _, override := range testOverrides {
   273  		override.precompute()
   274  		newOverridesPerLimit[override.Name]++
   275  	}
   276  
   277  	test.AssertDeepEquals(t, tb.limitRegistry.overrides, testOverrides)
   278  
   279  	var iom io_prometheus_client.Metric
   280  
   281  	for rlName, rlString := range nameToString {
   282  		err = tb.limitRegistry.overridesPerLimit.WithLabelValues(rlString).Write(&iom)
   283  		test.AssertNotError(t, err, fmt.Sprintf("encoding overridesPerLimit metric with label %q", rlString))
   284  		test.AssertEquals(t, iom.Gauge.GetValue(), newOverridesPerLimit[rlName])
   285  	}
   286  
   287  	err = tb.limitRegistry.overridesTimestamp.Write(&iom)
   288  	test.AssertNotError(t, err, "encoding overridesTimestamp metric")
   289  	test.Assert(t, int64(iom.Gauge.GetValue()) >= time.Now().Unix()-5, "overridesTimestamp too old")
   290  
   291  	// A failure loading overrides should log and return an error, and not
   292  	// overwrite existing overrides.
   293  	mockLog.Clear()
   294  	tb.limitRegistry.refreshOverrides = func(context.Context, prometheus.Gauge, blog.Logger) (Limits, error) {
   295  		return nil, errors.New("mock failure")
   296  	}
   297  	err = tb.limitRegistry.loadOverrides(context.Background())
   298  	test.AssertError(t, err, "fail to load overrides")
   299  	test.AssertDeepEquals(t, tb.limitRegistry.overrides, testOverrides)
   300  
   301  	// An empty set of overrides should log a warning, return nil, and not
   302  	// overwrite existing overrides.
   303  	mockLog.Clear()
   304  	tb.limitRegistry.refreshOverrides = func(context.Context, prometheus.Gauge, blog.Logger) (Limits, error) {
   305  		return Limits{}, nil
   306  	}
   307  	err = tb.limitRegistry.loadOverrides(context.Background())
   308  	test.AssertEquals(t, mockLog.GetAll()[0], "WARNING: loading overrides: no valid overrides")
   309  	test.AssertNotError(t, err, "load empty overrides")
   310  	test.AssertDeepEquals(t, tb.limitRegistry.overrides, testOverrides)
   311  }
   312  
   313  func TestNewRefresher(t *testing.T) {
   314  	mockLog := blog.NewMock()
   315  
   316  	reg := &limitRegistry{
   317  		refreshOverrides: func(_ context.Context, _ prometheus.Gauge, logger blog.Logger) (Limits, error) {
   318  			logger.Info("refreshed")
   319  			return nil, nil
   320  		},
   321  		logger: mockLog,
   322  	}
   323  
   324  	// Create and simultaneously cancel a refresher.
   325  	reg.NewRefresher(time.Millisecond * 2)()
   326  	time.Sleep(time.Millisecond * 20)
   327  	// The refresher should have run once, but then been cancelled before the
   328  	// first tick.
   329  	test.AssertDeepEquals(t, mockLog.GetAll(), []string{"INFO: refreshed", "WARNING: loading overrides: no valid overrides"})
   330  
   331  	reg.NewRefresher(time.Nanosecond)
   332  	retries := 0
   333  	for retries < 5 {
   334  		if slices.Contains(mockLog.GetAll(), "INFO: refreshed") {
   335  			break
   336  		}
   337  		retries++
   338  		time.Sleep(core.RetryBackoff(retries, time.Millisecond*2, time.Millisecond*50, 2))
   339  	}
   340  	test.AssertSliceContains(t, mockLog.GetAll(), "INFO: refreshed")
   341  	test.Assert(t, len(mockLog.GetAll()) > 1, "refresher didn't run more than once")
   342  }
   343  
   344  func TestHydrateOverrideLimit(t *testing.T) {
   345  	t.Parallel()
   346  
   347  	tests := []struct {
   348  		name            string
   349  		bucketKey       string
   350  		limit           Limit
   351  		expectBucketKey string
   352  		expectError     string
   353  	}{
   354  		{
   355  			name:            "bad limit name",
   356  			bucketKey:       "",
   357  			limit:           Limit{Name: 37},
   358  			expectBucketKey: "",
   359  			expectError:     "unrecognized limit name 37",
   360  		},
   361  		{
   362  			name:      "CertificatesPerDomain with bad FQDN, should fail validateIdForName",
   363  			bucketKey: "VelociousVacherin",
   364  			limit: Limit{
   365  				Name:   StringToName["CertificatesPerDomain"],
   366  				Burst:  1,
   367  				Count:  1,
   368  				Period: config.Duration{Duration: time.Second},
   369  			},
   370  			expectBucketKey: "",
   371  			expectError:     "\"VelociousVacherin\" is neither a domain (Domain name needs at least one dot) nor an IP address (ParseAddr(\"VelociousVacherin\"): unable to parse IP)",
   372  		},
   373  		{
   374  			name:      "CertificatesPerDomain with IPv4 address",
   375  			bucketKey: "64.112.117.1",
   376  			limit: Limit{
   377  				Name:   StringToName["CertificatesPerDomain"],
   378  				Burst:  1,
   379  				Count:  1,
   380  				Period: config.Duration{Duration: time.Second},
   381  			},
   382  			expectBucketKey: "64.112.117.1/32",
   383  			expectError:     "",
   384  		},
   385  		{
   386  			name:      "CertificatesPerDomain with IPv6 address",
   387  			bucketKey: "2602:80a:6000:666::",
   388  			limit: Limit{
   389  				Name:   StringToName["CertificatesPerDomain"],
   390  				Burst:  1,
   391  				Count:  1,
   392  				Period: config.Duration{Duration: time.Second},
   393  			},
   394  			expectBucketKey: "2602:80a:6000:666::/64",
   395  			expectError:     "",
   396  		},
   397  		{
   398  			name:      "CertificatesPerFQDNSet",
   399  			bucketKey: "example.com,example.net,example.org",
   400  			limit: Limit{
   401  				Name:   StringToName["CertificatesPerFQDNSet"],
   402  				Burst:  1,
   403  				Count:  1,
   404  				Period: config.Duration{Duration: time.Second},
   405  			},
   406  			expectBucketKey: "394e82811f52e2da38b970afdb21c9bc9af81060939c690183c00fce37408738",
   407  			expectError:     "",
   408  		},
   409  	}
   410  
   411  	for _, tc := range tests {
   412  		t.Run(tc.name, func(t *testing.T) {
   413  			bk, err := hydrateOverrideLimit(tc.bucketKey, tc.limit.Name)
   414  			if tc.expectError != "" {
   415  				if err == nil {
   416  					t.Errorf("expected error for test %q but got none", tc.name)
   417  				}
   418  				test.AssertContains(t, err.Error(), tc.expectError)
   419  			} else {
   420  				test.AssertNotError(t, err, tc.name)
   421  				test.AssertEquals(t, bk, tc.expectBucketKey)
   422  			}
   423  		})
   424  	}
   425  }
   426  
   427  func TestLoadAndParseDefaultLimits(t *testing.T) {
   428  	// Load a single valid default limit.
   429  	l, err := loadAndParseDefaultLimits("testdata/working_default.yml")
   430  	test.AssertNotError(t, err, "valid single default limit")
   431  	test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Burst, int64(20))
   432  	test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Count, int64(20))
   433  	test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Period.Duration, time.Second)
   434  
   435  	// Load multiple valid default limits.
   436  	l, err = loadAndParseDefaultLimits("testdata/working_defaults.yml")
   437  	test.AssertNotError(t, err, "multiple valid default limits")
   438  	test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Burst, int64(20))
   439  	test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Count, int64(20))
   440  	test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Period.Duration, time.Second)
   441  	test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].Burst, int64(30))
   442  	test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].Count, int64(30))
   443  	test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].Period.Duration, time.Second*2)
   444  
   445  	// Path is empty string.
   446  	_, err = loadAndParseDefaultLimits("")
   447  	test.AssertError(t, err, "path is empty string")
   448  	test.Assert(t, os.IsNotExist(err), "path is empty string")
   449  
   450  	// Path to file which does not exist.
   451  	_, err = loadAndParseDefaultLimits("testdata/file_does_not_exist.yml")
   452  	test.AssertError(t, err, "a file that does not exist")
   453  	test.Assert(t, os.IsNotExist(err), "test file should not exist")
   454  
   455  	// Burst cannot be 0.
   456  	_, err = loadAndParseDefaultLimits("testdata/busted_default_burst_0.yml")
   457  	test.AssertError(t, err, "single default limit with burst=0")
   458  	test.AssertContains(t, err.Error(), "invalid burst")
   459  
   460  	// Name cannot be empty.
   461  	_, err = loadAndParseDefaultLimits("testdata/busted_default_empty_name.yml")
   462  	test.AssertError(t, err, "single default limit with empty name")
   463  	test.Assert(t, !os.IsNotExist(err), "test file should exist")
   464  
   465  	// Name must be a string representation of a valid Name enumeration.
   466  	_, err = loadAndParseDefaultLimits("testdata/busted_default_invalid_name.yml")
   467  	test.AssertError(t, err, "single default limit with invalid name")
   468  	test.Assert(t, !os.IsNotExist(err), "test file should exist")
   469  
   470  	// Multiple entries, second entry has a bad name.
   471  	_, err = loadAndParseDefaultLimits("testdata/busted_defaults_second_entry_bad_name.yml")
   472  	test.AssertError(t, err, "multiple default limits, one is bad")
   473  	test.Assert(t, !os.IsNotExist(err), "test file should exist")
   474  }
   475  
   476  func TestLoadAndDumpOverrides(t *testing.T) {
   477  	t.Parallel()
   478  
   479  	input := `
   480  - CertificatesPerDomain:
   481      burst: 5000
   482      count: 5000
   483      period: 168h0m0s
   484      ids:
   485          - id: example.com
   486            comment: IN-10057
   487          - id: example.net
   488            comment: IN-10057
   489  - CertificatesPerDomain:
   490      burst: 300
   491      count: 300
   492      period: 168h0m0s
   493      ids:
   494          - id: example.org
   495            comment: IN-10057
   496  - CertificatesPerDomainPerAccount:
   497      burst: 12000
   498      count: 12000
   499      period: 168h0m0s
   500      ids:
   501          - id: "123456789"
   502            comment: Affluent (IN-8322)
   503  - CertificatesPerDomainPerAccount:
   504      burst: 6000
   505      count: 6000
   506      period: 168h0m0s
   507      ids:
   508          - id: "543219876"
   509            comment: Affluent (IN-8322)
   510          - id: "987654321"
   511            comment: Affluent (IN-8322)
   512  - CertificatesPerFQDNSet:
   513      burst: 50
   514      count: 50
   515      period: 168h0m0s
   516      ids:
   517          - id: example.co.uk,example.cn
   518            comment: IN-6843
   519  - CertificatesPerFQDNSet:
   520      burst: 24
   521      count: 24
   522      period: 168h0m0s
   523      ids:
   524          - id: example.org,example.com,example.net
   525            comment: IN-6006
   526  - FailedAuthorizationsPerDomainPerAccount:
   527      burst: 250
   528      count: 250
   529      period: 1h0m0s
   530      ids:
   531          - id: "123456789"
   532            comment: Digital Lake (IN-6736)
   533  - FailedAuthorizationsPerDomainPerAccount:
   534      burst: 50
   535      count: 50
   536      period: 1h0m0s
   537      ids:
   538          - id: "987654321"
   539            comment: Digital Lake (IN-6856)
   540  - FailedAuthorizationsPerDomainPerAccount:
   541      burst: 10
   542      count: 10
   543      period: 1h0m0s
   544      ids:
   545          - id: "543219876"
   546            comment: Big Mart (IN-6949)
   547  - NewOrdersPerAccount:
   548      burst: 3000
   549      count: 3000
   550      period: 3h0m0s
   551      ids:
   552          - id: "123456789"
   553            comment: Galaxy Hoster (IN-8180)
   554  - NewOrdersPerAccount:
   555      burst: 1000
   556      count: 1000
   557      period: 3h0m0s
   558      ids:
   559          - id: "543219876"
   560            comment: Big Mart (IN-8180)
   561          - id: "987654321"
   562            comment: Buy More (IN-10057)
   563  - NewRegistrationsPerIPAddress:
   564      burst: 100000
   565      count: 100000
   566      period: 3h0m0s
   567      ids:
   568          - id: 2600:1f1c:5e0:e702:ca06:d2a3:c7ce:a02e
   569            comment: example.org IN-2395
   570          - id: 55.66.77.88
   571            comment: example.org IN-2395
   572  - NewRegistrationsPerIPAddress:
   573      burst: 200
   574      count: 200
   575      period: 3h0m0s
   576      ids:
   577          - id: 11.22.33.44
   578            comment: example.net (IN-1583)`
   579  
   580  	expectCSV := `
   581  name,id,count,burst,period,comment
   582  CertificatesPerDomain,example.com,5000,5000,168h0m0s,IN-10057
   583  CertificatesPerDomain,example.net,5000,5000,168h0m0s,IN-10057
   584  CertificatesPerDomain,example.org,300,300,168h0m0s,IN-10057
   585  CertificatesPerDomainPerAccount,123456789,12000,12000,168h0m0s,Affluent (IN-8322)
   586  CertificatesPerDomainPerAccount,543219876,6000,6000,168h0m0s,Affluent (IN-8322)
   587  CertificatesPerDomainPerAccount,987654321,6000,6000,168h0m0s,Affluent (IN-8322)
   588  CertificatesPerFQDNSet,7c956936126b492845ddb48f4d220034509e7c0ad54ed2c1ba2650406846d9c3,50,50,168h0m0s,IN-6843
   589  CertificatesPerFQDNSet,394e82811f52e2da38b970afdb21c9bc9af81060939c690183c00fce37408738,24,24,168h0m0s,IN-6006
   590  FailedAuthorizationsPerDomainPerAccount,123456789,250,250,1h0m0s,Digital Lake (IN-6736)
   591  FailedAuthorizationsPerDomainPerAccount,987654321,50,50,1h0m0s,Digital Lake (IN-6856)
   592  FailedAuthorizationsPerDomainPerAccount,543219876,10,10,1h0m0s,Big Mart (IN-6949)
   593  NewOrdersPerAccount,123456789,3000,3000,3h0m0s,Galaxy Hoster (IN-8180)
   594  NewOrdersPerAccount,543219876,1000,1000,3h0m0s,Big Mart (IN-8180)
   595  NewOrdersPerAccount,987654321,1000,1000,3h0m0s,Buy More (IN-10057)
   596  NewRegistrationsPerIPAddress,2600:1f1c:5e0:e702:ca06:d2a3:c7ce:a02e,100000,100000,3h0m0s,example.org IN-2395
   597  NewRegistrationsPerIPAddress,55.66.77.88,100000,100000,3h0m0s,example.org IN-2395
   598  NewRegistrationsPerIPAddress,11.22.33.44,200,200,3h0m0s,example.net (IN-1583)
   599  `
   600  	tempDir := t.TempDir()
   601  	tempFile := filepath.Join(tempDir, "overrides.yaml")
   602  
   603  	err := os.WriteFile(tempFile, []byte(input), 0644)
   604  	test.AssertNotError(t, err, "writing temp overrides.yaml")
   605  
   606  	original, err := LoadOverridesByBucketKey(tempFile)
   607  	test.AssertNotError(t, err, "loading overrides")
   608  	test.Assert(t, len(original) > 0, "expected at least one override loaded")
   609  
   610  	dumpFile := filepath.Join(tempDir, "dumped.yaml")
   611  	err = DumpOverrides(dumpFile, original)
   612  	test.AssertNotError(t, err, "dumping overrides")
   613  
   614  	dumped, err := os.ReadFile(dumpFile)
   615  	test.AssertNotError(t, err, "reading dumped overrides file")
   616  	test.AssertEquals(t, strings.TrimLeft(string(dumped), "\n"), strings.TrimLeft(expectCSV, "\n"))
   617  }