diff --git a/backend/src/appointment/controller/apis/accounts_client.py b/backend/src/appointment/controller/apis/accounts_client.py index 3de6422f8..39fb4383a 100644 --- a/backend/src/appointment/controller/apis/accounts_client.py +++ b/backend/src/appointment/controller/apis/accounts_client.py @@ -104,6 +104,23 @@ def get_profile(self, token): logging.error(f'Could not retrieve profile, error occurred: {e.response.status_code} - {e.response.text}') raise e + def get_waffle_flags(self, token): + """Retrieve the waffle feature flags for the authenticated user.""" + try: + response = requests.get( + url=f'{self.accounts_url}/api/v1/auth/waffle-flags/', + headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'}, + ) + + response.raise_for_status() + + return response.json() + except requests.HTTPError as e: + logging.error( + f'Could not retrieve waffle flags, error occurred: {e.response.status_code} - {e.response.text}' + ) + raise e + def logout(self): """Invalidate the current refresh token""" try: diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index 35d21a836..9f2e67c7e 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -32,6 +32,7 @@ get_admin_subscriber, get_subscriber_from_onetime_token, get_accounts_client, + get_bearer_token, ) from ..controller import auth @@ -524,6 +525,21 @@ def logout( return True +@router.get('/auth/waffle-flags') +def waffle_flags( + token: str | None = Depends(get_bearer_token), + accounts_client: AccountsClient = Depends(get_accounts_client), +): + """Retrieve waffle feature flags for the current subscriber from Accounts.""" + if not AuthScheme.is_oidc(): + raise HTTPException(status_code=405) + + if token is None: + raise validation.InvalidTokenException() + + return accounts_client.get_waffle_flags(token) + + @router.get('/me', response_model=schemas.SubscriberMeOut) def me( db: Session = Depends(get_db), diff --git a/backend/test/integration/test_auth.py b/backend/test/integration/test_auth.py index 82b22c74a..6096aa9fe 100644 --- a/backend/test/integration/test_auth.py +++ b/backend/test/integration/test_auth.py @@ -7,6 +7,7 @@ from unittest.mock import patch from appointment.dependencies import auth +from appointment.dependencies.auth import get_accounts_client from appointment.routes.auth import create_access_token from defines import FXA_CLIENT_PATCH, auth_headers, TEST_USER_ID from appointment.database import repo, models @@ -1091,3 +1092,48 @@ def test_oidc_token_strips_domain_from_username(self, with_db, with_client, fake subscriber = repo.subscriber.get_by_email(db, email) assert subscriber is not None assert subscriber.username == expected_username + + +class TestWaffleFlags: + """Tests for the /auth/waffle-flags endpoint.""" + + @pytest.fixture(autouse=True) + def setup_teardown(self): + """Save and restore AUTH_SCHEME for each test""" + saved_scheme = os.environ.get('AUTH_SCHEME', 'password') + yield + os.environ['AUTH_SCHEME'] = saved_scheme + + def test_waffle_flags_fails_due_to_invalid_auth_scheme(self, with_client): + os.environ['AUTH_SCHEME'] = 'password' + + response = with_client.get('/auth/waffle-flags', headers=auth_headers) + + assert response.status_code == 405, response.text + + def test_waffle_flags_fails_without_authentication(self, with_client): + os.environ['AUTH_SCHEME'] = 'oidc' + + response = with_client.get('/auth/waffle-flags') + + assert response.status_code == 401, response.text + + def test_waffle_flags_forwards_callers_bearer_token(self, with_client): + os.environ['AUTH_SCHEME'] = 'oidc' + + mock_flags = {'flags': {'new-dashboard': True, 'beta-feature': False}} + captured_tokens = [] + + class MockAccountsClient: + def get_waffle_flags(self, token): + captured_tokens.append(token) + return mock_flags + + with_client.app.dependency_overrides[get_accounts_client] = lambda: MockAccountsClient() + + response = with_client.get('/auth/waffle-flags', headers=auth_headers) + + assert response.status_code == 200, response.text + assert response.json() == mock_flags + # auth_headers is `Bearer testtokenplsignore` + assert captured_tokens == ['testtokenplsignore'] diff --git a/backend/test/unit/test_accounts_client.py b/backend/test/unit/test_accounts_client.py new file mode 100644 index 000000000..b7f5154f7 --- /dev/null +++ b/backend/test/unit/test_accounts_client.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from appointment.controller.apis.accounts_client import AccountsClient + + +class TestAccountsClientWaffleFlags: + def test_get_waffle_flags_success(self): + accounts_client = AccountsClient('client_id', 'client_secret', 'callback_url') + accounts_client.accounts_url = 'https://accounts.example.org' + + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = {'flags': {'new-dashboard': True, 'beta-feature': False}} + + with patch( + 'appointment.controller.apis.accounts_client.requests.get', return_value=mock_response + ) as mock_get: + flags = accounts_client.get_waffle_flags('a-keycloak-access-token') + + assert flags == {'flags': {'new-dashboard': True, 'beta-feature': False}} + mock_get.assert_called_once_with( + url='https://accounts.example.org/api/v1/auth/waffle-flags/', + headers={'Accept': 'application/json', 'Authorization': 'Bearer a-keycloak-access-token'}, + ) + + def test_get_waffle_flags_raises_on_http_error(self): + accounts_client = AccountsClient('client_id', 'client_secret', 'callback_url') + accounts_client.accounts_url = 'https://accounts.example.org' + + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = 'Unauthorized' + mock_response.raise_for_status.side_effect = requests.HTTPError(response=mock_response) + + with patch('appointment.controller.apis.accounts_client.requests.get', return_value=mock_response): + with pytest.raises(requests.HTTPError): + accounts_client.get_waffle_flags('bad-token') diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6daa217c6..d7c2f9776 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -61,7 +61,7 @@ if (useSentry) { 'localhost', 'https://stage.appointment.day', 'https://appointment-stage.tb.pro', - 'https://appointment.tp.pro', + 'https://appointment.tb.pro', 'https://apmt.day', 'https://apt.mt', ], diff --git a/frontend/src/models.ts b/frontend/src/models.ts index 2c587faf1..4fccd06f2 100644 --- a/frontend/src/models.ts +++ b/frontend/src/models.ts @@ -236,6 +236,7 @@ export type User = { scheduleSlugs: object; isSetup: boolean; uniqueHash: string; + featureFlags: WaffleFlags; }; export type SettingsForm = { @@ -360,6 +361,8 @@ export type SlotResponse = UseFetchReturn<(Slot & Appointment) | Exception>; export type StringResponse = UseFetchReturn; export type SubscriberResponse = UseFetchReturn; export type TokenResponse = UseFetchReturn; +export type WaffleFlags = Record; +export type WaffleFlagsResponse = UseFetchReturn<{ flags: WaffleFlags }>; export type ListResponse = UseFetchReturn<{ page_meta: PageMeta; items: any[]; diff --git a/frontend/src/stores/user-store.ts b/frontend/src/stores/user-store.ts index 472fb7a43..e064606a1 100644 --- a/frontend/src/stores/user-store.ts +++ b/frontend/src/stores/user-store.ts @@ -12,6 +12,7 @@ import { SubscriberResponse, TokenResponse, UserConfig, + WaffleFlagsResponse, } from '@/models'; import { usePosthog, posthog } from '@/composables/posthog'; import { dayjsKey } from '@/keys'; @@ -41,6 +42,7 @@ const initialUserObject = { scheduleSlugs: {}, isSetup: false, uniqueHash: null, + featureFlags: {}, } as User; export const useUserStore = defineStore('user', () => { @@ -186,6 +188,23 @@ export const useUserStore = defineStore('user', () => { return { error: false }; }; + /** + * Retrieve the current waffle feature flags and update store + */ + const updateFeatureFlags = async (): Promise => { + const { error, data: flagsData }: WaffleFlagsResponse = await call.value('auth/waffle-flags').get().json(); + + if (error.value) { + return { error: flagsData.value ?? error.value }; + } + + // We are getting flags, samples and switches from django-waffle in Accounts + // but we only care about flags for now + data.value.featureFlags = flagsData.value?.flags ?? {}; + + return { error: false }; + }; + /** * Retrieve the current signed url and update store * @param inputData Subscriber data to throw into the db @@ -318,6 +337,7 @@ export const useUserStore = defineStore('user', () => { authenticated, $reset, updateSignedUrl, + updateFeatureFlags, profile, updateProfile, changeSignedUrl, diff --git a/frontend/src/views/PostLoginView.vue b/frontend/src/views/PostLoginView.vue index 1d310d5e7..bd0b95755 100644 --- a/frontend/src/views/PostLoginView.vue +++ b/frontend/src/views/PostLoginView.vue @@ -55,6 +55,12 @@ onMounted(async () => { // Run health checks on external connections in the background externalConnectionsStore.checkStatus(); + // Fetch waffle feature flags and store them on the user + const { error: featureFlagsError } = await user.updateFeatureFlags(); + if (featureFlagsError) { + console.error('Could not retrieve waffle flags', featureFlagsError); + } + // If we don't have a redirectTo or it's to logout then push to dashboard! if (!redirectTo || redirectTo === '/logout') { await router.push('/dashboard');