diff --git a/pyoaev/contracts/contract_config.py b/pyoaev/contracts/contract_config.py index cc39dc0..1fead7d 100644 --- a/pyoaev/contracts/contract_config.py +++ b/pyoaev/contracts/contract_config.py @@ -65,6 +65,53 @@ class ContractOutputType(str, Enum): KerberoastableAccount: str = "kerberoastable_account" +class PrimitiveType(str, Enum): + """The semantic type of a contract argument's value, independent of how the + field renders in the UI (see ``ContractFieldType``) and independent of + chaining specifically — an argument can carry a ``PrimitiveType`` and be + filled by hand, exactly as it can be auto-linked from a prior step's + matching output. Mirrors ``io.openaev.database.model.PrimitiveType`` + (the openaev platform's own enum) label-for-label; every value here must + stay in sync with it. + """ + + AccountWithPasswordNotRequired: str = "account_with_password_not_required" + ActionOutput: str = "action_output" + AdminUsername: str = "admin_username" + AsreproastableAccount: str = "asreproastable_account" + AssetGroupId: str = "asset_group_id" + AssetId: str = "asset_id" + ComputerName: str = "computer_name" + CVE: str = "cve" + DelegationAccount: str = "delegation_account" + Document: str = "document" + Domain: str = "domain" + FileName: str = "file_name" + FilePath: str = "file_path" + GroupName: str = "group_name" + Hash: str = "hash" + Host: str = "host" + IPv4: str = "ipv4" + IPv6: str = "ipv6" + IpSubnet: str = "ip_subnet" + KerberoastableAccount: str = "kerberoastable_account" + Key: str = "key" + Number: str = "number" + Password: str = "password" + Permissions: str = "permissions" + Port: str = "port" + Service: str = "service" + Severity: str = "severity" + ShareName: str = "share_name" + SID: str = "sid" + TargetedAsset: str = "targeted-asset" + Text: str = "text" + Username: str = "username" + Value: str = "value" + VulnerabilityName: str = "vulnerability_name" + VulnerabilityStatus: str = "vulnerability_status" + + class ExpectationType(str, Enum): text: str = "TEXT" document: str = "DOCUMENT" @@ -131,6 +178,15 @@ class ContractElement(ABC): linkedFields: List[str] = field(default_factory=list) mandatory: bool = False readOnly: bool = False + # The argument's chaining/semantic type (``PrimitiveType``), e.g. "username" + # or "host" — matches the platform's own ``argumentType`` field + # (``io.openaev.database.model.ContractElement``) label-for-label so a + # contract pushed from here needs no translation on the other end. Left + # unset (``None``) for fields with no established type: the platform + # normalizes a missing/null/empty value to ``PrimitiveType.Text`` on its + # own, so omitting this is always safe and never a breaking change for + # existing contracts. + argumentType: Optional[PrimitiveType] = None @property @abstractmethod diff --git a/test/contracts/test_contract_element_argument_type.py b/test/contracts/test_contract_element_argument_type.py new file mode 100644 index 0000000..6f8ddc4 --- /dev/null +++ b/test/contracts/test_contract_element_argument_type.py @@ -0,0 +1,41 @@ +import json +import unittest + +from pyoaev import utils +from pyoaev.contracts.contract_config import ContractText, PrimitiveType + + +def _serialize(field): + return json.loads(json.dumps(field, cls=utils.EnhancedJSONEncoder)) + + +class ContractElementArgumentTypeTest(unittest.TestCase): + def test_defaults_to_none_when_not_declared(self): + """Existing contracts that never pass argumentType keep working + unchanged — the platform normalizes a null value to PrimitiveType.Text + on its own, so this is not a breaking change for anything already + deployed.""" + untyped = ContractText(key="uri", label="URL") + self.assertIsNone(untyped.argumentType) + self.assertIsNone(_serialize(untyped)["argumentType"]) + + def test_explicit_argument_type_round_trips(self): + typed = ContractText( + key="basicUser", label="Username", argumentType=PrimitiveType.Username + ) + self.assertEqual(typed.argumentType, PrimitiveType.Username) + self.assertEqual(_serialize(typed)["argumentType"], "username") + + def test_argument_type_is_independent_of_widget_type(self): + """A field's rendering (ContractFieldType, on `type`) and its chaining + semantics (PrimitiveType, on `argumentType`) are separate axes.""" + typed = ContractText( + key="basicUser", label="Username", argumentType=PrimitiveType.Username + ) + serialized = _serialize(typed) + self.assertEqual(serialized["type"], "text") + self.assertEqual(serialized["argumentType"], "username") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/contracts/test_primitive_type.py b/test/contracts/test_primitive_type.py new file mode 100644 index 0000000..fbabacf --- /dev/null +++ b/test/contracts/test_primitive_type.py @@ -0,0 +1,77 @@ +import unittest + +from pyoaev.contracts.contract_config import PrimitiveType + + +class PrimitiveTypeTest(unittest.TestCase): + def test_username_wire_label(self): + # The wire label is a public contract shared with the platform enum + # (io.openaev.database.model.PrimitiveType.Username); it must stay + # exactly "username". + self.assertEqual(PrimitiveType.Username.value, "username") + self.assertEqual(PrimitiveType.Username, "username") + + def test_host_wire_label(self): + self.assertEqual(PrimitiveType.Host.value, "host") + self.assertEqual(PrimitiveType.Host, "host") + + def test_text_wire_label(self): + # The platform's own normalizer defaults a missing argumentType to + # exactly this value — it must stay "text". + self.assertEqual(PrimitiveType.Text.value, "text") + self.assertEqual(PrimitiveType.Text, "text") + + def test_action_output_wire_label(self): + # Shared with ContractOutputType.ActionOutput; both enums describe the + # same platform-side value from two different angles (an output + # producing it vs. an input typed to receive it) and must not drift + # apart. + self.assertEqual(PrimitiveType.ActionOutput.value, "action_output") + + def test_every_value_matches_the_openaev_platform_enum(self): + # io.openaev.database.model.PrimitiveType, transcribed label-for-label. + # Keep this set in sync with that file, not just the four spot-checked + # above. + expected_labels = { + "account_with_password_not_required", + "action_output", + "admin_username", + "asreproastable_account", + "asset_group_id", + "asset_id", + "computer_name", + "cve", + "delegation_account", + "document", + "domain", + "file_name", + "file_path", + "group_name", + "hash", + "host", + "ipv4", + "ipv6", + "ip_subnet", + "kerberoastable_account", + "key", + "number", + "password", + "permissions", + "port", + "service", + "severity", + "share_name", + "sid", + "targeted-asset", + "text", + "username", + "value", + "vulnerability_name", + "vulnerability_status", + } + actual_labels = {member.value for member in PrimitiveType} + self.assertEqual(actual_labels, expected_labels) + + +if __name__ == "__main__": + unittest.main()