package tor import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "testing" "github.com/apex/log" "github.com/google/go-cmp/cmp" "github.com/ooni/probe-cli/v3/internal/engine/mockable" "github.com/ooni/probe-cli/v3/internal/measurex" "github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/netxlite" "github.com/ooni/probe-cli/v3/internal/scrubber" ) func TestNewExperimentMeasurer(t *testing.T) { measurer := NewExperimentMeasurer(Config{}) if measurer.ExperimentName() != "tor" { t.Fatal("unexpected name") } if measurer.ExperimentVersion() != "0.4.0" { t.Fatal("unexpected version") } } func TestMeasurerMeasureFetchTorTargetsError(t *testing.T) { measurer := NewMeasurer(Config{}) expected := errors.New("mocked error") measurer.fetchTorTargets = func(ctx context.Context, sess model.ExperimentSession, cc string) (map[string]model.OOAPITorTarget, error) { return nil, expected } err := measurer.Run( context.Background(), &mockable.Session{ MockableLogger: log.Log, }, new(model.Measurement), model.NewPrinterCallbacks(log.Log), ) if !errors.Is(err, expected) { t.Fatal("not the error we expected") } } func TestMeasurerMeasureFetchTorTargetsEmptyList(t *testing.T) { measurer := NewMeasurer(Config{}) measurer.fetchTorTargets = func(ctx context.Context, sess model.ExperimentSession, cc string) (map[string]model.OOAPITorTarget, error) { return nil, nil } measurement := new(model.Measurement) err := measurer.Run( context.Background(), &mockable.Session{ MockableLogger: log.Log, }, measurement, model.NewPrinterCallbacks(log.Log), ) if err != nil { t.Fatal(err) } tk := measurement.TestKeys.(*TestKeys) if len(tk.Targets) != 0 { t.Fatal("expected no targets here") } } func TestMeasurerMeasureGoodWithMockedOrchestra(t *testing.T) { // This test mocks orchestra to return a nil list of targets, so the code runs // but we don't perform any actual network actions. measurer := NewMeasurer(Config{}) measurer.fetchTorTargets = func(ctx context.Context, sess model.ExperimentSession, cc string) (map[string]model.OOAPITorTarget, error) { return nil, nil } err := measurer.Run( context.Background(), &mockable.Session{ MockableLogger: log.Log, }, new(model.Measurement), model.NewPrinterCallbacks(log.Log), ) if err != nil { t.Fatal(err) } } func TestMeasurerMeasureGood(t *testing.T) { if testing.Short() { t.Skip("skip test in short mode") } measurer := NewMeasurer(Config{}) sess := newsession() measurement := new(model.Measurement) err := measurer.Run( context.Background(), sess, measurement, model.NewPrinterCallbacks(log.Log), ) if err != nil { t.Fatal(err) } sk, err := measurer.GetSummaryKeys(measurement) if err != nil { t.Fatal(err) } if _, ok := sk.(SummaryKeys); !ok { t.Fatal("invalid type for summary keys") } } var staticPrivateTestingTargetEndpoint = "209.148.46.65:443" var staticPrivateTestingTarget = model.OOAPITorTarget{ Address: staticPrivateTestingTargetEndpoint, Params: map[string][]string{ "cert": { "ssH+9rP8dG2NLDN2XuFw63hIO/9MNNinLmxQDpVa+7kTOa9/m+tGWT1SmSYpQ9uTBGa6Hw", }, "iat-mode": {"0"}, }, Protocol: "obfs4", Source: "bridgedb", } func TestMeasurerMeasureSanitiseOutput(t *testing.T) { if testing.Short() { t.Skip("skip test in short mode") } measurer := NewMeasurer(Config{}) sess := newsession() key := "xyz-xyz-xyz-theCh2ju-ahG4chei-Ai2eka0a" sess.MockableFetchTorTargetsResult = map[string]model.OOAPITorTarget{ key: staticPrivateTestingTarget, } measurement := new(model.Measurement) err := measurer.Run( context.Background(), sess, measurement, model.NewPrinterCallbacks(log.Log), ) if err != nil { t.Fatal(err) } data, err := json.Marshal(measurement) if err != nil { t.Fatal(err) } tk := measurement.TestKeys.(*TestKeys) entry := tk.Targets[key] if entry.Failure != nil { t.Fatal("measurement failed unexpectedly", *entry.Failure) } if !bytes.Contains(data, []byte(key)) { t.Fatal("cannot find expected key") } if bytes.Contains(data, []byte(staticPrivateTestingTargetEndpoint)) { t.Fatal("endpoint found in serialized measurement") } if !bytes.Contains(data, []byte("[scrubbed]")) { t.Fatal("[scrubbed] not found in serialized measurement") } } var staticTestingTargets = []model.OOAPITorTarget{ { Address: "192.95.36.142:443", Params: map[string][]string{ "cert": { "qUVQ0srL1JI/vO6V6m/24anYXiJD3QP2HgzUKQtQ7GRqqUvs7P+tG43RtAqdhLOALP7DJQ", }, "iat-mode": {"1"}, }, Protocol: "obfs4", }, { Address: "66.111.2.131:9030", Protocol: "dir_port", }, { Address: "66.111.2.131:9001", Protocol: "or_port", }, { Address: "1.1.1.1:80", Protocol: "tcp", }, } func TestMeasurerMeasureTargetsNoInput(t *testing.T) { var measurement model.Measurement measurer := new(Measurer) measurer.measureTargets( context.Background(), &mockable.Session{ MockableLogger: log.Log, }, &measurement, model.NewPrinterCallbacks(log.Log), nil, ) if len(measurement.TestKeys.(*TestKeys).Targets) != 0 { t.Fatal("expected no measurements here") } } func TestMeasurerMeasureTargetsCanceledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // so we don't actually do anything var measurement model.Measurement measurer := new(Measurer) measurer.measureTargets( ctx, &mockable.Session{ MockableLogger: log.Log, }, &measurement, model.NewPrinterCallbacks(log.Log), map[string]model.OOAPITorTarget{ "xx": staticTestingTargets[0], }, ) targets := measurement.TestKeys.(*TestKeys).Targets if len(targets) != 1 { t.Fatal("expected single measurements here") } if _, found := targets["xx"]; !found { t.Fatal("the target we expected is missing") } tgt := targets["xx"] if *tgt.Failure != "interrupted" { t.Fatal("not the error we expected") } } func wrapTestingTarget(tt model.OOAPITorTarget) keytarget { return keytarget{ key: "xx", // using an super simple key; should work anyway target: tt, } } func TestResultsCollectorMeasureSingleTargetGood(t *testing.T) { rc := newResultsCollector( &mockable.Session{ MockableLogger: log.Log, }, new(model.Measurement), model.NewPrinterCallbacks(log.Log), ) rc.flexibleConnect = func(context.Context, keytarget) (*measurex.ArchivalMeasurement, *string) { return &measurex.ArchivalMeasurement{}, nil } rc.measureSingleTarget( context.Background(), wrapTestingTarget(staticTestingTargets[0]), len(staticTestingTargets), ) if len(rc.targetresults) != 1 { t.Fatal("wrong number of entries") } // Implementation note: here we won't bother with checking that // oonidatamodel works correctly because we already test that. if rc.targetresults["xx"].Agent != "redirect" { t.Fatal("agent is invalid") } if rc.targetresults["xx"].Failure != nil { t.Fatal("failure is invalid") } if rc.targetresults["xx"].TargetAddress != staticTestingTargets[0].Address { t.Fatal("target address is invalid") } if rc.targetresults["xx"].TargetProtocol != staticTestingTargets[0].Protocol { t.Fatal("target protocol is invalid") } } func TestResultsCollectorMeasureSingleTargetWithFailure(t *testing.T) { rc := newResultsCollector( &mockable.Session{ MockableLogger: log.Log, }, new(model.Measurement), model.NewPrinterCallbacks(log.Log), ) rc.flexibleConnect = func(context.Context, keytarget) (*measurex.ArchivalMeasurement, *string) { failure := "mocked error" return &measurex.ArchivalMeasurement{}, &failure } rc.measureSingleTarget( context.Background(), keytarget{ key: "xx", // using an super simple key; should work anyway target: staticTestingTargets[0], }, len(staticTestingTargets), ) if len(rc.targetresults) != 1 { t.Fatal("wrong number of entries") } // Implementation note: here we won't bother with checking that // oonidatamodel works correctly because we already test that. if rc.targetresults["xx"].Agent != "redirect" { t.Fatal("agent is invalid") } if *rc.targetresults["xx"].Failure != "mocked error" { t.Fatal("failure is invalid") } if rc.targetresults["xx"].TargetAddress != staticTestingTargets[0].Address { t.Fatal("target address is invalid") } if rc.targetresults["xx"].TargetProtocol != staticTestingTargets[0].Protocol { t.Fatal("target protocol is invalid") } } func TestDefautFlexibleConnectDirPort(t *testing.T) { rc := newResultsCollector( &mockable.Session{ MockableLogger: log.Log, }, new(model.Measurement), model.NewPrinterCallbacks(log.Log), ) ctx, cancel := context.WithCancel(context.Background()) cancel() tk, failure := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[1])) if failure == nil { t.Fatal("expected a failure here") } if !strings.HasSuffix(*failure, "interrupted") { t.Fatal("not the error we expected") } if tk.Requests == nil { t.Fatal("expected HTTP data here") } } func TestDefautFlexibleConnectOrPort(t *testing.T) { rc := newResultsCollector( &mockable.Session{ MockableLogger: log.Log, }, new(model.Measurement), model.NewPrinterCallbacks(log.Log), ) ctx, cancel := context.WithCancel(context.Background()) cancel() tk, failure := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[2])) if failure == nil { t.Fatal("expected a failure here") } if *failure != "interrupted" { t.Fatal("not the error we expected") } if tk.TCPConnect == nil { t.Fatal("expected connects data here") } if tk.NetworkEvents != nil { t.Fatal("expected no network events data here") } } func TestDefautFlexibleConnectOBFS4(t *testing.T) { rc := newResultsCollector( &mockable.Session{ MockableLogger: log.Log, }, new(model.Measurement), model.NewPrinterCallbacks(log.Log), ) ctx, cancel := context.WithCancel(context.Background()) cancel() tk, failure := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[0])) if failure == nil { t.Fatal("expected a failure here") } if *failure != "interrupted" { t.Fatal("not the error we expected") } if tk.TCPConnect == nil { t.Fatal("expected connects data here") } if tk.NetworkEvents != nil { t.Fatal("expected no network events data here") } } func TestDefautFlexibleConnectDefault(t *testing.T) { rc := newResultsCollector( &mockable.Session{ MockableLogger: log.Log, }, new(model.Measurement), model.NewPrinterCallbacks(log.Log), ) ctx, cancel := context.WithCancel(context.Background()) cancel() tk, failure := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[3])) if failure == nil { t.Fatal("expected a failure here") } if *failure != "interrupted" { t.Fatalf("not the error we expected: %+v", *failure) } if tk.TCPConnect == nil { t.Fatalf("expected connects data here, found: %+v", tk.TCPConnect) } } func TestFailureString(t *testing.T) { if failureString(nil) != "success" { t.Fatal("not working with nil") } s := "antani" if failureString(&s) != "antani" { t.Fatal("not working with non-nil string") } } func TestSummary(t *testing.T) { t.Run("without any piece of data", func(t *testing.T) { tr := new(TargetResults) tr.fillSummary() if len(tr.Summary) != 0 { t.Fatal("summary must be empty") } }) t.Run("with a TCP connect and nothing else", func(t *testing.T) { tr := new(TargetResults) failure := "mocked_error" tr.TCPConnect = append(tr.TCPConnect, &measurex.ArchivalTCPConnect{ Status: &measurex.ArchivalTCPConnectStatus{ Success: true, Failure: &failure, }, }) tr.fillSummary() if len(tr.Summary) != 1 { t.Fatal("cannot find expected entry") } if *tr.Summary[netxlite.ConnectOperation].Failure != failure { t.Fatal("invalid failure") } }) t.Run("for OBFS4", func(t *testing.T) { tr := new(TargetResults) tr.TCPConnect = append(tr.TCPConnect, &measurex.ArchivalTCPConnect{ Status: &measurex.ArchivalTCPConnectStatus{ Success: true, }, }) failure := "mocked_error" tr.TargetProtocol = "obfs4" tr.Failure = &failure tr.fillSummary() if len(tr.Summary) != 2 { t.Fatal("cannot find expected entry") } if tr.Summary[netxlite.ConnectOperation].Failure != nil { t.Fatal("invalid failure") } if *tr.Summary["handshake"].Failure != failure { t.Fatal("invalid failure") } }) t.Run("for or_port/or_port_dirauth", func(t *testing.T) { doit := func(targetProtocol string, handshake *measurex.ArchivalQUICTLSHandshakeEvent) { tr := new(TargetResults) tr.TCPConnect = append(tr.TCPConnect, &measurex.ArchivalTCPConnect{ Status: &measurex.ArchivalTCPConnectStatus{ Success: true, }, }) tr.TargetProtocol = targetProtocol if handshake != nil { tr.TLSHandshakes = append(tr.TLSHandshakes, handshake) } tr.fillSummary() if len(tr.Summary) < 1 { t.Fatal("cannot find expected entry") } if tr.Summary[netxlite.ConnectOperation].Failure != nil { t.Fatal("invalid failure") } if handshake == nil { if len(tr.Summary) != 1 { t.Fatal("unexpected summary length") } return } if len(tr.Summary) != 2 { t.Fatal("unexpected summary length") } if tr.Summary["handshake"].Failure != handshake.Failure { t.Fatal("the failure value is unexpected") } } doit("or_port_dirauth", nil) doit("or_port", nil) doit("or_port", &measurex.ArchivalQUICTLSHandshakeEvent{ Failure: (func() *string { s := io.EOF.Error() return &s })(), }) }) } func TestFillToplevelKeys(t *testing.T) { var tr TargetResults tr.TargetProtocol = "or_port" tk := new(TestKeys) tk.Targets = make(map[string]TargetResults) tk.Targets["xxx"] = tr tk.fillToplevelKeys() if tk.ORPortTotal != 1 { t.Fatal("unexpected ORPortTotal value") } } func newsession() *mockable.Session { return &mockable.Session{ MockableLogger: log.Log, MockableHTTPClient: http.DefaultClient, } } var referenceTargetResult = []byte(`{ "agent": "redirect", "failure": null, "network_events": [ { "address": "85.31.186.98:443", "conn_id": 19, "dial_id": 21, "failure": null, "operation": "connect", "proto": "tcp", "t": 8.639313 }, { "conn_id": 19, "failure": null, "num_bytes": 1915, "operation": "write", "proto": "tcp", "t": 8.639686 }, { "conn_id": 19, "failure": null, "num_bytes": 1440, "operation": "read", "proto": "tcp", "t": 8.691708 }, { "conn_id": 19, "failure": null, "num_bytes": 1440, "operation": "read", "proto": "tcp", "t": 8.691912 }, { "conn_id": 19, "failure": null, "num_bytes": 1383, "operation": "read", "proto": "tcp", "t": 8.69234 } ], "queries": null, "requests": null, "summary": { "connect": { "failure": null } }, "target_address": "85.31.186.98:443", "target_protocol": "obfs4", "tcp_connect": [ { "conn_id": 19, "dial_id": 21, "ip": "85.31.186.98", "port": 443, "status": { "failure": null, "success": true }, "t": 8.639313 } ], "tls_handshakes": null }`) var scrubbedTargetResult = []byte(`{ "agent": "redirect", "failure": null, "network_events": [ { "address": "[scrubbed]", "conn_id": 19, "dial_id": 21, "failure": null, "operation": "connect", "proto": "tcp", "t": 8.639313 }, { "conn_id": 19, "failure": null, "num_bytes": 1915, "operation": "write", "proto": "tcp", "t": 8.639686 }, { "conn_id": 19, "failure": null, "num_bytes": 1440, "operation": "read", "proto": "tcp", "t": 8.691708 }, { "conn_id": 19, "failure": null, "num_bytes": 1440, "operation": "read", "proto": "tcp", "t": 8.691912 }, { "conn_id": 19, "failure": null, "num_bytes": 1383, "operation": "read", "proto": "tcp", "t": 8.69234 } ], "queries": null, "requests": null, "summary": { "connect": { "failure": null } }, "target_address": "[scrubbed]", "target_protocol": "obfs4", "tcp_connect": [ { "conn_id": 19, "dial_id": 21, "ip": "[scrubbed]", "port": 443, "status": { "failure": null, "success": true }, "t": 8.639313 } ], "tls_handshakes": null }`) func TestMaybeSanitize(t *testing.T) { var input TargetResults if err := json.Unmarshal(referenceTargetResult, &input); err != nil { t.Fatal(err) } t.Run("nothing to do", func(t *testing.T) { out := maybeSanitize(input, keytarget{target: model.OOAPITorTarget{Source: ""}}) diff := cmp.Diff(input, out) if diff != "" { t.Fatal(diff) } }) t.Run("scrubbing to do", func(t *testing.T) { var expected TargetResults if err := json.Unmarshal(scrubbedTargetResult, &expected); err != nil { t.Fatal(err) } out := maybeSanitize(input, keytarget{target: model.OOAPITorTarget{ Address: "85.31.186.98:443", Source: "bridgedb", }}) diff := cmp.Diff(expected, out) if diff != "" { t.Fatal(diff) } }) } func TestMaybeScrubbingLogger(t *testing.T) { var input model.Logger = log.Log t.Run("for when we don't need to save", func(t *testing.T) { kt := keytarget{target: model.OOAPITorTarget{ Source: "", }} out := maybeScrubbingLogger(input, kt) if out != input { t.Fatal("not the output we expected") } if _, ok := out.(*scrubber.Logger); ok { t.Fatal("not the output type we expected") } }) t.Run("for when we need to save", func(t *testing.T) { kt := keytarget{target: model.OOAPITorTarget{ Source: "bridgedb", }} out := maybeScrubbingLogger(input, kt) if out == input { t.Fatal("not the output value we expected") } if _, ok := out.(*scrubber.Logger); !ok { t.Fatal("not the output type we expected") } }) } func TestSummaryKeysInvalidType(t *testing.T) { measurement := new(model.Measurement) m := &Measurer{} _, err := m.GetSummaryKeys(measurement) if err.Error() != "invalid test keys type" { t.Fatal("not the error we expected") } } func TestSummaryKeysWorksAsIntended(t *testing.T) { tests := []struct { tk TestKeys isAnomaly bool }{{ tk: TestKeys{}, isAnomaly: false, }, { tk: TestKeys{DirPortAccessible: 1, DirPortTotal: 3}, isAnomaly: false, }, { tk: TestKeys{DirPortAccessible: 0, DirPortTotal: 3}, isAnomaly: true, }, { tk: TestKeys{OBFS4Accessible: 1, OBFS4Total: 3}, isAnomaly: false, }, { tk: TestKeys{OBFS4Accessible: 0, OBFS4Total: 3}, isAnomaly: true, }, { tk: TestKeys{ORPortDirauthAccessible: 1, ORPortDirauthTotal: 3}, isAnomaly: false, }, { tk: TestKeys{ORPortDirauthAccessible: 0, ORPortDirauthTotal: 3}, isAnomaly: true, }, { tk: TestKeys{ORPortAccessible: 1, ORPortTotal: 3}, isAnomaly: false, }, { tk: TestKeys{ORPortAccessible: 0, ORPortTotal: 3}, isAnomaly: true, }} for idx, tt := range tests { t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { m := &Measurer{} measurement := &model.Measurement{TestKeys: &tt.tk} got, err := m.GetSummaryKeys(measurement) if err != nil { t.Fatal(err) return } sk := got.(SummaryKeys) if sk.IsAnomaly != tt.isAnomaly { t.Fatal("unexpected isAnomaly value") } }) } } func TestTargetResultsFillSummaryDirPort(t *testing.T) { tr := &TargetResults{ TargetProtocol: "dir_port", TCPConnect: []*measurex.ArchivalTCPConnect{{ IP: "1.2.3.4", Port: 443, Status: &measurex.ArchivalTCPConnectStatus{ Failure: nil, }, }}, } tr.fillSummary() if tr.DirPortCount != 1 { t.Fatal("unexpected dirPortCount") } } func TestTestKeysFillToplevelKeysCoverMissingFields(t *testing.T) { failureString := "eof_error" tk := &TestKeys{ Targets: map[string]TargetResults{ "foobar": {Failure: &failureString, TargetProtocol: "dir_port"}, "baz": {TargetProtocol: "dir_port"}, "jafar": {Failure: &failureString, TargetProtocol: "or_port_dirauth"}, "jasmine": {TargetProtocol: "or_port_dirauth"}, }, } tk.fillToplevelKeys() if tk.DirPortTotal != 2 { t.Fatal("unexpected DirPortTotal") } if tk.DirPortAccessible != 1 { t.Fatal("unexpected DirPortAccessible") } if tk.ORPortDirauthTotal != 2 { t.Fatal("unexpected ORPortDirauthTotal") } if tk.ORPortDirauthAccessible != 1 { t.Fatal("unexpected ORPortDirauthAccessible") } }