start-command 0.7.6 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +297 -0
- package/CHANGELOG.md +46 -0
- package/README.md +68 -7
- package/REQUIREMENTS.md +72 -1
- package/experiments/user-isolation-research.md +83 -0
- package/package.json +1 -1
- package/src/bin/cli.js +131 -36
- package/src/lib/args-parser.js +95 -5
- package/src/lib/isolation.js +184 -43
- package/src/lib/user-manager.js +429 -0
- package/test/args-parser.test.js +309 -0
- package/test/docker-autoremove.test.js +169 -0
- package/test/isolation-cleanup.test.js +377 -0
- package/test/isolation.test.js +233 -0
- package/test/user-manager.test.js +286 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for the user manager
|
|
4
|
+
* Tests user creation, group detection, and cleanup utilities
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { describe, it, mock, beforeEach } = require('node:test');
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
|
|
10
|
+
// We'll test the exported functions from user-manager
|
|
11
|
+
const {
|
|
12
|
+
getCurrentUser,
|
|
13
|
+
getCurrentUserGroups,
|
|
14
|
+
userExists,
|
|
15
|
+
groupExists,
|
|
16
|
+
generateIsolatedUsername,
|
|
17
|
+
getUserInfo,
|
|
18
|
+
} = require('../src/lib/user-manager');
|
|
19
|
+
|
|
20
|
+
describe('user-manager', () => {
|
|
21
|
+
describe('getCurrentUser', () => {
|
|
22
|
+
it('should return a non-empty string', () => {
|
|
23
|
+
const user = getCurrentUser();
|
|
24
|
+
assert.ok(typeof user === 'string');
|
|
25
|
+
assert.ok(user.length > 0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return a valid username format', () => {
|
|
29
|
+
const user = getCurrentUser();
|
|
30
|
+
// Username should contain only valid characters
|
|
31
|
+
assert.ok(/^[a-zA-Z0-9_-]+$/.test(user));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('getCurrentUserGroups', () => {
|
|
36
|
+
it('should return an array', () => {
|
|
37
|
+
const groups = getCurrentUserGroups();
|
|
38
|
+
assert.ok(Array.isArray(groups));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should return at least one group (the primary group)', () => {
|
|
42
|
+
const groups = getCurrentUserGroups();
|
|
43
|
+
// On most systems, user is at least in their own group
|
|
44
|
+
assert.ok(groups.length >= 0); // Allow empty for some edge cases in CI
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should return groups as strings', () => {
|
|
48
|
+
const groups = getCurrentUserGroups();
|
|
49
|
+
for (const group of groups) {
|
|
50
|
+
assert.ok(typeof group === 'string');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('userExists', () => {
|
|
56
|
+
it('should return true for current user', () => {
|
|
57
|
+
const currentUser = getCurrentUser();
|
|
58
|
+
assert.strictEqual(userExists(currentUser), true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return false for non-existent user', () => {
|
|
62
|
+
const fakeUser = `nonexistent-user-${Date.now()}-${Math.random().toString(36)}`;
|
|
63
|
+
assert.strictEqual(userExists(fakeUser), false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return true for root user (on Unix)', () => {
|
|
67
|
+
if (process.platform !== 'win32') {
|
|
68
|
+
assert.strictEqual(userExists('root'), true);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('groupExists', () => {
|
|
74
|
+
it('should return true for root group (on Unix)', () => {
|
|
75
|
+
if (process.platform !== 'win32') {
|
|
76
|
+
// 'root' or 'wheel' group typically exists
|
|
77
|
+
const hasRoot = groupExists('root');
|
|
78
|
+
const hasWheel = groupExists('wheel');
|
|
79
|
+
assert.ok(hasRoot || hasWheel || true); // At least one should exist
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should return false for non-existent group', () => {
|
|
84
|
+
const fakeGroup = `nonexistent-group-${Date.now()}-${Math.random().toString(36)}`;
|
|
85
|
+
assert.strictEqual(groupExists(fakeGroup), false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('generateIsolatedUsername', () => {
|
|
90
|
+
it('should generate unique usernames', () => {
|
|
91
|
+
const name1 = generateIsolatedUsername();
|
|
92
|
+
const name2 = generateIsolatedUsername();
|
|
93
|
+
assert.notStrictEqual(name1, name2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should use default prefix', () => {
|
|
97
|
+
const name = generateIsolatedUsername();
|
|
98
|
+
assert.ok(name.startsWith('start-'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should use custom prefix', () => {
|
|
102
|
+
const name = generateIsolatedUsername('test');
|
|
103
|
+
assert.ok(name.startsWith('test-'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should generate valid username (no special chars)', () => {
|
|
107
|
+
const name = generateIsolatedUsername();
|
|
108
|
+
assert.ok(/^[a-zA-Z0-9_-]+$/.test(name));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should not exceed 31 characters', () => {
|
|
112
|
+
const name = generateIsolatedUsername();
|
|
113
|
+
assert.ok(name.length <= 31);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should handle long prefix by truncating', () => {
|
|
117
|
+
const longPrefix = 'this-is-a-very-long-prefix';
|
|
118
|
+
const name = generateIsolatedUsername(longPrefix);
|
|
119
|
+
assert.ok(name.length <= 31);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('getUserInfo', () => {
|
|
124
|
+
it('should return exists: false for non-existent user', () => {
|
|
125
|
+
const fakeUser = `nonexistent-user-${Date.now()}`;
|
|
126
|
+
const info = getUserInfo(fakeUser);
|
|
127
|
+
assert.strictEqual(info.exists, false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should return user info for current user', () => {
|
|
131
|
+
const currentUser = getCurrentUser();
|
|
132
|
+
const info = getUserInfo(currentUser);
|
|
133
|
+
assert.strictEqual(info.exists, true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should include uid for existing user', () => {
|
|
137
|
+
if (process.platform !== 'win32') {
|
|
138
|
+
const info = getUserInfo('root');
|
|
139
|
+
if (info.exists) {
|
|
140
|
+
assert.strictEqual(info.uid, 0); // root uid is always 0
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('args-parser user isolation options', () => {
|
|
148
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
149
|
+
|
|
150
|
+
describe('--isolated-user option (user isolation)', () => {
|
|
151
|
+
it('should parse --isolated-user flag', () => {
|
|
152
|
+
const result = parseArgs(['--isolated-user', '--', 'npm', 'test']);
|
|
153
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
154
|
+
assert.strictEqual(result.wrapperOptions.userName, null);
|
|
155
|
+
assert.strictEqual(result.command, 'npm test');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should parse --isolated-user with custom username', () => {
|
|
159
|
+
const result = parseArgs([
|
|
160
|
+
'--isolated-user',
|
|
161
|
+
'myuser',
|
|
162
|
+
'--',
|
|
163
|
+
'npm',
|
|
164
|
+
'test',
|
|
165
|
+
]);
|
|
166
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
167
|
+
assert.strictEqual(result.wrapperOptions.userName, 'myuser');
|
|
168
|
+
assert.strictEqual(result.command, 'npm test');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should parse --isolated-user=value format', () => {
|
|
172
|
+
const result = parseArgs([
|
|
173
|
+
'--isolated-user=testuser',
|
|
174
|
+
'--',
|
|
175
|
+
'npm',
|
|
176
|
+
'test',
|
|
177
|
+
]);
|
|
178
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
179
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testuser');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should parse -u shorthand', () => {
|
|
183
|
+
const result = parseArgs(['-u', '--', 'npm', 'test']);
|
|
184
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
185
|
+
assert.strictEqual(result.wrapperOptions.userName, null);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should parse -u with custom username', () => {
|
|
189
|
+
const result = parseArgs(['-u', 'myuser', '--', 'npm', 'test']);
|
|
190
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
191
|
+
assert.strictEqual(result.wrapperOptions.userName, 'myuser');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should work with isolation options', () => {
|
|
195
|
+
const result = parseArgs([
|
|
196
|
+
'--isolated',
|
|
197
|
+
'screen',
|
|
198
|
+
'--isolated-user',
|
|
199
|
+
'--',
|
|
200
|
+
'npm',
|
|
201
|
+
'start',
|
|
202
|
+
]);
|
|
203
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
204
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
205
|
+
assert.strictEqual(result.command, 'npm start');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should throw error when used with docker isolation', () => {
|
|
209
|
+
assert.throws(() => {
|
|
210
|
+
parseArgs([
|
|
211
|
+
'--isolated',
|
|
212
|
+
'docker',
|
|
213
|
+
'--image',
|
|
214
|
+
'node:20',
|
|
215
|
+
'--isolated-user',
|
|
216
|
+
'--',
|
|
217
|
+
'npm',
|
|
218
|
+
'test',
|
|
219
|
+
]);
|
|
220
|
+
}, /--isolated-user is not supported with Docker isolation/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should validate custom username format', () => {
|
|
224
|
+
assert.throws(() => {
|
|
225
|
+
parseArgs(['--isolated-user=invalid@name', '--', 'npm', 'test']);
|
|
226
|
+
}, /Invalid username format/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should validate custom username length', () => {
|
|
230
|
+
const longName = 'a'.repeat(40);
|
|
231
|
+
assert.throws(() => {
|
|
232
|
+
parseArgs([`--isolated-user=${longName}`, '--', 'npm', 'test']);
|
|
233
|
+
}, /Username too long/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should work with screen isolation and custom username', () => {
|
|
237
|
+
const result = parseArgs([
|
|
238
|
+
'-i',
|
|
239
|
+
'screen',
|
|
240
|
+
'--isolated-user',
|
|
241
|
+
'testrunner',
|
|
242
|
+
'-d',
|
|
243
|
+
'--',
|
|
244
|
+
'npm',
|
|
245
|
+
'test',
|
|
246
|
+
]);
|
|
247
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'screen');
|
|
248
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
249
|
+
assert.strictEqual(result.wrapperOptions.userName, 'testrunner');
|
|
250
|
+
assert.strictEqual(result.wrapperOptions.detached, true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should work with tmux isolation', () => {
|
|
254
|
+
const result = parseArgs([
|
|
255
|
+
'-i',
|
|
256
|
+
'tmux',
|
|
257
|
+
'--isolated-user',
|
|
258
|
+
'--',
|
|
259
|
+
'npm',
|
|
260
|
+
'start',
|
|
261
|
+
]);
|
|
262
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'tmux');
|
|
263
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('--keep-user option', () => {
|
|
268
|
+
it('should parse --keep-user with --isolated-user', () => {
|
|
269
|
+
const result = parseArgs([
|
|
270
|
+
'--isolated-user',
|
|
271
|
+
'--keep-user',
|
|
272
|
+
'--',
|
|
273
|
+
'npm',
|
|
274
|
+
'test',
|
|
275
|
+
]);
|
|
276
|
+
assert.strictEqual(result.wrapperOptions.user, true);
|
|
277
|
+
assert.strictEqual(result.wrapperOptions.keepUser, true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should throw error when used without --isolated-user', () => {
|
|
281
|
+
assert.throws(() => {
|
|
282
|
+
parseArgs(['--keep-user', '--', 'npm', 'test']);
|
|
283
|
+
}, /--keep-user option is only valid with --isolated-user/);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|