1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
package desktop
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/armanlife/firefox-vpn-client/core/internal/config"
"github.com/armanlife/firefox-vpn-client/core/internal/service"
)
// The app's end of the standard input closing ends the watch; data the
// app writes does not.
func TestWatchParentStopsAtEndOfInput(t *testing.T) {
r, w := io.Pipe()
done := make(chan struct{})
go watchParent(r, func() { close(done) })
if _, err := w.Write([]byte("ping\n")); err != nil {
t.Fatal(err)
}
select {
case <-done:
t.Fatal("input data must not stop the process")
case <-time.After(50 * time.Millisecond):
}
_ = w.Close()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("the end of the input must stop the process")
}
}
// A broken pipe (the app crashed) also ends the watch.
func TestWatchParentStopsOnReadError(t *testing.T) {
r, w := io.Pipe()
done := make(chan struct{})
go watchParent(r, func() { close(done) })
_ = w.CloseWithError(errors.New("broken pipe"))
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("a read error must stop the process")
}
}
// Only a pipe is watched; a file or nothing is not.
func TestStdinIsPipe(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
if !stdinIsPipe(r) {
t.Error("a pipe must be watched")
}
file, err := os.Create(filepath.Join(t.TempDir(), "stdin"))
if err != nil {
t.Fatal(err)
}
defer file.Close()
if stdinIsPipe(file) {
t.Error("a file must not be watched")
}
if stdinIsPipe(nil) {
t.Error("no standard input must not be watched")
}
}
// fakeService stands in for the daemon: Run blocks until Shutdown, or
// fails at once with runErr.
type fakeService struct {
runErr error
events *[]string
mu *sync.Mutex
stop chan struct{}
once sync.Once
}
func newFakeService(runErr error) *fakeService {
return &fakeService{runErr: runErr, events: &[]string{}, mu: &sync.Mutex{}, stop: make(chan struct{})}
}
func (f *fakeService) record(event string) {
f.mu.Lock()
defer f.mu.Unlock()
*f.events = append(*f.events, event)
}
func (f *fakeService) recorded() []string {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(*f.events)
}
func (f *fakeService) Run() error {
if f.runErr != nil {
return f.runErr
}
<-f.stop
return nil
}
func (f *fakeService) Shutdown() {
f.record("shutdown")
f.once.Do(func() { close(f.stop) })
}
// A stop request (exit API, end of input, signal) stops the status loop,
// then restores the system proxy, then stops the service, and is a clean
// exit.
func TestRunHostedStopsInOrder(t *testing.T) {
svc := newFakeService(nil)
stop := newStopRequest()
loopStarted := make(chan struct{})
result := make(chan error, 1)
go func() {
result <- runHosted(svc, stop.ch, func(ctx context.Context) {
close(loopStarted)
<-ctx.Done()
svc.record("loop stopped")
}, func() { svc.record("system proxy restored") })
}()
<-loopStarted
stop.request("the app closed the standard input")
stop.request("a second request is dropped")
select {
case err := <-result:
if err != nil {
t.Fatalf("a requested stop is a clean exit: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("runHosted did not return")
}
want := []string{"loop stopped", "system proxy restored", "shutdown"}
if got := svc.recorded(); !slices.Equal(got, want) {
t.Fatalf("order %v, want %v", got, want)
}
}
// A service that cannot start (the dashboard address is taken) ends the
// process with its error, after the same cleanup.
func TestRunHostedReportsServiceFailure(t *testing.T) {
failure := errors.New("starting the dashboard: address in use")
svc := newFakeService(failure)
err := runHosted(svc, newStopRequest().ch, func(ctx context.Context) { <-ctx.Done() }, func() { svc.record("system proxy restored") })
if !errors.Is(err, failure) {
t.Fatalf("got %v, want the service's error", err)
}
if got, want := svc.recorded(), []string{"system proxy restored", "shutdown"}; !slices.Equal(got, want) {
t.Fatalf("order %v, want %v", got, want)
}
}
// The app parses the first line of the standard error: "app-error: ",
// a one-word code, a space, and the detail on the rest of the line.
func TestReportAppError(t *testing.T) {
dir := filepath.Join("C:", "Users", "A B", "data")
for name, tc := range map[string]struct {
err error
exit int
want []string
}{
"already running": {
err: &alreadyRunningError{dir: dir, who: "a tray icon runs for it"},
exit: ExitAlreadyRunning,
want: []string{
"app-error: already_running " + dir,
"firefox-vpn-core: " + ErrAlreadyRunning.Error() + " (" + dir + "): a tray icon runs for it",
},
},
"dashboard port in use": {
err: fmt.Errorf("starting the dashboard: %w", &service.AddressInUseError{Listener: service.ListenerDashboard, Address: "127.0.0.1:10820", Err: errors.New("bind")}),
exit: ExitDashboardPortInUse,
want: []string{"app-error: dashboard_port_in_use 127.0.0.1:10820"},
},
"proxy port is not the dashboard's": {
err: &service.AddressInUseError{Listener: service.ListenerProxy, Address: "127.0.0.1:10810", Err: errors.New("bind")},
exit: ExitStartFailed,
want: []string{"app-error: start_failed the proxy address 127.0.0.1:10810 is already in use by another program"},
},
"other error on one line": {
err: errors.New("reading config.json:\r\nbad\nvalue"),
exit: ExitStartFailed,
want: []string{"app-error: start_failed reading config.json: bad value"},
},
} {
var out strings.Builder
if got := ReportAppError(&out, tc.err); got != tc.exit {
t.Errorf("%s: exit %d, want %d", name, got, tc.exit)
}
if got := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n"); !slices.Equal(got, tc.want) {
t.Errorf("%s: wrote %q, want %q", name, got, tc.want)
}
}
if !errors.Is(&alreadyRunningError{dir: dir}, ErrAlreadyRunning) {
t.Error("alreadyRunningError must match ErrAlreadyRunning")
}
}
// The app-mode start path with its dashboard address held by another
// program ends with the dashboard_port_in_use line and exit status 4.
func TestAppDashboardPortInUse(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
taken, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer taken.Close()
paths, err := service.ResolveDataPaths(filepath.Join(t.TempDir(), "data"))
if err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Dashboard.Listen = taken.Addr().String()
cfg.Proxy.Listen = "127.0.0.1:0"
if err := config.Save(paths.Config, cfg); err != nil {
t.Fatal(err)
}
d, err := newHostedDaemon(paths, false, func() {})
if err != nil {
t.Fatal(err)
}
d.RequireDashboard = true
result := make(chan error, 1)
go func() {
result <- runHosted(d, newStopRequest().ch, func(ctx context.Context) { <-ctx.Done() }, func() {})
}()
select {
case err = <-result:
case <-time.After(10 * time.Second):
t.Fatal("the start did not fail")
}
var out strings.Builder
if exit := ReportAppError(&out, err); exit != ExitDashboardPortInUse {
t.Fatalf("exit %d for %v", exit, err)
}
if want := "app-error: dashboard_port_in_use " + taken.Addr().String() + "\n"; out.String() != want {
t.Fatalf("wrote %q, want %q", out.String(), want)
}
}