diff --git a/internal/enginenetx/bridgespolicy.go b/internal/enginenetx/bridgespolicy.go index 27126cca7..23e7cfaf9 100644 --- a/internal/enginenetx/bridgespolicy.go +++ b/internal/enginenetx/bridgespolicy.go @@ -12,6 +12,26 @@ import ( "time" ) +// bridgesPolicyV2 is a policy where we use bridges for communicating +// with the OONI backend, i.e., api.ooni.io. +// +// A bridge is an IP address that can route traffic from and to +// the OONI backend and accepts any SNI. +// +// The zero value is invalid; please, init MANDATORY fields. +// +// This is v2 of the bridgesPolicy because the previous implementation +// incorporated mixing logic, while now the mixing happens outside +// of this policy, thus giving us much more flexibility. +type bridgesPolicyV2 struct{} + +var _ httpsDialerPolicy = &bridgesPolicyV2{} + +// LookupTactics implements httpsDialerPolicy. +func (p *bridgesPolicyV2) LookupTactics(ctx context.Context, domain, port string) <-chan *httpsDialerTactic { + return bridgesTacticsForDomain(domain, port) +} + // bridgesPolicy is a policy where we use bridges for communicating // with the OONI backend, i.e., api.ooni.io. // @@ -31,7 +51,7 @@ func (p *bridgesPolicy) LookupTactics(ctx context.Context, domain, port string) return mixSequentially( // emit bridges related tactics first which are empty if there are // no bridges for the givend domain and port - p.bridgesTacticsForDomain(domain, port), + bridgesTacticsForDomain(domain, port), // now fallback to get more tactics (typically here the fallback // uses the DNS and obtains some extra tactics) @@ -42,14 +62,6 @@ func (p *bridgesPolicy) LookupTactics(ctx context.Context, domain, port string) ) } -var bridgesPolicyTestHelpersDomains = []string{ - "0.th.ooni.org", - "1.th.ooni.org", - "2.th.ooni.org", - "3.th.ooni.org", - "d33d1gs9kpq1c5.cloudfront.net", -} - func (p *bridgesPolicy) maybeRewriteTestHelpersTactics(input <-chan *httpsDialerTactic) <-chan *httpsDialerTactic { out := make(chan *httpsDialerTactic) @@ -58,14 +70,14 @@ func (p *bridgesPolicy) maybeRewriteTestHelpersTactics(input <-chan *httpsDialer for tactic := range input { // When we're not connecting to a TH, pass the policy down the chain unmodified - if !slices.Contains(bridgesPolicyTestHelpersDomains, tactic.VerifyHostname) { + if !slices.Contains(testHelpersDomains, tactic.VerifyHostname) { out <- tactic continue } // This is the case where we're connecting to a test helper. Let's try // to produce policies hiding the SNI to censoring middleboxes. - for _, sni := range p.bridgesDomainsInRandomOrder() { + for _, sni := range bridgesDomainsInRandomOrder() { out <- &httpsDialerTactic{ Address: tactic.Address, InitialDelay: 0, // set when dialing @@ -80,7 +92,7 @@ func (p *bridgesPolicy) maybeRewriteTestHelpersTactics(input <-chan *httpsDialer return out } -func (p *bridgesPolicy) bridgesTacticsForDomain(domain, port string) <-chan *httpsDialerTactic { +func bridgesTacticsForDomain(domain, port string) <-chan *httpsDialerTactic { out := make(chan *httpsDialerTactic) go func() { @@ -91,8 +103,8 @@ func (p *bridgesPolicy) bridgesTacticsForDomain(domain, port string) <-chan *htt return } - for _, ipAddr := range p.bridgesAddrs() { - for _, sni := range p.bridgesDomainsInRandomOrder() { + for _, ipAddr := range bridgesAddrs() { + for _, sni := range bridgesDomainsInRandomOrder() { out <- &httpsDialerTactic{ Address: ipAddr, InitialDelay: 0, // set when dialing @@ -107,8 +119,8 @@ func (p *bridgesPolicy) bridgesTacticsForDomain(domain, port string) <-chan *htt return out } -func (p *bridgesPolicy) bridgesDomainsInRandomOrder() (out []string) { - out = p.bridgesDomains() +func bridgesDomainsInRandomOrder() (out []string) { + out = bridgesDomains() r := rand.New(rand.NewSource(time.Now().UnixNano())) r.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] @@ -116,14 +128,14 @@ func (p *bridgesPolicy) bridgesDomainsInRandomOrder() (out []string) { return } -func (p *bridgesPolicy) bridgesAddrs() (out []string) { +func bridgesAddrs() (out []string) { return append( out, "162.55.247.208", ) } -func (p *bridgesPolicy) bridgesDomains() (out []string) { +func bridgesDomains() (out []string) { // See https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/issues/40273 return append( out, diff --git a/internal/enginenetx/bridgespolicy_test.go b/internal/enginenetx/bridgespolicy_test.go index 4d5b2ad3c..27d2430e1 100644 --- a/internal/enginenetx/bridgespolicy_test.go +++ b/internal/enginenetx/bridgespolicy_test.go @@ -9,6 +9,61 @@ import ( "github.com/ooni/probe-cli/v3/internal/model" ) +func TestBridgesPolicyV2(t *testing.T) { + t.Run("for domains for which we don't have bridges", func(t *testing.T) { + p := &bridgesPolicyV2{} + + tactics := p.LookupTactics(context.Background(), "www.example.com", "443") + + var count int + for range tactics { + count++ + } + + if count != 0 { + t.Fatal("expected to see zero tactics") + } + }) + + t.Run("for the api.ooni.io domain", func(t *testing.T) { + p := &bridgesPolicyV2{} + + tactics := p.LookupTactics(context.Background(), "api.ooni.io", "443") + + var count int + for tactic := range tactics { + count++ + + // for each generated tactic, make sure we're getting the + // expected value for each of the fields + + if tactic.Port != "443" { + t.Fatal("the port should always be 443") + } + + if tactic.Address != "162.55.247.208" { + t.Fatal("the host should always be 162.55.247.208") + } + + if tactic.InitialDelay != 0 { + t.Fatal("unexpected InitialDelay") + } + + if tactic.SNI == "api.ooni.io" { + t.Fatal("we should not see the `api.ooni.io` SNI on the wire") + } + + if tactic.VerifyHostname != "api.ooni.io" { + t.Fatal("the VerifyHostname field should always be like `api.ooni.io`") + } + } + + if count <= 0 { + t.Fatal("expected to see at least one tactic") + } + }) +} + func TestBridgesPolicy(t *testing.T) { t.Run("for domains for which we don't have bridges and DNS failure", func(t *testing.T) { expected := errors.New("mocked error") @@ -202,7 +257,7 @@ func TestBridgesPolicy(t *testing.T) { }) t.Run("for test helper domains", func(t *testing.T) { - for _, domain := range bridgesPolicyTestHelpersDomains { + for _, domain := range testHelpersDomains { t.Run(domain, func(t *testing.T) { expectedAddrs := []string{"164.92.180.7"} diff --git a/internal/enginenetx/dnspolicy.go b/internal/enginenetx/dnspolicy.go index 39dc5fb14..75e43148e 100644 --- a/internal/enginenetx/dnspolicy.go +++ b/internal/enginenetx/dnspolicy.go @@ -16,8 +16,6 @@ import ( // given resolver and the domain as the SNI. // // The zero value is invalid; please, init all MANDATORY fields. -// -// This policy uses an Happy-Eyeballs-like algorithm. type dnsPolicy struct { // Logger is the MANDATORY logger. Logger model.Logger diff --git a/internal/enginenetx/httpsdialer.go b/internal/enginenetx/httpsdialer.go index 2f0e28386..0375967d8 100644 --- a/internal/enginenetx/httpsdialer.go +++ b/internal/enginenetx/httpsdialer.go @@ -202,6 +202,16 @@ func (hd *httpsDialer) DialTLSContext(ctx context.Context, network string, endpo return nil, err } + // TODO(bassosimone): this code should be refactored using the same + // pattern used by `./internal/httpclientx` to perform attempts faster + // in case there is an initial early failure. + + // TODO(bassosimone): the algorithm to filter and assign initial + // delays is broken because, if the DNS runs for more than one + // second, then several policies will immediately be due. We should + // probably use a better strategy that takes as the zero the time + // when the first dialing policy becomes available. + // We need a cancellable context to interrupt the tactics emitter early when we // immediately get a valid response and we don't need to use other tactics. ctx, cancel := context.WithCancel(ctx) @@ -319,6 +329,9 @@ func (hd *httpsDialer) dialTLS( return nil, err } + // for debugging let the user know which tactic is ready + logger.Infof("tactic '%+v' is ready", tactic) + // tell the observer that we're starting hd.stats.OnStarting(tactic) diff --git a/internal/enginenetx/mixpolicy.go b/internal/enginenetx/mixpolicy.go new file mode 100644 index 000000000..a95a032b6 --- /dev/null +++ b/internal/enginenetx/mixpolicy.go @@ -0,0 +1,133 @@ +package enginenetx + +// +// Mix policies - ability of mixing from a primary policy and a fallback policy +// in a more flexible way than strictly falling back +// + +import ( + "context" + + "github.com/ooni/probe-cli/v3/internal/optional" +) + +// mixPolicyEitherOr reads from primary and only if primary does +// not return any tactic, then it reads from fallback. +type mixPolicyEitherOr struct { + // Primary is the primary policy. + Primary httpsDialerPolicy + + // Fallback is the fallback policy. + Fallback httpsDialerPolicy +} + +var _ httpsDialerPolicy = &mixPolicyEitherOr{} + +// LookupTactics implements httpsDialerPolicy. +func (m *mixPolicyEitherOr) LookupTactics(ctx context.Context, domain string, port string) <-chan *httpsDialerTactic { + // create the output channel + output := make(chan *httpsDialerTactic) + + go func() { + // make sure we eventually close the output channel + defer close(output) + + // drain the primary policy + var count int + for tx := range m.Primary.LookupTactics(ctx, domain, port) { + output <- tx + count++ + } + + // if the primary worked, we're good + if count > 0 { + return + } + + // drain the fallback policy + for tx := range m.Fallback.LookupTactics(ctx, domain, port) { + output <- tx + } + }() + + return output +} + +// mixPolicyInterleave interleaves policies by a given interleaving +// factor. Say the interleave factor is N, then we first read N tactics +// from the primary policy, then N from the fallback one, and we keep +// going on like this until we've read all the tactics from both. +type mixPolicyInterleave struct { + // Primary is the primary policy. We will read N from this + // policy first, then N from fallback, and so on. + Primary httpsDialerPolicy + + // Fallback is the fallback policy. + Fallback httpsDialerPolicy + + // Factor is the interleaving factor to use. If this value is + // zero, we behave like it was set to one. + Factor uint8 +} + +var _ httpsDialerPolicy = &mixPolicyInterleave{} + +// LookupTactics implements httpsDialerPolicy. +func (p *mixPolicyInterleave) LookupTactics(ctx context.Context, domain string, port string) <-chan *httpsDialerTactic { + // create the output channel + output := make(chan *httpsDialerTactic) + + go func() { + // make sure we eventually close the output channel + defer close(output) + + // obtain the primary channel + primary := optional.Some(p.Primary.LookupTactics(ctx, domain, port)) + + // obtain the fallback channel + fallback := optional.Some(p.Fallback.LookupTactics(ctx, domain, port)) + + // loop until both channels are drained + for !primary.IsNone() || !fallback.IsNone() { + // take N from primary if possible + primary = p.maybeTakeN(primary, output) + + // take N from secondary if possible + fallback = p.maybeTakeN(fallback, output) + } + }() + + return output +} + +// maybeTakeN takes N entries from input if it's not none. When input is not +// none and reading from it indicates EOF, this function returns none. Otherwise, +// it returns the same value given as input. +func (p *mixPolicyInterleave) maybeTakeN( + input optional.Value[<-chan *httpsDialerTactic], + output chan<- *httpsDialerTactic, +) optional.Value[<-chan *httpsDialerTactic] { + // make sure we've not already drained this channel + if !input.IsNone() { + + // obtain the underlying channel + ch := input.Unwrap() + + // take N entries from the channel + for idx := uint8(0); idx < max(1, p.Factor); idx++ { + + // attempt to get the next tactic + tactic, good := <-ch + + // handle the case where the channel has been drained + if !good { + return optional.None[<-chan *httpsDialerTactic]() + } + + // emit the tactic + output <- tactic + } + } + + return input +} diff --git a/internal/enginenetx/mixpolicy_test.go b/internal/enginenetx/mixpolicy_test.go new file mode 100644 index 000000000..7927fd405 --- /dev/null +++ b/internal/enginenetx/mixpolicy_test.go @@ -0,0 +1,370 @@ +package enginenetx + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestMixPolicyEitherOr(t *testing.T) { + // testcase is a test case implemented by this function. + type testcase struct { + // name is the name of the test case + name string + + // primary is the primary policy to use + primary httpsDialerPolicy + + // fallback is the fallback policy to use + fallback httpsDialerPolicy + + // domain is the domain to pass to LookupTactics + domain string + + // port is the port to pass to LookupTactics + port string + + // expect is the expectations in terms of tactics + expect []*httpsDialerTactic + } + + // This is the list of tactics that we expect the primary + // policy to return when we're not using a null policy + expectedPrimaryTactics := []*httpsDialerTactic{{ + Address: "130.192.91.211", + InitialDelay: 0, + Port: "443", + SNI: "shelob.polito.it", + VerifyHostname: "api.ooni.io", + }, { + Address: "130.192.91.211", + InitialDelay: 0, + Port: "443", + SNI: "whitespider.polito.it", + VerifyHostname: "api.ooni.io", + }, { + Address: "130.192.91.211", + InitialDelay: 0, + Port: "443", + SNI: "mirkwood.polito.it", + VerifyHostname: "api.ooni.io", + }, { + Address: "130.192.91.211", + InitialDelay: 0, + Port: "443", + SNI: "highgarden.polito.it", + VerifyHostname: "api.ooni.io", + }} + + // Create the non-null primary policy + primary := &userPolicyV2{ + Root: &userPolicyRoot{ + DomainEndpoints: map[string][]*httpsDialerTactic{ + "api.ooni.io:443": expectedPrimaryTactics, + }, + Version: userPolicyVersion, + }, + } + + // This is the list of tactics that we expect the fallback + // policy to return when we're not using a null policy + expectedFallbackTactics := []*httpsDialerTactic{{ + Address: "130.192.91.231", + InitialDelay: 0, + Port: "443", + SNI: "kingslanding.polito.it", + VerifyHostname: "api.ooni.io", + }, { + Address: "130.192.91.231", + InitialDelay: 0, + Port: "443", + SNI: "pyke.polito.it", + VerifyHostname: "api.ooni.io", + }, { + Address: "130.192.91.231", + InitialDelay: 0, + Port: "443", + SNI: "winterfell.polito.it", + VerifyHostname: "api.ooni.io", + }} + + // Create the non-null fallback policy + fallback := &userPolicyV2{ + Root: &userPolicyRoot{ + DomainEndpoints: map[string][]*httpsDialerTactic{ + "api.ooni.io:443": expectedFallbackTactics, + }, + Version: userPolicyVersion, + }, + } + + cases := []testcase{ + + // This test ensures that the code is WAI with two null policies + { + name: "with two null policies", + primary: &nullPolicy{}, + fallback: &nullPolicy{}, + domain: "api.ooni.io", + port: "443", + expect: nil, + }, + + // This test ensures that we get the content of the primary + // policy when the fallback policy is the null policy + { + name: "with the fallback policy being null", + primary: primary, + fallback: &nullPolicy{}, + domain: "api.ooni.io", + port: "443", + expect: expectedPrimaryTactics, + }, + + // This test ensures that we get the content of the fallback + // policy when the primary policy is the null policy + { + name: "with the primary policy being null", + primary: &nullPolicy{}, + fallback: fallback, + domain: "api.ooni.io", + port: "443", + expect: expectedFallbackTactics, + }, + + // This test ensures that we correctly only get the primary + // policy when both primary and fallback are set + { + name: "with both policies being nonnull", + primary: primary, + fallback: fallback, + domain: "api.ooni.io", + port: "443", + expect: expectedPrimaryTactics, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + + // construct the mixPolicyEitherOr instance + p := &mixPolicyEitherOr{ + Primary: tc.primary, + Fallback: tc.fallback, + } + + // start looking up for tactics + outch := p.LookupTactics(context.Background(), tc.domain, tc.port) + + // collect all the generated tactics + var got []*httpsDialerTactic + for entry := range outch { + got = append(got, entry) + } + + // compare to expectations + if diff := cmp.Diff(tc.expect, got); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestMixPolicyInterleave(t *testing.T) { + // testcase is a test case implemented by this function. + type testcase struct { + // name is the name of the test case + name string + + // primary is the primary policy to use + primary httpsDialerPolicy + + // fallback is the fallback policy to use + fallback httpsDialerPolicy + + // factor is the interleave factor + factor uint8 + + // domain is the domain to pass to LookupTactics + domain string + + // port is the port to pass to LookupTactics + port string + + // expect is the expectations in terms of tactics + expect []*httpsDialerTactic + } + + // This is the list of tactics that we expect the primary + // policy to return when we're not using a null policy + expectedPrimaryTactics := []*httpsDialerTactic{{ + Address: "130.192.91.211", + InitialDelay: 0, + Port: "443", + SNI: "shelob.polito.it", + VerifyHostname: "api.ooni.io", + }, { + Address: "130.192.91.211", + InitialDelay: 0, + Port: "443", + SNI: "whitespider.polito.it", + VerifyHostname: "api.ooni.io", + }, { + Address: "130.192.91.211", + InitialDelay: 0, + Port: "443", + SNI: "mirkwood.polito.it", + VerifyHostname: "api.ooni.io", + }, { + Address: "130.192.91.211", + InitialDelay: 0, + Port: "443", + SNI: "highgarden.polito.it", + VerifyHostname: "api.ooni.io", + }} + + // Create the non-null primary policy + primary := &userPolicyV2{ + Root: &userPolicyRoot{ + DomainEndpoints: map[string][]*httpsDialerTactic{ + "api.ooni.io:443": expectedPrimaryTactics, + }, + Version: userPolicyVersion, + }, + } + + // This is the list of tactics that we expect the fallback + // policy to return when we're not using a null policy + expectedFallbackTactics := []*httpsDialerTactic{{ + Address: "130.192.91.231", + InitialDelay: 0, + Port: "443", + SNI: "kingslanding.polito.it", + VerifyHostname: "api.ooni.io", + }, { + Address: "130.192.91.231", + InitialDelay: 0, + Port: "443", + SNI: "pyke.polito.it", + VerifyHostname: "api.ooni.io", + }, { + Address: "130.192.91.231", + InitialDelay: 0, + Port: "443", + SNI: "winterfell.polito.it", + VerifyHostname: "api.ooni.io", + }} + + // Create the non-null fallback policy + fallback := &userPolicyV2{ + Root: &userPolicyRoot{ + DomainEndpoints: map[string][]*httpsDialerTactic{ + "api.ooni.io:443": expectedFallbackTactics, + }, + Version: userPolicyVersion, + }, + } + + cases := []testcase{ + + // This test ensures that the code is WAI with two null policies + { + name: "with two null policies", + primary: &nullPolicy{}, + fallback: &nullPolicy{}, + factor: 0, + domain: "api.ooni.io", + port: "443", + expect: nil, + }, + + // This test ensures that we get the content of the primary + // policy when the fallback policy is the null policy + { + name: "with the fallback policy being null", + primary: primary, + fallback: &nullPolicy{}, + factor: 0, + domain: "api.ooni.io", + port: "443", + expect: expectedPrimaryTactics, + }, + + // This test ensures that we get the content of the fallback + // policy when the primary policy is the null policy + { + name: "with the primary policy being null", + primary: &nullPolicy{}, + fallback: fallback, + factor: 0, + domain: "api.ooni.io", + port: "443", + expect: expectedFallbackTactics, + }, + + // This test ensures that we correctly interleave the tactics + { + name: "with both policies being nonnull and interleave being nonzero", + primary: primary, + fallback: fallback, + factor: 2, + domain: "api.ooni.io", + port: "443", + expect: []*httpsDialerTactic{ + expectedPrimaryTactics[0], + expectedPrimaryTactics[1], + expectedFallbackTactics[0], + expectedFallbackTactics[1], + expectedPrimaryTactics[2], + expectedPrimaryTactics[3], + expectedFallbackTactics[2], + }, + }, + + // This test ensures that we behave correctly when factor is zero + { + name: "with both policies being nonnull and interleave being zero", + primary: primary, + fallback: fallback, + factor: 0, + domain: "api.ooni.io", + port: "443", + expect: []*httpsDialerTactic{ + expectedPrimaryTactics[0], + expectedFallbackTactics[0], + expectedPrimaryTactics[1], + expectedFallbackTactics[1], + expectedPrimaryTactics[2], + expectedFallbackTactics[2], + expectedPrimaryTactics[3], + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + + // construct the mixPolicyInterleave instance + p := &mixPolicyInterleave{ + Primary: tc.primary, + Fallback: tc.fallback, + Factor: tc.factor, + } + + // start looking up for tactics + outch := p.LookupTactics(context.Background(), tc.domain, tc.port) + + // collect all the generated tactics + var got []*httpsDialerTactic + for entry := range outch { + got = append(got, entry) + } + + // compare to expectations + if diff := cmp.Diff(tc.expect, got); diff != "" { + t.Fatal(diff) + } + }) + } +} diff --git a/internal/enginenetx/mockspolicy_test.go b/internal/enginenetx/mockspolicy_test.go new file mode 100644 index 000000000..a952ecb17 --- /dev/null +++ b/internal/enginenetx/mockspolicy_test.go @@ -0,0 +1,49 @@ +package enginenetx + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ooni/probe-cli/v3/internal/testingx" +) + +// mocksPolicy is a mockable policy +type mocksPolicy struct { + MockLookupTactics func(ctx context.Context, domain string, port string) <-chan *httpsDialerTactic +} + +var _ httpsDialerPolicy = &mocksPolicy{} + +// LookupTactics implements httpsDialerPolicy. +func (p *mocksPolicy) LookupTactics(ctx context.Context, domain string, port string) <-chan *httpsDialerTactic { + return p.MockLookupTactics(ctx, domain, port) +} + +func TestMocksPolicy(t *testing.T) { + // create and fake fill a mocked tactic + var tx httpsDialerTactic + ff := &testingx.FakeFiller{} + ff.Fill(&tx) + + // create a mocks policy + p := &mocksPolicy{ + MockLookupTactics: func(ctx context.Context, domain, port string) <-chan *httpsDialerTactic { + output := make(chan *httpsDialerTactic, 1) + output <- &tx + close(output) + return output + }, + } + + // read the tactics emitted by the policy + var got []*httpsDialerTactic + for entry := range p.LookupTactics(context.Background(), "api.ooni.io", "443") { + got = append(got, entry) + } + + // make sure we've got what we expect + if diff := cmp.Diff([]*httpsDialerTactic{&tx}, got); diff != "" { + t.Fatal(diff) + } +} diff --git a/internal/enginenetx/nullpolicy.go b/internal/enginenetx/nullpolicy.go new file mode 100644 index 000000000..44424991b --- /dev/null +++ b/internal/enginenetx/nullpolicy.go @@ -0,0 +1,27 @@ +package enginenetx + +// +// A policy that never returns any tactic. +// + +import "context" + +// nullPolicy is a policy that never returns any tactics. +// +// You can use this policy to terminate the policy chain and +// ensure ane existing policy has a "null" fallback. +// +// The zero value is ready to use. +type nullPolicy struct{} + +var _ httpsDialerPolicy = &nullPolicy{} + +// LookupTactics implements httpsDialerPolicy. +// +// This policy returns a closed channel such that it won't +// be possible to read policies from it. +func (n *nullPolicy) LookupTactics(ctx context.Context, domain string, port string) <-chan *httpsDialerTactic { + output := make(chan *httpsDialerTactic) + close(output) + return output +} diff --git a/internal/enginenetx/nullpolicy_test.go b/internal/enginenetx/nullpolicy_test.go new file mode 100644 index 000000000..0bb40ec0d --- /dev/null +++ b/internal/enginenetx/nullpolicy_test.go @@ -0,0 +1,17 @@ +package enginenetx + +import ( + "context" + "testing" +) + +func TestNullPolicy(t *testing.T) { + p := &nullPolicy{} + var count int + for range p.LookupTactics(context.Background(), "api.ooni.io", "443") { + count++ + } + if count != 0 { + t.Fatal("should have not returned any policy") + } +} diff --git a/internal/enginenetx/statspolicy.go b/internal/enginenetx/statspolicy.go index 9c773bf08..74f42f18e 100644 --- a/internal/enginenetx/statspolicy.go +++ b/internal/enginenetx/statspolicy.go @@ -40,6 +40,27 @@ func (p *statsPolicy) LookupTactics(ctx context.Context, domain string, port str ))) } +// statsPolicyV2 is a policy that schedules tactics already known +// to work based on the previously collected stats. +// +// The zero value of this struct is invalid; please, make sure +// you fill all the fields marked as MANDATORY. +// +// This is v2 of the statsPolicy because the previous implementation +// incorporated mixing logic, while now the mixing happens outside +// of this policy, thus giving us much more flexibility. +type statsPolicyV2 struct { + // Stats is the MANDATORY stats manager. + Stats *statsManager +} + +var _ httpsDialerPolicy = &statsPolicyV2{} + +// LookupTactics implements httpsDialerPolicy. +func (p *statsPolicyV2) LookupTactics(ctx context.Context, domain string, port string) <-chan *httpsDialerTactic { + return streamTacticsFromSlice(statsPolicyFilterStatsTactics(p.Stats.LookupTactics(domain, port))) +} + func statsPolicyFilterStatsTactics(tactics []*statsTactic, good bool) (out []*httpsDialerTactic) { // when good is false, it means p.Stats.LookupTactics failed if !good { diff --git a/internal/enginenetx/statspolicy_test.go b/internal/enginenetx/statspolicy_test.go index 6431ea3f3..19ea48ddb 100644 --- a/internal/enginenetx/statspolicy_test.go +++ b/internal/enginenetx/statspolicy_test.go @@ -15,6 +15,188 @@ import ( "github.com/ooni/probe-cli/v3/internal/runtimex" ) +func TestStatsPolicyV2(t *testing.T) { + // prepare the content of the stats + twentyMinutesAgo := time.Now().Add(-20 * time.Minute) + + const bridgeAddress = netemx.AddressApiOONIIo + + expectTacticsStats := []*statsTactic{{ + CountStarted: 5, + CountTCPConnectError: 0, + CountTCPConnectInterrupt: 0, + CountTLSHandshakeError: 0, + CountTLSHandshakeInterrupt: 0, + CountTLSVerificationError: 0, + CountSuccess: 5, // this one always succeeds, so it should be there + HistoTCPConnectError: map[string]int64{}, + HistoTLSHandshakeError: map[string]int64{}, + HistoTLSVerificationError: map[string]int64{}, + LastUpdated: twentyMinutesAgo, + Tactic: &httpsDialerTactic{ + Address: bridgeAddress, + InitialDelay: 0, + Port: "443", + SNI: "www.repubblica.it", + VerifyHostname: "api.ooni.io", + }, + }, { + CountStarted: 3, + CountTCPConnectError: 0, + CountTCPConnectInterrupt: 0, + CountTLSHandshakeError: 1, + CountTLSHandshakeInterrupt: 0, + CountTLSVerificationError: 0, + CountSuccess: 2, // this one sometimes succeded so it should be added + HistoTCPConnectError: map[string]int64{}, + HistoTLSHandshakeError: map[string]int64{}, + HistoTLSVerificationError: map[string]int64{}, + LastUpdated: twentyMinutesAgo, + Tactic: &httpsDialerTactic{ + Address: bridgeAddress, + InitialDelay: 0, + Port: "443", + SNI: "www.kernel.org", + VerifyHostname: "api.ooni.io", + }, + }, { + CountStarted: 3, + CountTCPConnectError: 0, + CountTCPConnectInterrupt: 0, + CountTLSHandshakeError: 3, // this one always failed, so should not be added + CountTLSHandshakeInterrupt: 0, + CountTLSVerificationError: 0, + CountSuccess: 0, + HistoTCPConnectError: map[string]int64{}, + HistoTLSHandshakeError: map[string]int64{}, + HistoTLSVerificationError: map[string]int64{}, + LastUpdated: twentyMinutesAgo, + Tactic: &httpsDialerTactic{ + Address: bridgeAddress, + InitialDelay: 0, + Port: "443", + SNI: "theconversation.com", + VerifyHostname: "api.ooni.io", + }, + }, { + CountStarted: 4, + CountTCPConnectError: 0, + CountTCPConnectInterrupt: 0, + CountTLSHandshakeError: 0, + CountTLSHandshakeInterrupt: 0, + CountTLSVerificationError: 0, + CountSuccess: 4, + HistoTCPConnectError: map[string]int64{}, + HistoTLSHandshakeError: map[string]int64{}, + HistoTLSVerificationError: map[string]int64{}, + LastUpdated: twentyMinutesAgo, + Tactic: nil, // the nil policy here should cause this entry to be filtered out + }, { + CountStarted: 0, + CountTCPConnectError: 0, + CountTCPConnectInterrupt: 0, + CountTLSHandshakeError: 0, + CountTLSHandshakeInterrupt: 0, + CountTLSVerificationError: 0, + CountSuccess: 0, + HistoTCPConnectError: map[string]int64{}, + HistoTLSHandshakeError: map[string]int64{}, + HistoTLSVerificationError: map[string]int64{}, + LastUpdated: time.Time{}, // the zero time should exclude this one + Tactic: &httpsDialerTactic{ + Address: bridgeAddress, + InitialDelay: 0, + Port: "443", + SNI: "ilpost.it", + VerifyHostname: "api.ooni.io", + }, + }} + + // createStatsManager creates a stats manager given some baseline stats + createStatsManager := func(domainEndpoint string, tactics ...*statsTactic) *statsManager { + container := &statsContainer{ + DomainEndpoints: map[string]*statsDomainEndpoint{ + domainEndpoint: { + Tactics: map[string]*statsTactic{}, + }, + }, + Version: statsContainerVersion, + } + + for _, tx := range tactics { + if tx.Tactic != nil { + container.DomainEndpoints[domainEndpoint].Tactics[tx.Tactic.tacticSummaryKey()] = tx + } + } + + kvStore := &kvstore.Memory{} + if err := kvStore.Set(statsKey, runtimex.Try1(json.Marshal(container))); err != nil { + t.Fatal(err) + } + + const trimInterval = 30 * time.Second + return newStatsManager(kvStore, log.Log, trimInterval) + } + + t.Run("when we have relevant stats", func(t *testing.T) { + // create stats manager + stats := createStatsManager("api.ooni.io:443", expectTacticsStats...) + defer stats.Close() + + // create the policy + policy := &statsPolicyV2{ + Stats: stats, + } + + // obtain the tactics from the saved stats + var tactics []*httpsDialerTactic + for entry := range policy.LookupTactics(context.Background(), "api.ooni.io", "443") { + tactics = append(tactics, entry) + } + + // compute the list of results we expect to see from the stats data + var expect []*httpsDialerTactic + for _, entry := range expectTacticsStats { + if entry.CountSuccess <= 0 || entry.Tactic == nil { + continue // we SHOULD NOT include entries that systematically failed + } + t := entry.Tactic.Clone() + t.InitialDelay = 0 + expect = append(expect, t) + } + + // perform the actual comparison + if diff := cmp.Diff(expect, tactics); diff != "" { + t.Fatal(diff) + } + }) + + t.Run("when there are no relevant stats", func(t *testing.T) { + // create stats manager + stats := createStatsManager("api.ooni.io:443" /*, nothing */) + defer stats.Close() + + // create the policy + policy := &statsPolicyV2{ + Stats: stats, + } + + // obtain the tactics from the saved stats + var tactics []*httpsDialerTactic + for entry := range policy.LookupTactics(context.Background(), "api.ooni.io", "443") { + tactics = append(tactics, entry) + } + + // compute the list of results we expect to see from the stats data + var expect []*httpsDialerTactic + + // perform the actual comparison + if diff := cmp.Diff(expect, tactics); diff != "" { + t.Fatal(diff) + } + }) +} + func TestStatsPolicyWorkingAsIntended(t *testing.T) { // prepare the content of the stats twentyMinutesAgo := time.Now().Add(-20 * time.Minute) @@ -302,17 +484,6 @@ func TestStatsPolicyWorkingAsIntended(t *testing.T) { }) } -type mocksPolicy struct { - MockLookupTactics func(ctx context.Context, domain string, port string) <-chan *httpsDialerTactic -} - -var _ httpsDialerPolicy = &mocksPolicy{} - -// LookupTactics implements httpsDialerPolicy. -func (p *mocksPolicy) LookupTactics(ctx context.Context, domain string, port string) <-chan *httpsDialerTactic { - return p.MockLookupTactics(ctx, domain, port) -} - func TestStatsPolicyFilterStatsTactics(t *testing.T) { t.Run("we do nothing when good is false", func(t *testing.T) { tactics := statsPolicyFilterStatsTactics(nil, false) diff --git a/internal/enginenetx/testhelperspolicy.go b/internal/enginenetx/testhelperspolicy.go new file mode 100644 index 000000000..482307ead --- /dev/null +++ b/internal/enginenetx/testhelperspolicy.go @@ -0,0 +1,71 @@ +package enginenetx + +import ( + "context" + "slices" +) + +// testHelpersDomains is our understanding of TH domains. +var testHelpersDomains = []string{ + "0.th.ooni.org", + "1.th.ooni.org", + "2.th.ooni.org", + "3.th.ooni.org", + "d33d1gs9kpq1c5.cloudfront.net", +} + +// testHelpersPolicy is a policy where we extend TH related policies +// by adding additional SNIs that it makes sense to try. +// +// The zero value is invalid; please, init MANDATORY fields. +type testHelpersPolicy struct { + // Child is the MANDATORY child policy. + Child httpsDialerPolicy +} + +var _ httpsDialerPolicy = &testHelpersPolicy{} + +// LookupTactics implements httpsDialerPolicy. +func (p *testHelpersPolicy) LookupTactics(ctx context.Context, domain, port string) <-chan *httpsDialerTactic { + out := make(chan *httpsDialerTactic) + + go func() { + // tell the parent when we're done + defer close(out) + + // collect tactics that we may want to modify later + var todo []*httpsDialerTactic + + // always emit the original tactic first + // + // See https://github.com/ooni/probe-cli/pull/1552 review for + // a rationale of why we're emitting the original first + for tactic := range p.Child.LookupTactics(ctx, domain, port) { + out <- tactic + + // When we're not connecting to a TH, our job is done + if !slices.Contains(testHelpersDomains, tactic.VerifyHostname) { + continue + } + + // otherwise, let's rememeber to modify this later + todo = append(todo, tactic) + } + + // This is the case where we're connecting to a test helper. Let's try + // to produce tactics using different SNIs for the domain. + for _, tactic := range todo { + for _, sni := range bridgesDomainsInRandomOrder() { + out <- &httpsDialerTactic{ + Address: tactic.Address, + InitialDelay: 0, // set when dialing + Port: tactic.Port, + SNI: sni, + VerifyHostname: tactic.VerifyHostname, + } + } + } + }() + + return out +} diff --git a/internal/enginenetx/testhelperspolicy_test.go b/internal/enginenetx/testhelperspolicy_test.go new file mode 100644 index 000000000..964f06e8b --- /dev/null +++ b/internal/enginenetx/testhelperspolicy_test.go @@ -0,0 +1,144 @@ +package enginenetx + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestTestHelpersPolicy(t *testing.T) { + + // testHelperTactics contains tactics related to test helpers + testHelperTactics := []*httpsDialerTactic{{ + Address: "18.195.190.71", + InitialDelay: 0, + Port: "443", + SNI: "0.th.ooni.org", + VerifyHostname: "0.th.ooni.org", + }, { + Address: "18.198.214.127", + InitialDelay: 0, + Port: "443", + SNI: "0.th.ooni.org", + VerifyHostname: "0.th.ooni.org", + }} + + // wwwExampleComTactics contains tactics related to www.example.com + wwwExampleComTactic := []*httpsDialerTactic{{ + Address: "93.184.215.14", + InitialDelay: 0, + Port: "443", + SNI: "www.example.com", + VerifyHostname: "www.example.com", + }, { + Address: "2606:2800:21f:cb07:6820:80da:af6b:8b2c", + InitialDelay: 0, + Port: "443", + SNI: "www.example.com", + VerifyHostname: "www.example.com", + }} + + // testcase is a test case implemented by this function + type testcase struct { + // name is the test case name + name string + + // childTactics contains the tactics that the child policy + // should return when invoked by the policy + childTactics []*httpsDialerTactic + + // domain is the domain to attempt to obtain tactics for + domain string + + // expectExtra contains the number of expected tactics + // we want to see beyond the child tactics above + expectExtra int + } + + cases := []testcase{{ + name: "when the children does not return any tactic, duh", + childTactics: nil, + domain: "www.example.com", + expectExtra: 0, + }, { + name: "when the children returns a non-TH domain", + childTactics: wwwExampleComTactic, + domain: wwwExampleComTactic[0].VerifyHostname, + expectExtra: 0, + }, { + name: "when the children returns a TH domain", + childTactics: testHelperTactics, + domain: testHelperTactics[0].VerifyHostname, + expectExtra: 304, + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + + // create the policy that we're testing + // + // note how the child policy is just returning the expected + // set of child tactics in the original order + policy := &testHelpersPolicy{ + Child: &mocksPolicy{ + MockLookupTactics: func(ctx context.Context, domain, port string) <-chan *httpsDialerTactic { + output := make(chan *httpsDialerTactic) + go func() { + defer close(output) + for _, entry := range tc.childTactics { + output <- entry + } + }() + return output + }, + }, + } + + // start to generate tactics for the given domain + generator := policy.LookupTactics(context.Background(), tc.domain, "443") + + // obtain all the tactics + var tactics []*httpsDialerTactic + for entry := range generator { + tactics = append(tactics, entry) + } + + // make sure we have the expected number of tactics + // at the beginning of the list + if len(tactics) < len(tc.childTactics) { + t.Fatal("expected at least", len(tc.childTactics), "got", len(tactics)) + } + + // if there are expected tactics make sure they + // indeed match our expectations + if len(tc.childTactics) > 0 { + if diff := cmp.Diff(tc.childTactics, tactics[:len(tc.childTactics)]); diff != "" { + t.Fatal(diff) + } + } + + // make sure we have the expected nymber of extras + if diff := len(tactics) - len(tc.childTactics); diff != tc.expectExtra { + t.Fatal("expected", tc.expectExtra, "extras but got", diff) + return + } + + // if the expected number of extras is zero, what are we still + // doing here and why don't we return like now? + if tc.expectExtra <= 0 { + return + } + + // make sure we're not going to expose the domain via the SNI + for _, entry := range tactics[len(tc.childTactics):] { + if entry.SNI == tc.domain { + t.Fatal("did not expect to see", tc.domain, "but got", entry.SNI) + } + if entry.VerifyHostname != tc.domain { + t.Fatal("expected to see", tc.domain, "but got", entry.VerifyHostname) + } + } + }) + } +} diff --git a/internal/enginenetx/userpolicy.go b/internal/enginenetx/userpolicy.go index 778c1393f..b782b8313 100644 --- a/internal/enginenetx/userpolicy.go +++ b/internal/enginenetx/userpolicy.go @@ -18,6 +18,83 @@ import ( "github.com/ooni/probe-cli/v3/internal/model" ) +// userPolicyV2 is an [httpsDialerPolicy] incorporating verbatim +// a user policy loaded from the engine's key-value store. +// +// This policy is very useful for exploration and experimentation. +// +// This is v2 of the userPolicy because the previous implementation +// incorporated mixing logic, while now the mixing happens outside +// of this policy, thus giving us much more flexibility. +type userPolicyV2 struct { + // Root is the root of the user policy loaded from disk. + Root *userPolicyRoot +} + +// newUserPolicyV2 attempts to constructs a user policy. The typical error case is the one +// in which there's no httpsDialerUserPolicyKey in the key-value store. +func newUserPolicyV2(kvStore model.KeyValueStore) (*userPolicyV2, error) { + // attempt to read the user policy bytes from the kvstore + data, err := kvStore.Get(userPolicyKey) + if err != nil { + return nil, err + } + + // attempt to parse the user policy using human-readable JSON + var root userPolicyRoot + if err := hujsonx.Unmarshal(data, &root); err != nil { + return nil, err + } + + // make sure the version is OK + if root.Version != userPolicyVersion { + err := fmt.Errorf( + "%s: %w: expected=%d got=%d", + userPolicyKey, + errUserPolicyWrongVersion, + userPolicyVersion, + root.Version, + ) + return nil, err + } + + out := &userPolicyV2{Root: &root} + return out, nil +} + +var _ httpsDialerPolicy = &userPolicyV2{} + +// LookupTactics implements httpsDialerPolicy. +func (ldp *userPolicyV2) LookupTactics(ctx context.Context, domain string, port string) <-chan *httpsDialerTactic { + // create the output channel + out := make(chan *httpsDialerTactic) + + go func() { + // make sure we close the output channel + defer close(out) + + // check whether an entry exists in the user-provided map, which MAY be nil + // if/when the user has chosen their policy to be as such + tactics, found := ldp.Root.DomainEndpoints[net.JoinHostPort(domain, port)] + if !found { + return + } + + // make sure that there are actionable entries here + tactics = userPolicyRemoveNilEntries(tactics) + if len(tactics) <= 0 { + return + } + + // emit all the user-configured tactics + for _, tactic := range tactics { + out <- tactic + } + }() + + return out +} + // userPolicy is an [httpsDialerPolicy] incorporating verbatim // a user policy loaded from the engine's key-value store. // diff --git a/internal/enginenetx/userpolicy_test.go b/internal/enginenetx/userpolicy_test.go index 9a7b8b3c9..2f4215e9c 100644 --- a/internal/enginenetx/userpolicy_test.go +++ b/internal/enginenetx/userpolicy_test.go @@ -13,6 +13,278 @@ import ( "github.com/ooni/probe-cli/v3/internal/runtimex" ) +func TestUserPolicyV2(t *testing.T) { + t.Run("newUserPolicyV2", func(t *testing.T) { + // testcase is a test case implemented by this function + type testcase struct { + // name is the test case name + name string + + // key is the key to use for settings the input inside the kvstore + key string + + // input contains the serialized input bytes + input []byte + + // expectErr contains the expected error string or the empty string on success + expectErr string + + // expectRoot contains the expected policy we loaded or nil + expectedPolicy *userPolicyV2 + } + + cases := []testcase{{ + name: "when there is no key in the kvstore", + key: "", + input: []byte{}, + expectErr: "no such key", + expectedPolicy: nil, + }, { + name: "with nil input", + key: userPolicyKey, + input: nil, + expectErr: "hujson: line 1, column 1: parsing value: unexpected EOF", + expectedPolicy: nil, + }, { + name: "with invalid serialized JSON", + key: userPolicyKey, + input: []byte(`{`), + expectErr: "hujson: line 1, column 2: parsing value: unexpected EOF", + expectedPolicy: nil, + }, { + name: "with empty JSON", + key: userPolicyKey, + input: []byte(`{}`), + expectErr: "bridges.conf: wrong user policy version: expected=3 got=0", + expectedPolicy: nil, + }, { + name: "with real serialized policy", + key: userPolicyKey, + input: (func() []byte { + return runtimex.Try1(json.Marshal(&userPolicyRoot{ + DomainEndpoints: map[string][]*httpsDialerTactic{ + + // Please, note how the input includes explicitly nil entries + // with the purpose of making sure the code can handle them + "api.ooni.io:443": {{ + Address: "162.55.247.208", + InitialDelay: 0, + Port: "443", + SNI: "api.ooni.io", + VerifyHostname: "api.ooni.io", + }, nil, { + Address: "46.101.82.151", + InitialDelay: 300 * time.Millisecond, + Port: "443", + SNI: "api.ooni.io", + VerifyHostname: "api.ooni.io", + }, { + Address: "2a03:b0c0:1:d0::ec4:9001", + InitialDelay: 600 * time.Millisecond, + Port: "443", + SNI: "api.ooni.io", + VerifyHostname: "api.ooni.io", + }, nil, { + Address: "46.101.82.151", + InitialDelay: 3000 * time.Millisecond, + Port: "443", + SNI: "www.example.com", + VerifyHostname: "api.ooni.io", + }, { + Address: "2a03:b0c0:1:d0::ec4:9001", + InitialDelay: 3300 * time.Millisecond, + Port: "443", + SNI: "www.example.com", + VerifyHostname: "api.ooni.io", + }, nil}, + // + + }, + Version: userPolicyVersion, + })) + })(), + expectErr: "", + expectedPolicy: &userPolicyV2{ + Root: &userPolicyRoot{ + DomainEndpoints: map[string][]*httpsDialerTactic{ + "api.ooni.io:443": {{ + Address: "162.55.247.208", + InitialDelay: 0, + Port: "443", + SNI: "api.ooni.io", + VerifyHostname: "api.ooni.io", + }, nil, { + Address: "46.101.82.151", + InitialDelay: 300 * time.Millisecond, + Port: "443", + SNI: "api.ooni.io", + VerifyHostname: "api.ooni.io", + }, { + Address: "2a03:b0c0:1:d0::ec4:9001", + InitialDelay: 600 * time.Millisecond, + Port: "443", + SNI: "api.ooni.io", + VerifyHostname: "api.ooni.io", + }, nil, { + Address: "46.101.82.151", + InitialDelay: 3000 * time.Millisecond, + Port: "443", + SNI: "www.example.com", + VerifyHostname: "api.ooni.io", + }, { + Address: "2a03:b0c0:1:d0::ec4:9001", + InitialDelay: 3300 * time.Millisecond, + Port: "443", + SNI: "www.example.com", + VerifyHostname: "api.ooni.io", + }, nil}, + }, + Version: userPolicyVersion, + }, + }, + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + kvStore := &kvstore.Memory{} + runtimex.Try0(kvStore.Set(tc.key, tc.input)) + + policy, err := newUserPolicyV2(kvStore) + + switch { + case err != nil && tc.expectErr == "": + t.Fatal("expected", tc.expectErr, "got", err) + + case err == nil && tc.expectErr != "": + t.Fatal("expected", tc.expectErr, "got", err) + + case err != nil && tc.expectErr != "": + if diff := cmp.Diff(tc.expectErr, err.Error()); diff != "" { + t.Fatal(diff) + } + + case err == nil && tc.expectErr == "": + // all good + } + + if diff := cmp.Diff(tc.expectedPolicy, policy); diff != "" { + t.Fatal(diff) + } + }) + } + }) + + t.Run("LookupTactics", func(t *testing.T) { + // define the tactic we would expect to see + expectedTactic := &httpsDialerTactic{ + Address: "162.55.247.208", + InitialDelay: 0, + Port: "443", + SNI: "www.example.com", + VerifyHostname: "api.ooni.io", + } + + // define the root of the user policy + userPolicyRoot := &userPolicyRoot{ + DomainEndpoints: map[string][]*httpsDialerTactic{ + // Note that here we're adding explicitly nil entries + // to make sure that the code correctly handles 'em + "api.ooni.io:443": { + nil, + expectedTactic, + nil, + }, + + // We add additional entries to make sure that in those + // cases we are going to get nil entries as they're basically + // empty and so non-actionable for us. + "api.ooni.xyz:443": nil, + "api.ooni.org:443": {}, + "api.ooni.com:443": {nil, nil, nil}, + }, + Version: userPolicyVersion, + } + + // serialize into a key-value store running in memory + kvStore := &kvstore.Memory{} + rawUserPolicyRoot := runtimex.Try1(json.Marshal(userPolicyRoot)) + if err := kvStore.Set(userPolicyKey, rawUserPolicyRoot); err != nil { + t.Fatal(err) + } + + t.Run("with user policy", func(t *testing.T) { + ctx := context.Background() + + policy, err := newUserPolicyV2(kvStore) + if err != nil { + t.Fatal(err) + } + + tactics := policy.LookupTactics(ctx, "api.ooni.io", "443") + got := []*httpsDialerTactic{} + for tactic := range tactics { + t.Logf("%+v", tactic) + got = append(got, tactic) + } + + expect := []*httpsDialerTactic{expectedTactic} + + if diff := cmp.Diff(expect, got); diff != "" { + t.Fatal(diff) + } + }) + + t.Run("we get nothing if there is no entry in the user policy", func(t *testing.T) { + ctx := context.Background() + + policy, err := newUserPolicyV2(kvStore) + if err != nil { + t.Fatal(err) + } + + tactics := policy.LookupTactics(ctx, "www.example.com", "443") + got := []*httpsDialerTactic{} + for tactic := range tactics { + t.Logf("%+v", tactic) + got = append(got, tactic) + } + + expect := []*httpsDialerTactic{} + + if diff := cmp.Diff(expect, got); diff != "" { + t.Fatal(diff) + } + }) + + t.Run("we get nothing if the entry in the user policy is ~empty", func(t *testing.T) { + ctx := context.Background() + + policy, err := newUserPolicyV2(kvStore) + if err != nil { + t.Fatal(err) + } + + // these cases are specially constructed to be empty/invalid user policies + for _, domain := range []string{"api.ooni.xyz", "api.ooni.org", "api.ooni.com"} { + t.Run(domain, func(t *testing.T) { + tactics := policy.LookupTactics(ctx, domain, "443") + got := []*httpsDialerTactic{} + for tactic := range tactics { + t.Logf("%+v", tactic) + got = append(got, tactic) + } + + expect := []*httpsDialerTactic{} + + if diff := cmp.Diff(expect, got); diff != "" { + t.Fatal(diff) + } + }) + } + }) + }) +} + func TestUserPolicy(t *testing.T) { t.Run("newUserPolicy", func(t *testing.T) { // testcase is a test case implemented by this function