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
23 changes: 23 additions & 0 deletions securedrop/journalist_app/api2/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def process(self, event: Event, minor: int) -> EventResult:
handler = {
EventType.ITEM_DELETED: self.handle_item_deleted,
EventType.ITEM_SEEN: self.handle_item_seen,
EventType.ITEM_UNSEEN: self.handle_item_unseen,
EventType.REPLY_SENT: self.handle_reply_sent,
EventType.SOURCE_DELETED: self.handle_source_deleted,
EventType.SOURCE_CONVERSATION_DELETED: self.handle_source_conversation_deleted,
Expand Down Expand Up @@ -348,6 +349,28 @@ def handle_item_seen(event: Event, minor: int) -> EventResult:
items={item.uuid: item},
)

@staticmethod
def handle_item_unseen(event: Event, minor: int) -> EventResult:
item = find_item(event.target.item_uuid)
if item is None:
return EventResult(
event_id=event.id,
status=(EventStatusCode.NotFound, f"could not find item: {event.target.item_uuid}"),
)

utils.mark_unseen([item], session.get_user())

source = item.source
db.session.refresh(source)
db.session.refresh(item)

return EventResult(
event_id=event.id,
status=(EventStatusCode.OK, None),
sources={source.uuid: source},
items={item.uuid: item},
)


def find_item(item_uuid: ItemUUID) -> Submission | Reply | None:
submission = Submission.query.filter(Submission.uuid == item_uuid).one_or_none()
Expand Down
1 change: 1 addition & 0 deletions securedrop/journalist_app/api2/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class EventType(StrEnum):
REPLY_SENT = auto()
ITEM_DELETED = auto()
ITEM_SEEN = auto()
ITEM_UNSEEN = auto()
SOURCE_DELETED = auto()
SOURCE_CONVERSATION_DELETED = auto()
SOURCE_CONVERSATION_TRUNCATED = auto()
Expand Down
32 changes: 28 additions & 4 deletions securedrop/journalist_app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def commit_account_changes(user: Journalist) -> None:
db.session.commit()
except Exception as e:
flash(
gettext("An unexpected error occurred! Please " "inform your admin."),
gettext("An unexpected error occurred! Please inform your admin."),
"error",
)
current_app.logger.error(f"Account changes for '{user}' failed: {e}")
Expand Down Expand Up @@ -104,7 +104,7 @@ def validate_user(
elif isinstance(e, OtpSecretInvalid):
login_flashed_msg += " "
login_flashed_msg += gettext(
"Your 2FA details are invalid" " - please contact an administrator to reset them."
"Your 2FA details are invalid - please contact an administrator to reset them."
)
else:
try:
Expand Down Expand Up @@ -149,14 +149,14 @@ def validate_hotp_secret(user: Journalist, otp_secret: str) -> bool:
if "Non-hexadecimal digit found" in str(e):
flash(
gettext(
"Invalid HOTP secret format: " "please only submit letters A-F and numbers 0-9."
"Invalid HOTP secret format: please only submit letters A-F and numbers 0-9."
),
"error",
)
return False
else:
flash(
gettext("An unexpected error occurred! " "Please inform your admin."),
gettext("An unexpected error occurred! Please inform your admin."),
"error",
)
current_app.logger.error(f"set_hotp_secret '{otp_secret}' (id {user.id}) failed: {e}")
Expand Down Expand Up @@ -190,6 +190,30 @@ def mark_seen(targets: List[Union[Submission, Reply]], user: Journalist) -> None
raise


def mark_unseen(targets: List[Union[Submission, Reply]], user: Journalist) -> None:
"""Fully reset seen state for the provided submissions or replies."""

for t in targets:
try:
if isinstance(t, Submission):
t.downloaded = False
if t.is_file:
SeenFile.query.filter(SeenFile.file_id == t.id).delete(
synchronize_session=False
)
if t.is_message:
SeenMessage.query.filter(SeenMessage.message_id == t.id).delete(
synchronize_session=False
)
db.session.commit()
elif isinstance(t, Reply):
SeenReply.query.filter(SeenReply.reply_id == t.id).delete(synchronize_session=False)
db.session.commit()
except IntegrityError:
db.session.rollback()
raise


def download(
zip_basename: str,
submissions: List[Union[Submission, Reply]],
Expand Down
65 changes: 54 additions & 11 deletions securedrop/tests/test_journalist.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from flask import g, url_for
from flask_babel import gettext, ngettext
from journalist_app.sessions import session
from journalist_app.utils import mark_seen
from journalist_app.utils import mark_seen, mark_unseen
from markupsafe import escape
from models import (
InstanceConfig,
Expand Down Expand Up @@ -413,7 +413,7 @@ def test_admin_has_link_to_edit_account_page_in_index_page(journalist_app, test_
),
follow_redirects=True,
)
edit_account_link = '<a href="/account/account" ' 'id="link-edit-account">'
edit_account_link = '<a href="/account/account" id="link-edit-account">'
text = resp.data.decode("utf-8")
assert edit_account_link in text

Expand All @@ -429,7 +429,7 @@ def test_user_has_link_to_edit_account_page_in_index_page(journalist_app, test_j
),
follow_redirects=True,
)
edit_account_link = '<a href="/account/account" ' 'id="link-edit-account">'
edit_account_link = '<a href="/account/account" id="link-edit-account">'
text = resp.data.decode("utf-8")
assert edit_account_link in text

Expand Down Expand Up @@ -1005,7 +1005,7 @@ def test_admin_edits_user_password_too_long_warning(journalist_app, test_admin,
)

ins.assert_message_flashed(
"The password you submitted is invalid. " "Password not changed.",
"The password you submitted is invalid. Password not changed.",
"error",
)

Expand Down Expand Up @@ -1040,7 +1040,7 @@ def test_user_edits_password_too_long_warning(config, journalist_app, test_journ
)

ins.assert_message_flashed(
"The password you submitted is invalid. " "Password not changed.",
"The password you submitted is invalid. Password not changed.",
"error",
)

Expand Down Expand Up @@ -1216,7 +1216,7 @@ def test_admin_resets_user_hotp_format_non_hexa(journalist_app, test_admin, test
assert journo.is_totp

ins.assert_message_flashed(
"Invalid HOTP secret format: please only submit letters A-F and " "numbers 0-9.",
"Invalid HOTP secret format: please only submit letters A-F and numbers 0-9.",
"error",
)

Expand Down Expand Up @@ -1255,7 +1255,7 @@ def test_admin_resets_user_hotp_format_too_short(
assert journo.is_totp

ins.assert_message_flashed(
"HOTP secrets are 40 characters long" " - you have entered {num}.".format(
"HOTP secrets are 40 characters long - you have entered {num}.".format(
num=len(the_secret.replace(" ", ""))
),
"error",
Expand Down Expand Up @@ -1318,7 +1318,7 @@ def test_admin_resets_user_hotp_error(mocker, journalist_app, test_admin, test_j
data=dict(uid=test_journo["id"], otp_secret=bad_secret),
)
ins.assert_message_flashed(
"An unexpected error occurred! " "Please inform your admin.", "error"
"An unexpected error occurred! Please inform your admin.", "error"
)

# Re-fetch journalist to get fresh DB instance
Expand Down Expand Up @@ -1384,7 +1384,7 @@ def test_user_resets_user_hotp_format_non_hexa(journalist_app, test_journo):
data=dict(otp_secret=non_hexa_secret),
)
ins.assert_message_flashed(
"Invalid HOTP secret format: " "please only submit letters A-F and numbers 0-9.",
"Invalid HOTP secret format: please only submit letters A-F and numbers 0-9.",
"error",
)

Expand Down Expand Up @@ -1420,7 +1420,7 @@ def test_user_resets_user_hotp_error(mocker, journalist_app, test_journo):
data=dict(otp_secret=bad_secret),
)
ins.assert_message_flashed(
"An unexpected error occurred! Please inform your " "admin.", "error"
"An unexpected error occurred! Please inform your admin.", "error"
)

# Re-fetch journalist to get fresh DB instance
Expand Down Expand Up @@ -2152,7 +2152,7 @@ def test_admin_add_user_integrity_error(config, journalist_app, test_admin, mock
)
assert page_language(resp.data) == language_tag(locale)
msgids = [
"An error occurred saving this user to the database. " "Please inform your admin."
"An error occurred saving this user to the database. Please inform your admin."
]
with xfail_untranslated_messages(config, locale, msgids):
ins.assert_message_flashed(gettext(msgids[0]), "error")
Expand Down Expand Up @@ -3111,6 +3111,49 @@ def test_delete_collection_updates_db(journalist_app, test_journo, test_source,
assert not seen_reply


def test_mark_unseen_resets_file_submission(journalist_app, test_journo, test_source, app_storage):
with journalist_app.app_context():
source = Source.query.get(test_source["id"])
journo = Journalist.query.get(test_journo["id"])
submissions = utils.db_helper.submit(app_storage, source, 1, submission_type="file")
mark_seen(submissions, journo)

mark_unseen(submissions, journo)

db.session.refresh(submissions[0])
assert submissions[0].downloaded is False
assert SeenFile.query.filter_by(file_id=submissions[0].id).count() == 0


def test_mark_unseen_resets_message_submission(
journalist_app, test_journo, test_source, app_storage
):
with journalist_app.app_context():
source = Source.query.get(test_source["id"])
journo = Journalist.query.get(test_journo["id"])
submissions = utils.db_helper.submit(app_storage, source, 1, submission_type="message")
mark_seen(submissions, journo)

mark_unseen(submissions, journo)

db.session.refresh(submissions[0])
assert submissions[0].downloaded is False
assert SeenMessage.query.filter_by(message_id=submissions[0].id).count() == 0


def test_mark_unseen_resets_reply_state(journalist_app, test_journo, test_source, app_storage):
with journalist_app.app_context():
source = Source.query.get(test_source["id"])
journo = Journalist.query.get(test_journo["id"])
replies = utils.db_helper.reply(app_storage, journo, source, 1)
mark_seen(replies, journo)

mark_unseen(replies, journo)

reply = Reply.query.get(replies[0].id)
assert SeenReply.query.filter_by(reply_id=reply.id).count() == 0


def test_delete_source_deletes_gpg_source_key(
journalist_app, test_source, test_journo, app_storage
):
Expand Down
79 changes: 76 additions & 3 deletions securedrop/tests/test_journalist_api2.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ def assert_query_count(expected_count, expect_login=True):
):
new_queries = new_queries[1:]

assert (
len(new_queries) == expected_count
), f"Expected {expected_count} queries, but {len(new_queries)} were executed"
assert len(new_queries) == expected_count, (
f"Expected {expected_count} queries, but {len(new_queries)} were executed"
)


def test_json_version():
Expand Down Expand Up @@ -863,6 +863,79 @@ def test_api2_item_seen(
assert "could not find item" in response.json["events"][no_such_item_event.id][1]


def test_api2_item_unseen(
journalist_app,
journalist_api_token,
test_files,
):
"""Test processing of the "item_unseen" event."""

with journalist_app.test_client() as app:
source = test_files["source"]
source_uuid = source.uuid

submission = test_files["submissions"][0]
submission_uuid = submission.uuid

index = app.get(
url_for("api2.index"),
headers=get_api_headers(journalist_api_token),
)
assert index.status_code == 200
current_item_version = index.json["items"][submission_uuid]

seen_event = Event(
id="345678",
target=ItemTarget(item_uuid=submission_uuid, version=current_item_version),
type=EventType.ITEM_SEEN,
)
response = app.post(
url_for("api2.data"),
json={"events": [asdict(seen_event)]},
headers=get_api_headers(journalist_api_token),
)
assert response.json["events"][seen_event.id] == [200, None]

refreshed_index = app.get(
url_for("api2.index"),
headers=get_api_headers(journalist_api_token),
)
assert refreshed_index.status_code == 200
unseen_version = refreshed_index.json["items"][submission_uuid]

unseen_event = Event(
id="456789",
target=ItemTarget(item_uuid=submission_uuid, version=unseen_version),
type=EventType.ITEM_UNSEEN,
)
response = app.post(
url_for("api2.data"),
json={"events": [asdict(unseen_event)]},
headers=get_api_headers(journalist_api_token),
)
assert response.json["events"][unseen_event.id] == [200, None]
assert source_uuid in response.json["sources"]
assert submission_uuid in response.json["items"]

updated_submission = Submission.query.filter(Submission.uuid == submission_uuid).one()
assert updated_submission.downloaded is False
assert len(updated_submission.seen_files) == 0
assert len(updated_submission.seen_messages) == 0

no_such_item_event = Event(
id="567890",
target=ItemTarget(item_uuid=str(uuid.uuid4()), version=unseen_version),
type=EventType.ITEM_UNSEEN,
)
response = app.post(
url_for("api2.data"),
json={"events": [asdict(no_such_item_event)]},
headers=get_api_headers(journalist_api_token),
)
assert response.json["events"][no_such_item_event.id][0] == 404
assert "could not find item" in response.json["events"][no_such_item_event.id][1]


def test_api2_idempotence_period(journalist_app):
"""
`IDEMPOTENCE_PERIOD` MUST be greater than or equal to
Expand Down
Loading