fix: tests oom breaking (#2661)

* fix: healthcheck route visible in subdomains

* fix: tests oom breaking

* fix: don't spawn many dynalites

* fix: possible loop when merging self owned apps
This commit is contained in:
Daniel Salazar
2026-03-13 13:41:07 -07:00
committed by GitHub
parent 87672802fe
commit 9169114e60
8 changed files with 173 additions and 31 deletions
+3 -5
View File
@@ -28,13 +28,11 @@ jobs:
- name: Backend Tests (with coverage)
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=4096
run: |
rm package-lock.json
npm install -g npm@latest
npm install
npm ci
npm run build
npm run test:backend -- --coverage --maxWorkers=2 --coverage.reporter=json --coverage.reporter=json-summary --coverage.reporter=lcov
npm run test:backend -- --coverage --coverage.reporter=json --coverage.reporter=json-summary --coverage.reporter=lcov
- name: Upload backend coverage report
if: ${{ always() && hashFiles('coverage/**/coverage-summary.json') != '' }}
+50 -15
View File
@@ -15,6 +15,43 @@ interface DBClientConfig {
endpoint?: string
}
const LOCAL_DYNAMO_PATH_KEY = ':memory:';
const localDynaliteEndpointPromises = new Map<string, Promise<string>>();
const getDynalitePathKey = (path?: string) => {
if ( path === ':memory:' ) return LOCAL_DYNAMO_PATH_KEY;
return path || './puter-ddb';
};
const getOrCreateLocalDynaliteEndpoint = async (pathKey: string) => {
let endpointPromise = localDynaliteEndpointPromises.get(pathKey);
if ( endpointPromise ) return endpointPromise;
endpointPromise = (async () => {
const dynaliteOptions = pathKey === LOCAL_DYNAMO_PATH_KEY
? { createTableMs: 0 }
: { createTableMs: 0, path: pathKey };
const dynaliteInstance = dynalite(dynaliteOptions);
const dynaliteServer = dynaliteInstance.listen(0, '127.0.0.1');
// Don't keep test workers alive just because dynalite is still open.
dynaliteServer.unref?.();
await once(dynaliteServer, 'listening');
const address = dynaliteServer.address();
const port = (typeof address === 'object' && address ? address.port : undefined) || 4567;
return `http://127.0.0.1:${port}`;
})();
localDynaliteEndpointPromises.set(pathKey, endpointPromise);
endpointPromise.catch(() => {
if ( localDynaliteEndpointPromises.get(pathKey) === endpointPromise ) {
localDynaliteEndpointPromises.delete(pathKey);
}
});
return endpointPromise;
};
export class DDBClient {
ddbClientPromise: Promise<DynamoDBClient>;
#documentClient!: DynamoDBDocumentClient;
@@ -42,12 +79,8 @@ export class DDBClient {
async #getClient () {
if ( ! this.config?.aws ) {
console.warn('No config for DynamoDB, will fall back on local dynalite');
const dynaliteInstance = dynalite({ createTableMs: 0, path: this.config?.path === ':memory:' ? undefined : this.config?.path || './puter-ddb' });
const dynaliteServer = dynaliteInstance.listen(0, '127.0.0.1');
await once(dynaliteServer, 'listening');
const address = dynaliteServer.address();
const port = (typeof address === 'object' && address ? address.port : undefined) || 4567;
const dynamoEndpoint = `http://127.0.0.1:${port}`;
const pathKey = getDynalitePathKey(this.config?.path);
const dynamoEndpoint = await getOrCreateLocalDynaliteEndpoint(pathKey);
const client = new DynamoDBClient({
credentials: {
@@ -117,15 +150,17 @@ export class DDBClient {
return acc;
}, {} as Record<string, Record<string, unknown>[]>);
const RequestItems: BatchGetCommandInput['RequestItems'] = Object.entries(allRequestItemsPerTable).reduce((acc, [table, keyList]) => {
const Keys = keyList;
acc[table] = {
Keys,
ConsistentRead: consistentRead,
};
return acc;
},
{} as NonNullable<BatchGetCommandInput['RequestItems']>);
const RequestItems: BatchGetCommandInput['RequestItems'] = Object.entries(allRequestItemsPerTable).reduce(
(acc, [table, keyList]) => {
const Keys = keyList;
acc[table] = {
Keys,
ConsistentRead: consistentRead,
};
return acc;
},
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
);
const command = new BatchGetCommand({
RequestItems,
@@ -1025,6 +1025,7 @@ export default class AppService extends BaseService {
// Handle app-specific logic (AppES behavior)
const user = actor.type.user;
const oldAppId = await this.#resolveAppId(old_app);
// Ensure puter.site subdomain is owned by user (if index_url changed)
if ( object.index_url && object.index_url !== old_app.index_url ) {
@@ -1033,14 +1034,14 @@ export default class AppService extends BaseService {
object,
options,
user,
excludeAppId: old_app.id,
excludeAppId: oldAppId,
});
if ( joinedApp ) {
return joinedApp;
}
await this.#ensureIndexUrlNotAlreadyInUse({
indexUrl: object.index_url,
excludeAppId: old_app.id,
excludeAppId: oldAppId,
});
}
@@ -1065,6 +1066,20 @@ export default class AppService extends BaseService {
return await this.#read({ uid: old_app.uid });
}
async #resolveAppId (app) {
const appId = Number(app?.id);
if ( Number.isInteger(appId) && appId > 0 ) return appId;
if ( typeof app?.uid !== 'string' || !app.uid ) return undefined;
const rows = await this.db.read(
'SELECT id FROM apps WHERE uid = ? LIMIT 1',
[app.uid],
);
const resolvedId = Number(rows?.[0]?.id);
if ( Number.isInteger(resolvedId) && resolvedId > 0 ) return resolvedId;
return undefined;
}
async #check_owner_permission (old_app) {
const svc_permission = this.services.get('permission');
const actor = Context.get('actor');
@@ -1244,6 +1259,13 @@ export default class AppService extends BaseService {
const rows = await this.db.read(query, parameters);
const conflictRow = rows.find(row => {
if (
Number.isInteger(excludeAppId)
&& excludeAppId > 0
&& Number(row?.id) === excludeAppId
) {
return false;
}
if ( typeof row?.index_url === 'string' ) {
return indexUrlCandidates.includes(row.index_url);
}
@@ -1837,8 +1837,17 @@ describe('AppService', () => {
it('should join existing unowned hosted app when index_url is already in use on update', async () => {
setupContextForWrite(createMockUserActor(1));
mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 });
let readCallCount = 0;
mockDb.read.mockImplementation(async (query, params) => {
readCallCount++;
if ( readCallCount > 100 ) {
throw new Error(`excessive mockDb.read calls in join test: ${String(query)} :: ${JSON.stringify(params)}`);
}
if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) {
if ( Array.isArray(params) && params[params.length - 1] === 777 ) {
// Mirrors SQL `AND id != ?` behavior during join follow-up updates.
return [];
}
return [{
id: 777,
uid: 'app-conflict-uid',
@@ -1878,7 +1887,7 @@ describe('AppService', () => {
expect(result.uid).toBe('app-conflict-uid');
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['Joined Update Title', 777]),
expect.arrayContaining(['Joined Update Title', 'app-conflict-uid']),
);
expect(mockAppInformationService.delete_app).toHaveBeenCalledWith(
'app-uid-123',
@@ -540,8 +540,7 @@ class WebServerService extends BaseService {
// Check if the hostname matches any of the allowed domains or is a subdomain of an allowed domain
// Exception: allow /healthcheck endpoint on the root domain
if (
req.path === '/healthcheck' &&
hostName === config.domain.toLowerCase()
req.path === '/healthcheck'
) {
next();
return;
+37 -2
View File
@@ -679,7 +679,15 @@ class AppES extends BaseES {
query += ' ORDER BY timestamp ASC, id ASC LIMIT 1';
const rows = await this.db.read(query, parameters);
const normalizedExcludeMysqlId = Number(excludeMysqlId);
const conflictRow = rows.find(row => {
if (
Number.isInteger(normalizedExcludeMysqlId)
&& normalizedExcludeMysqlId > 0
&& Number(row?.id) === normalizedExcludeMysqlId
) {
return false;
}
if ( typeof row?.index_url === 'string' ) {
return candidates.includes(row.index_url);
}
@@ -688,6 +696,33 @@ class AppES extends BaseES {
return conflictRow || null;
},
async resolve_entity_mysql_id_ (entity) {
const directMysqlId = Number(entity?.private_meta?.mysql_id);
if ( Number.isInteger(directMysqlId) && directMysqlId > 0 ) {
return directMysqlId;
}
if ( !entity || typeof entity.get !== 'function' ) {
return undefined;
}
const uid = await entity.get('uid');
if ( typeof uid !== 'string' || !uid ) {
return undefined;
}
const rows = await this.db.read(
'SELECT id FROM apps WHERE uid = ? LIMIT 1',
[uid],
);
const mysqlId = Number(rows?.[0]?.id);
if ( Number.isInteger(mysqlId) && mysqlId > 0 ) {
return mysqlId;
}
return undefined;
},
async claim_app_ownership_by_id_for_user_ ({ appId, userId }) {
if ( !Number.isInteger(appId) || appId <= 0 ) return;
if ( !Number.isInteger(userId) || userId <= 0 ) return;
@@ -783,7 +818,7 @@ class AppES extends BaseES {
const new_index_url = await entity.get('index_url');
const source_entity = extra.old_entity;
const currentMysqlId = extra.old_entity?.private_meta?.mysql_id;
const currentMysqlId = await this.resolve_entity_mysql_id_(extra.old_entity);
const conflictRow = await this.find_index_url_conflict_({
indexUrl: new_index_url,
excludeMysqlId: currentMysqlId,
@@ -918,7 +953,7 @@ class AppES extends BaseES {
}
}
const currentMysqlId = extra.old_entity?.private_meta?.mysql_id;
const currentMysqlId = await this.resolve_entity_mysql_id_(extra.old_entity);
const conflictRow = await this.find_index_url_conflict_({
indexUrl: new_index_url,
excludeMysqlId: currentMysqlId,
+48 -1
View File
@@ -18,12 +18,59 @@
*/
'use strict';
const express = require('express');
const config = require('../config');
const router = new express.Router();
const normalizeHostDomain = (domain) => {
if ( typeof domain !== 'string' ) return null;
const normalizedDomain = domain.trim().toLowerCase().replace(/^\./, '');
if ( ! normalizedDomain ) return null;
try {
return new URL(`http://${normalizedDomain}`).hostname.toLowerCase();
} catch {
return normalizedDomain.split(':')[0] || null;
}
};
const hostMatchesDomain = (hostname, domain) => {
const normalizedHost = normalizeHostDomain(hostname);
const normalizedDomain = normalizeHostDomain(domain);
if ( !normalizedHost || !normalizedDomain ) return false;
return normalizedHost === normalizedDomain ||
normalizedHost.endsWith(`.${normalizedDomain}`);
};
const isHostedDomainRequest = (req) => {
const requestHost = normalizeHostDomain(req.hostname ?? req.headers?.host);
if ( ! requestHost ) return false;
const hostedDomains = new Set();
for ( const domain of [
config.static_hosting_domain,
config.static_hosting_domain_alt,
config.private_app_hosting_domain,
config.private_app_hosting_domain_alt,
] ) {
const normalizedDomain = normalizeHostDomain(domain);
if ( normalizedDomain ) {
hostedDomains.add(normalizedDomain);
}
}
return [...hostedDomains].some(hostedDomain =>
hostMatchesDomain(requestHost, hostedDomain));
};
// -----------------------------------------------------------------------//
// GET /healthcheck
// -----------------------------------------------------------------------//
router.get('/healthcheck', async (req, res) => {
router.get('/healthcheck', async (req, res, next) => {
if ( isHostedDomainRequest(req) ) {
next();
return;
}
const svc_serverHealth = req.services.get('server-health');
const status = await svc_serverHealth.get_status();
-3
View File
@@ -7,14 +7,11 @@ const isCi = process.env.CI === 'true';
export default defineConfig(({ mode }) => ({
test: {
globals: true,
maxWorkers: isCi ? 2 : undefined,
minWorkers: isCi ? 1 : undefined,
coverage: {
provider: 'v8',
reporter: isCi
? ['json', 'json-summary', 'lcov']
: ['text', 'json', 'json-summary', 'html', 'lcov'],
processingConcurrency: isCi ? 2 : undefined,
excludeAfterRemap: true,
// Keep coverage focused on executed files to avoid high-memory
// uncovered-file remapping in CI.