Files
meteor/packages/webapp/socket_file_tests.js
2026-04-08 09:15:25 -03:00

212 lines
5.7 KiB
JavaScript

import { writeFileSync, unlinkSync, statSync, readFileSync } from 'fs';
import { createServer } from 'net';
import { createServer as createServerHttp } from 'http';
import {
removeExistingSocketFile,
registerSocketFileCleanup,
} from './socket_file.js';
import { EventEmitter } from 'events';
import { tmpdir, userInfo, platform } from 'os';
import { main, getGroupInfo } from './webapp_server';
import express from 'express';
const testSocketFile = `${tmpdir()}/socket_file_tests`;
const getChownInfo = async (filePath) => {
try {
const stats = await statSync(filePath);
return { uid: stats.uid, gid: stats.gid };
} catch (error) {
console.error(`Error fetching ownership info for ${filePath}:`, error.message);
return null;
}
};
const isMacOS = () => {
return platform() === 'darwin';
};
const getGroupNameForGid = (gid) => {
try {
const data = readFileSync('/etc/group', 'utf8');
const line = data
.trim()
.split('\n')
.find((groupLine) => {
const [, , groupGid] = groupLine.trim().split(':');
return Number(groupGid) === gid;
});
if (!line) return null;
const [name] = line.trim().split(':');
return name || null;
} catch {
return null;
}
};
const getWritableGroupName = () => {
const { gid, uid } = userInfo();
const gidsToTry = new Set();
if (typeof gid === 'number') {
gidsToTry.add(gid);
}
if (typeof process.getgroups === 'function') {
process.getgroups().forEach((groupId) => gidsToTry.add(groupId));
}
for (const groupId of gidsToTry) {
const groupName = getGroupNameForGid(groupId);
if (groupName) {
return groupName;
}
}
if (Boolean(process.env.TRAVIS)) return 'travis';
if (isMacOS()) return 'staff';
return uid === 0 ? 'root' : null;
};
const removeTestSocketFile = () => {
try {
unlinkSync(testSocketFile);
} catch (error) {
// Do nothing
}
}
Tinytest.add("socket file - don't remove a non-socket file", test => {
writeFileSync(testSocketFile, "");
test.throws(
() => { removeExistingSocketFile(testSocketFile); },
/An existing file was found/
);
removeTestSocketFile()
});
Tinytest.addAsync(
'socket file - remove a previously existing socket file',
(test, done) => {
removeTestSocketFile();
const server = createServer();
server.listen(testSocketFile);
server.on('listening', Meteor.bindEnvironment(() => {
test.isNotUndefined(statSync(testSocketFile));
removeExistingSocketFile(testSocketFile);
test.throws(
() => { statSync(testSocketFile); },
/ENOENT/
);
server.close();
done();
}));
}
);
Tinytest.add(
'socket file - no existing socket file, nothing to remove',
test => {
removeTestSocketFile();
removeExistingSocketFile(testSocketFile);
}
);
Tinytest.add('socket file - remove socket file on exit', test => {
const testEventEmitter = new EventEmitter();
registerSocketFileCleanup(testSocketFile, testEventEmitter);
['exit', 'SIGINT', 'SIGHUP', 'SIGTERM'].forEach(signal => {
writeFileSync(testSocketFile, "");
test.isNotUndefined(statSync(testSocketFile));
testEventEmitter.emit(signal);
test.throws(
() => { statSync(testSocketFile); },
/ENOENT/
);
});
});
function prepareServer() {
removeTestSocketFile();
removeExistingSocketFile(testSocketFile);
const testEventEmitter = new EventEmitter();
registerSocketFileCleanup(testSocketFile, testEventEmitter);
const server = createServer();
server.listen(testSocketFile);
const app = express();
const httpServer = createServerHttp(app);
return { httpServer, server };
}
function closeServer({ httpServer, server }) {
return new Promise((resolve) => {
httpServer.on(
"listening",
Meteor.bindEnvironment(() => {
process.env.UNIX_SOCKET_PATH = "";
process.env.UNIX_SOCKET_GROUP = "";
removeExistingSocketFile(testSocketFile);
server.close();
httpServer.close();
resolve();
})
);
});
}
testAsyncMulti(
"socket usage - use socket file for inter-process communication",
[
async (test) => {
// use UNIX_SOCKET_PATH
const { httpServer, server } = prepareServer();
process.env.UNIX_SOCKET_PATH = testSocketFile;
const result = await main({ httpServer });
const currentGid = userInfo({ encoding: "utf8" })?.gid;
test.equal((await getChownInfo(testSocketFile))?.gid, currentGid);
return closeServer({ httpServer, server });
},
async (test) => {
const isLinux = platform() === 'linux';
const isTravis = Boolean(process.env.TRAVIS);
if (isLinux && !isTravis) {
/*
* Local Linux developers usually run Meteor as an unprivileged user.
* Changing the socket file's group to "root" would require elevated
* permissions, so we skip this assertion outside CI to avoid forcing
* sudo usage. The behavior is still verified on macOS and in CI.
*/
test.ok();
return;
}
// use UNIX_SOCKET_PATH and UNIX_SOCKET_GROUP
const groupToUse = getWritableGroupName();
if (!groupToUse) {
// Skip when no writable group could be determined for the current user.
test.isTrue(true);
return;
}
const { httpServer, server } = prepareServer();
process.env.UNIX_SOCKET_PATH = testSocketFile;
process.env.UNIX_SOCKET_GROUP = groupToUse;
process.env.UNIX_SOCKET_PERMISSIONS = '777';
const result = await main({ httpServer });
test.equal(result, "DAEMON");
test.equal((await getChownInfo(testSocketFile))?.gid, getGroupInfo(process.env.UNIX_SOCKET_GROUP)?.gid);
return closeServer({ httpServer, server });
},
]
);