161 lines
12 KiB
JavaScript
161 lines
12 KiB
JavaScript
import { execFileSync } from 'node:child_process';
|
|
import { randomBytes, randomInt, randomUUID } from 'node:crypto';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { resolve } from 'node:path';
|
|
const suffix = randomUUID().slice(0, 8),
|
|
name = `hop-check-${suffix}`,
|
|
volume = `hop-check-db-${suffix}`,
|
|
uploads = `hop-check-files-${suffix}`;
|
|
const port = String(randomInt(20000, 60000)); // Isolated container only; no host port is published.
|
|
const bindUploads = process.env.HOP_CHECK_BIND_UPLOADS === '1';
|
|
const folder = bindUploads ? mkdtempSync(resolve(tmpdir(), 'hop-bind-files-')) : null;
|
|
const runtimeUser = bindUploads ? '12001:12001' : '1000:1000';
|
|
const docker = (...args) =>
|
|
execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
const node = (code) => docker('exec', name, 'node', '--input-type=module', '-e', code);
|
|
try {
|
|
docker('volume', 'create', volume);
|
|
if (bindUploads) {
|
|
docker(
|
|
'run',
|
|
'--rm',
|
|
'--network',
|
|
'none',
|
|
'--user',
|
|
'0:0',
|
|
'-v',
|
|
`${volume}:/database`,
|
|
'--mount',
|
|
`type=bind,source=${folder},target=/uploads`,
|
|
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
|
|
'sh',
|
|
'-eu',
|
|
'-c',
|
|
`chown 12001:12001 /database /uploads; chmod 700 /database /uploads`,
|
|
);
|
|
} else docker('volume', 'create', uploads);
|
|
docker(
|
|
'run',
|
|
'-d',
|
|
'--name',
|
|
name,
|
|
'--network',
|
|
'none',
|
|
'--user',
|
|
runtimeUser,
|
|
'-e',
|
|
'NODE_ENV=production',
|
|
'-e',
|
|
`PORT=${port}`,
|
|
'-e',
|
|
'APP_URL=https://hop.test',
|
|
'-e',
|
|
'PAIR_REQUESTS_PER_MINUTE=4',
|
|
'-e',
|
|
`SESSION_SECRET=${randomBytes(32).toString('hex')}`,
|
|
'-e',
|
|
`DISCOVERY_SECRET=${randomBytes(32).toString('hex')}`,
|
|
...(bindUploads
|
|
? ['--mount', `type=volume,source=${volume},target=/data/database,volume-nocopy`]
|
|
: ['-v', `${volume}:/data/database`]),
|
|
...(bindUploads
|
|
? ['--mount', `type=bind,source=${folder},target=/data/uploads`]
|
|
: ['-v', `${uploads}:/data/uploads`]),
|
|
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
|
|
);
|
|
const wait = () =>
|
|
node(
|
|
`for(let i=0;i<60;i++){try{const r=await fetch('http://127.0.0.1:${port}/api/health');if(r.ok){console.log('ready');break}}catch{}if(i===59)throw Error('not ready');await new Promise(r=>setTimeout(r,250))}`,
|
|
);
|
|
wait();
|
|
const state = JSON.parse(
|
|
node(
|
|
`const base='http://127.0.0.1:${port}';const r=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test','Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error('session failed');const session=await r.json(),cookie=r.headers.get('set-cookie').split(';')[0];if(!r.headers.get('set-cookie').includes('Secure'))throw Error('insecure cookie');const renamed=await fetch(base+'/api/device/me',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({name:'Docker 저장 검증'})});if(!renamed.ok)throw Error('rename failed');console.log(JSON.stringify({cookie,id:session.device.id}));`,
|
|
),
|
|
);
|
|
const fileState = JSON.parse(
|
|
node(
|
|
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
|
|
),
|
|
);
|
|
docker('restart', name);
|
|
wait();
|
|
const result = JSON.parse(
|
|
node(
|
|
`const r=await fetch('http://127.0.0.1:${port}/api/device/me',{headers:{Cookie:${JSON.stringify(state.cookie)}}});console.log(JSON.stringify(await r.json()));`,
|
|
),
|
|
);
|
|
assert.equal(result.device.name, 'Docker 저장 검증');
|
|
assert.equal(result.device.id, state.id);
|
|
const preservedText = JSON.parse(
|
|
node(
|
|
`const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('text list failed');const item=(await r.json()).items.find(i=>i.accessId===${JSON.stringify(fileState.text.accessId)});if(!item)throw Error('text not preserved');console.log(JSON.stringify(item.text));`,
|
|
),
|
|
);
|
|
assert.equal(preservedText, fileState.text.value);
|
|
|
|
const restored = node(
|
|
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('download failed');console.log(await r.text());`,
|
|
);
|
|
assert.equal(restored, 'Docker file persistence');
|
|
const resumed = node(
|
|
`import {createHash} from 'node:crypto';const state=${JSON.stringify(fileState.pending)},base='http://127.0.0.1:${port}',path='/api/uploads/resumable/'+state.uploadId;const headers={Origin:'https://hop.test',Cookie:state.cookie,'X-CSRF-Token':state.csrf};const r=await fetch(base+path,{headers});const progress=await r.json();if(!r.ok||progress.offset!==8*1024*1024)throw Error('partial offset not preserved');const last=await fetch(base+path,{method:'PUT',headers:{...headers,'Content-Type':'application/octet-stream','Upload-Offset':String(progress.offset)},body:Buffer.alloc(7,120)});if(!last.ok)throw Error(await last.text());const complete=await fetch(base+path+'/complete',{method:'POST',headers:{...headers,'Content-Type':'application/json'},body:'{}'});if(!complete.ok)throw Error(await complete.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});const item=(await list.json()).items.find(i=>i.file?.name==='resume.bin');const downloaded=await fetch(base+'/api/access/'+item.accessId+'/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});const content=Buffer.from(await downloaded.arrayBuffer());if(!downloaded.ok||content.length!==8*1024*1024+7||!content.equals(Buffer.alloc(content.length,120)))throw Error('resumed content mismatch');console.log('resumed');`,
|
|
);
|
|
assert.equal(resumed, 'resumed');
|
|
const integrity = node(
|
|
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');console.log(db.pragma('integrity_check',{simple:true}));db.close();`,
|
|
);
|
|
assert.equal(integrity, 'ok');
|
|
if (bindUploads) {
|
|
assert.equal(docker('inspect', '--format', '{{.Config.User}}', name), runtimeUser);
|
|
const mode = JSON.parse(
|
|
node(
|
|
`import {statSync} from 'node:fs';const s=statSync('/data/uploads/originals');console.log(JSON.stringify({uid:s.uid,gid:s.gid,mode:s.mode&511}));`,
|
|
),
|
|
);
|
|
assert.deepEqual(mode, { uid: 12001, gid: 12001, mode: 0o700 });
|
|
const originals = resolve(folder, 'originals');
|
|
const stored = readdirSync(originals).find(
|
|
(key) => readFileSync(resolve(originals, key)).toString() === 'Docker file persistence',
|
|
);
|
|
assert(stored, 'uploaded file is visible in the host project directory');
|
|
unlinkSync(resolve(originals, stored));
|
|
const missing = JSON.parse(
|
|
node(
|
|
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify({status:r.status,body:await r.json()}));`,
|
|
),
|
|
);
|
|
assert.equal(missing.status, 404);
|
|
assert.equal(missing.body.code, 'FILE_NOT_FOUND');
|
|
const visible = JSON.parse(
|
|
node(
|
|
`const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify((await r.json()).items));`,
|
|
),
|
|
);
|
|
assert.equal(
|
|
visible.some((item) => item.accessId === fileState.accessId),
|
|
false,
|
|
);
|
|
assert(visible.some((item) => item.file?.name === 'resume.bin'));
|
|
}
|
|
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
|
|
console.log(
|
|
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}`,
|
|
);
|
|
} catch (error) {
|
|
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));
|
|
console.error(docker('inspect', '--format', '{{json .State}}', name));
|
|
throw error;
|
|
} finally {
|
|
try {
|
|
docker('rm', '-f', name);
|
|
} catch {}
|
|
for (const v of [volume, ...(bindUploads ? [] : [uploads])])
|
|
try {
|
|
docker('volume', 'rm', v);
|
|
} catch {}
|
|
if (folder) rmSync(folder, { recursive: true, force: true });
|
|
}
|