Skip to content

Commit 1fc6df9

Browse files
committed
feat: multi-user integration test suite (ISS-44)
2 parents ea97d6f + 69fe865 commit 1fc6df9

6 files changed

Lines changed: 507 additions & 3 deletions

File tree

Makefile

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION)
66

77
PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64
88

9-
.PHONY: build release clean test test-integration vet
9+
.PHONY: build release clean test test-unit test-integration test-all vet
1010

1111
build:
1212
@mkdir -p $(BUILD_DIR)
@@ -26,10 +26,14 @@ clean:
2626
rm -rf $(BUILD_DIR)
2727

2828
test:
29-
go test ./...
29+
go test -count=1 ./...
30+
31+
test-unit: test
3032

3133
test-integration:
32-
go test -tags integration -v -timeout 300s ./tests/integration/
34+
go test -tags integration -v -timeout 300s -count=1 ./internal/integration/
35+
36+
test-all: test test-integration
3337

3438
vet:
3539
go vet ./...

devbox.test.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
name: inttest
2+
server: devbox-vps
3+
services:
4+
- nginx:alpine
5+
ports:
6+
nginx: 18080
7+
resources:
8+
cpus: 0.25
9+
memory: 128m

internal/integration/doc.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//go:build integration
2+
3+
// Package integration contains end-to-end tests that exercise real SSH
4+
// connections and Docker operations against a live VPS.
5+
//
6+
// Run with: go test -tags integration ./internal/integration/ -v
7+
//
8+
// Requires:
9+
// - SSH access to the test server (default: devbox-vps)
10+
// - Docker + Compose installed on the test server
11+
// - Set DEVBOX_TEST_SERVER env to override the target host
12+
// - Set DEVBOX_TEST_SERVER=skip to skip all integration tests
13+
package integration
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
//go:build integration
2+
3+
package integration
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"strconv"
9+
"strings"
10+
"sync"
11+
"testing"
12+
"time"
13+
14+
"github.com/junixlabs/devbox/internal/config"
15+
"github.com/junixlabs/devbox/internal/testutil"
16+
"github.com/junixlabs/devbox/internal/workspace"
17+
)
18+
19+
const testService = "nginx:alpine"
20+
21+
// testParams returns CreateParams for a test workspace with unique port.
22+
func testParams(server, name string, port int, res *config.Resources) workspace.CreateParams {
23+
p := workspace.CreateParams{
24+
Name: name,
25+
Server: server,
26+
Services: []string{testService},
27+
Ports: map[string]int{"nginx": port},
28+
}
29+
if res != nil {
30+
p.Resources = *res
31+
}
32+
return p
33+
}
34+
35+
// containerName returns the expected Docker container name for a test workspace.
36+
func containerName(wsName string) string {
37+
return wsName + "-nginx-1"
38+
}
39+
40+
// TestConcurrentWorkspaceCreation creates 3 workspaces in parallel goroutines
41+
// and verifies all reach running status with their containers active.
42+
func TestConcurrentWorkspaceCreation(t *testing.T) {
43+
server := testutil.TestServer(t)
44+
mgr := testutil.NewManager()
45+
46+
names := []string{"inttest-a", "inttest-b", "inttest-c"}
47+
ports := []int{18081, 18082, 18083}
48+
49+
// Best-effort cleanup of any leftover workspaces.
50+
for _, name := range names {
51+
_ = mgr.Destroy(name)
52+
}
53+
54+
var (
55+
mu sync.Mutex
56+
results = make([]*workspace.Workspace, len(names))
57+
errs = make([]error, len(names))
58+
wg sync.WaitGroup
59+
)
60+
61+
for i, name := range names {
62+
wg.Add(1)
63+
go func(idx int, n string) {
64+
defer wg.Done()
65+
params := testParams(server, n, ports[idx], &config.Resources{CPUs: 0.25, Memory: "128m"})
66+
ws, err := mgr.Create(params)
67+
mu.Lock()
68+
results[idx] = ws
69+
errs[idx] = err
70+
mu.Unlock()
71+
}(i, name)
72+
}
73+
wg.Wait()
74+
75+
// Register cleanup for all successfully created workspaces.
76+
t.Cleanup(func() {
77+
for _, name := range names {
78+
if err := mgr.Destroy(name); err != nil {
79+
t.Logf("cleanup Destroy(%s): %v", name, err)
80+
}
81+
}
82+
})
83+
84+
// Verify all created successfully.
85+
for i, name := range names {
86+
if errs[i] != nil {
87+
t.Fatalf("Create(%s) failed: %v", name, errs[i])
88+
}
89+
if results[i].Status != workspace.StatusRunning {
90+
t.Errorf("workspace %s status = %s, want running", name, results[i].Status)
91+
}
92+
}
93+
94+
// Verify all containers are running on the server.
95+
for _, name := range names {
96+
testutil.WaitForContainer(t, server, containerName(name), 60*time.Second)
97+
}
98+
99+
// Verify list returns all 3.
100+
list, err := mgr.List()
101+
if err != nil {
102+
t.Fatalf("List(): %v", err)
103+
}
104+
found := 0
105+
for _, ws := range list {
106+
for _, name := range names {
107+
if ws.Name == name {
108+
found++
109+
}
110+
}
111+
}
112+
if found != len(names) {
113+
t.Errorf("List() found %d of %d test workspaces", found, len(names))
114+
}
115+
}
116+
117+
// TestPortAutoAllocation creates workspaces with different explicit ports
118+
// and verifies each port is bound on the host without conflicts.
119+
func TestPortAutoAllocation(t *testing.T) {
120+
server := testutil.TestServer(t)
121+
mgr := testutil.NewManager()
122+
123+
type wsSpec struct {
124+
name string
125+
port int
126+
}
127+
specs := []wsSpec{
128+
{"inttest-port-a", 19091},
129+
{"inttest-port-b", 19092},
130+
}
131+
132+
for _, s := range specs {
133+
params := testParams(server, s.name, s.port, nil)
134+
testutil.CreateWorkspace(t, mgr, params)
135+
}
136+
137+
// Wait for containers.
138+
for _, s := range specs {
139+
testutil.WaitForContainer(t, server, containerName(s.name), 60*time.Second)
140+
}
141+
142+
// Verify each port is listening.
143+
for _, s := range specs {
144+
testutil.AssertPortListening(t, server, s.port)
145+
}
146+
147+
// Verify no port collision: each workspace's assigned port is unique.
148+
portSet := make(map[int]string)
149+
for _, s := range specs {
150+
ws, err := mgr.Get(s.name)
151+
if err != nil {
152+
t.Fatalf("Get(%s): %v", s.name, err)
153+
}
154+
for _, port := range ws.Ports {
155+
if existing, ok := portSet[port]; ok {
156+
t.Errorf("port %d used by both %s and %s", port, existing, s.name)
157+
}
158+
portSet[port] = s.name
159+
}
160+
}
161+
}
162+
163+
// TestResourceLimitsEnforcement creates a workspace with resource limits
164+
// and verifies Docker applies the constraints.
165+
func TestResourceLimitsEnforcement(t *testing.T) {
166+
server := testutil.TestServer(t)
167+
mgr := testutil.NewManager()
168+
169+
res := &config.Resources{CPUs: 0.5, Memory: "256m"}
170+
params := testParams(server, "inttest-reslimit", 19001, res)
171+
testutil.CreateWorkspace(t, mgr, params)
172+
173+
container := containerName("inttest-reslimit")
174+
testutil.WaitForContainer(t, server, container, 60*time.Second)
175+
176+
// Verify CPU limit via docker inspect (NanoCpus = CPUs * 1e9).
177+
cpuOut := testutil.DockerInspect(t, server, container, "{{.HostConfig.NanoCpus}}")
178+
nanoCpus, err := strconv.ParseInt(strings.TrimSpace(cpuOut), 10, 64)
179+
if err != nil {
180+
t.Fatalf("parse NanoCpus %q: %v", cpuOut, err)
181+
}
182+
expectedNano := int64(0.5 * 1e9)
183+
if nanoCpus != expectedNano {
184+
t.Errorf("NanoCpus = %d, want %d (0.5 CPUs)", nanoCpus, expectedNano)
185+
}
186+
187+
// Verify memory limit (256m = 268435456 bytes).
188+
memOut := testutil.DockerInspect(t, server, container, "{{.HostConfig.Memory}}")
189+
memBytes, err := strconv.ParseInt(strings.TrimSpace(memOut), 10, 64)
190+
if err != nil {
191+
t.Fatalf("parse Memory %q: %v", memOut, err)
192+
}
193+
expectedMem := int64(256 * 1024 * 1024)
194+
if memBytes != expectedMem {
195+
t.Errorf("Memory = %d, want %d (256m)", memBytes, expectedMem)
196+
}
197+
}
198+
199+
// TestUserIsolation verifies that workspaces are isolated from each other:
200+
// filesystem separation and independent Docker Compose projects.
201+
func TestUserIsolation(t *testing.T) {
202+
server := testutil.TestServer(t)
203+
mgr := testutil.NewManager()
204+
205+
wsA := testutil.CreateWorkspace(t, mgr, testParams(server, "inttest-iso-a", 19011, nil))
206+
wsB := testutil.CreateWorkspace(t, mgr, testParams(server, "inttest-iso-b", 19012, nil))
207+
208+
cA := containerName(wsA.Name)
209+
cB := containerName(wsB.Name)
210+
testutil.WaitForContainer(t, server, cA, 60*time.Second)
211+
testutil.WaitForContainer(t, server, cB, 60*time.Second)
212+
213+
// Verify workspace directories are separate.
214+
testutil.AssertDirExists(t, server, "/workspaces/"+wsA.Name)
215+
testutil.AssertDirExists(t, server, "/workspaces/"+wsB.Name)
216+
217+
// Verify containers run in separate Docker Compose projects.
218+
projectA := testutil.DockerInspect(t, server, cA, "{{index .Config.Labels \"com.docker.compose.project\"}}")
219+
projectB := testutil.DockerInspect(t, server, cB, "{{index .Config.Labels \"com.docker.compose.project\"}}")
220+
if strings.TrimSpace(projectA) == strings.TrimSpace(projectB) {
221+
t.Errorf("workspaces share compose project: %s", projectA)
222+
}
223+
224+
// Verify container A cannot see container B's workspace directory.
225+
cmd := fmt.Sprintf("docker exec %s test -d /workspaces/%s 2>&1; echo $?", cA, wsB.Name)
226+
out, err := testutil.SSHRunE(server, cmd)
227+
if err != nil {
228+
t.Fatalf("isolation check: %v", err)
229+
}
230+
// exit code 1 means directory not found = isolated
231+
if strings.TrimSpace(out) == "0" {
232+
t.Errorf("container %s can see workspace dir of %s — isolation violation", wsA.Name, wsB.Name)
233+
}
234+
}
235+
236+
// TestMultiServerDistribution is skipped when only one test server is available.
237+
// Set DEVBOX_TEST_SERVER_2 to enable this test.
238+
func TestMultiServerDistribution(t *testing.T) {
239+
server1 := testutil.TestServer(t)
240+
server2 := os.Getenv("DEVBOX_TEST_SERVER_2")
241+
if server2 == "" {
242+
t.Skip("DEVBOX_TEST_SERVER_2 not set — skipping multi-server test")
243+
}
244+
245+
mgr := testutil.NewManager()
246+
wsA := testutil.CreateWorkspace(t, mgr, testParams(server1, "inttest-multi-a", 19021, nil))
247+
wsB := testutil.CreateWorkspace(t, mgr, testParams(server2, "inttest-multi-b", 19021, nil))
248+
249+
// Same port on different servers should not conflict.
250+
if wsA.ServerHost == wsB.ServerHost {
251+
t.Fatal("expected workspaces on different servers")
252+
}
253+
254+
testutil.WaitForContainer(t, server1, containerName(wsA.Name), 60*time.Second)
255+
testutil.WaitForContainer(t, server2, containerName(wsB.Name), 60*time.Second)
256+
257+
testutil.AssertPortListening(t, server1, 19021)
258+
testutil.AssertPortListening(t, server2, 19021)
259+
}
260+
261+
// TestCleanupAfterDestroy creates a workspace, destroys it, and verifies
262+
// that containers, ports, and directories are fully cleaned up.
263+
func TestCleanupAfterDestroy(t *testing.T) {
264+
server := testutil.TestServer(t)
265+
mgr := testutil.NewManager()
266+
267+
port := 19031
268+
params := testParams(server, "inttest-cleanup", port, nil)
269+
270+
// Best-effort pre-cleanup.
271+
_ = mgr.Destroy(params.Name)
272+
273+
ws, err := mgr.Create(params)
274+
if err != nil {
275+
t.Fatalf("Create: %v", err)
276+
}
277+
testutil.WaitForContainer(t, server, containerName(ws.Name), 60*time.Second)
278+
testutil.AssertDirExists(t, server, "/workspaces/"+ws.Name)
279+
280+
// Destroy the workspace.
281+
if err := mgr.Destroy(ws.Name); err != nil {
282+
t.Fatalf("Destroy: %v", err)
283+
}
284+
285+
// Wait for Docker cleanup.
286+
time.Sleep(3 * time.Second)
287+
288+
// Verify container is gone.
289+
out, _ := testutil.SSHRunE(server, fmt.Sprintf("docker ps -q --filter name=%s", containerName(ws.Name)))
290+
if strings.TrimSpace(out) != "" {
291+
t.Errorf("container %s still exists after destroy", containerName(ws.Name))
292+
}
293+
294+
// Verify port is free.
295+
testutil.AssertPortFree(t, server, port)
296+
297+
// Verify workspace directory is gone.
298+
testutil.AssertDirNotExists(t, server, "/workspaces/"+ws.Name)
299+
300+
// Verify workspace is removed from state.
301+
_, err = mgr.Get(ws.Name)
302+
if err == nil {
303+
t.Error("Get() returned no error after destroy — workspace still in state")
304+
}
305+
}

0 commit comments

Comments
 (0)