fix: finalize acceptance identity and raw QA review evidence
Some checks are pending
Docker Build and Publish / build (push) Waiting to run

This commit is contained in:
hellcat 2026-10-17 15:32:03 +08:00
parent 5c9157f5d7
commit e26b5bc6ca
7 changed files with 110 additions and 17 deletions

View File

@ -18,13 +18,13 @@
先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。 先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。
生产前先提交并推送源码,再生成、推送和部署独立运维Git版本:保留原安装脚本,加入`panel-acceptance.php`及其`operations.php`,还有stage/promote/rollback/readiness/verify-node入口;面板、网关和目标节点的对应运维目录必须为计划中完整`provision_commit`且无工作区差异。配置文件和凭据不入Git。独立快照由正式`bundle-ops.py`生成,保持基础运维树、工作目录和真实Git索引不变,仅复制10个明确的已提交运维文件,拒绝覆盖已有分支或bundle,不联网: 生产前先提交并推送源码,再生成、推送和部署独立运维Git版本:保留原安装脚本,加入`panel-acceptance.php`及其`operations.php`,还有stage/promote/rollback/readiness/verify-node入口;面板、网关和目标节点的对应运维目录必须为计划中完整`provision_commit`且无工作区差异。配置文件和凭据不入Git。独立快照由正式`bundle-ops.py`生成,保持基础运维树、工作目录和真实Git索引不变,仅复制10个明确的已提交运维文件,默认拒绝已有分支或bundle,不联网:
```sh ```sh
python3 scripts/node-onboarding/bundle-ops.py --source FULL_SOURCE_COMMIT --base FULL_OPS_BASE_COMMIT --branch codex/node-tools-BATCH --bundle /private/task/provision.bundle python3 scripts/node-onboarding/bundle-ops.py --source FULL_SOURCE_COMMIT --base FULL_OPS_BASE_COMMIT --branch codex/node-tools-BATCH --bundle /private/task/provision.bundle
``` ```
随后按已授权任务推送返回的运维分支、审核commit/bundle摘要,再经白色机部署到明确版本。当前源码/运维快照的提交推送与远端部署分别报告;本轮不自动切换任何生产应用或旧任务的运维目录。 同一运维分支的后续修订只有显式`--advance`才允许,且`--base`必须精确等于当前分支头;新提交以该base为父并通过旧值比较更新,仅快进,不强推或覆盖并行修改。bundle仍要求新路径。随后按已授权任务推送返回的运维分支、审核commit/bundle摘要,再经白色机部署到明确版本。当前源码/运维快照的提交推送与远端部署分别报告;本轮不自动切换任何生产应用或旧任务的运维目录。
适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局证书钩子或目标证书的额外pre/post钩子均停止。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。 适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局证书钩子或目标证书的额外pre/post钩子均停止。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。
@ -92,9 +92,9 @@ SSH会话仅在本次进程、同一工作线程内复用,线程不共用可
## 动态验收和测试用户 ## 动态验收和测试用户
准备阶段先按正式组、banned、额度和有效期选既有账号,不把管理员身份自动排除;检查真实`ServerService::getAvailableServers`及`getAvailableUsers`,包括插件过滤。没有可用账号时自动创建本任务测试用户:非管理员、100MiB、1小时、1设备、无订单/套餐改写及通知消息。私有journal在DB提交前记录身份,丢失回包时恢复同一用户,不重复创建或接管同名用户。 准备阶段先按正式组、banned、额度和有效期选既有账号,优先管理员且最多检查20个候选,避免全表巡检客户凭据;检查真实`ServerService::getAvailableServers`及`getAvailableUsers`,包括插件过滤;候选被实际节点下发过滤时不算可用。未找到可用账号时自动创建本任务测试用户:非管理员、100MiB、1小时、1设备、无订单/套餐改写及通知消息。私有journal在DB提交前记录身份,丢失回包时恢复同一用户,不重复创建或接管同名用户。
新节点仍沿用各自隔离QA→严格TLS/出口/上报→独立助手按精确报告审核→append开放并禁用QA。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验,并逐条实际联网。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。旧节点同样必须覆盖。 新节点仍沿用各自隔离QA→严格TLS/出口/上报→独立助手按精确报告审核→append开放并禁用QA。适配器直接生成`key-evidence.json`,包含原始报告字节SHA256、请求摘要、QA身份、报告时间及最新在线/双向上报状态,600原子保存,不再需要任务专用采集脚本。助手完成独立审核后,主协调者在同目录记录`key-reviewed.json`:`request_sha256`、`report_sha256`、`qa_user_id`、`verified_at`必须与该报告完全相同;未审核不得自动填批准文件。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验,并逐条实际联网。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。旧节点同样必须覆盖。
每组正式客户端串行运行,保留完整订阅DNS/路由,只给诊断目的地固定出站。新建测试用户可能等待节点拉取用户列表;只对通用客户端运行失败最多做3轮诊断读测试,间隔15秒且共用900秒预算。结构、TLS或出口断言错误不重试;不重放生产写入、不另建用户来绕过失败。证据绑定完整计划摘要、节点集合摘要、原始报告及QA拒绝文件SHA256、QA实际ID、403、banned、实际下发排除及900秒时效。任一缺项或摘要/集合变化失败。旧节点关键配置、全部套餐、原用户关联和订阅地址按批次原始基线核对,网关容器ID/启动时间/重启数再次核对。 每组正式客户端串行运行,保留完整订阅DNS/路由,只给诊断目的地固定出站。新建测试用户可能等待节点拉取用户列表;只对通用客户端运行失败最多做3轮诊断读测试,间隔15秒且共用900秒预算。结构、TLS或出口断言错误不重试;不重放生产写入、不另建用户来绕过失败。证据绑定完整计划摘要、节点集合摘要、原始报告及QA拒绝文件SHA256、QA实际ID、403、banned、实际下发排除及900秒时效。任一缺项或摘要/集合变化失败。旧节点关键配置、全部套餐、原用户关联和订阅地址按批次原始基线核对,网关容器ID/启动时间/重启数再次核对。
@ -109,3 +109,11 @@ preflight/publish/audit始终现场执行。只有先前状态为done、收到
## 验证范围 ## 验证范围
新增Python测试覆盖动态1/2/7/23节点、集合变化/漏旧节点、出口/TLS/QA身份/摘要/时效、白色机跳转及严格密钥策略、线程会话复用/断线、依赖续跑及未知/过期状态、原服务精确调查回执、远端脚本与PHP构造。PHP隔离测试使用真实Eloquent/SQLite和ServerService,覆盖自动受限用户、既有管理员复用、真实下发、无效账号、journal恢复、关闭后新租约、归属漂移、插件排除及失败收尾;不加载.env/应用provider/Redis/HTTP。生产停机、失败回退、远端真实会话复用性能及十节点耗时尚未演练,不承诺固定提速倍数。 新增Python测试覆盖动态1/2/7/23节点、集合变化/漏旧节点、出口/TLS/QA身份/摘要/时效、白色机跳转及严格密钥策略、线程会话复用/断线、依赖续跑及未知/过期状态、原服务精确调查回执、远端脚本与PHP构造。PHP隔离测试使用真实Eloquent/SQLite和ServerService,覆盖自动受限用户、既有管理员复用、真实下发、无效账号、journal恢复、关闭后新租约、归属漂移、插件排除及失败收尾;不加载.env/应用provider/Redis/HTTP。生产停机、失败回退、远端真实会话复用性能及十节点耗时尚未演练,不承诺固定提速倍数。
隔离回归入口(不使用项目默认PHPUnit配置):
```sh
python3 -B -m unittest discover -s tests/node-onboarding -p 'test_*.py' -q
php vendor/bin/phpunit --no-configuration tests/node-onboarding/PanelChangeIsolatedTest.php
php vendor/bin/phpunit --no-configuration tests/node-onboarding/AcceptanceUserIsolatedTest.php
```

View File

@ -8,7 +8,7 @@
续跑保留全批前检及publish/audit现场关口,以请求/代码/当前依赖摘要和有效期复用已完成步骤,前驱变化向下游失效;事件输出替代重复状态轮询。原服务以unit/程序/配置/监听和进程信息区分实际冲突与待调查重启,原基线不覆盖。独立运维打包工具从已提交源码生成可追踪快照,保留工作区/真实索引及旧运维基础树;旧命令适配器及旧冻结任务文件不修改。 续跑保留全批前检及publish/audit现场关口,以请求/代码/当前依赖摘要和有效期复用已完成步骤,前驱变化向下游失效;事件输出替代重复状态轮询。原服务以unit/程序/配置/监听和进程信息区分实际冲突与待调查重启,原基线不覆盖。独立运维打包工具从已提交源码生成可追踪快照,保留工作区/真实索引及旧运维基础树;旧命令适配器及旧冻结任务文件不修改。
本轮Python 30项测试通过;PHP新增9项/35断言及既有9项/65断言隔离测试通过,均未加载真实.env或联网。CoDocs全库离线检查通过,前次.obsidian权限问题已由用户处理,本轮未读写其配置正文。新版本尚未部署至远端或进行真实SSH/客户端性能演练,不能把上一轮三节点验收复用为新实现线上通过,也不承诺十节点耗时。准备/部署/执行与边界见[正式适配器专题](NODE_BATCH_RUNTIME.md);后续新批次默认按该入口执行。 本轮Python 32项测试通过;PHP新增10项/38断言及既有9项/65断言隔离测试通过,均未加载真实.env或联网。CoDocs全库离线检查通过,前次.obsidian权限问题已由用户处理,本轮未读写其配置正文。新版本尚未部署至远端或进行真实SSH/客户端性能演练,不能把上一轮三节点验收复用为新实现线上通过,也不承诺十节点耗时。准备/部署/执行与边界见[正式适配器专题](NODE_BATCH_RUNTIME.md);后续新批次默认按该入口执行。实现源码已提交推送,独立运维分支`codex/node-runtime-20261017`已准备并经Git bundle校验;精确版本按该分支Git记录读取,仍未切换远端旧任务的运行目录。
## 2026-10-17 三节点批量接入完成 ## 2026-10-17 三节点批量接入完成

View File

@ -13,7 +13,7 @@ FILES=('stage.php','promote.php','operations.php','rollback.php','readiness.php'
'verify-node.py','panel-acceptance.php','install-node.sh','renew-node.sh','dpl-node.service') 'verify-node.py','panel-acceptance.php','install-node.sh','renew-node.sh','dpl-node.service')
def build(repo,source,base,branch,bundle): def build(repo,source,base,branch,bundle,advance=False):
if not all(re.fullmatch('[0-9a-f]{40}',r) for r in (source,base)) or not re.fullmatch('codex/[A-Za-z0-9_/-]+',branch) or '..' in branch: if not all(re.fullmatch('[0-9a-f]{40}',r) for r in (source,base)) or not re.fullmatch('codex/[A-Za-z0-9_/-]+',branch) or '..' in branch:
raise ValueError('Explicit full commits and a new codex branch required') raise ValueError('Explicit full commits and a new codex branch required')
destination=Path(bundle) destination=Path(bundle)
@ -24,7 +24,11 @@ def build(repo,source,base,branch,bundle):
return r.stdout return r.stdout
git('cat-file','-e',source+'^{commit}');git('cat-file','-e',base+'^{commit}');git('check-ref-format','refs/heads/'+branch) git('cat-file','-e',source+'^{commit}');git('cat-file','-e',base+'^{commit}');git('check-ref-format','refs/heads/'+branch)
exists=subprocess.run(['git','-C',str(repo),'show-ref','--verify','--quiet','refs/heads/'+branch]).returncode exists=subprocess.run(['git','-C',str(repo),'show-ref','--verify','--quiet','refs/heads/'+branch]).returncode
if exists!=1:raise ValueError('Branch exists or outcome unknown; inspect it, do not overwrite') expected_old='0'*40
if exists==0 and advance:
expected_old=git('rev-parse','refs/heads/'+branch).decode().strip()
if expected_old!=base:raise ValueError('Advance requires the exact current branch head as reviewed base')
elif exists!=1:raise ValueError('Branch exists or outcome unknown; inspect it, do not overwrite')
with tempfile.TemporaryDirectory(prefix='dpl-ops-index-',dir=destination.parent) as temp: with tempfile.TemporaryDirectory(prefix='dpl-ops-index-',dir=destination.parent) as temp:
env=dict(os.environ,GIT_INDEX_FILE=str(Path(temp)/'index')) env=dict(os.environ,GIT_INDEX_FILE=str(Path(temp)/'index'))
git('read-tree',base,env=env) git('read-tree',base,env=env)
@ -36,7 +40,7 @@ def build(repo,source,base,branch,bundle):
git('update-index','--add','--cacheinfo',mode+','+sha+','+name,env=env) git('update-index','--add','--cacheinfo',mode+','+sha+','+name,env=env)
tree=git('write-tree',env=env).decode().strip() tree=git('write-tree',env=env).decode().strip()
commit=git('commit-tree',tree,'-p',base,input=('DPL operations snapshot\n\nSource commit: '+source+'\n').encode()).decode().strip() commit=git('commit-tree',tree,'-p',base,input=('DPL operations snapshot\n\nSource commit: '+source+'\n').encode()).decode().strip()
git('update-ref','refs/heads/'+branch,commit,'0'*40) git('update-ref','refs/heads/'+branch,commit,expected_old)
git('bundle','create',str(destination),'refs/heads/'+branch) git('bundle','create',str(destination),'refs/heads/'+branch)
destination.chmod(0o600) destination.chmod(0o600)
return {'commit':commit,'source_commit':source,'branch':branch,'bundle_sha256':hashlib.sha256(destination.read_bytes()).hexdigest(),'files':len(FILES),'network':False} return {'commit':commit,'source_commit':source,'branch':branch,'bundle_sha256':hashlib.sha256(destination.read_bytes()).hexdigest(),'files':len(FILES),'network':False}
@ -46,9 +50,10 @@ def main():
p=argparse.ArgumentParser(description=__doc__) p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--repo',default=str(Path(__file__).resolve().parents[2])) p.add_argument('--repo',default=str(Path(__file__).resolve().parents[2]))
for k in ('source','base','branch','bundle'):p.add_argument('--'+k,required=True) for k in ('source','base','branch','bundle'):p.add_argument('--'+k,required=True)
p.add_argument('--advance',action='store_true',help='Explicit fast-forward only from the exact --base current branch head')
a=p.parse_args() a=p.parse_args()
try: try:
print(json.dumps(build(a.repo,a.source,a.base,a.branch,a.bundle)));return 0 print(json.dumps(build(a.repo,a.source,a.base,a.branch,a.bundle,a.advance)));return 0
except Exception: except Exception:
print(json.dumps({'state':'blocked','reason':'Inspect source/base, branch and bundle outcome before retry; no network performed'}));return 2 print(json.dumps({'state':'blocked','reason':'Inspect source/base, branch and bundle outcome before retry; no network performed'}));return 2

View File

@ -33,7 +33,13 @@ final class Acceptance {
$j['id']=$u->id;$j['state']='active';if($apply)writeJson($path,$j); $j['id']=$u->id;$j['state']='active';if($apply)writeJson($path,$j);
return ['state'=>'ready','id'=>$u->id,'owned'=>$j['owned']]; return ['state'=>'ready','id'=>$u->id,'owned'=>$j['owned']];
} }
$u=User::where('group_id',$group)->where('banned',0)->whereRaw('u+d<transfer_enable')->where(fn($q)=>$q->whereNull('expired_at')->orWhere('expired_at','>=',time()))->where('email','not like','node-qa-%')->where('email','not like','batch-acceptance-%')->orderBy('id')->first(); $u=null;
$candidates=User::where('group_id',$group)->where('banned',0)->whereRaw('u+d<transfer_enable')->where(fn($q)=>$q->whereNull('expired_at')->orWhere('expired_at','>=',time()))->where('email','not like','node-qa-%')->where('email','not like','batch-acceptance-%')->orderByDesc('is_admin')->orderBy('id')->limit(20)->get();
foreach($candidates as $candidate){
$effective=true;
foreach(ServerService::getAvailableServers($candidate) as $s)if(!ServerService::getAvailableUsers(Server::findOrFail($s['id']))->contains('id',$candidate->id)){$effective=false;break;}
if($effective){$u=$candidate;break;}
}
if(!$apply)return ['state'=>$u?'existing':'would_create']; if(!$apply)return ['state'=>$u?'existing':'would_create'];
$j=['group'=>$group,'state'=>'prepared','owned'=>!$u,'email'=>$u?->email??'batch-acceptance-'.substr(hash('sha256',$dir),0,12).'-'.Str::lower(Str::random(8)).'@example.invalid','uuid'=>$u?->uuid??(string)Str::uuid(),'token'=>$u?->token??Str::random(32),'quota'=>104857600,'expires_at'=>time()+3600]; $j=['group'=>$group,'state'=>'prepared','owned'=>!$u,'email'=>$u?->email??'batch-acceptance-'.substr(hash('sha256',$dir),0,12).'-'.Str::lower(Str::random(8)).'@example.invalid','uuid'=>$u?->uuid??(string)Str::uuid(),'token'=>$u?->token??Str::random(32),'quota'=>104857600,'expires_at'=>time()+3600];
// Persist identity before commit. A lost response resumes the same row, never another user. // Persist identity before commit. A lost response resumes the same row, never another user.

View File

@ -220,6 +220,15 @@ for attempt in range(3 if source['owned'] else 1):
# Persist only nonsecret, byte-bound results for coordinator/operator review. # Persist only nonsecret, byte-bound results for coordinator/operator review.
self.atomic_json(self.directory/('formal-group-'+str(group)+'.json'),{'snapshot':before,'report':json.loads(report),'qas':json.loads(qas),'binding':binding}) self.atomic_json(self.directory/('formal-group-'+str(group)+'.json'),{'snapshot':before,'report':json.loads(report),'qas':json.loads(qas),'binding':binding})
def qa_evidence(self,n,raw,state):
# Bind the exact remote bytes, not a re-serialized nested report. Persist the latest
# online/traffic state so independent review never needs a separate task collector.
report=json.loads(raw)
self.atomic_json(self.directory/(n['key']+'-evidence.json'),{
'key':n['key'],'request_sha256':n['request_sha256'],
'report_sha256':hashlib.sha256(raw).hexdigest(),'report':report,
'panel':state,'collected_at':int(time.time())})
@staticmethod @staticmethod
def php_value(value): def php_value(value):
return "json_decode(base64_decode('"+base64.b64encode(json.dumps(value).encode()).decode()+"'),true)" return "json_decode(base64_decode('"+base64.b64encode(json.dumps(value).encode()).decode()+"'),true)"
@ -376,20 +385,20 @@ for attempt in range(3 if source['owned'] else 1):
existing=read(panel,B+'/qa-report.json') existing=read(panel,B+'/qa-report.json')
if existing: if existing:
report=json.loads(existing) report=json.loads(existing)
if report.get('state')=='live_verified' and report.get('qa_user_id')==s['manifest']['test_user_id'] and 0<=time.time()-report.get('verified_at',0)<=900 and s['node_online'] and s['u']+s['d']>0: if report.get('state')=='live_verified' and report.get('qa_user_id')==s['manifest']['test_user_id'] and 0<=time.time()-report.get('verified_at',0)<=900 and s['node_online'] and s['u']>0 and s['d']>0:
self.qa_evidence(n,existing,s)
r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r
transfer(panel,gateway,node);fetch_qa(gateway) transfer(panel,gateway,node);fetch_qa(gateway)
execute(gateway,'python3 '+OPS+'/verify-node.py --qa '+Q+'/qa.json --subscription '+Q+'/subscription.json --binary '+ctx['client_binary']+' --port '+str(n['socks_port'])+' --expected-exit '+shlex.quote(n['expected_exit'])+' --expected-country '+n['country']+' --report '+Q+'/qa-report.json --run > '+Q+'/verify.stdout 2> '+Q+'/verify.stderr',300) execute(gateway,'python3 '+OPS+'/verify-node.py --qa '+Q+'/qa.json --subscription '+Q+'/subscription.json --binary '+ctx['client_binary']+' --port '+str(n['socks_port'])+' --expected-exit '+shlex.quote(n['expected_exit'])+' --expected-country '+n['country']+' --report '+Q+'/qa-report.json --run > '+Q+'/verify.stdout 2> '+Q+'/verify.stderr',300)
data=read(gateway,Q+'/qa-report.json');report=json.loads(data) data=read(gateway,Q+'/qa-report.json');report=json.loads(data)
write(panel,B+'/qa-report.json',data) write(panel,B+'/qa-report.json',data)
# Reports contain endpoints and measured results, no passwords or tokens.
evidence={'key':key,'request_sha256':sha,'report':report,'panel':state(panel)}
(Path(a.state_dir)/(key+'-evidence.json')).write_text(json.dumps(evidence,ensure_ascii=False,indent=2))
for _ in range(24): for _ in range(24):
s=state(panel) s=state(panel)
if s['node_online'] and s['u']+s['d']>0: if s['node_online'] and s['u']>0 and s['d']>0:
self.qa_evidence(n,data,s)
r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r
time.sleep(5) time.sleep(5)
self.qa_evidence(n,data,s)
return receipt('waiting') return receipt('waiting')
if a.phase=='publish': if a.phase=='publish':
s=state(panel) s=state(panel)
@ -404,7 +413,7 @@ for attempt in range(3 if source['owned'] else 1):
if not journal:return receipt('conflict') if not journal:return receipt('conflict')
j=json.loads(journal) j=json.loads(journal)
return receipt('ready' if j.get('manifest')==s['manifest'] and j.get('request',{}).get('mode')=='append' and j.get('request',{}).get('group')==n['formal_group_id'] and j.get('state')=='applied' else 'conflict') return receipt('ready' if j.get('manifest')==s['manifest'] and j.get('request',{}).get('mode')=='append' and j.get('request',{}).get('group')==n['formal_group_id'] and j.get('state')=='applied' else 'conflict')
if not s['node_online'] or s['u']+s['d']<=0:return receipt('waiting') if not s['node_online'] or s['u']<=0 or s['d']<=0:return receipt('waiting')
if a.action=='probe':return receipt('absent',True) if a.action=='probe':return receipt('absent',True)
cmd='cd '+APP+' && php '+OPS+'/promote.php --backup='+B+' --mode=append --group='+str(n['formal_group_id'])+' --verification='+B+'/qa-report.json' cmd='cd '+APP+' && php '+OPS+'/promote.php --backup='+B+' --mode=append --group='+str(n['formal_group_id'])+' --verification='+B+'/qa-report.json'
execute(panel,cmd);execute(panel,cmd+' --apply');return receipt('ready') execute(panel,cmd);execute(panel,cmd+' --apply');return receipt('ready')

View File

@ -35,7 +35,7 @@ final class AcceptanceUserIsolatedTest extends TestCase {
Acceptance::cleanup($this->dir,true);self::assertTrue($u->fresh()->banned);self::assertTrue(App\Services\ServerService::getAvailableUsers(Server::first())->isEmpty());self::assertArrayNotHasKey('token',Acceptance::source($this->dir,1,true));$this->reject(fn()=>Acceptance::source($this->dir,1)); Acceptance::cleanup($this->dir,true);self::assertTrue($u->fresh()->banned);self::assertTrue(App\Services\ServerService::getAvailableUsers(Server::first())->isEmpty());self::assertArrayNotHasKey('token',Acceptance::source($this->dir,1,true));$this->reject(fn()=>Acceptance::source($this->dir,1));
} }
public function testExistingEligibleAdminIsUsedWithoutPrivilegeOrPasswordChange():void { public function testExistingEligibleAdminIsUsedWithoutPrivilegeOrPasswordChange():void {
$u=$this->user(['is_admin'=>1]);$before=$u->fresh()->toArray();$r=Acceptance::prepare($this->dir,1,true);self::assertFalse($r['owned']);self::assertSame($u->id,$r['id']);Acceptance::source($this->dir,1);Acceptance::cleanup($this->dir,true);self::assertSame($before,$u->fresh()->toArray()); $u=$this->user(['is_admin'=>1]);$before=(array)Capsule::table('v2_user')->where('id',$u->id)->first();$r=Acceptance::prepare($this->dir,1,true);self::assertFalse($r['owned']);self::assertSame($u->id,$r['id']);Acceptance::source($this->dir,1);Acceptance::cleanup($this->dir,true);self::assertSame($before,(array)Capsule::table('v2_user')->where('id',$u->id)->first());
} }
public function testExpiredExhaustedBannedWrongGroupAreNotReused():void { public function testExpiredExhaustedBannedWrongGroupAreNotReused():void {
foreach([['expired_at'=>time()-1],['u'=>1000000],['banned'=>1],['group_id'=>2]] as $i=>$change)$this->user($change+['email'=>'existing'.$i.'@example.invalid']); foreach([['expired_at'=>time()-1],['u'=>1000000],['banned'=>1],['group_id'=>2]] as $i=>$change)$this->user($change+['email'=>'existing'.$i.'@example.invalid']);
@ -53,6 +53,11 @@ final class AcceptanceUserIsolatedTest extends TestCase {
public function testCleanupRefusesDriftAndNeverBansSomeoneElsesAccount():void { public function testCleanupRefusesDriftAndNeverBansSomeoneElsesAccount():void {
$id=Acceptance::prepare($this->dir,1,true)['id'];$u=User::find($id);$u->is_admin=true;$u->saveQuietly();$this->reject(fn()=>Acceptance::cleanup($this->dir,true));self::assertFalse($u->fresh()->banned); $id=Acceptance::prepare($this->dir,1,true)['id'];$u=User::find($id);$u->is_admin=true;$u->saveQuietly();$this->reject(fn()=>Acceptance::cleanup($this->dir,true));self::assertFalse($u->fresh()->banned);
} }
public function testNoEffectivelyEligibleExistingUserCreatesBoundedTestAccount():void {
$old=$this->user(['is_admin'=>1]);$id=$old->id;
App\Services\Plugin\HookManager::registerFilter('server.users.get',fn($users)=>$users->where('id','!=',$id)->values());
$r=Acceptance::prepare($this->dir,1,true);self::assertTrue($r['owned']);self::assertNotSame($id,$r['id']);self::assertFalse(User::find($r['id'])->is_admin);Acceptance::source($this->dir,1);Acceptance::cleanup($this->dir,true);
}
public function testEffectivePluginExclusionBlocksAcceptanceSource():void { public function testEffectivePluginExclusionBlocksAcceptanceSource():void {
Acceptance::prepare($this->dir,1,true); Acceptance::prepare($this->dir,1,true);
App\Services\Plugin\HookManager::registerFilter('server.users.get',fn($users)=>collect()); App\Services\Plugin\HookManager::registerFilter('server.users.get',fn($users)=>collect());

View File

@ -154,6 +154,63 @@ class ResumeTests(unittest.TestCase):
class SiteAdapterTests(unittest.TestCase): class SiteAdapterTests(unittest.TestCase):
def test_qa_export_binds_raw_bytes_and_latest_traffic_for_independent_review(self):
from site_adapter import SiteAdapter
with tempfile.TemporaryDirectory() as d:
adapter=object.__new__(SiteAdapter);adapter.directory=Path(d);adapter.atomic_json=batch.atomic_json
raw=b'{ "state": "live_verified", "qa_user_id": 9 }'
latest=dict(node_online=True,u=12,d=34,manifest={'test_user_id':9})
adapter.qa_evidence(dict(key='new',request_sha256='request'),raw,latest)
path=Path(d)/'new-evidence.json';value=json.loads(path.read_text())
self.assertEqual(value['report_sha256'],hashlib.sha256(raw).hexdigest())
self.assertEqual(value['panel'],latest);self.assertEqual(path.stat().st_mode & 0o077,0)
def test_new_live_qa_exports_new_raw_report_after_transfer_not_prior_report(self):
import io
from site_adapter import SiteAdapter
with tempfile.TemporaryDirectory() as d:
adapter=object.__new__(SiteAdapter);adapter.directory=Path(d);adapter.atomic_json=batch.atomic_json
ctx=dict(panel_vps='panel',gateway_vps='gateway',node_root='/node',ops_root='/ops',app_root='/app',node_backup_root='/backups',qa_root='/qa',client_binary='/client',subscription_url='https://sub.example.invalid')
adapter.plan={'context':ctx};adapter.ctx=ctx
raw=json.dumps(dict(state='live_verified',qa_user_id=9,verified_at=int(time.time()),results=[dict(tag='new',live=True,strict_tls=True,exit_ip='192.0.2.11',country='JP')])).encode()
class Channel:
def shutdown_write(self):pass
def recv_exit_status(self):return 0
class In:
channel=Channel()
def write(self,data):pass
class Out(io.BytesIO):channel=Channel()
class Writable(io.BytesIO):
def __init__(self,files,path):super().__init__();self.files,self.path=files,path
def close(self):self.files[self.path]=self.getvalue();super().close()
class Sftp:
def __init__(self,files):self.files=files
def __enter__(self):return self
def __exit__(self,*args):pass
def open(self,path,mode):
if mode=='w':return Writable(self.files,path)
if path not in self.files:raise FileNotFoundError(path)
return io.BytesIO(self.files[path])
def chmod(self,*args):pass
def posix_rename(self,old,new):self.files[new]=self.files.pop(old)
state=dict(manifest={'test_user_id':9},node_online=True,groups=['2'],qa_banned=False,u=12,d=34)
class Remote:
def __init__(self,kind):self.kind=kind;self.files={}
def open_sftp(self):return Sftp(self.files)
def exec_command(self,cmd,timeout=0):
if cmd.endswith(' && php'):return In(),Out(json.dumps(state).encode()),Out()
if 'verify-node.py' in cmd:self.files['/qa/new/qa-report.json']=raw
return In(),Out(b'{}'),Out()
remotes={key:Remote(key) for key in ['panel','gateway','node']}
remotes['panel'].files.update({'/backups/new/node-config.yml':b'fixture-config','/backups/new/node-secret.env':b'fixture-env','/backups/new/qa-private.json':b'{}'})
class Pool:
def get(self,key):return remotes[key]
adapter.pool=Pool()
node=dict(key='new',request_sha256='request',formal_group_id=1,vps_id='node',socks_port=19011,expected_exit='192.0.2.11',country='JP')
result=adapter.call(node,'verify','probe');self.assertEqual(result['status'],'ready')
exported=json.loads((Path(d)/'new-evidence.json').read_text());self.assertEqual(exported['report_sha256'],hashlib.sha256(raw).hexdigest());self.assertEqual(exported['panel']['d'],34)
self.assertEqual(remotes['panel'].files['/backups/new/qa-report.json'],raw)
def test_preserved_service_review_binds_original_and_exact_observation(self): def test_preserved_service_review_binds_original_and_exact_observation(self):
from site_adapter import SiteAdapter from site_adapter import SiteAdapter
adapter=object.__new__(SiteAdapter);adapter.ctx={'preserved_service':dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443)} adapter=object.__new__(SiteAdapter);adapter.ctx={'preserved_service':dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443)}
@ -297,6 +354,9 @@ class PackagingTests(unittest.TestCase):
self.assertEqual(git('rev-parse','HEAD').decode().strip(),source) self.assertEqual(git('rev-parse','HEAD').decode().strip(),source)
git('bundle','verify',str(output)) git('bundle','verify',str(output))
with self.assertRaises(ValueError):module.build(repo,source,base,'codex/offline-ops',root/'second.bundle') with self.assertRaises(ValueError):module.build(repo,source,base,'codex/offline-ops',root/'second.bundle')
advanced=module.build(repo,source,result['commit'],'codex/offline-ops',root/'advanced.bundle',advance=True)
self.assertEqual(git('rev-parse',advanced['commit']+'^').decode().strip(),result['commit'])
with self.assertRaises(ValueError):module.build(repo,source,base,'codex/offline-ops',root/'stale.bundle',advance=True)
def test_event_stream_preserves_final_result_and_avoids_status_polling(self): def test_event_stream_preserves_final_result_and_avoids_status_polling(self):
import subprocess import subprocess
spec=importlib.util.spec_from_file_location('event_fixture',Path(__file__).with_name('test_batch.py'));f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f) spec=importlib.util.spec_from_file_location('event_fixture',Path(__file__).with_name('test_batch.py'));f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f)