Skip to content

Commit

Permalink
tests: snapshots and complete deep comparison, instead of pseudo-comp…
Browse files Browse the repository at this point in the history
…are (#464)

part of #437
also fixed a bug: unused first level dependencies were not detected. now they are.

---------

Signed-off-by: Jan Kowalleck <[email protected]>
  • Loading branch information
jkowalleck authored Oct 8, 2023
1 parent a68ae24 commit 7543789
Show file tree
Hide file tree
Showing 239 changed files with 8,322 additions and 9,984 deletions.
4 changes: 3 additions & 1 deletion cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,9 @@ def validate(self) -> bool:
# 1. Make sure dependencies are all in this Bom.
all_bom_refs = set(map(lambda c: c.bom_ref, self._get_all_components())) | set(
map(lambda s: s.bom_ref, self.services))
all_dependency_bom_refs = set().union(*(d.dependencies_as_bom_refs() for d in self.dependencies))
all_dependency_bom_refs = set(chain((d.ref for d in self.dependencies),
chain.from_iterable(
d.dependencies_as_bom_refs() for d in self.dependencies)))

dependency_diff = all_dependency_bom_refs - all_bom_refs
if len(dependency_diff) > 0:
Expand Down
5 changes: 1 addition & 4 deletions cyclonedx/model/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from enum import Enum
from os.path import exists
from typing import Any, Iterable, Optional, Set, Union
from uuid import uuid4

# See https://github.com/package-url/packageurl-python/issues/65
import serializable
Expand Down Expand Up @@ -769,7 +768,7 @@ def __init__(self, *, name: str, type: ComponentType = ComponentType.LIBRARY,
if isinstance(bom_ref, BomRef):
self._bom_ref = bom_ref
else:
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else str(uuid4()))
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else None)
self.supplier = supplier
self.author = author
self.publisher = publisher
Expand Down Expand Up @@ -809,8 +808,6 @@ def __init__(self, *, name: str, type: ComponentType = ComponentType.LIBRARY,
if not licenses:
self.licenses = [LicenseChoice(expression=license_str)] # type: ignore

self.__dependencies: "SortedSet[BomRef]" = SortedSet()

@property
@serializable.xml_attribute()
def type(self) -> ComponentType:
Expand Down
2 changes: 1 addition & 1 deletion cyclonedx/model/dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,4 @@ class Dependable(ABC):
@property
@abstractmethod
def bom_ref(self) -> BomRef:
pass
...
3 changes: 1 addition & 2 deletions cyclonedx/model/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
# Copyright (c) OWASP Foundation. All Rights Reserved.

from typing import Any, Iterable, Optional, Union
from uuid import uuid4

import serializable
from sortedcontainers import SortedSet
Expand Down Expand Up @@ -66,7 +65,7 @@ def __init__(self, *, name: str, bom_ref: Optional[Union[str, BomRef]] = None,
if isinstance(bom_ref, BomRef):
self._bom_ref = bom_ref
else:
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else str(uuid4()))
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else None)
self.provider = provider
self.group = group
self.name = name
Expand Down
3 changes: 1 addition & 2 deletions cyclonedx/model/vulnerability.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from decimal import Decimal
from enum import Enum
from typing import Any, Iterable, Optional, Tuple, Union
from uuid import uuid4

import serializable
from sortedcontainers import SortedSet
Expand Down Expand Up @@ -837,7 +836,7 @@ def __init__(self, *, bom_ref: Optional[Union[str, BomRef]] = None, id: Optional
if isinstance(bom_ref, BomRef):
self._bom_ref = bom_ref
else:
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else str(uuid4()))
self._bom_ref = BomRef(value=str(bom_ref) if bom_ref else None)
self.id = id
self.source = source
self.references = references or [] # type: ignore
Expand Down
3 changes: 3 additions & 0 deletions cyclonedx/schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ class OutputFormat(Enum):
def __hash__(self) -> int:
return hash(self.name)

def __eq__(self, other: Any) -> bool:
return self is other


_SV = TypeVar('_SV', bound='SchemaVersion')

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ keywords = [
# ATTENTION: keep `deps.lowest.r` file in sync
python = "^3.8"
packageurl-python = ">= 0.11"
py-serializable = "^0.14.0"
py-serializable = "^0.14.1"
sortedcontainers = "^2.4.0"
license-expression = "^30"
jsonschema = { version = "^4.18", extras=['format'], optional=true }
Expand Down
132 changes: 130 additions & 2 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,137 @@
# Copyright (c) OWASP Foundation. All Rights Reserved.

from os import getenv, path
from os.path import join
from typing import TYPE_CHECKING, Any, Generator, Iterable, List, Optional, TypeVar, Union
from unittest import TestCase
from uuid import UUID

TESTDATA_DIRECTORY = path.join(path.dirname(__file__), '_data')
from sortedcontainers import SortedSet

RECREATE_SNAPSHOTS = bool(getenv('CDX_TEST_RECREATE_SNAPSHOTS'))
from cyclonedx.schema import OutputFormat, SchemaVersion

if TYPE_CHECKING:
from cyclonedx.model.bom import Bom
from cyclonedx.model.dependency import Dependency

_T = TypeVar('_T')

_TESTDATA_DIRECTORY = path.join(path.dirname(__file__), '_data')

SCHEMA_TESTDATA_DIRECTORY = path.join(_TESTDATA_DIRECTORY, 'schemaTestData')
OWN_DATA_DIRECTORY = path.join(_TESTDATA_DIRECTORY, 'own')
SNAPSHOTS_DIRECTORY = path.join(_TESTDATA_DIRECTORY, 'snapshots')

RECREATE_SNAPSHOTS = '1' == getenv('CDX_TEST_RECREATE_SNAPSHOTS')
if RECREATE_SNAPSHOTS:
print('!!! WILL RECREATE ALL SNAPSHOTS !!!')


class SnapshotMixin:

@staticmethod
def getSnapshotFile(snapshot_name: str) -> str:
return join(SNAPSHOTS_DIRECTORY, f'{snapshot_name}.bin')

@classmethod
def writeSnapshot(cls, snapshot_name: str, data: str) -> None:
with open(cls.getSnapshotFile(snapshot_name), 'w') as s:
s.write(data)

@classmethod
def readSnapshot(cls, snapshot_name: str) -> str:
with open(cls.getSnapshotFile(snapshot_name), 'r') as s:
return s.read()

def assertEqualSnapshot(self: Union[TestCase, 'SnapshotMixin'], actual: str, snapshot_name: str) -> None:
if RECREATE_SNAPSHOTS:
self.writeSnapshot(snapshot_name, actual)
_omd = self.maxDiff
_omd = self.maxDiff
self.maxDiff = None
try:
self.assertEqual(actual, self.readSnapshot(snapshot_name))
finally:
self.maxDiff = _omd


class DeepCompareMixin:
def assertDeepEqual(self: Union[TestCase, 'DeepCompareMixin'], first: Any, second: Any,
msg: Optional[str] = None) -> None:
"""costly compare, but very verbose"""
_omd = self.maxDiff
self.maxDiff = None
try:
self.maxDiff = None
dd1 = self.__deepDict(first)
dd2 = self.__deepDict(second)
self.assertDictEqual(dd1, dd2, msg)
finally:
self.maxDiff = _omd

def __deepDict(self, o: Any) -> Any:
if isinstance(o, tuple):
return tuple(self.__deepDict(i) for i in o)
if isinstance(o, list):
return list(self.__deepDict(i) for i in o)
if isinstance(o, dict):
return {k: self.__deepDict(v) for k, v in o.items()}
if isinstance(o, (set, SortedSet)):
# this method returns dict. `dict` is not hashable, so use `tuple` instead.
return tuple(self.__deepDict(i) for i in sorted(o, key=hash)) + ('%conv:%set',)
if hasattr(o, '__dict__'):
d = {a: self.__deepDict(v) for a, v in o.__dict__.items() if '__' not in a}
d['%conv'] = str(type(o))
return d
return o

def assertBomDeepEqual(self: Union[TestCase, 'DeepCompareMixin'], expected: 'Bom', actual: 'Bom',
msg: Optional[str] = None, *,
fuzzy_deps: bool = True) -> None:
# deps might have been upgraded on serialization, so they might differ
edeps = expected.dependencies
adeps = actual.dependencies
if fuzzy_deps:
expected.dependencies = []
actual.dependencies = []
try:
self.assertDeepEqual(expected, actual, msg)
if fuzzy_deps:
self._assertDependenciesFuzzyEqual(edeps, adeps)
finally:
expected.dependencies = edeps
actual.dependencies = adeps

def _assertDependenciesFuzzyEqual(self: TestCase, a: Iterable['Dependency'], b: Iterable['Dependency']) -> None:
delta = set(a) ^ set(b)
for d in delta:
# only actual relevant dependencies shall be taken into account.
self.assertEqual(0, len(d.dependencies), f'unexpected dependencies for {d.ref}')


def reorder(items: List[_T], indexes: List[int]) -> List[_T]:
"""
Return list of items in the order indicated by indexes.
"""
reordered_items = []
for i in range(len(items)):
reordered_items.append(items[indexes[i]])
return reordered_items


def uuid_generator(offset: int = 0, version: int = 4) -> Generator[UUID, None, None]:
v = offset
while True:
v += 1
yield UUID(int=v, version=version)


_SNAME_EXT = {
OutputFormat.JSON: 'json',
OutputFormat.XML: 'xml',
}


def mksname(purpose: Union[Any], sv: SchemaVersion, f: OutputFormat) -> str:
purpose = purpose if isinstance(purpose, str) else purpose.__name__
return f'{purpose}-{sv.to_version()}.{_SNAME_EXT[f]}'
18 changes: 18 additions & 0 deletions tests/_data/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# encoding: utf-8

# This file is part of CycloneDX Python Lib
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.
Loading

0 comments on commit 7543789

Please sign in to comment.