t
This commit is contained in:
parent
4c4e767ec6
commit
9d70afb4cc
42 changed files with 1266 additions and 630 deletions
|
|
@ -25,15 +25,15 @@ const {createCursorModel}=require('../client/ui/cursor');
|
|||
let unauthorized=false;try{auth.parse({headers:{}})}catch(error){unauthorized=error.status===401}
|
||||
assert(unauthorized,'Authentication middleware did not reject a missing bearer token');
|
||||
|
||||
const files=new Map(),fsp={
|
||||
let renameAttempts=0;const files=new Map(),fsp={
|
||||
async readFile(file){const value=files.get(file);if(value==null)throw Object.assign(new Error('missing'),{code:'ENOENT'});return value},
|
||||
async writeFile(file,value){files.set(file,value)},
|
||||
async rename(from,to){files.set(to,files.get(from));files.delete(from)},
|
||||
async rename(from,to){if(++renameAttempts<3)throw Object.assign(new Error('busy'),{code:'EPERM'});files.set(to,files.get(from));files.delete(from)},
|
||||
async unlink(file){if(!files.delete(file))throw Object.assign(new Error('missing'),{code:'ENOENT'})}
|
||||
};
|
||||
const repository=createJsonRepository({fsp,crypto:{randomBytes:()=>Buffer.from('abcdef','hex')},processId:1});
|
||||
await repository.write('world.json',{revision:3});
|
||||
assert((await repository.read('world.json')).revision===3,'JSON repository did not publish an atomic record');
|
||||
assert((await repository.read('world.json')).revision===3&&renameAttempts===3,'JSON repository did not retry and publish a transiently blocked atomic record');
|
||||
await repository.remove('world.json');assert(await repository.read('world.json',{missing:null})===null,'JSON repository missing-value behavior is incorrect');
|
||||
|
||||
const cursor=createCursorModel([{cursorStyle:'smile',cursorEmoji:'🙂'},{cursorStyle:'flag',flagAsset:'flag.svg'}]);
|
||||
|
|
|
|||
|
|
@ -87,23 +87,10 @@ async function endpoint(){
|
|||
}
|
||||
|
||||
async function startStaticServer(){
|
||||
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.svg':'image/svg+xml','.ttf':'font/ttf','.ico':'image/x-icon'};
|
||||
const server=http.createServer((request,response)=>{
|
||||
const pathname=new URL(request.url,'http://localhost').pathname;
|
||||
const relative=pathname==='/'?'index.html':decodeURIComponent(pathname.slice(1));
|
||||
const file=path.resolve(root,relative);
|
||||
if(file!==root&&!file.startsWith(root+path.sep)){response.writeHead(403);response.end();return}
|
||||
fs.readFile(file,(error,body)=>{
|
||||
if(error){response.writeHead(error.code==='ENOENT'?404:500);response.end();return}
|
||||
response.writeHead(200,{'content-type':types[path.extname(file)]||'application/octet-stream','cache-control':'no-store'});
|
||||
response.end(body);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve,reject)=>{
|
||||
server.once('error',reject);server.listen(0,'127.0.0.1',()=>{server.off('error',reject);resolve()});
|
||||
});
|
||||
serverPort=server.address().port;
|
||||
return server;
|
||||
const publicDir=path.join(temporaryRoot,'public'),dataRoot=path.join(temporaryRoot,'server-data');
|
||||
process.env.HOST='127.0.0.1';process.env.PORT='0';process.env.LINK_FIELD_TEST_DATA_ROOT=dataRoot;process.env.LINK_FIELD_PUBLIC_DIR=publicDir;process.env.LINK_FIELD_APACHE_BRIDGE='0';
|
||||
const service=require('../scripts/service-control');await service.deployPublicFiles(publicDir);
|
||||
const application=require('../server'),result=await application.main(),server=result.server;serverPort=result.port;server._linkfieldClose=()=>application.closeApplication(result);return server;
|
||||
}
|
||||
|
||||
async function ready(client){
|
||||
|
|
@ -686,7 +673,7 @@ async function main(runProfiles=profiles,cpuRates=[1,4]){
|
|||
if(startupOnly){
|
||||
await sleep(12000);
|
||||
const state=await client.evaluate("({ready:document.body?.dataset?.ready||null,version:document.querySelector('.brand small')?.textContent||null,boards:typeof data!=='undefined'?Object.keys(data.metas).length:null,origin:typeof data!=='undefined'&&Boolean(data.metas?.B0?.puzzle),worldGeneration:typeof data!=='undefined'?data.worldGeneration:null,turnFont:getComputedStyle(document.querySelector('.board-svg text')||document.body).fontFamily,status:document.querySelector('#statusMessage')?.textContent||'',text:document.body?.innerText?.slice(0,300)||''})");
|
||||
assert(state.ready==='true'&&state.version==='v47.87'&&state.boards===1&&state.origin&&state.worldGeneration==='linkfield-single-world-20260801',`Real-browser startup state is incomplete: ${JSON.stringify(state)}`);
|
||||
assert(state.ready==='true'&&state.version==='v48.0'&&state.boards===1&&state.origin&&state.worldGeneration==='linkfield-single-world-20260801',`Real-browser startup state is incomplete: ${JSON.stringify(state)}`);
|
||||
assert(/DotGothic16|Press Start 2P|MS Gothic|monospace/i.test(state.turnFont),'Dot-styled game font is not active in the browser');
|
||||
console.log(`Real-browser startup passed: ${JSON.stringify(state)}`);return;
|
||||
}
|
||||
|
|
@ -724,7 +711,7 @@ async function main(runProfiles=profiles,cpuRates=[1,4]){
|
|||
}finally{
|
||||
client?.close();
|
||||
stopBrowserTree(edge);
|
||||
server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()));
|
||||
if(server._linkfieldClose)await server._linkfieldClose();else{server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()))}
|
||||
for(let attempt=0;attempt<8;attempt++){
|
||||
try{fs.rmSync(temporaryRoot,{recursive:true,force:true});break}
|
||||
catch(error){if(attempt===7)console.warn(`Benchmark cleanup deferred: ${error.message}`);await sleep(150)}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ assert(!css.includes('.board-card:not(.input-active) .static-layer')&&!css.inclu
|
|||
|
||||
// 15. Completion is shown only after durable shared-world confirmation.
|
||||
const solveSource=functionSource('checkSolvedAndExpand');
|
||||
assert(solveSource.indexOf('persistence=save(true)')<solveSource.indexOf('completionEffect(immediateBoard,award)')&&solveSource.indexOf('pushCloudPending()')<solveSource.indexOf('completionEffect(immediateBoard,award)'),'Completion is displayed before local persistence and shared-world confirmation');
|
||||
assert(solveSource.indexOf('persistence=save(true)')<solveSource.indexOf('completionEffect(immediateBoard,award)')&&solveSource.indexOf('pushCloudPending(b.id)')<solveSource.indexOf('completionEffect(immediateBoard,award)'),'Completion is displayed before local persistence and shared-world confirmation');
|
||||
assert(solveSource.includes('preparation=prepareExpansionCandidate(b.meta)')&&solveSource.includes('expandMeta(durableMeta,prepared)'),'Expansion generation does not start with the clear display or does not install against durable metadata');
|
||||
assert(solveSource.includes('playGemCollectionAnimation(immediateBoard,award)')&&functionSource('playGemCollectionAnimation').includes('gemCollectionSources')&&functionSource('playGemCollectionAnimation').includes('scoreCountEl'),'Clear rewards do not travel from the board to the gem wallet');
|
||||
assert(functionSource('skipCompletionVisuals').includes('finishCompletionVisual')&&functionSource('finishCompletionVisual').includes('visual.resolve'),'Completion visual is not independently skippable');
|
||||
|
|
|
|||
27
test/php-bridge-integration-test.js
Normal file
27
test/php-bridge-integration-test.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const fsp=fs.promises;
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {spawn,spawnSync}=require('child_process');
|
||||
const service=require('../scripts/service-control');
|
||||
|
||||
const php=process.env.LINK_FIELD_PHP_PATH||'php',probe=spawnSync(php,['-v'],{encoding:'utf8'});
|
||||
if(probe.error||probe.status!==0)throw new Error('PHP CLI is required for the bridge integration test');
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
|
||||
(async()=>{
|
||||
const root=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-php-bridge-')),publicDir=path.join(root,'public'),dataRoot=path.join(root,'data'),nodePort=40000+Math.floor(Math.random()*5000),phpPort=nodePort+5000;
|
||||
await service.deployPublicFiles(publicDir);
|
||||
const node=spawn(process.execPath,[path.join(service.ROOT,'server.js')],{env:{...process.env,HOST:'127.0.0.1',PORT:String(nodePort),LINK_FIELD_TEST_DATA_ROOT:dataRoot,LINK_FIELD_PUBLIC_DIR:publicDir,LINK_FIELD_APACHE_BRIDGE:'0'},stdio:['ignore','pipe','pipe']});
|
||||
const phpServer=spawn(php,['-S',`127.0.0.1:${phpPort}`,'-t',publicDir],{stdio:['ignore','pipe','pipe']});let errors='';node.stderr.on('data',chunk=>errors+=chunk);phpServer.stderr.on('data',chunk=>errors+=chunk);
|
||||
try{
|
||||
let response;for(let i=0;i<100;i++){try{response=await fetch(`http://127.0.0.1:${phpPort}/api-bridge.php?path=/api/cloud/status`);if(response.ok)break}catch(_){}await sleep(50)}
|
||||
assert(response?.ok,errors);assert.equal(response.headers.get('x-linkfield-bridge'),'php');const status=await response.json();assert.equal(status.available,true);
|
||||
response=await fetch(`http://127.0.0.1:${phpPort}/api-bridge.php?path=/api/cloud/session`,{method:'POST',headers:{'content-type':'application/json'},body:'{"name":"PHP"}'});assert.equal(response.status,201);assert.match((await response.json()).playerId,/^[a-f0-9]{24}$/);
|
||||
response=await fetch(`http://127.0.0.1:${phpPort}/api-bridge.php?path=${encodeURIComponent('/api/cloud/status\r\nInjected: true')}`);assert.equal(response.status,400);
|
||||
console.log('Real PHP-to-Node bridge integration passed');
|
||||
}finally{node.kill('SIGTERM');phpServer.kill('SIGTERM');await sleep(300);if(node.exitCode==null)node.kill('SIGKILL');if(phpServer.exitCode==null)phpServer.kill('SIGKILL');await fsp.rm(root,{recursive:true,force:true})}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
|
||||
34
test/public-health-smoke-test.js
Normal file
34
test/public-health-smoke-test.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const http=require('http');
|
||||
const {checkPublicDeployment}=require('../server/public-health');
|
||||
|
||||
(async()=>{
|
||||
let mode='healthy';
|
||||
const server=http.createServer((request,response)=>{
|
||||
if(request.url==='/link-field/'||request.url==='/link-field'){
|
||||
response.setHeader('content-type','text/html');
|
||||
if(mode==='disclosure')response.setHeader('server','Apache/2.4.66');
|
||||
response.end('<title>LinkField</title>');return;
|
||||
}
|
||||
if(['/link-field/server.js','/link-field/package.json','/link-field/scripts/service-control.js','/link-field/.linkfield-deployment.json'].includes(request.url)){
|
||||
if(mode==='source'){response.writeHead(200,{'content-type':'text/plain'});response.end('private source');return}
|
||||
response.writeHead(403);response.end();return;
|
||||
}
|
||||
if(request.url==='/link-field/api/cloud/status'){
|
||||
response.setHeader('content-type','application/json');response.end(JSON.stringify({available:true,appVersion:'48.0'}));return;
|
||||
}
|
||||
if(request.url==='/link-field/api-bridge.php?path=/api/cloud/status'){
|
||||
response.setHeader('content-type','application/json');response.setHeader('x-linkfield-bridge','php');response.end(JSON.stringify({available:true,appVersion:'48.0'}));return;
|
||||
}
|
||||
response.writeHead(404);response.end();
|
||||
});
|
||||
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve)});
|
||||
try{
|
||||
const base=`http://127.0.0.1:${server.address().port}/link-field/`;
|
||||
const healthy=await checkPublicDeployment(base,{appVersion:'48.0',timeoutMs:2000});assert.equal(healthy.base,base);
|
||||
mode='source';await assert.rejects(checkPublicDeployment(base,{appVersion:'48.0',timeoutMs:2000}),/Private deployment file is public/);
|
||||
mode='disclosure';await assert.rejects(checkPublicDeployment(base,{appVersion:'48.0',timeoutMs:2000}),/discloses a detailed version/);
|
||||
}finally{await new Promise(resolve=>server.close(resolve))}
|
||||
console.log('Public deployment health and source-boundary smoke test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
|
|
@ -5,7 +5,7 @@ const {execFileSync}=require('child_process');
|
|||
const tests=[
|
||||
'shared-contracts-test.js','interaction-ownership-test.js','frame-drag-scheduler-test.js','architecture-boundaries-test.js','source-smoke-test.js','v4771-ui-input-smoke-test.js','v4772-ownership-reaction-name-smoke-test.js',
|
||||
'v4774-drag-overview-smoke-test.js','v4775-pan-cursor-performance-smoke-test.js','v4776-frame-pipeline-smoke-test.js','v4777-hud-input-performance-smoke-test.js','v4778-interaction-scheduler-smoke-test.js','v4779-settings-pan-hud-smoke-test.js','v4780-release-persistence-cursor-smoke-test.js','v4781-hud-gate-overlay-smoke-test.js','v4782-audio-highlight-store-internal-gate-smoke-test.js','v4783-map-store-economy-persistence-smoke-test.js','v4784-pan-solve-production-smoke-test.js','v4785-cosmetics-shop-smoke-test.js','v4786-effects-ux-smoke-test.js','v4787-user-cosmetic-realtime-smoke-test.js','v4788-time-attack-navigation-smoke-test.js','v4784-user-request-smoke-test.js','effects-performance-smoke-test.js','cleanup-performance-smoke-test.js','field-persistence-smoke-test.js','field-save-load-v2-smoke-test.js','gameplay-simplification-smoke-test.js','economy-simulation-test.js','performance-smoke-test.js','mirror-chunk-smoke-test.js','storage-smoke-test.js','save-pipeline-smoke-test.js','concurrency-smoke-test.js','stage34-smoke-test.js',
|
||||
'expansion-repair-smoke-test.js','expansion-smoke-test.js','interaction-smoke-test.js','anomaly-smoke-test.js','special-cell-smoke-test.js','reset-smoke-test.js','shared-world-client-smoke-test.js','phase2-source-smoke-test.js','realtime-lease-unit-test.js','realtime-phase2-smoke-test.js','server-recovery-test.js','v4791-server-startup-smoke-test.js','v4792-apache-bridge-smoke-test.js','v4793-php-poll-bridge-smoke-test.js','v4794-background-deploy-smoke-test.js','v4795-single-world-only-smoke-test.js','v4797-shared-board-input-smoke-test.js','v4798-startup-version-retry-smoke-test.js','v4800-shared-clear-economy-smoke-test.js','server-smoke-test.js','shared-world-complete-smoke-test.js','security-authority-smoke-test.js'
|
||||
'expansion-repair-smoke-test.js','expansion-smoke-test.js','interaction-smoke-test.js','anomaly-smoke-test.js','special-cell-smoke-test.js','reset-smoke-test.js','shared-world-client-smoke-test.js','phase2-source-smoke-test.js','realtime-lease-unit-test.js','realtime-phase2-smoke-test.js','server-recovery-test.js','server-backup-test.js','server-hardening-test.js','public-health-smoke-test.js','v4791-server-startup-smoke-test.js','v4792-apache-bridge-smoke-test.js','v4793-php-poll-bridge-smoke-test.js','v4794-background-deploy-smoke-test.js','v4795-single-world-only-smoke-test.js','v4797-shared-board-input-smoke-test.js','v4798-startup-version-retry-smoke-test.js','v4800-shared-clear-economy-smoke-test.js','v4801-shared-sync-recovery-smoke-test.js','windows-host-hardening-smoke-test.js','server-smoke-test.js','shared-world-complete-smoke-test.js','security-authority-smoke-test.js'
|
||||
];
|
||||
for(const file of tests)execFileSync(process.execPath,[path.join(__dirname,file)],{stdio:'inherit'});
|
||||
const browserPath=process.env.BEND_FIELD_BROWSER_PATH||process.env.BEND_FIELD_EDGE_PATH||(process.platform==='win32'?'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe':'/usr/bin/chromium');
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ assert(pushCloudSource.includes('mergeCloudPending(cloudPushPending,pending)')&&
|
|||
const batchContext={};vm.createContext(batchContext);vm.runInContext(`${functionSource('emptyCloudPending')}\n${functionSource('takeCloudPendingBatch')}\nthis.takeCloudPendingBatch=takeCloudPendingBatch;`,batchContext);
|
||||
const source={metaIds:new Set(Array.from({length:600},(_,index)=>`B${index}`)),stateIds:new Set(['B0']),deleted:new Set(['B999']),globalChanged:true},
|
||||
{batch,remainder}=batchContext.takeCloudPendingBatch(source,512);
|
||||
assert(batch.metaIds.size===512&&remainder.metaIds.size===88&&remainder.stateIds.has('B0')&&remainder.deleted.has('B999')&&batch.globalChanged,'Cloud change batching lost or exceeded work');
|
||||
assert(batch.metaIds.size===1&&batch.metaIds.has('B0')&&batch.stateIds.has('B0')&&remainder.metaIds.size===599&&batch.deleted.has('B999')&&batch.globalChanged,'Cloud batching did not isolate one complete board mutation');
|
||||
}
|
||||
|
||||
const writes={meta:0,state:0,global:0,outbox:0,recovery:0,mirror:0,snapshot:0,checkpoint:0,revision:0,journalClear:0,signal:0,cloud:0};
|
||||
|
|
|
|||
26
test/server-backup-test.js
Normal file
26
test/server-backup-test.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const fsp=fs.promises;
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {createWorldBackup,verifyWorldBackup,restoreWorldBackup}=require('../server/world-backup');
|
||||
|
||||
(async()=>{
|
||||
const root=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-backup-')),world=path.join(root,'world'),backups=path.join(root,'backups');
|
||||
try{
|
||||
await fsp.mkdir(path.join(world,'shared-world.boards'),{recursive:true});
|
||||
const board={meta:{id:'B0'},state:{solved:false}},record={revision:3,global:{worldGeneration:'test'},boardVersions:{B0:3}};
|
||||
await fsp.writeFile(path.join(world,'shared-world.json'),JSON.stringify(record));
|
||||
await fsp.writeFile(path.join(world,'shared-world.boards','B0.3.json'),JSON.stringify(board));
|
||||
await fsp.writeFile(path.join(world,`${'a'.repeat(24)}.json`),JSON.stringify({playerId:'a'.repeat(24)}));
|
||||
const created=await createWorldBackup(world,backups,{retain:2,appVersion:'test'});assert.equal(created.manifest.worldRevision,3);assert.equal(created.manifest.fileCount,3);
|
||||
const verified=await verifyWorldBackup(backups,created.name);assert.equal(verified.manifest.app,'LinkField');
|
||||
await fsp.writeFile(path.join(world,'shared-world.json'),JSON.stringify({...record,revision:4}));
|
||||
const restored=await restoreWorldBackup(world,backups,created.name);assert(fs.existsSync(restored.previous));
|
||||
assert.equal(JSON.parse(await fsp.readFile(path.join(world,'shared-world.json'),'utf8')).revision,3);
|
||||
assert.deepEqual(JSON.parse(await fsp.readFile(path.join(world,'shared-world.boards','B0.3.json'),'utf8')),board);
|
||||
console.log('Server backup checksum and recoverable restore test passed');
|
||||
}finally{await fsp.rm(root,{recursive:true,force:true})}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
|
||||
49
test/server-hardening-test.js
Normal file
49
test/server-hardening-test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const fsp=fs.promises;
|
||||
const http=require('http');
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {spawn}=require('child_process');
|
||||
const {root,starterPuzzle}=require('./helpers/app-source');
|
||||
const {createRealtimeHub}=require('../realtime-server');
|
||||
const {checkPublicDeployment}=require('../server/public-health');
|
||||
const BuildMeta=require('../build-meta');
|
||||
const service=require('../scripts/service-control');
|
||||
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
function auth(session){return{authorization:`Bearer ${session.playerId}.${session.token}`,'content-type':'application/json'}}
|
||||
function boardMeta(id,x,seed,puzzle){return{id,x,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed,axis:'MIX',sealedSides:[],puzzle,rev:1,revAuthor:'client',untrustedBulk:{ignored:true}}}
|
||||
|
||||
(async()=>{
|
||||
const unitServer=http.createServer(),hub=createRealtimeHub({server:unitServer,authenticate:async()=>null,getBoardInfo:async()=>null,maxClients:10,maxClientsPerPlayer:5});
|
||||
try{const identity={playerId:'a'.repeat(24),name:'A'},client=hub.createPollingClient(identity);for(let i=0;i<300;i++)hub.broadcastClearEvents([{id:`B${i}`}]);const envelope=hub.pollPollingClient(identity,client.presenceId,0);assert.equal(envelope.eventGap,true);assert(envelope.messages.length<=256);let live=envelope;for(let i=0;i<=300&&live;i++)live=await hub.handlePollingMessage(identity,client.presenceId,{type:'snapshot-request'},live.sequence);assert.equal(live,null,'Realtime message flood was not disconnected')}finally{hub.close()}
|
||||
|
||||
let discloseVersion=false;const healthServer=http.createServer((req,res)=>{res.setHeader('server',discloseVersion?'Apache/2.4.58':'Apache');if(req.url==='/'){res.writeHead(200,{'content-type':'text/html'});return res.end('<title>LinkField</title>')}if(['/server.js','/package.json','/scripts/service-control.js','/.linkfield-deployment.json'].includes(req.url)){res.writeHead(403);return res.end()}const body=JSON.stringify({available:true,appVersion:BuildMeta.APP_VERSION});if(req.url?.startsWith('/api-bridge.php'))res.setHeader('x-linkfield-bridge','php');res.writeHead(200,{'content-type':'application/json'});res.end(body)});await new Promise(resolve=>healthServer.listen(0,'127.0.0.1',resolve));const healthBase=`http://127.0.0.1:${healthServer.address().port}/`;try{const checked=await checkPublicDeployment(healthBase,{appVersion:BuildMeta.APP_VERSION});assert.equal(checked.base,healthBase);discloseVersion=true;await assert.rejects(()=>checkPublicDeployment(healthBase,{appVersion:BuildMeta.APP_VERSION}),/discloses a detailed version/)}finally{await new Promise(resolve=>healthServer.close(resolve))}
|
||||
|
||||
const dataRoot=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-hardening-')),publicRoot=path.join(dataRoot,'public'),port=36000+Math.floor(Math.random()*2000),base=`http://127.0.0.1:${port}`;
|
||||
await service.deployPublicFiles(publicRoot);
|
||||
let symlinkCreated=false;try{await fsp.symlink(path.join(root,'server.js'),path.join(publicRoot,'assets','private-link.js'),'file');symlinkCreated=true}catch(error){if(!['EPERM','EACCES','ENOTSUP'].includes(error?.code))throw error}
|
||||
const child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,HOST:'127.0.0.1',PORT:String(port),LINK_FIELD_TEST_DATA_ROOT:dataRoot,LINK_FIELD_PUBLIC_DIR:publicRoot},stdio:['ignore','pipe','pipe']});let stderr='';child.stderr.on('data',chunk=>stderr+=chunk);
|
||||
async function request(url,options={}){const response=await fetch(base+url,options),text=await response.text();let body;try{body=JSON.parse(text)}catch{body={raw:text}}return{response,body}}
|
||||
async function stop(){child.kill('SIGTERM');await Promise.race([new Promise(resolve=>child.once('exit',resolve)),sleep(3000)]);if(child.exitCode==null)child.kill('SIGKILL')}
|
||||
try{
|
||||
for(let i=0;i<100;i++){try{if((await request('/api/cloud/status')).response.ok)break}catch(_){}await sleep(30)}
|
||||
const privateFile=await request('/package.json');assert.equal(privateFile.response.status,404);if(symlinkCreated)assert.equal((await request('/assets/private-link.js')).response.status,404);const publicScript=await fetch(`${base}/app.js`);assert.equal(publicScript.status,200);assert.match(publicScript.headers.get('cache-control')||'',/max-age=300/);
|
||||
const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:'{"name":"Alice"}'})).body,bob=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:'{"name":"Bob"}'})).body;
|
||||
const puzzle=starterPuzzle(),b0=boardMeta('B0',0,11,puzzle),b1=boardMeta('B1',1,12,puzzle);
|
||||
const initialPush={baseRevision:0,mutationId:'alice-init-0001',global:{worldGeneration:'attacker',schema:-1,appVersion:'fake',quarantine:{blob:'x'.repeat(1000)}},metas:[b0,b1],states:[{id:'B0',value:{paths:[],solved:false}},{id:'B1',value:{paths:[],solved:false}}]};let result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify(initialPush)});assert.equal(result.response.status,200,JSON.stringify(result.body));const duplicate=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify(initialPush)});assert.equal(duplicate.response.status,200);assert.equal(duplicate.body.duplicate,true);assert.equal(duplicate.body.revision,1);
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,mutationId:'alice-expand-0002',metas:[{id:'B2'}],states:[]})});assert.equal(result.response.status,403,'Expansion authorization must run before expensive metadata validation');
|
||||
const aliceConnect=await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'}),bobConnect=await request('/api/realtime/connect',{method:'POST',headers:auth(bob),body:'{}'});assert.equal(aliceConnect.response.status,200);assert.equal(bobConnect.response.status,200);
|
||||
for(const[session,connected,id]of[[alice,aliceConnect.body,'B0'],[bob,bobConnect.body,'B1']]){const claim=await request('/api/realtime/claim',{method:'POST',headers:auth(session),body:JSON.stringify({presenceId:connected.presenceId,boardId:id})});assert.equal(claim.body.ok,true)}
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,mutationId:'alice-state-0003',global:{worldGeneration:'poisoned'},metas:[],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false,unknown:{blob:'x'.repeat(1000)}}}]})});assert.equal(result.response.status,200);
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:1,mutationId:'bob-state-0001',global:{},metas:[],states:[{id:'B1',value:{paths:[],solved:false}}]})});assert.equal(result.response.status,200);assert.equal(result.body.rebased,true);
|
||||
const pull=await request('/api/cloud/pull?since=0',{headers:auth(alice)});assert.equal(pull.body.page.global.worldGeneration,BuildMeta.WORLD_GENERATION);assert.equal(pull.body.page.global.appVersion,BuildMeta.APP_VERSION);assert.equal(pull.body.page.global.quarantine,undefined);assert.equal(pull.body.page.states.B0.unknown,undefined);
|
||||
const shardNames=(await fsp.readdir(path.join(dataRoot,'world','shared-world.boards'))).sort();assert.deepEqual(shardNames,['B0.2.json','B1.3.json']);
|
||||
const viewport=await request('/api/realtime/send',{method:'POST',headers:auth(bob),body:JSON.stringify({presenceId:bobConnect.body.presenceId,message:{type:'viewport',minX:-10,minY:-10,maxX:10,maxY:10},afterSequence:bobConnect.body.sequence})}),after=viewport.body.sequence,started=Date.now(),pollPromise=request(`/api/realtime/poll?presenceId=${encodeURIComponent(bobConnect.body.presenceId)}&after=${after}&wait=20000`,{headers:auth(bob)});await sleep(300);await request('/api/realtime/send',{method:'POST',headers:auth(alice),body:JSON.stringify({presenceId:aliceConnect.body.presenceId,message:{type:'release',boardId:'B0'},afterSequence:aliceConnect.body.sequence})});const poll=await pollPromise;assert(Date.now()-started<3000);assert(poll.body.messages.some(message=>message.type==='claim-release'&&message.boardId==='B0'));
|
||||
for(let i=0;i<4;i++)assert.equal((await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'})).response.status,200);assert.equal((await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'})).response.status,503);
|
||||
for(let index=2;index<30;index++)assert.equal((await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:`P${index}`})})).response.status,201);assert.equal((await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:'{}'})).response.status,429);
|
||||
console.log('Server integrity, rebase, retention, long-poll, capacity, and rate-limit hardening passed');
|
||||
}finally{await stop();await fsp.rm(dataRoot,{recursive:true,force:true})}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
|
|
@ -37,5 +37,6 @@ const world=revision=>({revision,rowRevision:revision,boardVersions:{},changes:[
|
|||
assert.equal(JSON.parse(fs.readFileSync(worldFile,'utf8')).revision,1);
|
||||
assert.equal(JSON.parse(fs.readFileSync(playerFile,'utf8')).earnedScore,100);
|
||||
assert.equal(fs.existsSync(commitFile),false);
|
||||
const poisoned=JSON.parse(fs.readFileSync(worldFile,'utf8'));poisoned.global.worldGeneration='foreign-generation';fs.writeFileSync(worldFile,JSON.stringify(poisoned));fs.writeFileSync(path.join(boardsDir,'B9.99.json'),'{}');assert.equal(await server.collectRetiredBoardVersions(),0);assert.equal(fs.existsSync(path.join(boardsDir,'B9.99.json')),true,'Generation mismatch cleanup deleted an unreferenced shard');
|
||||
console.log('Shared-world commit recovery and retired-version collection passed');
|
||||
})().finally(()=>fs.rmSync(dataRoot,{recursive:true,force:true})).catch(error=>{console.error(error);process.exitCode=1});
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ const hostedRuntimeContext={globalThis:null,URL,location:{protocol:'https:',href
|
|||
assert.equal(hostedRuntimeContext.BendRuntimeConfig.cloudApi,true,'HTTP hosting must enable the shared-world API');
|
||||
assert.equal(hostedRuntimeContext.BendRuntimeConfig.appBaseUrl,'https://host.example/~333/link-field/','Hosted runtime did not preserve the application mount path');
|
||||
assert.equal(hostedRuntimeContext.BendRuntimeConfig.apiBridgeUrl,'https://host.example/~333/link-field/api-bridge.php','Hosted runtime did not configure the PHP API bridge');
|
||||
assert.equal(hostedRuntimeContext.BendRuntimeConfig.realtimeTransport,'http-poll','Static hosting must use HTTP realtime polling');
|
||||
assert.equal(hostedRuntimeContext.BendRuntimeConfig.realtimeTransport,'auto','Hosted runtime must prefer WebSocket and fall back to bounded HTTP polling');
|
||||
const endpointContext={URL,cloudApiBaseUrl:'https://host.example/~333/link-field/api/'};vm.createContext(endpointContext);vm.runInContext(`${functionSource('cloudEndpointUrl')}
|
||||
this.cloudEndpointUrl=cloudEndpointUrl;`,endpointContext);
|
||||
assert.equal(endpointContext.cloudEndpointUrl('/api/cloud/status'),'https://host.example/~333/link-field/api/cloud/status','Cloud API URL lost the mounted application path');
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ assert(!app.includes('anomalyAt')&&!app.includes('anomalyScoreMultiplier')&&!app
|
|||
assert(!serverSource.includes('migrateLegacyPlayer')&&!serverSource.includes('value.metas')&&!serverSource.includes('value.states'),'Cloud server still reads or migrates the retired monolithic player format');
|
||||
assert(html.includes('id="clearFeed"')&&css.includes('.clear-feed-item'),'Shared clear feed is missing above the minimap');
|
||||
assert(serverSource.includes("const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json')")&&serverSource.includes(".add('POST','/api/cloud/profile',handleCloudProfile)")&&serverSource.includes('withWorldQueue')&&serverSource.includes('clearEvents'),'Shared-world storage, profile naming, serialization, or clear feed API is missing');
|
||||
assert(functionSource('checkSolvedAndExpand').indexOf('pullCloudWorld(true)')<functionSource('checkSolvedAndExpand').indexOf('st.solved=true')&&functionSource('checkSolvedAndExpand').indexOf('pushCloudPending()')<functionSource('checkSolvedAndExpand').indexOf('expandMeta('),'Clear publication is not server-validated before shared expansion');
|
||||
assert(functionSource('checkSolvedAndExpand').indexOf('pullCloudWorld(true)')<functionSource('checkSolvedAndExpand').indexOf('st.solved=true')&&functionSource('checkSolvedAndExpand').indexOf('pushCloudPending(b.id)')<functionSource('checkSolvedAndExpand').indexOf('expandMeta('),'Clear publication is not server-validated before shared expansion');
|
||||
assert(functionSource('mergeSnapshotIntoData').includes('authoritativeWorld')&&functionSource('pullCloudWorld').includes('initial&&previousRevision===0&&targetRevision>0')&&functionSource('clearSharedWorldJournalRow').includes('cloudOutboxDeleteKeys'),'Initial shared-world adoption does not replace stale local world rows or clean their outbox entries');
|
||||
assert(!functionSource('noteCloudRow').includes("solved!==true")&&functionSource('currentCloudPending').includes('stateIds:[...cloudJournalStateIds]'),'Unfinished shared paths are still excluded from the durable outbox');
|
||||
assert(functionSource('canExpandSharedBoard').includes('state.solvedById===currentPlayerId()')&&functionSource('repairExpansions').includes('canExpandSharedBoard(meta,st)'),'Non-solving clients can race the solver while publishing newly generated boards');
|
||||
|
|
@ -146,7 +146,7 @@ assert(AppLogic.generatedShapeFamilyKey(familyA)===AppLogic.generatedShapeFamily
|
|||
const balancedFamilies=AppLogic.balancedShapeCandidates([familyA,familyARotated,familyB],12345,{hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle}).slice(0,2).map(AppLogic.generatedShapeFamilyKey);
|
||||
assert(new Set(balancedFamilies).size===2,'Shape balancing still exhausts one orientation-rich family before another family');
|
||||
for(let level=1;level<=10;level++){
|
||||
const range=AppLogic.sectionCountRange(level);assert(range.max===level&&range.min===Math.max(1,level-3),`Level ${level} section range is invalid`);
|
||||
const range=AppLogic.sectionCountRange(level),expected=level<=3?{min:1,max:1}:{min:Math.max(1,level-3),max:level};assert(range.max===expected.max&&range.min===expected.min,`Level ${level} section range is invalid`);
|
||||
const candidates=AppLogic.shapeCandidatesForLevel(0x470000+level,level,8,shapeDeps);assert(candidates.length>0,`Level ${level} has no section shapes`);
|
||||
for(const shape of candidates){assert(shape.length>=range.min&&shape.length<=range.max,`Level ${level} generated ${shape.length} sections outside ${range.min}-${range.max}`);const set=new Set(shape.map(([x,y])=>`${x},${y}`));let reached=new Set([`${shape[0][0]},${shape[0][1]}`]),changed=true;while(changed){changed=false;for(const[x,y]of shape)if(!reached.has(`${x},${y}`)&&[[1,0],[-1,0],[0,1],[0,-1]].some(([dx,dy])=>reached.has(`${x+dx},${y+dy}`))){reached.add(`${x},${y}`);changed=true}}assert(reached.size===set.size,'Generated section shape is disconnected')}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ assert(app.includes("timeAttackBtn.classList.toggle('final-countdown',remaining<
|
|||
assert(html.includes('獲得ジェムの倍率UP')&&html.includes('獲得ジェムに応じて次の通り倍率が上昇します。')&&html.includes('<small>基礎累計</small>'),'The multiplier explanation is missing');
|
||||
assert(app.includes('return timeAttackMultiplier(run.baseCollected||0)')&&app.includes('reward.preTimeAward')&&app.includes('run.baseCollected'),'The multiplier implementation no longer matches the displayed explanation');
|
||||
assert(!html.includes('id="timeAttackResultCollected"')&&!html.includes('id="timeAttackResultMultiplier"')&&!html.includes('id="timeAttackResultBonus"'),'Removed result fields remain');
|
||||
assert(app.includes("'https://host.nishi.boats/~333/link-field/'"),'The result URL is missing');
|
||||
assert(app.includes('cloudAppBaseUrl'),'The time-attack result does not use the current deployment URL');
|
||||
assert(css.includes('rgba(194,108,255,.42)')&&css.includes('#timeAttackCountdownOverlay'),'The purple multiplier panel or countdown styling is missing');
|
||||
console.log('v47.88 time-attack and navigation smoke test passed');
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,19 @@ const fs=require('fs');
|
|||
const fsp=fs.promises;
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {renderApacheBridge,replaceManagedBlock,installApacheBridge,BEGIN_MARKER,END_MARKER}=require('../server/apache-bridge');
|
||||
const {renderApacheBridge,renderApacheBootstrap,replaceManagedBlock,installApacheBridge,BEGIN_MARKER,END_MARKER}=require('../server/apache-bridge');
|
||||
|
||||
(async()=>{
|
||||
const rendered=renderApacheBridge(8080);
|
||||
assert.match(rendered,/RewriteRule \^api\/\(\.\*\)\$ http:\/\/127\.0\.0\.1:8080\/api\/\$1 \[P,L\]/);
|
||||
assert.match(rendered,/ws:\/\/127\.0\.0\.1:8080\/api\/realtime/);
|
||||
assert.match(rendered,/api-bridge\.php\?path=\/api\/\$1 \[QSA,L\]/);
|
||||
assert.match(rendered,/RewriteRule \^\(\?:assets\|client\)/);
|
||||
assert.match(rendered,/RewriteRule \^ - \[F,L\]/);
|
||||
assert.match(rendered,/server\\\.js\$/);
|
||||
assert.doesNotMatch(rendered,/RewriteRule \^\(\.\*\)\$ - \[L\]/);
|
||||
const bootstrap=renderApacheBootstrap();assert.match(bootstrap,/api-bridge\.php/);assert.match(bootstrap,/RewriteRule \^ - \[F,L\]/);assert.doesNotMatch(bootstrap,/127\.0\.0\.1:\d+/);
|
||||
assert.equal((await fsp.readFile(path.join(__dirname,'..','.htaccess'),'utf8')).replace(/\r\n?/g,'\n'),bootstrap,'Checked-in Apache bootstrap drifted from the safe renderer');
|
||||
assert.equal((rendered.match(new RegExp(BEGIN_MARKER,'g'))||[]).length,1);
|
||||
assert.equal((rendered.match(new RegExp(END_MARKER,'g'))||[]).length,1);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,15 @@ const {renderApacheBridge}=require('../server/apache-bridge');
|
|||
const app=fs.readFileSync(require.resolve('../app.js'),'utf8');
|
||||
const php=fs.readFileSync(require.resolve('../api-bridge.php'),'utf8');
|
||||
assert.match(runtime,/api-bridge\.php/);
|
||||
assert.match(runtime,/realtimeTransport:'http-poll'/);
|
||||
assert.match(runtime,/realtimeTransport:'auto'/);
|
||||
assert.match(app,/\/api\/realtime\/connect/);
|
||||
assert.match(app,/\/api\/realtime\/poll/);
|
||||
assert.match(app,/x-linkfield-authorization/);
|
||||
assert.match(app,/wait:bridge\?'0':'20000'/);
|
||||
assert.match(php,/\.linkfield-port/);
|
||||
assert.match(php,/X-LinkField-Authorization/i);
|
||||
assert.doesNotMatch(php,/file_get_contents\('php:\/\/input'\)/);
|
||||
assert.match(php,/php:\/\/temp\/maxmemory:1048576/);
|
||||
assert.match(renderApacheBridge(4312),/api-bridge\.php\?path=\/api\/\$1 \[QSA,L\]/);
|
||||
const phpCheck=spawnSync('php',['-l',require.resolve('../api-bridge.php')],{encoding:'utf8'});
|
||||
if(!phpCheck.error)assert.equal(phpCheck.status,0,phpCheck.stderr||phpCheck.stdout);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const fs=require('fs');
|
|||
const fsp=fs.promises;
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {spawnSync}=require('child_process');
|
||||
const {spawn,spawnSync}=require('child_process');
|
||||
const BuildMeta=require('../build-meta');
|
||||
const service=require('../scripts/service-control');
|
||||
|
||||
|
|
@ -15,21 +15,33 @@ const service=require('../scripts/service-control');
|
|||
const dataDir=path.join(root,'data');
|
||||
const logFile=path.join(root,'server.log');
|
||||
const serviceDir=path.join(root,'service');
|
||||
const env={...process.env,LINK_FIELD_PUBLIC_DIR:publicDir,LINK_FIELD_TEST_DATA_ROOT:dataDir,LINK_FIELD_TEST_DATA_ROOT:path.join(root,'shared-root'),LINK_FIELD_SERVICE_DIR:serviceDir,LINK_FIELD_LOG_FILE:logFile,PORT:'0'};
|
||||
const env={...process.env,LINK_FIELD_PUBLIC_DIR:publicDir,LINK_FIELD_TEST_DATA_ROOT:path.join(root,'shared-root'),LINK_FIELD_APACHE_BRIDGE:'1',LINK_FIELD_SERVICE_DIR:serviceDir,LINK_FIELD_LOG_FILE:logFile,LINK_FIELD_LOG_MAX_BYTES:'4096',LINK_FIELD_LOG_ROTATE_INTERVAL_MS:'100',PORT:'0'};
|
||||
let orphan=null;
|
||||
try{
|
||||
await service.deployPublicFiles(publicDir);
|
||||
const deployedBootstrap=await fsp.readFile(path.join(publicDir,'.htaccess'),'utf8');assert.match(deployedBootstrap,/api-bridge\.php/);assert.match(deployedBootstrap,/RewriteRule \^ - \[F,L\]/);assert.match(deployedBootstrap,/server\\\.js\$/);assert.doesNotMatch(deployedBootstrap,/127\.0\.0\.1:\d+/);
|
||||
for(const relative of ['index.html','app.js','runtime-config.js','api-bridge.php','assets','client']){
|
||||
assert.equal(fs.existsSync(path.join(publicDir,relative)),true,`${relative} was not deployed`);
|
||||
}
|
||||
const manifest=JSON.parse(await fsp.readFile(path.join(publicDir,'.linkfield-deployment.json'),'utf8'));
|
||||
assert.equal(manifest.version,'48.0');
|
||||
await fsp.writeFile(path.join(publicDir,'retired-managed.js'),'obsolete');await fsp.writeFile(path.join(publicDir,'operator-note.txt'),'preserve');
|
||||
await fsp.writeFile(path.join(publicDir,'.linkfield-deployment.json'),JSON.stringify({...manifest,entries:[...manifest.entries,'retired-managed.js']}));
|
||||
await service.deployPublicFiles(publicDir);assert.equal(fs.existsSync(path.join(publicDir,'retired-managed.js')),false);assert.equal(await fsp.readFile(path.join(publicDir,'operator-note.txt'),'utf8'),'preserve');
|
||||
|
||||
const worldDir=path.join(env.LINK_FIELD_TEST_DATA_ROOT,'world'),worldLockFile=path.join(worldDir,'server.pid');
|
||||
orphan=spawn(process.execPath,[path.join(service.ROOT,'server.js')],{cwd:service.ROOT,env,stdio:'ignore'});let orphanReady=false;for(let attempt=0;attempt<100;attempt++){await new Promise(resolve=>setTimeout(resolve,50));try{const orphanPort=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim()),health=await fetch(`http://127.0.0.1:${orphanPort}/api/cloud/status`);if(health.ok){orphanReady=true;break}}catch(_){}}assert(orphanReady,'Legacy-lock recovery fixture did not start');await fsp.writeFile(worldLockFile,String(orphan.pid));const blockedStart=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'start'],{cwd:service.ROOT,env,encoding:'utf8',timeout:5000});assert.notEqual(blockedStart.status,0);assert.match(blockedStart.stderr,/npm run recover/i);assert.equal(service.isProcessRunning(orphan.pid),true,'Legacy lock preflight stopped an unverified process');
|
||||
const recovery=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'recover'],{cwd:service.ROOT,env,encoding:'utf8',timeout:20_000});assert.equal(recovery.status,0,recovery.stderr||recovery.stdout);assert.match(recovery.stdout,/Recovered the LinkField world lock/i);assert.equal(service.isProcessRunning(orphan.pid),false,'Explicit recovery did not stop the legacy numeric lock holder');orphan=null;
|
||||
orphan=spawn(process.execPath,[path.join(service.ROOT,'server.js')],{cwd:service.ROOT,env,stdio:'ignore'});orphanReady=false;for(let attempt=0;attempt<100;attempt++){await new Promise(resolve=>setTimeout(resolve,50));try{const orphanPort=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim()),health=await fetch(`http://127.0.0.1:${orphanPort}/api/cloud/status`);if(health.ok){orphanReady=true;break}}catch(_){}}assert(orphanReady,'Orphan-server replacement fixture did not start');assert((await service.legacyLinkFieldPids({worldDir})).includes(orphan.pid),'World-lock discovery did not find the orphan LinkField server');
|
||||
|
||||
const start=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'start'],{cwd:service.ROOT,env,encoding:'utf8',timeout:20_000});
|
||||
assert.equal(start.status,0,start.stderr||start.stdout);
|
||||
assert.equal(service.isProcessRunning(orphan.pid),false,'npm start did not stop the untracked LinkField server');orphan=null;
|
||||
assert.match(start.stdout,/started in the background/i);
|
||||
assert.match(start.stdout,/command prompt is available again/i);
|
||||
const pidFile=path.join(serviceDir,'server.pid');
|
||||
const pid=Number((await fsp.readFile(pidFile,'utf8')).trim());
|
||||
const pidRecord=JSON.parse(await fsp.readFile(pidFile,'utf8')),pid=pidRecord.pid;
|
||||
assert.match(pidRecord.nonce,/^[a-f0-9]{32}$/);
|
||||
assert.equal(service.isProcessRunning(pid),true);
|
||||
const port=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim());
|
||||
assert.ok(Number.isSafeInteger(port)&&port>0);
|
||||
|
|
@ -39,11 +51,20 @@ const service=require('../scripts/service-control');
|
|||
assert.equal(status.sharedWorld,true);
|
||||
assert.equal(status.appVersion,'48.0');
|
||||
assert.equal(fs.existsSync(path.join(publicDir,'.htaccess')),true);
|
||||
await fsp.appendFile(logFile,'x'.repeat(8192));let rotated=false;for(let attempt=0;attempt<50;attempt++){await new Promise(resolve=>setTimeout(resolve,100));if(fs.existsSync(`${logFile}.1`)){rotated=true;break}}assert(rotated,'Supervisor did not rotate a live oversized log');
|
||||
await fsp.writeFile(pidFile,JSON.stringify({...pidRecord,nonce:'0'.repeat(32)}));const refusedStop=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'stop'],{cwd:service.ROOT,env,encoding:'utf8',timeout:10_000});assert.notEqual(refusedStop.status,0);assert.match(refusedStop.stderr,/not a verified LinkField supervisor/i);assert.equal(service.isProcessRunning(pid),true);await fsp.writeFile(pidFile,JSON.stringify(pidRecord));
|
||||
|
||||
const identityFile=path.join(serviceDir,'service.identity.json'),firstIdentity=JSON.parse(await fsp.readFile(identityFile,'utf8')),firstChildPid=firstIdentity.childPid;
|
||||
process.kill(firstChildPid,'SIGKILL');
|
||||
let restartedChildPid=null;
|
||||
for(let attempt=0;attempt<100;attempt++){await new Promise(resolve=>setTimeout(resolve,100));try{const identity=JSON.parse(await fsp.readFile(identityFile,'utf8')),activePort=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim());if(identity.childPid&&identity.childPid!==firstChildPid){const health=await fetch(`http://127.0.0.1:${activePort}/api/cloud/status`).catch(()=>null);if(health?.ok){restartedChildPid=identity.childPid;break}}}catch(_){}}
|
||||
assert(restartedChildPid,'Supervisor did not restart a crashed LinkField server');
|
||||
|
||||
await fsp.unlink(pidFile);
|
||||
const secondStart=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'start'],{cwd:service.ROOT,env,encoding:'utf8',timeout:20_000});
|
||||
assert.equal(secondStart.status,0,secondStart.stderr||secondStart.stdout);
|
||||
assert.match(secondStart.stdout,/Replacing the running LinkField server/i);
|
||||
const replacementPid=Number((await fsp.readFile(pidFile,'utf8')).trim());
|
||||
const replacementPid=JSON.parse(await fsp.readFile(pidFile,'utf8')).pid;
|
||||
assert.notEqual(replacementPid,pid);
|
||||
assert.equal(service.isProcessRunning(pid),false);
|
||||
assert.equal(service.isProcessRunning(replacementPid),true);
|
||||
|
|
@ -52,6 +73,7 @@ const service=require('../scripts/service-control');
|
|||
assert.equal(stop.status,0,stop.stderr||stop.stdout);
|
||||
assert.equal(service.isProcessRunning(replacementPid),false);
|
||||
}finally{
|
||||
if(orphan&&service.isProcessRunning(orphan.pid))try{process.kill(orphan.pid,'SIGKILL')}catch(_){}
|
||||
spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'stop'],{cwd:service.ROOT,env,encoding:'utf8',timeout:10_000});
|
||||
await fsp.rm(root,{recursive:true,force:true});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ assert(functionSource('fetchCurrentSharedWorldStatus').includes("status.singleWo
|
|||
assert(functionSource('requestBoardClaim').indexOf("fetchJson('/api/realtime/claim'")<functionSource('requestBoardClaim').indexOf('requestBoardClaimThroughRealtime')&&functionSource('requestBoardClaimThroughRealtime').includes('realtimeSend')&&!functionSource('requestBoardClaim').includes('await waitForRealtimeReady()'),'Board input is not using direct claim with realtime fallback');
|
||||
assert(!app.includes('BroadcastChannel')&&!app.includes('syncStorageKey')&&!app.includes('queueWorldSignal'),'Retired local cross-tab synchronization remains');
|
||||
assert(serverSource.includes('INSTANCE_LOCK_FILE')&&serverSource.includes('Another LinkField server is already running'),'Server process lock is missing');
|
||||
assert(serverSource.includes("const PRODUCTION_DATA_DIR = path.resolve('/link-field/world')"),'Shared data is not fixed to /link-field/world');
|
||||
assert(!serverSource.includes('LINK_FIELD_WORLD_DIR')&&!serverSource.includes('LINK_FIELD_DATA_ROOT')&&!serverSource.includes("'.local', 'share', 'LinkField'"),'Production can still select a second shared-world directory');
|
||||
assert(serverSource.includes("process.env.LINK_FIELD_WORLD_DIR || '/link-field/world'"),'Shared data does not use the safe production default or explicit world-directory override');
|
||||
assert(!serverSource.includes('LINK_FIELD_DATA_ROOT')&&!serverSource.includes("'.local', 'share', 'LinkField'"),'Production can still select an implicit second shared-world directory');
|
||||
assert(serviceSource.includes('stopLegacyLinkFieldServers')&&serviceSource.includes('Replacing the running LinkField server')&&serviceSource.includes("fsp.unlink(path.join(publicDir,'.linkfield-port'))"),'Old server processes or stale bridge ports can survive deployment');
|
||||
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ function boardMeta(puzzle){return{id:'B0',x:0,y:0,chunks:[[0,0]],level:1,targetL
|
|||
|
||||
const route=puzzle.solution[0],partial={paths:[{startGate:route.startGate,endGate:null,openGate:null,cells:route.cells.slice(0,3).map(cell=>[...cell])}],specialProgress:{crossings:[]},solved:false};
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:partial}],deleted:[]})});
|
||||
assert.equal(result.response.status,423,'A player without the board claim changed unfinished progress');
|
||||
assert.equal(result.response.status,423,'A player without the board claim changed unfinished progress');assert.equal(result.body.boardId,'B0','Claim failure did not identify the blocked board');
|
||||
|
||||
const bobConnected=await request('/api/realtime/connect',{method:'POST',headers:auth(bob),body:'{}'});assert.equal(bobConnected.response.status,200);const bobPresence=bobConnected.body.presenceId;
|
||||
|
||||
const connected=await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'});
|
||||
assert.equal(connected.response.status,200);
|
||||
|
|
@ -53,10 +55,15 @@ function boardMeta(puzzle){return{id:'B0',x:0,y:0,chunks:[[0,0]],level:1,targetL
|
|||
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:partial}],deleted:[]})});
|
||||
assert.equal(result.response.status,200);assert.equal(result.body.revision,2);
|
||||
const pulled=await request('/api/cloud/pull?since=0',{headers:auth(bob)});
|
||||
assert.equal(pulled.response.status,200);
|
||||
assert.deepEqual(pulled.body.page.states.B0.paths[0].cells,partial.paths[0].cells,'Another player did not receive unfinished board progress');
|
||||
assert.equal(pulled.body.page.states.B0.solved,false);
|
||||
const bobDraftEvents=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(bobPresence)}&after=${bobConnected.body.sequence||0}&wait=0`,{headers:auth(bob)});assert.equal(bobDraftEvents.response.status,200);assert(!bobDraftEvents.body.messages.some(message=>message.type==='world-revision'&&message.revision===2),'Every unfinished drag was announced as shared progress');
|
||||
let pulled=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pulled.response.status,200);assert.deepEqual(pulled.body.page.states.B0.paths,[],'Another player received an open, single-ended draft line');
|
||||
pulled=await request('/api/cloud/pull?since=0',{headers:auth(alice)});assert.deepEqual(pulled.body.page.states.B0.paths[0].cells,partial.paths[0].cells,'The active claimant could not recover their own draft line');
|
||||
|
||||
console.log('LinkField v48.0 shared board input and progress smoke test passed');
|
||||
assert(puzzle.solution.length>1,'Starter puzzle needs two routes for shared-progress coverage');const second=puzzle.solution[1],mixed={paths:[{startGate:route.startGate,endGate:route.endGate,openGate:null,cells:route.cells.map(cell=>[...cell])},{startGate:second.startGate,endGate:null,openGate:null,cells:second.cells.slice(0,Math.max(1,Math.min(3,second.cells.length))).map(cell=>[...cell])}],specialProgress:{crossings:[]},solved:false};
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:2,global:{nextId:1},metas:[],states:[{id:'B0',value:mixed}],deleted:[]})});assert.equal(result.response.status,200);assert.equal(result.body.revision,3);
|
||||
const bobRealtime=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(bobPresence)}&after=${bobDraftEvents.body.sequence||0}&wait=1000`,{headers:auth(bob)});assert.equal(bobRealtime.response.status,200);const progressRevision=bobRealtime.body.messages.find(message=>message.type==='world-revision'&&message.revision===3&&message.boardIds.includes('B0'));assert(progressRevision,'A completed gate-to-gate line did not emit shared progress');assert.equal(progressRevision.page.states.B0.paths.length,1,'Realtime progress leaked an unfinished draft or omitted the completed line');assert.equal(progressRevision.page.states.B0.paths[0].endGate,route.endGate,'Realtime progress did not contain the latest completed gate-to-gate line');
|
||||
pulled=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pulled.body.page.states.B0.paths.length,1);assert.equal(pulled.body.page.states.B0.paths[0].endGate,route.endGate,'Other player did not receive the completed gate-to-gate line');
|
||||
pulled=await request('/api/cloud/pull?since=0',{headers:auth(alice)});assert.equal(pulled.body.page.states.B0.paths.length,2,'The claimant lost their private open draft while sharing a completed line');
|
||||
|
||||
console.log('LinkField v48.03 immediate completed-line progress visibility smoke test passed');
|
||||
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});
|
||||
|
|
|
|||
|
|
@ -15,5 +15,5 @@ assert(functionSource('pullCloudWorld').includes('cloudSyncing=false;setCloudSta
|
|||
assert(functionSource('disconnectRealtimeForLifecycle').includes('keepalive:true'),'HTTP polling presence is not disconnected when the page closes');
|
||||
assert(read('realtime-server.js').includes("claim.ownerPresenceId === client.id")&&read('realtime-server.js').includes("releaseBoardClaim(boardId, 'disconnected')"),'Disconnected clients can retain board claims');
|
||||
|
||||
assert(service.includes('Replacing the running LinkField server')&&functionSource('start',service).indexOf('await stop({quiet:true})')<functionSource('start',service).indexOf('deployPublicFiles()'),'npm start does not replace a stale server before deployment');
|
||||
assert(service.includes('Replacing the running LinkField server')&&functionSource('start',service).indexOf('deployPublicFiles()')<functionSource('start',service).indexOf('await stop({quiet:true})')&&functionSource('deployPublicFiles',service).includes('fsp.rename(stage,publicDir)'),'npm start does not stage and atomically publish before replacing the managed server');
|
||||
console.log('LinkField v48.0 startup version and retry regression test passed');
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const {root,starterPuzzle,read,functionSource,loadBendPuzzle}=require('./helpers
|
|||
const app=read('app.js'),catalog=require('../store-catalog.json');
|
||||
const bindBoard=functionSource('bindBoard'),claimRequest=functionSource('requestBoardClaim'),removeClaim=functionSource('removeClaim'),claimPresentation=functionSource('applyClaimPresentationToBoard');
|
||||
assert(!bindBoard.includes('pointerover'),'Hover still starts board ownership');
|
||||
assert(bindBoard.includes("const endpointTarget=e.target.closest?.('.endpoint-hit'),gateTarget=e.target.closest?.('.gate-hit')")&&bindBoard.indexOf('!endpointTarget&&!gateTarget')<bindBoard.indexOf('ensureBoardClaimForInput(b)'),'Ownership is requested before a knob/endpoint operation starts');
|
||||
assert(bindBoard.includes("boardTarget=e.target.closest?.('.board-input-surface')")&&bindBoard.includes('!endpointTarget&&!gateTarget&&!boardTarget')&&bindBoard.indexOf('!endpointTarget&&!gateTarget&&!boardTarget')<bindBoard.indexOf('ensureBoardClaimForInput(b)'),'Board cells and existing lines cannot enter the claimed input path');
|
||||
assert(claimPresentation.includes("own?'プレイ中'"),'Own board badge is not labelled プレイ中');
|
||||
assert(!claimRequest.includes('toast(')&&!removeClaim.includes('toast('),'Board ownership still emits bottom notifications');
|
||||
assert(functionSource('applyCloudEnvelope').includes('applyPlayerEconomyEnvelope(result)'),'Clear push response does not update the local gem wallet');
|
||||
|
|
@ -48,7 +48,7 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
|
|||
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:solvedState(puzzle)}]})});
|
||||
assert.equal(result.response.status,200,JSON.stringify(result.body));assert.equal(result.body.clearEvents.length,1,'Clear was not authoritatively accepted');assert(result.body.player.earnedScore>0,'Clear response did not include earned gems');assert.equal(result.body.player.availableScore,result.body.player.earnedScore);
|
||||
const reward=result.body.player.earnedScore,clearRevision=result.body.revision;
|
||||
const polling=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(presenceId)}&afterSequence=${connected.body.sequence||0}`,{headers:auth(alice)});assert.equal(polling.response.status,200);assert(polling.body.messages.some(message=>message.type==='claim-release'&&message.boardId==='B0'),'Clear did not release the プレイ中 claim');
|
||||
const polling=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(presenceId)}&afterSequence=${connected.body.sequence||0}`,{headers:auth(alice)});assert.equal(polling.response.status,200);assert(polling.body.messages.some(message=>message.type==='claim-release'&&message.boardId==='B0'),'Clear did not release the プレイ中 claim');assert(polling.body.messages.some(message=>message.type==='world-revision'&&message.revision===clearRevision&&message.boardIds.includes('B0')),'Clear did not announce its world revision in realtime');
|
||||
let pull=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pull.body.page.states.B0.solved,true,'Other player did not receive the clear');
|
||||
const playerState=await request('/api/player/state',{headers:auth(alice)});assert.equal(playerState.body.player.earnedScore,reward,'Gem wallet did not persist the clear reward');
|
||||
const stale=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:clearRevision,global:{nextId:1},metas:[],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false}}]})});assert.equal(stale.response.status,200,JSON.stringify(stale.body));
|
||||
|
|
|
|||
26
test/v4801-shared-sync-recovery-smoke-test.js
Normal file
26
test/v4801-shared-sync-recovery-smoke-test.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const {functionSource,read}=require('./helpers/app-source');
|
||||
|
||||
const bindBoard=functionSource('bindBoard'),takeBatch=functionSource('takeCloudPendingBatch'),pushPending=functionSource('pushCloudPending'),claim=functionSource('requestBoardClaim'),handleRealtime=functionSource('handleRealtimeMessage'),scheduleRemote=functionSource('scheduleRemoteWorldPull');
|
||||
assert(read('index.html').includes('app.js?v=48.0.3'),'The gameplay hotfix is not cache-busted for already-open v48.0 browsers');
|
||||
assert(bindBoard.includes("boardTarget=e.target.closest?.('.board-input-surface')")&&bindBoard.includes('!endpointTarget&&!gateTarget&&!boardTarget'),'Existing path cells do not reach the line pickup logic');
|
||||
assert(claim.includes('allowLocalSolved=false')&&claim.includes('!allowLocalSolved&&metaState(boardId)?.solved'),'A locally solved board cannot be reclaimed after a rejected clear push');
|
||||
assert(pushPending.includes("error.status===423")&&pushPending.includes("requestBoardClaim(boardId,{allowLocalSolved:true})"),'A claim-rejected cloud save does not reclaim and retry its board');
|
||||
assert(handleRealtime.includes("message.type==='world-revision'")&&handleRealtime.includes('scheduleRemoteWorldPull'),'Realtime world changes are ignored by the browser');
|
||||
assert(scheduleRemote.includes('pullCloudWorld()')&&scheduleRemote.includes('scheduleRemoteWorldPull(remoteWorldRevision,500)'),'A realtime pull lost during an interaction is not retried');
|
||||
assert(functionSource('pullCloudWorld').includes('reopenMissingGateExpansions()')&&functionSource('pullCloudWorld').includes('scheduleExpansionRepair(150)'),'A reconciled generated-board conflict remains falsely marked expanded until reload');
|
||||
assert(functionSource('scheduleRealtimeHttpPoll').includes('PHP_REALTIME_POLL_INTERVAL')&&functionSource('queueRealtimeCursor').includes('PHP_REALTIME_CURSOR_INTERVAL'),'PHP fallback cursor latency remains on the multi-second cadence');
|
||||
assert(functionSource('recoverLostBoardPointerCapture').includes('realtimeHeldPointers.has(pointerId)')&&bindBoard.includes('recoverLostBoardPointerCapture(b,e)'),'A transient pointer-capture loss still terminates an actively held drag');
|
||||
assert(functionSource('preserveActiveDrawingClaim').includes("requestBoardClaim(boardId,{force:true})")&&!functionSource('removeClaim').includes('cancelPointerGestures'),'A transient claim update still forcibly stops an active drag');
|
||||
assert(functionSource('applyRealtimeWorldDelta').includes('mergeSnapshotIntoData')&&handleRealtime.includes('applyRealtimeWorldDelta(message)'),'Realtime map changes still require an additional cloud pull before rendering');
|
||||
assert(functionSource('scheduleCloudPush').includes('delay=120'),'Completed progress waits too long before publication');
|
||||
for(let level=1;level<=3;level++)assert.deepEqual(require('../app-logic').sectionCountRange(level),{min:1,max:1},`Level ${level} can still generate merged sections`);
|
||||
assert(functionSource('placeChildAtFrontierAttempt').includes("level<=3&&shape.length!==1"),'A low intrinsic difficulty can bypass the single-square generation rule');
|
||||
|
||||
const emptyCloudPending=()=>({metaIds:new Set(),stateIds:new Set(),deleted:new Set(),globalChanged:false});
|
||||
const take=Function('emptyCloudPending',`return (${takeBatch})`)(emptyCloudPending),source={metaIds:new Set(['B1','B2']),stateIds:new Set(['B1','B2']),deleted:new Set(['B9']),globalChanged:true};
|
||||
const {batch,remainder}=take(source,16,'B2');
|
||||
assert.deepEqual([...batch.metaIds],['B2']);assert.deepEqual([...batch.stateIds],['B2']);assert.deepEqual([...remainder.metaIds],['B1']);assert.deepEqual([...remainder.stateIds],['B1']);assert.equal(batch.globalChanged,true);
|
||||
assert(functionSource('checkSolvedAndExpand').includes('pushCloudPending(b.id)'),'Clear confirmation can publish an unrelated older board instead of the solved board');
|
||||
console.log('LinkField v48.03 drag recovery and low-latency map synchronization smoke test passed');
|
||||
33
test/windows-host-hardening-smoke-test.js
Normal file
33
test/windows-host-hardening-smoke-test.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {spawnSync}=require('child_process');
|
||||
|
||||
if(process.platform!=='win32'){
|
||||
console.log('Windows host-hardening smoke test skipped on this platform');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'linkfield-host-hardening-'));
|
||||
try{
|
||||
const apacheRoot=path.join(root,'Apache24'),apacheConfig=path.join(apacheRoot,'conf','httpd.conf'),phpIni=path.join(root,'php.ini');
|
||||
fs.mkdirSync(path.dirname(apacheConfig),{recursive:true});
|
||||
fs.writeFileSync(apacheConfig,'Listen 80\nServerSignature On\n');
|
||||
fs.writeFileSync(phpIni,'expose_php = On\n');
|
||||
const script=path.join(__dirname,'..','scripts','harden-windows-host.ps1');
|
||||
const args=['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',script,'-ApacheConfig',apacheConfig,'-PhpIni',phpIni,'-Apply','-SkipApacheSyntaxCheck'];
|
||||
const first=spawnSync('powershell.exe',args,{encoding:'utf8'});assert.equal(first.status,0,first.stderr||first.stdout);
|
||||
const apache=fs.readFileSync(apacheConfig,'utf8'),php=fs.readFileSync(phpIni,'utf8');
|
||||
assert.match(apache,/BEGIN LINKFIELD HOST HARDENING[\s\S]*ServerTokens Prod[\s\S]*ServerSignature Off/);
|
||||
assert.match(php,/BEGIN LINKFIELD HOST HARDENING[\s\S]*expose_php = Off/);
|
||||
assert.equal((apache.match(/BEGIN LINKFIELD HOST HARDENING/g)||[]).length,1);
|
||||
assert.equal((php.match(/BEGIN LINKFIELD HOST HARDENING/g)||[]).length,1);
|
||||
assert(fs.readdirSync(path.dirname(apacheConfig)).some(name=>name.startsWith('httpd.conf.linkfield-backup-')));
|
||||
assert(fs.readdirSync(root).some(name=>name.startsWith('php.ini.linkfield-backup-')));
|
||||
const second=spawnSync('powershell.exe',args,{encoding:'utf8'});assert.equal(second.status,0,second.stderr||second.stdout);
|
||||
assert.equal((fs.readFileSync(apacheConfig,'utf8').match(/BEGIN LINKFIELD HOST HARDENING/g)||[]).length,1);
|
||||
assert.equal((fs.readFileSync(phpIni,'utf8').match(/BEGIN LINKFIELD HOST HARDENING/g)||[]).length,1);
|
||||
}finally{fs.rmSync(root,{recursive:true,force:true})}
|
||||
console.log('Windows Apache/PHP host hardening smoke test passed');
|
||||
Loading…
Add table
Add a link
Reference in a new issue