Migrate TickTick OAuth and action tasks
This commit is contained in:
@@ -4,7 +4,9 @@ from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.auth_db import reset_auth_db_caches
|
||||
from app.config import get_settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def _extract_csrf_token(html: str) -> str:
|
||||
@@ -56,6 +58,8 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC
|
||||
assert "New Password" in config_response.text
|
||||
assert "Save Config" in config_response.text
|
||||
assert "当前用户" in config_response.text
|
||||
assert "Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth." in config_response.text
|
||||
assert 'aria-disabled="true">Authorize TickTick<' in config_response.text
|
||||
|
||||
|
||||
def test_login_failure_returns_generic_error(client: TestClient) -> None:
|
||||
@@ -187,3 +191,77 @@ def test_config_page_update_persists_to_database(
|
||||
assert rows["APP_NAME"] == "Updated Home Automation"
|
||||
assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token"
|
||||
assert "AUTH_BOOTSTRAP_USERNAME" not in rows
|
||||
|
||||
|
||||
def test_config_page_shows_ticktick_oauth_link_when_ticktick_is_configured(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
config_response = client.get("/config")
|
||||
|
||||
assert config_response.status_code == 200
|
||||
assert "Use the saved TickTick client settings to start the authorization flow." in config_response.text
|
||||
assert 'href="/ticktick/auth/start">Authorize TickTick<' in config_response.text
|
||||
|
||||
|
||||
def test_config_page_shows_ticktick_oauth_success_notice(client: TestClient) -> None:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
response = client.get("/config?ticktick_oauth=success")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "TickTick authorization completed successfully." in response.text
|
||||
|
||||
|
||||
def test_config_page_shows_ticktick_oauth_failure_notice(client: TestClient) -> None:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
response = client.get("/config?ticktick_oauth=failed")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." in response.text
|
||||
|
||||
@@ -109,7 +109,23 @@ def test_homeassistant_publish_rejects_missing_content(location_client) -> None:
|
||||
assert "content" not in response.text
|
||||
|
||||
|
||||
def test_homeassistant_publish_returns_not_implemented_for_unknown_target(location_client) -> None:
|
||||
def test_homeassistant_publish_returns_internal_error_for_unconfigured_ticktick(location_client) -> None:
|
||||
client, _ = location_client
|
||||
|
||||
response = client.post(
|
||||
"/homeassistant/publish",
|
||||
json={
|
||||
"target": "ticktick",
|
||||
"action": "create_action_task",
|
||||
"content": "{'action': 'take out trash', 'due_hour': 6}",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.text == "internal server error"
|
||||
|
||||
|
||||
def test_homeassistant_publish_rejects_invalid_ticktick_content(location_client) -> None:
|
||||
client, _ = location_client
|
||||
|
||||
response = client.post(
|
||||
@@ -121,8 +137,8 @@ def test_homeassistant_publish_returns_not_implemented_for_unknown_target(locati
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.text == "internal server error"
|
||||
assert response.status_code == 400
|
||||
assert response.text == "bad request"
|
||||
|
||||
|
||||
def test_homeassistant_publish_returns_not_implemented_for_unknown_location_action(
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import json
|
||||
import sqlite3
|
||||
from urllib import error
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.auth_db import reset_auth_db_caches
|
||||
from app.config import Settings, get_settings
|
||||
from app.integrations.ticktick import (
|
||||
AUTH_SCOPE,
|
||||
TICKTICK_AUTH_URL,
|
||||
TickTickClient,
|
||||
TickTickTask,
|
||||
default_auth_state_store,
|
||||
)
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class _FakeJsonResponse:
|
||||
def __init__(self, status_code: int, payload):
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
|
||||
def getcode(self) -> int:
|
||||
return self.status_code
|
||||
|
||||
def read(self) -> bytes:
|
||||
return json.dumps(self.payload).encode("utf-8")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _configured_settings(**overrides) -> Settings:
|
||||
payload = {
|
||||
"ticktick_client_id": "ticktick-client-id",
|
||||
"ticktick_client_secret": "ticktick-client-secret",
|
||||
"ticktick_redirect_uri": "http://localhost:8000/ticktick/auth/code",
|
||||
"ticktick_token": "ticktick-access-token",
|
||||
"home_assistant_action_task_project_id": "project-123",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return Settings(_env_file=None, **payload)
|
||||
|
||||
|
||||
def _extract_csrf_token(html: str) -> str:
|
||||
import re
|
||||
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
|
||||
assert match is not None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_build_authorization_url_contains_expected_query(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = TickTickClient(settings=_configured_settings())
|
||||
monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-123")
|
||||
|
||||
authorization_url = client.build_authorization_url()
|
||||
parsed = urlparse(authorization_url)
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == TICKTICK_AUTH_URL
|
||||
assert query["client_id"] == ["ticktick-client-id"]
|
||||
assert query["response_type"] == ["code"]
|
||||
assert query["redirect_uri"] == ["http://localhost:8000/ticktick/auth/code"]
|
||||
assert query["state"] == ["state-123"]
|
||||
assert query["scope"] == [AUTH_SCOPE]
|
||||
|
||||
|
||||
def test_exchange_authorization_code_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured = {}
|
||||
client = TickTickClient(settings=_configured_settings())
|
||||
default_auth_state_store.pending_state = "expected-state"
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
captured["url"] = req.full_url
|
||||
captured["timeout"] = timeout
|
||||
captured["authorization"] = req.headers["Authorization"]
|
||||
captured["content_type"] = req.headers["Content-type"]
|
||||
captured["body"] = req.data.decode("utf-8")
|
||||
return _FakeJsonResponse(200, {"access_token": "new-token"})
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
token = client.exchange_authorization_code(code="oauth-code", state="expected-state")
|
||||
|
||||
assert token == "new-token"
|
||||
assert captured["url"] == "https://ticktick.com/oauth/token"
|
||||
assert captured["timeout"] == pytest.approx(10.0)
|
||||
assert captured["content_type"] == "application/x-www-form-urlencoded"
|
||||
assert captured["authorization"].startswith("Basic ")
|
||||
assert "code=oauth-code" in captured["body"]
|
||||
assert "grant_type=authorization_code" in captured["body"]
|
||||
assert "scope=tasks%3Aread+tasks%3Awrite" in captured["body"]
|
||||
assert "client_id=" not in captured["body"]
|
||||
assert "client_secret=" not in captured["body"]
|
||||
|
||||
|
||||
def test_exchange_authorization_code_trims_ticktick_config_values(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured = {}
|
||||
client = TickTickClient(
|
||||
settings=_configured_settings(
|
||||
ticktick_client_id=" ticktick-client-id ",
|
||||
ticktick_client_secret=" ticktick-client-secret ",
|
||||
ticktick_redirect_uri=" http://localhost:8000/ticktick/auth/code ",
|
||||
)
|
||||
)
|
||||
default_auth_state_store.pending_state = "trimmed-state"
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
captured["authorization"] = req.headers["Authorization"]
|
||||
captured["body"] = req.data.decode("utf-8")
|
||||
return _FakeJsonResponse(200, {"access_token": "trimmed-token"})
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
token = client.exchange_authorization_code(code="oauth-code", state="trimmed-state")
|
||||
|
||||
assert token == "trimmed-token"
|
||||
assert captured["authorization"].startswith("Basic ")
|
||||
assert "redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fticktick%2Fauth%2Fcode" in captured["body"]
|
||||
|
||||
|
||||
def test_create_task_skips_duplicate_titles(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = TickTickClient(settings=_configured_settings())
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
assert req.full_url.endswith("/project/project-123/data")
|
||||
return _FakeJsonResponse(
|
||||
200,
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"projectId": "project-123",
|
||||
"title": "wash dishes",
|
||||
"columnId": "column-7",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
client.create_task(TickTickTask(projectId="project-123", title="wash dishes"))
|
||||
|
||||
|
||||
def test_get_projects_ignores_unknown_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = TickTickClient(settings=_configured_settings())
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
assert req.full_url.endswith("/project/")
|
||||
return _FakeJsonResponse(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"id": "project-123",
|
||||
"name": "Inbox",
|
||||
"etag": "project-etag",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
projects = client.get_projects()
|
||||
|
||||
assert len(projects) == 1
|
||||
assert projects[0].id == "project-123"
|
||||
assert projects[0].name == "Inbox"
|
||||
|
||||
|
||||
def test_create_task_posts_expected_payload(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured = {"calls": []}
|
||||
client = TickTickClient(settings=_configured_settings())
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
captured["calls"].append(req.full_url)
|
||||
if req.full_url.endswith("/project/project-123/data"):
|
||||
return _FakeJsonResponse(200, {"tasks": []})
|
||||
captured["authorization"] = req.headers["Authorization"]
|
||||
captured["content_type"] = req.headers["Content-type"]
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
return _FakeJsonResponse(200, {"id": "task-99"})
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
client.create_task(
|
||||
TickTickTask(projectId="project-123", title="wash dishes", dueDate="2026-04-21T00:00:00+0000")
|
||||
)
|
||||
|
||||
assert captured["calls"] == [
|
||||
"https://api.ticktick.com/open/v1/project/project-123/data",
|
||||
"https://api.ticktick.com/open/v1/task",
|
||||
]
|
||||
assert captured["authorization"] == "Bearer ticktick-access-token"
|
||||
assert captured["content_type"] == "application/json"
|
||||
assert captured["body"] == {
|
||||
"projectId": "project-123",
|
||||
"title": "wash dishes",
|
||||
"dueDate": "2026-04-21T00:00:00+0000",
|
||||
}
|
||||
|
||||
|
||||
def test_homeassistant_publish_creates_ticktick_action_task(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
monkeypatch.setenv("TICKTICK_TOKEN", "ticktick-access-token")
|
||||
monkeypatch.setenv("HOME_ASSISTANT_ACTION_TASK_PROJECT_ID", "project-123")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
|
||||
captured = {"calls": []}
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
captured["calls"].append(req.full_url)
|
||||
if req.full_url.endswith("/project/project-123/data"):
|
||||
return _FakeJsonResponse(200, {"tasks": []})
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
return _FakeJsonResponse(200, {"id": "task-1"})
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.post(
|
||||
"/homeassistant/publish",
|
||||
json={
|
||||
"target": "ticktick",
|
||||
"action": "create_action_task",
|
||||
"content": "{'title': 'ignored', 'action': 'take out trash', 'due_hour': 6}",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["calls"] == [
|
||||
"https://api.ticktick.com/open/v1/project/project-123/data",
|
||||
"https://api.ticktick.com/open/v1/task",
|
||||
]
|
||||
assert captured["body"]["projectId"] == "project-123"
|
||||
assert captured["body"]["title"] == "take out trash"
|
||||
assert captured["body"]["dueDate"].endswith("+0000")
|
||||
|
||||
|
||||
def test_ticktick_auth_start_redirects_authenticated_user(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-redirect")
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
response = client.get("/ticktick/auth/start", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
parsed = urlparse(response.headers["location"])
|
||||
query = parse_qs(parsed.query)
|
||||
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == TICKTICK_AUTH_URL
|
||||
assert query["state"] == ["state-redirect"]
|
||||
|
||||
|
||||
def test_ticktick_auth_callback_persists_token(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
default_auth_state_store.pending_state = "callback-state"
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
return _FakeJsonResponse(200, {"access_token": "persisted-token"})
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/ticktick/auth/code?state=callback-state&code=oauth-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/config?ticktick_oauth=success"
|
||||
|
||||
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_config WHERE key = ?",
|
||||
("TICKTICK_TOKEN",),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert row is not None
|
||||
assert row[0] == "persisted-token"
|
||||
|
||||
|
||||
def test_ticktick_auth_callback_redirects_on_invalid_state(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
default_auth_state_store.pending_state = "expected-state"
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/ticktick/auth/code?state=wrong-state&code=oauth-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/config?ticktick_oauth=invalid-state"
|
||||
|
||||
|
||||
def test_ticktick_auth_callback_redirects_when_token_exchange_fails(
|
||||
test_database_urls,
|
||||
ready_location_database,
|
||||
ready_poo_database,
|
||||
auth_database,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id")
|
||||
monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret")
|
||||
monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code")
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
default_auth_state_store.pending_state = "callback-state"
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
raise error.HTTPError(req.full_url, 401, "Unauthorized", hdrs=None, fp=None)
|
||||
|
||||
monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/ticktick/auth/code?state=callback-state&code=oauth-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/config?ticktick_oauth=failed"
|
||||
Reference in New Issue
Block a user