Skip to content

Commit 91b258f

Browse files
committed
Cleanup and version update
1 parent 6b0a05a commit 91b258f

File tree

4 files changed

+163
-8
lines changed

4 files changed

+163
-8
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import asyncio
2+
import json
3+
import base64
4+
from datetime import datetime, timezone
5+
6+
import pytest
7+
import aiohttp
8+
from aioresponses import aioresponses
9+
10+
from fmd_api.client import FmdClient
11+
from fmd_api.helpers import _pad_base64
12+
13+
# NOTE: These tests validate behavior parity for the core HTTP flows using mocks.
14+
# They do not perform full Argon2/RSA cryptography verification, but they assert
15+
# that the client calls the expected endpoints and behaves like the original client.
16+
17+
@pytest.mark.asyncio
18+
async def test_get_locations_and_decrypt(monkeypatch):
19+
# Create a fake client and stub methods that require heavy crypto with small helpers.
20+
client = FmdClient("https://fmd.example.com")
21+
# Provide a dummy private_key with a decrypt method for testing
22+
class DummyKey:
23+
def decrypt(self, packet, padding_obj):
24+
# Return a 32-byte AES session key for AESGCM, for tests we use 32 zero bytes
25+
return b"\x00" * 32
26+
client.private_key = DummyKey()
27+
28+
# Build a fake AES-GCM encrypted payload: we'll create plaintext b'{"lat":1.0,"lon":2.0,"date":1234,"bat":50}'
29+
plaintext = b'{"lat":1.0,"lon":2.0,"date":1600000000000,"bat":50}'
30+
# For the test, simulate AESGCM by encrypting with a known key using AESGCM class
31+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
32+
session_key = b"\x00" * 32
33+
aesgcm = AESGCM(session_key)
34+
iv = b"\x01" * 12
35+
ciphertext = aesgcm.encrypt(iv, plaintext, None)
36+
# Build blob: session_key_packet (RSA_KEY_SIZE_BYTES) + iv + ciphertext
37+
session_key_packet = b"\xAA" * 384 # dummy RSA packet; DummyKey.decrypt ignores it
38+
blob = session_key_packet + iv + ciphertext
39+
blob_b64 = base64.b64encode(blob).decode('utf-8').rstrip('=')
40+
41+
# Mock the endpoints used by get_locations:
42+
with aioresponses() as m:
43+
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"})
44+
m.put("https://fmd.example.com/api/v1/location", payload=blob_b64)
45+
client.access_token = "dummy-token"
46+
locations = await client.get_locations(num_to_get=1)
47+
assert len(locations) == 1
48+
decrypted = client.decrypt_data_blob(locations[0])
49+
assert b'"lat":1.0' in decrypted
50+
assert b'"lon":2.0' in decrypted
51+
52+
@pytest.mark.asyncio
53+
async def test_send_command_reauth(monkeypatch):
54+
client = FmdClient("https://fmd.example.com")
55+
# create a dummy private key with sign()
56+
class DummySigner:
57+
def sign(self, message_bytes, pad, algo):
58+
return b"\xAB" * 64
59+
client.private_key = DummySigner()
60+
client._fmd_id = "id"
61+
client._password = "pw"
62+
client.access_token = "old-token"
63+
64+
with aioresponses() as m:
65+
# First POST returns 401 -> client should re-authenticate
66+
m.post("https://fmd.example.com/api/v1/command", status=401)
67+
# When authenticate is called during reauth, stub the internal calls:
68+
async def fake_authenticate(fmd_id, password, session_duration):
69+
client.access_token = "new-token"
70+
monkeypatch.setattr(client, "authenticate", fake_authenticate)
71+
# Second attempt should now succeed
72+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
73+
res = await client.send_command("ring")
74+
assert res is True
75+
76+
@pytest.mark.asyncio
77+
async def test_export_data_zip_stream(monkeypatch, tmp_path):
78+
client = FmdClient("https://fmd.example.com")
79+
client.access_token = "token"
80+
small_zip = b'PK\x03\x04' + b'\x00' * 100
81+
with aioresponses() as m:
82+
m.post("https://fmd.example.com/api/v1/exportData", body=small_zip, status=200)
83+
out_file = tmp_path / "export.zip"
84+
await client.export_data_zip(str(out_file))
85+
assert out_file.exists()
86+
content = out_file.read_bytes()
87+
assert content.startswith(b'PK\x03\x04')
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import asyncio
2+
import base64
3+
import json
4+
from datetime import datetime, timezone
5+
6+
import pytest
7+
from aioresponses import aioresponses
8+
9+
from fmd_api.client import FmdClient
10+
from fmd_api.device import Device
11+
12+
@pytest.mark.asyncio
13+
async def test_device_refresh_and_get_location(monkeypatch):
14+
client = FmdClient("https://fmd.example.com")
15+
# Dummy private_key decrypt path (reuse approach from client tests)
16+
class DummyKey:
17+
def decrypt(self, packet, padding_obj):
18+
return b'\x00' * 32
19+
client.private_key = DummyKey()
20+
21+
# Create a simple AES-GCM encrypted location blob (same scheme as client test)
22+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
23+
session_key = b'\x00' * 32
24+
aesgcm = AESGCM(session_key)
25+
iv = b'\x02' * 12
26+
plaintext = b'{"lat":10.0,"lon":20.0,"date":1600000000000,"bat":80}'
27+
ciphertext = aesgcm.encrypt(iv, plaintext, None)
28+
blob = b'\xAA' * 384 + iv + ciphertext
29+
blob_b64 = base64.b64encode(blob).decode('utf-8').rstrip('=')
30+
31+
with aioresponses() as m:
32+
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"})
33+
m.put("https://fmd.example.com/api/v1/location", payload=blob_b64)
34+
client.access_token = "token"
35+
device = Device(client, "alice")
36+
await device.refresh()
37+
loc = await device.get_location()
38+
assert loc is not None
39+
assert abs(loc.lat - 10.0) < 1e-6
40+
assert abs(loc.lon - 20.0) < 1e-6
41+
42+
@pytest.mark.asyncio
43+
async def test_device_fetch_and_download_picture(monkeypatch):
44+
client = FmdClient("https://fmd.example.com")
45+
# Provide dummy private key that decrypts session packet into all-zero key
46+
class DummyKey:
47+
def decrypt(self, packet, padding_obj):
48+
return b'\x00' * 32
49+
client.private_key = DummyKey()
50+
51+
# Prepare an "encrypted blob" that after decrypt yields a base64 image string.
52+
# For test simplicity, we'll make decrypted payload the base64 of b'PNGDATA'
53+
inner_image = base64.b64encode(b'PNGDATA').decode('utf-8')
54+
# Encrypt inner_image using AESGCM with zero key
55+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
56+
session_key = b'\x00' * 32
57+
aesgcm = AESGCM(session_key)
58+
iv = b'\x03' * 12
59+
ciphertext = aesgcm.encrypt(iv, inner_image.encode('utf-8'), None)
60+
blob = b'\xAA' * 384 + iv + ciphertext
61+
blob_b64 = base64.b64encode(blob).decode('utf-8').rstrip('=')
62+
63+
with aioresponses() as m:
64+
# get_pictures endpoint returns a JSON list; emulate simple list containing our blob
65+
m.put("https://fmd.example.com/api/v1/pictures", payload=[blob_b64])
66+
client.access_token = "token"
67+
device = Device(client, "alice")
68+
pics = await device.fetch_pictures()
69+
assert len(pics) == 1
70+
# download the picture and verify we got PNGDATA bytes
71+
photo = await device.download_photo(pics[0])
72+
assert photo.data == b'PNGDATA'
73+
assert photo.mime_type.startswith("image/")

fmd_api/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.0.0-dev4"
1+
__version__ = "2.0.0-dev5"

pyproject.toml

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fmd_api"
3-
version = "2.0.0.dev4"
3+
version = "2.0.0.dev5"
44
authors = [{name = "devinslick"}]
55
description = "A Python client for the FMD (Find My Device) server API"
66
readme = "README.md"
@@ -32,12 +32,6 @@ dependencies = [
3232
requires = ["setuptools>=61.0", "wheel"]
3333
build-backend = "setuptools.build_meta"
3434

35-
[tool.poetry.dev-dependencies]
36-
pytest = "^7.0"
37-
pytest-asyncio = "^0.20"
38-
aioresponses = "^0.9"
39-
40-
4135
[project.urls]
4236
Homepage = "https://github.com/devinslick/fmd_api"
4337
Repository = "https://github.com/devinslick/fmd_api"
@@ -48,6 +42,7 @@ Documentation = "https://github.com/devinslick/fmd_api#readme"
4842
dev = [
4943
"pytest>=7.0",
5044
"pytest-asyncio",
45+
"aioresponses>=0.7.0",
5146
"black",
5247
"flake8",
5348
"mypy",

0 commit comments

Comments
 (0)