2026-10-26 11:56:38 +00:00
'use strict' ;
// [Ariweb Suite 변경] Ariweb Suite 연동 회귀 테스트 — lib/ariweb(티켓·서비스 서명·연결 등록부), store.rev, 서버 단위(SSO 진입·내부 API·동시 편집 충돌·발행 안내·폐기 영향).
const test = require ( 'node:test' ); const assert = require ( 'node:assert/strict' );
const fs = require ( 'fs' ); const path = require ( 'path' ); const os = require ( 'os' ); const http = require ( 'http' ); const crypto = require ( 'crypto' ); const { spawn } = require ( 'child_process' );
const ROOT = path . join ( __dirname , '..' ); const PORT = 4100 + Math . floor ( Math . random () * 90 );
const tmp = fs . mkdtempSync ( path . join ( os . tmpdir (), 'wm-ariweb-' )); process . env . DATA_ROOT = tmp ; process . env . DATA_DIR = path . join ( tmp , 'sites' ); process . env . SITES_ROOT = path . join ( tmp , 'www' );
fs . mkdirSync ( path . join ( tmp , 'sites' ), { recursive : true }); fs . mkdirSync ( path . join ( tmp , 'www' ), { recursive : true });
const SECRET = 'test-shared-secret-' + crypto . randomBytes ( 8 ). toString ( 'hex' );
const B = `http://127.0.0.1: ${ PORT } ` ;
2026-10-28 15:07:35 +00:00
const SSHWRAP = path . join ( tmp , 'sshwrap' ); const SSHLOG = path . join ( tmp , 'ssh-calls.log' );
2026-10-26 11:56:38 +00:00
const sha256 = ( s ) => crypto . createHash ( 'sha256' ). update ( String ( s || '' )). digest ( 'hex' );
const sign = ( key , d ) => crypto . createHmac ( 'sha256' , key ). update ( d ). digest ( 'base64url' );
/** 포털이 하는 서비스 서명 호출 */
const svc = async ( method , p , body ) => { const text = body === undefined ? undefined : JSON . stringify ( body ); const ts = String ( Date . now ()); const r = await fetch ( B + p , { method , headers : { 'Content-Type' : 'application/json' , 'X-Service-Key-Id' : 'portal' , 'X-Service-Ts' : ts , 'X-Service-Sign' : sign ( SECRET , ` ${ method } | ${ p } | ${ ts } | ${ sha256 ( text ) } ` ) }, body : text }); const ct = r . headers . get ( 'content-type' ) || '' ; return { status : r . status , b : ct . includes ( 'json' ) ? await r . json () : null , r }; };
const ticket = ( data ) => { const payload = Buffer . from ( JSON . stringify ({ v : 1 , exp : Date . now () + 60000 , nonce : crypto . randomBytes ( 8 ). toString ( 'hex' ), ... data })). toString ( 'base64url' ); return ` ${ payload } . ${ sign ( SECRET , payload ) } ` ; };
let child ; let portal ; let portalImpact = { impact : { blocked : false , liveSite : true , lifecycle : 'live' , deployments : 2 , domains : [ 'example.test' ], message : '운영 사이트가 연결되어 있습니다' } }; let portalCalls = [];
test ( 'lib/ariweb: 티켓 검증(서명·만료·1회용)과 서비스 서명 검증' , () => {
const a = require ( ROOT + '/lib/ariweb' );
const t = a . makeTicket ({ login : 'aw-g1-u1' , name : 'U' , groupId : 'g1' }, { key : SECRET }); const ok = a . verifyTicket ( t , { key : SECRET }); assert . equal ( ok . login , 'aw-g1-u1' );
assert . match ( a . verifyTicket ( t , { key : SECRET }). error , /이미 사용/ ); assert . match ( a . verifyTicket ( t + 'x' , { key : SECRET }). error , /서명/ );
assert . match ( a . verifyTicket ( a . makeTicket ({ login : 'x' , exp : Date . now () - 1 }, { key : SECRET }), { key : SECRET }). error , /만료/ );
assert . match ( a . verifyTicket ( a . makeTicket ({ login : 'x' }, { key : 'other' }), { key : SECRET }). error , /서명/ );
assert . match ( a . verifyTicket ( t , { key : '' }). error , /설정되지/ );
const req = ( h ) => ({ method : 'POST' , headers : h }); const ts = String ( Date . now ()); const body = '{"a":1}' ;
assert . equal ( a . verifyService ( req ({ 'x-service-key-id' : 'portal' , 'x-service-ts' : ts , 'x-service-sign' : sign ( SECRET , `POST|/api/internal/ariweb/health| ${ ts } | ${ sha256 ( body ) } ` ) }), '/api/internal/ariweb/health' , body , { key : SECRET }). ok , true );
assert . equal ( a . verifyService ( req ({ 'x-service-key-id' : 'portal' , 'x-service-ts' : ts , 'x-service-sign' : 'bad' }), '/x' , body , { key : SECRET }). ok , false );
assert . equal ( a . verifyService ( req ({ 'x-service-key-id' : 'portal' , 'x-service-ts' : String ( Date . now () - 10 * 60000 ), 'x-service-sign' : 'x' }), '/x' , body , { key : SECRET }). reason , 'clock_skew' );
assert . equal ( a . linkOf ( 'p1' ), null ); a . link ( 'p1' , { siteId : 's1' , groupId : 'g1' , portalUrl : 'https://my.example.test' }); assert . equal ( a . linkOf ( 'p1' ). siteId , 's1' ); assert . equal ( a . unlink ( 'p1' ), true ); assert . equal ( a . linkOf ( 'p1' ), null );
});
test ( 'store.save: rev 가 저장마다 1 오른다' , () => {
const store = require ( ROOT + '/lib/store' ); const id = crypto . randomBytes ( 16 ). toString ( 'hex' ); const s = store . create ({ id , title : 't' }); assert . equal ( s . rev , 1 ); store . save ( s ); assert . equal ( store . load ( id ). rev , 2 );
});
test . before ( async () => {
// 가짜 포털(프로젝트 삭제 영향 응답) — Webmaker 가 서명한 요청만 받는다
portal = http . createServer (( req , res ) => { let d = '' ; req . on ( 'data' , ( c ) => { d += c ; }); req . on ( 'end' , () => { portalCalls . push ({ url : req . url , keyId : req . headers [ 'x-service-key-id' ], hasSign : !! req . headers [ 'x-service-sign' ], body : d }); res . writeHead ( 200 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ( portalImpact )); }); });
await new Promise (( r ) => portal . listen ( 0 , '127.0.0.1' , r ));
// 보관소에 공유 비밀·설정에 포털 주소를 넣고 서버를 띄운다
const vault = require ( ROOT + '/lib/vault' ); vault . set ({ kind : 'ariweb' , id : 'ariweb' , label : 'test' , url : '' , secret : SECRET , settings : {} });
const config = require ( ROOT + '/lib/config' ); config . set ({ 'ariweb.portalUrl' : `http://127.0.0.1: ${ portal . address (). port } ` }, 'test' );
2026-10-28 15:07:35 +00:00
// 가짜 배포 대상 + 가짜 ssh(PATH 앞): 폐기의 배포 서버 정리를 결정적으로 늦추거나(1초) 실패시킨다(SSH_FAIL). 호출마다 로그를 남겨 "정리가 실제로 수행됐는지" 를 본다
fs . mkdirSync ( SSHWRAP , { recursive : true });
fs . writeFileSync ( path . join ( SSHWRAP , 'ssh' ), `#!/bin/sh\necho call >> " ${ SSHLOG } "\nsleep 1\nif [ -e " ${ tmp } /SSH_FAIL" ]; then echo "ssh: connect to host fake.test: Connection refused (test)" >&2; exit 255; fi\necho REMOVED\n` , { mode : 0o755 });
fs . writeFileSync ( path . join ( tmp , 'deploy-targets.json' ), JSON . stringify ({ defaultId : 't1' , targets : [{ id : 't1' , name : '가짜 배포 서버' , type : 'ssh' , baseDir : '/srv/sites' , publicBaseUrl : '' , note : '' , manageVhost : false , host : 'fake.test' , port : 22 , user : 'root' , createdAt : new Date (). toISOString () }] }));
child = spawn ( 'node' , [ path . join ( ROOT , 'server.js' )], { env : { ... process . env , PORT : String ( PORT ), HOST : '127.0.0.1' , DATA_ROOT : tmp , DATA_DIR : path . join ( tmp , 'sites' ), SITES_ROOT : path . join ( tmp , 'www' ), PUBLIC_BASE_URL : B , SOURCE_PROVIDER : 'none' , PATH : ` ${ SSHWRAP } : ${ process . env . PATH } ` } });
2026-10-26 11:56:38 +00:00
let out = '' ; child . stdout . on ( 'data' , ( c ) => { out += c ; }); child . stderr . on ( 'data' , ( c ) => { out += c ; });
for ( let i = 0 ; i < 150 ; i ++ ) { if ( /listening on/ . test ( out )) break ; await new Promise (( r ) => setTimeout ( r , 100 )); }
assert . match ( out , /listening on/ );
});
2026-10-28 15:07:35 +00:00
test . after (() => { try { child . kill ( 'SIGTERM' ); } catch ( _ ) {} try { portal . close (); } catch ( _ ) {} try { fs . chmodSync ( tmp , 0o755 ); } catch ( _ ) {} });
2026-10-26 11:56:38 +00:00
let projectId ; let cookie ;
test ( '내부 API: 서명 없는 호출 거부 · 프로젝트 만들기·연결·조회·소스·export' , async () => {
assert . equal (( await fetch ( B + '/api/internal/ariweb/health' )). status , 401 );
assert . equal (( await svc ( 'GET' , '/api/internal/ariweb/health' )). b . ok , true );
const c = await svc ( 'POST' , '/api/internal/ariweb/projects' , { owner : 'aw-g1-u1' , ownerName : '테스트' , title : '포털 사이트' , mode : 'manual' , link : { siteId : 's_1' , groupId : 'g_1' , siteName : '우리 사이트' , groupName : '우리 그룹' , portalUrl : 'http://127.0.0.1:' + portal . address (). port } });
if ( c . status !== 200 ) console . log ( 'DEBUG create' , c . status , JSON . stringify ( c . b ), await c . r . text (). catch (() => '' )); assert . equal ( c . status , 200 , JSON . stringify ( c . b )); projectId = c . b . project . id ; assert . equal ( c . b . project . link . siteId , 's_1' ); assert . equal ( c . b . project . hasContent , false );
fs . writeFileSync ( path . join ( tmp , 'www' , projectId , 'index.html' ), '<h1>hello</h1>' );
const g = await svc ( 'GET' , `/api/internal/ariweb/projects/ ${ projectId } ` ); assert . equal ( g . b . project . hasContent , true ); assert . ok ( Array . isArray ( g . b . project . versionList )); assert . equal ( g . b . project . git . provider , 'none' );
const l = await svc ( 'GET' , '/api/internal/ariweb/projects?owner=aw-g1-u1' ); assert . equal ( l . b . projects . length , 1 );
const src = await svc ( 'GET' , `/api/internal/ariweb/projects/ ${ projectId } /source` ); assert . equal ( src . b . ok , true ); assert . ok ( src . b . source . siteBytes > 0 ); assert . equal ( src . b . source . lfsSupported , false );
const ex = await svc ( 'GET' , `/api/internal/ariweb/projects/ ${ projectId } /export` ); assert . equal ( ex . status , 200 ); assert . equal ( ex . r . headers . get ( 'x-ariweb-version' ), 'current' ); const buf = Buffer . from ( await ex . r . arrayBuffer ()); assert . ok ( buf . length > 20 && buf [ 0 ] === 0x1f && buf [ 1 ] === 0x8b , 'gzip' );
assert . equal (( await svc ( 'GET' , `/api/internal/ariweb/projects/ ${ projectId } /export?version=99` )). status , 404 );
assert . equal (( await svc ( 'GET' , '/api/internal/ariweb/credits/aw-g1-u1' )). b . credit . balance , 0 );
});
test ( 'SSO: 티켓으로 세션 발급·프로젝트로 이동·배너 컨텍스트, 재사용·위조 거부' , async () => {
const t = ticket ({ login : 'aw-g1-u1' , name : '테스트' , groupId : 'g_1' , groupName : '우리 그룹' , siteId : 's_1' , siteName : '우리 사이트' , projectId });
const r = await fetch ( B + '/api/auth/sso?ticket=' + encodeURIComponent ( t ), { redirect : 'manual' }); assert . equal ( r . status , 302 ); assert . equal ( r . headers . get ( 'location' ), `/?site= ${ projectId } ` );
const sc = r . headers . getSetCookie (); assert . ok ( sc . some (( c ) => c . startsWith ( 'wh_session=' )) && sc . some (( c ) => c . startsWith ( 'wm_ariweb=' )));
cookie = sc . map (( c ) => c . split ( ';' )[ 0 ]). join ( '; ' );
const meR = await fetch ( B + '/api/auth/me' , { headers : { cookie , referer : B + '/' } }); const me = await meR . json (); if ( ! me . user ) console . log ( 'DEBUG me' , meR . status , JSON . stringify ( me ), cookie ); assert . equal ( me . user . id , 'aw-g1-u1' );
const cfg = await ( await fetch ( B + '/api/config' , { headers : { cookie , referer : B + '/' } })). json (); assert . equal ( cfg . ariweb . groupName , '우리 그룹' ); assert . equal ( cfg . ariweb . siteName , '우리 사이트' );
assert . equal (( await fetch ( B + '/api/auth/sso?ticket=' + encodeURIComponent ( t ), { redirect : 'manual' })). status , 403 , '1회용' );
assert . equal (( await fetch ( B + '/api/auth/sso?ticket=' + encodeURIComponent ( t . slice ( 0 , - 2 ) + 'zz' ), { redirect : 'manual' })). status , 403 , '위조' );
});
test ( '동시 편집 충돌: 옛 rev 로 저장하면 409 + 두 버전 요약, 최신 rev 는 저장' , async () => {
const H = { cookie , 'Content-Type' : 'application/json' , referer : B + '/' };
const s0 = await ( await fetch ( B + `/api/sites/ ${ projectId } ` , { headers : H })). json (); const rev = s0 . rev ; assert . ok ( rev >= 1 ); assert . equal ( s0 . ariweb . siteId , 's_1' );
const ok = await fetch ( B + `/api/sites/ ${ projectId } ` , { method : 'PATCH' , headers : H , body : JSON . stringify ({ title : '바뀐 제목' , rev }) }); assert . equal ( ok . status , 200 );
const stale = await fetch ( B + `/api/sites/ ${ projectId } ` , { method : 'PATCH' , headers : H , body : JSON . stringify ({ title : '다른 창' , rev }) }); assert . equal ( stale . status , 409 ); const j = await stale . json (); assert . equal ( j . conflict . yourRev , rev ); assert . ok ( j . conflict . currentRev > rev );
const stale2 = await fetch ( B + `/api/sites/ ${ projectId } /files/index.html` , { method : 'PUT' , headers : { ... H , 'If-Match' : String ( rev ) }, body : JSON . stringify ({ content : '<h1>x</h1>' }) }); assert . equal ( stale2 . status , 409 );
const noRev = await fetch ( B + `/api/sites/ ${ projectId } ` , { method : 'PATCH' , headers : H , body : JSON . stringify ({ purpose : '옛 화면' }) }); assert . equal ( noRev . status , 200 , 'rev 없는 옛 화면은 예전처럼' );
});
test ( '발행: 포털 연결 프로젝트는 409 + 포털 배포 화면 안내' , async () => {
const r = await fetch ( B + `/api/sites/ ${ projectId } /plan/publish` , { method : 'POST' , headers : { cookie , 'Content-Type' : 'application/json' , referer : B + '/' }, body : '{}' });
assert . equal ( r . status , 409 ); const j = await r . json (); assert . match ( j . error , /포털/ ); assert . match ( j . ariweb . deployUrl , /\/sites\/s_1\/deploy$/ );
});
test ( '폐기: 포털 영향 확인 → 확인 전 409(영향 표시) → 운영 중이면 차단 → 확인 후 폐기(포털 사이트는 건드리지 않음)' , async () => {
const H = { cookie , 'Content-Type' : 'application/json' , referer : B + '/' };
const r1 = await fetch ( B + `/api/sites/ ${ projectId } ` , { method : 'DELETE' , headers : H , body : JSON . stringify ({ confirm : '바뀐 제목' }) }); assert . equal ( r1 . status , 409 ); const j1 = await r1 . json (); assert . equal ( j1 . ariweb . needAcknowledge , true ); assert . equal ( j1 . ariweb . impact . deployments , 2 );
assert . equal ( portalCalls [ 0 ]. keyId , 'webmaker' ); assert . ok ( portalCalls [ 0 ]. hasSign ); assert . equal ( JSON . parse ( portalCalls [ 0 ]. body ). siteId , 's_1' );
portalImpact = { impact : { blocked : true , liveSite : true , lifecycle : 'live' , deployments : 2 , domains : [], message : '운영 중' } };
const r2 = await fetch ( B + `/api/sites/ ${ projectId } ` , { method : 'DELETE' , headers : H , body : JSON . stringify ({ confirm : '바뀐 제목' , acknowledgeImpact : true }) }); assert . equal ( r2 . status , 409 );
portalImpact = { impact : { blocked : false , liveSite : false , lifecycle : 'draft' , deployments : 0 , domains : [], message : '초안' } };
const r3 = await fetch ( B + `/api/sites/ ${ projectId } ` , { method : 'DELETE' , headers : H , body : JSON . stringify ({ confirm : '바뀐 제목' , acknowledgeImpact : true }) }); assert . equal ( r3 . status , 200 , await r3 . text ());
assert . equal ( require ( ROOT + '/lib/ariweb' ). linkOf ( projectId ), null );
});
2026-10-28 15:07:35 +00:00
/** 포털에 연결된 프로젝트를 만들고 index.html 과 "배포됨" 기록(가짜 배포 대상 t1)을 둔다 → 폐기가 배포 서버 정리(가짜 ssh)를 거친다 */
async function mkLinked ( title , siteId ) {
const c = await svc ( 'POST' , '/api/internal/ariweb/projects' , { owner : 'aw-g1-u1' , ownerName : '테스트' , title , mode : 'manual' , link : { siteId , groupId : 'g_1' , siteName : '우리 사이트' , groupName : '우리 그룹' , portalUrl : 'http://127.0.0.1:' + portal . address (). port } });
assert . equal ( c . status , 200 , JSON . stringify ( c . b )); const id = c . b . project . id ; const dir = path . join ( tmp , 'www' , id ); fs . mkdirSync ( dir , { recursive : true }); fs . writeFileSync ( path . join ( dir , 'index.html' ), `<h1> ${ title } </h1>` );
const f = path . join ( tmp , 'sites' , ` ${ id } .json` ); const rec = JSON . parse ( fs . readFileSync ( f , 'utf8' )); rec . deployments = [{ at : new Date (). toISOString (), targetId : 't1' , targetName : '가짜 배포 서버' , ok : true , ready : true , phase : 'ready' , url : 'http://fake.test/x/' , domains : [], domainUrls : [] }]; fs . writeFileSync ( f , JSON . stringify ( rec , null , 2 ));
return { id , dir , record : f };
}
const sshCalls = () => { try { return fs . readFileSync ( SSHLOG , 'utf8' ). split ( '\n' ). filter ( Boolean ). length ; } catch ( _ ) { return 0 ; } };
const del = ( id , title ) => fetch ( B + `/api/sites/ ${ id } ` , { method : 'DELETE' , headers : { cookie , 'Content-Type' : 'application/json' , referer : B + '/' }, body : JSON . stringify ({ confirm : title , acknowledgeImpact : true }) });
test ( '폐기 보상 1 — 배포 서버 정리가 실패하면 포털 연결은 풀리지 않는다(연결 해제는 정리 성공 뒤·되돌릴 수 없는 삭제 직전에만): 사이트 파일·기록·연결이 모두 그대로이고 deleteFailed 가 남는다' , async () => {
portalImpact = { impact : { blocked : false , liveSite : false , lifecycle : 'draft' , deployments : 0 , domains : [], message : '초안' } };
const P = await mkLinked ( '보상-정리실패' , 's_c1' ); const a = require ( ROOT + '/lib/ariweb' ); assert . ok ( a . linkOf ( P . id ), '연결됨' ); const before = fs . readFileSync ( P . record , 'utf8' ); const n0 = sshCalls ();
fs . writeFileSync ( path . join ( tmp , 'SSH_FAIL' ), '' );
const r = await del ( P . id , '보상-정리실패' ); const j = await r . json (). catch (() => ({})); fs . unlinkSync ( path . join ( tmp , 'SSH_FAIL' ));
assert . ok ( r . status >= 400 , '정리 실패는 오류: ' + r . status + ' ' + JSON . stringify ( j )); assert . match ( j . error || '' , /배포 서버/ ); assert . ok ( sshCalls () > n0 , '정리를 실제로 시도했다' );
assert . ok ( a . linkOf ( P . id ), '포털 연결이 풀리지 않았다(연결 해제는 정리 성공 뒤에만): ' + JSON . stringify ( a . linkOf ( P . id ))); assert . equal ( a . linkOf ( P . id ). siteId , 's_c1' );
assert . ok ( fs . existsSync ( path . join ( P . dir , 'index.html' )), '사이트 파일 그대로' ); const after = JSON . parse ( fs . readFileSync ( P . record , 'utf8' )); assert . ok ( after . deleteFailed && after . deleteFailed . detail , '실패 기록' ); assert . equal ( after . title , JSON . parse ( before ). title );
const ok = await del ( P . id , '보상-정리실패' ); assert . equal ( ok . status , 200 , '정리가 되면 폐기 성공: ' + await ok . text ()); assert . equal ( a . linkOf ( P . id ), null ); assert . ok ( ! fs . existsSync ( P . dir ));
});
test ( '폐기 보상 2 — 정리는 성공했는데 포털 연결 해제(연결 등록부 쓰기)가 실패하면 되돌릴 수 없는 삭제로 가지 않는다: 사이트 파일·기록이 그대로(임시 이름으로 옮긴 것도 되돌림)이고 연결도 그대로, 명시적 실패' , async () => {
portalImpact = { impact : { blocked : false , liveSite : false , lifecycle : 'draft' , deployments : 0 , domains : [], message : '초안' } };
const P = await mkLinked ( '보상-연결해제실패' , 's_c2' ); const a = require ( ROOT + '/lib/ariweb' ); const n0 = sshCalls ();
fs . chmodSync ( tmp , 0o555 ); // DATA_ROOT 루트를 읽기 전용으로 → 연결 등록부(ariweb-links.json)의 원자적 쓰기(임시 파일)가 실패한다. sites/·www/ 는 하위 폴더라 그대로 쓸 수 있다
let r , j ; try { r = await del ( P . id , '보상-연결해제실패' ); j = await r . json (). catch (() => ({})); } finally { fs . chmodSync ( tmp , 0o755 ); }
assert . ok ( r . status >= 400 , '연결 해제 실패는 오류: ' + r . status + ' ' + JSON . stringify ( j )); assert . ok ( sshCalls () > n0 , '정리는 먼저 수행됐다' );
assert . ok ( a . linkOf ( P . id ) && a . linkOf ( P . id ). siteId === 's_c2' , '연결 그대로' ); assert . ok ( fs . existsSync ( path . join ( P . dir , 'index.html' )), '사이트 파일 그대로(임시 이름 아님)' ); assert . ok ( fs . existsSync ( P . record ), '기록 그대로' );
assert . ok ( ! fs . readdirSync ( path . join ( tmp , 'www' )). some (( d ) => d . startsWith ( '.deleting-' )), '임시로 옮긴 폴더가 남지 않는다' );
const g = await fetch ( B + `/api/sites/ ${ P . id } ` , { headers : { cookie , referer : B + '/' } }); assert . equal ( g . status , 200 , '사이트는 그대로 쓸 수 있다' ); const rec = JSON . parse ( fs . readFileSync ( P . record , 'utf8' )); assert . ok ( rec . deleteFailed , '실패 기록' );
const ok = await del ( P . id , '보상-연결해제실패' ); assert . equal ( ok . status , 200 , '쓸 수 있게 되면 폐기 성공: ' + await ok . text ()); assert . equal ( a . linkOf ( P . id ), null );
});
test ( '폐기 보상 3 — 연결 해제 뒤 되돌릴 수 없는 단계는 기록 삭제 한 번이고, 그 뒤 뒷정리(사이트 폴더 지우기)가 실패해도 성공으로 꾸미거나 삼키지 않는다: 사이트·기록·연결은 모두 없고 남은 폴더는 warnings·감사 기록으로 운영자에게 보인다; 보상(되살리기)은 readback 으로 확인된다' , async () => {
portalImpact = { impact : { blocked : false , liveSite : false , lifecycle : 'draft' , deployments : 0 , domains : [], message : '초안' } };
const a = require ( ROOT + '/lib/ariweb' );
// 보상 함수 자체: 되살리기 뒤 readback 이 원래 연결과 같다(시각 보존)
const l0 = { siteId : 's_x' , groupId : 'g_x' , portalUrl : 'http://p' , siteName : 'n' , groupName : 'g' , linkedAt : '2026-01-01T00:00:00.000Z' }; a . link ( 'p_comp' , l0 ); a . unlink ( 'p_comp' ); assert . equal ( a . linkOf ( 'p_comp' ), null ); a . link ( 'p_comp' , l0 ); assert . deepEqual ( a . linkOf ( 'p_comp' ), l0 , 'readback' ); a . unlink ( 'p_comp' );
const P = await mkLinked ( '보상-뒷정리실패' , 's_c3' );
const locked = path . join ( P . dir , 'locked' ); fs . mkdirSync ( locked ); fs . writeFileSync ( path . join ( locked , 'keep.txt' ), 'x' ); fs . chmodSync ( locked , 0o555 ); // 안의 파일을 지울 수 없는 폴더 → 옮겨 둔 폴더의 뒷정리(rmSync)가 실패한다
let r , j , trash ; try { r = await del ( P . id , '보상-뒷정리실패' ); j = await r . json (). catch (() => ({})); trash = fs . readdirSync ( path . join ( tmp , 'www' )). filter (( d ) => d . startsWith ( '.deleting-' )); } finally { try { fs . chmodSync ( locked , 0o755 ); } catch ( _ ) {} for ( const d of trash || []) { try { fs . chmodSync ( path . join ( tmp , 'www' , d , 'locked' ), 0o755 ); } catch ( _ ) {} } }
assert . equal ( r . status , 200 , '연결 해제·기록 삭제까지 끝났으면 폐기는 성공: ' + JSON . stringify ( j )); assert . equal ( j . deleted , P . id ); assert . ok ( Array . isArray ( j . warnings ) && j . warnings . some (( w ) => / 폴더 정리 실패 / . test ( w )), '남은 폴더는 경고로 보인다: ' + JSON . stringify ( j . warnings ));
assert . equal ( a . linkOf ( P . id ), null , '연결 없음' ); assert . ok ( ! fs . existsSync ( P . record ), '기록 없음' ); assert . ok ( ! fs . existsSync ( P . dir ), '원래 이름의 사이트 폴더 없음' );
assert . equal ( trash . length , 1 , '옮겨 둔 폴더가 운영자 확인용으로 남아 있다: ' + JSON . stringify ( trash )); assert . ok ( fs . existsSync ( path . join ( tmp , 'www' , trash [ 0 ], 'locked' , 'keep.txt' )));
const g = await fetch ( B + `/api/sites/ ${ P . id } ` , { headers : { cookie , referer : B + '/' } }); assert . equal ( g . status , 404 );
fs . rmSync ( path . join ( tmp , 'www' , trash [ 0 ]), { recursive : true , force : true });
});