garyburgmann / drf-firebase-auth

Firebase backend to receive a user idToken and authenticate via Django REST Framework 'authentication.BaseAuthentication'. Optionally, a new local user can be created in the process.
MIT License
128 stars 62 forks source link

feat: added firebase_user.phone_number for mapping email field #33

Open Arka-cell opened 3 years ago

Arka-cell commented 3 years ago

I did make a minor change, whereas, the phone number, if the user is registered with it in firebase, would be mapped to the email field in auth_user. I did run unit tests within my app with the phone number and it is expected to work absolutely fine in production mode (I did not run it in my CI/CD pipeline). My app's settings were by default:

# drf_firebase_auth settings

DRF_FIREBASE_AUTH = {
    # allow anonymous requests without Authorization header set
    "ALLOW_ANONYMOUS_REQUESTS": os.getenv("ALLOW_ANONYMOUS_REQUESTS", False),
    # path to JSON file with firebase secrets
    "FIREBASE_SERVICE_ACCOUNT_KEY": json.loads(
        env(
            "FIREBASE_SERVICE_ACCOUNT_KEY",
        )
    ),
    # allow creation of new local user in db
    "FIREBASE_CREATE_LOCAL_USER": os.getenv("FIREBASE_CREATE_LOCAL_USER", True),
    # attempt to split firebase user.display_name and set local user
    # first_name and last_name
    "FIREBASE_ATTEMPT_CREATE_WITH_DISPLAY_NAME": os.getenv(
        "FIREBASE_ATTEMPT_CREATE_WITH_DISPLAY_NAME", True
    ),
    # commonly JWT or Bearer (e.g. JWT <token>)
    "FIREBASE_AUTH_HEADER_PREFIX": os.getenv("FIREBASE_AUTH_HEADER_PREFIX", "Bearer"),
    # verify that JWT has not been revoked
    "FIREBASE_CHECK_JWT_REVOKED": os.getenv("FIREBASE_CHECK_JWT_REVOKED", True),
    # require that firebase user.email_verified is True
    "FIREBASE_AUTH_EMAIL_VERIFICATION": os.getenv(
        "FIREBASE_AUTH_EMAIL_VERIFICATION", False
    ),
    # function should accept firebase_admin.auth.UserRecord as argument
    # and return str
    "FIREBASE_USERNAME_MAPPING_FUNC": map_firebase_uid_to_username,
}

The only thing is that the current email field should be identifier instead of email in auth_user

Arka-cell commented 3 years ago

@garyburgmann The following code contains the tests that I have made:

import ast
import json

import pytest
from api_app.models import Seeker, CV
from api_app.serializers import SeekerSerializer
from django.core.management import call_command
from fpdf import FPDF
from graphene.test import Client as graphene_client
from rest_framework.renderers import JSONRenderer
from rest_framework.reverse import reverse
from token_generator_phone import TokenGenerator, PHONE_NUMBER
from api_app.schema import schema

test_token = TokenGenerator().generate_id_token()
test_user = TokenGenerator().get_test_user()

@pytest.fixture
def test_get_seeker_infos_successful_200(client):
    """
    :param client: Simulate HTTP methods requests on a URL and observe the response.
    :return: Status code 200 with a JSON object representing user profile data
    """
    """
        GIVEN a registered firebase user
    """
    token = test_token
    url = reverse("personal-infos")
    client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {token}"
    """
        WHEN he sends a get with authentication credentials
    """
    response = client.get(url)
    """
        THEN drf_firebase_auth would register him in local database and his personal data
        would be put into default and returned as a JSON object.
    """
    test_client = Seeker.objects.get(username=test_user.uid)
    serializer = SeekerSerializer(test_client)
    mock_data = JSONRenderer().render(serializer.data)
    assert (
        response.status_code == 200
    ), f"Status code should be 200 instead of {response.status_code}"
    """
        AND response content would assert serializer data in JSON format.
    """
    assert mock_data == response.content

@pytest.mark.django_db
def test_get_seeker_infos_absent_credentials_401(client):
    """
    :param client: Simulate HTTP methods requests on a URL and observe the response.
    :return: Status code 401 with the message: {"detail": "Authentication credentials were not provided."}
    """
    """
        GIVEN a registered or an un-registered user in Firebase 
        WHEN he sends a patch request without a token
    """
    url = reverse("seeker-infos")
    response = client.get(url)
    """
        THEN the status code should return 401.
    """
    assert (
        response.status_code == 401
    ), f"Status code should be 401 instead of {response.status_code}"

@pytest.mark.django_db
def test_patch_seeker_infos_successful_200(client):
    """
    :param client: Simulate HTTP methods requests on a URL and observe the response.
    :return: Status code 200 with the user's newly updated profile data.
    """
    """
        GIVEN a registered firebase user
    """
    url = reverse("seeker-infos")
    token = test_token
    client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {token}"
    """
        WHEN he sends a patch with new data with authentication credentials
    """
    response = client.patch(
        url,
        json={
            "first_name": "test",
            "last_name": "seeker",
            "birthdate": "2021-03-28",
        },
    )
    """
        THEN the client response will be successful
    """
    assert (
        response.status_code == 200
    ), f"Status code should be 200 instead of {response.status_code}"
    """
        AND response data should be the same as the serializer data in a JSON format
    """
    test_client = Seeker.objects.get(username=test_user.uid)
    serializer = SeekerSerializer(test_client)
    mock_data = JSONRenderer().render(serializer.data)
    assert mock_data == response.content

@pytest.mark.django_db
def test_patch_seeker_infos_absent_credentials_401(client):
    """
    :param client: Simulate HTTP methods requests on a URL and observe the response.
    :return: Status code 401 with the message: {"detail": "Authentication credentials were not provided."}
    """
    """
        GIVEN a un-registered user in Firebase 
        WHEN he sends a patch request  
    """
    url = reverse("seeker-infos")
    response = client.patch(
        url,
        json={
            "first_name": "test",
            "last_name": "seeker",
            "birthdate": "2021-03-28",
        },
    )
    """
        THEN the status code should return 401.
    """
    assert (
        response.status_code == 401
    ), f"Status code should be 401 instead of {response.status_code}"

@pytest.mark.django_db
def test_seeker_infos_not_allowed(client):
    """
    :param client: Simulate HTTP methods requests on a URL and observe the response.
    :return json: Status code 405 with the message: {"detail":"HTTP method not allowed"}"
    """
    """
        GIVEN a registered firebase user
    """
    url = reverse("seeker-infos")
    token = test_token
    client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {token}"
    """
        WHEN he sends a POST method in seeker-infos
    """
    response = client.post(
        url,
        json={
            "email": "Jobseeker@email.com",
            "username": "FirebaseUID",
            "first_name": "Job",
            "last_name": "Seeker",
            "birthdate": "2021-11-30",
            "phone_number": "0795524594",
            "longitude": "0.111111",
            "latitude": "-0.111111",
        },
    )
    """
        THEN the data will not be registered in the local database
        and status code is going to be 405.
    """
    assert (
        response.status_code == 405
    ), f"Status code should be 405 instead of {response.status_code}"
    """
        AND when he sends a DELETE method
    """
    response = client.delete(url)
    """
        THEN the DELETE method will not be performed
        and status code is going to be 405.
    """
    assert (
        response.status_code == 405
    ), f"Status code should be 405 instead of {response.status_code}"

@pytest.mark.django_db
def test_seeker_personal_info_unsupported_media_415(client):
    """
    :param client: Simulate HTTP methods requests on a URL and observe the response.
    :return json: Status code 415 with the message: {"detail":"Unsupported Media Type."
    """
    """
        GIVEN a registered firebase user
    """
    url = reverse("seeker-infos")
    token = test_token
    client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {token}"
    """
        WHEN he sends a patch with data in format other than JSON
    """
    response = client.patch(
        url,
        data={
            "first_name": "test",
            "last_name": "seeker",
            "birthdate": "2021-03-28",
        },
    )
    """
        THEN the data will not be registered in the local database
        and status code is going to be 415.
    """
    assert (
        response.status_code == 415
    ), f"Status code should be 415 instead of {response.status_code}"

@pytest.mark.django_db
class TestCVFile:
    pytestmark = pytest.mark.django_db
    pdf = FPDF()
    pdf.add_page()
    pdf.set_font("Arial", size=15)
    pdf.cell(200, 10, txt="PDF Sample", ln=1, align="C")
    pdf.output("sample.pdf")

    def test_seeker_cv_file_put_get_200(self, client):
        """
        :param client: Simulate HTTP methods requests on a URL to observe the response.
        :return json: Status code 200 with a pdf file of content-type: application/pdf "
        """
        """
            GIVEN a registered firebase user
        """
        url = reverse("seeker-cv-file")
        token = test_token
        client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {token}"
        """
            WHEN he sends a put request with a file
        """
        client.defaults["HTTP_CONTENT_DISPOSITION"] = "cv_file; filename=cv.pdf"
        client.defaults["HTTP_CONTENT_TYPE"] = "application/pdf"
        response = client.put(
            url, data=open("sample.pdf", "rb").read(), content_type="application/pdf"
        )
        """
            THEN the response status code will be 200 and his CV would therefore be saved 
            as binary on database
        """
        assert (
            response.status_code == 200
        ), f"Status code should be 200 instead of {response.status_code}"
        assert (
            response.headers["Content-Type"] == "application/pdf"
        ), f"Content-Type should be 'application/pdf' instead of {response.headers['Content-Type']}"
        """
            AND he requests CV file as binary based on his content-type
        """
        client.defaults["HTTP_CONTENT_TYPE"] = "application/octet-stream"
        response = client.get(url, content_type="application/octet-stream")
        """
            THEN he receives the file with the content-type he requested for 
        """
        assert (
            response.status_code == 200
        ), f"Status code should be 200 instead of {response.status_code}"
        client.defaults["HTTP_CONTENT_TYPE"] = "application/pdf"
        assert (
            response.headers["Content-Type"] == "application/octet-stream"
        ), f"Content-Type should be 'octet-stream' instead of {response.headers['Content-Type']}"
        """
            AND he requests CV file as a PDF
        """
        response = client.get(url, content_type="application/pdf")
        """
            THEN he receives the file with the content-type he requested for
        """
        assert (
            response.status_code == 200
        ), f"Status code should be 200 instead of {response.status_code}"
        assert (
            response.headers["Content-Type"] == "application/pdf"
        ), f"Content-Type should be 'application/pdf' instead of {response.headers['Content-Type']}"

    def test_get_500(self, client):
        """
        :param client: Simulate HTTP methods requests on a URL to observe the response.
        :return json: Status code 500 with the following JSON: {"Message": "CV not available"}"
        """
        """
            GIVEN a registered firebase user
        """
        url = reverse("seeker-cv-file")
        token = test_token
        client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {token}"
        """
            WHEN he sends a put request with a file
        """
        client.defaults["HTTP_CONTENT_DISPOSITION"] = "cv_file; filename=cv.pdf"
        client.defaults["HTTP_CONTENT_TYPE"] = "application/pdf"
        response = client.get(url, content_type="application/pdf")
        content = ast.literal_eval(response.content.decode("UTF-8"))
        """
            THEN the server would point out that the CV has not been uploaded in order to
            be able to perform a GET request
        """
        assert (
            response.status_code == 200
        ), f"Status code should be 200 instead of {response.status_code}"
        assert (
            content["Message"] == "CV not available"
        ), f"Response content should be 'CV not available' instead of {content['Message']}"

    def test_seeker_cv_file_put_415(self, client):
        """
        :param client: Simulate HTTP methods requests on a URL to observe the response.
        :return json: Status code 415 with the message saying that cv file media type is incorrect"
        """
        """
            GIVEN a registered firebase user with a html file
        """
        url = reverse("seeker-cv-file")
        token = test_token
        client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {token}"
        html_string = "<p>This is an html file</p>"
        with open("file.pdf", "w") as file:
            file.write(html_string)
        """
            WHEN he sends a put request with an html file carrying a false extension
        """
        client.defaults["HTTP_CONTENT_DISPOSITION"] = "cv_file; filename=cv.pdf"
        client.defaults["HTTP_CONTENT_TYPE"] = "application/pdf"
        response = client.put(
            url, data=open("file.pdf", "rb").read(), content_type="application/pdf"
        )
        assert (
            response.status_code == 415
        ), f"Status code should be 415 instead of {response.status_code}"

class TestCV:
    pytestmark = pytest.mark.django_db

    def test_seeker_cv_get_200(self, client):
        """
        :param client: Simulate HTTP methods requests on a URL to observe the response.
        :return json: Status code 200 with CV fields to be filled by admin or AI model"
        """
        """
            GIVEN an authenticated seeker uploading a file
        """
        file_upload_url = reverse("seeker-cv-file")
        token = test_token
        client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {token}"
        client.get(reverse("seeker-infos"))
        client.defaults["HTTP_CONTENT_DISPOSITION"] = "cv_file; filename=cv.pdf"
        client.defaults["HTTP_CONTENT_TYPE"] = "application/pdf"
        client.put(
            file_upload_url,
            data=open("sample.pdf", "rb").read(),
            content_type="application/pdf",
        )
        """
            WHEN an admin update CV fields according to field
        """
        mock_data = {"category": "developer", "tags": ["pytest", "unit-testing"]}
        seeker_cv = CV.objects.get(seeker=Seeker.objects.get(email=PHONE_NUMBER))
        seeker_cv.category = mock_data["category"]
        seeker_cv.tags = mock_data["tags"]
        seeker_cv.save()
        """
            AND the authenticated seeker want to retrieve the updated CV fields by the admin 
        """
        cv_url = reverse("seeker-cv")
        client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {token}"
        response = client.get(cv_url)
        """
            THEN he would get a status code, as well as data JSON data such as the one saved by the admin
        """
        assert (
            response.status_code == 200
        ), f"Status code should be 200 instead of {response.status_code}"
        assert (
            ast.literal_eval(response.content.decode("utf-8")) == mock_data
        ), f"Retrieved content should be the same content as this: {mock_data}"

class TestJobAppliance:
    pytestmark = pytest.mark.django_db

    def test_apply(self, client):
        url = reverse("job-apply", args=[1])
        token = test_token
        client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {token}"
        response = client.put(url)
        response_data = json.loads(response.content.decode("UTF-8"))
        assert response.status_code == 200
        assert response_data == [
            {
                "id": 1,
                "company": "some_company",
                "description": "",
                "title": "Vue Developer",
                "category": "Developement",
                "active": True,
                "created_at": "2021-05-20T16:24:34.824385+02:00",
            }
        ], f"{response_data} is not the expected data"
        """
            WHEN seeker asks for jobs he applied for
        """

        """
            WHEN the seeker tries to apply for the same job, the message "You already 
            applied for this job" would be returned
        """
        url = reverse("job-apply", args=[1])
        response = client.put(url)
        assert ast.literal_eval(response.content.decode("UTF-8")) == {
            "Message": "You already applied for this job"
        }, f"{response.content} is not the expected message."

        url = reverse("job-applications")
        response = client.get(url)
        response_data = json.loads(response.content.decode("UTF-8"))
        assert (
            response.status_code == 200
        ), f"Status code should be 200 instead of {response.status_code}"
        assert response_data == [
            {
                "id": 1,
                "company": "some_company",
                "description": "",
                "title": "Vue Developer",
                "category": "Developement",
                "active": True,
                "created_at": "2021-05-20T16:24:34.824385+02:00",
            }
        ], f"{response_data} is not the expected data"
garyburgmann commented 3 years ago

thanks @Arka-cell , I will review this when I have time

everestbis commented 2 years ago

@garyburgmann got time ?

opensworup commented 2 years ago

@garyburgmann did you got time?