477 lines
16 KiB
Nim
477 lines
16 KiB
Nim
## Property-Based Test: NIP Manifest Roundtrip
|
|
##
|
|
## **Feature:** 01-nip-unified-storage-and-formats
|
|
## **Property 3:** Manifest Roundtrip
|
|
## **Validates:** Requirements 6.4
|
|
##
|
|
## **Property Statement:**
|
|
## For any NIP manifest, parsing and regenerating SHALL produce semantically equivalent KDL
|
|
##
|
|
## **Test Strategy:**
|
|
## 1. Generate random NIP manifests with valid data
|
|
## 2. Convert manifest to KDL string
|
|
## 3. Parse KDL string back to manifest
|
|
## 4. Verify semantic equivalence (all fields match)
|
|
## 5. Verify determinism (same manifest = same KDL)
|
|
|
|
import std/[unittest, times, options, random, strutils]
|
|
import nip/nip_manifest
|
|
import nip/manifest_parser
|
|
|
|
# ============================================================================
|
|
# Test Generators
|
|
# ============================================================================
|
|
|
|
proc genSemanticVersion(): SemanticVersion =
|
|
## Generate random semantic version
|
|
SemanticVersion(
|
|
major: rand(0..10),
|
|
minor: rand(0..20),
|
|
patch: rand(0..50)
|
|
)
|
|
|
|
proc genAppInfo(): AppInfo =
|
|
## Generate random application metadata
|
|
AppInfo(
|
|
description: "Test application " & $rand(1000),
|
|
homepage: some("https://example.com/" & $rand(1000)),
|
|
license: ["MIT", "GPL-3.0", "Apache-2.0", "BSD-3-Clause"][rand(3)],
|
|
author: some("Test Author " & $rand(100)),
|
|
maintainer: some("Test Maintainer " & $rand(100)),
|
|
tags: @["test", "example", "app" & $rand(10)],
|
|
category: some(["Graphics", "Network", "Development", "Utility"][rand(3)])
|
|
)
|
|
|
|
proc genProvenanceInfo(): ProvenanceInfo =
|
|
## Generate random provenance information
|
|
ProvenanceInfo(
|
|
source: "https://github.com/test/repo" & $rand(1000),
|
|
sourceHash: "xxh3-" & $rand(high(int)),
|
|
upstream: some("https://upstream.example.com/" & $rand(1000)),
|
|
buildTimestamp: now(),
|
|
builder: some("builder-" & $rand(100))
|
|
)
|
|
|
|
proc genBuildConfiguration(): BuildConfiguration =
|
|
## Generate random build configuration
|
|
BuildConfiguration(
|
|
configureFlags: @["--enable-feature" & $rand(10), "--with-lib" & $rand(5)],
|
|
compilerFlags: @["-O2", "-march=native", "-flto"],
|
|
compilerVersion: "gcc-" & $rand(10..13) & ".0.0",
|
|
targetArchitecture: ["x86_64", "aarch64", "riscv64"][rand(2)],
|
|
libc: ["musl", "glibc"][rand(1)],
|
|
allocator: ["jemalloc", "tcmalloc", "default"][rand(2)],
|
|
buildSystem: ["cmake", "meson", "autotools"][rand(2)]
|
|
)
|
|
|
|
proc genChunkReference(): ChunkReference =
|
|
## Generate random CAS chunk reference
|
|
ChunkReference(
|
|
hash: "xxh3-" & $rand(high(int)),
|
|
size: rand(1024..1048576).int64,
|
|
chunkType: [Binary, Library, Runtime, Config, Data][rand(4)],
|
|
path: "bin/app" & $rand(10)
|
|
)
|
|
|
|
proc genDesktopFileSpec(): DesktopFileSpec =
|
|
## Generate random desktop file specification
|
|
DesktopFileSpec(
|
|
name: "Test App " & $rand(100),
|
|
genericName: some("Generic App " & $rand(100)),
|
|
comment: some("A test application"),
|
|
exec: "/usr/bin/testapp" & $rand(10),
|
|
icon: "testapp" & $rand(10),
|
|
terminal: rand(1) == 0,
|
|
categories: @["Graphics", "Utility"],
|
|
keywords: @["test", "example", "app"]
|
|
)
|
|
|
|
proc genIconSpec(): IconSpec =
|
|
## Generate random icon specification
|
|
IconSpec(
|
|
size: [16, 32, 48, 64, 128, 256][rand(5)],
|
|
path: "icons/icon" & $rand(10) & ".png",
|
|
format: ["png", "svg"][rand(1)]
|
|
)
|
|
|
|
proc genDesktopMetadata(): DesktopMetadata =
|
|
## Generate random desktop metadata
|
|
DesktopMetadata(
|
|
desktopFile: genDesktopFileSpec(),
|
|
icons: @[genIconSpec(), genIconSpec()],
|
|
mimeTypes: @["text/plain", "application/json"],
|
|
appId: "org.example.testapp" & $rand(100)
|
|
)
|
|
|
|
proc genFilesystemAccess(): FilesystemAccess =
|
|
## Generate random filesystem access permission
|
|
FilesystemAccess(
|
|
path: ["/home", "/tmp", "/var/cache"][rand(2)],
|
|
mode: [ReadOnly, ReadWrite, Create][rand(2)]
|
|
)
|
|
|
|
proc genDBusAccess(): DBusAccess =
|
|
## Generate random D-Bus access permissions
|
|
DBusAccess(
|
|
session: @["org.freedesktop.Notifications", "org.kde.StatusNotifierWatcher"],
|
|
system: @["org.freedesktop.NetworkManager"],
|
|
own: @["org.example.TestApp" & $rand(100)]
|
|
)
|
|
|
|
proc genMount(): Mount =
|
|
## Generate random mount specification
|
|
Mount(
|
|
source: "/host/path" & $rand(10),
|
|
target: "/app/path" & $rand(10),
|
|
mountType: [Bind, Tmpfs, Devtmpfs][rand(2)],
|
|
readOnly: rand(1) == 0
|
|
)
|
|
|
|
proc genNamespaceConfig(): NamespaceConfig =
|
|
## Generate random namespace configuration
|
|
NamespaceConfig(
|
|
namespaceType: ["user", "strict", "none"][rand(2)],
|
|
permissions: Permissions(
|
|
network: rand(1) == 0,
|
|
gpu: rand(1) == 0,
|
|
audio: rand(1) == 0,
|
|
camera: rand(1) == 0,
|
|
microphone: rand(1) == 0,
|
|
filesystem: @[genFilesystemAccess(), genFilesystemAccess()],
|
|
dbus: genDBusAccess()
|
|
),
|
|
mounts: @[genMount(), genMount()]
|
|
)
|
|
|
|
proc genSignatureInfo(): SignatureInfo =
|
|
## Generate random signature information
|
|
SignatureInfo(
|
|
algorithm: "ed25519",
|
|
keyId: "key-" & $rand(1000),
|
|
signature: "sig-" & $rand(high(int))
|
|
)
|
|
|
|
proc genNIPManifest(): NIPManifest =
|
|
## Generate random NIP manifest
|
|
NIPManifest(
|
|
name: "testapp" & $rand(1000),
|
|
version: genSemanticVersion(),
|
|
buildDate: now(),
|
|
metadata: genAppInfo(),
|
|
provenance: genProvenanceInfo(),
|
|
buildConfig: genBuildConfiguration(),
|
|
casChunks: @[genChunkReference(), genChunkReference(), genChunkReference()],
|
|
desktop: genDesktopMetadata(),
|
|
namespace: genNamespaceConfig(),
|
|
buildHash: "xxh3-" & $rand(high(int)),
|
|
signature: genSignatureInfo()
|
|
)
|
|
|
|
# ============================================================================
|
|
# Semantic Equivalence Checks
|
|
# ============================================================================
|
|
|
|
proc checkTimestampsClose(a, b: DateTime, toleranceSeconds: int = 2): bool =
|
|
## Check if two timestamps are within tolerance (for parsing precision)
|
|
let diff = abs((a - b).inSeconds)
|
|
return diff <= toleranceSeconds
|
|
|
|
proc checkAppInfoEqual(a, b: AppInfo): bool =
|
|
## Check if two AppInfo objects are semantically equivalent
|
|
result = a.description == b.description and
|
|
a.homepage == b.homepage and
|
|
a.license == b.license and
|
|
a.author == b.author and
|
|
a.maintainer == b.maintainer and
|
|
a.tags == b.tags and
|
|
a.category == b.category
|
|
|
|
proc checkProvenanceEqual(a, b: ProvenanceInfo): bool =
|
|
## Check if two ProvenanceInfo objects are semantically equivalent
|
|
result = a.source == b.source and
|
|
a.sourceHash == b.sourceHash and
|
|
a.upstream == b.upstream and
|
|
a.builder == b.builder and
|
|
checkTimestampsClose(a.buildTimestamp, b.buildTimestamp)
|
|
|
|
proc checkBuildConfigEqual(a, b: BuildConfiguration): bool =
|
|
## Check if two BuildConfiguration objects are semantically equivalent
|
|
result = a.configureFlags == b.configureFlags and
|
|
a.compilerFlags == b.compilerFlags and
|
|
a.compilerVersion == b.compilerVersion and
|
|
a.targetArchitecture == b.targetArchitecture and
|
|
a.libc == b.libc and
|
|
a.allocator == b.allocator and
|
|
a.buildSystem == b.buildSystem
|
|
|
|
proc checkChunkRefEqual(a, b: ChunkReference): bool =
|
|
## Check if two ChunkReference objects are semantically equivalent
|
|
result = a.hash == b.hash and
|
|
a.size == b.size and
|
|
a.chunkType == b.chunkType and
|
|
a.path == b.path
|
|
|
|
proc checkDesktopFileEqual(a, b: DesktopFileSpec): bool =
|
|
## Check if two DesktopFileSpec objects are semantically equivalent
|
|
result = a.name == b.name and
|
|
a.genericName == b.genericName and
|
|
a.comment == b.comment and
|
|
a.exec == b.exec and
|
|
a.icon == b.icon and
|
|
a.terminal == b.terminal and
|
|
a.categories == b.categories and
|
|
a.keywords == b.keywords
|
|
|
|
proc checkIconEqual(a, b: IconSpec): bool =
|
|
## Check if two IconSpec objects are semantically equivalent
|
|
result = a.size == b.size and
|
|
a.path == b.path and
|
|
a.format == b.format
|
|
|
|
proc checkDesktopMetadataEqual(a, b: DesktopMetadata, verbose: bool = false): bool =
|
|
## Check if two DesktopMetadata objects are semantically equivalent
|
|
if not checkDesktopFileEqual(a.desktopFile, b.desktopFile):
|
|
if verbose: echo " Desktop file mismatch"
|
|
return false
|
|
if a.icons.len != b.icons.len:
|
|
if verbose: echo " Icons length mismatch: ", a.icons.len, " vs ", b.icons.len
|
|
return false
|
|
for i in 0..<a.icons.len:
|
|
if not checkIconEqual(a.icons[i], b.icons[i]):
|
|
if verbose: echo " Icon ", i, " mismatch"
|
|
return false
|
|
if a.mimeTypes != b.mimeTypes:
|
|
if verbose: echo " MIME types mismatch: ", a.mimeTypes, " vs ", b.mimeTypes
|
|
return false
|
|
if a.appId != b.appId:
|
|
if verbose: echo " App ID mismatch: ", a.appId, " vs ", b.appId
|
|
return false
|
|
return true
|
|
|
|
proc checkFilesystemAccessEqual(a, b: FilesystemAccess): bool =
|
|
## Check if two FilesystemAccess objects are semantically equivalent
|
|
result = a.path == b.path and a.mode == b.mode
|
|
|
|
proc checkDBusAccessEqual(a, b: DBusAccess): bool =
|
|
## Check if two DBusAccess objects are semantically equivalent
|
|
result = a.session == b.session and
|
|
a.system == b.system and
|
|
a.own == b.own
|
|
|
|
proc checkMountEqual(a, b: Mount): bool =
|
|
## Check if two Mount objects are semantically equivalent
|
|
result = a.source == b.source and
|
|
a.target == b.target and
|
|
a.mountType == b.mountType and
|
|
a.readOnly == b.readOnly
|
|
|
|
proc checkNamespaceConfigEqual(a, b: NamespaceConfig): bool =
|
|
## Check if two NamespaceConfig objects are semantically equivalent
|
|
if a.namespaceType != b.namespaceType:
|
|
return false
|
|
if a.permissions.network != b.permissions.network or
|
|
a.permissions.gpu != b.permissions.gpu or
|
|
a.permissions.audio != b.permissions.audio or
|
|
a.permissions.camera != b.permissions.camera or
|
|
a.permissions.microphone != b.permissions.microphone:
|
|
return false
|
|
if a.permissions.filesystem.len != b.permissions.filesystem.len:
|
|
return false
|
|
for i in 0..<a.permissions.filesystem.len:
|
|
if not checkFilesystemAccessEqual(a.permissions.filesystem[i], b.permissions.filesystem[i]):
|
|
return false
|
|
if not checkDBusAccessEqual(a.permissions.dbus, b.permissions.dbus):
|
|
return false
|
|
if a.mounts.len != b.mounts.len:
|
|
return false
|
|
for i in 0..<a.mounts.len:
|
|
if not checkMountEqual(a.mounts[i], b.mounts[i]):
|
|
return false
|
|
result = true
|
|
|
|
proc checkSignatureEqual(a, b: SignatureInfo): bool =
|
|
## Check if two SignatureInfo objects are semantically equivalent
|
|
result = a.algorithm == b.algorithm and
|
|
a.keyId == b.keyId and
|
|
a.signature == b.signature
|
|
|
|
proc checkManifestsEqual(a, b: NIPManifest, verbose: bool = false): bool =
|
|
## Check if two NIP manifests are semantically equivalent
|
|
|
|
if a.name != b.name:
|
|
if verbose: echo "Name mismatch: ", a.name, " vs ", b.name
|
|
return false
|
|
|
|
if a.version != b.version:
|
|
if verbose: echo "Version mismatch: ", a.version, " vs ", b.version
|
|
return false
|
|
|
|
if not checkTimestampsClose(a.buildDate, b.buildDate):
|
|
if verbose: echo "BuildDate mismatch: ", a.buildDate, " vs ", b.buildDate
|
|
return false
|
|
|
|
if not checkAppInfoEqual(a.metadata, b.metadata):
|
|
if verbose: echo "Metadata mismatch"
|
|
return false
|
|
|
|
if not checkProvenanceEqual(a.provenance, b.provenance):
|
|
if verbose: echo "Provenance mismatch"
|
|
return false
|
|
|
|
if not checkBuildConfigEqual(a.buildConfig, b.buildConfig):
|
|
if verbose: echo "BuildConfig mismatch"
|
|
return false
|
|
|
|
if not checkDesktopMetadataEqual(a.desktop, b.desktop, verbose):
|
|
if verbose: echo "Desktop metadata mismatch"
|
|
return false
|
|
|
|
if not checkNamespaceConfigEqual(a.namespace, b.namespace):
|
|
if verbose: echo "Namespace config mismatch"
|
|
return false
|
|
|
|
if a.buildHash != b.buildHash:
|
|
if verbose: echo "BuildHash mismatch: ", a.buildHash, " vs ", b.buildHash
|
|
return false
|
|
|
|
if not checkSignatureEqual(a.signature, b.signature):
|
|
if verbose: echo "Signature mismatch"
|
|
return false
|
|
|
|
# Check CAS chunks
|
|
if a.casChunks.len != b.casChunks.len:
|
|
if verbose: echo "CAS chunks length mismatch: ", a.casChunks.len, " vs ", b.casChunks.len
|
|
return false
|
|
|
|
for i in 0..<a.casChunks.len:
|
|
if not checkChunkRefEqual(a.casChunks[i], b.casChunks[i]):
|
|
if verbose: echo "CAS chunk ", i, " mismatch"
|
|
return false
|
|
|
|
return true
|
|
|
|
# ============================================================================
|
|
# Property-Based Tests
|
|
# ============================================================================
|
|
|
|
suite "NIP Manifest Roundtrip Property Tests":
|
|
test "Property 3: Manifest Roundtrip - Generate then parse produces equivalent manifest":
|
|
## **Feature:** 01-nip-unified-storage-and-formats, Property 3: Manifest Roundtrip
|
|
## **Validates:** Requirements 6.4
|
|
##
|
|
## For any NIP manifest, generating KDL and parsing it back should produce
|
|
## a semantically equivalent manifest.
|
|
|
|
# Run property test with 100 random manifests
|
|
var passCount = 0
|
|
var failCount = 0
|
|
var errors: seq[string] = @[]
|
|
|
|
for i in 0..<100:
|
|
try:
|
|
# Generate random manifest
|
|
let original = genNIPManifest()
|
|
|
|
# Generate KDL
|
|
let kdl = generateNIPManifest(original)
|
|
|
|
# Parse KDL back to manifest
|
|
let parsed = parseNIPManifest(kdl)
|
|
|
|
# Check semantic equivalence
|
|
let verbose = (failCount == 0) # Show details for first failure only
|
|
if checkManifestsEqual(original, parsed, verbose):
|
|
passCount.inc()
|
|
else:
|
|
failCount.inc()
|
|
errors.add("Iteration " & $i & ": Manifests not equivalent after roundtrip")
|
|
except CatchableError as e:
|
|
failCount.inc()
|
|
errors.add("Iteration " & $i & ": " & e.msg)
|
|
|
|
# Report results
|
|
echo "\nProperty Test Results:"
|
|
echo " Passed: ", passCount, "/100"
|
|
echo " Failed: ", failCount, "/100"
|
|
|
|
if errors.len > 0:
|
|
echo "\nFirst 5 errors:"
|
|
for i in 0..<min(5, errors.len):
|
|
echo " ", errors[i]
|
|
|
|
# Property should hold for all cases
|
|
check passCount == 100
|
|
|
|
test "Property 3: Manifest Roundtrip - Determinism (same manifest = same KDL)":
|
|
## **Feature:** 01-nip-unified-storage-and-formats, Property 3: Manifest Roundtrip
|
|
## **Validates:** Requirements 6.4
|
|
##
|
|
## For any NIP manifest, generating KDL twice should produce identical output.
|
|
|
|
var passCount = 0
|
|
var failCount = 0
|
|
|
|
for i in 0..<100:
|
|
try:
|
|
# Generate random manifest
|
|
let manifest = genNIPManifest()
|
|
|
|
# Generate KDL twice
|
|
let kdl1 = generateNIPManifest(manifest)
|
|
let kdl2 = generateNIPManifest(manifest)
|
|
|
|
# Check they're identical
|
|
if kdl1 == kdl2:
|
|
passCount.inc()
|
|
else:
|
|
failCount.inc()
|
|
echo "Iteration ", i, ": KDL output not deterministic"
|
|
except CatchableError as e:
|
|
failCount.inc()
|
|
echo "Iteration ", i, ": ", e.msg
|
|
|
|
echo "\nDeterminism Test Results:"
|
|
echo " Passed: ", passCount, "/100"
|
|
echo " Failed: ", failCount, "/100"
|
|
|
|
check passCount == 100
|
|
|
|
test "Property 3: Manifest Roundtrip - Validation catches invalid manifests":
|
|
## Verify that validation catches common errors
|
|
|
|
# Test empty name
|
|
var manifest = genNIPManifest()
|
|
manifest.name = ""
|
|
let issues1 = validateNIPManifest(manifest)
|
|
check issues1.len > 0
|
|
check "name cannot be empty" in issues1[0].toLowerAscii()
|
|
|
|
# Test invalid hash format
|
|
manifest = genNIPManifest()
|
|
manifest.buildHash = "invalid-hash"
|
|
let issues2 = validateNIPManifest(manifest)
|
|
check issues2.len > 0
|
|
check "xxh3" in issues2[0].toLowerAscii()
|
|
|
|
# Test invalid namespace type
|
|
manifest = genNIPManifest()
|
|
manifest.namespace.namespaceType = "invalid"
|
|
let issues3 = validateNIPManifest(manifest)
|
|
check issues3.len > 0
|
|
check "namespace type" in issues3[0].toLowerAscii()
|
|
|
|
# Test empty app_id
|
|
manifest = genNIPManifest()
|
|
manifest.desktop.appId = ""
|
|
let issues4 = validateNIPManifest(manifest)
|
|
check issues4.len > 0
|
|
check "app_id" in issues4[0].toLowerAscii()
|
|
|
|
when isMainModule:
|
|
# Run tests
|
|
randomize()
|
|
echo "Running NIP Manifest Roundtrip Property Tests..."
|
|
echo "Testing Property 3: Manifest Roundtrip"
|
|
echo "Validates: Requirements 6.4"
|
|
echo ""
|