Code coverage for SonarCloud (#150)
* cleanup code and unit tests * add test coverage for SonarCloud * configure SonarCloud * update changelog
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
[run]
|
[run]
|
||||||
branch = True
|
branch = True
|
||||||
|
relative_files = True
|
||||||
22
.github/workflows/python-app.yml
vendored
22
.github/workflows/python-app.yml
vendored
@@ -8,7 +8,7 @@ on:
|
|||||||
branches: [ "main", "dev-*", "*/issue*" ]
|
branches: [ "main", "dev-*", "*/issue*" ]
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**.md' # Do no build on *.md changes
|
- '**.md' # Do no build on *.md changes
|
||||||
- '**.yml' # Do no build on *.yml changes
|
# - '**.yml' # Do no build on *.yml changes
|
||||||
- '**.yaml' # Do no build on *.yaml changes
|
- '**.yaml' # Do no build on *.yaml changes
|
||||||
- '**.yuml' # Do no build on *.yuml changes
|
- '**.yuml' # Do no build on *.yuml changes
|
||||||
- '**.svg' # Do no build on *.svg changes
|
- '**.svg' # Do no build on *.svg changes
|
||||||
@@ -18,10 +18,11 @@ on:
|
|||||||
- '**.dockerfile' # Do no build on *.dockerfile changes
|
- '**.dockerfile' # Do no build on *.dockerfile changes
|
||||||
- '**.sh' # Do no build on *.sh changes
|
- '**.sh' # Do no build on *.sh changes
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: [ "main", "dev-*" ]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
pull-requests: read # allows SonarCloud to decorate PRs with analysis results
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -53,4 +54,19 @@ jobs:
|
|||||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
run: |
|
run: |
|
||||||
python -m pytest app
|
pip install pytest pytest-cov
|
||||||
|
#pytest app --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html
|
||||||
|
python -m pytest app --cov=app/src --cov-report=xml
|
||||||
|
- name: Analyze with SonarCloud
|
||||||
|
uses: SonarSource/sonarcloud-github-action@v2.2.0
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
with:
|
||||||
|
projectBaseDir: .
|
||||||
|
args:
|
||||||
|
-Dsonar.projectKey=s-allius_tsun-gen3-proxy
|
||||||
|
-Dsonar.organization=s-allius
|
||||||
|
-Dsonar.python.version=3.12
|
||||||
|
-Dsonar.python.coverage.reportPaths=coverage.xml
|
||||||
|
-Dsonar.tests=system_tests,app/tests
|
||||||
|
-Dsonar.source=app/src
|
||||||
|
|||||||
65
.github/workflows/sonarcloud.yml
vendored
65
.github/workflows/sonarcloud.yml
vendored
@@ -1,65 +0,0 @@
|
|||||||
# This workflow uses actions that are not certified by GitHub.
|
|
||||||
# They are provided by a third-party and are governed by
|
|
||||||
# separate terms of service, privacy policy, and support
|
|
||||||
# documentation.
|
|
||||||
|
|
||||||
# This workflow helps you trigger a SonarCloud analysis of your code and populates
|
|
||||||
# GitHub Code Scanning alerts with the vulnerabilities found.
|
|
||||||
# Free for open source project.
|
|
||||||
|
|
||||||
# 1. Login to SonarCloud.io using your GitHub account
|
|
||||||
|
|
||||||
# 2. Import your project on SonarCloud
|
|
||||||
# * Add your GitHub organization first, then add your repository as a new project.
|
|
||||||
# * Please note that many languages are eligible for automatic analysis,
|
|
||||||
# which means that the analysis will start automatically without the need to set up GitHub Actions.
|
|
||||||
# * This behavior can be changed in Administration > Analysis Method.
|
|
||||||
#
|
|
||||||
# 3. Follow the SonarCloud in-product tutorial
|
|
||||||
# * a. Copy/paste the Project Key and the Organization Key into the args parameter below
|
|
||||||
# (You'll find this information in SonarCloud. Click on "Information" at the bottom left)
|
|
||||||
#
|
|
||||||
# * b. Generate a new token and add it to your Github repository's secrets using the name SONAR_TOKEN
|
|
||||||
# (On SonarCloud, click on your avatar on top-right > My account > Security
|
|
||||||
# or go directly to https://sonarcloud.io/account/security/)
|
|
||||||
|
|
||||||
# Feel free to take a look at our documentation (https://docs.sonarcloud.io/getting-started/github/)
|
|
||||||
# or reach out to our community forum if you need some help (https://community.sonarsource.com/c/help/sc/9)
|
|
||||||
|
|
||||||
name: SonarCloud analysis
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main", "dev-*" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main", "dev-*" ]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: read # allows SonarCloud to decorate PRs with analysis results
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
Analysis:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Analyze with SonarCloud
|
|
||||||
|
|
||||||
# You can pin the exact commit or the version.
|
|
||||||
# uses: SonarSource/sonarcloud-github-action@v2.2.0
|
|
||||||
uses: SonarSource/sonarcloud-github-action@4006f663ecaf1f8093e8e4abb9227f6041f52216
|
|
||||||
env:
|
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} # Generate a token on Sonarcloud.io, add it to the secrets of this repo with the name SONAR_TOKEN (Settings > Secrets > Actions > add new repository secret)
|
|
||||||
with:
|
|
||||||
# Additional arguments for the SonarScanner CLI
|
|
||||||
args:
|
|
||||||
# Unique keys of your project and organization. You can find them in SonarCloud > Information (bottom-left menu)
|
|
||||||
# mandatory
|
|
||||||
-Dsonar.projectKey=s-allius_tsun-gen3-proxy
|
|
||||||
-Dsonar.organization=s-allius
|
|
||||||
# -Dsonar.sources=tsun-gen3-proxy/app/src
|
|
||||||
# -Dsonar.tests=tsun-gen3-proxy/app/tests,tsun-gen3-proxy/system_tests
|
|
||||||
# Adds more detail to both client and server-side analysis logs, activating DEBUG mode for the scanner, and adding client-side environment variables and system properties to the server-side log of analysis report processing.
|
|
||||||
#-Dsonar.verbose= # optional, default is false
|
|
||||||
# When you need the analysis to take place in a directory other than the one from which it was launched, default is .
|
|
||||||
projectBaseDir: .
|
|
||||||
4
.sonarlint/connectedMode.json
Normal file
4
.sonarlint/connectedMode.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"sonarCloudOrganization": "s-allius",
|
||||||
|
"projectKey": "s-allius_tsun-gen3-proxy"
|
||||||
|
}
|
||||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -11,5 +11,9 @@
|
|||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
"flake8.args": [
|
"flake8.args": [
|
||||||
"--extend-exclude=app/tests/*.py system_tests/*.py"
|
"--extend-exclude=app/tests/*.py system_tests/*.py"
|
||||||
]
|
],
|
||||||
|
"sonarlint.connectedMode.project": {
|
||||||
|
"connectionId": "s-allius",
|
||||||
|
"projectKey": "s-allius_tsun-gen3-proxy"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [unreleased]
|
## [unreleased]
|
||||||
|
|
||||||
|
- add SonarQube and code coverage support
|
||||||
- don't send MODBUS request when state is note up; adapt timeouts [#141](https://github.com/s-allius/tsun-gen3-proxy/issues/141)
|
- don't send MODBUS request when state is note up; adapt timeouts [#141](https://github.com/s-allius/tsun-gen3-proxy/issues/141)
|
||||||
- build multi arch images with sboms [#144](https://github.com/s-allius/tsun-gen3-proxy/issues/144)
|
- build multi arch images with sboms [#144](https://github.com/s-allius/tsun-gen3-proxy/issues/144)
|
||||||
- add timestamp to MQTT topics [#138](https://github.com/s-allius/tsun-gen3-proxy/issues/138)
|
- add timestamp to MQTT topics [#138](https://github.com/s-allius/tsun-gen3-proxy/issues/138)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def test_empty_config():
|
|||||||
Config.conf_schema.validate(cnf)
|
Config.conf_schema.validate(cnf)
|
||||||
assert False
|
assert False
|
||||||
except SchemaMissingKeyError:
|
except SchemaMissingKeyError:
|
||||||
assert True
|
pass
|
||||||
|
|
||||||
def test_default_config():
|
def test_default_config():
|
||||||
with open("app/config/default_config.toml", "rb") as f:
|
with open("app/config/default_config.toml", "rb") as f:
|
||||||
@@ -28,7 +28,6 @@ def test_default_config():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
validated = Config.conf_schema.validate(cnf)
|
validated = Config.conf_schema.validate(cnf)
|
||||||
assert True
|
|
||||||
except:
|
except:
|
||||||
assert False
|
assert False
|
||||||
assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'modbus_polling': True, 'monitor_sn': 0, 'suggested_area': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
|
assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'modbus_polling': True, 'monitor_sn': 0, 'suggested_area': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
|
||||||
@@ -45,7 +44,6 @@ def test_full_config():
|
|||||||
'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
|
'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
|
||||||
try:
|
try:
|
||||||
validated = Config.conf_schema.validate(cnf)
|
validated = Config.conf_schema.validate(cnf)
|
||||||
assert True
|
|
||||||
except:
|
except:
|
||||||
assert False
|
assert False
|
||||||
assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'modbus_polling': True, 'monitor_sn': 0, 'pv1': {'manufacturer': 'man1','type': 'type1'},'pv2': {'manufacturer': 'man2','type': 'type2'},'pv3': {'manufacturer': 'man3','type': 'type3'}, 'suggested_area': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
|
assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'modbus_polling': True, 'monitor_sn': 0, 'pv1': {'manufacturer': 'man1','type': 'type1'},'pv2': {'manufacturer': 'man2','type': 'type2'},'pv3': {'manufacturer': 'man3','type': 'type3'}, 'suggested_area': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
|
||||||
@@ -63,7 +61,6 @@ def test_mininum_config():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
validated = Config.conf_schema.validate(cnf)
|
validated = Config.conf_schema.validate(cnf)
|
||||||
assert True
|
|
||||||
except:
|
except:
|
||||||
assert False
|
assert False
|
||||||
assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'modbus_polling': True, 'monitor_sn': 0, 'suggested_area': ''}}}
|
assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'modbus_polling': True, 'monitor_sn': 0, 'suggested_area': ''}}}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# test_with_pytest.py and scapy
|
# test_with_pytest.py and scapy
|
||||||
#
|
#
|
||||||
import pytest, socket, time
|
import pytest, socket, time
|
||||||
#from scapy.all import *
|
|
||||||
#from scapy.layers.inet import IP, TCP, TCP_client
|
|
||||||
|
|
||||||
def get_sn() -> bytes:
|
def get_sn() -> bytes:
|
||||||
return b'R170000000000001'
|
return b'R170000000000001'
|
||||||
@@ -120,9 +118,7 @@ def MsgOtaUpdateReq(): # Over the air update request from talent cloud
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def ClientConnection():
|
def ClientConnection():
|
||||||
#host = '172.16.30.7'
|
|
||||||
host = 'logger.talent-monitoring.com'
|
host = 'logger.talent-monitoring.com'
|
||||||
#host = '127.0.0.1'
|
|
||||||
port = 5005
|
port = 5005
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.connect((host, port))
|
s.connect((host, port))
|
||||||
@@ -132,9 +128,7 @@ def ClientConnection():
|
|||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
def tempClientConnection():
|
def tempClientConnection():
|
||||||
#host = '172.16.30.7'
|
|
||||||
host = 'logger.talent-monitoring.com'
|
host = 'logger.talent-monitoring.com'
|
||||||
#host = '127.0.0.1'
|
|
||||||
port = 5005
|
port = 5005
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.connect((host, port))
|
s.connect((host, port))
|
||||||
@@ -148,7 +142,6 @@ def test_open_close():
|
|||||||
pass
|
pass
|
||||||
except:
|
except:
|
||||||
assert False
|
assert False
|
||||||
assert True
|
|
||||||
|
|
||||||
def test_send_contact_info1(ClientConnection, MsgContactInfo, MsgContactResp):
|
def test_send_contact_info1(ClientConnection, MsgContactInfo, MsgContactResp):
|
||||||
s = ClientConnection
|
s = ClientConnection
|
||||||
@@ -166,7 +159,7 @@ def test_send_contact_info2(ClientConnection, MsgContactInfo2, MsgContactInfo, M
|
|||||||
s.sendall(MsgContactInfo2)
|
s.sendall(MsgContactInfo2)
|
||||||
data = s.recv(1024)
|
data = s.recv(1024)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
assert True
|
pass
|
||||||
else:
|
else:
|
||||||
assert False
|
assert False
|
||||||
|
|
||||||
@@ -198,7 +191,7 @@ def test_send_contact_resp(ClientConnection, MsgContactResp):
|
|||||||
s.sendall(MsgContactResp)
|
s.sendall(MsgContactResp)
|
||||||
data = s.recv(1024)
|
data = s.recv(1024)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
assert True
|
pass
|
||||||
else:
|
else:
|
||||||
assert data == b''
|
assert data == b''
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
import pytest, socket, time, os
|
import pytest, socket, time, os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
#from scapy.all import *
|
|
||||||
#from scapy.layers.inet import IP, TCP, TCP_client
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
SOLARMAN_SNR = os.getenv('SOLARMAN_SNR', '00000080')
|
SOLARMAN_SNR = os.getenv('SOLARMAN_SNR', '00000080')
|
||||||
@@ -111,10 +108,7 @@ def MsgInvalidInfo(): # Contact Info message wrong start byte
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def ClientConnection():
|
def ClientConnection():
|
||||||
#host = '172.16.30.7'
|
|
||||||
host = 'logger.talent-monitoring.com'
|
host = 'logger.talent-monitoring.com'
|
||||||
#host = 'iot.talent-monitoring.com'
|
|
||||||
#host = '127.0.0.1'
|
|
||||||
port = 10000
|
port = 10000
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.connect((host, port))
|
s.connect((host, port))
|
||||||
@@ -131,10 +125,7 @@ def checkResponse(data, Msg):
|
|||||||
|
|
||||||
|
|
||||||
def tempClientConnection():
|
def tempClientConnection():
|
||||||
#host = '172.16.30.7'
|
|
||||||
host = 'logger.talent-monitoring.com'
|
host = 'logger.talent-monitoring.com'
|
||||||
#host = 'iot.talent-monitoring.com'
|
|
||||||
#host = '127.0.0.1'
|
|
||||||
port = 10000
|
port = 10000
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.connect((host, port))
|
s.connect((host, port))
|
||||||
@@ -145,11 +136,10 @@ def tempClientConnection():
|
|||||||
|
|
||||||
def test_open_close():
|
def test_open_close():
|
||||||
try:
|
try:
|
||||||
for s in tempClientConnection():
|
for _ in tempClientConnection():
|
||||||
pass
|
pass # test generator tempClientConnection()
|
||||||
except:
|
except:
|
||||||
assert False
|
assert False
|
||||||
assert True
|
|
||||||
|
|
||||||
def test_conn_msg(ClientConnection,MsgContactInfo, MsgContactResp):
|
def test_conn_msg(ClientConnection,MsgContactInfo, MsgContactResp):
|
||||||
s = ClientConnection
|
s = ClientConnection
|
||||||
|
|||||||
Reference in New Issue
Block a user