1414//! before is refused now and every payload lands on the same leaf either way.
1515
1616use bencher_api_tests:: { TestServer , TestUser } ;
17+ use bencher_json:: { BmfVersion , ProjectSlug } ;
1718use http:: StatusCode ;
1819
1920/// A BMF v0 payload: a benchmark maps straight to its measures.
@@ -42,8 +43,11 @@ fn v1_results() -> String {
4243}
4344
4445/// A signed up user with an organization and a project to report into.
46+ ///
47+ /// The user is the server's first signup, which makes them its admin, so this
48+ /// fixture is also what moves the project's BMF version gate.
4549struct Fixture {
46- project_slug : String ,
50+ project_slug : ProjectSlug ,
4751 user : TestUser ,
4852}
4953
@@ -56,11 +60,22 @@ async fn fixture(server: &TestServer, label: &str) -> Fixture {
5660 . create_project ( & user, & org, & format ! ( "Bmf Project {label}" ) )
5761 . await ;
5862 Fixture {
59- project_slug : project. slug . to_string ( ) ,
63+ project_slug : project. slug ,
6064 user,
6165 }
6266}
6367
68+ /// The same fixture with the project's gate raised to BMF version 1.
69+ ///
70+ /// Every payload that states version 1, by declaring the key or by parsing as v1,
71+ /// needs this: a project accepts version 0 and nothing above it until an admin
72+ /// says otherwise.
73+ async fn gated_fixture ( server : & TestServer , label : & str ) -> Fixture {
74+ let fixture = fixture ( server, label) . await ;
75+ server. set_bmf_version ( & fixture. project_slug , BmfVersion :: V1 ) ;
76+ fixture
77+ }
78+
6479/// Post one report and return its status and body, whatever they are.
6580///
6681/// Every report a comparison uses carries the same `hash`, so they all resolve to
@@ -196,7 +211,7 @@ async fn report_absent_bmf_version_is_version_0() {
196211#[ tokio:: test]
197212async fn report_bmf_version_1_ingests_a_v1_payload_identically_to_version_0 ( ) {
198213 let server = TestServer :: new ( ) . await ;
199- let fixture = fixture ( & server, "v1" ) . await ;
214+ let fixture = gated_fixture ( & server, "v1" ) . await ;
200215
201216 let one = report (
202217 & server,
@@ -236,7 +251,7 @@ async fn report_bmf_version_1_ingests_a_v1_payload_identically_to_version_0() {
236251#[ tokio:: test]
237252async fn report_bmf_version_1_still_ingests_a_v0_payload ( ) {
238253 let server = TestServer :: new ( ) . await ;
239- let fixture = fixture ( & server, "fallback" ) . await ;
254+ let fixture = gated_fixture ( & server, "fallback" ) . await ;
240255
241256 let one = report (
242257 & server,
@@ -305,7 +320,7 @@ async fn report_unknown_bmf_version_is_rejected() {
305320#[ tokio:: test]
306321async fn report_explicit_leaf_wins_over_bmf_version ( ) {
307322 let server = TestServer :: new ( ) . await ;
308- let fixture = fixture ( & server, "leaf" ) . await ;
323+ let fixture = gated_fixture ( & server, "leaf" ) . await ;
309324
310325 let ( status, body) = try_report (
311326 & server,
@@ -341,3 +356,277 @@ async fn report_explicit_leaf_wins_over_bmf_version() {
341356
342357 server. close ( ) . await ;
343358}
359+
360+ // --- The project gate ---
361+ //
362+ // A project declares the highest BMF payload version it accepts. It defaults to 0
363+ // everywhere, only a server admin can raise it, and it is visible to everyone.
364+
365+ /// GET one project as `user`.
366+ async fn get_project (
367+ server : & TestServer ,
368+ user : & TestUser ,
369+ project : & ProjectSlug ,
370+ ) -> serde_json:: Value {
371+ let resp = server
372+ . client
373+ . get ( server. api_url ( & format ! ( "/v0/projects/{project}" ) ) )
374+ . header (
375+ bencher_json:: AUTHORIZATION ,
376+ bencher_json:: bearer_header ( & user. token ) ,
377+ )
378+ . send ( )
379+ . await
380+ . expect ( "Request failed" ) ;
381+ let status = resp. status ( ) ;
382+ assert_eq ! ( status, StatusCode :: OK , "GET project: {status}" ) ;
383+ resp. json ( ) . await . expect ( "Failed to parse the project" )
384+ }
385+
386+ /// PATCH one project with whatever body, and return its status and body.
387+ async fn try_patch (
388+ server : & TestServer ,
389+ user : & TestUser ,
390+ project : & ProjectSlug ,
391+ body : & serde_json:: Value ,
392+ ) -> ( StatusCode , String ) {
393+ let resp = server
394+ . client
395+ . patch ( server. api_url ( & format ! ( "/v0/projects/{project}" ) ) )
396+ . header (
397+ bencher_json:: AUTHORIZATION ,
398+ bencher_json:: bearer_header ( & user. token ) ,
399+ )
400+ . json ( body)
401+ . send ( )
402+ . await
403+ . expect ( "Request failed" ) ;
404+ let status = resp. status ( ) ;
405+ let body = resp. text ( ) . await . expect ( "Failed to read the response" ) ;
406+ ( status, body)
407+ }
408+
409+ /// What an error response said, without the request id the server mints for it.
410+ fn message ( body : & str ) -> String {
411+ let error: serde_json:: Value = serde_json:: from_str ( body) . expect ( "Failed to parse the error" ) ;
412+ error[ "message" ]
413+ . as_str ( )
414+ . expect ( "the error carries a message" )
415+ . to_owned ( )
416+ }
417+
418+ /// The refusal has to name the payload's version and the project's, so the message
419+ /// says both what was sent and what the project would take.
420+ fn assert_gate_refusal ( status : StatusCode , body : & str , payload : u8 , project : u8 ) {
421+ assert_eq ! ( status, StatusCode :: LOCKED , "{body}" ) ;
422+ assert ! (
423+ body. contains( & payload. to_string( ) ) && body. contains( & project. to_string( ) ) ,
424+ "expected the refusal to name BMF version {payload} and {project}: {body}"
425+ ) ;
426+ }
427+
428+ /// A new project accepts BMF version 0 and says so.
429+ #[ tokio:: test]
430+ async fn project_bmf_version_defaults_to_0 ( ) {
431+ let server = TestServer :: new ( ) . await ;
432+ let fixture = fixture ( & server, "default" ) . await ;
433+
434+ let project = get_project ( & server, & fixture. user , & fixture. project_slug ) . await ;
435+ assert_eq ! ( project[ "bmf_version" ] , serde_json:: json!( 0 ) ) ;
436+
437+ server. close ( ) . await ;
438+ }
439+
440+ /// A payload that declares a version above the gate is refused, and the same
441+ /// project still takes a version 0 payload.
442+ #[ tokio:: test]
443+ async fn report_declared_version_above_the_gate_is_refused ( ) {
444+ let server = TestServer :: new ( ) . await ;
445+ let fixture = fixture ( & server, "declared" ) . await ;
446+
447+ let ( status, body) = try_report (
448+ & server,
449+ & fixture,
450+ & v0_results ( ) ,
451+ Some ( serde_json:: json!( 1 ) ) ,
452+ None ,
453+ )
454+ . await ;
455+ assert_gate_refusal ( status, & body, 1 , 0 ) ;
456+
457+ // The gate is about the version, not about the project: version 0 still ingests.
458+ report (
459+ & server,
460+ & fixture,
461+ & v0_results ( ) ,
462+ Some ( serde_json:: json!( 0 ) ) ,
463+ None ,
464+ )
465+ . await ;
466+
467+ server. close ( ) . await ;
468+ }
469+
470+ /// Results that parse as v1 are refused even when no version was declared.
471+ ///
472+ /// These are the two side doors the declared key does not cover: the `json_v1`
473+ /// leaf named outright, and the default `magic` adapter falling back to that leaf.
474+ /// Both are refused, and refused the same way, because both are the same statement
475+ /// about the payload.
476+ #[ tokio:: test]
477+ async fn report_parsed_v1_above_the_gate_is_refused ( ) {
478+ let server = TestServer :: new ( ) . await ;
479+ let fixture = fixture ( & server, "parsed" ) . await ;
480+
481+ let ( magic_status, magic_body) = try_report ( & server, & fixture, & v1_results ( ) , None , None ) . await ;
482+ assert_gate_refusal ( magic_status, & magic_body, 1 , 0 ) ;
483+
484+ let ( leaf_status, leaf_body) =
485+ try_report ( & server, & fixture, & v1_results ( ) , None , Some ( "json_v1" ) ) . await ;
486+ assert_gate_refusal ( leaf_status, & leaf_body, 1 , 0 ) ;
487+
488+ // The same refusal, not just the same class. Only the request id differs,
489+ // since the server mints a fresh one per request.
490+ assert_eq ! ( message( & magic_body) , message( & leaf_body) ) ;
491+
492+ server. close ( ) . await ;
493+ }
494+
495+ /// An admin raises the gate, and every payload the gate refused ingests.
496+ #[ tokio:: test]
497+ async fn admin_raising_the_gate_admits_the_refused_payloads ( ) {
498+ let server = TestServer :: new ( ) . await ;
499+ let fixture = fixture ( & server, "raise" ) . await ;
500+
501+ for ( results, version, adapter) in [
502+ ( v0_results ( ) , Some ( serde_json:: json!( 1 ) ) , None ) ,
503+ ( v1_results ( ) , None , None ) ,
504+ ( v1_results ( ) , None , Some ( "json_v1" ) ) ,
505+ ] {
506+ let ( status, body) =
507+ try_report ( & server, & fixture, & results, version. clone ( ) , adapter) . await ;
508+ assert_gate_refusal ( status, & body, 1 , 0 ) ;
509+ }
510+
511+ let ( status, body) = try_patch (
512+ & server,
513+ & fixture. user ,
514+ & fixture. project_slug ,
515+ & serde_json:: json!( { "bmf_version" : 1 } ) ,
516+ )
517+ . await ;
518+ assert_eq ! ( status, StatusCode :: OK , "{body}" ) ;
519+ let project = get_project ( & server, & fixture. user , & fixture. project_slug ) . await ;
520+ assert_eq ! ( project[ "bmf_version" ] , serde_json:: json!( 1 ) ) ;
521+
522+ for ( results, version, adapter) in [
523+ ( v0_results ( ) , Some ( serde_json:: json!( 1 ) ) , None ) ,
524+ ( v1_results ( ) , None , None ) ,
525+ ( v1_results ( ) , None , Some ( "json_v1" ) ) ,
526+ ] {
527+ report ( & server, & fixture, & results, version. clone ( ) , adapter) . await ;
528+ }
529+
530+ server. close ( ) . await ;
531+ }
532+
533+ /// The gate is a maximum, so a raised project takes every lower version unchanged.
534+ #[ tokio:: test]
535+ async fn a_raised_gate_still_accepts_version_0_payloads ( ) {
536+ let server = TestServer :: new ( ) . await ;
537+ let fixture = gated_fixture ( & server, "maximum" ) . await ;
538+
539+ let absent = report ( & server, & fixture, & v0_results ( ) , None , None ) . await ;
540+ let zero = report (
541+ & server,
542+ & fixture,
543+ & v0_results ( ) ,
544+ Some ( serde_json:: json!( 0 ) ) ,
545+ None ,
546+ )
547+ . await ;
548+ assert_eq ! ( absent, zero) ;
549+
550+ server. close ( ) . await ;
551+ }
552+
553+ /// Nothing ratchets: the gate is a plain setting an admin can put back.
554+ #[ tokio:: test]
555+ async fn admin_can_lower_the_gate ( ) {
556+ let server = TestServer :: new ( ) . await ;
557+ let fixture = gated_fixture ( & server, "lower" ) . await ;
558+
559+ report (
560+ & server,
561+ & fixture,
562+ & v1_results ( ) ,
563+ Some ( serde_json:: json!( 1 ) ) ,
564+ None ,
565+ )
566+ . await ;
567+
568+ let ( status, body) = try_patch (
569+ & server,
570+ & fixture. user ,
571+ & fixture. project_slug ,
572+ & serde_json:: json!( { "bmf_version" : 0 } ) ,
573+ )
574+ . await ;
575+ assert_eq ! ( status, StatusCode :: OK , "{body}" ) ;
576+ let project = get_project ( & server, & fixture. user , & fixture. project_slug ) . await ;
577+ assert_eq ! ( project[ "bmf_version" ] , serde_json:: json!( 0 ) ) ;
578+
579+ let ( status, body) = try_report (
580+ & server,
581+ & fixture,
582+ & v1_results ( ) ,
583+ Some ( serde_json:: json!( 1 ) ) ,
584+ None ,
585+ )
586+ . await ;
587+ assert_gate_refusal ( status, & body, 1 , 0 ) ;
588+
589+ server. close ( ) . await ;
590+ }
591+
592+ /// Only a server admin can move the gate, and the rest of the patch is unaffected.
593+ ///
594+ /// The second user owns their own organization and project, so they are allowed to
595+ /// edit it. The only thing standing between them and the field is the admin check.
596+ #[ tokio:: test]
597+ async fn non_admin_cannot_move_the_gate ( ) {
598+ let server = TestServer :: new ( ) . await ;
599+ // The first signup is the server admin, so the second one is not.
600+ let _admin = fixture ( & server, "owner" ) . await ;
601+ let user = server. signup ( "Other User" , "bmfother@example.com" ) . await ;
602+ let org = server. create_org ( & user, "Bmf Other Org" ) . await ;
603+ let project = server
604+ . create_project ( & user, & org, "Bmf Other Project" )
605+ . await ;
606+
607+ let ( status, body) = try_patch (
608+ & server,
609+ & user,
610+ & project. slug ,
611+ & serde_json:: json!( { "bmf_version" : 1 } ) ,
612+ )
613+ . await ;
614+ assert_eq ! ( status, StatusCode :: FORBIDDEN , "{body}" ) ;
615+ assert ! ( body. contains( "bmf_version" ) , "{body}" ) ;
616+
617+ // The same patch without the field is the patch it has always been.
618+ let ( status, body) = try_patch (
619+ & server,
620+ & user,
621+ & project. slug ,
622+ & serde_json:: json!( { "name" : "Bmf Renamed Project" } ) ,
623+ )
624+ . await ;
625+ assert_eq ! ( status, StatusCode :: OK , "{body}" ) ;
626+
627+ let project = get_project ( & server, & user, & project. slug ) . await ;
628+ assert_eq ! ( project[ "name" ] , serde_json:: json!( "Bmf Renamed Project" ) ) ;
629+ assert_eq ! ( project[ "bmf_version" ] , serde_json:: json!( 0 ) ) ;
630+
631+ server. close ( ) . await ;
632+ }
0 commit comments