Skip to content

Commit a5270c4

Browse files
Add Cypress e2e tests with authenticated user login via magic links (#1379)
1 parent bfea7e3 commit a5270c4

File tree

9 files changed

+298
-29
lines changed

9 files changed

+298
-29
lines changed

pydatalab/src/pydatalab/routes/v0_1/auth.py

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import jwt
1616
from bson import ObjectId
17-
from flask import Blueprint, g, jsonify, redirect, request
17+
from flask import Blueprint, Response, g, jsonify, redirect, request
1818
from flask_dance.consumer import OAuth2ConsumerBlueprint, oauth_authorized
1919
from flask_login import current_user, login_user
2020
from flask_login.utils import LocalProxy
@@ -420,24 +420,15 @@ def attach_identity_to_user(
420420
wrapped_login_user(get_by_id(str(user.immutable_id)))
421421

422422

423-
@EMAIL_BLUEPRINT.route("/magic-link", methods=["POST"])
424-
def generate_and_share_magic_link():
425-
"""Generates a JWT-based magic link with which a user can log in, stores it
426-
in the database and sends it to the verified email address.
427-
428-
"""
429-
request_json = request.get_json()
430-
email = request_json.get("email")
431-
referrer = request_json.get("referrer")
432-
423+
def _validate_magic_link_request(email: str, referrer: str) -> tuple[Response | None, int]:
433424
if not email:
434425
return jsonify({"status": "error", "detail": "No email provided."}), 400
435426

436427
if not re.match(r"^\S+@\S+.\S+$", email):
437428
return jsonify({"status": "error", "detail": "Invalid email provided."}), 400
438429

439430
if not referrer:
440-
LOGGER.warning("No referrer provided for magic link request: %s", request_json)
431+
LOGGER.warning("No referrer provided for magic link request")
441432
return (
442433
jsonify(
443434
{
@@ -448,25 +439,42 @@ def generate_and_share_magic_link():
448439
400,
449440
)
450441

451-
# Generate a JWT for the user with a short expiration; the session itself
452-
# should persist
453-
# The key `exp` is a standard part of JWT; pyjwt treats this as an expiration time
454-
# and will correctly encode the datetime
442+
return None, 200
443+
444+
445+
def _generate_and_store_token(email: str, is_test: bool = False) -> str:
446+
"""Generate a JWT for the user with a short expiration and store it in the session.
447+
448+
The session itself persists beyond the JWT expiration. The `exp` key is a standard
449+
part of JWT that PyJWT treats as an expiration time and will correctly encode the datetime.
450+
451+
Args:
452+
email: The user's email address to include in the token.
453+
is_test: If True, generates a token for testing purposes that may have different
454+
expiration or validation rules. Defaults to False.
455+
456+
Returns:
457+
The generated JWT token string.
458+
"""
459+
payload = {
460+
"exp": datetime.datetime.now(datetime.timezone.utc) + LINK_EXPIRATION,
461+
"email": email,
462+
}
463+
if is_test:
464+
payload["is_test"] = True
465+
455466
token = jwt.encode(
456-
{"exp": datetime.datetime.now(datetime.timezone.utc) + LINK_EXPIRATION, "email": email},
467+
payload,
457468
CONFIG.SECRET_KEY,
458469
algorithm="HS256",
459470
)
460471

461-
flask_mongo.db.magic_links.insert_one(
462-
{"jwt": token},
463-
)
472+
flask_mongo.db.magic_links.insert_one({"jwt": token})
464473

465-
link = f"{referrer}?token={token}"
474+
return token
466475

467-
instance_url = referrer.replace("https://", "")
468476

469-
# See if the user already exists and adjust the email if so
477+
def _check_user_registration_allowed(email: str) -> tuple[Response | None, int]:
470478
user = find_user_with_identity(email, IdentityType.EMAIL, verify=False)
471479

472480
if not user:
@@ -483,6 +491,14 @@ def generate_and_share_magic_link():
483491
403,
484492
)
485493

494+
return None, 200
495+
496+
497+
def _send_magic_link_email(email: str, token: str, referrer: str) -> tuple[Response | None, int]:
498+
link = f"{referrer}?token={token}"
499+
instance_url = referrer.replace("https://", "")
500+
user = find_user_with_identity(email, IdentityType.EMAIL, verify=False)
501+
486502
if user is not None:
487503
subject = "Datalab Sign-in Magic Link"
488504
body = f"Click the link below to sign-in to the datalab instance at {instance_url}:\n\n{link}\n\nThis link is single-use and will expire in 1 hour."
@@ -496,6 +512,34 @@ def generate_and_share_magic_link():
496512
LOGGER.warning("Failed to send email to %s: %s", email, exc)
497513
return jsonify({"status": "error", "detail": "Email not sent successfully."}), 400
498514

515+
return None, 200
516+
517+
518+
@EMAIL_BLUEPRINT.route("/magic-link", methods=["POST"])
519+
def generate_and_share_magic_link():
520+
"""Generates a JWT-based magic link with which a user can log in, stores it
521+
in the database and sends it to the verified email address.
522+
523+
"""
524+
525+
request_json = request.get_json()
526+
email = request_json.get("email")
527+
referrer = request_json.get("referrer")
528+
529+
error_response, status_code = _validate_magic_link_request(email, referrer)
530+
if error_response:
531+
return error_response, status_code
532+
533+
error_response, status_code = _check_user_registration_allowed(email)
534+
if error_response:
535+
return error_response, status_code
536+
537+
token = _generate_and_store_token(email)
538+
539+
error_response, status_code = _send_magic_link_email(email, token, referrer)
540+
if error_response:
541+
return error_response, status_code
542+
499543
return jsonify({"status": "success", "detail": "Email sent successfully."}), 200
500544

501545

@@ -536,17 +580,19 @@ def email_logged_in():
536580
# If the email domain list is explicitly configured to None, this allows any
537581
# email address to make an active account, otherwise the email domain must match
538582
# the list of allowed domains and the admin must verify the user
539-
allowed = _check_email_domain(email, CONFIG.EMAIL_DOMAIN_ALLOW_LIST)
540-
if not allowed:
541-
# If this point is reached, the token is valid but the server settings have
542-
# changed since the link was generated, so best to fail safe
543-
raise UserRegistrationForbidden
583+
is_test = data.get("is_test", False)
584+
585+
if not is_test:
586+
allowed = _check_email_domain(email, CONFIG.EMAIL_DOMAIN_ALLOW_LIST)
587+
if not allowed:
588+
raise UserRegistrationForbidden
544589

545590
create_account = AccountStatus.UNVERIFIED
546591
if (
547592
CONFIG.EMAIL_DOMAIN_ALLOW_LIST is None
548593
or CONFIG.EMAIL_AUTO_ACTIVATE_ACCOUNTS
549594
or CONFIG.AUTO_ACTIVATE_ACCOUNTS
595+
or is_test
550596
):
551597
create_account = AccountStatus.ACTIVE
552598

@@ -686,3 +732,29 @@ def generate_user_api_key():
686732
),
687733
401,
688734
)
735+
736+
737+
@AUTH.route("/testing/create-magic-link", methods=["POST"])
738+
def create_test_magic_link():
739+
"""Create a magic link for testing purposes.
740+
741+
This endpoint is only available when TESTING=True.
742+
It creates a user with the specified email and role, generates a magic link,
743+
and returns the token.
744+
"""
745+
if not CONFIG.TESTING:
746+
return jsonify(
747+
{"status": "error", "detail": "This endpoint is only available in testing mode."}
748+
), 403
749+
750+
request_json = request.get_json()
751+
email = request_json.get("email")
752+
referrer = request_json.get("referrer", "http://localhost:8080")
753+
754+
error_response, status_code = _validate_magic_link_request(email, referrer)
755+
if error_response:
756+
return error_response, status_code
757+
758+
token = _generate_and_store_token(email, is_test=True)
759+
760+
return jsonify({"status": "success", "token": token}), 200

pydatalab/src/pydatalab/routes/v0_1/items.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ def _create_sample(
503503
# so no creators are assigned
504504
new_sample["creator_ids"] = []
505505
new_sample["creators"] = []
506-
elif CONFIG.TESTING:
506+
elif CONFIG.TESTING and not current_user.is_authenticated:
507507
# Set fake ID to ObjectId("000000000000000000000000") so a dummy user can be created
508508
# locally for testing creator UI elements
509509
new_sample["creator_ids"] = [PUBLIC_USER_ID]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
describe("Authenticated sample tests", () => {
2+
beforeEach(() => {
3+
cy.loginViaTestMagicLink("[email protected]", "user");
4+
cy.visit("/");
5+
});
6+
7+
afterEach(() => {
8+
cy.logout();
9+
});
10+
11+
it("Creates a sample as authenticated user", () => {
12+
cy.createSample("auth-test-1", "Sample created by authenticated user");
13+
cy.verifySample("auth-test-1", "Sample created by authenticated user");
14+
15+
cy.findByText("auth-test-1").click();
16+
cy.contains("[email protected]").should("exist");
17+
18+
cy.visit("/");
19+
cy.deleteSample("auth-test-1");
20+
});
21+
22+
it("Shows correct user info when logged in", () => {
23+
cy.get(".alert-info").should("not.exist");
24+
25+
cy.contains("[email protected]").should("exist");
26+
});
27+
});
28+
29+
describe("Admin-specific functionality", () => {
30+
beforeEach(() => {
31+
cy.loginViaTestMagicLink("[email protected]", "admin");
32+
cy.visit("/admin");
33+
});
34+
35+
afterEach(() => {
36+
cy.logout();
37+
});
38+
39+
it("Accesses admin dashboard", () => {
40+
cy.visit("/admin");
41+
cy.url().should("include", "/admin");
42+
cy.contains("Admin Menu").should("exist");
43+
cy.get('[data-testid="admin-table"]').should("exist");
44+
});
45+
});
46+
47+
describe("Multi-user sample visibility", () => {
48+
const user1Email = "[email protected]";
49+
const user2Email = "[email protected]";
50+
const user1SampleId = "user1-sample";
51+
const user2SampleId = "user2-sample";
52+
53+
it("User 1 creates a sample", () => {
54+
cy.loginViaTestMagicLink(user1Email, "user");
55+
cy.visit("/");
56+
57+
cy.createSample(user1SampleId, "User 1's sample");
58+
cy.verifySample(user1SampleId, "User 1's sample");
59+
60+
cy.logout();
61+
});
62+
63+
it("User 2 creates a different sample", () => {
64+
cy.loginViaTestMagicLink(user2Email, "user");
65+
cy.visit("/");
66+
67+
cy.createSample(user2SampleId, "User 2's sample");
68+
cy.verifySample(user2SampleId, "User 2's sample");
69+
70+
cy.logout();
71+
});
72+
73+
it("User 1 can see their own sample", () => {
74+
cy.loginViaTestMagicLink(user1Email, "user");
75+
cy.visit("/");
76+
77+
cy.verifySample(user1SampleId, "User 1's sample");
78+
79+
cy.logout();
80+
});
81+
82+
it("User 2 can see their own sample", () => {
83+
cy.loginViaTestMagicLink(user2Email, "user");
84+
cy.visit("/");
85+
86+
cy.verifySample(user2SampleId, "User 2's sample");
87+
88+
cy.logout();
89+
});
90+
91+
it("User 1 cannot see User 2's sample", () => {
92+
cy.loginViaTestMagicLink(user1Email, "user");
93+
cy.visit("/");
94+
95+
cy.get("[data-testid=sample-table]").should("not.contain", user2SampleId);
96+
97+
cy.logout();
98+
});
99+
100+
it("User 2 cannot see User 1's sample", () => {
101+
cy.loginViaTestMagicLink(user2Email, "user");
102+
cy.visit("/");
103+
104+
cy.get("[data-testid=sample-table]").should("not.contain", user1SampleId);
105+
106+
cy.logout();
107+
});
108+
109+
after(() => {
110+
cy.loginViaTestMagicLink(user1Email, "user");
111+
cy.visit("/");
112+
cy.deleteSample(user1SampleId);
113+
cy.logout();
114+
115+
cy.loginViaTestMagicLink(user2Email, "user");
116+
cy.visit("/");
117+
cy.deleteSample(user2SampleId);
118+
cy.logout();
119+
});
120+
});

webapp/cypress/e2e/batchSampleFeature.cy.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ let sample_ids = [
6969
"cell_3",
7070
];
7171

72+
before(() => {
73+
cy.loginViaTestMagicLink("[email protected]", "user");
74+
});
75+
76+
after(() => {
77+
cy.logout();
78+
});
79+
7280
before(() => {
7381
cy.visit("/");
7482
cy.removeAllTestSamples(sample_ids, true);

webapp/cypress/e2e/editPage.cy.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ Cypress.on("window:before:load", (win) => {
88

99
let item_ids = ["editable_sample", "component1", "component2"];
1010

11+
before(() => {
12+
cy.loginViaTestMagicLink("[email protected]", "user");
13+
});
14+
15+
after(() => {
16+
cy.logout();
17+
});
18+
1119
before(() => {
1220
cy.visit("/");
1321
cy.removeAllTestSamples(item_ids, true);

webapp/cypress/e2e/equipment.cy.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ Cypress.on("window:before:load", (win) => {
88

99
let item_ids = ["test_e1", "test_e2", "test_e3", "123equipment", "test_e3_copy"];
1010

11+
before(() => {
12+
cy.loginViaTestMagicLink("[email protected]", "user");
13+
});
14+
15+
after(() => {
16+
cy.logout();
17+
});
18+
1119
before(() => {
1220
cy.visit("/equipment");
1321
cy.removeAllTestSamples(item_ids);

webapp/cypress/e2e/sampleTablePage.cy.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ Cypress.on("window:before:load", (win) => {
66
consoleSpy = cy.spy(win.console, "error");
77
});
88

9+
before(() => {
10+
cy.loginViaTestMagicLink("[email protected]", "user");
11+
});
12+
13+
after(() => {
14+
cy.logout();
15+
});
16+
917
let sample_ids = [
1018
"12345678910",
1119
"test1",

webapp/cypress/e2e/startingMaterial.cy.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
const API_URL = Cypress.config("apiUrl");
22
console.log(API_URL);
33

4+
before(() => {
5+
cy.loginViaTestMagicLink("[email protected]", "user");
6+
});
7+
8+
after(() => {
9+
cy.logout();
10+
});
11+
412
describe("Starting material table page - editable_inventory FALSE", () => {
513
beforeEach(() => {
614
cy.visit("/starting-materials");

0 commit comments

Comments
 (0)