BTHLABS-58: Share Extension in Apple Apps

This commit is contained in:
2025-10-04 08:02:13 +02:00
parent 0c12f52569
commit 99e9226338
122 changed files with 5488 additions and 411 deletions

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.3 on 2025-09-22 07:20
import uuid6
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_accesstoken'),
]
operations = [
migrations.CreateModel(
name='AuthKey',
fields=[
('id', models.UUIDField(default=uuid6.uuid7, editable=False, primary_key=True, serialize=False)),
('account_uuid', models.UUIDField(db_index=True, default=None)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deleted_at', models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True)),
('key', models.CharField(db_index=True, default=None, editable=False, max_length=128, unique=True)),
],
options={
'verbose_name': 'Auth Key',
'verbose_name_plural': 'Auth Keys',
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-10-01 07:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_authkey'),
]
operations = [
migrations.AddField(
model_name='authkey',
name='consumed_at',
field=models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True),
),
]

View File

@@ -1,2 +1,3 @@
from .access_token import AccessToken # noqa: F401
from .account import Account # noqa: F401
from .auth_key import AuthKey # noqa: F401

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.db import models
from django.utils.translation import gettext_lazy as _
from hotpocket_backend.apps.core.models import Model
class ActiveAuthKeysManager(models.Manager):
def get_queryset(self) -> models.QuerySet[AuthKey]:
return super().get_queryset().filter(
deleted_at__isnull=True,
)
class AuthKey(Model):
key = models.CharField(
blank=False,
default=None,
null=False,
max_length=128,
db_index=True,
unique=True,
editable=False,
)
consumed_at = models.DateTimeField(
blank=True,
null=True,
default=None,
db_index=True,
editable=False,
)
objects = models.Manager()
active_objects = ActiveAuthKeysManager()
class Meta:
verbose_name = _('Auth Key')
verbose_name_plural = _('Auth Keys')
def __str__(self) -> str:
return f'<AuthKey pk={self.pk} key={self.key}>'

View File

@@ -1 +1,3 @@
from .access_tokens import AccessTokensService # noqa: F401
from .accounts import AccountsService # noqa: F401
from .auth_keys import AuthKeysService # noqa: F401

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
import uuid
from hotpocket_backend.apps.accounts.models import Account
LOGGER = logging.getLogger(__name__)
class AccountsService:
class AccountsServiceError(Exception):
pass
class AccountNotFound(AccountsServiceError):
pass
def get(self, *, pk: uuid.UUID) -> Account:
try:
query_set = Account.objects.filter(is_active=True)
return query_set.get(pk=pk)
except Account.DoesNotExist as exception:
raise self.AccountNotFound(
f'Account not found: pk=`{pk}`',
) from exception

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import datetime
import logging
import uuid
from django.utils.timezone import now
import uuid6
from hotpocket_backend.apps.accounts.models import AuthKey
from hotpocket_backend.apps.core.conf import settings
LOGGER = logging.getLogger(__name__)
class AuthKeysService:
class AuthKeysServiceError(Exception):
pass
class AuthKeyNotFound(AuthKeysServiceError):
pass
class AuthKeyExpired(AuthKeysServiceError):
pass
class AuthKeyAccessDenied(AuthKeysServiceError):
pass
def create(self, *, account_uuid: uuid.UUID) -> AuthKey:
key = str(uuid6.uuid7())
return AuthKey.objects.create(
account_uuid=account_uuid,
key=key,
)
def get(self, *, pk: uuid.UUID) -> AuthKey:
try:
query_set = AuthKey.active_objects
return query_set.get(pk=pk)
except AuthKey.DoesNotExist as exception:
raise self.AuthKeyNotFound(
f'Auth Key not found: pk=`{pk}`',
) from exception
def get_by_key(self, *, key: str, ttl: int | None = None) -> AuthKey:
try:
query_set = AuthKey.active_objects
result = query_set.get(key=key)
if ttl is None:
ttl = settings.AUTH_KEY_TTL
if ttl > 0:
if result.created_at < now() - datetime.timedelta(seconds=ttl):
raise self.AuthKeyExpired(
f'Auth Key expired: pk=`{key}`',
)
if result.consumed_at is not None:
raise self.AuthKeyExpired(
f'Auth Key already consumed: pk=`{key}`',
)
return result
except AuthKey.DoesNotExist as exception:
raise self.AuthKeyNotFound(
f'Auth Key not found: key=`{key}`',
) from exception

View File

@@ -30,3 +30,5 @@ class PSettings(typing.Protocol):
SAVES_ASSOCIATION_ADAPTER: str
UPLOADS_PATH: pathlib.Path
AUTH_KEY_TTL: int

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.3 on 2025-10-01 05:35
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('saves', '0007_association_target_description_and_more'),
]
operations = [
migrations.AlterField(
model_name='save',
name='url',
field=models.CharField(default=None, validators=[django.core.validators.URLValidator(schemes=['http', 'https'])]),
),
]

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.core import validators
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -20,6 +21,9 @@ class Save(Model):
)
url = models.CharField(
blank=False, null=False, default=None,
validators=[
validators.URLValidator(schemes=['http', 'https']),
],
)
content = models.BinaryField(
blank=True, null=True, default=None, editable=False,

View File

@@ -23,3 +23,11 @@ class UIAccessTokenOriginApp(enum.Enum):
SAFARI_WEB_EXTENSION = _('Safari Web Extension')
CHROME_EXTENSION = _('Chrome Extension')
FIREFOX_EXTENSION = _('Firefox Extension')
HOTPOCKET_DESKTOP = _('HotPocket Desktop')
HOTPOCKET_MOBILE = _('HotPocket Mobile')
class AuthSource(enum.Enum):
BROWSER_EXTENSION = 'HotPocketExtension'
DESKTOP = 'HotPocketDesktop'
MOBILE = 'HotPocketMobile'

View File

@@ -7,23 +7,37 @@ from bthlabs_jsonrpc_core import register_method
from django import db
from django.http import HttpRequest
from hotpocket_soa.services import AccessTokensService
from hotpocket_soa.services import (
AccessTokensService,
AccountsService,
AuthKeysService,
)
LOGGER = logging.getLogger(__name__)
@register_method('accounts.access_tokens.create')
@register_method('accounts.access_tokens.create', namespace='accounts')
def create(request: HttpRequest,
auth_key: str,
meta: dict,
) -> str:
with db.transaction.atomic():
try:
assert 'extension_auth_key' in request.session, 'Auth key missing'
assert request.session['extension_auth_key'] == auth_key, (
'Auth key mismatch'
auth_key_object = AuthKeysService().get_by_key(
account_uuid=None,
key=auth_key,
)
except AssertionError as exception:
except AuthKeysService.AuthKeyNotFound as exception:
LOGGER.error(
'Unable to issue access token: %s',
exception,
exc_info=exception,
)
raise
try:
account = AccountsService().get(pk=auth_key_object.account_uuid)
except AccountsService.AccountNotFound as exception:
LOGGER.error(
'Unable to issue access token: %s',
exception,
@@ -32,12 +46,9 @@ def create(request: HttpRequest,
raise
access_token = AccessTokensService().create(
account_uuid=request.user.pk,
account_uuid=account.pk,
origin=request.META['HTTP_ORIGIN'],
meta=meta,
)
request.session.pop('extension_auth_key')
request.session.save()
return access_token.key

View File

@@ -13,16 +13,18 @@ from hotpocket_soa.services import AccessTokensService
LOGGER = logging.getLogger(__name__)
@register_method('accounts.auth.check')
@register_method('accounts.auth.check', namespace='accounts')
def check(request: HttpRequest) -> bool:
return request.user.is_anonymous is False
@register_method('accounts.auth.check_access_token')
@register_method('accounts.auth.check_access_token', namespace='accounts')
def check_access_token(request: HttpRequest,
access_token: str,
meta: dict | None = None,
) -> bool:
assert request.user.is_anonymous is False, 'Not authenticated'
result = True
try:

View File

@@ -11,8 +11,27 @@
<div class="alert alert-success mt-3" role="alert">
<h4 class="alert-heading">{% translate 'Done!' %}</h4>
<p class="lead mb-0">
{% translate "You've successfully logged in to the extension." %}
{% if app_redirect_url %}
{% translate "You've successfully logged in to the application." %}
{% else %}
{% translate "You've successfully logged in to the extension." %}
{% endif %}
</p>
</div>
</div>
{% endblock %}
{% block page_scripts %}
{% if app_redirect_url %}
<script type="text/javascript">
(() => {
window.setTimeout(
() => {
window.location.replace('{{ app_redirect_url|safe }}');
},
1000,
);
})();
</script>
{% endif %}
{% endblock %}

View File

@@ -137,6 +137,8 @@ def render_access_token_app(access_token: AccessTokenOut) -> str:
AccessTokenOriginApp.SAFARI_WEB_EXTENSION,
AccessTokenOriginApp.CHROME_EXTENSION,
AccessTokenOriginApp.FIREFOX_EXTENSION,
AccessTokenOriginApp.HOTPOCKET_DESKTOP,
AccessTokenOriginApp.HOTPOCKET_MOBILE,
)
if origin_app in extension_origin_apps:
app = UIAccessTokenOriginApp[origin_app.value].value
@@ -152,7 +154,7 @@ def render_access_token_app(access_token: AccessTokenOut) -> str:
@register.filter(name='render_access_token_platform')
def render_access_token_platform(access_token: AccessTokenOut) -> str:
match access_token.meta.get('platform', None):
case 'MacIntel':
case 'MacIntel' | 'macOS':
return 'macOS'
case 'iPhone':

View File

@@ -59,6 +59,13 @@ urlpatterns = [
accounts.apps.DeleteView.as_view(),
name='ui.accounts.apps.delete',
),
path(
'accounts/rpc/',
JSONRPCView.as_view(
namespace='accounts',
),
name='ui.accounts.rpc',
),
path('accounts/', accounts.index.index, name='ui.accounts.index'),
path(
'imports/pocket/',

View File

@@ -2,27 +2,56 @@
from __future__ import annotations
import logging
import urllib.parse
import uuid
from django import db
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from hotpocket_backend.apps.ui.constants import AuthSource
from hotpocket_soa.services import AuthKeysService
LOGGER = logging.getLogger(__name__)
SOURCE_TO_REDIRECT_SCHEME = {
AuthSource.DESKTOP.value: 'hotpocket-desktop',
AuthSource.MOBILE.value: 'hotpocket-mobile',
}
def authenticate(request: HttpRequest) -> HttpResponse:
if request.user.is_anonymous is False:
auth_key = str(uuid.uuid4())
source = request.GET.get(
'source',
request.session.get('extension_source', AuthSource.BROWSER_EXTENSION.value),
)
session_token = request.GET.get(
'session_token', request.session.get('extension_session_token', None),
)
request.session['extension_auth_key'] = auth_key
request.session.save()
if source == AuthSource.BROWSER_EXTENSION.value:
session_token = str(uuid.uuid4())
elif source in (AuthSource.DESKTOP.value, AuthSource.MOBILE.value):
assert session_token not in ('', None), 'Session token missing'
else:
raise ValueError(f'Unknown source: `{source}`')
request.session['extension_source'] = source
request.session['extension_session_token'] = session_token
request.session.save()
if request.user.is_anonymous is False:
with db.transaction.atomic():
auth_key = AuthKeysService().create(
account_uuid=request.user.pk,
)
return redirect(reverse(
'ui.integrations.extension.post_authenticate',
query=[
('auth_key', auth_key),
('auth_key', auth_key.key),
],
))
@@ -36,12 +65,35 @@ def post_authenticate(request: HttpRequest) -> HttpResponse:
assert request.user.is_anonymous is False, 'Not authenticated'
auth_key = request.GET.get('auth_key', None)
assert request.session.get('extension_auth_key', None) == auth_key, (
'Auth key mismatch'
)
assert auth_key is not None, 'Auth key missing'
source = request.session.get('extension_source', None)
assert source is not None, 'Source is missing'
session_token = request.session.get('extension_session_token', None)
assert session_token is not None, 'Session token is missing'
app_redirect_url = None
if source in (AuthSource.DESKTOP.value, AuthSource.MOBILE.value):
app_redirect_url = urllib.parse.urlunsplit((
SOURCE_TO_REDIRECT_SCHEME[source],
'post-authenticate',
'/',
urllib.parse.urlencode([
('session_token', session_token),
('auth_key', auth_key),
]),
'',
))
request.session.pop('extension_source')
request.session.pop('extension_session_token')
request.session.save()
return render(
request, 'ui/integrations/extension/post_authenticate.html',
request,
'ui/integrations/extension/post_authenticate.html',
{
'app_redirect_url': app_redirect_url,
},
)
except AssertionError as exception:
LOGGER.error(

View File

@@ -79,3 +79,5 @@ CORS_ALLOW_HEADERS = (
*default_headers,
'cookie',
)
AUTH_KEY_TTL = 30

View File

@@ -1,2 +1,3 @@
from .access_token import AccessTokenFactory # noqa: F401,F403
from .account import AccountFactory # noqa: F401,F403
from .auth_key import AuthKeyFactory # noqa: F401,F403

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import uuid
import factory
from hotpocket_backend.apps.accounts.models import AuthKey
class AuthKeyFactory(factory.django.DjangoModelFactory):
account_uuid = None
key = factory.LazyFunction(lambda: str(uuid.uuid4()))
consumed_at = None
class Meta:
model = AuthKey

View File

@@ -1,3 +1,4 @@
from .access_token import * # noqa: F401,F403
from .account import * # noqa: F401,F403
from .apps import * # noqa: F401,F403
from .auth_key import * # noqa: F401,F403

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import datetime
from django.utils.timezone import get_current_timezone, now
import pytest
from hotpocket_soa.dto.accounts import AuthKeyOut
@pytest.fixture
def auth_key_factory(request: pytest.FixtureRequest):
default_account = request.getfixturevalue('account')
def factory(account=None, **kwargs):
from hotpocket_backend_testing.factories.accounts import AuthKeyFactory
return AuthKeyFactory(
account_uuid=(
account.pk
if account is not None
else default_account.pk
),
**kwargs,
)
return factory
@pytest.fixture
def auth_key(auth_key_factory):
return auth_key_factory()
@pytest.fixture
def auth_key_out(auth_key):
return AuthKeyOut.model_validate(auth_key, from_attributes=True)
@pytest.fixture
def deleted_auth_key(auth_key_factory):
return auth_key_factory(deleted_at=now())
@pytest.fixture
def deleted_auth_key_out(deleted_auth_key):
return AuthKeyOut.model_validate(deleted_auth_key, from_attributes=True)
@pytest.fixture
def expired_auth_key(auth_key_factory):
result = auth_key_factory()
result.created_at = datetime.datetime(
1987, 10, 3, 8, 0, 0, tzinfo=get_current_timezone(),
)
result.save()
return result
@pytest.fixture
def expired_auth_key_out(expired_auth_key):
return AuthKeyOut.model_validate(expired_auth_key, from_attributes=True)
@pytest.fixture
def consumed_auth_key(auth_key_factory):
return auth_key_factory(consumed_at=now())
@pytest.fixture
def consumed_auth_key_out(consumed_auth_key):
return AuthKeyOut.model_validate(consumed_auth_key, from_attributes=True)
@pytest.fixture
def other_auth_key(auth_key_factory):
return auth_key_factory()
@pytest.fixture
def other_auth_key_out(other_auth_key):
return AuthKeyOut.model_validate(other_auth_key, from_attributes=True)
@pytest.fixture
def inactive_account_auth_key(auth_key_factory, inactive_account):
return auth_key_factory(account=inactive_account)
@pytest.fixture
def inactive_account_auth_key_out(auth_key):
return AuthKeyOut.model_validate(
inactive_account_auth_key, from_attributes=True,
)
@pytest.fixture
def other_account_auth_key(auth_key_factory, other_account):
return auth_key_factory(account=other_account)
@pytest.fixture
def other_account_auth_key_out(other_account_auth_key):
return AuthKeyOut.model_validate(
other_account_auth_key, from_attributes=True,
)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import csv
import datetime
import io
import uuid
import pytest
@@ -86,3 +87,23 @@ def pocket_csv_content(pocket_import_created_save_spec,
csv_f.seek(0)
yield csv_f.getvalue()
@pytest.fixture
def extension_auth_source_extension():
return 'HotPocketExtension'
@pytest.fixture
def extension_auth_source_desktop():
return 'HotPocketDesktop'
@pytest.fixture
def extension_auth_source_mobile():
return 'HotPocketMobile'
@pytest.fixture
def extension_auth_session_token():
return str(uuid.uuid4())

View File

@@ -1,2 +1,3 @@
from .access_tokens import AccessTokensTestingService # noqa: F401
from .accounts import AccountsTestingService # noqa: F401
from .auth_key import AuthKeysTestingService # noqa: F401

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import uuid
from hotpocket_backend.apps.accounts.models import AuthKey
class AuthKeysTestingService:
def assert_created(self,
*,
key: str,
account_uuid: uuid.UUID,
):
auth_key = AuthKey.objects.get(key=key)
assert auth_key.account_uuid == account_uuid
assert auth_key.created_at is not None
assert auth_key.updated_at is not None

View File

@@ -9,11 +9,15 @@ from django.urls import reverse
import pytest
from pytest_django import asserts
from hotpocket_backend_testing.services.accounts import AuthKeysTestingService
from hotpocket_common.url import URL
@pytest.mark.django_db
def test_ok(authenticated_client: Client):
def test_ok(authenticated_client: Client,
extension_auth_source_extension,
account,
):
# When
result = authenticated_client.get(
reverse('ui.integrations.extension.authenticate'),
@@ -28,8 +32,118 @@ def test_ok(authenticated_client: Client):
assert redirect_url.raw_path == reverse('ui.integrations.extension.post_authenticate')
assert 'auth_key' in redirect_url.query
assert 'extension_auth_key' in authenticated_client.session
assert authenticated_client.session['extension_auth_key'] == redirect_url.query['auth_key'][0]
assert 'extension_source' in authenticated_client.session
assert authenticated_client.session['extension_source'] == extension_auth_source_extension
assert 'extension_session_token' in authenticated_client.session
AuthKeysTestingService().assert_created(
key=redirect_url.query['auth_key'][0],
account_uuid=account.pk,
)
@pytest.mark.parametrize(
'source_fixture_name',
['extension_auth_source_desktop', 'extension_auth_source_mobile'],
)
@pytest.mark.django_db
def test_ok_with_source(source_fixture_name,
request: pytest.FixtureRequest,
authenticated_client: Client,
extension_auth_session_token,
):
# Given
source = request.getfixturevalue(source_fixture_name)
# When
result = authenticated_client.get(
reverse(
'ui.integrations.extension.authenticate',
query=[
('source', source),
('session_token', extension_auth_session_token),
],
),
follow=False,
)
# Then
assert result.status_code == http.HTTPStatus.FOUND
assert 'Location' in result.headers
redirect_url = URL(result.headers['Location'])
assert redirect_url.raw_path == reverse('ui.integrations.extension.post_authenticate')
assert 'auth_key' in redirect_url.query
assert 'extension_source' in authenticated_client.session
assert authenticated_client.session['extension_source'] == source
assert 'extension_session_token' in authenticated_client.session
assert authenticated_client.session['extension_session_token'] == extension_auth_session_token
@pytest.mark.django_db
def test_source_without_session_token(authenticated_client: Client,
extension_auth_source_desktop,
):
# Given
with pytest.raises(AssertionError) as exception_info:
# When
_ = authenticated_client.get(
reverse(
'ui.integrations.extension.authenticate',
query=[
('source', extension_auth_source_desktop),
],
),
follow=False,
)
# Then
assert exception_info.value.args[0] == 'Session token missing'
@pytest.mark.django_db
def test_source_without_empty_session_token(authenticated_client: Client,
extension_auth_source_desktop,
):
# Given
with pytest.raises(AssertionError) as exception_info:
# When
_ = authenticated_client.get(
reverse(
'ui.integrations.extension.authenticate',
query=[
('source', extension_auth_source_desktop),
('session_token', ''),
],
),
follow=False,
)
# Then
assert exception_info.value.args[0] == 'Session token missing'
@pytest.mark.django_db
def test_unknown_source(authenticated_client: Client, extension_auth_session_token):
# Given
with pytest.raises(ValueError) as exception_info:
# When
_ = authenticated_client.get(
reverse(
'ui.integrations.extension.authenticate',
query=[
('source', 'thisisntright'),
('session_token', extension_auth_session_token),
],
),
follow=False,
)
# Then
assert exception_info.value.args[0] == 'Unknown source: `thisisntright`'
@pytest.mark.django_db

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import http
import urllib.parse
import uuid
from django.test import Client
@@ -17,10 +18,15 @@ def auth_key():
@pytest.mark.django_db
def test_ok(authenticated_client: Client, auth_key):
def test_ok(authenticated_client: Client,
auth_key,
extension_auth_source_extension,
extension_auth_session_token,
):
# Given
session = authenticated_client.session
session['extension_auth_key'] = auth_key
session['extension_source'] = extension_auth_source_extension
session['extension_session_token'] = extension_auth_session_token
session.save()
# When
@@ -34,13 +40,95 @@ def test_ok(authenticated_client: Client, auth_key):
# Then
assert result.status_code == http.HTTPStatus.OK
assert 'extension_source' not in authenticated_client.session
assert 'extension_session_token' not in authenticated_client.session
asserts.assertTemplateUsed(
result, 'ui/integrations/extension/post_authenticate.html',
)
assert result.context[0]['app_redirect_url'] is None
@pytest.mark.parametrize(
'source_fixture_name,expected_app_redirect_url_scheme',
[
('extension_auth_source_desktop', 'hotpocket-desktop'),
('extension_auth_source_mobile', 'hotpocket-mobile'),
],
)
@pytest.mark.django_db
def test_ok_with_source(source_fixture_name,
expected_app_redirect_url_scheme,
request: pytest.FixtureRequest,
authenticated_client: Client,
auth_key,
extension_auth_session_token,
):
# Given
source = request.getfixturevalue(source_fixture_name)
session = authenticated_client.session
session['extension_source'] = source
session['extension_session_token'] = extension_auth_session_token
session.save()
# When
result = authenticated_client.get(
reverse('ui.integrations.extension.post_authenticate'),
data={
'auth_key': auth_key,
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
assert result.context[0]['app_redirect_url'] is not None
app_redirect_url = result.context[0]['app_redirect_url']
parsed_app_redirect_url = urllib.parse.urlsplit(app_redirect_url)
assert parsed_app_redirect_url.scheme == expected_app_redirect_url_scheme
assert parsed_app_redirect_url.netloc == 'post-authenticate'
assert parsed_app_redirect_url.path == '/'
parsed_app_redirect_url_query = urllib.parse.parse_qs(parsed_app_redirect_url.query)
assert parsed_app_redirect_url_query['session_token'] == [extension_auth_session_token]
assert parsed_app_redirect_url_query['auth_key'] == [auth_key]
@pytest.mark.django_db
def test_auth_key_not_in_session(authenticated_client: Client, auth_key):
def test_auth_key_not_request(authenticated_client: Client,
extension_auth_source_extension,
extension_auth_session_token,
):
# Given
session = authenticated_client.session
session['extension_source'] = extension_auth_source_extension
session['extension_session_token'] = extension_auth_session_token
session.save()
# When
result = authenticated_client.get(
reverse('ui.integrations.extension.post_authenticate'),
data={
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_source_not_in_session(authenticated_client: Client,
extension_auth_session_token,
auth_key,
):
# Given
session = authenticated_client.session
session['extension_session_token'] = extension_auth_session_token
session.save()
# When
result = authenticated_client.get(
reverse('ui.integrations.extension.post_authenticate'),
@@ -54,16 +142,20 @@ def test_auth_key_not_in_session(authenticated_client: Client, auth_key):
@pytest.mark.django_db
def test_auth_key_not_request(authenticated_client: Client, auth_key):
def test_session_token_in_session(authenticated_client: Client,
extension_auth_source_extension,
auth_key,
):
# Given
session = authenticated_client.session
session['extension_auth_key'] = auth_key
session['extension_source'] = extension_auth_source_extension
session.save()
# When
result = authenticated_client.get(
reverse('ui.integrations.extension.post_authenticate'),
data={
'auth_key': auth_key,
},
)

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import http
import uuid
from django.test import Client
from django.urls import reverse
@@ -15,34 +14,23 @@ from hotpocket_backend_testing.services.accounts import (
@pytest.fixture
def auth_key():
return str(uuid.uuid4())
@pytest.fixture
def call(rpc_call_factory, auth_key, safari_extension_meta):
def call(rpc_call_factory, auth_key_out, safari_extension_meta):
return rpc_call_factory(
'accounts.access_tokens.create',
[auth_key, safari_extension_meta],
[auth_key_out.key, safari_extension_meta],
)
@pytest.mark.django_db
def test_ok(authenticated_client: Client,
auth_key,
def test_ok(client: Client,
call,
safari_extension_origin,
account,
safari_extension_meta,
):
# Given
session = authenticated_client.session
session['extension_auth_key'] = auth_key
session.save()
# When
result = authenticated_client.post(
reverse('ui.rpc'),
result = client.post(
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
@@ -63,17 +51,20 @@ def test_ok(authenticated_client: Client,
meta=safari_extension_meta,
)
assert 'extension_auth_key' not in authenticated_client.session
@pytest.mark.django_db
def test_auth_key_missing(authenticated_client: Client,
call,
safari_extension_origin,
):
def test_auth_key_not_found(null_uuid,
call,
client: Client,
safari_extension_origin,
):
# Given
call_auth_key = str(null_uuid)
call['params'][0] = call_auth_key
# When
result = authenticated_client.post(
reverse('ui.rpc'),
result = client.post(
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
@@ -86,22 +77,87 @@ def test_auth_key_missing(authenticated_client: Client,
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'] == 'Auth key missing'
assert call_result['error']['data'].startswith(
'Auth Key not found',
)
assert call_auth_key in call_result['error']['data']
@pytest.mark.django_db
def test_auth_key_mismatch(authenticated_client: Client,
def test_deleted_auth_key(deleted_auth_key_out,
call,
client: Client,
safari_extension_origin,
):
# Given
call_auth_key = deleted_auth_key_out.key
call['params'][0] = call_auth_key
# When
result = client.post(
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
'Origin': safari_extension_origin,
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'].startswith(
'Auth Key not found',
)
assert call_auth_key in call_result['error']['data']
@pytest.mark.django_db
def test_expired_auth_key(expired_auth_key_out,
call,
client: Client,
safari_extension_origin,
):
# Given
call_auth_key = expired_auth_key_out.key
call['params'][0] = call_auth_key
# When
result = client.post(
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
'Origin': safari_extension_origin,
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'].startswith(
'Auth Key expired',
)
assert call_auth_key in call_result['error']['data']
@pytest.mark.django_db
def test_consumed_auth_key(consumed_auth_key,
call,
client: Client,
safari_extension_origin,
):
# Given
session = authenticated_client.session
session['extension_auth_key'] = 'thisisntright'
session.save()
call_auth_key = consumed_auth_key.key
call['params'][0] = call_auth_key
# When
result = authenticated_client.post(
reverse('ui.rpc'),
result = client.post(
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
@@ -114,28 +170,35 @@ def test_auth_key_mismatch(authenticated_client: Client,
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'] == 'Auth key mismatch'
@pytest.mark.django_db
def test_inactive_account(inactive_account_client: Client, call):
# When
result = inactive_account_client.post(
reverse('ui.rpc'),
data=call,
assert call_result['error']['data'].startswith(
'Auth Key already consumed',
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
assert call_auth_key in call_result['error']['data']
@pytest.mark.django_db
def test_anonymous(client: Client, call):
def test_inactive_account(inactive_account_auth_key,
call,
client: Client,
safari_extension_origin,
inactive_account,
):
# Given
call['params'][0] = inactive_account_auth_key.key
# When
result = client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
'Origin': safari_extension_origin,
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' in call_result
assert str(inactive_account.pk) in call_result['error']['data']

View File

@@ -23,7 +23,7 @@ def test_ok_session_auth(authenticated_client: Client,
):
# When
result = authenticated_client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
)
@@ -42,12 +42,17 @@ def test_session_auth_inactive_account(inactive_account_client: Client,
):
# When
result = inactive_account_client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
assert call_result['result'] is False
@pytest.mark.django_db
@@ -57,7 +62,7 @@ def test_ok_access_token_auth(client: Client,
):
# When
result = client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
@@ -80,15 +85,20 @@ def test_access_token_auth_not_bearer(client: Client,
):
# When
result = client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
'Authorization': f'thisisntright {access_token_out.key}',
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
assert call_result['result'] is False
@pytest.mark.django_db
@@ -98,15 +108,20 @@ def test_access_token_auth_invalid_access_token(client: Client,
):
# When
result = client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
'Authorization': f'Bearer {null_uuid}',
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
assert call_result['result'] is False
@pytest.mark.django_db
@@ -116,15 +131,20 @@ def test_access_token_auth_deleted_access_token(client: Client,
):
# When
result = client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
'Authorization': f'Bearer {deleted_access_token.key}',
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
assert call_result['result'] is False
@pytest.mark.django_db
@@ -134,24 +154,34 @@ def test_access_token_auth_inactive_account(client: Client,
):
# When
result = client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
headers={
'Authorization': f'Bearer {inactive_account_access_token.key}',
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
assert call_result['result'] is False
@pytest.mark.django_db
def test_anonymous(client: Client, call):
# When
result = client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
assert call_result['result'] is False

View File

@@ -51,7 +51,7 @@ def test_ok(authenticated_client: Client,
):
# When
result = authenticated_client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
)
@@ -94,7 +94,7 @@ def test_ok_with_partial_meta_update(meta_keys_to_pop,
# When
result = authenticated_client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
)
@@ -122,7 +122,7 @@ def test_invalid_access_token(authenticated_client: Client,
# When
result = authenticated_client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
)
@@ -145,7 +145,7 @@ def test_deleted_access_token(call_factory,
# When
result = authenticated_client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
)
@@ -168,7 +168,7 @@ def test_other_account_access_token(call_factory,
# When
result = authenticated_client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
)
@@ -185,21 +185,31 @@ def test_other_account_access_token(call_factory,
def test_inactive_account(inactive_account_client: Client, call):
# When
result = inactive_account_client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'] == 'Not authenticated'
@pytest.mark.django_db
def test_anonymous(client: Client, call):
# When
result = client.post(
reverse('ui.rpc'),
reverse('ui.accounts.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'] == 'Not authenticated'

View File

@@ -110,12 +110,12 @@ def test_ok_netloc_banned(authenticated_client: Client,
@pytest.mark.django_db
def test_ok_resuse_save(save_out,
authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
def test_ok_reuse_save(save_out,
authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# Given
call['params'][0] = save_out.url
@@ -148,13 +148,13 @@ def test_ok_resuse_save(save_out,
@pytest.mark.django_db
def test_ok_resuse_association(association_out,
save_out,
authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
def test_ok_reuse_association(association_out,
save_out,
authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# Given
call['params'][0] = save_out.url
@@ -263,6 +263,31 @@ def test_empty_url(authenticated_client: Client,
assert call_result['error']['data']['url'] == ['blank']
@pytest.mark.django_db
def test_invalid_url(authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# Given
call['params'][0] = 'thisisntright'
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data']['url'] == ['invalid']
@pytest.mark.django_db
def test_inactive_account(inactive_account_client: Client, call):
# When