feat(oonirun): improve tests (#915)

See https://github.com/ooni/probe/issues/2184

While there, rename `runtimex.PanicIfFalse` to `runtimex.Assert` (it was about time...)
This commit is contained in:
Simone Basso 2022-08-31 18:40:27 +02:00 committed by GitHub
parent a8a29cc0dd
commit d0da224a2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1837 additions and 112 deletions

View File

@ -17,7 +17,7 @@ func acquireUserConsent(miniooniDir string, currentOptions *Options) {
consentFile := path.Join(miniooniDir, "informed")
err := maybeWriteConsentFile(currentOptions.Yes, consentFile)
runtimex.PanicOnError(err, "cannot write informed consent file")
runtimex.PanicIfFalse(
runtimex.Assert(
regularFileExists(consentFile),
riskOfRunningOONI,
)

View File

@ -308,7 +308,7 @@ func mainSingleIteration(logger model.Logger, experimentName string, currentOpti
log.Infof("Current time: %s", time.Now().Format("2006-01-02 15:04:05 MST"))
homeDir := gethomedir(currentOptions.HomeDir)
runtimex.PanicIfFalse(homeDir != "", "home directory is empty")
runtimex.Assert(homeDir != "", "home directory is empty")
miniooniDir := path.Join(homeDir, ".miniooni")
err := os.MkdirAll(miniooniDir, 0700)
runtimex.PanicOnError(err, "cannot create $HOME/.miniooni directory")

View File

@ -11,6 +11,8 @@ import (
)
// Session allows to mock sessions.
//
// Deprecated: use ./internal/model/mocks.Session instead.
type Session struct {
MockableTestHelpers map[string][]model.OOAPIService
MockableHTTPClient model.HTTPClient

View File

@ -6,10 +6,8 @@ import (
"github.com/ooni/probe-cli/v3/internal/model"
)
// Saver saves a measurement on some persistent storage.
type Saver interface {
SaveMeasurement(m *model.Measurement) error
}
// Saver is an alias for model.Saver.
type Saver = model.Saver
// SaverConfig is the configuration for creating a new Saver.
type SaverConfig struct {

View File

@ -9,12 +9,8 @@ import (
// TODO(bassosimone): maybe keep track of which measurements
// could not be submitted by a specific submitter?
// Submitter submits a measurement to the OONI collector.
type Submitter interface {
// Submit submits the measurement and updates its
// report ID field in case of success.
Submit(ctx context.Context, m *model.Measurement) error
}
// Submitter is an alias for model.Submitter
type Submitter = model.Submitter
// SubmitterSession is the Submitter's view of the Session.
type SubmitterSession interface {

View File

@ -27,7 +27,7 @@ func NewFailure(err error) *string {
if !errors.As(err, &errWrapper) {
err := netxlite.NewTopLevelGenericErrWrapper(err)
couldConvert := errors.As(err, &errWrapper)
runtimex.PanicIfFalse(couldConvert, "we should have an ErrWrapper here")
runtimex.Assert(couldConvert, "we should have an ErrWrapper here")
}
s := errWrapper.Failure
if s == "" {

View File

@ -281,3 +281,25 @@ type ExperimentOptionInfo struct {
// Type contains the type.
Type string
}
// ExperimentInputLoader loads inputs from local or remote sources.
type ExperimentInputLoader interface {
Load(ctx context.Context) ([]OOAPIURLInfo, error)
}
// Submitter submits a measurement to the OONI collector.
type Submitter interface {
// Submit submits the measurement and updates its
// report ID field in case of success.
Submit(ctx context.Context, m *Measurement) error
}
// Saver saves a measurement on some persistent storage.
type Saver interface {
SaveMeasurement(m *Measurement) error
}
// ExperimentInputProcessor processes inputs for an experiment.
type ExperimentInputProcessor interface {
Run(ctx context.Context) error
}

View File

@ -0,0 +1,75 @@
package mocks
import (
"context"
"github.com/ooni/probe-cli/v3/internal/model"
)
// Experiment mocks model.Experiment
type Experiment struct {
MockKibiBytesReceived func() float64
MockKibiBytesSent func() float64
MockName func() string
MockGetSummaryKeys func(m *model.Measurement) (any, error)
MockReportID func() string
MockMeasureAsync func(ctx context.Context, input string) (<-chan *model.Measurement, error)
MockMeasureWithContext func(
ctx context.Context, input string) (measurement *model.Measurement, err error)
MockSaveMeasurement func(measurement *model.Measurement, filePath string) error
MockSubmitAndUpdateMeasurementContext func(
ctx context.Context, measurement *model.Measurement) error
MockOpenReportContext func(ctx context.Context) error
}
func (e *Experiment) KibiBytesReceived() float64 {
return e.MockKibiBytesReceived()
}
func (e *Experiment) KibiBytesSent() float64 {
return e.MockKibiBytesSent()
}
func (e *Experiment) Name() string {
return e.MockName()
}
func (e *Experiment) GetSummaryKeys(m *model.Measurement) (any, error) {
return e.MockGetSummaryKeys(m)
}
func (e *Experiment) ReportID() string {
return e.MockReportID()
}
func (e *Experiment) MeasureAsync(
ctx context.Context, input string) (<-chan *model.Measurement, error) {
return e.MockMeasureAsync(ctx, input)
}
func (e *Experiment) MeasureWithContext(
ctx context.Context, input string) (measurement *model.Measurement, err error) {
return e.MockMeasureWithContext(ctx, input)
}
func (e *Experiment) SaveMeasurement(measurement *model.Measurement, filePath string) error {
return e.MockSaveMeasurement(measurement, filePath)
}
func (e *Experiment) SubmitAndUpdateMeasurementContext(
ctx context.Context, measurement *model.Measurement) error {
return e.MockSubmitAndUpdateMeasurementContext(ctx, measurement)
}
func (e *Experiment) OpenReportContext(ctx context.Context) error {
return e.MockOpenReportContext(ctx)
}

View File

@ -0,0 +1,146 @@
package mocks
import (
"context"
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/model"
)
func TestExperiment(t *testing.T) {
t.Run("KibiBytesReceived", func(t *testing.T) {
expected := 1.0
e := &Experiment{
MockKibiBytesReceived: func() float64 {
return expected
},
}
if e.KibiBytesReceived() != expected {
t.Fatal("unexpected result")
}
})
t.Run("KibiBytesSent", func(t *testing.T) {
expected := 1.0
e := &Experiment{
MockKibiBytesSent: func() float64 {
return expected
},
}
if e.KibiBytesSent() != expected {
t.Fatal("unexpected result")
}
})
t.Run("Name", func(t *testing.T) {
expected := "antani"
e := &Experiment{
MockName: func() string {
return expected
},
}
if e.Name() != expected {
t.Fatal("unexpected result")
}
})
t.Run("GetSummaryKeys", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockGetSummaryKeys: func(m *model.Measurement) (any, error) {
return nil, expected
},
}
out, err := e.GetSummaryKeys(&model.Measurement{})
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if out != nil {
t.Fatal("invalid out")
}
})
t.Run("ReportID", func(t *testing.T) {
expect := "xyz"
e := &Experiment{
MockReportID: func() string {
return expect
},
}
if e.ReportID() != expect {
t.Fatal("invalid value")
}
})
t.Run("MeasureAsync", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockMeasureAsync: func(ctx context.Context, input string) (<-chan *model.Measurement, error) {
return nil, expected
},
}
out, err := e.MeasureAsync(context.Background(), "xo")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if out != nil {
t.Fatal("expected nil")
}
})
t.Run("MeasureWithContext", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockMeasureWithContext: func(ctx context.Context, input string) (measurement *model.Measurement, err error) {
return nil, expected
},
}
out, err := e.MeasureWithContext(context.Background(), "xo")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if out != nil {
t.Fatal("expected nil")
}
})
t.Run("SaveMeasurement", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockSaveMeasurement: func(measurement *model.Measurement, filePath string) error {
return expected
},
}
err := e.SaveMeasurement(&model.Measurement{}, "x")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
})
t.Run("SubmitAndUpdateMeasurementContext", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockSubmitAndUpdateMeasurementContext: func(ctx context.Context, measurement *model.Measurement) error {
return expected
},
}
err := e.SubmitAndUpdateMeasurementContext(context.Background(), &model.Measurement{})
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
})
t.Run("OpenReportContext", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockOpenReportContext: func(ctx context.Context) error {
return expected
},
}
err := e.OpenReportContext(context.Background())
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
})
}

View File

@ -0,0 +1,48 @@
package mocks
import "github.com/ooni/probe-cli/v3/internal/model"
// ExperimentBuilder mocks model.ExperimentBuilder.
type ExperimentBuilder struct {
MockInterruptible func() bool
MockInputPolicy func() model.InputPolicy
MockOptions func() (map[string]model.ExperimentOptionInfo, error)
MockSetOptionAny func(key string, value any) error
MockSetOptionsAny func(options map[string]any) error
MockSetCallbacks func(callbacks model.ExperimentCallbacks)
MockNewExperiment func() model.Experiment
}
func (eb *ExperimentBuilder) Interruptible() bool {
return eb.MockInterruptible()
}
func (eb *ExperimentBuilder) InputPolicy() model.InputPolicy {
return eb.MockInputPolicy()
}
func (eb *ExperimentBuilder) Options() (map[string]model.ExperimentOptionInfo, error) {
return eb.MockOptions()
}
func (eb *ExperimentBuilder) SetOptionAny(key string, value any) error {
return eb.MockSetOptionAny(key, value)
}
func (eb *ExperimentBuilder) SetOptionsAny(options map[string]any) error {
return eb.MockSetOptionsAny(options)
}
func (eb *ExperimentBuilder) SetCallbacks(callbacks model.ExperimentCallbacks) {
eb.MockSetCallbacks(callbacks)
}
func (eb *ExperimentBuilder) NewExperiment() model.Experiment {
return eb.MockNewExperiment()
}

View File

@ -0,0 +1,99 @@
package mocks
import (
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/model"
)
func TestExperimentBuilder(t *testing.T) {
t.Run("Interruptible", func(t *testing.T) {
eb := &ExperimentBuilder{
MockInterruptible: func() bool {
return true
},
}
if !eb.Interruptible() {
t.Fatal("unexpected value")
}
})
t.Run("InputPolicy", func(t *testing.T) {
eb := &ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOrQueryBackend
},
}
if eb.InputPolicy() != model.InputOrQueryBackend {
t.Fatal("unexpected value")
}
})
t.Run("Options", func(t *testing.T) {
expected := errors.New("mocked error")
eb := &ExperimentBuilder{
MockOptions: func() (map[string]model.ExperimentOptionInfo, error) {
return nil, expected
},
}
out, err := eb.Options()
if !errors.Is(err, expected) {
t.Fatal("unexpected value")
}
if len(out) > 0 {
t.Fatal("unexpected value")
}
})
t.Run("SetOptionAny", func(t *testing.T) {
expected := errors.New("mocked error")
eb := &ExperimentBuilder{
MockSetOptionAny: func(key string, value any) error {
return expected
},
}
err := eb.SetOptionAny("antani", 1245678)
if !errors.Is(err, expected) {
t.Fatal("unexpected value")
}
})
t.Run("SetOptionsAny", func(t *testing.T) {
expected := errors.New("mocked error")
eb := &ExperimentBuilder{
MockSetOptionsAny: func(options map[string]any) error {
return expected
},
}
err := eb.SetOptionsAny(make(map[string]any))
if !errors.Is(err, expected) {
t.Fatal("unexpected value")
}
})
t.Run("SetCallbacks", func(t *testing.T) {
var called bool
eb := &ExperimentBuilder{
MockSetCallbacks: func(callbacks model.ExperimentCallbacks) {
called = true
},
}
eb.SetCallbacks(model.NewPrinterCallbacks(model.DiscardLogger))
if !called {
t.Fatal("not called")
}
})
t.Run("NewExperiment", func(t *testing.T) {
exp := &Experiment{}
eb := &ExperimentBuilder{
MockNewExperiment: func() model.Experiment {
return exp
},
}
if out := eb.NewExperiment(); out != exp {
t.Fatal("invalid result")
}
})
}

View File

@ -0,0 +1,19 @@
package mocks
import (
"context"
"github.com/ooni/probe-cli/v3/internal/model"
)
// ExperimentInputLoader mocks model.ExperimentInputLoader
type ExperimentInputLoader struct {
MockLoad func(ctx context.Context) ([]model.OOAPIURLInfo, error)
}
var _ model.ExperimentInputLoader = &ExperimentInputLoader{}
// Load calls MockLoad
func (eil *ExperimentInputLoader) Load(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return eil.MockLoad(ctx)
}

View File

@ -0,0 +1,27 @@
package mocks
import (
"context"
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/model"
)
func TestExperimentInputLoader(t *testing.T) {
t.Run("Load", func(t *testing.T) {
expected := errors.New("mocked error")
eil := &ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return nil, expected
},
}
out, err := eil.Load(context.Background())
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if len(out) > 0 {
t.Fatal("unexpected length")
}
})
}

View File

@ -0,0 +1,12 @@
package mocks
import "context"
// ExperimentInputProcessor processes inputs running the given experiment.
type ExperimentInputProcessor struct {
MockRun func(ctx context.Context) error
}
func (eip *ExperimentInputProcessor) Run(ctx context.Context) error {
return eip.MockRun(ctx)
}

View File

@ -0,0 +1,22 @@
package mocks
import (
"context"
"errors"
"testing"
)
func TestExperimentInputProcessor(t *testing.T) {
t.Run("Run", func(t *testing.T) {
expected := errors.New("mocked error")
eip := &ExperimentInputProcessor{
MockRun: func(ctx context.Context) error {
return expected
},
}
err := eip.Run(context.Background())
if !errors.Is(err, expected) {
t.Fatal("unexpected result")
}
})
}

View File

@ -0,0 +1,12 @@
package mocks
import "github.com/ooni/probe-cli/v3/internal/model"
// Saver saves a measurement on some persistent storage.
type Saver struct {
MockSaveMeasurement func(m *model.Measurement) error
}
func (s *Saver) SaveMeasurement(m *model.Measurement) error {
return s.MockSaveMeasurement(m)
}

View File

@ -0,0 +1,23 @@
package mocks
import (
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/model"
)
func TestSaver(t *testing.T) {
t.Run("SaveMeasurement", func(t *testing.T) {
expected := errors.New("mocked error")
s := &Saver{
MockSaveMeasurement: func(m *model.Measurement) error {
return expected
},
}
err := s.SaveMeasurement(&model.Measurement{})
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
})
}

View File

@ -0,0 +1,161 @@
package mocks
import (
"context"
"net/url"
"github.com/ooni/probe-cli/v3/internal/model"
)
// Session allows to mock sessions.
type Session struct {
MockGetTestHelpersByName func(name string) ([]model.OOAPIService, bool)
MockDefaultHTTPClient func() model.HTTPClient
MockFetchPsiphonConfig func(ctx context.Context) ([]byte, error)
MockFetchTorTargets func(
ctx context.Context, cc string) (map[string]model.OOAPITorTarget, error)
MockFetchURLList func(
ctx context.Context, config model.OOAPIURLListConfig) ([]model.OOAPIURLInfo, error)
MockKeyValueStore func() model.KeyValueStore
MockLogger func() model.Logger
MockMaybeResolverIP func() string
MockProbeASNString func() string
MockProbeCC func() string
MockProbeIP func() string
MockProbeNetworkName func() string
MockProxyURL func() *url.URL
MockResolverIP func() string
MockSoftwareName func() string
MockSoftwareVersion func() string
MockTempDir func() string
MockTorArgs func() []string
MockTorBinary func() string
MockTunnelDir func() string
MockUserAgent func() string
MockNewExperimentBuilder func(name string) (model.ExperimentBuilder, error)
MockNewSubmitter func(ctx context.Context) (model.Submitter, error)
MockCheckIn func(ctx context.Context,
config *model.OOAPICheckInConfig) (*model.OOAPICheckInInfo, error)
}
func (sess *Session) GetTestHelpersByName(name string) ([]model.OOAPIService, bool) {
return sess.MockGetTestHelpersByName(name)
}
func (sess *Session) DefaultHTTPClient() model.HTTPClient {
return sess.MockDefaultHTTPClient()
}
func (sess *Session) FetchPsiphonConfig(ctx context.Context) ([]byte, error) {
return sess.MockFetchPsiphonConfig(ctx)
}
func (sess *Session) FetchTorTargets(
ctx context.Context, cc string) (map[string]model.OOAPITorTarget, error) {
return sess.MockFetchTorTargets(ctx, cc)
}
func (sess *Session) FetchURLList(
ctx context.Context, config model.OOAPIURLListConfig) ([]model.OOAPIURLInfo, error) {
return sess.MockFetchURLList(ctx, config)
}
func (sess *Session) KeyValueStore() model.KeyValueStore {
return sess.MockKeyValueStore()
}
func (sess *Session) Logger() model.Logger {
return sess.MockLogger()
}
func (sess *Session) MaybeResolverIP() string {
return sess.MockMaybeResolverIP()
}
func (sess *Session) ProbeASNString() string {
return sess.MockProbeASNString()
}
func (sess *Session) ProbeCC() string {
return sess.MockProbeCC()
}
func (sess *Session) ProbeIP() string {
return sess.MockProbeIP()
}
func (sess *Session) ProbeNetworkName() string {
return sess.MockProbeNetworkName()
}
func (sess *Session) ProxyURL() *url.URL {
return sess.MockProxyURL()
}
func (sess *Session) ResolverIP() string {
return sess.MockResolverIP()
}
func (sess *Session) SoftwareName() string {
return sess.MockSoftwareName()
}
func (sess *Session) SoftwareVersion() string {
return sess.MockSoftwareVersion()
}
func (sess *Session) TempDir() string {
return sess.MockTempDir()
}
func (sess *Session) TorArgs() []string {
return sess.MockTorArgs()
}
func (sess *Session) TorBinary() string {
return sess.MockTorBinary()
}
func (sess *Session) TunnelDir() string {
return sess.MockTunnelDir()
}
func (sess *Session) UserAgent() string {
return sess.MockUserAgent()
}
func (sess *Session) NewExperimentBuilder(name string) (model.ExperimentBuilder, error) {
return sess.MockNewExperimentBuilder(name)
}
func (sess *Session) NewSubmitter(ctx context.Context) (model.Submitter, error) {
return sess.MockNewSubmitter(ctx)
}
func (sess *Session) CheckIn(ctx context.Context,
config *model.OOAPICheckInConfig) (*model.OOAPICheckInInfo, error) {
return sess.MockCheckIn(ctx, config)
}

View File

@ -0,0 +1,357 @@
package mocks
import (
"context"
"errors"
"net/url"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/runtimex"
"github.com/ooni/probe-cli/v3/internal/testingx"
)
func TestSession(t *testing.T) {
t.Run("GetTestHelpersByName", func(t *testing.T) {
var expect []model.OOAPIService
ff := &testingx.FakeFiller{}
ff.Fill(&expect)
runtimex.Assert(len(expect) > 0, "expected non-empty array")
s := &Session{
MockGetTestHelpersByName: func(name string) ([]model.OOAPIService, bool) {
return expect, len(expect) > 0
},
}
out, good := s.GetTestHelpersByName("xx")
if !good {
t.Fatal("not good")
}
if diff := cmp.Diff(expect, out); diff != "" {
t.Fatal(diff)
}
})
t.Run("DefaultHTTPClient", func(t *testing.T) {
expected := &HTTPClient{}
s := &Session{
MockDefaultHTTPClient: func() model.HTTPClient {
return expected
},
}
out := s.DefaultHTTPClient()
if expected != out {
t.Fatal("unexpected result")
}
})
t.Run("FetchPsiphonConfig", func(t *testing.T) {
var expected []byte
ff := &testingx.FakeFiller{}
ff.Fill(&expected)
runtimex.Assert(len(expected) > 0, "expected nonempty list")
s := &Session{
MockFetchPsiphonConfig: func(ctx context.Context) ([]byte, error) {
return expected, nil
},
}
out, err := s.FetchPsiphonConfig(context.Background())
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(expected, out); diff != "" {
t.Fatal(diff)
}
})
t.Run("FetchTorTargets", func(t *testing.T) {
expected := errors.New("mocked err")
s := &Session{
MockFetchTorTargets: func(ctx context.Context, cc string) (map[string]model.OOAPITorTarget, error) {
return nil, expected
},
}
out, err := s.FetchTorTargets(context.Background(), "IT")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if len(out) > 0 {
t.Fatal("expected empty out")
}
})
t.Run("FetchURLList", func(t *testing.T) {
expected := errors.New("mocked err")
s := &Session{
MockFetchURLList: func(ctx context.Context, config model.OOAPIURLListConfig) ([]model.OOAPIURLInfo, error) {
return nil, expected
},
}
out, err := s.FetchURLList(context.Background(), model.OOAPIURLListConfig{})
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if len(out) > 0 {
t.Fatal("expected empty out")
}
})
t.Run("KeyValueStore", func(t *testing.T) {
expect := &KeyValueStore{}
s := &Session{
MockKeyValueStore: func() model.KeyValueStore {
return expect
},
}
out := s.KeyValueStore()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("Logger", func(t *testing.T) {
expect := &Logger{}
s := &Session{
MockLogger: func() model.Logger {
return expect
},
}
out := s.Logger()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("MaybeResolverIP", func(t *testing.T) {
expect := "xx"
s := &Session{
MockMaybeResolverIP: func() string {
return expect
},
}
out := s.MaybeResolverIP()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ProbeASNString", func(t *testing.T) {
expect := "xx"
s := &Session{
MockProbeASNString: func() string {
return expect
},
}
out := s.ProbeASNString()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ProbeCC", func(t *testing.T) {
expect := "xx"
s := &Session{
MockProbeCC: func() string {
return expect
},
}
out := s.ProbeCC()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ProbeIP", func(t *testing.T) {
expect := "xx"
s := &Session{
MockProbeIP: func() string {
return expect
},
}
out := s.ProbeIP()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ProbeNetworkName", func(t *testing.T) {
expect := "xx"
s := &Session{
MockProbeNetworkName: func() string {
return expect
},
}
out := s.ProbeNetworkName()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ProxyURL", func(t *testing.T) {
expect := &url.URL{Scheme: "xx"}
s := &Session{
MockProxyURL: func() *url.URL {
return expect
},
}
out := s.ProxyURL()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ResolverIP", func(t *testing.T) {
expect := "xx"
s := &Session{
MockResolverIP: func() string {
return expect
},
}
out := s.ResolverIP()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("SoftwareName", func(t *testing.T) {
expect := "xx"
s := &Session{
MockSoftwareName: func() string {
return expect
},
}
out := s.SoftwareName()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("SoftwareVersion", func(t *testing.T) {
expect := "xx"
s := &Session{
MockSoftwareVersion: func() string {
return expect
},
}
out := s.SoftwareVersion()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("TempDir", func(t *testing.T) {
expect := "xx"
s := &Session{
MockTempDir: func() string {
return expect
},
}
out := s.TempDir()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("TorArgs", func(t *testing.T) {
var expect []string
ff := &testingx.FakeFiller{}
ff.Fill(&expect)
runtimex.Assert(len(expect) > 0, "expected non empty slice")
s := &Session{
MockTorArgs: func() []string {
return expect
},
}
out := s.TorArgs()
if diff := cmp.Diff(expect, out); diff != "" {
t.Fatal(diff)
}
})
t.Run("TorBinary", func(t *testing.T) {
expect := "xx"
s := &Session{
MockTorBinary: func() string {
return expect
},
}
out := s.TorBinary()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("TunnelDir", func(t *testing.T) {
expect := "xx"
s := &Session{
MockTunnelDir: func() string {
return expect
},
}
out := s.TunnelDir()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("UserAgent", func(t *testing.T) {
expect := "xx"
s := &Session{
MockUserAgent: func() string {
return expect
},
}
out := s.UserAgent()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("NewExperimentBuilder", func(t *testing.T) {
eb := &ExperimentBuilder{}
s := &Session{
MockNewExperimentBuilder: func(name string) (model.ExperimentBuilder, error) {
return eb, nil
},
}
out, err := s.NewExperimentBuilder("x")
if err != nil {
t.Fatal(err)
}
if out != eb {
t.Fatal("invalid output")
}
})
t.Run("NewSubmitter", func(t *testing.T) {
expected := errors.New("mocked err")
s := &Session{
MockNewSubmitter: func(ctx context.Context) (model.Submitter, error) {
return nil, expected
},
}
out, err := s.NewSubmitter(context.Background())
if !errors.Is(err, expected) {
t.Fatal("unexpected err")
}
if out != nil {
t.Fatal("unexpected out")
}
})
t.Run("CheckIn", func(t *testing.T) {
expected := errors.New("mocked err")
s := &Session{
MockCheckIn: func(ctx context.Context, config *model.OOAPICheckInConfig) (*model.OOAPICheckInInfo, error) {
return nil, expected
},
}
out, err := s.CheckIn(context.Background(), &model.OOAPICheckInConfig{})
if !errors.Is(err, expected) {
t.Fatal("unexpected err")
}
if out != nil {
t.Fatal("unexpected out")
}
})
}

View File

@ -0,0 +1,17 @@
package mocks
import (
"context"
"github.com/ooni/probe-cli/v3/internal/model"
)
// Submitter mocks model.Submitter.
type Submitter struct {
MockSubmit func(ctx context.Context, m *model.Measurement) error
}
// Submit calls MockSubmit
func (s *Submitter) Submit(ctx context.Context, m *model.Measurement) error {
return s.MockSubmit(ctx, m)
}

View File

@ -0,0 +1,24 @@
package mocks
import (
"context"
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/model"
)
func TestSubmitter(t *testing.T) {
t.Run("Submit", func(t *testing.T) {
expect := errors.New("mocked error")
s := &Submitter{
MockSubmit: func(ctx context.Context, m *model.Measurement) error {
return expect
},
}
err := s.Submit(context.Background(), &model.Measurement{})
if !errors.Is(err, expect) {
t.Fatal("unexpected err", err)
}
})
}

View File

@ -613,9 +613,9 @@ func dnsGenLookupHostReplySuccess(rawQuery []byte, cname *dnsCNAMEAnswer, ips ..
query := new(dns.Msg)
err := query.Unpack(rawQuery)
runtimex.PanicOnError(err, "query.Unpack failed")
runtimex.PanicIfFalse(len(query.Question) == 1, "more than one question")
runtimex.Assert(len(query.Question) == 1, "more than one question")
question := query.Question[0]
runtimex.PanicIfFalse(
runtimex.Assert(
question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA,
"invalid query type (expected A or AAAA)",
)
@ -669,9 +669,9 @@ func dnsGenHTTPSReplySuccess(rawQuery []byte, alpns, ipv4s, ipv6s []string) []by
query := new(dns.Msg)
err := query.Unpack(rawQuery)
runtimex.PanicOnError(err, "query.Unpack failed")
runtimex.PanicIfFalse(len(query.Question) == 1, "expected just a single question")
runtimex.Assert(len(query.Question) == 1, "expected just a single question")
question := query.Question[0]
runtimex.PanicIfFalse(question.Qtype == dns.TypeHTTPS, "expected HTTPS query")
runtimex.Assert(question.Qtype == dns.TypeHTTPS, "expected HTTPS query")
reply := new(dns.Msg)
reply.Compress = true
reply.MsgHdr.RecursionAvailable = true
@ -716,9 +716,9 @@ func dnsGenNSReplySuccess(rawQuery []byte, names ...string) []byte {
query := new(dns.Msg)
err := query.Unpack(rawQuery)
runtimex.PanicOnError(err, "query.Unpack failed")
runtimex.PanicIfFalse(len(query.Question) == 1, "more than one question")
runtimex.Assert(len(query.Question) == 1, "more than one question")
question := query.Question[0]
runtimex.PanicIfFalse(question.Qtype == dns.TypeNS, "expected NS query")
runtimex.Assert(question.Qtype == dns.TypeNS, "expected NS query")
reply := new(dns.Msg)
reply.Compress = true
reply.MsgHdr.RecursionAvailable = true

View File

@ -104,7 +104,7 @@ func NewDefaultCertPool() *x509.CertPool {
// Assumption: AppendCertsFromPEM cannot fail because we
// have a test in certify_test.go that guarantees that
ok := pool.AppendCertsFromPEM([]byte(pemcerts))
runtimex.PanicIfFalse(ok, "pool.AppendCertsFromPEM failed")
runtimex.Assert(ok, "pool.AppendCertsFromPEM failed")
return pool
}

View File

@ -11,11 +11,15 @@ import (
"strings"
"time"
"github.com/ooni/probe-cli/v3/internal/atomicx"
"github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/humanize"
"github.com/ooni/probe-cli/v3/internal/model"
)
// experimentShuffledInputs counts how many times we shuffled inputs
var experimentShuffledInputs = &atomicx.Int64{}
// Experiment describes an experiment to run. You MUST fill all the fields that
// are marked as MANDATORY, otherwise Experiment.Run will cause panics.
type Experiment struct {
@ -52,6 +56,22 @@ type Experiment struct {
// Session is the MANDATORY session.
Session Session
// newExperimentBuilderFn is OPTIONAL and used for testing.
newExperimentBuilderFn func(experimentName string) (model.ExperimentBuilder, error)
// newInputLoaderFn is OPTIONAL and used for testing.
newInputLoaderFn func(inputPolicy model.InputPolicy) inputLoader
// newSubmitterFn is OPTIONAL and used for testing.
newSubmitterFn func(ctx context.Context) (engine.Submitter, error)
// newSaverFn is OPTIONAL and used for testing.
newSaverFn func(experiment model.Experiment) (engine.Saver, error)
// newInputProcessorFn is OPTIONAL and used for testing.
newInputProcessorFn func(experiment model.Experiment, inputList []model.OOAPIURLInfo,
saver engine.Saver, submitter engine.Submitter) inputProcessor
}
// Run runs the given experiment.
@ -76,6 +96,7 @@ func (ed *Experiment) Run(ctx context.Context) error {
rnd.Shuffle(len(inputList), func(i, j int) {
inputList[i], inputList[j] = inputList[j], inputList[i]
})
experimentShuffledInputs.Add(1)
}
// 4. configure experiment's options
@ -112,14 +133,15 @@ func (ed *Experiment) Run(ctx context.Context) error {
return inputProcessor.Run(ctx)
}
// inputProcessor processes inputs running the given experiment.
type inputProcessor interface {
Run(ctx context.Context) error
}
// inputProcessor is an alias for model.ExperimentInputProcessor
type inputProcessor = model.ExperimentInputProcessor
// newInputProcessor creates a new inputProcessor instance.
func (ed *Experiment) newInputProcessor(experiment model.Experiment,
inputList []model.OOAPIURLInfo, saver engine.Saver, submitter engine.Submitter) inputProcessor {
if ed.newInputProcessorFn != nil {
return ed.newInputProcessorFn(experiment, inputList, saver, submitter)
}
return &engine.InputProcessor{
Annotations: ed.Annotations,
Experiment: &experimentWrapper{
@ -140,6 +162,9 @@ func (ed *Experiment) newInputProcessor(experiment model.Experiment,
// newSaver creates a new engine.Saver instance.
func (ed *Experiment) newSaver(experiment model.Experiment) (engine.Saver, error) {
if ed.newSaverFn != nil {
return ed.newSaverFn(experiment)
}
return engine.NewSaver(engine.SaverConfig{
Enabled: !ed.NoJSON,
Experiment: experiment,
@ -150,6 +175,9 @@ func (ed *Experiment) newSaver(experiment model.Experiment) (engine.Saver, error
// newSubmitter creates a new engine.Submitter instance.
func (ed *Experiment) newSubmitter(ctx context.Context) (engine.Submitter, error) {
if ed.newSubmitterFn != nil {
return ed.newSubmitterFn(ctx)
}
return engine.NewSubmitter(ctx, engine.SubmitterConfig{
Enabled: !ed.NoCollector,
Session: ed.Session,
@ -159,16 +187,20 @@ func (ed *Experiment) newSubmitter(ctx context.Context) (engine.Submitter, error
// newExperimentBuilder creates a new engine.ExperimentBuilder for the given experimentName.
func (ed *Experiment) newExperimentBuilder(experimentName string) (model.ExperimentBuilder, error) {
if ed.newExperimentBuilderFn != nil {
return ed.newExperimentBuilderFn(experimentName)
}
return ed.Session.NewExperimentBuilder(ed.Name)
}
// inputLoader loads inputs from local or remote sources.
type inputLoader interface {
Load(ctx context.Context) ([]model.OOAPIURLInfo, error)
}
// inputLoader is an alias for model.ExperimentInputLoader
type inputLoader = model.ExperimentInputLoader
// newInputLoader creates a new inputLoader.
func (ed *Experiment) newInputLoader(inputPolicy model.InputPolicy) inputLoader {
if ed.newInputLoaderFn != nil {
return ed.newInputLoaderFn(inputPolicy)
}
return &engine.InputLoader{
CheckInConfig: &model.OOAPICheckInConfig{
RunType: model.RunTypeManual,

View File

@ -2,42 +2,24 @@ package oonirun
import (
"context"
"os"
"errors"
"reflect"
"sort"
"testing"
"time"
"github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/kvstore"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/version"
"github.com/ooni/probe-cli/v3/internal/model/mocks"
"github.com/ooni/probe-cli/v3/internal/testingx"
)
// TODO(bassosimone): it would be cool to write unit tests. However, to do that
// we need to ~redesign the engine package for unit-testability.
func newSession(ctx context.Context, t *testing.T) *engine.Session {
config := engine.SessionConfig{
AvailableProbeServices: []model.OOAPIService{},
KVStore: &kvstore.Memory{},
Logger: model.DiscardLogger,
ProxyURL: nil,
SoftwareName: "miniooni",
SoftwareVersion: version.Version,
TempDir: os.TempDir(),
TorArgs: []string{},
TorBinary: "",
TunnelDir: "",
}
sess, err := engine.NewSession(ctx, config)
if err != nil {
t.Fatal(err)
}
return sess
}
func TestExperimentRunWithExample(t *testing.T) {
func TestExperimentRunWithFailureToSubmitAndShuffle(t *testing.T) {
shuffledInputsPrev := experimentShuffledInputs.Load()
var calledSetOptionsAny int
var failedToSubmit int
var calledKibiBytesReceived int
var calledKibiBytesSent int
ctx := context.Background()
desc := &Experiment{
Annotations: map[string]string{
@ -46,19 +28,89 @@ func TestExperimentRunWithExample(t *testing.T) {
ExtraOptions: map[string]any{
"SleepTime": int64(10 * time.Millisecond),
},
Inputs: []string{},
Inputs: []string{
"a", "b", "c",
},
InputFilePaths: []string{},
MaxRuntime: 0,
Name: "example",
NoCollector: true,
NoJSON: true,
Random: false,
Random: true, // to test randomness
ReportFile: "",
Session: newSession(ctx, t),
Session: &mocks.Session{
MockNewExperimentBuilder: func(name string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
MockSetOptionsAny: func(options map[string]any) error {
calledSetOptionsAny++
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockMeasureAsync: func(ctx context.Context, input string) (<-chan *model.Measurement, error) {
out := make(chan *model.Measurement)
go func() {
defer close(out)
ff := &testingx.FakeFiller{}
var meas model.Measurement
ff.Fill(&meas)
out <- &meas
}()
return out, nil
},
MockKibiBytesReceived: func() float64 {
calledKibiBytesReceived++
return 1.453
},
MockKibiBytesSent: func() float64 {
calledKibiBytesSent++
return 1.648
},
}
return exp
},
}
return eb, nil
},
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
newExperimentBuilderFn: nil,
newInputLoaderFn: nil,
newSubmitterFn: func(ctx context.Context) (engine.Submitter, error) {
subm := &mocks.Submitter{
MockSubmit: func(ctx context.Context, m *model.Measurement) error {
failedToSubmit++
return errors.New("mocked error")
},
}
return subm, nil
},
newSaverFn: nil,
newInputProcessorFn: nil,
}
if err := desc.Run(ctx); err != nil {
t.Fatal(err)
}
if failedToSubmit < 1 {
t.Fatal("expected to see failure to submit")
}
if experimentShuffledInputs.Load() != shuffledInputsPrev+1 {
t.Fatal("did not shuffle inputs")
}
if calledSetOptionsAny < 1 {
t.Fatal("should have called SetOptionsAny")
}
if calledKibiBytesReceived < 1 {
t.Fatal("did not call KibiBytesReceived")
}
if calledKibiBytesSent < 1 {
t.Fatal("did not call KibiBytesSent")
}
}
func Test_experimentOptionsToStringList(t *testing.T) {
@ -102,3 +154,257 @@ func Test_experimentOptionsToStringList(t *testing.T) {
})
}
}
func TestExperimentRun(t *testing.T) {
errMocked := errors.New("mocked error")
type fields struct {
Annotations map[string]string
ExtraOptions map[string]any
Inputs []string
InputFilePaths []string
MaxRuntime int64
Name string
NoCollector bool
NoJSON bool
Random bool
ReportFile string
Session Session
newExperimentBuilderFn func(experimentName string) (model.ExperimentBuilder, error)
newInputLoaderFn func(inputPolicy model.InputPolicy) inputLoader
newSubmitterFn func(ctx context.Context) (engine.Submitter, error)
newSaverFn func(experiment model.Experiment) (engine.Saver, error)
newInputProcessorFn func(experiment model.Experiment, inputList []model.OOAPIURLInfo, saver engine.Saver, submitter engine.Submitter) inputProcessor
}
type args struct {
ctx context.Context
}
tests := []struct {
name string
fields fields
args args
expectErr error
}{{
name: "cannot construct an experiment builder",
fields: fields{
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
return nil, errMocked
},
},
args: args{},
expectErr: errMocked,
}, {
name: "cannot load input",
fields: fields{
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
}
return eb, nil
},
newInputLoaderFn: func(inputPolicy model.InputPolicy) inputLoader {
return &mocks.ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return nil, errMocked
},
}
},
},
args: args{},
expectErr: errMocked,
}, {
name: "cannot set options",
fields: fields{
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
MockSetOptionsAny: func(options map[string]any) error {
return errMocked
},
}
return eb, nil
},
newInputLoaderFn: func(inputPolicy model.InputPolicy) inputLoader {
return &mocks.ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return []model.OOAPIURLInfo{}, nil
},
}
},
},
args: args{},
expectErr: errMocked,
}, {
name: "cannot create new submitter",
fields: fields{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
MockSetOptionsAny: func(options map[string]any) error {
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockKibiBytesReceived: func() float64 {
return 0
},
MockKibiBytesSent: func() float64 {
return 0
},
}
return exp
},
}
return eb, nil
},
newInputLoaderFn: func(inputPolicy model.InputPolicy) inputLoader {
return &mocks.ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return []model.OOAPIURLInfo{}, nil
},
}
},
newSubmitterFn: func(ctx context.Context) (engine.Submitter, error) {
return nil, errMocked
},
},
args: args{},
expectErr: errMocked,
}, {
name: "cannot create new saver",
fields: fields{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
MockSetOptionsAny: func(options map[string]any) error {
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockKibiBytesReceived: func() float64 {
return 0
},
MockKibiBytesSent: func() float64 {
return 0
},
}
return exp
},
}
return eb, nil
},
newInputLoaderFn: func(inputPolicy model.InputPolicy) inputLoader {
return &mocks.ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return []model.OOAPIURLInfo{}, nil
},
}
},
newSubmitterFn: func(ctx context.Context) (engine.Submitter, error) {
return &mocks.Submitter{}, nil
},
newSaverFn: func(experiment model.Experiment) (engine.Saver, error) {
return nil, errMocked
},
},
args: args{},
expectErr: errMocked,
}, {
name: "input processor fails",
fields: fields{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
MockSetOptionsAny: func(options map[string]any) error {
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockKibiBytesReceived: func() float64 {
return 0
},
MockKibiBytesSent: func() float64 {
return 0
},
}
return exp
},
}
return eb, nil
},
newInputLoaderFn: func(inputPolicy model.InputPolicy) inputLoader {
return &mocks.ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return []model.OOAPIURLInfo{}, nil
},
}
},
newSubmitterFn: func(ctx context.Context) (engine.Submitter, error) {
return &mocks.Submitter{}, nil
},
newSaverFn: func(experiment model.Experiment) (engine.Saver, error) {
return &mocks.Saver{}, nil
},
newInputProcessorFn: func(experiment model.Experiment, inputList []model.OOAPIURLInfo,
saver engine.Saver, submitter engine.Submitter) inputProcessor {
return &mocks.ExperimentInputProcessor{
MockRun: func(ctx context.Context) error {
return errMocked
},
}
},
},
args: args{},
expectErr: errMocked,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ed := &Experiment{
Annotations: tt.fields.Annotations,
ExtraOptions: tt.fields.ExtraOptions,
Inputs: tt.fields.Inputs,
InputFilePaths: tt.fields.InputFilePaths,
MaxRuntime: tt.fields.MaxRuntime,
Name: tt.fields.Name,
NoCollector: tt.fields.NoCollector,
NoJSON: tt.fields.NoJSON,
Random: tt.fields.Random,
ReportFile: tt.fields.ReportFile,
Session: tt.fields.Session,
newExperimentBuilderFn: tt.fields.newExperimentBuilderFn,
newInputLoaderFn: tt.fields.newInputLoaderFn,
newSubmitterFn: tt.fields.newSubmitterFn,
newSaverFn: tt.fields.newSaverFn,
newInputProcessorFn: tt.fields.newInputProcessorFn,
}
err := ed.Run(tt.args.ctx)
if !errors.Is(err, tt.expectErr) {
t.Fatalf("Experiment.Run() error = %v, expectErr %v", err, tt.expectErr)
}
})
}
}

View File

@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
)
@ -50,7 +51,7 @@ func v1Measure(ctx context.Context, config *LinkConfig, URL string) error {
if pu.Host != "nettest" {
return ErrInvalidV1URLHost
}
if pu.Path != "" {
if pu.Path != "" && pu.Path != "/" {
return ErrInvalidV1URLPath
}
default:
@ -58,33 +59,50 @@ func v1Measure(ctx context.Context, config *LinkConfig, URL string) error {
}
name := pu.Query().Get("tn")
if name == "" {
return ErrInvalidV1URLQueryArgument
return fmt.Errorf("%w: empty test name", ErrInvalidV1URLQueryArgument)
}
var inputs []string
if ra := pu.Query().Get("ta"); ra != "" {
pa, err := url.QueryUnescape(ra)
if ta := pu.Query().Get("ta"); ta != "" {
inputs, err = v1ParseArguments(ta)
if err != nil {
return err
}
var arguments v1Arguments
if err := json.Unmarshal([]byte(pa), &arguments); err != nil {
return err
}
inputs = arguments.URLs
}
// TODO(bassosimone): reject mv < 1.2.0
if mv := pu.Query().Get("mv"); mv != "1.2.0" {
return fmt.Errorf("%w: unknown minimum version", ErrInvalidV1URLQueryArgument)
}
exp := &Experiment{
Annotations: config.Annotations,
ExtraOptions: nil, // no way to specify with v1 URLs
Inputs: inputs,
InputFilePaths: nil,
MaxRuntime: config.MaxRuntime,
Name: name,
NoCollector: config.NoCollector,
NoJSON: config.NoJSON,
Random: config.Random,
ReportFile: config.ReportFile,
Session: config.Session,
Annotations: config.Annotations,
ExtraOptions: nil, // no way to specify with v1 URLs
Inputs: inputs,
InputFilePaths: nil,
MaxRuntime: config.MaxRuntime,
Name: name,
NoCollector: config.NoCollector,
NoJSON: config.NoJSON,
Random: config.Random,
ReportFile: config.ReportFile,
Session: config.Session,
newExperimentBuilderFn: nil,
newInputLoaderFn: nil,
newSubmitterFn: nil,
newSaverFn: nil,
newInputProcessorFn: nil,
}
return exp.Run(ctx)
}
// v1ParseArguments parses the `ta` field of the query string.
func v1ParseArguments(ta string) ([]string, error) {
var inputs []string
pa, err := url.QueryUnescape(ta)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidV1URLQueryArgument, err.Error())
}
var arguments v1Arguments
if err := json.Unmarshal([]byte(pa), &arguments); err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidV1URLQueryArgument, err.Error())
}
inputs = arguments.URLs
return inputs, nil
}

View File

@ -2,13 +2,60 @@ package oonirun
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"github.com/ooni/probe-cli/v3/internal/kvstore"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/model/mocks"
"github.com/ooni/probe-cli/v3/internal/testingx"
)
// TODO(bassosimone): it would be cool to write unit tests. However, to do that
// we need to ~redesign the engine package for unit-testability.
func newMinimalFakeSession() *mocks.Session {
return &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
MockNewExperimentBuilder: func(name string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputNone
},
MockSetOptionsAny: func(options map[string]any) error {
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockMeasureAsync: func(ctx context.Context, input string) (<-chan *model.Measurement, error) {
out := make(chan *model.Measurement)
go func() {
defer close(out)
ff := &testingx.FakeFiller{}
var meas model.Measurement
ff.Fill(&meas)
out <- &meas
}()
return out, nil
},
MockKibiBytesReceived: func() float64 {
return 1.1
},
MockKibiBytesSent: func() float64 {
return 0.1
},
}
return exp
},
}
return eb, nil
},
MockDefaultHTTPClient: func() model.HTTPClient {
return http.DefaultClient
},
}
}
func TestOONIRunV1Link(t *testing.T) {
ctx := context.Background()
@ -23,7 +70,7 @@ func TestOONIRunV1Link(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, "https://run.ooni.io/nettest?tn=example&mv=1.2.0")
if err := r.Run(ctx); err != nil {
@ -34,3 +81,188 @@ func TestOONIRunV1Link(t *testing.T) {
t.Fatal(err)
}
}
func TestV1MeasureInvalidURL(t *testing.T) {
t.Run("URL does not parse", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "\t"
err := v1Measure(ctx, config, URL)
if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
t.Fatal("unexpected err", err)
}
})
t.Run("with https:// URL and invalid hostname", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "https://run.ooni.nu/nettest"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLHost) {
t.Fatal("unexpected err", err)
}
})
t.Run("with https:// URL and invalid path", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "https://run.ooni.io/antani"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLPath) {
t.Fatal("unexpected err", err)
}
})
t.Run("with ooni:// URL and invalid host", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://antani"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLHost) {
t.Fatal("unexpected err", err)
}
})
t.Run("with ooni:// URL and path", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://nettest/x"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLPath) {
t.Fatal("unexpected err", err)
}
})
t.Run("with invalid URL scheme", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "antani://nettest"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLScheme) {
t.Fatal("unexpected err", err)
}
})
t.Run("with empty test name", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://nettest/"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLQueryArgument) {
t.Fatal("unexpected err", err)
}
})
t.Run("with invalid JSON and explicit / as path", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://nettest/?tn=web_connectivity&ta=123x"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLQueryArgument) {
t.Fatal("unexpected err", err)
}
})
t.Run("with invalid JSON and empty path", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://nettest?tn=web_connectivity&ta=123x"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLQueryArgument) {
t.Fatal("unexpected err", err)
}
})
t.Run("with missing minimum version", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://nettest?tn=example"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLQueryArgument) {
t.Fatal("unexpected err", err)
}
})
}
func TestV1ParseArguments(t *testing.T) {
t.Run("with invalid test arguments", func(t *testing.T) {
// "[QueryUnescape] returns an error if any % is not followed by two hexadecimal digits."
out, err := v1ParseArguments("%KK")
if !errors.Is(err, ErrInvalidV1URLQueryArgument) {
t.Fatal("unexpected err", err)
}
if len(out) > 0 {
t.Fatal("expected no output")
}
})
t.Run("with valid arguments", func(t *testing.T) {
out, err := v1ParseArguments("%7B%22urls%22%3A%5B%22https%3A%2F%2Fexample.com%2F%22%5D%7D")
if err != nil {
t.Fatal(err)
}
if len(out) != 1 || out[0] != "https://example.com/" {
t.Fatal("unexpected out", out)
}
})
}

View File

@ -24,6 +24,10 @@ var (
// v2CountEmptyNettestNames counts the number of cases in which we have been
// given an empty nettest name, which is useful for testing.
v2CountEmptyNettestNames = &atomicx.Int64{}
// v2CountFailedExperiments countes the number of failed experiments
// and is useful when testing this package
v2CountFailedExperiments = &atomicx.Int64{}
)
// v2Descriptor describes a single nettest to run.
@ -172,20 +176,26 @@ func v2MeasureDescriptor(ctx context.Context, config *LinkConfig, desc *v2Descri
continue
}
exp := &Experiment{
Annotations: config.Annotations,
ExtraOptions: nettest.Options,
Inputs: nettest.Inputs,
InputFilePaths: nil,
MaxRuntime: config.MaxRuntime,
Name: nettest.TestName,
NoCollector: config.NoCollector,
NoJSON: config.NoJSON,
Random: config.Random,
ReportFile: config.ReportFile,
Session: config.Session,
Annotations: config.Annotations,
ExtraOptions: nettest.Options,
Inputs: nettest.Inputs,
InputFilePaths: nil,
MaxRuntime: config.MaxRuntime,
Name: nettest.TestName,
NoCollector: config.NoCollector,
NoJSON: config.NoJSON,
Random: config.Random,
ReportFile: config.ReportFile,
Session: config.Session,
newExperimentBuilderFn: nil,
newInputLoaderFn: nil,
newSubmitterFn: nil,
newSaverFn: nil,
newInputProcessorFn: nil,
}
if err := exp.Run(ctx); err != nil {
logger.Warnf("cannot run experiment: %s", err.Error())
v2CountFailedExperiments.Add(1)
continue
}
}

View File

@ -10,13 +10,11 @@ import (
"time"
"github.com/ooni/probe-cli/v3/internal/kvstore"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/model/mocks"
"github.com/ooni/probe-cli/v3/internal/runtimex"
)
// TODO(bassosimone): it would be cool to write unit tests. However, to do that
// we need to ~redesign the engine package for unit-testability.
func TestOONIRunV2LinkCommonCase(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
descriptor := &v2Descriptor{
@ -48,7 +46,7 @@ func TestOONIRunV2LinkCommonCase(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, server.URL)
if err := r.Run(ctx); err != nil {
@ -95,7 +93,7 @@ func TestOONIRunV2LinkCannotUpdateCache(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, server.URL)
err := r.Run(ctx)
@ -135,7 +133,7 @@ func TestOONIRunV2LinkWithoutAcceptChanges(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, server.URL)
err := r.Run(ctx)
@ -161,7 +159,7 @@ func TestOONIRunV2LinkNilDescriptor(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, server.URL)
if err := r.Run(ctx); err != nil {
@ -170,6 +168,7 @@ func TestOONIRunV2LinkNilDescriptor(t *testing.T) {
}
func TestOONIRunV2LinkEmptyTestName(t *testing.T) {
emptyTestNamesPrev := v2CountEmptyNettestNames.Load()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
descriptor := &v2Descriptor{
Name: "",
@ -200,14 +199,14 @@ func TestOONIRunV2LinkEmptyTestName(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, server.URL)
if err := r.Run(ctx); err != nil {
t.Fatal(err)
}
if v2CountEmptyNettestNames.Load() != 1 {
t.Fatal("expected to see 1 instance of empty nettest names")
if v2CountEmptyNettestNames.Load() != emptyTestNamesPrev+1 {
t.Fatal("expected to see 1 more instance of empty nettest names")
}
}
@ -220,6 +219,74 @@ func TestV2MeasureDescriptor(t *testing.T) {
t.Fatal("unexpected err", err)
}
})
t.Run("with failing experiment", func(t *testing.T) {
previousFailedExperiments := v2CountFailedExperiments.Load()
expected := errors.New("mocked error")
ctx := context.Background()
sess := newMinimalFakeSession()
sess.MockNewSubmitter = func(ctx context.Context) (model.Submitter, error) {
subm := &mocks.Submitter{
MockSubmit: func(ctx context.Context, m *model.Measurement) error {
panic("should not be called")
},
}
return subm, nil
}
sess.MockNewExperimentBuilder = func(name string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputNone
},
MockSetOptionsAny: func(options map[string]any) error {
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockMeasureAsync: func(ctx context.Context, input string) (<-chan *model.Measurement, error) {
return nil, expected
},
MockKibiBytesReceived: func() float64 {
return 1.1
},
MockKibiBytesSent: func() float64 {
return 0.1
},
}
return exp
},
}
return eb, nil
}
config := &LinkConfig{
AcceptChanges: false,
Annotations: map[string]string{},
KVStore: nil,
MaxRuntime: 0,
NoCollector: false,
NoJSON: false,
Random: false,
ReportFile: "",
Session: sess,
}
descr := &v2Descriptor{
Name: "",
Description: "",
Author: "",
Nettests: []v2Nettest{{
Inputs: []string{},
Options: map[string]any{},
TestName: "example",
}},
}
err := v2MeasureDescriptor(ctx, config, descr)
if err != nil {
t.Fatal(err)
}
if v2CountFailedExperiments.Load() != previousFailedExperiments+1 {
t.Fatal("expected to see a failed experiment")
}
})
}
func TestV2MeasureHTTPS(t *testing.T) {
@ -239,7 +306,7 @@ func TestV2MeasureHTTPS(t *testing.T) {
NoJSON: false,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
err := v2MeasureHTTPS(ctx, config, "")
if !errors.Is(err, expected) {
@ -259,7 +326,7 @@ func TestV2MeasureHTTPS(t *testing.T) {
NoJSON: false,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
err := v2MeasureHTTPS(ctx, config, "https://example.com") // should not use URL
if !errors.Is(err, context.Canceled) {

View File

@ -11,8 +11,8 @@ func PanicOnError(err error, message string) {
}
}
// PanicIfFalse calls panic if assertion is false.
func PanicIfFalse(assertion bool, message string) {
// Assert calls panic if assertion is false.
func Assert(assertion bool, message string) {
if !assertion {
panic(message)
}
@ -20,7 +20,7 @@ func PanicIfFalse(assertion bool, message string) {
// PanicIfTrue calls panic if assertion is true.
func PanicIfTrue(assertion bool, message string) {
PanicIfFalse(!assertion, message)
Assert(!assertion, message)
}
// PanicIfNil calls panic if the given interface is nil.

View File

@ -28,17 +28,17 @@ func TestPanicOnError(t *testing.T) {
})
}
func TestPanicIfFalse(t *testing.T) {
func TestAssert(t *testing.T) {
badfunc := func(in bool, message string) (out error) {
defer func() {
out = errors.New(recover().(string))
}()
runtimex.PanicIfFalse(in, message)
runtimex.Assert(in, message)
return
}
t.Run("assertion is true", func(t *testing.T) {
runtimex.PanicIfFalse(true, "this assertion should not fail")
runtimex.Assert(true, "this assertion should not fail")
})
t.Run("assertion is false", func(t *testing.T) {

View File

@ -33,7 +33,7 @@ func NewFailureStr(err error) FailureStr {
if !errors.As(err, &errWrapper) {
err := netxlite.NewTopLevelGenericErrWrapper(err)
couldConvert := errors.As(err, &errWrapper)
runtimex.PanicIfFalse(couldConvert, "we should have an ErrWrapper here")
runtimex.Assert(couldConvert, "we should have an ErrWrapper here")
}
s := errWrapper.Failure
if s == "" {