-
Notifications
You must be signed in to change notification settings - Fork 114
feat(db): persist users + resource selection — Part of #586 #973
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6710627
780a40a
2c0cd6e
9d9a62a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| """Tests for user persistence and per-user resource selection (issue #586, RFC #876 TODO 1/2).""" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Solid SQL-layer coverage: idempotent upsert, selection round-trip/replace/dedup/isolation, unique constraint, cascade delete. Postgres verification in the PR description is the right bar for prod parity. |
||
|
|
||
| import os | ||
| import unittest | ||
|
|
||
| from sqlalchemy.exc import IntegrityError | ||
|
|
||
| from application import create_app, sqla | ||
| from application.database import db | ||
|
|
||
|
|
||
| class TestUserModel(unittest.TestCase): | ||
| def setUp(self) -> None: | ||
| # These tests exercise only the SQL layer; skip the Neo4j graph load. | ||
| os.environ["NO_LOAD_GRAPH_DB"] = "1" | ||
| self.app = create_app(mode="test") | ||
| self.app_context = self.app.app_context() | ||
| self.app_context.push() | ||
| sqla.create_all() | ||
| self.collection = db.Node_collection() | ||
|
|
||
| def tearDown(self) -> None: | ||
| sqla.session.remove() | ||
| sqla.drop_all() | ||
| self.app_context.pop() | ||
| os.environ.pop("NO_LOAD_GRAPH_DB", None) | ||
|
|
||
| def test_upsert_user_creates_single_row(self) -> None: | ||
| user = self.collection.upsert_user( | ||
| google_sub="sub-123", email="a@example.com", display_name="Alice" | ||
| ) | ||
| self.assertIsNotNone(user.id) | ||
| self.assertEqual(user.google_sub, "sub-123") | ||
| self.assertEqual(user.email, "a@example.com") | ||
| self.assertEqual(user.display_name, "Alice") | ||
| self.assertIsNotNone(user.created_at) | ||
| self.assertIsNotNone(user.last_seen_at) | ||
| self.assertEqual(sqla.session.query(db.User).count(), 1) | ||
|
|
||
| def test_upsert_user_is_idempotent_and_updates_in_place(self) -> None: | ||
| first = self.collection.upsert_user( | ||
| google_sub="sub-123", email="old@example.com", display_name="Alice" | ||
| ) | ||
| first_id = first.id | ||
| created_at = first.created_at | ||
|
|
||
| second = self.collection.upsert_user( | ||
| google_sub="sub-123", email="new@example.com", display_name="Alice B" | ||
| ) | ||
|
|
||
| # No duplicate row, same identity, refreshed profile fields. | ||
| self.assertEqual(sqla.session.query(db.User).count(), 1) | ||
| self.assertEqual(second.id, first_id) | ||
| self.assertEqual(second.email, "new@example.com") | ||
| self.assertEqual(second.display_name, "Alice B") | ||
| self.assertEqual(second.created_at, created_at) | ||
| self.assertGreaterEqual(second.last_seen_at, created_at) | ||
|
|
||
| def test_upsert_user_tolerates_missing_email_and_name(self) -> None: | ||
| user = self.collection.upsert_user( | ||
| google_sub="sub-noemail", email="", display_name=None | ||
| ) | ||
| self.assertEqual(user.email, "") | ||
| self.assertIsNone(user.display_name) | ||
| self.assertEqual(sqla.session.query(db.User).count(), 1) | ||
|
|
||
| def test_get_user_by_sub(self) -> None: | ||
| self.collection.upsert_user( | ||
| google_sub="sub-123", email="a@example.com", display_name="Alice" | ||
| ) | ||
| found = self.collection.get_user_by_sub("sub-123") | ||
| self.assertIsNotNone(found) | ||
| self.assertEqual(found.google_sub, "sub-123") | ||
| self.assertIsNone(self.collection.get_user_by_sub("does-not-exist")) | ||
|
|
||
| def test_resource_selection_round_trips(self) -> None: | ||
| user = self.collection.upsert_user( | ||
| google_sub="sub-123", email="a@example.com", display_name="Alice" | ||
| ) | ||
| # Empty by default. | ||
| self.assertEqual(self.collection.get_user_resource_selection(user.id), []) | ||
|
|
||
| stored = self.collection.set_user_resource_selection( | ||
| user.id, ["ASVS", "CWE", "SAMM"] | ||
| ) | ||
| self.assertEqual(sorted(stored), ["ASVS", "CWE", "SAMM"]) | ||
| self.assertEqual( | ||
| self.collection.get_user_resource_selection(user.id), | ||
| ["ASVS", "CWE", "SAMM"], | ||
| ) | ||
|
|
||
| def test_set_resource_selection_replaces_previous(self) -> None: | ||
| user = self.collection.upsert_user( | ||
| google_sub="sub-123", email="a@example.com", display_name="Alice" | ||
| ) | ||
| self.collection.set_user_resource_selection(user.id, ["ASVS", "CWE"]) | ||
| self.collection.set_user_resource_selection(user.id, ["SAMM"]) | ||
| self.assertEqual(self.collection.get_user_resource_selection(user.id), ["SAMM"]) | ||
| self.assertEqual( | ||
| sqla.session.query(db.UserResourceSelection) | ||
| .filter(db.UserResourceSelection.user_id == user.id) | ||
| .count(), | ||
| 1, | ||
| ) | ||
|
|
||
| def test_set_resource_selection_dedupes_input(self) -> None: | ||
| user = self.collection.upsert_user( | ||
| google_sub="sub-123", email="a@example.com", display_name="Alice" | ||
| ) | ||
| self.collection.set_user_resource_selection(user.id, ["ASVS", "ASVS", "CWE"]) | ||
| self.assertEqual( | ||
| self.collection.get_user_resource_selection(user.id), ["ASVS", "CWE"] | ||
| ) | ||
|
|
||
| def test_resource_selection_is_per_user(self) -> None: | ||
| alice = self.collection.upsert_user( | ||
| google_sub="sub-a", email="a@example.com", display_name="Alice" | ||
| ) | ||
| bob = self.collection.upsert_user( | ||
| google_sub="sub-b", email="b@example.com", display_name="Bob" | ||
| ) | ||
| self.collection.set_user_resource_selection(alice.id, ["ASVS"]) | ||
| self.collection.set_user_resource_selection(bob.id, ["CWE"]) | ||
| self.assertEqual( | ||
| self.collection.get_user_resource_selection(alice.id), ["ASVS"] | ||
| ) | ||
| self.assertEqual(self.collection.get_user_resource_selection(bob.id), ["CWE"]) | ||
|
|
||
| def test_resource_selection_unique_constraint_enforced(self) -> None: | ||
| user = self.collection.upsert_user( | ||
| google_sub="sub-123", email="a@example.com", display_name="Alice" | ||
| ) | ||
| from datetime import datetime, timezone | ||
|
|
||
| sqla.session.add( | ||
| db.UserResourceSelection( | ||
| user_id=user.id, | ||
| standard_name="ASVS", | ||
| created_at=datetime.now(timezone.utc), | ||
| ) | ||
| ) | ||
| sqla.session.add( | ||
| db.UserResourceSelection( | ||
| user_id=user.id, | ||
| standard_name="ASVS", | ||
| created_at=datetime.now(timezone.utc), | ||
| ) | ||
| ) | ||
| with self.assertRaises(IntegrityError): | ||
| sqla.session.commit() | ||
| sqla.session.rollback() | ||
|
|
||
| def test_deleting_user_cascades_to_selection(self) -> None: | ||
| user = self.collection.upsert_user( | ||
| google_sub="sub-123", email="a@example.com", display_name="Alice" | ||
| ) | ||
| self.collection.set_user_resource_selection(user.id, ["ASVS", "CWE"]) | ||
| sqla.session.delete(user) | ||
| sqla.session.commit() | ||
| self.assertEqual(sqla.session.query(db.UserResourceSelection).count(), 0) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1151,6 +1151,72 @@ def test_gap_analysis_weak_links_response(self, db_mock) -> None: | |
| self.assertEqual(200, response.status_code) | ||
| self.assertEqual(expected, json.loads(response.data)) | ||
|
|
||
| @patch("application.web.web_main.id_token") | ||
| @patch("application.web.web_main.CREFlow") | ||
| def test_callback_upserts_user_and_sets_session( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good coverage for flag-on persist and flag-off no-op. Small addition: assert |
||
| self, cre_flow_mock, id_token_mock | ||
| ) -> None: | ||
| id_token_mock.verify_oauth2_token.return_value = { | ||
| "sub": "sub-xyz", | ||
| "name": "Test User", | ||
| "email": "test@example.com", | ||
| } | ||
| flow_instance = cre_flow_mock.instance.return_value | ||
| flow_instance.flow.credentials._id_token = "tok" | ||
| self.app.secret_key = "test-secret" | ||
| with patch.dict( | ||
| os.environ, | ||
| { | ||
| "CRE_ENABLE_LOGIN": "1", | ||
| "LOGIN_ALLOWED_DOMAINS": "*", | ||
| "NO_LOAD_GRAPH_DB": "1", | ||
| "INSECURE_REQUESTS": "1", | ||
| }, | ||
| ): | ||
| session_user_id = None | ||
| with self.app.test_client() as client: | ||
| with client.session_transaction() as sess: | ||
| sess["state"] = "xyz" | ||
| client.get("/rest/v1/callback?state=xyz") | ||
| with client.session_transaction() as sess: | ||
| self.assertIn("user_id", sess) | ||
| session_user_id = sess["user_id"] | ||
|
|
||
| users = sqla.session.query(db.User).all() | ||
| self.assertEqual(len(users), 1) | ||
| self.assertEqual(users[0].google_sub, "sub-xyz") | ||
| self.assertEqual(users[0].email, "test@example.com") | ||
| self.assertEqual(users[0].display_name, "Test User") | ||
| # Session id must match the persisted row (guards PR2's /user wiring). | ||
| self.assertEqual(session_user_id, users[0].id) | ||
|
|
||
| @patch("application.web.web_main.id_token") | ||
| @patch("application.web.web_main.CREFlow") | ||
| def test_callback_does_not_persist_when_login_flag_off( | ||
| self, cre_flow_mock, id_token_mock | ||
| ) -> None: | ||
| id_token_mock.verify_oauth2_token.return_value = { | ||
| "sub": "sub-xyz", | ||
| "name": "Test User", | ||
| "email": "test@example.com", | ||
| } | ||
| flow_instance = cre_flow_mock.instance.return_value | ||
| flow_instance.flow.credentials._id_token = "tok" | ||
| self.app.secret_key = "test-secret" | ||
| env = { | ||
| "LOGIN_ALLOWED_DOMAINS": "*", | ||
| "NO_LOAD_GRAPH_DB": "1", | ||
| "INSECURE_REQUESTS": "1", | ||
| } | ||
| with patch.dict(os.environ, env): | ||
| os.environ.pop("CRE_ENABLE_LOGIN", None) | ||
| with self.app.test_client() as client: | ||
| with client.session_transaction() as sess: | ||
| sess["state"] = "xyz" | ||
| client.get("/rest/v1/callback?state=xyz") | ||
|
|
||
| self.assertEqual(sqla.session.query(db.User).count(), 0) | ||
|
|
||
| def test_deeplink(self) -> None: | ||
| self.maxDiff = None | ||
| collection = db.Node_collection().with_graph() | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.