Migrate TickTick OAuth and action tasks

This commit is contained in:
2026-04-20 17:06:03 +02:00
parent 179aae264e
commit 982af62f4f
17 changed files with 1114 additions and 15 deletions
+4 -1
View File
@@ -18,7 +18,7 @@ from app.services.auth import (
revoke_session, revoke_session,
validate_csrf_token, validate_csrf_token,
) )
from app.services.config_page import build_config_sections from app.services.config_page import build_config_sections, is_ticktick_oauth_ready
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
@@ -225,6 +225,9 @@ def _render_config_page(
"config_error": None, "config_error": None,
"config_saved": False, "config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings), "config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_oauth_notice": None,
"ticktick_oauth_error": None,
}, },
status_code=status_code, status_code=status_code,
) )
+12 -3
View File
@@ -6,7 +6,8 @@ from fastapi.responses import PlainTextResponse, Response
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.dependencies import get_db from app.dependencies import get_db, get_ticktick_client
from app.integrations.ticktick import TickTickClient, TickTickConfigError, TickTickRequestError
from app.schemas.homeassistant import HomeAssistantPublishEnvelope from app.schemas.homeassistant import HomeAssistantPublishEnvelope
from app.services.homeassistant_inbound import ( from app.services.homeassistant_inbound import (
UnsupportedHomeAssistantMessage, UnsupportedHomeAssistantMessage,
@@ -21,13 +22,15 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
@router.post("/homeassistant/publish") @router.post("/homeassistant/publish")
async def publish_from_homeassistant( async def publish_from_homeassistant(
request: Request, db: Session = Depends(get_db) request: Request,
db: Session = Depends(get_db),
ticktick_client: TickTickClient = Depends(get_ticktick_client),
) -> Response: ) -> Response:
try: try:
raw_payload = await request.body() raw_payload = await request.body()
data = json.loads(raw_payload) data = json.loads(raw_payload)
envelope = HomeAssistantPublishEnvelope.model_validate(data) envelope = HomeAssistantPublishEnvelope.model_validate(data)
handle_homeassistant_message(db, envelope) handle_homeassistant_message(db, envelope, ticktick_client)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
logger.warning("Rejected Home Assistant publish request due to invalid JSON: %s", exc) logger.warning("Rejected Home Assistant publish request due to invalid JSON: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
@@ -42,6 +45,12 @@ async def publish_from_homeassistant(
INTERNAL_SERVER_ERROR_MESSAGE, INTERNAL_SERVER_ERROR_MESSAGE,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
) )
except (TickTickConfigError, TickTickRequestError, RuntimeError) as exc:
logger.warning("Home Assistant publish request failed during TickTick handling: %s", exc)
return PlainTextResponse(
INTERNAL_SERVER_ERROR_MESSAGE,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except ValueError as exc: except ValueError as exc:
logger.warning("Rejected Home Assistant publish request due to invalid content: %s", exc) logger.warning("Rejected Home Assistant publish request due to invalid content: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
+32 -2
View File
@@ -8,7 +8,12 @@ from fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
from app.services.auth import AuthenticatedSession from app.services.auth import AuthenticatedSession
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates from app.services.config_page import (
ConfigSaveError,
build_config_sections,
is_ticktick_oauth_ready,
save_config_updates,
)
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
@@ -16,6 +21,18 @@ router = APIRouter(tags=["pages"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]:
if status_value == "success":
return "TickTick authorization completed successfully.", None
if status_value == "invalid-state":
return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again."
if status_value == "invalid-callback":
return None, "TickTick authorization callback was missing required parameters."
if status_value == "failed":
return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI."
return None, None
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def home( def home(
request: Request, request: Request,
@@ -46,6 +63,10 @@ def config_page(
if current_auth is None: if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice(
request.query_params.get("ticktick_oauth")
)
context = { context = {
"app_name": settings.app_name, "app_name": settings.app_name,
"app_env": settings.app_env, "app_env": settings.app_env,
@@ -56,6 +77,9 @@ def config_page(
"config_error": None, "config_error": None,
"config_saved": request.query_params.get("saved") == "1", "config_saved": request.query_params.get("saved") == "1",
"config_sections": build_config_sections(auth_db_session, settings), "config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_oauth_notice": ticktick_oauth_notice,
"ticktick_oauth_error": ticktick_oauth_error,
} }
return templates.TemplateResponse(request, "config.html", context) return templates.TemplateResponse(request, "config.html", context)
@@ -84,6 +108,9 @@ async def config_submit(
"config_error": "invalid config update request", "config_error": "invalid config update request",
"config_saved": False, "config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings), "config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_oauth_notice": None,
"ticktick_oauth_error": None,
} }
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
@@ -96,7 +123,7 @@ async def config_submit(
save_config_updates(auth_db_session, dict(form), settings) save_config_updates(auth_db_session, dict(form), settings)
except ConfigSaveError: except ConfigSaveError:
logger.warning("Rejected config update due to invalid submitted values") logger.warning("Rejected config update due to invalid submitted values")
refreshed_settings = build_runtime_settings(auth_db_session, get_settings()) refreshed_settings = get_settings()
context = { context = {
"app_name": refreshed_settings.app_name, "app_name": refreshed_settings.app_name,
"app_env": refreshed_settings.app_env, "app_env": refreshed_settings.app_env,
@@ -107,6 +134,9 @@ async def config_submit(
"config_error": "invalid config submission", "config_error": "invalid config submission",
"config_saved": False, "config_saved": False,
"config_sections": build_config_sections(auth_db_session, refreshed_settings), "config_sections": build_config_sections(auth_db_session, refreshed_settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings),
"ticktick_oauth_notice": None,
"ticktick_oauth_error": None,
} }
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
+79
View File
@@ -0,0 +1,79 @@
import logging
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import PlainTextResponse, RedirectResponse, Response
from sqlalchemy.orm import Session
from app.config import Settings
from app.dependencies import (
get_app_settings,
get_auth_db,
get_current_auth_session,
get_ticktick_client,
)
from app.integrations.ticktick import TickTickAuthError, TickTickClient, TickTickConfigError, TickTickRequestError
from app.services.auth import AuthenticatedSession
from app.services.config_page import save_config_value
router = APIRouter(tags=["ticktick"])
logger = logging.getLogger(__name__)
@router.get("/ticktick/auth/start")
def start_ticktick_auth(
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
ticktick_client: TickTickClient = Depends(get_ticktick_client),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
try:
authorization_url = ticktick_client.build_authorization_url()
except TickTickConfigError as exc:
logger.warning("Rejected TickTick OAuth start due to incomplete configuration: %s", exc)
return PlainTextResponse("TickTick integration is not configured", status_code=400)
return RedirectResponse(url=authorization_url, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/ticktick/auth/code")
def handle_ticktick_auth_code(
request: Request,
auth_db_session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings),
ticktick_client: TickTickClient = Depends(get_ticktick_client),
) -> Response:
code = request.query_params.get("code", "")
state = request.query_params.get("state", "")
if not code or not state:
return RedirectResponse(
url="/config?ticktick_oauth=invalid-callback",
status_code=status.HTTP_303_SEE_OTHER,
)
try:
token = ticktick_client.exchange_authorization_code(code=code, state=state)
save_config_value(
auth_db_session,
env_name="TICKTICK_TOKEN",
value=token,
bootstrap_settings=settings,
)
except TickTickAuthError as exc:
logger.warning("Rejected TickTick OAuth callback due to invalid state: %s", exc)
return RedirectResponse(
url="/config?ticktick_oauth=invalid-state",
status_code=status.HTTP_303_SEE_OTHER,
)
except (TickTickConfigError, TickTickRequestError, ValueError) as exc:
logger.warning("TickTick OAuth callback failed: %s", exc)
return RedirectResponse(
url="/config?ticktick_oauth=failed",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
url="/config?ticktick_oauth=success",
status_code=status.HTTP_303_SEE_OTHER,
)
+5
View File
@@ -7,6 +7,7 @@ from app.auth_db import get_auth_db_session
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.db import get_db_session from app.db import get_db_session
from app.integrations.homeassistant import HomeAssistantClient from app.integrations.homeassistant import HomeAssistantClient
from app.integrations.ticktick import TickTickClient
from app.poo_db import get_poo_db_session from app.poo_db import get_poo_db_session
from app.services.auth import AuthenticatedSession, get_authenticated_session from app.services.auth import AuthenticatedSession, get_authenticated_session
from app.services.config_page import build_runtime_settings from app.services.config_page import build_runtime_settings
@@ -32,6 +33,10 @@ def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) ->
return HomeAssistantClient(settings) return HomeAssistantClient(settings)
def get_ticktick_client(settings: Settings = Depends(get_app_settings)) -> TickTickClient:
return TickTickClient(settings)
def get_current_auth_session( def get_current_auth_session(
request: Request, request: Request,
session: Session = Depends(get_auth_db), session: Session = Depends(get_auth_db),
+291 -2
View File
@@ -1,12 +1,301 @@
from dataclasses import dataclass from __future__ import annotations
import json
import logging
import secrets
import base64
from dataclasses import asdict, dataclass, field, fields
from typing import Any
from urllib import error, parse, request
from app.config import Settings from app.config import Settings
logger = logging.getLogger(__name__)
TICKTICK_AUTH_URL = "https://ticktick.com/oauth/authorize"
TICKTICK_TOKEN_URL = "https://ticktick.com/oauth/token"
TICKTICK_OPEN_API_BASE_URL = "https://api.ticktick.com/open/v1"
TICKTICK_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
AUTH_SCOPE = "tasks:read tasks:write"
class TickTickConfigError(RuntimeError):
"""Raised when TickTick is missing required runtime configuration."""
class TickTickAuthError(RuntimeError):
"""Raised when TickTick OAuth state validation fails."""
class TickTickRequestError(RuntimeError):
"""Raised when a TickTick API request fails."""
@dataclass(slots=True)
class TickTickProject:
id: str
name: str
color: str | None = None
sortOrder: int | None = None
closed: bool | None = None
groupId: str | None = None
viewMode: str | None = None
permission: str | None = None
kind: str | None = None
@dataclass(slots=True)
class TickTickTask:
projectId: str
title: str
id: str | None = None
isAllDay: bool | None = None
completedTime: str | None = None
content: str | None = None
desc: str | None = None
dueDate: str | None = None
items: list[Any] | None = None
priority: int | None = None
reminders: list[str] | None = None
repeatFlag: str | None = None
sortOrder: int | None = None
startDate: str | None = None
status: int | None = None
timeZone: str | None = None
@dataclass(slots=True)
class TickTickAuthStateStore:
pending_state: str | None = None
def issue_state(self) -> str:
self.pending_state = secrets.token_hex(6)
return self.pending_state
def matches_state(self, state: str) -> bool:
return bool(self.pending_state and state == self.pending_state)
def consume_state(self, state: str) -> bool:
if not self.pending_state or state != self.pending_state:
return False
self.pending_state = None
return True
def clear(self) -> None:
self.pending_state = None
default_auth_state_store = TickTickAuthStateStore()
def _coerce_dataclass_payload(model_type: type, payload: dict[str, Any]) -> Any:
allowed_field_names = {item.name for item in fields(model_type)}
filtered_payload = {
key: value for key, value in payload.items() if key in allowed_field_names
}
return model_type(**filtered_payload)
@dataclass(slots=True) @dataclass(slots=True)
class TickTickClient: class TickTickClient:
settings: Settings settings: Settings
auth_state_store: TickTickAuthStateStore = field(default_factory=lambda: default_auth_state_store)
timeout_seconds: float = 10.0
def is_configured(self) -> bool: def is_configured(self) -> bool:
return bool(self.settings.ticktick_client_id and self.settings.ticktick_client_secret) return bool(self._client_id() and self._client_secret())
def has_token(self) -> bool:
return bool(self.settings.ticktick_token)
def build_authorization_url(self) -> str:
self._require_auth_config()
state = self.auth_state_store.issue_state()
params = parse.urlencode(
{
"client_id": self._client_id(),
"response_type": "code",
"redirect_uri": self._redirect_uri(),
"state": state,
"scope": AUTH_SCOPE,
}
)
return f"{TICKTICK_AUTH_URL}?{params}"
def exchange_authorization_code(self, *, code: str, state: str) -> str:
self._require_auth_config()
if not code:
raise ValueError("code must not be empty")
if not state:
raise ValueError("state must not be empty")
if not self.auth_state_store.matches_state(state):
raise TickTickAuthError("Invalid state")
body = parse.urlencode(
{
"code": code,
"grant_type": "authorization_code",
"scope": AUTH_SCOPE,
"redirect_uri": self._redirect_uri(),
}
).encode("utf-8")
req = request.Request(TICKTICK_TOKEN_URL, data=body, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
req.add_header("Authorization", self._basic_auth_header())
payload = self._send_json_request(req, operation="exchange_authorization_code")
self.auth_state_store.clear()
token = payload.get("access_token")
if not isinstance(token, str) or not token:
raise TickTickRequestError("TickTick token response did not include access_token")
return token
def get_projects(self) -> list[TickTickProject]:
self._require_token()
payload = self._authorized_json_request(
method="GET",
path="/project/",
operation="get_projects",
)
if not isinstance(payload, list):
raise TickTickRequestError("TickTick get_projects returned an unexpected payload")
return [_coerce_dataclass_payload(TickTickProject, project) for project in payload]
def get_tasks(self, project_id: str) -> list[TickTickTask]:
self._require_token()
if not project_id:
raise ValueError("project_id must not be empty")
payload = self._authorized_json_request(
method="GET",
path=f"/project/{parse.quote(project_id, safe='')}/data",
operation="get_tasks",
accepted_status_codes={200, 404},
)
if payload is None:
return []
if not isinstance(payload, dict):
raise TickTickRequestError("TickTick get_tasks returned an unexpected payload")
tasks = payload.get("tasks", [])
if not isinstance(tasks, list):
raise TickTickRequestError("TickTick get_tasks returned an invalid tasks payload")
return [_coerce_dataclass_payload(TickTickTask, task) for task in tasks]
def has_duplicate_task(self, *, project_id: str, task_title: str) -> bool:
if not task_title:
raise ValueError("task_title must not be empty")
return any(task.title == task_title for task in self.get_tasks(project_id))
def create_task(self, task: TickTickTask) -> None:
self._require_token()
if not task.projectId:
raise ValueError("task.projectId must not be empty")
if not task.title:
raise ValueError("task.title must not be empty")
if self.has_duplicate_task(project_id=task.projectId, task_title=task.title):
return
payload = {key: value for key, value in asdict(task).items() if value is not None}
self._authorized_json_request(
method="POST",
path="/task",
operation="create_task",
body=payload,
accepted_status_codes={200},
)
def _authorized_json_request(
self,
*,
method: str,
path: str,
operation: str,
body: Any | None = None,
accepted_status_codes: set[int] | None = None,
) -> Any:
url = f"{TICKTICK_OPEN_API_BASE_URL}{path}"
encoded_body = None if body is None else json.dumps(body).encode("utf-8")
req = request.Request(url, data=encoded_body, method=method)
req.add_header("Authorization", f"Bearer {self.settings.ticktick_token}")
if body is not None:
req.add_header("Content-Type", "application/json")
return self._send_json_request(
req,
operation=operation,
accepted_status_codes=accepted_status_codes,
)
def _send_json_request(
self,
req: request.Request,
*,
operation: str,
accepted_status_codes: set[int] | None = None,
) -> Any:
accepted_codes = accepted_status_codes or {200}
try:
with request.urlopen(req, timeout=self.timeout_seconds) as response:
status_code = response.getcode()
if status_code not in accepted_codes:
raise TickTickRequestError(
f"TickTick {operation} returned unexpected status {status_code}"
)
raw_body = response.read()
except error.HTTPError as exc:
if exc.code in accepted_codes:
raw_body = exc.read()
else:
logger.warning(
"TickTick %s failed with HTTP %s for %s",
operation,
exc.code,
req.full_url,
)
raise TickTickRequestError(
f"TickTick {operation} failed with HTTP {exc.code}"
) from exc
except error.URLError as exc:
logger.warning("TickTick %s failed for %s: %s", operation, req.full_url, exc)
raise TickTickRequestError(
f"TickTick {operation} failed to reach TickTick API"
) from exc
if not raw_body:
return None
try:
return json.loads(raw_body)
except json.JSONDecodeError as exc:
raise TickTickRequestError(
f"TickTick {operation} returned invalid JSON"
) from exc
def _basic_auth_header(self) -> str:
raw_credentials = f"{self._client_id()}:{self._client_secret()}"
token = base64.b64encode(raw_credentials.encode("utf-8")).decode("ascii")
return f"Basic {token}"
def _client_id(self) -> str:
return self.settings.ticktick_client_id.strip()
def _client_secret(self) -> str:
return self.settings.ticktick_client_secret.strip()
def _redirect_uri(self) -> str:
return self.settings.ticktick_redirect_uri.strip()
def _require_auth_config(self) -> None:
if not self.is_configured():
raise TickTickConfigError(
"TickTick integration is not configured. Set TICKTICK_CLIENT_ID and "
"TICKTICK_CLIENT_SECRET."
)
if not self._redirect_uri():
raise TickTickConfigError(
"TickTick integration is missing TICKTICK_REDIRECT_URI."
)
def _require_token(self) -> None:
self._require_auth_config()
if self.has_token():
return
raise TickTickConfigError(
"TickTick integration is missing TICKTICK_TOKEN. Complete the OAuth flow first."
)
+2
View File
@@ -12,6 +12,7 @@ import app.auth_db as auth_db
from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.homeassistant import router as homeassistant_router
from app.api.routes.location import router as location_router from app.api.routes.location import router as location_router
from app.api.routes.poo import router as poo_router from app.api.routes.poo import router as poo_router
from app.api.routes.ticktick import router as ticktick_router
from app.config import get_settings from app.config import get_settings
from app.services.auth import AuthBootstrapError, initialize_auth_schema from app.services.auth import AuthBootstrapError, initialize_auth_schema
from app.services.config_page import seed_missing_config_from_bootstrap from app.services.config_page import seed_missing_config_from_bootstrap
@@ -95,6 +96,7 @@ def create_app() -> FastAPI:
app.include_router(homeassistant_router) app.include_router(homeassistant_router)
app.include_router(location_router) app.include_router(location_router)
app.include_router(poo_router) app.include_router(poo_router)
app.include_router(ticktick_router)
return app return app
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel, ConfigDict, Field
class TickTickActionTaskRequest(BaseModel):
title: str | None = None
action: str
due_hour: int = Field(alias="due_hour")
model_config = ConfigDict(extra="forbid", populate_by_name=True)
+23
View File
@@ -173,6 +173,29 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s
reset_auth_db_caches() reset_auth_db_caches()
def save_config_value(
session: Session,
*,
env_name: str,
value: str,
bootstrap_settings: Settings,
) -> None:
current_values = _read_config_values(session)
current_values[env_name] = value
_validate_config_values(current_values, bootstrap_settings)
_persist_config_values(session, current_values)
get_settings.cache_clear()
reset_auth_db_caches()
def is_ticktick_oauth_ready(settings: Settings) -> bool:
return bool(
settings.ticktick_client_id
and settings.ticktick_client_secret
and settings.ticktick_redirect_uri
)
def _read_config_values(session: Session) -> dict[str, str]: def _read_config_values(session: Session) -> dict[str, str]:
rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all() rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all()
return {row.key: row.value for row in rows} return {row.key: row.value for row in rows}
+45 -1
View File
@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import UTC, datetime, time, timedelta
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.integrations.ticktick import TICKTICK_DATETIME_FORMAT, TickTickClient, TickTickTask
from app.schemas.homeassistant import HomeAssistantPublishEnvelope from app.schemas.homeassistant import HomeAssistantPublishEnvelope
from app.schemas.location import LocationRecordRequest from app.schemas.location import LocationRecordRequest
from app.schemas.ticktick import TickTickActionTaskRequest
from app.services.location import record_location from app.services.location import record_location
@@ -13,12 +16,18 @@ class UnsupportedHomeAssistantMessage(RuntimeError):
def handle_homeassistant_message( def handle_homeassistant_message(
session: Session, envelope: HomeAssistantPublishEnvelope session: Session,
envelope: HomeAssistantPublishEnvelope,
ticktick_client: TickTickClient | None = None,
) -> None: ) -> None:
if envelope.target == "location_recorder": if envelope.target == "location_recorder":
_handle_location_message(session, envelope) _handle_location_message(session, envelope)
return return
if envelope.target == "ticktick":
_handle_ticktick_message(envelope, ticktick_client)
return
raise UnsupportedHomeAssistantMessage( raise UnsupportedHomeAssistantMessage(
f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}"
) )
@@ -33,3 +42,38 @@ def _handle_location_message(session: Session, envelope: HomeAssistantPublishEnv
content = json.loads(envelope.content.replace("'", '"')) content = json.loads(envelope.content.replace("'", '"'))
payload = LocationRecordRequest.model_validate(content) payload = LocationRecordRequest.model_validate(content)
record_location(session, payload) record_location(session, payload)
def _handle_ticktick_message(
envelope: HomeAssistantPublishEnvelope,
ticktick_client: TickTickClient | None,
) -> None:
if envelope.action != "create_action_task":
raise UnsupportedHomeAssistantMessage(
f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}"
)
if ticktick_client is None:
raise UnsupportedHomeAssistantMessage("TickTick client is unavailable")
content = json.loads(envelope.content.replace("'", '"'))
payload = TickTickActionTaskRequest.model_validate(content)
project_id = ticktick_client.settings.home_assistant_action_task_project_id
if not project_id:
raise RuntimeError(
"TickTick action task integration is missing HOME_ASSISTANT_ACTION_TASK_PROJECT_ID"
)
ticktick_client.create_task(
TickTickTask(
projectId=project_id,
title=payload.action,
dueDate=build_action_task_due_date(datetime.now().astimezone(), payload.due_hour),
)
)
def build_action_task_due_date(now: datetime, due_hour: int) -> str:
local_now = now.astimezone()
due = local_now + timedelta(hours=due_hour)
next_midnight = datetime.combine(due.date(), time.min, tzinfo=local_now.tzinfo) + timedelta(days=1)
return next_midnight.astimezone(UTC).strftime(TICKTICK_DATETIME_FORMAT)
+52
View File
@@ -182,6 +182,53 @@ button:hover {
color: var(--muted); color: var(--muted);
} }
.integration-action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 8px;
border-top: 1px solid rgba(31, 41, 51, 0.08);
}
.integration-action-title {
margin: 0 0 6px;
font-weight: 600;
color: var(--text);
}
.integration-action-copy {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-width: 120px;
padding: 12px 18px;
border: none;
border-radius: 999px;
background: var(--accent);
color: white;
text-decoration: none;
cursor: pointer;
}
.button-link:hover {
filter: brightness(1.04);
}
.button-link.disabled {
background: rgba(91, 104, 117, 0.28);
color: rgba(31, 41, 51, 0.72);
cursor: not-allowed;
pointer-events: none;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.shell { .shell {
margin: 24px auto; margin: 24px auto;
@@ -190,4 +237,9 @@ button:hover {
.panel { .panel {
padding: 24px; padding: 24px;
} }
.integration-action-row {
align-items: stretch;
flex-direction: column;
}
} }
+26
View File
@@ -25,6 +25,14 @@
<div class="notice">config saved to .env. Some changes may require an app restart.</div> <div class="notice">config saved to .env. Some changes may require an app restart.</div>
{% endif %} {% endif %}
{% if ticktick_oauth_error %}
<div class="alert">{{ ticktick_oauth_error }}</div>
{% endif %}
{% if ticktick_oauth_notice %}
<div class="notice">{{ ticktick_oauth_notice }}</div>
{% endif %}
<div class="meta single-column"> <div class="meta single-column">
<div> <div>
<dt>当前用户</dt> <dt>当前用户</dt>
@@ -75,6 +83,24 @@
{% endif %} {% endif %}
</label> </label>
{% endfor %} {% endfor %}
{% if section.name == "TickTick" %}
<div class="integration-action-row">
<div>
<p class="integration-action-title">TickTick OAuth</p>
{% if ticktick_oauth_ready %}
<p class="integration-action-copy">Use the saved TickTick client settings to start the authorization flow.</p>
{% else %}
<p class="integration-action-copy">Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth.</p>
{% endif %}
</div>
{% if ticktick_oauth_ready %}
<a class="button-link" href="/ticktick/auth/start">Authorize TickTick</a>
{% else %}
<span class="button-link disabled" aria-disabled="true">Authorize TickTick</span>
{% endif %}
</div>
{% endif %}
</fieldset> </fieldset>
{% endfor %} {% endfor %}
+12 -3
View File
@@ -30,18 +30,27 @@
## 当前已支持的 Target / Action ## 当前已支持的 Target / Action
当前接回最小可用路径: 当前接回路径:
- `location_recorder / record` - `location_recorder / record`
- `ticktick / create_action_task`
它会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑。 其中:
- `location_recorder / record` 会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑
- `ticktick / create_action_task` 会沿用 legacy 行为,把 `content` 解析为:
- `action: string`
- `due_hour: int`
- 可选 `title` 字段会被忽略
- TickTick task title 仍使用 `action`
- due date 仍按 legacy 语义计算:先取 `now + due_hour`,再落到该日期的“次日零点”,最后转成 UTC 后写给 TickTick
- 具体 project 仍由 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` 提供
## 当前尚未接回 ## 当前尚未接回
以下 legacy 路径在当前阶段还没有迁入: 以下 legacy 路径在当前阶段还没有迁入:
- `poo_recorder / get_latest` - `poo_recorder / get_latest`
- `ticktick / create_action_task`
- 其他未定义 target/action - 其他未定义 target/action
这些请求当前会返回: 这些请求当前会返回:
+42
View File
@@ -0,0 +1,42 @@
# TickTick Integration
当前 Python 项目里的 TickTick 迁移先恢复 legacy 的最核心能力,不额外扩成更大的集成层。
## 当前已支持
- 运行时从 config 表读取 TickTick 配置,缺失时仍可 fallback `.env`
- `GET /ticktick/auth/start`
- 需要已登录 session
- 生成 OAuth `state`
- 直接重定向到 TickTick 授权页
- `GET /ticktick/auth/code`
- 校验进程内保存的 `state`
- 用 authorization code 换取 access token
-`TICKTICK_TOKEN` 持久化到 `app_config`
- TickTick Open API 基础调用:
- 列 project
- 列 project 下 task
- 创建 task
- 按 title 精确匹配做重复创建保护
- Home Assistant inbound 已重新接回 `ticktick / create_action_task`
## 当前配置项
- `TICKTICK_CLIENT_ID`
- `TICKTICK_CLIENT_SECRET`
- `TICKTICK_REDIRECT_URI`
- `TICKTICK_TOKEN`
- `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID`
## 兼容性说明
- 仍保留 legacy 的 OAuth authorization code flow
- `state` 仍是进程内临时状态;如果服务在 start 和 callback 之间重启,本轮实现下授权需要重新开始
- 不再把 token 写回 `.env` 或其他配置文件,统一写入 config 表
- 当前没有引入 legacy 的第三方 TickTick 库,先用标准库完成兼容行为
## 后续适合单独拆分的工作
- 给 config 页面增加明确的 TickTick 授权入口
- 增加 project 探测或选择能力,减少手工填写 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID`
- 如果后续发现 OAuth/token 生命周期需要更强健,再补 refresh token 或持久化 auth state
+78
View File
@@ -4,7 +4,9 @@ from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.auth_db import reset_auth_db_caches
from app.config import get_settings from app.config import get_settings
from app.main import create_app
def _extract_csrf_token(html: str) -> str: 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 "New Password" in config_response.text
assert "Save Config" in config_response.text assert "Save Config" in config_response.text
assert "当前用户" 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: 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["APP_NAME"] == "Updated Home Automation"
assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token" assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token"
assert "AUTH_BOOTSTRAP_USERNAME" not in rows 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
+19 -3
View File
@@ -109,7 +109,23 @@ def test_homeassistant_publish_rejects_missing_content(location_client) -> None:
assert "content" not in response.text 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 client, _ = location_client
response = client.post( 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.status_code == 400
assert response.text == "internal server error" assert response.text == "bad request"
def test_homeassistant_publish_returns_not_implemented_for_unknown_location_action( def test_homeassistant_publish_returns_not_implemented_for_unknown_location_action(
+383
View File
@@ -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"