[OCaml] High Intensity Training Online
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.
lib/web/handlers.ml
@@ -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
@@ -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
@@ -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
@@ -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