import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { applyRuntimePassword } from '../../src/lib/server/fleet/runtime-config.js'; import { newFixture, type Fixture } from '../helpers/fixture.js'; describe('Plutainer password rendering', () => { it.each(['set rcon_password', 'seta rcon_password', 'RCON_PASSWORD'])( 'rewrites %s while preserving comments and other settings', (prefix) => { const source = `// set rcon_password "comment"\n${prefix} 'old' // keep\nset sv_voice "1"\n`; const rendered = applyRuntimePassword(source, 'abc&/\\$xyz'); expect(rendered).toBe( `// set rcon_password "comment"\n${prefix} "abc&/\\$xyz" // keep\nset sv_voice "1"\n` ); expect(applyRuntimePassword(rendered, 'abc&/\\$xyz')).toBe(rendered); } ); it('matches startup handling of missing, duplicate, quoted and unquoted assignments', () => { expect(applyRuntimePassword('set sv_voice "1"\n', 'secret')).toBe( 'set sv_voice "1"\n\n// Added by Plutainer from PLUTAINER_RCON_PASSWORD.\nset rcon_password "secret"\n' ); expect( applyRuntimePassword('set rcon_password old\nrcon_password "later"', 'sec"ret') ).toBe('set rcon_password "secret"\nrcon_password "secret"'); expect(applyRuntimePassword('set rcon_password old\r\n', '')).toBe( 'set rcon_password old\r\n' ); }); it('normalizes CRLF only when a password write is needed, matching Plutainer', () => { const old = 'set rcon_password "old"\r\nset sv_voice "1"\r\n'; const effective = 'set rcon_password "new"\nset sv_voice "1"\n'; expect(applyRuntimePassword(old, 'new')).toBe(effective); expect(applyRuntimePassword(effective, 'new')).toBe(effective); const matching = old.replace('old', 'new'); expect(applyRuntimePassword(matching, 'new')).toBe(matching); }); }); let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); }); async function setup(game: 't5' | 't6') { const { db } = await import('../../src/lib/server/db/index.js'); const hosts = await import('../../src/lib/server/hosts/index.js'); const host = hosts.createHost({ name: 'Runtime config' }); hosts.enrollHost(hosts.createEnrollment(host.id).token, { protocolVersion: 1, capabilities: ['files.manifest'] }); const server = `${game}_runtime_1`; db().prepare('INSERT INTO servers(name,game) VALUES(?,?)').run(server, game); db() .prepare( `INSERT INTO native_instances(id,server,host_id,generation,library_id,image,port,created_at) VALUES('instance',?,?,1,'library','image',28960,?)` ) .run(server, host.id, new Date().toISOString()); const launch = ( id: string, kind: string, generation: number, password: string, serialized = false, state = 'completed' ) => { db() .prepare( `INSERT INTO ec_host_operations(id,host_id,instance_id,generation,kind,payload,state,created_at,updated_at) VALUES(?,?,'instance',?,?,?,?,?,?)` ) .run( id, host.id, generation, kind, JSON.stringify(serialized ? {} : { rconPassword: password }), state, new Date().toISOString(), new Date().toISOString() ); if (serialized) db() .prepare('INSERT INTO ec_host_operation_payloads(operation_id,payload) VALUES(?,?)') .run(id, JSON.stringify({ rconPassword: password })); }; return { db: db(), host, server, launch }; } it.each([ ['t5', 'a'.repeat(64)], ['t6', 'a'.repeat(64)], ['t5', 'shared-password'], ['t6', 'shared-password'] ] as const)( 'uses the assigned password for %s launches, drift and beta writes (%s)', async (game, password) => { const a = await setup(game); a.launch('create', 'instance.create', 1, password, true); const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const baseline = createScript({ name: 'Baseline', kind: 'baseline', scopeType: 'game', scopeValue: game, content: Buffer.from('set rcon_password "password"\nset sv_voice "1"\n') }); a.db .prepare('UPDATE scripts SET current_version_id=? WHERE id=?') .run(baseline.version.id, baseline.script.id); const { getPromotedServerConfig } = await import('../../src/lib/server/fleet/server-config.js'); const { captureRecipe } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { configFileForGame } = await import('../../src/lib/server/config.js'); const { sha256Buffer } = await import('../../src/lib/server/fleet/files.js'); const { recordScript, getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); const target = configFileForGame(game); const promoted = getPromotedServerConfig(game, null)!; const effective = getPromotedServerConfig(game, null, null, password)!; expect(effective.length - promoted.length).toBe(password.length - 8); const recipe = captureRecipe(game, game === 't5' ? 'theater' : 'transit', password); const recipeConfig = recipe.files.find((file) => file.path === target)!; expect(Buffer.from(recipeConfig.content, 'base64').toString()).toContain( `set rcon_password "${password}"` ); expect(recipeConfig.sha256).toBe(sha256Buffer(Buffer.from(recipeConfig.content, 'base64'))); recordScript( a.server, target, null, sha256Buffer(promoted), promoted.length, 'native-create' ); const backend = await import('../../src/lib/server/backends/native.js'); const { prepareNativeLaunch } = await import('../../src/lib/server/provisioning/launch-preparation.js'); const prepared = await prepareNativeLaunch({ game, mapSlug: recipe.mapSlug, instance: backend.nativeInstance(a.server)!, serverKey: 'test-key', rconPassword: password, bankToken: 'ec_bank_' + 'a'.repeat(43), rebuild: false }); const actualConfig = prepared.recipe.files.find((file) => file.path === target)!; expect(actualConfig.sha256).toBe(recipeConfig.sha256); const actualBytes = prepared.blobs!.find( (blob) => blob.sha256 === actualConfig.sha256 )!.content; expect(Buffer.from(actualBytes)).toEqual(Buffer.from(recipeConfig.content, 'base64')); expect(applyRuntimePassword(Buffer.from(actualBytes).toString(), password)).toBe( Buffer.from(actualBytes).toString() ); const payload = JSON.parse(Buffer.from(prepared.payloadJson).toString()); expect(payload.rconPassword).toBe(password); expect(payload.files).toContainEqual( expect.objectContaining({ path: `configs/${target}`, sha256: actualConfig.sha256 }) ); expect(prepared.recipeSummary).not.toContain(password); let observed = effective; vi.spyOn(backend, 'nativeRequest').mockImplementation(async (_instance, kind) => { expect(kind).toBe('files.manifest'); return { files: [ { path: `configs/${target}`, size: observed.length, sha256: sha256Buffer(observed) } ] } as never; }); const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js'); expect(await scanServerDrift(a.server)).toEqual([]); expect(getServerScript(a.server, target)?.sha256).toBe(sha256Buffer(effective)); observed = Buffer.from(effective.toString().replace(password, 'b'.repeat(password.length))); expect(await scanServerDrift(a.server)).toEqual([ expect.objectContaining({ relPath: target, driftKind: 'replace' }) ]); const overlay = createScript({ name: 'Overlay', kind: 'config', scopeType: 'game', scopeValue: game, content: Buffer.from('set sv_voice "0"\nset rcon_password "overlay-password"\n') }); const { openBeta, previewBetaPromotion } = await import('../../src/lib/server/fleet/beta.js'); const { betaId } = await openBeta({ scripts: [{ scriptId: overlay.script.id, scriptVersionId: overlay.version.id }], servers: [a.server], waitForHealth: false, waitForMatch: false }); const staged = a.db .prepare( `SELECT content_hash FROM staged_file_changes WHERE source_kind='beta' AND source_id=? AND server=?` ) .get(String(betaId), a.server) as { content_hash: string }; const { getFileBlob } = await import('../../src/lib/server/db/file-blobs.js'); const stagedContent = getFileBlob(staged.content_hash).toString(); expect(stagedContent).toContain(`set rcon_password "${password}"`); expect(stagedContent).toContain('set sv_voice "0"'); expect(previewBetaPromotion(betaId).configWrites).toContainEqual( expect.objectContaining({ server: a.server, sha256: staged.content_hash }) ); } ); it('retains live launch credentials through history cleanup, allowing file bodies and retired credentials to expire', async () => { const a = await setup('t5'); a.launch('create', 'instance.create', 1, 'original', true); a.launch('rebuild', 'instance.rebuild', 1, 'current', true); a.db.prepare("UPDATE native_instances SET generation=2 WHERE id='instance'").run(); a.db .prepare('UPDATE ec_host_operations SET updated_at=?') .run(new Date(Date.now() - 90 * 86400000).toISOString()); const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); const { pruneHistory } = await import('../../src/lib/server/retention.js'); const { runtimePasswordForServer } = await import('../../src/lib/server/backends/native.js'); saveFleetSettings({ ...fleetSettings(), actionKeepDays: 1, payloadKeepDays: 1, historyKeepCount: 0 }); expect(pruneHistory('operation-payload')).toBe(2); expect(runtimePasswordForServer(a.server)).toBe('current'); expect(pruneHistory('operation')).toBe(1); expect(runtimePasswordForServer(a.server)).toBe('current'); a.db .prepare("UPDATE native_instances SET retired_at=? WHERE id='instance'") .run(new Date().toISOString()); expect(pruneHistory('operation')).toBe(1); }); it('selects confirmed credentials by host and resulting generation, not future or failed attempts', async () => { const a = await setup('t5'); const { runtimePasswordForServer } = await import('../../src/lib/server/backends/native.js'); a.launch('create', 'instance.create', 1, 'original'); a.launch('future', 'instance.rebuild', 1, 'future', true, 'pending'); expect(runtimePasswordForServer(a.server)).toBe('original'); a.db.prepare("UPDATE ec_host_operations SET state='completed' WHERE id='future'").run(); expect(runtimePasswordForServer(a.server)).toBe('original'); a.db.prepare("UPDATE native_instances SET generation=2 WHERE id='instance'").run(); expect(runtimePasswordForServer(a.server)).toBe('future'); a.launch('failed', 'instance.create', 2, 'wrong', false, 'failed'); expect(runtimePasswordForServer(a.server)).toBe('future'); a.db.prepare("UPDATE native_instances SET host_id='different' WHERE id='instance'").run(); expect(() => runtimePasswordForServer(a.server)).toThrow('launch credentials are unavailable'); });