81 lines
11 KiB
JavaScript
81 lines
11 KiB
JavaScript
// Run only in an explicitly selected isolated database or verified production container.
|
|
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto'),assert=require('node:assert/strict')
|
|
const engine=require('./salary-grade-configuration.cjs')
|
|
const target=process.env.SALARY_GRADE_TARGET||'local',mode=process.argv[2]||'audit'
|
|
if(target==='local')require('@next/env').loadEnvConfig(process.cwd())
|
|
const {PrismaClient}=require('@prisma/client'),db=new PrismaClient({log:[]})
|
|
const root=path.resolve(process.env.SALARY_GRADE_RECEIPT_DIR||'.private/grade-release'),manifestPath=root+'/manifest.json',baselinePath=root+'/baseline.json'
|
|
const write=(file,value)=>{fs.writeFileSync(file+'.next',JSON.stringify(value,null,2),{mode:0o600});fs.renameSync(file+'.next',file)}
|
|
const read=file=>JSON.parse(fs.readFileSync(file))
|
|
const hash=value=>crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
let admin
|
|
function headers(user=admin,stale=false){
|
|
const key=crypto.hkdfSync('sha256',process.env.NEXTAUTH_SECRET,Buffer.alloc(0),'NextAuth.js Generated Encryption Key',32),head=Buffer.from(JSON.stringify({alg:'dir',enc:'A256GCM'})).toString('base64url'),iv=crypto.randomBytes(12),cipher=crypto.createCipheriv('aes-256-gcm',key,iv),now=Math.floor(Date.now()/1000)
|
|
cipher.setAAD(Buffer.from(head));const bytes=Buffer.concat([cipher.update(JSON.stringify({id:user.id,sub:user.id,role:'SYSTEM_ADMIN',canAccessBrandCenter:true,isSuperAdmin:stale||user.isSuperAdmin,iat:now,exp:now+900,jti:crypto.randomUUID()})),cipher.final()])
|
|
return {cookie:(target==='local'?'next-auth.session-token=':'__Secure-next-auth.session-token=')+[head,'',iv.toString('base64url'),bytes.toString('base64url'),cipher.getAuthTag().toString('base64url')].join('.'),host:target==='local'?'localhost:4321':'sc.jiansun.vip','x-forwarded-host':target==='local'?'localhost:4321':'sc.jiansun.vip','x-forwarded-proto':target==='local'?'http':'https'}
|
|
}
|
|
const base=target==='local'?'http://localhost:4321':'http://127.0.0.1:3000',endpoint='/api/organization/performance-salary/positions'
|
|
async function http(url,{method='GET',body,user=admin,stale=false,status=200,bytes=false}={}){
|
|
const response=await fetch(base+url,{method,headers:{...(user?headers(user,stale):{}),...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(120000),redirect:'manual'})
|
|
assert.equal(response.status,status,`${method} ${url}: ${response.status}`)
|
|
if(url.startsWith('/api/organization/'))assert.match(response.headers.get('cache-control'),/private, no-store/)
|
|
return bytes?Buffer.from(await response.arrayBuffer()):response.headers.get('content-type')?.includes('json')?response.json():response.text()
|
|
}
|
|
const request=(method,id,body)=>http(endpoint+(id?'/'+id:''),{method,body})
|
|
async function protectedState(){
|
|
const state={}
|
|
for(const table of ['personnel_salary_bases','personnel_salary_base_audits','performance_surveys','performance_survey_responses','performance_survey_assignments','performance_survey_employee_position_assignments']){
|
|
const rows=await db.$queryRawUnsafe(`SELECT ${table==='personnel_salary_bases'?"to_jsonb(t) - 'job_grade'":'to_jsonb(t)'} AS row FROM "${table}" t ORDER BY id`)
|
|
state[table]={count:rows.length,hash:hash(rows)}
|
|
}return state
|
|
}
|
|
async function baseline(){
|
|
if(fs.existsSync(baselinePath)||fs.existsSync(manifestPath))throw Error('Existing receipt; do not replace audit baseline')
|
|
const positions=(await request('GET')).positions,manifest=engine.prepare(positions,{environment:target})
|
|
const salaries={};for(const month of ['2026-08','2026-09','2026-10','2026-11'])salaries[month]=await http('/api/organization/performance-salary/details?month='+month)
|
|
const identity=await db.$queryRawUnsafe('SELECT current_database() AS database,current_user AS "user",inet_server_addr()::text AS address,inet_server_port() AS port')
|
|
const configRows=await db.$queryRawUnsafe('SELECT to_jsonb(t) AS row FROM salary_position_configs t ORDER BY id'),audits=await db.salaryPositionConfigAudit.findMany({orderBy:{id:'asc'}})
|
|
const nativeHashes={};for(const file of ['app/api/desktop/[...path]/route.ts','lib/desktop-client/template-packs.ts','lib/desktop-client/template-packs/gj-desk-dnz0385-native-v1.json','lib/desktop-client/template-packs/yjd-desk-dnz0385-native-v1.json'])if(fs.existsSync('/app/'+file))nativeHashes[file]=crypto.createHash('sha256').update(fs.readFileSync('/app/'+file)).digest('hex')
|
|
write(baselinePath,{identity,positions,configRows,audits,salaries,nativeHashes,protected:await protectedState()});write(manifestPath,manifest)
|
|
console.log(JSON.stringify({target,mode,operations:manifest.operations.map(o=>({id:o.before.id,revision:o.before.revision,grade:o.desired.jobGrade})),people:7,privateSnapshot:baselinePath}))
|
|
}
|
|
async function verify(){
|
|
const b=read(baselinePath),m=read(manifestPath),live=(await request('GET')).positions
|
|
assert.deepEqual(await protectedState(),b.protected)
|
|
assert.equal(live.length,b.positions.length)
|
|
for(const op of m.operations){assert.ok(op.receipt&&!op.pending&&!op.restored);const p=live.find(p=>p.id===op.before.id);assert.equal(p.revision,op.receipt.revision);assert.ok(engine.equal(engine.fields(p),op.desired))}
|
|
for(const p of b.positions.filter(p=>!m.operations.some(o=>o.before.id===p.id)))assert.ok(engine.equal(engine.fields(live.find(q=>q.id===p.id)),engine.fields(p)))
|
|
const people=await db.personnelSalaryBase.findMany({where:{deletedAt:null},select:{positionId:true,jobGrade:true}});assert.equal(people.length,7);assert.ok(people.every(p=>p.positionId===m.operations[0].before.id&&p.jobGrade===''))
|
|
const audits=await db.salaryPositionConfigAudit.findMany({orderBy:{id:'asc'}}),oldIds=new Set(b.audits.map(a=>a.id)),added=audits.filter(a=>!oldIds.has(a.id));assert.equal(added.length,3)
|
|
for(const op of m.operations){const a=added.find(a=>a.positionId===op.before.id);assert.ok(a);assert.equal(a.action,'UPDATE');assert.equal(a.actorUserId,admin.id);assert.ok(engine.equal(engine.fields(a.after),op.desired))}
|
|
for(const a of b.audits)assert.deepEqual(JSON.parse(JSON.stringify(audits.find(x=>x.id===a.id))),a)
|
|
const comparisons=[]
|
|
for(const month of Object.keys(b.salaries)){
|
|
const data=await http('/api/organization/performance-salary/details?month='+month),rows=Object.values(data.roles).flat(),before=Object.values(b.salaries[month].roles).flat();assert.equal(rows.length,7);assert.equal(data.sourceMonth,month);assert.equal(data.isFallback,false)
|
|
for(const row of rows){const old=before.find(r=>r.employeeProfileId===row.employeeProfileId);assert.ok(old);for(const key of ['name','nickname','department','brand','baseSalary','gradeSalary','performanceSalary','points'])assert.deepEqual(row.values[key],old.values[key]);assert.equal(row.jobGrade,'');assert.equal(row.performancePositionId,null);assert.equal(row.values.performanceCoefficient,null);assert.equal(row.values.currentTotal,null)}
|
|
comparisons.push({month,people:rows.length,wagesPointsUnchanged:true,unmatched:true})
|
|
}
|
|
for(const [file,digest] of Object.entries(b.nativeHashes))assert.equal(crypto.createHash('sha256').update(fs.readFileSync('/app/'+file)).digest('hex'),digest)
|
|
const ids=['c156778c-307d-42aa-89d3-f785aeb3ef86','463c5a60-c8a8-45dd-b4cd-4a31a6b1b84f','c041d8d8-93db-41f1-984d-5f76f114da48','2b1ccc12-844c-4eff-8006-58607157efc1']
|
|
const readers=await db.user.findMany({where:{id:{in:ids},isActive:true,status:'active',archivedAt:null},select:{id:true,isSuperAdmin:true}});assert.equal(readers.length,4)
|
|
for(const user of readers){assert.equal((await http(endpoint,{user})).canEdit,user.isSuperAdmin);if(!user.isSuperAdmin)await http(endpoint+'/'+m.operations[0].before.id,{method:'PUT',user,body:{...m.operations[0].desired,revision:m.operations[0].receipt.revision},status:403})}
|
|
await http(endpoint,{user:null,status:401});const stranger=await db.user.findFirstOrThrow({where:{id:{notIn:ids},isSuperAdmin:false,isActive:true,status:'active',archivedAt:null},select:{id:true,isSuperAdmin:true}});await http(endpoint,{user:stranger,stale:true,status:403});await http('/api/organization/performance-salary/details?month=2026-10',{user:stranger,stale:true,status:403})
|
|
await http(endpoint+'/'+m.operations[0].before.id,{method:'PUT',body:{...m.operations[0].desired,revision:m.operations[0].before.revision},status:409})
|
|
for(const [name,url] of [['personnel','/api/organization/personnel-salary/export'],['positions',endpoint+'/export']])fs.writeFileSync(root+'/'+name+'.xlsx',await http(url,{bytes:true}),{mode:0o600})
|
|
for(const key of ['PERSONNEL_SALARY_LOCAL_ACCEPTANCE','PERFORMANCE_SALARY_LOCAL_TEST_FALLBACK'])assert.notEqual(process.env[key],'true')
|
|
const report={target,verified:true,comparisons,people:7,gradesBlank:7,permissions:true,privateNoStore:true,audits:3,nativeTemplateHashesUnchanged:true};write(root+'/verification.json',report);console.log(JSON.stringify(report))
|
|
}
|
|
async function main(){
|
|
if(!['local','production'].includes(target)||!['audit','apply','reconcile','restore','verify'].includes(mode))throw Error('Invalid target or mode')
|
|
const url=new URL(process.env.DATABASE_URL||'http://invalid')
|
|
if(target==='local'&&(url.hostname!=='127.0.0.1'||url.port!=='55439'||url.pathname!=='/salary_isolated_grade_20261106'))throw Error('Not isolated grade database')
|
|
if(target==='production'&&(!process.env.SALARY_GRADE_EXPECT_DATABASE||url.pathname.slice(1)!==process.env.SALARY_GRADE_EXPECT_DATABASE))throw Error('Production database identity not confirmed')
|
|
if(['apply','restore','reconcile'].includes(mode)&&process.env.SALARY_GRADE_CONFIRM!=='APPLY_GRADE_SCHEMES')throw Error('Explicit write confirmation required')
|
|
fs.mkdirSync(root,{recursive:true,mode:0o700})
|
|
admin=await db.user.findFirstOrThrow({where:{isSuperAdmin:true,isActive:true,status:'active',archivedAt:null},select:{id:true,isSuperAdmin:true}})
|
|
if(mode==='audit'){if(fs.existsSync(manifestPath)){const m=read(manifestPath);const live=(await request('GET')).positions;for(const op of m.operations){const e=op.receipt||op.before,c=live.find(p=>p.id===e.id);assert.ok(c&&c.revision===e.revision&&engine.equal(engine.fields(c),engine.fields(e)))}console.log('Saved configuration audit still matches')}else await baseline()}
|
|
else if(mode==='verify')await verify()
|
|
else {const m=read(manifestPath),b=read(baselinePath);assert.deepEqual(await protectedState(),b.protected);const identity=await db.$queryRawUnsafe('SELECT current_database() AS database,current_user AS "user",inet_server_addr()::text AS address,inet_server_port() AS port');assert.deepEqual(identity,b.identity);await engine[mode](m,{environment:target,request,save:async value=>write(manifestPath,value)});console.log(JSON.stringify({target,mode,completed:m.operations.filter(o=>o.receipt).length,restored:m.operations.filter(o=>o.restored).length}))}
|
|
}
|
|
main().catch(e=>{console.error(e.stack);process.exitCode=1}).finally(()=>db.$disconnect())
|