Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions backend/src/appointment/controller/apis/accounts_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions backend/src/appointment/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
get_admin_subscriber,
get_subscriber_from_onetime_token,
get_accounts_client,
get_bearer_token,
)

from ..controller import auth
Expand Down Expand Up @@ -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),
Expand Down
46 changes: 46 additions & 0 deletions backend/test/integration/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']
40 changes: 40 additions & 0 deletions backend/test/unit/test_accounts_client.py
Original file line number Diff line number Diff line change
@@ -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')
2 changes: 1 addition & 1 deletion frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export type User = {
scheduleSlugs: object;
isSetup: boolean;
uniqueHash: string;
featureFlags: WaffleFlags;
};

export type SettingsForm = {
Expand Down Expand Up @@ -360,6 +361,8 @@ export type SlotResponse = UseFetchReturn<(Slot & Appointment) | Exception>;
export type StringResponse = UseFetchReturn<string | Exception>;
export type SubscriberResponse = UseFetchReturn<Subscriber>;
export type TokenResponse = UseFetchReturn<Token>;
export type WaffleFlags = Record<string, boolean>;
export type WaffleFlagsResponse = UseFetchReturn<{ flags: WaffleFlags }>;
export type ListResponse = UseFetchReturn<{
page_meta: PageMeta;
items: any[];
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/stores/user-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
SubscriberResponse,
TokenResponse,
UserConfig,
WaffleFlagsResponse,
} from '@/models';
import { usePosthog, posthog } from '@/composables/posthog';
import { dayjsKey } from '@/keys';
Expand Down Expand Up @@ -41,6 +42,7 @@ const initialUserObject = {
scheduleSlugs: {},
isSetup: false,
uniqueHash: null,
featureFlags: {},
} as User;

export const useUserStore = defineStore('user', () => {
Expand Down Expand Up @@ -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<Error> => {
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
Expand Down Expand Up @@ -318,6 +337,7 @@ export const useUserStore = defineStore('user', () => {
authenticated,
$reset,
updateSignedUrl,
updateFeatureFlags,
profile,
updateProfile,
changeSignedUrl,
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/views/PostLoginView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading