ooni-probe-cli/internal/engine/experiment/tor/tor_test.go
Simone Basso 83440cf110
refactor: split errorsx in good and legacy (#477)
The legacy part for now is internal/errorsx. It will stay there until
I figure out whether it also needs some extra bug fixing.

The good part is now in internal/netxlite/errorsx and contains all the
logic for mapping errors. We need to further improve upon this logic
by writing more thorough integration tests for QUIC.

We also need to copy the various dialer, conn, etc adapters that set
errors. We will put them inside netxlite and we will generate errors in
a way that is less crazy with respect to the major operation. (The
idea is to always wrap, given that now we measure in an incremental way
and we don't measure every operation together.)

Part of https://github.com/ooni/probe/issues/1591
2021-09-07 17:09:30 +02:00

837 lines
20 KiB
Go

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/legacy/oonidatamodel"
"github.com/ooni/probe-cli/v3/internal/engine/legacy/oonitemplates"
"github.com/ooni/probe-cli/v3/internal/engine/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/netxlite/errorsx"
"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.3.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.TorTarget, 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.TorTarget, 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.TorTarget, 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 = "192.95.36.142:443"
var staticPrivateTestingTarget = model.TorTarget{
Address: staticPrivateTestingTargetEndpoint,
Params: map[string][]string{
"cert": {
"qUVQ0srL1JI/vO6V6m/24anYXiJD3QP2HgzUKQtQ7GRqqUvs7P+tG43RtAqdhLOALP7DJQ",
},
"iat-mode": {"1"},
},
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.TorTarget{
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")
}
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.TorTarget{
{
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.TorTarget{
"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.TorTarget) 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) (oonitemplates.Results, error) {
return oonitemplates.Results{}, 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) (oonitemplates.Results, error) {
return oonitemplates.Results{}, errors.New("mocked error")
}
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, err := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[1]))
if err == nil {
t.Fatal("expected an error here")
}
if !strings.HasSuffix(err.Error(), "interrupted") {
t.Fatal("not the error we expected")
}
if tk.HTTPRequests == 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, err := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[2]))
if err == nil {
t.Fatal("expected an error here")
}
if err.Error() != "interrupted" {
t.Fatal("not the error we expected")
}
if tk.Connects == nil {
t.Fatal("expected connects data here")
}
if tk.NetworkEvents == nil {
t.Fatal("expected 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, err := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[0]))
if err == nil {
t.Fatal("expected an error here")
}
if err.Error() != "interrupted" {
t.Fatal("not the error we expected")
}
if tk.Connects == nil {
t.Fatal("expected connects data here")
}
if tk.NetworkEvents == nil {
t.Fatal("expected 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, err := rc.defaultFlexibleConnect(ctx, wrapTestingTarget(staticTestingTargets[3]))
if err == nil {
t.Fatal("expected an error here")
}
if err.Error() != "interrupted" {
t.Fatalf("not the error we expected: %+v", err)
}
if tk.Connects == nil {
t.Fatalf("expected connects data here, found: %+v", tk.Connects)
}
}
func TestErrString(t *testing.T) {
if errString(nil) != "success" {
t.Fatal("not working with nil")
}
if errString(errors.New("antani")) != "antani" {
t.Fatal("not working with error")
}
}
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, oonidatamodel.TCPConnectEntry{
Status: oonidatamodel.TCPConnectStatus{
Success: true,
Failure: &failure,
},
})
tr.fillSummary()
if len(tr.Summary) != 1 {
t.Fatal("cannot find expected entry")
}
if *tr.Summary[errorsx.ConnectOperation].Failure != failure {
t.Fatal("invalid failure")
}
})
t.Run("for OBFS4", func(t *testing.T) {
tr := new(TargetResults)
tr.TCPConnect = append(tr.TCPConnect, oonidatamodel.TCPConnectEntry{
Status: oonidatamodel.TCPConnectStatus{
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[errorsx.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 *oonidatamodel.TLSHandshake) {
tr := new(TargetResults)
tr.TCPConnect = append(tr.TCPConnect, oonidatamodel.TCPConnectEntry{
Status: oonidatamodel.TCPConnectStatus{
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[errorsx.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", &oonidatamodel.TLSHandshake{
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.TorTarget{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.TorTarget{
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.TorTarget{
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.TorTarget{
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: oonidatamodel.TCPConnectList{{
IP: "1.2.3.4",
Port: 443,
Status: oonidatamodel.TCPConnectStatus{
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")
}
}