feat: verify offline user data backup and recovery
This commit is contained in:
@@ -89,7 +89,7 @@ Tarayıcı kanıtı: [Yenileme sonrası beş kart](evidence/qa-kanban-after-relo
|
||||
|
||||
## Doğrulama
|
||||
|
||||
`npm test`: 45/45 geçti (son yeniden çalıştırma: 6 Eylül 2026).
|
||||
`npm test`: 46/46 geçti (son yeniden çalıştırma: 6 Eylül 2026).
|
||||
`npm run build`: geçti; mevcut büyük paket/dinamik import uyarıları sürüyor.
|
||||
`node --check server/index.js`, `node --check server/user-store.mjs`, `git diff --check`: geçti.
|
||||
|
||||
@@ -149,3 +149,8 @@ Bir test mevcut olmayan bulgu, yanlış kaynak, doğru alıntı ve tekrarlı kon
|
||||
Detayda açık kullanıcı işlemiyle incelendi/yeniden aç durumu sunucuya kaydedilir. Sadece pencerenin açılması incelemenin tamamlandığı iddiasını oluşturmaz. Sağ panel Kanban durumundan inceleme sonucu çıkarmayı bıraktı; aynı incelendi alanını ve filtre ölçütünü kullanır. İncelenmiş kayıt güncel havuzdan düşerse kayıtlı snapshot korunur. Aynı ihalede süren kayıt nedeniyle reddedilen işlem artık sessiz kalmaz.
|
||||
|
||||
İki test sayaç/filtre eşitliği ve havuz dışı kaydı kapsar. Tarayıcıda sentetik Giresun kaydı incelendi olarak işaretlenip yenilendi: incelenen1, incelenmemiş4 kaldı. [Kanıt](evidence/qa-reviewed-after-reload.txt). 45 test ve derleme geçti.
|
||||
|
||||
|
||||
## Kullanıcı verisi yedek/geri yükleme aracı
|
||||
|
||||
SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kontrolü ve yeni dizine geri yükleme eklendi. Çalışan yazıcıların durdurulması önkoşuldur; araç canlı üretim servisini kendisi durdurmaz. Bir test gerçek dosya ve hazırlık kaydının geri gelmesini, hedef üzerine yazmama ve bozuk yedek reddini doğrular. 46 test geçti. [Operasyon adımları ve kapsam sınırı](yedek-geri-yukleme.md). Üretim yedeği, tüm volume ve dağıtım geri dönüş provası hâlâ açık.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Kullanıcı verisi yedekleme ve geri yükleme
|
||||
|
||||
Araç `users.sqlite` ve veritabanında referansı bulunan `belgeler/` dosyalarını kapsar. İhale/kalem arşivi, kaynak önbellekleri, ortam değişkenleri ve dağıtım yapılandırması bu yedeğin parçası değildir. Tam üretim geri dönüşü için bunların ayrıca platform volume/config yedeği gerekir.
|
||||
|
||||
## Önkoşul
|
||||
|
||||
Uygulamayı ve bu dizine yazan bütün zamanlanmış işleri platform üzerinden durdurun. Etkin sunucu ortamından gerçek `IHALEIST_USER_DATA_DIR`/volume yolunu doğrulayın. `--offline` işlemlerin durdurulduğuna ilişkin operatör beyanıdır; araç çalışan servisleri otomatik bulup kapatmaz. Canlı dosya silme/yükleme sürerken tutarlı yedek garantisi verilmez.
|
||||
|
||||
Node'un `node:sqlite` modülü gerekli. Aşağıdaki yollar örnektir; doğrulanan yollarla değiştirin. Hedef dizin henüz bulunmamalı.
|
||||
|
||||
```sh
|
||||
node server/backup-user-data.mjs backup /volume/data /backups/release-001 --offline
|
||||
node server/backup-user-data.mjs verify /backups/release-001
|
||||
node server/backup-user-data.mjs restore /backups/release-001 /recovery/release-001
|
||||
```
|
||||
|
||||
Yedek aracı SQLite snapshot oluşturur, bütünlük kontrolü yapar, referanslı dosyaları kopyalar ve SHA-256/byte manifesti üretir. Kayıp dosya başarısızlıktır; eksik yedek başarılı gösterilmez. Dizin/dosya izinleri 0700/0600'dır. Doğrulama şifreleme veya imza değildir; yedeğin erişimini ve dış saklamasını platformda ayrıca yönetin.
|
||||
|
||||
## Geri dönüş provası
|
||||
|
||||
1. Önce yeni bir test dizinine geri yükleyin. Üretim dizininin üzerine yazmayın.
|
||||
2. Geri yüklenen dizine bağlı izole uygulamada iki test hesabının kayıtlarını, beş hazırlık kaydını, belgelerin indirilmesini, fiyat/toplam ve sürüm çakışmalarını doğrulayın.
|
||||
3. Yayın adayının migration'ını bu kopyada çalıştırın. Eski ve yeni uygulama sürümlerinin hangi şemayla uyumlu olduğunu kaydedin; otomatik ters migration varsaymayın.
|
||||
4. Uygulama ve veri geri dönüş noktalarını birlikte seçin. Platformun volume değiştirme adımı doğrulanmadan üretim dizinini silmeyin/yeniden adlandırmayın.
|
||||
5. Yayın sonrası kayıt/indirme hatası varsa ilgili özelliği kapatın; doğrulanmış uygulama/volume çiftine dönüşü uygulayın. Başarılı yeni kullanıcı kayıtları varsa eski yedeğe kör dönüşle bunları kaybetmeyin; ileri düzeltme veya veri birleştirme değerlendirin.
|
||||
|
||||
## Mevcut kanıt
|
||||
|
||||
`tests/backup-user-data.test.mjs` izole SQLite ve gerçek Türkçe dosya baytlarıyla yedek/restore, hazırlık fiyatı, hedef üzerine yazmama ve değiştirilmiş yedeği reddetme senaryolarını doğrular. Üretim servis durdurma, volume yedeği ve platform geri dönüşü henüz denenmedi. Bu dosya yayın onayı veya tüm D11 tamamlandı beyanı değildir.
|
||||
@@ -0,0 +1,76 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const safeName = name => typeof name==='string' && name.length>0 && path.basename(name)===name && name!=='.' && name!=='..';
|
||||
const regular = file => {if(!fs.lstatSync(file).isFile())throw new Error('Yedek yalnız normal dosyaları kabul eder.');};
|
||||
function verifyDatabase(file) {
|
||||
const db=new DatabaseSync(file,{readOnly:true});
|
||||
try {
|
||||
const result=db.prepare('PRAGMA integrity_check').all();
|
||||
if(result.length!==1 || Object.values(result[0])[0]!=='ok')throw new Error('Veritabanı bütünlük kontrolü başarısız.');
|
||||
return db.prepare('SELECT dosya FROM belgeler').all().map(r=>r.dosya);
|
||||
} finally {db.close();}
|
||||
}
|
||||
export function verifyUserBackup(directory) {
|
||||
const manifest=JSON.parse(fs.readFileSync(path.join(directory,'manifest.json'),'utf8'));
|
||||
if(manifest.version!==1 || !Array.isArray(manifest.files))throw new Error('Geçersiz yedek manifesti.');
|
||||
const seen=new Set();
|
||||
for(const entry of manifest.files) {
|
||||
if(entry.name!=='users.sqlite' && !(entry.name?.startsWith('belgeler/') && safeName(entry.name.slice(9))))throw new Error('Geçersiz yedek dosya yolu.');
|
||||
if(seen.has(entry.name))throw new Error('Tekrarlı yedek girdisi.');seen.add(entry.name);
|
||||
const file=path.join(directory,entry.name);regular(file);
|
||||
if(hash(file)!==entry.sha256 || fs.statSync(file).size!==entry.bytes)throw new Error('Yedek dosyası eksik veya değişmiş: '+entry.name);
|
||||
}
|
||||
if(!seen.has('users.sqlite'))throw new Error('Veritabanı yedekte yok.');
|
||||
for(const name of verifyDatabase(path.join(directory,'users.sqlite')))
|
||||
if(!safeName(name) || !seen.has('belgeler/'+name))throw new Error('Veritabanının referans verdiği belge yedekte yok.');
|
||||
return manifest;
|
||||
}
|
||||
export function backupUserData(source,destination,{offline=false}={}) {
|
||||
if(!offline)throw new Error('Önce uygulama ve yazan işleri durdurun; offline seçeneği gerekli.');
|
||||
const dbFile=path.join(source,'users.sqlite');regular(dbFile);
|
||||
fs.mkdirSync(destination,{mode:0o700}); // Never overwrite an existing backup.
|
||||
let db;
|
||||
try {
|
||||
fs.mkdirSync(path.join(destination,'belgeler'),{mode:0o700});
|
||||
db=new DatabaseSync(dbFile,{readOnly:true});
|
||||
db.prepare('VACUUM INTO ?').run(path.resolve(destination,'users.sqlite'));db.close();db=null;
|
||||
const references=verifyDatabase(path.join(destination,'users.sqlite'));
|
||||
for(const name of new Set(references)) {
|
||||
if(!safeName(name))throw new Error('Geçersiz belge dosya adı.');
|
||||
const file=path.join(source,'belgeler',name);regular(file);
|
||||
fs.copyFileSync(file,path.join(destination,'belgeler',name),fs.constants.COPYFILE_EXCL);
|
||||
}
|
||||
const names=['users.sqlite',...[...new Set(references)].map(name=>'belgeler/'+name)];
|
||||
const files=names.map(name=>{const file=path.join(destination,name);fs.chmodSync(file,0o600);return {name,bytes:fs.statSync(file).size,sha256:hash(file)};});
|
||||
fs.writeFileSync(path.join(destination,'manifest.json'),JSON.stringify({version:1,createdAt:new Date().toISOString(),scope:'users.sqlite and referenced documents',files},null,2),{mode:0o600,flag:'wx'});
|
||||
return verifyUserBackup(destination);
|
||||
} catch(error) {db?.close();fs.rmSync(destination,{recursive:true,force:true});throw error;}
|
||||
}
|
||||
export function restoreUserData(backup,destination) {
|
||||
const manifest=verifyUserBackup(backup);
|
||||
fs.mkdirSync(destination,{mode:0o700}); // Restore only to a NEW directory.
|
||||
try {
|
||||
fs.mkdirSync(path.join(destination,'belgeler'),{mode:0o700});
|
||||
for(const entry of manifest.files) {
|
||||
const file=path.join(destination,entry.name);
|
||||
fs.copyFileSync(path.join(backup,entry.name),file,fs.constants.COPYFILE_EXCL);fs.chmodSync(file,0o600);
|
||||
if(hash(file)!==entry.sha256)throw new Error('Geri yükleme sırasında dosya değişti.');
|
||||
}
|
||||
verifyDatabase(path.join(destination,'users.sqlite'));
|
||||
return {files:manifest.files.length,destination};
|
||||
}catch(error){fs.rmSync(destination,{recursive:true,force:true});throw error;}
|
||||
}
|
||||
if(process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
|
||||
const [command,source,destination,flag]=process.argv.slice(2);
|
||||
try {
|
||||
if(command==='backup' && source && destination)backupUserData(source,destination,{offline:flag==='--offline'});
|
||||
else if(command==='verify' && source)verifyUserBackup(source);
|
||||
else if(command==='restore' && source && destination)restoreUserData(source,destination);
|
||||
else throw new Error('Kullanım: backup <veri-dizini> <yeni-yedek-dizini> --offline | verify <yedek> | restore <yedek> <yeni-dizin>');
|
||||
console.log('İşlem doğrulandı.');
|
||||
}catch(error){console.error(error.message);process.exitCode=1;}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
import {backupUserData,restoreUserData,verifyUserBackup} from '../server/backup-user-data.mjs';
|
||||
test('offline backup restores actual documents and preparation data without overwriting a target',()=>{
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'ihale-backup-'));
|
||||
try {
|
||||
const source=path.join(root,'source'),backup=path.join(root,'backup'),restored=path.join(root,'restored');
|
||||
fs.mkdirSync(source);fs.mkdirSync(path.join(source,'belgeler'));
|
||||
fs.writeFileSync(path.join(source,'belgeler','file1'),'Türkçe yetki belgesi');
|
||||
const db=new DatabaseSync(path.join(source,'users.sqlite'));
|
||||
db.exec('CREATE TABLE belgeler(dosya TEXT); CREATE TABLE tender_state(data TEXT);');
|
||||
db.prepare('INSERT INTO belgeler VALUES(?)').run('file1');db.prepare('INSERT INTO tender_state VALUES(?)').run(JSON.stringify({ikn:'2026/1',bidDraft:{price:'125,50'}}));db.close();
|
||||
assert.throws(()=>backupUserData(source,backup));
|
||||
backupUserData(source,backup,{offline:true});assert.equal(verifyUserBackup(backup).files.length,2);
|
||||
restoreUserData(backup,restored);
|
||||
assert.equal(fs.readFileSync(path.join(restored,'belgeler','file1'),'utf8'),'Türkçe yetki belgesi');
|
||||
const read=new DatabaseSync(path.join(restored,'users.sqlite'));assert.equal(JSON.parse(read.prepare('SELECT data FROM tender_state').get().data).bidDraft.price,'125,50');read.close();
|
||||
assert.throws(()=>restoreUserData(backup,restored));assert.throws(()=>backupUserData(source,backup,{offline:true}));
|
||||
fs.appendFileSync(path.join(backup,'belgeler','file1'),'corrupt');assert.throws(()=>verifyUserBackup(backup));
|
||||
assert.throws(()=>restoreUserData(backup,path.join(root,'bad')));assert.equal(fs.existsSync(path.join(root,'bad')),false);
|
||||
}finally{fs.rmSync(root,{recursive:true,force:true});}
|
||||
});
|
||||
Reference in New Issue
Block a user