forked from ghostunnel/ghostunnel
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwindows_service_test.go
More file actions
268 lines (238 loc) · 7.22 KB
/
windows_service_test.go
File metadata and controls
268 lines (238 loc) · 7.22 KB
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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
//go:build windows
package main
import (
"errors"
"fmt"
"strings"
"testing"
"time"
"golang.org/x/sys/windows/registry"
"golang.org/x/sys/windows/svc"
)
// isWindowsAdmin reports whether the current process has Administrator
// privileges, which are required for service management operations.
func isWindowsAdmin() bool {
const keyCreateSubKey = 0x00000004
key, err := registry.OpenKey(registry.LOCAL_MACHINE,
`SYSTEM\CurrentControlSet\Services`,
keyCreateSubKey)
if err != nil {
return false
}
key.Close()
return true
}
func TestValidateServiceName(t *testing.T) {
tests := []struct {
name string
wantErr bool
}{
{"ghostunnel", false},
{"my-service", false},
{"my_service", false},
{"My Service", false},
{"a", false},
{"", true},
{string(make([]byte, 257)), true},
{"bad/name", true},
{"bad\\name", true},
{"bad<name>", true},
{"bad@name", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateServiceName(tt.name)
if (err != nil) != tt.wantErr {
t.Errorf("validateServiceName(%q) error = %v, wantErr %v", tt.name, err, tt.wantErr)
}
})
}
}
func TestCurrentServiceNameNotService(t *testing.T) {
if isRunningAsService() {
t.Skip("running as a Windows service")
}
if got := currentServiceName(); got != defaultServiceName {
t.Errorf("currentServiceName() = %q, want %q", got, defaultServiceName)
}
}
func TestStatusNonExistentService(t *testing.T) {
if err := doStatusService("ghostunnel-nonexistent-99999"); err == nil {
t.Error("expected error for non-existent service, got nil")
}
}
// TestServiceLifecycle exercises the full install→status→stop→uninstall
// cycle against the real Windows Service Control Manager.
func TestServiceLifecycle(t *testing.T) {
if !isWindowsAdmin() {
t.Skip("requires Administrator privileges")
}
const name = "ghostunnel-integration-test"
// Use "service status" as the proxy args so the service process exits
// promptly without needing TLS certificates. We are testing SCM
// registration, not proxy connectivity.
proxyArgs := []string{"service", "status", "--service-name", name}
t.Cleanup(func() {
_ = doUninstallService(name) // best-effort cleanup on failure
})
// When running under "go test", os.Executable() returns the test binary
// rather than ghostunnel.exe. The SCM will fail to start it as a Windows
// service because testing.Main() never registers a service control handler.
// We tolerate that specific error and verify the SCM registration itself.
installErr := doInstallService(name, proxyArgs)
if installErr != nil && !errors.Is(installErr, errServiceNotStarted) {
t.Fatalf("install: %v", installErr)
}
// If install failed during waitForServiceRunning, the registration has
// been rolled back automatically; nothing more to test or clean up.
statusErr := doStatusService(name)
if installErr != nil && statusErr != nil {
return
}
if statusErr != nil {
t.Errorf("status after install: %v", statusErr)
}
// Service will be stopped already (never started); stopServiceWithTimeout
// handles the already-stopped case gracefully.
if err := doStopService(name); err != nil {
t.Errorf("stop: %v", err)
}
if err := doUninstallService(name); err != nil {
t.Fatalf("uninstall: %v", err)
}
// Service must be gone after uninstall.
if err := doStatusService(name); err == nil {
t.Error("expected error querying service after uninstall, got nil")
}
}
// waitForServiceRunningPoll is exercised here without a real SCM by injecting
// a scripted query function. Tests use zero or near-zero durations to keep
// runtime negligible.
type pollStep struct {
state svc.State
err error
}
func newScriptedQuery(steps []pollStep) func() (svc.Status, error) {
i := 0
return func() (svc.Status, error) {
if i >= len(steps) {
return svc.Status{}, fmt.Errorf("query called more times than scripted (%d)", len(steps))
}
step := steps[i]
i++
return svc.Status{State: step.state}, step.err
}
}
func TestWaitForServiceRunningPoll(t *testing.T) {
queryFailure := errors.New("scripted query failure")
tests := []struct {
name string
steps []pollStep
wantErr bool
errSubstr string
}{
{
name: "running immediately",
steps: []pollStep{{state: svc.Running}},
wantErr: false,
},
{
name: "start pending then running",
steps: []pollStep{{state: svc.StartPending}, {state: svc.Running}},
wantErr: false,
},
{
name: "continue pending then running",
steps: []pollStep{{state: svc.ContinuePending}, {state: svc.Running}},
wantErr: false,
},
{
name: "stopped immediately",
steps: []pollStep{{state: svc.Stopped}},
wantErr: true,
errSubstr: "failed to reach running state",
},
{
name: "stop pending fails fast",
steps: []pollStep{{state: svc.StopPending}},
wantErr: true,
errSubstr: "failed to reach running state",
},
{
name: "paused fails fast",
steps: []pollStep{{state: svc.Paused}},
wantErr: true,
errSubstr: "failed to reach running state",
},
{
name: "query error",
steps: []pollStep{{err: queryFailure}},
wantErr: true,
errSubstr: "could not query",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query := newScriptedQuery(tt.steps)
// Zero poll keeps the test instantaneous; a generous timeout
// ensures the loop never trips the deadline before the scripted
// query yields a terminal state.
err := waitForServiceRunningPoll("test-service", query, 0, time.Minute)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr)
}
if tt.wantErr && tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
t.Errorf("err = %q, want substring %q", err, tt.errSubstr)
}
})
}
}
func TestWaitForServiceRunningPollTimeout(t *testing.T) {
// Always-StartPending query forces the loop to rely on the deadline check.
// A 1ms poll bound prevents busy-looping while still keeping the test fast.
query := func() (svc.Status, error) {
return svc.Status{State: svc.StartPending}, nil
}
err := waitForServiceRunningPoll("test-service", query, time.Millisecond, 10*time.Millisecond)
if err == nil {
t.Fatal("expected timeout error, got nil")
}
if !strings.Contains(err.Error(), "timed out") {
t.Errorf("err = %q, want timeout message", err)
}
}
func TestNotifyServiceReadySignalsChannel(t *testing.T) {
// Drain any prior signal so the test is independent of run order.
select {
case <-serviceReadyCh:
default:
}
notifyServiceReady()
select {
case <-serviceReadyCh:
case <-time.After(time.Second):
t.Fatal("notifyServiceReady did not signal serviceReadyCh within 1s")
}
}
func TestNotifyServiceReadyIsIdempotent(t *testing.T) {
// Drain any prior signal.
select {
case <-serviceReadyCh:
default:
}
// Multiple calls must not block even though only one fits in the buffer.
for i := 0; i < 5; i++ {
notifyServiceReady()
}
// Exactly one signal should be buffered (the rest dropped).
select {
case <-serviceReadyCh:
case <-time.After(time.Second):
t.Fatal("expected at least one buffered ready signal")
}
select {
case <-serviceReadyCh:
t.Fatal("expected only one buffered ready signal, got more")
case <-time.After(50 * time.Millisecond):
}
}