diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15d6eddf..76fa56d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -326,10 +326,28 @@ strongest test rebuilds the committed export fixtures in `app/src/test/resources Two things it deliberately does not cover, because they need an Apple account: signing in, and everything the account then yields. What happens to the records afterwards is covered in full. -**Two files need Tk and skip without it** — `test_asyncui.py`, for what happens when somebody -closes the progress window, and `test_wizard_list.py`, for the list of accessories. Both drive -real Tk widgets, because what they cover *is* the event loop and the widget: a cancelled progress -window used to leave the wizard hung for ever, and that is not reproducible against a stand-in. +**Four files need Tk and skip without it.** All of them drive real Tk widgets, because what they +cover *is* the widget: a cancelled progress window used to leave the wizard hung for ever, and +that is not reproducible against a stand-in. + +| File | What it drives | +| --- | --- | +| `test_asyncui.py` | closing the progress window mid-sign-in | +| `test_wizard_list.py` | the list of accessories | +| `test_save_logs_button.py` | the Save logs button, and that no identifier survives into the file | +| `test_terms_dialog.py` | Apple's terms of service, and that refusing them sends nothing | + +**The skip has to be `pytest.importorskip`, at the top, before importing `exporter.wizard`.** A +`pytest.skip` inside a fixture is too late: the module is imported during collection, so a Python +built without Tk fails the whole run before any fixture can decline. + +**Do not test a modal by driving it.** `grab_set` plus `wait_window` hand control to a nested +event loop and a window manager, and a test that schedules a click into that deadlocks rather than +failing. Build the window and show it in separate functions, as `_build_terms_window` and +`_show_terms` do, and drive the built one. For the same reason, keyboard bindings are asserted +with `widget.bind("")` rather than `event_generate`: a Toplevel under a withdrawn root holds +focus from nobody, so a generated keypress is dropped in silence and the test passes without +having pressed anything. **CI runs them on one job in four.** The matrix is 3.10 to 3.13 on `macos-14`, and only its 3.13 has `_tkinter` — the other three skip both files at collection, so a green square there says diff --git a/python/exporter/wizard.py b/python/exporter/wizard.py index 2cb3585a..e4c45a0d 100644 --- a/python/exporter/wizard.py +++ b/python/exporter/wizard.py @@ -32,7 +32,7 @@ from typing import Callable, Sequence from tkinter.filedialog import askopenfilenames, asksaveasfilename -from exporter import icloud, localsource, source +from exporter import icloud, localsource, source, terms from exporter.asyncui import Asker, Cancelled, run_with_progress from exporter.codes import ( VERIFICATION_CODE_LENGTH, @@ -48,7 +48,12 @@ suggested_identifier, suggested_name, ) -from findmy import InvalidCredentialsError +from findmy import ( + InvalidCredentialsError, + LoginState, + MobileMeDelegateError, + TermsError, +) from findmy.keychain.recovery import RecoveryError from exporter.icloud import Candidate, ExportSourceError @@ -79,6 +84,11 @@ TICKED = "\u25a0" UNTICKED = "\u25a1" +# How wide the terms are wrapped, and how wide the box that shows them is. One number rather than +# two, because they have to agree: `terms.render` hard-wraps at whatever it is given, so a box +# narrower than that wraps every line a second time and produces a ragged short line under each. +_TERMS_COLUMNS = 88 + TICKED_ROW = "ticked" """ The tag that colours a ticked row, using this platform's own selection colour. @@ -96,6 +106,19 @@ ] +class TermsDeclined(Exception): + """ + The user read Apple's terms of service and said no. + + **The one answer this window cannot carry on from**, and the only reason it is not just + another `ExportSourceError`. Everything else that goes wrong here leaves the button there to + try again, because trying again is a sensible thing to do. This does not: Apple will not + complete the delegate exchange for an account with terms pending, so a second attempt reaches + the same document and asks the same question. Offering a retry would be pretending the answer + might change by itself. + """ + + class WizardApp(tk.Tk): """ The window: a list of accessories, and a button that writes them. @@ -281,10 +304,13 @@ def _load(self) -> None: """ Read whichever source this machine can use, and add what it holds to the list. - **Nothing closes the window from here.** This runs when a button is pressed rather than - when the program starts, so a sign-in that fails, or one the user changes their mind about - half way through, leaves them where they were - with whatever they had already added from - a key file still in the list, and the button still there to try again. + **Almost nothing closes the window from here.** This runs when a button is pressed rather + than when the program starts, so a sign-in that fails, or one the user changes their mind + about half way through, leaves them where they were - with whatever they had already added + from a key file still in the list, and the button still there to try again. + + The single exception is refusing Apple's terms of service, which is not a failure that + trying again can get past - see :class:`TermsDeclined`. """ try: fetched = ( @@ -295,6 +321,21 @@ def _load(self) -> None: except Cancelled: # They closed the progress window, which says stop this - not close everything. return + except TermsDeclined as declined: + # **The one exception to the paragraph above**, and it is deliberate. Every other + # failure leaves the window open because trying again might work; this one cannot, + # since the same terms are waiting on the next attempt and the answer was no. So it + # says what that means, confirms nothing was sent, and closes. + logger.info("Terms of service declined: %s", declined) + messagebox.showinfo( + "Terms not accepted", + f"Nothing was sent, and your Apple account is unchanged.\n\n" + f"Apple will not let anything read this account until the {declined} terms are" + " accepted, so there is nothing more this exporter can do. You can accept them" + " on an Apple device or at icloud.com, or run this again and read them here.", + ) + self.destroy() + return except (ExportSourceError, ExportError) as e: messagebox.showerror("Could not read your accessories", str(e)) return @@ -367,29 +408,37 @@ async def _read_icloud(self, asker: Asker): if not email or not password: raise ExportSourceError("Signing in was cancelled.") - await icloud.log_in( - account, - email, - password, - choose_second_factor=lambda methods: _async( - asker.ask(lambda: _ask_choice(self, "Verification", "How should Apple send the code?", methods)), - ), - get_code=lambda: _async( - asker.ask(lambda: _ask_string( - self, - "Verification", - f"The {VERIFICATION_CODE_LENGTH}-digit code Apple sent:", - valid=is_verification_code, - transform=verification_code, - )), - ), - retry_credentials=lambda error, attempt: _async( - _ask_again_for_credentials(self, asker, error, attempt), - ), - retry_code=lambda error, attempt: _async( - _ask_again_for_code(self, asker, error, attempt), - ), - ) + try: + await icloud.log_in( + account, + email, + password, + choose_second_factor=lambda methods: _async( + asker.ask(lambda: _ask_choice( + self, "Verification", "How should Apple send the code?", methods, + )), + ), + get_code=lambda: _async( + asker.ask(lambda: _ask_string( + self, + "Verification", + f"The {VERIFICATION_CODE_LENGTH}-digit code Apple sent:", + valid=is_verification_code, + transform=verification_code, + )), + ), + retry_credentials=lambda error, attempt: _async( + _ask_again_for_credentials(self, asker, error, attempt), + ), + retry_code=lambda error, attempt: _async( + _ask_again_for_code(self, asker, error, attempt), + ), + ) + except MobileMeDelegateError as e: + # Authentication worked; the exchange that follows it did not. Unaccepted terms + # are the one cause of that with a remedy here - and which error value means + # "terms pending" is not established, so this looks rather than assumes. + await _accept_pending_terms(self, account, asker, e) # Registered a device by now; remembering it stops the next export registering another. icloud.remember(account) @@ -873,6 +922,157 @@ def _ask_choice(parent: tk.Tk, title: str, prompt: str, options: Sequence[str]) return chosen.get() +def _build_terms_window(parent: tk.Tk, document, index: int, total: int): + """ + Build the terms dialog, and hand back the answer it will write into. + + **Split from :func:`_show_terms` so that it can be tested.** Everything worth checking here is + in the widgets - the document is shown whole, no markup survives, closing means no - and all + of it is unreachable from a test once `grab_set` and `wait_window` have been called, because + those hand control to a nested event loop and a window manager. Building and showing are two + steps for that reason and no other. + + :returns: The window, and a dict whose `value` is the answer. It starts False: the default for + a contract nobody answered has to be the one that sends nothing, so Reject, Escape and the + window's close button all leave it alone and only Accept sets it. + + **Fixed-pitch, and that is not a cosmetic choice.** :func:`exporter.terms.render` wraps to a + column count and underlines its headings with a row of dashes as long as the heading - which + lines up in a terminal and in nothing else. In a proportional font every heading rule comes + out the wrong length, and a contract that looks broken invites the reasonable conclusion that + it has been tampered with. + + Nothing here shortens or summarises: what is on screen is the document Apple sent, because it + is what pressing Accept agrees to. + """ + window = tk.Toplevel(parent) + window.title(f"Apple's terms of service - {document.page_id}") + window.transient(parent) + + frame = ttk.Frame(window, padding=16) + frame.pack(fill="both", expand=True) + frame.grid_columnconfigure(0, weight=1) + frame.grid_rowconfigure(1, weight=1) + + # Said above the document rather than after it: somebody who has just typed a password and is + # suddenly looking at a contract needs to know why before they start reading it. + counted = f" ({index} of {total})" if total > 1 else "" + ttk.Label( + frame, + text=( + f"Apple will not finish signing you in until these terms are accepted{counted}." + " They are shown in full - this is what you would be agreeing to.\n\n" + "Accepting records your agreement on your Apple account. Nothing has been sent yet." + ), + wraplength=560, + justify="left", + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 12)) + + text = tk.Text(frame, wrap="word", width=_TERMS_COLUMNS, height=28, font="TkFixedFont") + text.grid(row=1, column=0, sticky="nsew") + + scrollbar = ttk.Scrollbar(frame, orient="vertical", command=text.yview) + scrollbar.grid(row=1, column=1, sticky="ns") + text.configure(yscrollcommand=scrollbar.set) + + text.insert("1.0", terms.render(document.html, _TERMS_COLUMNS)) + # Read-only rather than merely discouraged. A Text is editable by default, and a contract you + # can type into is not the document that was fetched. + text.configure(state="disabled") + + accepted = {"value": False} + + def _accept() -> None: + accepted["value"] = True + window.destroy() + + buttons = ttk.Frame(frame) + buttons.grid(row=2, column=0, columnspan=2, sticky="e", pady=(12, 0)) + ttk.Button(buttons, text="Reject", command=window.destroy).pack(side="right", padx=(8, 0)) + ttk.Button(buttons, text="Accept", command=_accept).pack(side="right") + + # No Return binding, deliberately. Every other dialog here submits on Return because its + # answer is something the user typed; this one's answer is agreement to a contract, and a + # stray keypress landing on it is not agreement. + window.bind("", lambda _event: window.destroy()) + + return window, accepted + + +def _show_terms(parent: tk.Tk, document, index: int, total: int) -> bool: + """ + Show one terms document in full, and wait for it to be agreed to or refused. + + :returns: True only if Accept was pressed. See :func:`_build_terms_window`, which is where + everything except the waiting lives. + """ + window, accepted = _build_terms_window(parent, document, index, total) + + centre_over(window, parent) + window.grab_set() + parent.wait_window(window) + + return accepted["value"] + + +async def _accept_pending_terms(parent: tk.Tk, account, asker: Asker, error) -> None: + """ + Show whatever Apple wants agreeing to, and finish the sign-in if it is agreed to. + + **Only reached when signing in has already failed** on the delegate exchange, which is what an + account with unaccepted terms does. Apple takes acceptance on one of its own devices or on + iCloud.com and nowhere else, so somebody with neither is stuck without this - which is the + whole reason the CLI grew it, and there was no reason for the window not to have it too. + + **Unlike the CLI, this does not ask permission to fetch first.** The CLI asks because it is + about to page a document at a terminal and cannot take that back. Here the fetch decides + whether there is anything to show at all, so asking first would mean offering to look and then + reporting that there was nothing - where simply looking reports Apple's own message unchanged. + + :param error: What the delegate exchange said. Carried so that a failure for some other reason + is reported as Apple worded it, rather than as "no terms found". + :raises TermsDeclined: If any document is rejected. Nothing is sent for it, and no later + document is shown. + """ + try: + documents = await account.fetch_terms() + except TermsError as e: + raise ExportSourceError( + f"Signing in stopped at your account:\n\n{error}\n\n" + f"Asking Apple which terms are pending also failed:\n\n{e}", + ) from e + + if not documents: + # Signing in failed for some other reason, and accepting nothing would not fix it. Apple's + # own words, because they are the only description of the actual problem anyone has. + raise ExportSourceError( + f"Signing in got as far as your account and then stopped:\n\n{error}\n\n" + "There are no terms of service waiting to be accepted, so this is something else.", + ) + + for index, document in enumerate(documents, start=1): + # Bound as a default argument: the lambda runs on the main thread after this iteration has + # moved on, and a closure over the loop variable would show the last document every time. + agreed = asker.ask( + lambda d=document, i=index: _show_terms(parent, d, i, len(documents)), + ) + if not agreed: + raise TermsDeclined(document.page_id) + + try: + await account.accept_terms(document) + except TermsError as e: + raise ExportSourceError( + f"Apple did not record agreement to the {document.page_id} terms:\n\n{e}", + ) from e + + # The step `login` would have run itself had the terms not been pending. Without it the + # account is left at AUTHENTICATED - readable, and unusable for everything after this. + state = await account.complete_login() + if state != LoginState.LOGGED_IN: + raise ExportSourceError(f"The terms were accepted, but signing in ended at {state}.") + + async def _async(value): """Wrap an already-computed answer, for the awaitable callbacks `icloud.log_in` expects.""" return value diff --git a/python/test/test_terms_dialog.py b/python/test/test_terms_dialog.py new file mode 100644 index 00000000..0d8e0f02 --- /dev/null +++ b/python/test/test_terms_dialog.py @@ -0,0 +1,481 @@ +""" +Apple's terms of service, in the window. + +**Two halves, tested apart.** `_accept_pending_terms` is the flow - what gets fetched, what gets +sent, and what happens when the answer is no; it runs with the dialog stubbed, so it needs no +display. `_show_terms` is the window, and is driven for real, because the failure that matters +there is one nothing throws on: a dialog that shows the wrong document, or that treats a closed +window as agreement. + +The second kind is why this exists at all. Sending acceptance of a contract the user rejected +would be silent, permanent and done in their name. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any +from unittest import mock + +import pytest + +# Before anything that imports tkinter, `exporter.wizard` included - see test_save_logs_button. +tk = pytest.importorskip("tkinter", reason="needs a Python built with Tk") + +from tkinter import ttk # noqa: E402 - same reason + +from exporter import wizard # noqa: E402 - has to follow the importorskip above +from exporter.icloud import ExportSourceError # noqa: E402 +from findmy import LoginState, MobileMeDelegateError, TermsError # noqa: E402 + +TERMS_HTML = """ + + +
+

iCloud Terms and Conditions

+

Welcome to iCloud. By using iCloud you agree to these terms.

+

1. Your Account

+

You are responsible for maintaining the confidentiality of your account.

+
    +
  • You must be over the age of majority.
  • +
+ +
+ +""" + +MEDIA_HTML = "

Media Services

A second document.

" + +DELEGATE_ERROR = MobileMeDelegateError(localized_error="Terms have not been accepted") + + +@dataclass(frozen=True) +class FakeTerms: + page_id: str + agree_url: str + html: str + + +ICLOUD_TERMS = FakeTerms("iCloud", "https://example.invalid/agree", TERMS_HTML) +MEDIA_TERMS = FakeTerms("Media", "https://example.invalid/agree-media", MEDIA_HTML) + + +class FakeAccount: + """ + An account with terms pending, recording only what actually reached Apple. + + `accepted` is the assertion that matters throughout: it is the list of documents this + program told Apple the user agreed to. + """ + + def __init__(self, documents=(ICLOUD_TERMS,), *, final_state=LoginState.LOGGED_IN): + self.documents = list(documents) + self.accepted: list[FakeTerms] = [] + self.completed = 0 + self.final_state = final_state + self.fetch_raises: Exception | None = None + self.accept_raises: Exception | None = None + + async def fetch_terms(self): + if self.fetch_raises: + raise self.fetch_raises + return self.documents + + async def accept_terms(self, document): + if self.accept_raises: + raise self.accept_raises + self.accepted.append(document) + + async def complete_login(self): + self.completed += 1 + return self.final_state + + +class DirectAsker(wizard.Asker): + """ + An `Asker` that answers on the calling thread, since these tests have no worker. + + A real one hands the question to the main thread and blocks until an event loop draws it, + which needs the worker thread that is not here. `ask` is the only method the flow uses, so + nothing the base class sets up is wanted - hence no `super().__init__`. + """ + + def __init__(self): + pass + + def ask(self, dialog): + return dialog() + + +def run_terms(account, answers, monkeypatch, parent: Any = None): + """Drive the flow with canned answers to each document, in order.""" + given = list(answers) + shown: list[FakeTerms] = [] + + def _fake_dialog(_parent, document, _index, _total): + shown.append(document) + return given.pop(0) + + monkeypatch.setattr(wizard, "_show_terms", _fake_dialog) + + asyncio.run(wizard._accept_pending_terms(parent, account, DirectAsker(), DELEGATE_ERROR)) + + return shown + + +class TestAgreeing: + def test_accepting_sends_agreement_for_that_document(self, monkeypatch): + account = FakeAccount() + + run_terms(account, [True], monkeypatch=monkeypatch) + + assert account.accepted == [ICLOUD_TERMS] + + def test_the_sign_in_is_finished_afterwards(self, monkeypatch): + # Without this the account is left at AUTHENTICATED - readable, and unusable for + # everything that comes after. It is the step `login` would have run itself. + account = FakeAccount() + + run_terms(account, [True], monkeypatch=monkeypatch) + + assert account.completed == 1 + + def test_every_document_is_shown_and_accepted(self, monkeypatch): + account = FakeAccount([ICLOUD_TERMS, MEDIA_TERMS]) + + shown = run_terms(account, [True, True], monkeypatch=monkeypatch) + + assert shown == [ICLOUD_TERMS, MEDIA_TERMS] + assert account.accepted == [ICLOUD_TERMS, MEDIA_TERMS] + + def test_a_sign_in_that_does_not_complete_is_reported(self, monkeypatch): + account = FakeAccount(final_state=LoginState.AUTHENTICATED) + + with pytest.raises(ExportSourceError, match="ended at"): + run_terms(account, [True], monkeypatch=monkeypatch) + + +class TestRefusing: + def test_rejecting_sends_nothing(self, monkeypatch): + # The whole point. Acceptance is permanent and made in the user's name. + account = FakeAccount() + + with pytest.raises(wizard.TermsDeclined): + run_terms(account, [False], monkeypatch=monkeypatch) + + assert account.accepted == [] + + def test_rejecting_does_not_finish_the_sign_in(self, monkeypatch): + account = FakeAccount() + + with pytest.raises(wizard.TermsDeclined): + run_terms(account, [False], monkeypatch=monkeypatch) + + assert account.completed == 0 + + def test_rejecting_the_first_document_does_not_show_the_second(self, monkeypatch): + account = FakeAccount([ICLOUD_TERMS, MEDIA_TERMS]) + + with pytest.raises(wizard.TermsDeclined): + run_terms(account, [False, True], monkeypatch=monkeypatch) + + assert account.accepted == [] + + def test_accepting_the_first_and_refusing_the_second_leaves_the_first_accepted(self, monkeypatch): + # Honest about what happened rather than tidy: the first agreement was sent the moment it + # was given, and cannot be taken back by refusing a later one. + account = FakeAccount([ICLOUD_TERMS, MEDIA_TERMS]) + + with pytest.raises(wizard.TermsDeclined): + run_terms(account, [True, False], monkeypatch=monkeypatch) + + assert account.accepted == [ICLOUD_TERMS] + assert account.completed == 0 + + def test_it_names_the_document_that_was_refused(self, monkeypatch): + account = FakeAccount([ICLOUD_TERMS]) + + with pytest.raises(wizard.TermsDeclined, match="iCloud"): + run_terms(account, [False], monkeypatch=monkeypatch) + + +class TestWhenItIsNotTermsAtAll: + """ + Which error means "terms pending" is not established, so the flow looks rather than assumes. + + A delegate failure with nothing to accept has to report what Apple said. Reporting "no terms + found" would describe the check rather than the problem. + """ + + def test_no_pending_terms_reports_what_apple_said(self, monkeypatch): + account = FakeAccount([]) + + with pytest.raises(ExportSourceError, match="Terms have not been accepted"): + run_terms(account, [], monkeypatch=monkeypatch) + + def test_no_pending_terms_is_not_a_declined_terms_close(self, monkeypatch): + # It must not close the window: nothing was refused, and trying again may well work. + account = FakeAccount([]) + + with pytest.raises(ExportSourceError): + run_terms(account, [], monkeypatch=monkeypatch) + + def test_a_failed_fetch_carries_both_messages(self, monkeypatch): + account = FakeAccount() + account.fetch_raises = TermsError("no terms in the response") + + with pytest.raises(ExportSourceError) as raised: + run_terms(account, [], monkeypatch=monkeypatch) + + assert "Terms have not been accepted" in str(raised.value) + assert "no terms in the response" in str(raised.value) + + def test_a_rejected_acceptance_is_reported_rather_than_ignored(self, monkeypatch): + # Apple refusing to record the agreement is not the same as it being recorded, and the + # sign-in must not carry on as though it were. + account = FakeAccount() + account.accept_raises = TermsError("status 500") + + with pytest.raises(ExportSourceError, match="did not record agreement"): + run_terms(account, [True], monkeypatch=monkeypatch) + + assert account.completed == 0 + + +# ------------------------------------------------------------------------------------------------ +# The window itself +# ------------------------------------------------------------------------------------------------ + + +@pytest.fixture(scope="module") +def root(): + """ + One root for the whole module - see the same fixture in test_save_logs_button for why. + + A bare `Tk` rather than a `WizardApp`: the dialog only uses its parent to own the window, so + building the whole wizard around it would be testing the wizard. + """ + try: + app = tk.Tk() + except tk.TclError as e: # pragma: no cover - depends on the machine, not on the code + pytest.skip(f"needs a display to build a window: {e}") + + app.withdraw() + try: + yield app + finally: + app.destroy() + + +@pytest.fixture +def dialog(root): + """The real terms window, built but not yet modal, with the answer it writes into.""" + window, accepted = wizard._build_terms_window(root, ICLOUD_TERMS, 1, 1) + try: + yield window, accepted + finally: + if window.winfo_exists(): + window.destroy() + + +def descendants(widget): + yield widget + for child in widget.winfo_children(): + yield from descendants(child) + + +def terms_box(window): + for widget in descendants(window): + if isinstance(widget, tk.Text): + return widget + raise AssertionError("the dialog has nowhere to show the terms") + + +def shown_text(window) -> str: + return terms_box(window).get("1.0", "end") + + +def blurb(window) -> str: + return " ".join( + str(widget.cget("text")) + for widget in descendants(window) + if isinstance(widget, ttk.Label) + ) + + +def still_open(app) -> bool: + """ + Whether the application is still there. + + Destroying a *root* tears down its Tcl interpreter, so `winfo_exists` does not come back + False - it raises, because there is no longer an interpreter to ask. Anything checking that + the exporter closed has to treat that as the answer rather than as an error. + """ + try: + return bool(app.winfo_exists()) + except tk.TclError: + return False + + +def press(window, label) -> None: + for widget in descendants(window): + if isinstance(widget, ttk.Button) and str(widget.cget("text")) == label: + widget.invoke() + return + + raise AssertionError(f"the dialog has no {label!r} button") + + +class TestTheDialogsAnswer: + def test_accept_agrees(self, dialog): + window, accepted = dialog + + press(window, "Accept") + + assert accepted["value"] is True + + def test_reject_does_not(self, dialog): + window, accepted = dialog + + press(window, "Reject") + + assert accepted["value"] is False + + def test_both_buttons_close_the_window(self, dialog): + # A dialog still on screen after its answer was given reads as the click not registering, + # and the next click lands on whatever replaced it. + window, _ = dialog + + press(window, "Reject") + + assert not window.winfo_exists() + + def test_an_unanswered_dialog_has_not_agreed(self, dialog): + # What the window's own close button leaves behind. `_show_terms` returns this value, so + # closing a contract unanswered has to read as no. + _, accepted = dialog + + assert accepted["value"] is False + + def test_escape_can_dismiss_it(self, dialog): + window, _ = dialog + + assert window.bind(""), "no way out of this dialog from the keyboard" + + def test_nothing_is_bound_to_return(self, dialog): + """ + Every other dialog here submits on Return. This one must not: its answer is agreement to a + contract, and a stray keypress is not agreement. + + **Asserted on the binding rather than by pressing the key**, which is not a shortcut. A + Toplevel whose root is withdrawn is not viewable and so holds focus from nobody, and + `event_generate` on an unfocused window is dropped without complaint - so a test that + presses Return and checks nothing happened passes just as well when the key was never + delivered. It did, here, until the same call started failing a run later. + """ + window, _ = dialog + + assert not window.bind("") + assert not window.bind("") + + +class TestWhatIsOnScreen: + def test_the_document_is_shown(self, dialog): + window, _ = dialog + + assert "By using iCloud you agree to these terms." in shown_text(window) + assert "You are responsible for maintaining the confidentiality" in shown_text(window) + + def test_list_items_survive(self, dialog): + window, _ = dialog + + assert "You must be over the age of majority." in shown_text(window) + + def test_no_markup_reaches_the_reader(self, dialog): + window, _ = dialog + text = shown_text(window) + + assert "

" not in text + assert "" not in text + # A contract with someone's analytics in the middle of it reads as a forgery. + assert "track();" not in text + assert "color: red" not in text + + def test_the_terms_cannot_be_edited(self, dialog): + # A Text is editable by default, and a contract you can type into is not the document + # that was fetched. + window, _ = dialog + + assert str(terms_box(window).cget("state")) == "disabled" + + def test_it_says_apple_is_waiting_on_this(self, dialog): + window, _ = dialog + + assert "will not finish signing you in" in blurb(window) + assert "Nothing has been sent yet" in blurb(window) + + def test_one_document_is_not_counted(self, dialog): + window, _ = dialog + + assert "(1 of 1)" not in blurb(window) + + def test_several_documents_are_counted(self, root): + # Otherwise accepting the first looks like the whole thing, and a second contract appears + # from nowhere. + window, _ = wizard._build_terms_window(root, MEDIA_TERMS, 2, 2) + + try: + assert "(2 of 2)" in blurb(window) + assert "A second document." in shown_text(window) + assert "By using iCloud" not in shown_text(window) + finally: + window.destroy() + + def test_the_title_names_the_document(self, root): + window, _ = wizard._build_terms_window(root, MEDIA_TERMS, 2, 2) + + try: + assert "Media" in window.title() + finally: + window.destroy() + + +class TestRejectingClosesTheExporter: + """ + The one failure this window does not offer to retry. + + Everything else that goes wrong during a sign-in leaves the button there, because trying + again might work. Refusing a contract is not like that: the same document is waiting on the + next attempt, so offering a retry would be pretending the answer might change by itself. + """ + + def test_it_says_nothing_was_sent_and_then_closes(self, monkeypatch): + app = wizard.WizardApp() + app.withdraw() + + def _declined(*_args, **_kwargs): + raise wizard.TermsDeclined("iCloud") + + monkeypatch.setattr(wizard, "run_with_progress", _declined) + + try: + with mock.patch.object(wizard.messagebox, "showinfo") as info, \ + mock.patch.object(wizard.messagebox, "showerror") as error: + app._load() + + # Not the generic handler, which asks for a bug report with a log attached. Somebody + # who read a contract and declined it has not hit a bug. + assert not error.called, "declining terms is not something to report" + + body = info.call_args[0][1] + assert "Nothing was sent" in body + assert "unchanged" in body + assert "iCloud" in body, "say which document, since there may have been several" + # Where they can accept, for somebody who declined by accident or changed their mind. + assert "icloud.com" in body + + assert not still_open(app), "Reject has to close the exporter" + finally: + if still_open(app): + app.destroy()