test(server): verify complete revision backup and restore

This commit is contained in:
aj committed 2026-10-11 17:02:31 -04:00
1 parent a7c8e8541e
commit 0216a9e9e2
6 files changed
+586 -31

No files matched your search

+34 -3
View File
@@ -103,6 +103,20 @@ internal static class Program
Check(copy.Id != Guid.Empty && copy.Id != created.Id, "Save copy must create a different id.");
CheckBinding(session, copy.Id, options.Url);
var copyState = await ReadSaved(repository, nest, archive, copy, ct);
// Keep a hidden nest with metadata, restore-source and visibility history in the backup.
var hidden = await repository.UploadAsync(archive, new NestRecord { Name = "Synthetic hidden history" }, ct);
hidden.Comments = "Metadata-only revision";
hidden = await repository.UpdateMetadataAsync(hidden.Id, hidden, hidden.Revision, ct);
hidden = await repository.RestoreAsync(hidden.Id, 1, hidden.Revision, ct);
Check(hidden.Revision == 3 && hidden.RestoredFromRevision == 1, "Restore must record its source.");
hidden = await repository.DeleteAsync(hidden.Id, hidden.Revision, ct);
hidden = await repository.RecoverAsync(hidden.Id, hidden.Revision, ct);
hidden = await repository.DeleteAsync(hidden.Id, hidden.Revision, ct);
Check(hidden.Deleted && hidden.Revision == 6, "Visibility changes must retain all revisions.");
var hiddenHistory = await repository.GetDeletedRevisionsAsync(hidden.Id, limit: 2, cancellationToken: ct);
Check(hiddenHistory.Total == 6 && hiddenHistory.Items.Count == 2, "Deleted history must be bounded.");
Check((await repository.GetDeletedRevisionFileAsync(hidden.Id, 1, ct))!.SequenceEqual(archive),
"Deleted history must retain original bytes.");
var entries = baseline.Concat(new[] { updateState, copyState }).ToList();
await CheckSnapshot(repository, entries, ct);
var state = new SmokeState { Version = 1, Records = entries };
@@ -153,6 +167,8 @@ internal static class Program
if (test.File is not null)
content.Add(new ByteArrayContent(test.File), "file", "synthetic.nest");
using var request = new HttpRequestMessage(method, new Uri(new Uri(url + "/"), path)) { Content = content };
if (method == HttpMethod.Put)
request.Headers.TryAddWithoutValidation("If-Match", $"\"{before[0].Metadata.Revision}\"");
using var response = await http.SendAsync(request, ct);
Check(response.StatusCode == HttpStatusCode.BadRequest, $"{method} {test.Name} must return 400.");
await CheckSnapshot(repository, before, ct);
@@ -190,14 +206,15 @@ internal static class Program
Check(downloaded is not null && downloaded.AsSpan().SequenceEqual(accepted),
"A near-limit archive must download byte-for-byte.");
await CheckMembership(repository, before.Select(s => s.Metadata.Id).Append(created.Id), ct);
await repository.DeleteAsync(created.Id, ct);
var deleted = await repository.DeleteAsync(created.Id, created.Revision, ct);
Check(deleted.Deleted && deleted.Revision == 2, "Near-limit archive must be retained as deleted history.");
await CheckSnapshot(repository, before, ct);
Console.WriteLine($"Accepted, downloaded, and deleted a {accepted.Length}-byte archive; all other records unchanged.");
// Kestrel rejects an oversized declared length before the app reads the form. With
// Expect: 100-continue the client waits for, and must receive, that 413 response.
var oversized = new byte[RequestBodyLimit];
var oversizedRecord = new NestRecord { Name = "Synthetic oversized upload" };
var oversizedRecord = new NestRecord { Name = "Synthetic oversized upload", Revision = before[0].Metadata.Revision };
var metadata = JsonSerializer.Serialize(oversizedRecord, JsonOptions);
foreach (var method in new[] { HttpMethod.Post, HttpMethod.Put })
{
@@ -207,6 +224,8 @@ internal static class Program
content.Add(new ByteArrayContent(oversized), "file", "synthetic.nest");
using var request = new HttpRequestMessage(method, new Uri(new Uri(url + "/"), path)) { Content = content };
request.Headers.ExpectContinue = true;
if (method == HttpMethod.Put)
request.Headers.TryAddWithoutValidation("If-Match", $"\"{before[0].Metadata.Revision}\"");
using var response = await http.SendAsync(request, ct);
Check(response.StatusCode == HttpStatusCode.RequestEntityTooLarge,
$"{method} of a request over {RequestBodyLimit} bytes must return 413.");
@@ -244,6 +263,7 @@ internal static class Program
Check(returned.SavedAt != default, "Server must assign SavedAt.");
var expected = NestRecordFactory.FromNest(nest, returned.Id, archive.LongLength);
expected.SavedAt = returned.SavedAt;
expected.Revision = returned.Revision;
CheckMetadata(expected, returned);
var readback = await repository.GetMetadataAsync(returned.Id, ct);
Check(readback is not null, "Saved metadata must be readable.");
@@ -360,6 +380,9 @@ internal static class Program
Check(reader.Warnings.Count == 0, "NestReader must parse without warnings.");
var parsed = NestRecordFactory.FromNest(nest, metadata.Id, archive.LongLength);
parsed.SavedAt = metadata.SavedAt;
parsed.Revision = metadata.Revision;
parsed.Deleted = metadata.Deleted;
parsed.RestoredFromRevision = metadata.RestoredFromRevision;
CheckMetadata(metadata, parsed);
Check(nest.Drawings.Count == 1 && nest.Plates.Count == 1 && nest.Plates[0].Parts.Count == 1,
"Parsed archive must contain one drawing, plate, and part.");
@@ -459,7 +482,15 @@ internal static class Program
CheckWireRecord(record);
}
else
CheckWireRecord(json.RootElement);
{
if (json.RootElement.TryGetProperty("items", out var items))
{
foreach (var record in items.EnumerateArray())
CheckWireRecord(record);
}
else
CheckWireRecord(json.RootElement);
}
CheckedResponses++;
}
return response;
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Generate only synthetic legacy SQLite files inside caller-owned scratch."""
import hashlib
import json
from pathlib import Path
import sqlite3
import sys
import tarfile
SCHEMA = """
CREATE TABLE nests (
id TEXT PRIMARY KEY, name TEXT NOT NULL, customer TEXT NOT NULL DEFAULT '',
dateCreated TEXT NOT NULL, dateModified TEXT NOT NULL, material TEXT NOT NULL DEFAULT '',
thickness REAL NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'Quote',
plateCount INTEGER NOT NULL DEFAULT 0, partCount INTEGER NOT NULL DEFAULT 0,
comments TEXT NOT NULL DEFAULT '', madeBy TEXT NOT NULL DEFAULT '',
fileSize INTEGER NOT NULL DEFAULT 0, savedAt TEXT NOT NULL, file BLOB NOT NULL);
CREATE INDEX ix_nests_savedAt ON nests(savedAt DESC);
"""
def create(directory):
db = directory / 'nests.db'
expected = []
with sqlite3.connect(db) as connection:
connection.executescript(SCHEMA)
for index in range(1, 3):
ident = f'00000000-0000-4000-8000-{index:012d}'
data = bytes([0, index, 255, 128, 0])
date = '2026-01-02T03:04:05'
fields = (ident, f'Synthetic legacy {index}', 'Synthetic fixture', date, date,
'Synthetic alloy', 0.25, 'ToBeCut', 2, 3, 'Legacy notes', 'Fixture', len(data), date)
connection.execute('INSERT INTO nests VALUES (' + ','.join('?' * 15) + ')', (*fields, data))
metadata: dict[str, object] = dict(zip(('id', 'name', 'customer', 'dateCreated', 'dateModified', 'material',
'thickness', 'status', 'plateCount', 'partCount', 'comments',
'madeBy', 'fileSize', 'savedAt'), fields))
metadata.update(status='toBeCut', revision=1, deleted=False, restoredFromRevision=None)
archive = {'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data)}
expected.append({'current': metadata, 'archive': archive,
'revisions': [{'metadata': metadata, 'archive': archive}]})
(directory / 'legacy.manifest').write_text(json.dumps({'format': 1, 'records': expected}, sort_keys=True, indent=2) + '\n')
with tarfile.open(directory / 'legacy.tar', 'w') as archive:
info = archive.gettarinfo(str(db), arcname='./nests.db')
info.uid = info.gid = 1654
info.uname = info.gname = ''
with db.open('rb') as source:
archive.addfile(info, source)
if __name__ == '__main__':
create(Path(sys.argv[1]))
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Read-only, canonical revision manifest; requires a quiescent version-aware server."""
import argparse
import hashlib
import json
import sys
import urllib.error
import urllib.request
import uuid
def require(condition, message):
if not condition:
raise ValueError(message)
def collect(url, limit=200):
require(1 <= limit <= 200, "Invalid page size")
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def read(path, binary=False):
try:
response = opener.open(url.rstrip('/') + path, timeout=30)
except urllib.error.HTTPError as error:
error.close()
raise ValueError("HTTP request failed") from None
with response:
if binary:
digest = hashlib.sha256()
size = 0
while chunk := response.read(1024 * 1024):
size += len(chunk)
digest.update(chunk)
return {"sha256": digest.hexdigest(), "bytes": size}
return json.load(response)
def pages(path):
items, total = [], None
while True:
page = read(f"{path}?offset={len(items)}&limit={limit}")
require(page['offset'] == len(items) and page['limit'] == limit, "Page bounds differ")
require(type(page['total']) is int and page['total'] >= 0, "Invalid total")
if total is None:
total = page['total']
require(total == page['total'], "Server changed during enumeration")
batch = page['items']
require(isinstance(batch, list) and len(batch) <= limit, "Invalid page")
require(len(items) + len(batch) <= total, "Page exceeds total")
require(batch or len(items) == total, "Truncated enumeration")
items.extend(batch)
if len(items) == total:
return items
result, ids = [], set()
for deleted in (False, True):
path = '/api/nests/deleted' if deleted else '/api/nests'
records = pages(path + '/query')
for record in records:
ident = record['id']
require(str(uuid.UUID(ident)) == ident and ident not in ids, "Invalid or duplicate nest ID")
ids.add(ident)
require(record['deleted'] is deleted, "Visibility differs")
revisions = pages(path + '/' + ident + '/revisions')
require([r['revision'] for r in revisions] == list(range(record['revision'], 0, -1)),
"Missing, duplicate, or unordered revisions")
require(revisions and revisions[0] == record, "Current projection differs from latest revision")
history = []
for revision in revisions:
number = revision['revision']
require(revision['id'] == ident, "Revision belongs to another nest")
parent = revision['restoredFromRevision']
require(parent is None or type(parent) is int and 1 <= parent < number,
"Invalid restore source relationship")
archive = read(f"{path}/{ident}/revisions/{number}/file", binary=True)
require(archive['bytes'] == revision['fileSize'], "Revision archive size differs")
history.append({"metadata": revision, "archive": archive})
current = read(f"{path}/{ident}/file", binary=True)
require(current == history[0]['archive'], "Current archive differs from latest revision")
result.append({"current": record, "archive": current,
"revisions": sorted(history, key=lambda r: r['metadata']['revision'])})
return {"format": 1, "records": sorted(result, key=lambda r: r['current']['id'])}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('url')
parser.add_argument('--page-size', type=int, default=200)
args = parser.parse_args()
try:
manifest = collect(args.url, args.page_size)
# Emit only after every request and invariant succeeds; failure never emits a partial manifest.
print(json.dumps(manifest, sort_keys=True, indent=2))
except Exception as error:
print(f"Manifest failed: {type(error).__name__}", file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,277 @@
"""Manifest refusal tests and opt-in executable documentation/rollback rehearsal.
OPENNEST_TEST_IMAGE=<local-new-image> [OPENNEST_TEST_OLD_IMAGE=<local-old-image>]
python3 -m unittest discover -s scripts/Server.Tests -p test_revision_backup.py -v
Only generated loopback containers/volumes are used; no live server URL is accepted.
"""
import copy
import hashlib
import http.server
import json
import os
from pathlib import Path
import re
import shutil
import socket
import subprocess
import tempfile
import threading
import time
import unittest
import urllib.parse
import urllib.request
import uuid
from nest_manifest import collect
from legacy_fixture import create
ROOT = Path(__file__).resolve().parents[2]
class ManifestTests(unittest.TestCase):
def setUp(self):
self.ident = '00000000-0000-4000-8000-000000000001'
self.record = dict(id=self.ident, revision=3, deleted=True, restoredFromRevision=1,
fileSize=3, comments='restored', savedAt='2026-01-02T03:04:05')
self.revisions = [self.record, dict(self.record, revision=2, deleted=False,
restoredFromRevision=None, comments='edited'),
dict(self.record, revision=1, deleted=False, restoredFromRevision=None)]
self.failure = None
owner = self
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
path = urllib.parse.urlsplit(self.path).path
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
if owner.failure == 'legacy' or owner.failure == 'download' and path.endswith('/file'):
self.send_error(500 if owner.failure == 'download' else 404)
return
if path.endswith('/file'):
data = b'abc'
else:
values = owner.revisions if path.endswith('/revisions') else ([owner.record] if '/deleted/' in path else [])
offset, limit = int(query['offset'][0]), int(query['limit'][0])
batch = values[offset:offset + limit]
if owner.failure == 'truncated' and offset > 0:
batch = []
total = len(values) + (1 if owner.failure == 'total' and offset else 0)
data = json.dumps(dict(items=batch, total=total, offset=offset, limit=limit)).encode()
self.send_response(200)
self.end_headers()
self.wfile.write(data)
def log_message(self, format, *args):
pass
self.server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.url = f'http://127.0.0.1:{self.server.server_port}'
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join()
def test_all_deleted_revisions_and_parent_metadata_are_preserved(self):
manifest = collect(self.url, 1)
record = manifest['records'][0]
self.assertEqual(self.record, record['current'])
self.assertEqual([1, 2, 3], [r['metadata']['revision'] for r in record['revisions']])
self.assertEqual(hashlib.sha256(b'abc').hexdigest(), record['archive']['sha256'])
self.assertEqual(1, record['revisions'][2]['metadata']['restoredFromRevision'])
altered = copy.deepcopy(manifest)
altered['records'][0]['revisions'][0]['metadata']['comments'] = 'tampered'
self.assertNotEqual(manifest, altered)
def test_failed_download_emits_no_partial_manifest(self):
self.failure = 'download'
result = subprocess.run(['python3', str(ROOT / 'scripts/Server.Tests/nest_manifest.py'), self.url], capture_output=True)
self.assertNotEqual(0, result.returncode)
self.assertEqual(b'', result.stdout)
def test_legacy_endpoint_is_not_empty_history(self):
self.failure = 'legacy'
with self.assertRaises(Exception):
collect(self.url, 1)
def test_missing_pages_and_changed_totals_fail(self):
for failure in ('truncated', 'total'):
self.failure = failure
with self.assertRaises(ValueError):
collect(self.url, 1)
def test_missing_revision_and_invalid_parent_fail(self):
self.revisions.pop(1)
with self.assertRaises(ValueError):
collect(self.url, 1)
self.revisions.insert(1, dict(self.record, revision=2, restoredFromRevision=None))
self.record['restoredFromRevision'] = 3
with self.assertRaises(ValueError):
collect(self.url, 1)
@unittest.skipUnless(os.environ.get('OPENNEST_TEST_IMAGE'), 'Set OPENNEST_TEST_IMAGE for disposable Docker documentation rehearsal')
class DocumentationTests(unittest.TestCase):
def run_command(self, *args, input=None, check=True):
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=120, cwd=self.scratch)
if check and result.returncode:
self.fail(f"Command failed ({result.returncode}): {args}\n{result.stdout}\n{result.stderr}")
return result
def docker(self, *args, **kwargs):
return self.run_command('docker', '--context', 'default', *args, **kwargs)
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='opennest-backup-doc-', dir=os.environ.get('TMPDIR'))
self.scratch = Path(self.temp.name)
self.token = 'opennest-doc-' + uuid.uuid4().hex
self.image = os.environ['OPENNEST_TEST_IMAGE']
self.volumes = []
self.containers = []
self.compose = None
self.addCleanup(self.cleanup_resources)
endpoint = self.docker('context', 'inspect', 'default', '--format', '{{.Endpoints.docker.Host}}').stdout.strip()
self.assertEqual('unix:///var/run/docker.sock', endpoint)
self.volume = self.token + '-data'
self.new_volume(self.volume)
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
port = probe.getsockname()[1]
configuration = {'services': {'opennest-server': {'image': self.image,
'ports': [f'127.0.0.1:{port}:8090'], 'volumes': [self.volume + ':/app/data'],
'cap_drop': ['ALL'], 'security_opt': ['no-new-privileges:true']}},
'volumes': {self.volume: {'external': True}}}
(self.scratch / 'compose.json').write_text(json.dumps(configuration))
self.compose = f'docker --context default compose -p {self.token} -f compose.json'
self.run_command('bash', '-c', self.compose + ' up -d')
self.cid = self.run_command('bash', '-c', self.compose + ' ps -q').stdout.strip()
self.url = self.ready(self.cid)
shutil.copy(ROOT / 'scripts/Server.Tests/nest_manifest.py', self.scratch / 'nest_manifest.py')
doc = (ROOT / 'docs/nest-storage.md').read_text()
blocks = re.findall(r'```bash\n(.*?)```', doc, re.S)
selected = [block for block in blocks if 'backup_nests()' in block or 'restore_check()' in block]
self.assertEqual(2, len(selected))
self.functions = '\n'.join('\n'.join(line for line in block.splitlines()
if not line.startswith(('url=', 'backup_nests opennest-', 'restore_check opennest-')))
for block in selected)
(self.scratch / 'procedures.sh').write_text(self.functions)
self.run_command('bash', '-n', 'procedures.sh')
# Opaque synthetic archives exercise history without any customer data.
record = self.request('POST', '/api/nests', multipart=True)
record['comments'] = 'metadata version'
record = self.request('PUT', f"/api/nests/{record['id']}/metadata", record, record['revision'])
record = self.request('POST', f"/api/nests/{record['id']}/revisions/1/restore", revision=record['revision'])
self.request('DELETE', f"/api/nests/{record['id']}", revision=record['revision'])
self.request('POST', '/api/nests', multipart=True)
def new_volume(self, name):
self.assertNotEqual(0, self.docker('volume', 'inspect', name, check=False).returncode)
self.docker('volume', 'create', '--label', 'com.opennest.doc-owner=' + self.token, name)
self.assertEqual(self.token, self.docker('volume', 'inspect', '--format', '{{index .Labels "com.opennest.doc-owner"}}', name).stdout.strip())
self.volumes.append(name)
def ready(self, cid):
port = self.docker('port', cid, '8090/tcp').stdout.strip().split(':')[1]
url = f'http://127.0.0.1:{port}'
for _ in range(60):
try:
with urllib.request.urlopen(url + '/healthz', timeout=1) as response:
if json.load(response) == {'status': 'ok'}:
return url
except OSError:
time.sleep(1)
self.fail('Disposable service did not become ready')
def request(self, method, path, data=None, revision=None, multipart=False):
headers = {}
if revision:
headers['If-Match'] = f'"{revision}"'
if multipart:
boundary = 'synthetic-boundary'
data = (f'--{boundary}\r\nContent-Disposition: form-data; name="metadata"\r\n\r\n'
'{"name":"Synthetic documentation fixture"}\r\n'
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="fixture.nest"\r\n'
f'Content-Type: application/zip\r\n\r\nabc\r\n--{boundary}--\r\n').encode()
headers['Content-Type'] = 'multipart/form-data; boundary=' + boundary
elif data is not None:
data = json.dumps(data).encode()
headers['Content-Type'] = 'application/json'
request = urllib.request.Request(self.url + path, data=data, headers=headers, method=method)
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)
def procedure(self, command, check=True):
prefix = f'compose={json.dumps(self.compose)}; url={self.url}; source ./procedures.sh; '
return self.run_command('bash', '-c', prefix + command, check=check)
def cleanup_resources(self):
# Compose removes only this randomly generated project's containers, never its external volumes.
try:
if self.compose:
self.run_command('bash', '-c', self.compose + ' down')
for cid in self.containers:
self.docker('rm', '-f', cid)
for volume in self.volumes:
labels = json.loads(self.docker('volume', 'inspect', '--format', '{{json .Labels}}', volume).stdout)
self.assertTrue(labels.get('com.opennest.doc-owner') == self.token
or labels.get('com.opennest.restore.owner') == volume and volume.startswith(self.token + '-'))
self.assertEqual('', self.docker('container', 'ls', '-aq', '--filter', 'volume=' + volume).stdout.strip(),
'A documentation check container was not removed')
self.docker('volume', 'rm', volume)
self.assertNotEqual(0, self.docker('volume', 'inspect', volume, check=False).returncode)
self.assertEqual('', self.docker('container', 'ls', '-aq', '--filter', 'label=com.docker.compose.project=' + self.token).stdout.strip())
finally:
self.temp.cleanup()
def test_verbatim_backup_restore_and_refusals(self):
self.procedure('backup_nests backup')
original = json.loads((self.scratch / 'backup.manifest').read_text())
self.assertEqual(2, len(original['records']))
self.assertEqual(5, sum(len(r['revisions']) for r in original['records']))
started = self.docker('inspect', '--format', '{{.State.StartedAt}}', self.cid).stdout
refused = self.procedure('if backup_nests backup; then exit 90; fi')
self.assertIn('already exists', refused.stderr)
self.assertEqual(started, self.docker('inspect', '--format', '{{.State.StartedAt}}', self.cid).stdout)
restored = self.token + '-restored'
refused = self.procedure(f'if restore_check backup {self.image} {self.volume}; then exit 90; fi')
self.assertIn('already exists', refused.stderr)
self.volumes.append(restored)
self.procedure(f'restore_check backup {self.image} {restored}')
tampered = self.token + '-tampered'
self.volumes.append(tampered)
original['records'][0]['revisions'][0]['archive']['sha256'] = '0' * 64
(self.scratch / 'backup.manifest').write_text(json.dumps(original))
refused = self.procedure(f'if restore_check backup {self.image} {tampered}; then exit 90; fi')
self.assertIn('archives differ', refused.stderr)
@unittest.skipUnless(os.environ.get('OPENNEST_TEST_OLD_IMAGE'), 'Set OPENNEST_TEST_OLD_IMAGE for old-image rollback rehearsal')
def test_old_image_only_on_pre_migration_backup_copy(self):
create(self.scratch)
rollback = self.token + '-rollback'
self.new_volume(rollback)
# Restore synthetic pre-migration tar to a NEW owned volume, never the upgraded database.
with (self.scratch / 'legacy.tar').open('rb') as archive:
subprocess.run(['docker', '--context', 'default', 'run', '--rm', '-i', '--network', 'none',
'--entrypoint', 'tar', '-v', rollback + ':/app/data', self.image,
'-C', '/app/data', '-xf', '-'], stdin=archive, check=True, timeout=30, capture_output=True)
cid = self.docker('run', '-d', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges:true',
'-p', '127.0.0.1::8090', '-v', rollback + ':/app/data',
os.environ['OPENNEST_TEST_OLD_IMAGE']).stdout.strip()
self.containers.append(cid)
url = self.ready(cid)
expected = json.loads((self.scratch / 'legacy.manifest').read_text())['records']
with urllib.request.urlopen(url + '/api/nests') as response:
actual = {r['id']: r for r in json.load(response)}
self.assertEqual(2, len(actual))
for entry in expected:
record = {k: v for k, v in entry['current'].items() if k not in ('revision', 'deleted', 'restoredFromRevision')}
self.assertEqual(record, actual[record['id']])
with urllib.request.urlopen(url + f"/api/nests/{record['id']}/file") as response:
self.assertEqual(entry['archive']['sha256'], hashlib.sha256(response.read()).hexdigest())
with self.assertRaises(Exception):
collect(url)
if __name__ == '__main__':
unittest.main()
+19 -3
View File
@@ -23,7 +23,7 @@ while (($#)); do
esac
done
[[ -n "$image" && "$image" != -* ]] || { usage; exit 2; }
for command in docker dotnet curl timeout mktemp tar; do
for command in docker dotnet curl timeout mktemp tar python3 cmp; do
command -v "$command" >/dev/null || { printf 'Missing prerequisite: %s\n' "$command" >&2; exit 2; }
done
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
@@ -40,7 +40,7 @@ token=${scratch##*/}
owner_label='com.opennest.server-smoke.owner'
# Two service generations on one data volume, a stopped-service backup and restore into
# a second volume, its service, then two storage locations that must fail startup.
container_roles=(first replacement backup restore restored readonly unwritable)
container_roles=(first replacement backup restore restored readonly unwritable legacy)
container_names=()
cidfiles=()
container_attempted=()
@@ -306,16 +306,29 @@ run_tool() {
# A fresh named volume must be writable by the non-root image user.
create_volume 0
# Migrate a synthetic prior-schema database, never an existing service's data.
python3 "$root/scripts/Server.Tests/legacy_fixture.py" "$scratch"
create_container 7 --rm --interactive --network none --entrypoint tar \
--mount "type=volume,src=${volumes[0]},dst=/app/data" "$image" -C /app/data -xf -
docker_local start --attach --interactive "$cid" < "$scratch/legacy.tar" > "$run_results/legacy.log" 2>&1
service=first
start_service 0 "${volumes[0]}"
run_tool seed --state "$scratch/state.json"
python3 "$root/scripts/Server.Tests/nest_manifest.py" "$url" --page-size 1 > "$scratch/migrated.manifest"
cmp "$scratch/legacy.manifest" "$scratch/migrated.manifest"
run_tool seed --state "$scratch/state.json" --test-allow-nonempty
python3 "$root/scripts/Server.Tests/nest_manifest.py" "$url" --page-size 1 > "$scratch/before-reject.manifest"
run_tool reject
python3 "$root/scripts/Server.Tests/nest_manifest.py" "$url" --page-size 1 > "$scratch/after-reject.manifest"
cmp "$scratch/before-reject.manifest" "$scratch/after-reject.manifest"
run_tool limits
python3 "$root/scripts/Server.Tests/nest_manifest.py" "$url" --page-size 1 > "$scratch/all.manifest"
retire_service 0
# The replacement gets another Docker-allocated loopback port; only the volume is reused.
service=replacement
start_service 1 "${volumes[0]}"
run_tool verify --state "$scratch/state.json"
python3 "$root/scripts/Server.Tests/nest_manifest.py" "$url" --page-size 1 > "$scratch/reopened.manifest"
cmp "$scratch/all.manifest" "$scratch/reopened.manifest"
retire_service 1
# Back up the whole data directory from the stopped service (SQLite WAL makes a live
@@ -335,4 +348,7 @@ docker_local start --attach --interactive "$cid" < "$scratch/data-backup.tar" >
service=restored
start_service 4 "${volumes[1]}"
run_tool verify --state "$scratch/state.json"
python3 "$root/scripts/Server.Tests/nest_manifest.py" "$url" --page-size 1 > "$scratch/restored.manifest"
cmp "$scratch/all.manifest" "$scratch/restored.manifest"
printf 'PASS: legacy migration, idempotent restart, active/deleted ALL-revision metadata, parent relationships and byte hashes match after restore.\n'
printf 'PASS: non-root healthy runtime, real-client round trips, rejections, upload limit, persistent recreation, stopped-service backup/restore, and storage startup failures.\n'