fix make workout cancellation return Home

Submit the confirmed cancellation as a native POST so the browser follows the server redirect instead of retaining a stale SPA workout shell. Keep CSRF protection, discard the active workout, and cover valid and invalid-token paths with web-flow tests.

Commit
7d9ee9e72af5c5c8bd96741d03a390e329be090f
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/web/handlers.ml
index 89004d45..90c37629 100644..100644
@@ -380,19 +380,15 @@
380 380 | Some _ -> Dream.redirect request "/logbook?prompt=feedback"
381 381 | None -> not_found Present.no_workout)
382 382
383 Removed: (* Leave the current workout unconditionally. Cancelling never shows an
384 Removed: invalid-form response: the workout in progress is discarded — an
385 Removed: abandoned session is not evidence, so it leaves no record — and the user
386 Removed: returns to the Routine tab, where the middle navigation action reverts
387 Removed: from "Current workout" to "Routine". A forged or missing CSRF token skips
388 Removed: the discard but still navigates away, so the view is always left
389 Removed: cleanly. *)
383 Added: (* Cancelling discards the current workout and returns Home. An abandoned
384 Added: session is not evidence, so it leaves no record. A forged or missing
385 Added: CSRF token skips the discard but still leaves the workout view. *)
390 386 let cancel t trainee request =
391 387 guard_csrf request >>= function
392 Removed: | Error _ -> redirect_to request Routes.routine
388 Added: | Error _ -> redirect_to request Routes.home
393 389 | Ok () ->
394 390 Service.cancel t.service trainee.Trainee.id >>= fun _ ->
395 Removed: redirect_to request Routes.routine
391 Added: redirect_to request Routes.home
396 392
397 393 let routes t =
398 394 [
lib/web/pages.ml
index 1fd1700d..85ff4d81 100644..100644
@@ -874,16 +874,16 @@
874 874 Dream_html.csrf_tag request;
875 875 void "input" [ type_ "submit"; value "Finish workout" ];
876 876 ];
877 Removed: (* Cancel is a real POST form. Without script, its submit cancels
878 Removed: immediately. The client enhances it: it intercepts the submit,
879 Removed: opens the confirm dialog, and only submits the form when the
880 Removed: user confirms. *)
877 Added: (* Cancel is a native POST form. Without script, it submits
878 Added: immediately. The client only opens the confirm dialog; its
879 Added: acceptance submits this form as a full document navigation so
880 Added: the cancelled workout cannot leave a stale app shell behind. *)
881 881 tag "form"
882 882 [
883 883 action Routes.cancel_workout;
884 884 post_form;
885 Removed: Dream_html.attr "data-hito-app-form";
886 885 Dream_html.attr "data-hito-confirm-form";
886 Added: Dream_html.attr "data-hito-cancel-form";
887 887 ]
888 888 [
889 889 Dream_html.csrf_tag request;
@@ -908,8 +908,8 @@
908 908 tag "p" []
909 909 [
910 910 txt
911 Removed: "The workout is saved as a partial logbook entry you can \
912 Removed: edit later, then you return to the routine.";
911 Added: "The workout is discarded and leaves no record, then you \
912 Added: return Home.";
913 913 ];
914 914 tag "div"
915 915 [ class_ "button-group" ]
lib/web/workout_client.ml
index df313f21..d219f22e 100644..100644
@@ -230,8 +230,8 @@
230 230 Js._true
231 231
232 232 (* The cancel confirmation modal. The cancel form submits directly without
233 Removed: script; with script, its submit is intercepted to open a native dialog.
234 Removed: "Cancel workout" submits the form through the normal app-form path; "Keep
233 Added: script; with script, its submit is intercepted only to open a native dialog.
234 Added: "Cancel workout" submits the native form as a full document POST; "Keep
235 235 logging" and Escape close the dialog. The dialog and the pending form are
236 236 found fresh on each click, so a content swap never leaves a stale
237 237 reference. *)
test/test_web.ml
index 59307531..9effb8f8 100644..100644
@@ -793,7 +793,19 @@
793 793 (contains ~substring:"data-hito-confirm-modal" page);
794 794 Alcotest.(check bool)
795 795 "marks the cancel form for confirmation" true
796 Removed: (contains ~substring:"data-hito-confirm-form" page) );
796 Added: (contains ~substring:"data-hito-confirm-form" page);
797 Added: Alcotest.(check bool)
798 Added: "uses native navigation after confirmation" true
799 Added: (contains ~substring:"data-hito-cancel-form" page);
800 Added: (* The modal copy must match the discard semantics: cancelling
801 Added: leaves no record. It must not promise a saved, editable entry,
802 Added: which would tell the user the workout was not really cancelled. *)
803 Added: Alcotest.(check bool)
804 Added: "warns that the workout is discarded" true
805 Added: (contains ~substring:"discarded and leaves no record" page);
806 Added: Alcotest.(check bool)
807 Added: "does not promise a saved editable entry" false
808 Added: (contains ~substring:"saved as a partial logbook entry" page) );
797 809 ( "the current workout shows a sticky timer bar",
798 810 `Quick,
799 811 fun () ->
@@ -824,14 +836,14 @@
824 836 post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
825 837 in
826 838 let response = post c "/workout/cancel" [ (* no dream.csrf *) ] in
827 Removed: (* No invalid-form response: cancelling always navigates away to the
828 Removed: routine. A forged or missing token skips the save, so the workout
829 Removed: is left untouched and still in progress. *)
839 Added: (* No invalid-form response: cancelling always navigates away to
840 Added: Home. A forged or missing token skips the discard, so the
841 Added: workout remains in progress. *)
830 842 Alcotest.(check int)
831 843 "navigates away without an error page" 303 (status response);
832 844 Alcotest.(check bool)
833 Removed: "returns to the routine tab" true
834 Removed: (List.mem "/routine" (Dream.headers response "Location"));
845 Added: "returns Home" true
846 Added: (List.mem "/" (Dream.headers response "Location"));
835 847 Alcotest.(check int)
836 848 "workout still in progress" 200
837 849 (status (get c "/workout")) );
@@ -861,16 +873,15 @@
861 873 in
862 874 Alcotest.(check int) "recorded a set" 303 (status recorded);
863 875 (* Cancel with a valid token. It discards the in-progress workout
864 Removed: and returns to the routine tab. *)
876 Added: and returns Home. *)
865 877 let token = Option.get (csrf_token (body (get c "/workout"))) in
866 878 let cancelled =
867 879 post c "/workout/cancel" [ ("dream.csrf", token) ]
868 880 in
869 Removed: Alcotest.(check int)
870 Removed: "cancel returns to the routine" 303 (status cancelled);
881 Added: Alcotest.(check int) "cancel returns Home" 303 (status cancelled);
871 882 Alcotest.(check bool)
872 Removed: "returns to the routine tab, not the logbook" true
873 Removed: (List.mem "/routine" (Dream.headers cancelled "Location"));
883 Added: "returns Home, not the logbook" true
884 Added: (List.mem "/" (Dream.headers cancelled "Location"));
874 885 (* No current workout: the workout view redirects away. *)
875 886 Alcotest.(check int)
876 887 "no workout in progress" 303
@@ -884,7 +895,7 @@
884 895 Alcotest.(check int)
885 896 "the discarded workout has no record" 404
886 897 (status (get c "/logbook/w1")) );
887 Removed: ( "the middle navigation tab returns to Routine after cancellation",
898 Added: ( "cancellation returns Home after discarding the workout",
888 899 `Quick,
889 900 fun () ->
890 901 let c = client () in