|
| 1 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest' |
| 2 | +import { spawnSync } from 'child_process' |
| 3 | +import { mkdirSync, writeFileSync, rmSync, chmodSync, existsSync, readFileSync } from 'fs' |
| 4 | +import { join } from 'path' |
| 5 | +import { tmpdir } from 'os' |
| 6 | +import { repoRoot } from '../helpers/repo-root' |
| 7 | + |
| 8 | +const libPath = join(repoRoot, 'scripts/lib/container-user.sh') |
| 9 | + |
| 10 | +let stubDir: string |
| 11 | +let logPath: string |
| 12 | + |
| 13 | +const writeStub = (name: string, body: string) => { |
| 14 | + const file = join(stubDir, name) |
| 15 | + writeFileSync(file, `#!/bin/bash\n${body}\n`) |
| 16 | + chmodSync(file, 0o755) |
| 17 | +} |
| 18 | + |
| 19 | +beforeEach(() => { |
| 20 | + stubDir = join(tmpdir(), `ocm-container-user-${Date.now()}-${Math.random().toString(36).slice(2)}`) |
| 21 | + mkdirSync(stubDir, { recursive: true }) |
| 22 | + logPath = join(stubDir, 'calls.log') |
| 23 | + |
| 24 | + writeStub('id', ` |
| 25 | +case "$1" in |
| 26 | + -u) echo "${'${OCM_STUB_NODE_UID:-1000}'}" ;; |
| 27 | + -g) echo "${'${OCM_STUB_NODE_GID:-1000}'}" ;; |
| 28 | + *) exit 1 ;; |
| 29 | +esac`) |
| 30 | + |
| 31 | + writeStub('getent', ` |
| 32 | +if [ "$1" = "group" ] && [ "$2" = "${'${OCM_STUB_GID_KEY:-__none__}'}" ]; then |
| 33 | + echo "${'${OCM_STUB_GID_HOLDER}'}:x:$2:" |
| 34 | + exit 0 |
| 35 | +fi |
| 36 | +if [ "$1" = "passwd" ] && [ "$2" = "${'${OCM_STUB_UID_KEY:-__none__}'}" ]; then |
| 37 | + echo "${'${OCM_STUB_UID_HOLDER}'}:x:$2:$2::/nonexistent:/bin/false" |
| 38 | + exit 0 |
| 39 | +fi |
| 40 | +exit 2`) |
| 41 | + |
| 42 | + writeStub('groupmod', `echo "groupmod $*" >> "$OCM_STUB_LOG"`) |
| 43 | + writeStub('usermod', `echo "usermod $*" >> "$OCM_STUB_LOG"`) |
| 44 | + writeStub('stat', `echo "${'${OCM_STUB_OWNER_UID:-0}'}"`) |
| 45 | +}) |
| 46 | + |
| 47 | +afterEach(() => { |
| 48 | + rmSync(stubDir, { recursive: true, force: true }) |
| 49 | +}) |
| 50 | + |
| 51 | +const runScript = (snippet: string, env: Record<string, string> = {}) => |
| 52 | + spawnSync('bash', ['-c', `set -e\nsource "${libPath}"\n${snippet}`], { |
| 53 | + encoding: 'utf-8', |
| 54 | + env: { |
| 55 | + ...process.env, |
| 56 | + PUID: '', |
| 57 | + PGID: '', |
| 58 | + PATH: `${stubDir}:${process.env.PATH}`, |
| 59 | + OCM_STUB_LOG: logPath, |
| 60 | + ...env, |
| 61 | + }, |
| 62 | + }) |
| 63 | + |
| 64 | +const stubCalls = () => { |
| 65 | + if (!existsSync(logPath)) return [] |
| 66 | + return readFileSync(logPath, 'utf-8').split('\n').filter(Boolean) |
| 67 | +} |
| 68 | + |
| 69 | +describe('resolve_target_ids', () => { |
| 70 | + it('defaults PUID/PGID to 1000 when unset', () => { |
| 71 | + const res = runScript('unset PUID PGID; resolve_target_ids; echo "uid=$OCM_TARGET_UID gid=$OCM_TARGET_GID"') |
| 72 | + expect(res.status).toBe(0) |
| 73 | + expect(res.stdout).toContain('uid=1000 gid=1000') |
| 74 | + }) |
| 75 | + |
| 76 | + it('honors explicit PUID and PGID values', () => { |
| 77 | + const res = runScript('resolve_target_ids; echo "uid=$OCM_TARGET_UID gid=$OCM_TARGET_GID"', { |
| 78 | + PUID: '1001', |
| 79 | + PGID: '1002', |
| 80 | + }) |
| 81 | + expect(res.status).toBe(0) |
| 82 | + expect(res.stdout).toContain('uid=1001 gid=1002') |
| 83 | + }) |
| 84 | + |
| 85 | + it('rejects non-numeric PUID with the offending value in stderr', () => { |
| 86 | + const res = runScript('resolve_target_ids', { PUID: 'abc' }) |
| 87 | + expect(res.status).not.toBe(0) |
| 88 | + expect(res.stderr).toMatch(/PUID must be a numeric user id/) |
| 89 | + expect(res.stderr).toContain('abc') |
| 90 | + }) |
| 91 | + |
| 92 | + it('rejects non-numeric PGID', () => { |
| 93 | + const res = runScript('resolve_target_ids', { PGID: '1x0' }) |
| 94 | + expect(res.status).not.toBe(0) |
| 95 | + expect(res.stderr).toMatch(/PGID must be a numeric group id/) |
| 96 | + }) |
| 97 | + |
| 98 | + it('falls back to 1000 when PUID is explicitly empty', () => { |
| 99 | + const res = runScript('resolve_target_ids; echo "uid=$OCM_TARGET_UID"', { PUID: '' }) |
| 100 | + expect(res.status).toBe(0) |
| 101 | + expect(res.stdout).toContain('uid=1000') |
| 102 | + }) |
| 103 | +}) |
| 104 | + |
| 105 | +describe('align_container_user', () => { |
| 106 | + it('is a no-op when ids already match', () => { |
| 107 | + const res = runScript( |
| 108 | + 'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', |
| 109 | + { OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1000', PGID: '1000' }, |
| 110 | + ) |
| 111 | + expect(res.status).toBe(0) |
| 112 | + expect(stubCalls()).toEqual([]) |
| 113 | + expect(res.stdout).toContain('uidChanged=0 gidChanged=0') |
| 114 | + }) |
| 115 | + |
| 116 | + it('aligns both ids and records the change, group before user', () => { |
| 117 | + const res = runScript( |
| 118 | + 'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', |
| 119 | + { OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1001', PGID: '1002' }, |
| 120 | + ) |
| 121 | + expect(res.status).toBe(0) |
| 122 | + expect(stubCalls()).toEqual(['groupmod -g 1002 node', 'usermod -u 1001 node']) |
| 123 | + expect(res.stdout).toContain('uidChanged=1 gidChanged=1') |
| 124 | + }) |
| 125 | + |
| 126 | + it('aligns only the gid when the uid already matches', () => { |
| 127 | + const res = runScript( |
| 128 | + 'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', |
| 129 | + { OCM_STUB_NODE_UID: '1001', PUID: '1001', PGID: '1002' }, |
| 130 | + ) |
| 131 | + expect(res.status).toBe(0) |
| 132 | + expect(stubCalls()).toEqual(['groupmod -g 1002 node']) |
| 133 | + expect(res.stdout).toContain('uidChanged=0 gidChanged=1') |
| 134 | + }) |
| 135 | + |
| 136 | + it('aligns only the uid when the gid already matches', () => { |
| 137 | + const res = runScript( |
| 138 | + 'align_container_user node; echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', |
| 139 | + { OCM_STUB_NODE_GID: '1002', PUID: '1001', PGID: '1002' }, |
| 140 | + ) |
| 141 | + expect(res.status).toBe(0) |
| 142 | + expect(stubCalls()).toEqual(['usermod -u 1001 node']) |
| 143 | + expect(res.stdout).toContain('uidChanged=1 gidChanged=0') |
| 144 | + }) |
| 145 | + |
| 146 | + it('defaults the account name to node', () => { |
| 147 | + const res = runScript('align_container_user', { PUID: '1001' }) |
| 148 | + expect(res.status).toBe(0) |
| 149 | + expect(stubCalls().some((c) => c.endsWith(' node'))).toBe(true) |
| 150 | + }) |
| 151 | + |
| 152 | + it('rejects malformed ids before touching accounts', () => { |
| 153 | + const res = runScript('align_container_user node', { PUID: 'abc' }) |
| 154 | + expect(res.status).not.toBe(0) |
| 155 | + expect(stubCalls()).toEqual([]) |
| 156 | + }) |
| 157 | + |
| 158 | + it('logs what it is doing', () => { |
| 159 | + const res = runScript('align_container_user node', { PUID: '1001', PGID: '1002' }) |
| 160 | + expect(res.status).toBe(0) |
| 161 | + expect(res.stdout).toMatch(/Aligning node group to gid 1002/) |
| 162 | + expect(res.stdout).toMatch(/Aligning node user to uid 1001/) |
| 163 | + }) |
| 164 | + |
| 165 | + it('propagates groupmod failure without setting the gid change flag', () => { |
| 166 | + writeStub('groupmod', `echo "groupmod $*" >> "$OCM_STUB_LOG"\nexit 1`) |
| 167 | + const res = runScript( |
| 168 | + 'align_container_user node || echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', |
| 169 | + { OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1001', PGID: '1002' }, |
| 170 | + ) |
| 171 | + expect(res.status).toBe(0) |
| 172 | + expect(stubCalls()).toEqual(['groupmod -g 1002 node']) |
| 173 | + expect(res.stdout).toContain('uidChanged=0 gidChanged=0') |
| 174 | + }) |
| 175 | + |
| 176 | + it('propagates usermod failure without setting the uid change flag', () => { |
| 177 | + writeStub('usermod', `echo "usermod $*" >> "$OCM_STUB_LOG"\nexit 1`) |
| 178 | + const res = runScript( |
| 179 | + 'align_container_user node || echo "uidChanged=$OCM_UID_CHANGED gidChanged=$OCM_GID_CHANGED"', |
| 180 | + { OCM_STUB_NODE_UID: '1000', OCM_STUB_NODE_GID: '1000', PUID: '1001', PGID: '1002' }, |
| 181 | + ) |
| 182 | + expect(res.status).toBe(0) |
| 183 | + expect(stubCalls()).toEqual(['groupmod -g 1002 node', 'usermod -u 1001 node']) |
| 184 | + expect(res.stdout).toContain('uidChanged=0 gidChanged=1') |
| 185 | + }) |
| 186 | +}) |
| 187 | + |
| 188 | +describe('align_container_user id collisions', () => { |
| 189 | + it('aborts before mutating anything when the gid is held by another group', () => { |
| 190 | + const res = runScript('align_container_user node', { |
| 191 | + OCM_STUB_NODE_UID: '1000', |
| 192 | + OCM_STUB_NODE_GID: '1000', |
| 193 | + PUID: '1001', |
| 194 | + PGID: '100', |
| 195 | + OCM_STUB_GID_KEY: '100', |
| 196 | + OCM_STUB_GID_HOLDER: 'users', |
| 197 | + }) |
| 198 | + expect(res.status).not.toBe(0) |
| 199 | + expect(res.stderr).toContain('PGID 100') |
| 200 | + expect(res.stderr).toContain('users') |
| 201 | + expect(res.stderr).toContain('id -g') |
| 202 | + expect(stubCalls()).toEqual([]) |
| 203 | + }) |
| 204 | + |
| 205 | + it('aborts after a successful gid alignment when the uid is held by another user', () => { |
| 206 | + const res = runScript('align_container_user node', { |
| 207 | + OCM_STUB_NODE_UID: '1000', |
| 208 | + OCM_STUB_NODE_GID: '1000', |
| 209 | + PUID: '4', |
| 210 | + PGID: '1002', |
| 211 | + OCM_STUB_UID_KEY: '4', |
| 212 | + OCM_STUB_UID_HOLDER: 'sync', |
| 213 | + }) |
| 214 | + expect(res.status).not.toBe(0) |
| 215 | + expect(res.stderr).toContain('PUID 4') |
| 216 | + expect(res.stderr).toContain('sync') |
| 217 | + expect(stubCalls()).toEqual(['groupmod -g 1002 node']) |
| 218 | + }) |
| 219 | + |
| 220 | + it('treats a gid already owned by the target account as a no-collision', () => { |
| 221 | + const res = runScript('align_container_user node', { |
| 222 | + OCM_STUB_NODE_GID: '1000', |
| 223 | + PGID: '1002', |
| 224 | + OCM_STUB_GID_KEY: '1002', |
| 225 | + OCM_STUB_GID_HOLDER: 'node', |
| 226 | + }) |
| 227 | + expect(res.status).toBe(0) |
| 228 | + expect(stubCalls()).toContain('groupmod -g 1002 node') |
| 229 | + }) |
| 230 | + |
| 231 | + it('treats a uid already owned by the target account as a no-collision', () => { |
| 232 | + const res = runScript('align_container_user node', { |
| 233 | + OCM_STUB_NODE_UID: '1000', |
| 234 | + PUID: '1001', |
| 235 | + PGID: '1000', |
| 236 | + OCM_STUB_UID_KEY: '1001', |
| 237 | + OCM_STUB_UID_HOLDER: 'node', |
| 238 | + }) |
| 239 | + expect(res.status).toBe(0) |
| 240 | + expect(stubCalls()).toEqual(['usermod -u 1001 node']) |
| 241 | + }) |
| 242 | + |
| 243 | + it('emits an actionable remediation hint in the collision message', () => { |
| 244 | + const gidCollision = runScript('align_container_user node', { |
| 245 | + OCM_STUB_NODE_UID: '1000', |
| 246 | + OCM_STUB_NODE_GID: '1000', |
| 247 | + PUID: '1001', |
| 248 | + PGID: '100', |
| 249 | + OCM_STUB_GID_KEY: '100', |
| 250 | + OCM_STUB_GID_HOLDER: 'users', |
| 251 | + }) |
| 252 | + expect(gidCollision.status).not.toBe(0) |
| 253 | + expect(gidCollision.stderr).toMatch(/Pick a different PGID/) |
| 254 | + |
| 255 | + const uidCollision = runScript('align_container_user node', { |
| 256 | + OCM_STUB_NODE_UID: '1000', |
| 257 | + OCM_STUB_NODE_GID: '1000', |
| 258 | + PUID: '4', |
| 259 | + PGID: '1002', |
| 260 | + OCM_STUB_UID_KEY: '4', |
| 261 | + OCM_STUB_UID_HOLDER: 'sync', |
| 262 | + }) |
| 263 | + expect(uidCollision.status).not.toBe(0) |
| 264 | + expect(uidCollision.stderr).toMatch(/Pick a different PUID/) |
| 265 | + }) |
| 266 | +}) |
| 267 | + |
| 268 | +describe('warn_if_workspace_owner_differs', () => { |
| 269 | + it('is silent when the path does not exist', () => { |
| 270 | + const res = runScript(`warn_if_workspace_owner_differs "${join(stubDir, 'does-not-exist')}" 1000`) |
| 271 | + expect(res.status).toBe(0) |
| 272 | + expect(res.stderr).toBe('') |
| 273 | + }) |
| 274 | + |
| 275 | + it('is silent when the directory is empty', () => { |
| 276 | + const ws = join(stubDir, 'ws') |
| 277 | + mkdirSync(ws, { recursive: true }) |
| 278 | + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000`, { OCM_STUB_OWNER_UID: '0' }) |
| 279 | + expect(res.status).toBe(0) |
| 280 | + expect(res.stderr).toBe('') |
| 281 | + }) |
| 282 | + |
| 283 | + it('is silent when the directory is non-empty and the owner matches', () => { |
| 284 | + const ws = join(stubDir, 'ws') |
| 285 | + mkdirSync(ws, { recursive: true }) |
| 286 | + writeFileSync(join(ws, 'repo.txt'), 'data') |
| 287 | + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000`, { OCM_STUB_OWNER_UID: '1000' }) |
| 288 | + expect(res.status).toBe(0) |
| 289 | + expect(res.stderr).toBe('') |
| 290 | + }) |
| 291 | + |
| 292 | + it('warns non-fatally when a non-empty directory has a mismatched owner', () => { |
| 293 | + const ws = join(stubDir, 'ws') |
| 294 | + mkdirSync(ws, { recursive: true }) |
| 295 | + writeFileSync(join(ws, 'repo.txt'), 'data') |
| 296 | + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000`, { OCM_STUB_OWNER_UID: '1001' }) |
| 297 | + expect(res.status).toBe(0) |
| 298 | + expect(res.stderr).toContain('1001') |
| 299 | + expect(res.stderr).toContain('1000') |
| 300 | + expect(res.stderr).toContain('WARNING') |
| 301 | + expect(res.stderr).toContain(ws) |
| 302 | + expect(res.stderr).toMatch(/rewriting ownership/) |
| 303 | + }) |
| 304 | + |
| 305 | + it('names the id -u / id -g remediation in the warning', () => { |
| 306 | + const ws = join(stubDir, 'ws') |
| 307 | + mkdirSync(ws, { recursive: true }) |
| 308 | + writeFileSync(join(ws, 'repo.txt'), 'data') |
| 309 | + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000`, { OCM_STUB_OWNER_UID: '1001' }) |
| 310 | + expect(res.status).toBe(0) |
| 311 | + expect(res.stderr).toMatch(/id -u/) |
| 312 | + expect(res.stderr).toMatch(/id -g/) |
| 313 | + }) |
| 314 | + |
| 315 | + it('stays silent and returns 0 when stat fails under set -e', () => { |
| 316 | + const ws = join(stubDir, 'ws') |
| 317 | + mkdirSync(ws, { recursive: true }) |
| 318 | + writeFileSync(join(ws, 'repo.txt'), 'data') |
| 319 | + writeStub('stat', `exit 1`) |
| 320 | + const res = runScript(`warn_if_workspace_owner_differs "${ws}" 1000; echo "status=$?"`, { |
| 321 | + OCM_STUB_OWNER_UID: '1001', |
| 322 | + }) |
| 323 | + expect(res.status).toBe(0) |
| 324 | + expect(res.stderr).toBe('') |
| 325 | + expect(res.stdout).toContain('status=0') |
| 326 | + }) |
| 327 | +}) |
0 commit comments