diff --git a/backend/.env.example b/backend/.env.example index 2916e696d1..3da6b66940 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,6 +10,13 @@ DJANGO_DB_NAME=None DJANGO_DB_PASSWORD=None DJANGO_DB_PORT=None DJANGO_DB_USER=None +DJANGO_GOOGLE_AUTH_AUTH_URI=https://accounts.google.com/o/oauth2/auth +DJANGO_GOOGLE_AUTH_CLIENT_ID=None +DJANGO_GOOGLE_AUTH_CLIENT_SECRET=None +DJANGO_GOOGLE_AUTH_REDIRECT_URI=http://localhost:8000/auth/google/callback/ +DJANGO_GOOGLE_AUTH_SCOPES=https://www.googleapis.com/auth/calendar.readonly +DJANGO_GOOGLE_AUTH_TOKEN_URI=https://oauth2.googleapis.com/token +DJANGO_IS_GOOGLE_AUTH_ENABLED=False DJANGO_OPEN_AI_SECRET_KEY=None DJANGO_PUBLIC_IP_ADDRESS="127.0.0.1" DJANGO_REDIS_HOST=None diff --git a/backend/apps/common/clients.py b/backend/apps/common/clients.py new file mode 100644 index 0000000000..68f1e1fd07 --- /dev/null +++ b/backend/apps/common/clients.py @@ -0,0 +1,20 @@ +"""Common API Clients.""" + +from django.conf import settings +from google_auth_oauthlib.flow import Flow + + +def get_google_auth_client(): + """Get a Google OAuth client.""" + return Flow.from_client_config( + client_config={ + "web": { + "client_id": settings.GOOGLE_AUTH_CLIENT_ID, + "client_secret": settings.GOOGLE_AUTH_CLIENT_SECRET, + "redirect_uris": [settings.GOOGLE_AUTH_REDIRECT_URI], + "auth_uri": settings.GOOGLE_AUTH_AUTH_URI, + "token_uri": settings.GOOGLE_AUTH_TOKEN_URI, + } + }, + scopes=settings.GOOGLE_AUTH_SCOPES, + ) diff --git a/backend/apps/nest/models/__init__.py b/backend/apps/nest/models/__init__.py index 597b834442..5dd1ca2380 100644 --- a/backend/apps/nest/models/__init__.py +++ b/backend/apps/nest/models/__init__.py @@ -1,3 +1,4 @@ from .api_key import ApiKey from .badge import Badge +from .member_google_credentials import MemberGoogleCredentials from .user import User diff --git a/backend/apps/nest/models/member_google_credentials.py b/backend/apps/nest/models/member_google_credentials.py new file mode 100644 index 0000000000..35b5fc7622 --- /dev/null +++ b/backend/apps/nest/models/member_google_credentials.py @@ -0,0 +1,131 @@ +"""Slack Google OAuth Authentication Model.""" + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials + +from apps.common.clients import get_google_auth_client +from apps.slack.models.member import Member + +AUTH_ERROR_MESSAGE = ( + "Google OAuth client ID, secret, and redirect URI must be set in environment variables." +) + + +class MemberGoogleCredentials(models.Model): + """Model to store Google OAuth tokens for Slack integration.""" + + class Meta: + db_table = "nest_member_google_credentials" + verbose_name_plural = "Member's Google Credentials" + + member = models.OneToOneField( + "slack.Member", + on_delete=models.CASCADE, + related_name="member_google_credentials", + verbose_name="Slack Member", + ) + access_token = models.BinaryField(verbose_name="Access Token", null=True) + refresh_token = models.BinaryField(verbose_name="Refresh Token", null=True) + expires_at = models.DateTimeField( + verbose_name="Token Expiry", + null=True, + ) + + @staticmethod + def authenticate(member): + """Authenticate a member. + + Returns: + - MemberGoogleCredentials instance if a valid/refreshable token exists, or + - (authorization_url, state) tuple to complete the OAuth flow. + + """ + if not settings.IS_GOOGLE_AUTH_ENABLED: + raise ValueError(AUTH_ERROR_MESSAGE) + auth = MemberGoogleCredentials.objects.get_or_create(member=member)[0] + if auth.access_token and not auth.is_token_expired: + return auth + if auth.access_token: + # If the access token is present but expired, refresh it + MemberGoogleCredentials.refresh_access_token(auth) + return auth + # If no access token is present, redirect to Google OAuth + flow = MemberGoogleCredentials.get_flow() + flow.redirect_uri = settings.GOOGLE_AUTH_REDIRECT_URI + state = member.slack_user_id + return flow.authorization_url( + access_type="offline", + prompt="consent", + state=state, + ) + + @staticmethod + def authenticate_callback(auth_response, member_id): + """Authenticate a member and return a MemberGoogleCredentials instance.""" + if not settings.IS_GOOGLE_AUTH_ENABLED: + raise ValueError(AUTH_ERROR_MESSAGE) + + member = None + try: + member = Member.objects.get(slack_user_id=member_id) + except Member.DoesNotExist as e: + error_message = f"Member with Slack ID {member_id} does not exist." + raise ValidationError(error_message) from e + + auth = MemberGoogleCredentials.objects.get_or_create(member=member)[0] + # This is the first time authentication, so we need to fetch a new token + flow = MemberGoogleCredentials.get_flow() + flow.redirect_uri = settings.GOOGLE_AUTH_REDIRECT_URI + flow.fetch_token(authorization_response=auth_response) + auth.access_token = flow.credentials.token + auth.refresh_token = flow.credentials.refresh_token + expires_at = flow.credentials.expiry + if expires_at and timezone.is_naive(expires_at): + expires_at = timezone.make_aware(expires_at) + auth.expires_at = expires_at + auth.save() + return auth + + @staticmethod + def get_flow(): + """Create a Google OAuth flow instance.""" + if not settings.IS_GOOGLE_AUTH_ENABLED: + raise ValueError(AUTH_ERROR_MESSAGE) + return get_google_auth_client() + + @property + def is_token_expired(self): + """Check if the access token is expired.""" + return self.expires_at is None or self.expires_at <= timezone.now() + timezone.timedelta( + seconds=60 + ) + + @staticmethod + def refresh_access_token(auth): + """Refresh the access token using the refresh token.""" + if not settings.IS_GOOGLE_AUTH_ENABLED: + raise ValueError(AUTH_ERROR_MESSAGE) + refresh_error = "Google OAuth refresh token is not set or expired." + if not auth.refresh_token: + raise ValidationError(refresh_error) + credentials = Credentials( + token=auth.access_token, + refresh_token=auth.refresh_token, + token_uri=settings.GOOGLE_AUTH_TOKEN_URI, + client_id=settings.GOOGLE_AUTH_CLIENT_ID, + client_secret=settings.GOOGLE_AUTH_CLIENT_SECRET, + ) + credentials.refresh(Request()) + + auth.access_token = credentials.token + auth.refresh_token = credentials.refresh_token + auth.expires_at = credentials.expiry + auth.save() + + def __str__(self): + """Return a string representation of the MemberGoogleCredentials instance.""" + return f"MemberGoogleCredentials(member={self.member})" diff --git a/backend/poetry.lock b/backend/poetry.lock index 6ace034c39..5f85582386 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -288,6 +288,18 @@ urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version > [package.extras] crt = ["awscrt (==0.27.6)"] +[[package]] +name = "cachetools" +version = "5.5.2" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, + {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -1101,6 +1113,130 @@ dev-test = ["coverage", "pytest (>=3.10)", "pytest-asyncio (>=0.17)", "sphinx (< requests = ["requests (>=2.16.2)", "urllib3 (>=1.24.2)"] timezone = ["pytz"] +[[package]] +name = "google-api-core" +version = "2.25.1" +description = "Google API client core library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, + {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.0" +googleapis-common-protos = ">=1.56.2,<2.0.0" +proto-plus = {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""} +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" +requests = ">=2.18.0,<3.0.0" + +[package.extras] +async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] +grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\""] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] + +[[package]] +name = "google-api-python-client" +version = "2.178.0" +description = "Google API Client Library for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_api_python_client-2.178.0-py3-none-any.whl", hash = "sha256:f420adcd050150ff1baefa817e96e1ffa16872744f53471cd34096612e580c34"}, + {file = "google_api_python_client-2.178.0.tar.gz", hash = "sha256:99cba921eb471bb5973b780c653ac54d96eef8a42f1b7375b7ab98f257a4414c"}, +] + +[package.dependencies] +google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0" +google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +google-auth-httplib2 = ">=0.2.0,<1.0.0" +httplib2 = ">=0.19.0,<1.0.0" +uritemplate = ">=3.0.1,<5" + +[[package]] +name = "google-auth" +version = "2.40.3" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca"}, + {file = "google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] +enterprise-cert = ["cryptography", "pyopenssl"] +pyjwt = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] +pyopenssl = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0)"] +testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] +urllib3 = ["packaging", "urllib3"] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +description = "Google Authentication Library: httplib2 transport" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, + {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, +] + +[package.dependencies] +google-auth = "*" +httplib2 = ">=0.19.0" + +[[package]] +name = "google-auth-oauthlib" +version = "1.2.2" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "google_auth_oauthlib-1.2.2-py3-none-any.whl", hash = "sha256:fd619506f4b3908b5df17b65f39ca8d66ea56986e5472eb5978fd8f3786f00a2"}, + {file = "google_auth_oauthlib-1.2.2.tar.gz", hash = "sha256:11046fb8d3348b296302dd939ace8af0a724042e8029c1b872d87fabc9f41684"}, +] + +[package.dependencies] +google-auth = ">=2.15.0" +requests-oauthlib = ">=0.7.0" + +[package.extras] +tool = ["click (>=6.0.0)"] + +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, + {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, +] + +[package.dependencies] +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0)"] + [[package]] name = "graphql-core" version = "3.2.6" @@ -1238,6 +1374,21 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<1.0)"] +[[package]] +name = "httplib2" +version = "0.22.0" +description = "A comprehensive HTTP client library." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, + {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, +] + +[package.dependencies] +pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} + [[package]] name = "httpx" version = "0.28.1" @@ -2134,6 +2285,23 @@ files = [ {file = "numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48"}, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"}, + {file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + [[package]] name = "openai" version = "1.100.2" @@ -2597,6 +2765,43 @@ files = [ {file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"}, ] +[[package]] +name = "proto-plus" +version = "1.26.1" +description = "Beautiful, Pythonic protocol buffers" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, + {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, +] + +[package.dependencies] +protobuf = ">=3.19.0,<7.0.0" + +[package.extras] +testing = ["google-api-core (>=1.31.5)"] + +[[package]] +name = "protobuf" +version = "6.31.1" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9"}, + {file = "protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447"}, + {file = "protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402"}, + {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39"}, + {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6"}, + {file = "protobuf-6.31.1-cp39-cp39-win32.whl", hash = "sha256:0414e3aa5a5f3ff423828e1e6a6e907d6c65c1d5b7e6e975793d5590bdeecc16"}, + {file = "protobuf-6.31.1-cp39-cp39-win_amd64.whl", hash = "sha256:8764cf4587791e7564051b35524b72844f845ad0bb011704c3736cce762d8fe9"}, + {file = "protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e"}, + {file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"}, +] + [[package]] name = "psycopg2-binary" version = "2.9.10" @@ -2675,6 +2880,33 @@ files = [ {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, +] + +[package.dependencies] +pyasn1 = ">=0.6.1,<0.7.0" + [[package]] name = "pycparser" version = "2.22" @@ -2927,6 +3159,21 @@ cffi = ">=1.4.1" docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] +[[package]] +name = "pyparsing" +version = "3.2.3" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf"}, + {file = "pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + [[package]] name = "pytest" version = "8.4.1" @@ -3402,6 +3649,25 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=3.4" +groups = ["main"] +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + [[package]] name = "requests-toolbelt" version = "1.0.0" @@ -3582,6 +3848,21 @@ files = [ {file = "rpds_py-0.27.0.tar.gz", hash = "sha256:8b23cf252f180cda89220b378d917180f29d313cd6a07b2431c0d3b776aae86f"}, ] +[[package]] +name = "rsa" +version = "4.9.1" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "<4,>=3.6" +groups = ["main"] +files = [ + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "ruff" version = "0.12.9" @@ -3749,6 +4030,62 @@ optional = false python-versions = ">=3.7" groups = ["main"] files = [ + {file = "SQLAlchemy-2.0.43-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07097c0a1886c150ef2adba2ff7437e84d40c0f7dcb44a2c2b9c905ccfc6361c"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cdeff998cb294896a34e5b2f00e383e7c5c4ef3b4bfa375d9104723f15186443"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:bcf0724a62a5670e5718957e05c56ec2d6850267ea859f8ad2481838f889b42c"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-win32.whl", hash = "sha256:c697575d0e2b0a5f0433f679bda22f63873821d991e95a90e9e52aae517b2e32"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-win_amd64.whl", hash = "sha256:d34c0f6dbefd2e816e8f341d0df7d4763d382e3f452423e752ffd1e213da2512"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-win32.whl", hash = "sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-win_amd64.whl", hash = "sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-win32.whl", hash = "sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-win_amd64.whl", hash = "sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4e6aeb2e0932f32950cf56a8b4813cb15ff792fc0c9b3752eaf067cfe298496a"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f964a05356f4bca4112e6334ed7c208174511bd56e6b8fc86dad4d024d4185"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46293c39252f93ea0910aababa8752ad628bcce3a10d3f260648dd472256983f"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136063a68644eca9339d02e6693932116f6a8591ac013b0014479a1de664e40a"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6e2bf13d9256398d037fef09fd8bf9b0bf77876e22647d10761d35593b9ac547"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:44337823462291f17f994d64282a71c51d738fc9ef561bf265f1d0fd9116a782"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-win32.whl", hash = "sha256:13194276e69bb2af56198fef7909d48fd34820de01d9c92711a5fa45497cc7ed"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-win_amd64.whl", hash = "sha256:334f41fa28de9f9be4b78445e68530da3c5fa054c907176460c81494f4ae1f5e"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ceb5c832cc30663aeaf5e39657712f4c4241ad1f638d487ef7216258f6d41fe7"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11f43c39b4b2ec755573952bbcc58d976779d482f6f832d7f33a8d869ae891bf"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:413391b2239db55be14fa4223034d7e13325a1812c8396ecd4f2c08696d5ccad"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c379e37b08c6c527181a397212346be39319fb64323741d23e46abd97a400d34"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03d73ab2a37d9e40dec4984d1813d7878e01dbdc742448d44a7341b7a9f408c7"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8cee08f15d9e238ede42e9bbc1d6e7158d0ca4f176e4eab21f88ac819ae3bd7b"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-win32.whl", hash = "sha256:b3edaec7e8b6dc5cd94523c6df4f294014df67097c8217a89929c99975811414"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-win_amd64.whl", hash = "sha256:227119ce0a89e762ecd882dc661e0aa677a690c914e358f0dd8932a2e8b2765b"}, + {file = "sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc"}, {file = "sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417"}, ] @@ -3966,6 +4303,18 @@ files = [ {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] +[[package]] +name = "uritemplate" +version = "4.2.0" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686"}, + {file = "uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e"}, +] + [[package]] name = "urllib3" version = "2.5.0" @@ -4254,4 +4603,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "52c97a78f5d6a7d2367d6bb92032038024f09b8dc3a32e156fdb0c793fff63ec" +content-hash = "2012735a4addfeab3c2c0b209539d224b8367d5801e9f36dc96e63eaf202366c" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 770bbe8c16..6269e07289 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,6 +22,9 @@ django-redis = "^6.0.0" django-storages = { extras = [ "s3" ], version = "^1.14.4" } emoji = "^2.14.1" geopy = "^2.4.1" +google-api-python-client = "^2.178.0" +google-auth-httplib2 = "^0.2.0" +google-auth-oauthlib = "^1.2.2" gunicorn = "^23.0.0" humanize = "^4.11.0" jinja2 = "^3.1.6" diff --git a/backend/settings/base.py b/backend/settings/base.py index b00a731afb..5dbc383264 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -19,6 +19,14 @@ class Base(Configuration): DEBUG = False GITHUB_APP_ID = None GITHUB_APP_INSTALLATION_ID = None + GOOGLE_AUTH_AUTH_URI = values.Value() + GOOGLE_AUTH_CLIENT_ID = values.SecretValue() + GOOGLE_AUTH_CLIENT_SECRET = values.SecretValue() + GOOGLE_AUTH_REDIRECT_URI = values.Value() + GOOGLE_AUTH_SCOPES = values.ListValue() + GOOGLE_AUTH_TOKEN_URI = values.Value() + + IS_GOOGLE_AUTH_ENABLED = values.BooleanValue(default=False) IS_LOCAL_ENVIRONMENT = False IS_PRODUCTION_ENVIRONMENT = False IS_STAGING_ENVIRONMENT = False diff --git a/backend/tests/apps/nest/models/member_google_credentials_test.py b/backend/tests/apps/nest/models/member_google_credentials_test.py new file mode 100644 index 0000000000..54a77a929e --- /dev/null +++ b/backend/tests/apps/nest/models/member_google_credentials_test.py @@ -0,0 +1,325 @@ +"""Tests for MemberGoogleCredentials model.""" + +from datetime import timedelta +from unittest.mock import Mock, patch + +import pytest +from django.conf import settings +from django.core.exceptions import ValidationError +from django.test import override_settings +from django.utils import timezone +from google_auth_oauthlib.flow import Flow + +from apps.nest.models.member_google_credentials import MemberGoogleCredentials +from apps.slack.models.member import Member + + +class TestMemberGoogleCredentialsModel: + """Test cases for MemberGoogleCredentials model.""" + + @pytest.fixture(autouse=True) + def setUp(self): + """Set up test data.""" + self.member = Member(slack_user_id="U123456789", username="testuser") + self.valid_token = b"valid_token" + self.valid_refresh_token = b"valid_refresh_token" + self.expired_time = timezone.now() - timedelta(hours=1) + self.future_time = timezone.now() + timedelta(hours=1) + + def test_member_google_credentials_creation(self): + """Test MemberGoogleCredentials model creation.""" + auth = MemberGoogleCredentials( + member=self.member, + access_token=self.valid_token, + refresh_token=self.valid_refresh_token, + expires_at=self.future_time, + ) + + assert auth.member == self.member + assert auth.access_token == self.valid_token + assert auth.refresh_token == self.valid_refresh_token + assert auth.expires_at == self.future_time + + def test_string_representation(self): + """Test string representation of MemberGoogleCredentials.""" + auth = MemberGoogleCredentials(member=self.member, access_token=self.valid_token) + + expected = f"MemberGoogleCredentials(member={self.member})" + assert str(auth) == expected + + def test_one_to_one_relationship(self): + """Test one-to-one relationship with Member.""" + auth = MemberGoogleCredentials(member=self.member, access_token=self.valid_token) + + assert self.member.member_google_credentials == auth + assert auth.member == self.member + + def test_is_token_expired_with_future_expiry(self): + """Test is_token_expired property with future expiry.""" + auth = MemberGoogleCredentials( + member=self.member, access_token=self.valid_token, expires_at=self.future_time + ) + + assert not auth.is_token_expired + + def test_is_token_expired_with_past_expiry(self): + """Test is_token_expired property with past expiry.""" + auth = MemberGoogleCredentials( + member=self.member, access_token=self.valid_token, expires_at=self.expired_time + ) + + assert auth.is_token_expired + + def test_is_token_expired_with_none_expiry(self): + """Test is_token_expired property with None expiry.""" + auth = MemberGoogleCredentials( + member=self.member, access_token=self.valid_token, expires_at=None + ) + + assert auth.is_token_expired + + @override_settings(IS_GOOGLE_AUTH_ENABLED=False) + def test_get_flow_when_disabled(self): + """Test get_flow raises error when Google auth is disabled.""" + with pytest.raises(ValueError, match="Google OAuth client ID"): + MemberGoogleCredentials.get_flow() + + @override_settings(IS_GOOGLE_AUTH_ENABLED=False) + def test_authenticate_when_disabled(self): + """Test authenticate raises error when Google auth is disabled.""" + with pytest.raises(ValueError, match="Google OAuth client ID"): + MemberGoogleCredentials.authenticate(self.member) + + @override_settings( + IS_GOOGLE_AUTH_ENABLED=True, + GOOGLE_AUTH_CLIENT_ID="test_client_id", + GOOGLE_AUTH_CLIENT_SECRET="test_client_secret", # noqa: S106 + GOOGLE_AUTH_REDIRECT_URI="http://localhost:8000/callback", + ) + @patch("apps.nest.models.member_google_credentials.MemberGoogleCredentials.save") + @patch( + "apps.nest.models.member_google_credentials.MemberGoogleCredentials.objects.get_or_create" + ) + @patch("apps.nest.models.member_google_credentials.MemberGoogleCredentials.get_flow") + def test_authenticate_existing_valid_token(self, mock_get_flow, mock_get_or_create, mock_save): + """Test authenticate with existing valid token.""" + # Create existing auth with valid token + + mock_get_flow.return_value = Mock(spec=Flow) + mock_get_or_create.return_value = ( + MemberGoogleCredentials( + member=self.member, + access_token=self.valid_token, + refresh_token=self.valid_refresh_token, + expires_at=self.future_time, + ), + True, + ) + result = MemberGoogleCredentials.authenticate(self.member) + + assert result.access_token == self.valid_token + assert result.refresh_token == self.valid_refresh_token + assert result.expires_at == self.future_time + mock_get_or_create.assert_called_once_with(member=self.member) + mock_save.assert_not_called() + + @override_settings( + IS_GOOGLE_AUTH_ENABLED=True, + GOOGLE_AUTH_CLIENT_ID="test_client_id", + GOOGLE_AUTH_CLIENT_SECRET="test_client_secret", # noqa: S106 + GOOGLE_AUTH_REDIRECT_URI="http://localhost:8000/callback", + ) + @patch( + "apps.nest.models.member_google_credentials.MemberGoogleCredentials.refresh_access_token" + ) + @patch( + "apps.nest.models.member_google_credentials.MemberGoogleCredentials.objects.get_or_create" + ) + def test_authenticate_existing_expired_token(self, mock_get_or_create, mock_refresh): + """Test authenticate with existing expired token.""" + # Create existing auth with expired token + existing_auth = MemberGoogleCredentials( + member=self.member, + access_token=self.valid_token, + refresh_token=self.valid_refresh_token, + expires_at=self.expired_time, + ) + mock_get_or_create.return_value = (existing_auth, False) + + MemberGoogleCredentials.authenticate(self.member) + + mock_refresh.assert_called_once_with(existing_auth) + mock_get_or_create.assert_called_once_with(member=self.member) + + @override_settings( + IS_GOOGLE_AUTH_ENABLED=True, + GOOGLE_AUTH_CLIENT_ID="test_client_id", + GOOGLE_AUTH_CLIENT_SECRET="test_client_secret", # noqa: S106 + GOOGLE_AUTH_REDIRECT_URI="http://localhost:8000/callback", + ) + @patch("apps.nest.models.member_google_credentials.MemberGoogleCredentials.get_flow") + @patch( + "apps.nest.models.member_google_credentials.MemberGoogleCredentials.objects.get_or_create" + ) + def test_authenticate_first_time(self, mock_get_or_create, mock_get_flow): + """Test authenticate for first time (no existing token).""" + # Mock flow and credentials + mock_flow_instance = Mock() + mock_get_flow.return_value = mock_flow_instance + mock_get_or_create.return_value = ( + MemberGoogleCredentials( + member=self.member, + access_token=None, + refresh_token=None, + expires_at=None, + ), + True, + ) + MemberGoogleCredentials.authenticate(self.member) + + mock_get_or_create.assert_called_once_with(member=self.member) + + mock_flow_instance.authorization_url.assert_called_once_with( + access_type="offline", + prompt="consent", + state=self.member.slack_user_id, + ) + + @override_settings(IS_GOOGLE_AUTH_ENABLED=False) + def test_refresh_access_token_when_disabled(self): + """Test refresh_access_token raises error when Google auth is disabled.""" + auth = MemberGoogleCredentials( + member=self.member, + access_token=self.valid_token, + refresh_token=self.valid_refresh_token, + ) + + with pytest.raises(ValueError, match="Google OAuth client ID"): + MemberGoogleCredentials.refresh_access_token(auth) + + @override_settings( + IS_GOOGLE_AUTH_ENABLED=True, + GOOGLE_AUTH_CLIENT_ID="test_client_id", + GOOGLE_AUTH_CLIENT_SECRET="test_client_secret", # noqa: S106 + GOOGLE_AUTH_REDIRECT_URI="http://localhost:8000/callback", + ) + @patch("apps.nest.models.member_google_credentials.Credentials") + @patch("apps.nest.models.member_google_credentials.Request") + @patch("apps.nest.models.member_google_credentials.MemberGoogleCredentials.save") + def test_refresh_access_token_success(self, mock_save, mock_request, mock_credentials): + """Test successful refresh_access_token.""" + # Create auth with refresh token + auth = MemberGoogleCredentials( + member=self.member, + access_token=self.valid_token, + refresh_token=self.valid_refresh_token, + expires_at=self.expired_time, + ) + + # Mock flow and new credentials + mock_credentials_instance = Mock() + mock_credentials_instance.token = b"token" # NOSONAR + mock_credentials_instance.refresh_token = b"refresh_token" + mock_credentials_instance.expiry = self.future_time + + mock_credentials.return_value = mock_credentials_instance + + MemberGoogleCredentials.refresh_access_token(auth) + + assert auth.access_token == b"token" + assert auth.refresh_token == b"refresh_token" + assert auth.expires_at == self.future_time + + mock_credentials.assert_called_once_with( + token=self.valid_token, + refresh_token=self.valid_refresh_token, + token_uri=settings.GOOGLE_AUTH_TOKEN_URI, + client_id=settings.GOOGLE_AUTH_CLIENT_ID, + client_secret=settings.GOOGLE_AUTH_CLIENT_SECRET, + ) + mock_credentials_instance.refresh.assert_called_once_with(mock_request.return_value) + mock_save.assert_called_once() + + @override_settings( + IS_GOOGLE_AUTH_ENABLED=True, + GOOGLE_AUTH_CLIENT_ID="test_client_id", + GOOGLE_AUTH_CLIENT_SECRET="test_client_secret", # noqa: S106 + GOOGLE_AUTH_REDIRECT_URI="http://localhost:8000/callback", + ) + def test_refresh_token_not_found(self): + """Test refresh_access_token raises error when no refresh token is present.""" + auth = MemberGoogleCredentials( + member=self.member, + access_token=self.valid_token, + refresh_token=None, + ) + + with pytest.raises( + ValidationError, match="Google OAuth refresh token is not set or expired." + ): + MemberGoogleCredentials.refresh_access_token(auth) + + def test_verbose_names(self): + """Test model field verbose names.""" + auth = MemberGoogleCredentials(member=self.member, access_token=self.valid_token) + + assert auth._meta.get_field("member").verbose_name == "Slack Member" + assert auth._meta.get_field("access_token").verbose_name == "Access Token" + assert auth._meta.get_field("refresh_token").verbose_name == "Refresh Token" + assert auth._meta.get_field("expires_at").verbose_name == "Token Expiry" + + @override_settings(IS_GOOGLE_AUTH_ENABLED=False) + def test_authenticate_callback_member_google_credentials_disabled(self): + """Test authenticate_callback raises error when Google auth is disabled.""" + with pytest.raises(ValueError, match="Google OAuth client ID"): + MemberGoogleCredentials.authenticate_callback(auth_response={}, member_id=4) + + @override_settings( + IS_GOOGLE_AUTH_ENABLED=True, + GOOGLE_AUTH_CLIENT_ID="test_client_id", + GOOGLE_AUTH_CLIENT_SECRET="test_client_secret", # noqa: S106 + GOOGLE_AUTH_REDIRECT_URI="http://localhost:8000/callback", + ) + @patch("apps.nest.models.member_google_credentials.MemberGoogleCredentials.get_flow") + @patch( + "apps.nest.models.member_google_credentials.MemberGoogleCredentials.objects.get_or_create" + ) + @patch("apps.nest.models.member_google_credentials.MemberGoogleCredentials.save") + @patch("apps.nest.models.member_google_credentials.Member.objects.get") + def test_authenticate_callback_success( + self, mock_member_get, mock_save, mock_get_or_create, mock_get_flow + ): + """Test successful authenticate_callback.""" + mock_credentials = Mock() + mock_credentials.token = b"token" # NOSONAR + mock_credentials.refresh_token = b"refresh_token" + mock_credentials.expiry = self.future_time + + mock_flow_instance = Mock(spec=Flow) + mock_flow_instance.credentials = mock_credentials + mock_member_get.return_value = self.member + mock_get_flow.return_value = mock_flow_instance + mock_get_or_create.return_value = (MemberGoogleCredentials(member=self.member), False) + result = MemberGoogleCredentials.authenticate_callback( + {}, member_id=self.member.slack_user_id + ) + + assert result.access_token == b"token" + assert result.refresh_token == b"refresh_token" + assert result.expires_at == self.future_time + mock_get_or_create.assert_called_once_with(member=self.member) + mock_save.assert_called_once() + mock_flow_instance.fetch_token.assert_called_once_with(authorization_response={}) + + @override_settings( + IS_GOOGLE_AUTH_ENABLED=True, + GOOGLE_AUTH_CLIENT_ID="test_client_id", + GOOGLE_AUTH_CLIENT_SECRET="test_client_secret", # noqa: S106 + GOOGLE_AUTH_REDIRECT_URI="http://localhost:8000/callback", + ) + @patch("apps.slack.models.member.Member.objects.get") + def test_authenticate_callback_member_not_found(self, mock_member_get): + """Test authenticate_callback raises error when member is not found.""" + mock_member_get.side_effect = Member.DoesNotExist + with pytest.raises(ValidationError, match="Member with Slack ID 4 does not exist."): + MemberGoogleCredentials.authenticate_callback(auth_response={}, member_id=4)