Skip to content

Commit 2b771aa

Browse files
authored
Merge pull request #1 from jn9e9/initial-dev
Initial stab at test generator
2 parents eda5b19 + 650d72f commit 2b771aa

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+6752
-1
lines changed

.github/workflows/build.yml

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Python build
2+
3+
on: [push]
4+
5+
jobs:
6+
build:
7+
8+
runs-on: ubuntu-latest
9+
strategy:
10+
matrix:
11+
# pyyaml version we use only supporting python 3.6 upwards
12+
python-version: [ 3.6, 3.7, 3.8]
13+
14+
steps:
15+
- uses: actions/checkout@v2
16+
- name: Set up Python ${{ matrix.python-version }}
17+
uses: actions/setup-python@v2
18+
with:
19+
python-version: ${{ matrix.python-version }}
20+
- name: Install dependencies
21+
run: |
22+
python -m pip install --upgrade pip
23+
pip install flake8 pytest
24+
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
25+
- name: Lint with flake8
26+
run: |
27+
# stop the build if there are Python syntax errors or undefined names
28+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude generator/protobuf
29+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
30+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --exclude generator/protobuf
31+
# - name: Test with pytest
32+
# run: |
33+
# pytest

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
/target
22
*patch
3+
.devcontainer
4+
.vscode

.gitmodules

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "parsec-operations"]
2+
path = parsec-operations
3+
url = https://github.com/parallaxsecond/parsec-operations

Makefile

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Copyright 2021 Contributors to the Parsec project.
2+
# SPDX-License-Identifier: Apache-2.0
3+
PROTOC_OUTPUT_FILES=$(shell find parsec-operations/protobuf/ -name "*.proto" -exec basename {} .proto \; | awk '{print "generator/protobuf/"$$1"_pb2.py"}')
4+
.PHONY: protobuf all
5+
6+
all: protobuf
7+
8+
protobuf: ${PROTOC_OUTPUT_FILES}
9+
10+
generator/protobuf/%_pb2.py: parsec-operations/protobuf/%.proto
11+
@protoc -I=parsec-operations/protobuf --python_out=generator/protobuf $< > /dev/null

README.md

+156-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,164 @@
11
# Parsec Mock
22

3-
This repository contains a mock service to test clients.
3+
This repository contains a mock service to test clients, and a test data generator to create test data files for the mock service, or to be used in testing of the parsec service, or parsec clients.
4+
45
See [this issue](https://github.com/parallaxsecond/parsec/issues/350) which describes the
56
proposal.
67

8+
# Prerequisites
9+
To run the utilities in this repository, you will need python3 and pip installing.
10+
11+
To install package prerequisites, run the following from this folder:
12+
13+
```bash
14+
pip install -r requirements.txt
15+
```
16+
# Usage
17+
## Run Mock Service
18+
**TBD**
19+
20+
## Generate Test Data
21+
To generate test data from test specs, run
22+
23+
```
24+
python generator/generator.py
25+
```
26+
27+
This will parse the test specs in generator/testspecs and create test data in testdata/.
28+
29+
# Test Data
30+
The generator/generator.py script creates test data files in the testdata folder. These test data files are intended for use, either with the mock service supplied here, or in other mock services that may be developed for the parsec service or parsec clients. The test data files consist of the [test spec](#test-spec-format) (useful for the writer of a unit test), and test data, which contains base 64 encodes strings for a parsec request and a corresponding result.
31+
32+
The overall test data file format is, therefore:
33+
34+
```yaml
35+
spec:
36+
# for format see below
37+
test_data:
38+
request: EKfAXh4AAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAA
39+
result: EKfAXh4AAQAAAAAAAAAAAAAAAAAAAAIAAAAAAAEAAAAAAAAACAE=
40+
```
41+
## Expected Use Of Test Data
42+
This repository does not dictate how the test data should be used by parsec client developers, but it was developed with the following use case in mind:
43+
44+
A client developer wants to test a parsec request in their client.
45+
- They select an appropriate test data file that fulfils the needs of their test (or creates a new one in this repository and uses that)
46+
- They write client code that should generate a message to match the request section of the [test spec](#test-spec-format).
47+
- They configure a mock service (the one here or a custom one) to expect the data defined in the test_data.request part of the test data file as well as the data to send if that request is received and what to send if it is not.
48+
- They stimulate their client under test to send the request message to the mock service.
49+
- The mock service will check to see if the request message it received matches the expected value (this is an exact byte match).
50+
- If the match was successful, then it will return the configured match response
51+
- Otherwise, it will return the configured non-match response
52+
- The client under test will attempt to decode the response. The results can be checked by the test code.
53+
- If the spec.response.parse_should_succeed field is set to true, then the test code *should* expect a successful parse of the response data. Otherwise, it *should* expect the parsing to fail.
54+
55+
56+
## Writing Tests
57+
To write a new test, a developer should create a test spec file in the generator/testspecs folder, using the [format below](#test-spec-format).
58+
59+
They then need to write a generator function in ```generator/generator_lib.py```. This generator function takes no arguments, and should output a tuple of python binary strings.
60+
61+
The first element of the tuple should be the protocol buffer encoded value of the request's *operation* message that is described in the spec.request.body_description field of the test spec. The second element of the tuple should be the protocol buffer encoded *result* value of the response message that is described in the spec.result.body_description field of the test spec.
62+
63+
An example generator for a list opcodes test is shown below:
64+
65+
```python
66+
def gen_list_opcodes_auth_direct():
67+
op = protobuf.list_opcodes_pb2.Operation()
68+
op.provider_id = 1
69+
70+
result = protobuf.list_opcodes_pb2.Result()
71+
result.opcodes.extend([1,3,2])
72+
return (op.SerializeToString(), result.SerializeToString())
73+
```
74+
The generated protocol buffers python library files are in generator/protobuf.
75+
76+
Finally, the developer should add an entry to the generator dictionary at the bottom of the ```generator/generator_lib.py``` file. The key must correspond to the value in the test spec's spec.generator field. The value in the generator dictionary must be the new generator function name. e.g.:
77+
78+
```python
79+
generators = {
80+
'ping_no_auth': gen_ping_no_auth,
81+
'list_opcodes_auth_direct': gen_list_opcodes_auth_direct,
82+
}
83+
```
84+
**NOTE** A generator function can be used in multiple test specs, if required.
85+
86+
## Test Spec Format
87+
88+
Test specs are defined as YAML. An example file is shown below:
89+
90+
```yaml
91+
# Copyright 2021 Contributors to the Parsec project.
92+
# SPDX-License-Identifier: Apache-2.0
93+
spec:
94+
name: list_opcodes_auth_direct
95+
generator: list_opcodes_auth_direct
96+
description: List opcodes using direct authentication
97+
request:
98+
header:
99+
magic_number: 0x5EC0A710
100+
header_size: 0x1E
101+
major_version_number: 0x01
102+
minor_version_number: 0x00
103+
flags: 0x0000
104+
provider: 0x00
105+
session_handle: 0x0000000000000000
106+
content_type: 0x00
107+
accept_type: 0x00
108+
auth_type: 0x00
109+
content_length: auto
110+
auth_length: auto
111+
opcode: 0x00000009
112+
status: 0x0000
113+
body_description: provider id 0x01
114+
auth:
115+
type: direct
116+
app_name: jimbob
117+
result:
118+
header:
119+
magic_number: 0x5EC0A710
120+
header_size: 0x1E
121+
major_version_number: 0x01
122+
minor_version_number: 0x00
123+
flags: 0x0000
124+
provider: 0x00
125+
session_handle: 0x0000000000000000
126+
content_type: 0x00
127+
accept_type: 0x00
128+
auth_type: 0x00
129+
content_length: auto
130+
auth_length: auto
131+
opcode: 0x00000009
132+
status: 0x0000
133+
body_description: list opcodes result [1,3,2]
134+
auth:
135+
type: direct
136+
app_name: jimbob
137+
138+
```
139+
The whole test spec is defined in the spec: object in the file. In that spec, the fields are:
140+
| Field | Description |
141+
| --- | --- |
142+
| name | The name of the test spec. Used to name the output test data file |
143+
| generator | The lookup for the operation and result generator in the generator library. See [writing tests](#writing-tests).|
144+
| description | Description of test, not used in generation |
145+
| request | Settings for configuring the request data for the test data file |
146+
| request.header | Field values for the request header. Meanings of these files and valid values can be found in the [parsec book](https://parallaxsecond.github.io/parsec-book/parsec_client/wire_protocol.html). |
147+
| request.header.content_length | If this field has the value ```auto``` then its value is calculated from the generated request content. If the value is a number, then that value is used in the field instead. |
148+
| request.header.auth_length | If this field has the value ```auto``` then its value is calculated from the generated auth content. If the value is a number, then that value is used in the field instead. |
149+
| request.body_description | A free form description of the contents of the request content. Used for test writers (and generator writers) to understand how to construct the request object. This field is not used by the test generator. |
150+
| request.auth.type | This can either be ```none```, which will cause the auth section of the message to be empty (equivalent of the No Authentication type of authentication); or it can be ```direct```, which will cause the auth section of the message to contain authentication data corresponding to the format for Direct Authentication. If ```direct``` is set, then the request.auth.app_name field must be set. Note that this field does not cause the header auth_type field to be set. |
151+
| request.auth.app_name | Used to populate the auth section of the message when ```direct``` authentication is selected |
152+
| response | Settings for configuration the response data for the test data file |
153+
| response.header | Field values for the response header. Meanings of these files and valid values can be found in the [parsec book](https://parallaxsecond.github.io/parsec-book/parsec_client/wire_protocol.html). |
154+
| response.header.content_length | If this field has the value ```auto``` then its value is calculated from the generated response content. If the value is a number, then that value is used in the field instead |
155+
| response.header.auth_length | If this field has the value ```auto``` then its value is calculated from the generated auth content. If the value is a number, then that value is used in the field instead. |
156+
| response.body_description | A free form description of the contents of the response content. Used for test writers (and generator writers) to understand how to construct the result object. This field is not used by the test generator. |
157+
| response.auth.type | This can either be ```none```, which will cause the auth section of the message to be empty (equivalent of the No Authentication type of authentication); or it can be ```direct```, which will cause the auth section of the message to contain authentication data corresponding to the format for Direct Authentication. If ```direct``` is set, then the result.auth.app_name field must be set. Note that this field does not cause the header auth_type field to be set. **NOTE:** A Parsec client would not send authentication data in a result message, but this spec format allows test authors to create the message as they wish to excersice the parsec client code. |
158+
| response.auth.app_name | Used to populate the auth section of the message when ```direct``` authentication is selected |
159+
160+
161+
7162
# License
8163

9164
The software is provided under Apache-2.0. Contributions to this project are accepted under the same

generator/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__

generator/__init__.py

Whitespace-only changes.

generator/gen_list_operations.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2021 Contributors to the Parsec project.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import protobuf.ping_pb2
4+
import protobuf.list_opcodes_pb2
5+
6+
7+
def gen_list_opcodes_auth_direct():
8+
operation = protobuf.list_opcodes_pb2.Operation()
9+
operation.provider_id = 1
10+
11+
result = protobuf.list_opcodes_pb2.Result()
12+
result.opcodes.extend([1, 3, 2])
13+
return (operation.SerializeToString(), result.SerializeToString())

generator/gen_ping_operations.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2021 Contributors to the Parsec project.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import protobuf.ping_pb2
4+
import protobuf.list_opcodes_pb2
5+
6+
7+
def gen_ping_no_auth():
8+
op = protobuf.ping_pb2.Operation()
9+
result = protobuf.ping_pb2.Result()
10+
result.wire_protocol_version_maj = 1
11+
result.wire_protocol_version_min = 0
12+
13+
return (op.SerializeToString(), result.SerializeToString())

generator/generator.py

+143
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright 2021 Contributors to the Parsec project.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import os
4+
from os import listdir
5+
from os.path import isfile, join
6+
7+
from yaml import safe_load, dump
8+
9+
from struct import pack
10+
import base64
11+
12+
from generator_lib import generators
13+
14+
15+
class TestSpec(object):
16+
"""Class to represent a test specification. Used to convert
17+
dictionary created by pyaml to object format, making code easier to read."""
18+
19+
def __init__(self, dictionary):
20+
def _traverse(key, element):
21+
if isinstance(element, dict):
22+
return key, TestSpec(element)
23+
else:
24+
return key, element
25+
26+
objd = dict(_traverse(k, v) for k, v in dictionary.items())
27+
self.__dict__.update(objd)
28+
self.basedict = dictionary
29+
30+
def is_valid(self):
31+
return True
32+
33+
34+
def read_specs(folder):
35+
"""Read test specs from a folder"""
36+
specfiles = [f for f in listdir(folder) if isfile(join(folder, f))]
37+
specs = []
38+
for file in specfiles:
39+
print(f"Parsing spec file: {file}")
40+
with open(os.path.join(folder, file), 'r') as f:
41+
spec = safe_load(f)
42+
testspec = TestSpec(spec["spec"])
43+
if testspec.is_valid():
44+
specs.append(testspec)
45+
else:
46+
print(f"Error loading test spec from {file}")
47+
return specs
48+
49+
50+
def generate_data(specs, output_folder):
51+
"""Generate test data for a list of specs"""
52+
for spec in specs:
53+
if spec.generator in generators:
54+
print(f"Generating test {spec.name}")
55+
generate_spec_data(output_folder, spec, generators[spec.generator])
56+
else:
57+
print(f"No generator function found for spec {spec.name}, skipping...")
58+
59+
60+
def generate_spec_data(output_folder, spec, generator_fn):
61+
"""Generates data for a single spec and outputs it into the specified output folder."""
62+
(operation, result) = generator_fn()
63+
64+
request_auth = create_auth(spec.request.auth)
65+
request_content_len = spec.request.header.content_length
66+
if request_content_len == 'auto':
67+
request_content_len = len(operation)
68+
69+
request_auth_len = spec.request.header.auth_length
70+
if request_auth_len == 'auto':
71+
request_auth_len = len(request_auth)
72+
request_header = pack_header(spec.request.header, request_auth_len, request_content_len)
73+
74+
response_content_len = spec.response.header.content_length
75+
if response_content_len == 'auto':
76+
response_content_len = len(result)
77+
78+
response_auth_len = spec.response.header.auth_length
79+
80+
response_header = pack_header(spec.response.header, response_auth_len, response_content_len)
81+
82+
request_buf = request_header + operation + request_auth
83+
response_buf = response_header + result
84+
85+
out_data = {
86+
"spec": spec.basedict,
87+
"test_data": {
88+
"request": base64.b64encode(request_buf).decode('ascii'),
89+
"response": base64.b64encode(response_buf).decode('ascii'),
90+
}
91+
}
92+
out_path = os.path.join(output_folder, spec.name + ".test.yaml")
93+
print(f"Writing spec {spec.name} test data to {out_path}")
94+
with open(out_path, 'w') as f:
95+
dump(out_data, f, sort_keys=False)
96+
97+
98+
def pack_header(header, auth_len, body_len):
99+
"""Take a header data structure and convert it into binary representation."""
100+
# pack function converts arguments into binary string, based on format string.
101+
# < means integers are little endian. Rest of format string is one character per input to indicate
102+
# packed field interpretation. See struct.pack docs for details.
103+
return pack('<IHBBHBQBBBIHIHH',
104+
header.magic_number,
105+
header.header_size,
106+
header.major_version_number,
107+
header.minor_version_number,
108+
header.flags,
109+
header.provider,
110+
header.session_handle,
111+
header.content_type,
112+
header.accept_type,
113+
header.auth_type,
114+
body_len,
115+
auth_len,
116+
header.opcode,
117+
header.status,
118+
0
119+
)
120+
121+
122+
def create_auth(auth_spec):
123+
"""Creates auth body of message"""
124+
if auth_spec.type == 'none':
125+
return b''
126+
if auth_spec.type == 'direct':
127+
return auth_spec.app_name.encode('utf-8')
128+
return b''
129+
130+
131+
def main():
132+
specdir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'testspecs'))
133+
datadir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../testdata'))
134+
print("Generating test data.")
135+
print(f"Reading test specs from {specdir}")
136+
print(f"Generating test data to {datadir}")
137+
specs = read_specs(specdir)
138+
139+
generate_data(specs, datadir)
140+
141+
142+
if __name__ == "__main__":
143+
main()

0 commit comments

Comments
 (0)