@@ -293,6 +293,116 @@ func TestCreateSpritzRejectsMissingRequiredResolvedFieldFromInstanceClass(t *tes
293293 }
294294}
295295
296+ func TestCreateSpritzStoresResolvedRuntimePolicy (t * testing.T ) {
297+ s := newCreateSpritzTestServer (t )
298+ var presetReceived map [string ]any
299+ resolver := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
300+ defer r .Body .Close ()
301+ if err := json .NewDecoder (r .Body ).Decode (& presetReceived ); err != nil {
302+ t .Fatalf ("failed to decode resolver request: %v" , err )
303+ }
304+ w .Header ().Set ("Content-Type" , "application/json" )
305+ _ = json .NewEncoder (w ).Encode (map [string ]any {
306+ "status" : "resolved" ,
307+ "mutations" : map [string ]any {
308+ "spec" : map [string ]any {
309+ "serviceAccountName" : "dev-agent-ag-123" ,
310+ "runtimePolicy" : map [string ]string {
311+ "networkProfile" : "dev-cluster-only" ,
312+ "mountProfile" : "dev-default" ,
313+ "exposureProfile" : "internal-acp" ,
314+ "revision" : "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ,
315+ },
316+ },
317+ },
318+ })
319+ }))
320+ defer resolver .Close ()
321+
322+ s .presets = presetCatalog {
323+ byID : []runtimePreset {{
324+ ID : "devbox" ,
325+ Name : "Devbox" ,
326+ Image : "example.com/devbox:latest" ,
327+ NamePrefix : "devbox" ,
328+ InstanceClass : "dev-runtime" ,
329+ }},
330+ }
331+ s .instanceClasses = instanceClassCatalog {
332+ byID : map [string ]instanceClass {
333+ "dev-runtime" : {
334+ ID : "dev-runtime" ,
335+ Version : "v1" ,
336+ Creation : instanceClassCreationPolicy {
337+ RequireOwner : true ,
338+ RequiredResolvedFields : []string {
339+ requiredResolvedFieldRuntimePolicy ,
340+ requiredResolvedFieldServiceAccountName ,
341+ },
342+ },
343+ },
344+ },
345+ }
346+ s .extensions = extensionRegistry {
347+ resolvers : []configuredResolver {{
348+ id : "runtime-binding" ,
349+ extensionType : extensionTypeResolver ,
350+ operation : extensionOperationPresetCreateResolve ,
351+ match : extensionMatchRule {
352+ presetIDs : map [string ]struct {}{"devbox" : {}},
353+ },
354+ transport : configuredHTTPTransport {
355+ url : resolver .URL ,
356+ timeout : time .Second ,
357+ },
358+ }},
359+ }
360+
361+ e := echo .New ()
362+ secured := e .Group ("" , s .authMiddleware ())
363+ secured .POST ("/api/spritzes" , s .createSpritz )
364+
365+ body := []byte (`{
366+ "name":"devbox-lake",
367+ "presetId":"devbox",
368+ "spec":{}
369+ }` )
370+ req := httptest .NewRequest (http .MethodPost , "/api/spritzes" , bytes .NewReader (body ))
371+ req .Header .Set (echo .HeaderContentType , echo .MIMEApplicationJSON )
372+ req .Header .Set ("X-Spritz-User-Id" , "user-1" )
373+ rec := httptest .NewRecorder ()
374+
375+ e .ServeHTTP (rec , req )
376+
377+ if rec .Code != http .StatusCreated {
378+ t .Fatalf ("expected status 201, got %d: %s" , rec .Code , rec .Body .String ())
379+ }
380+ contextPayload , ok := presetReceived ["context" ].(map [string ]any )
381+ if ! ok {
382+ t .Fatalf ("expected resolver context payload, got %#v" , presetReceived ["context" ])
383+ }
384+ if contextPayload ["instanceClassId" ] != "dev-runtime" {
385+ t .Fatalf ("expected resolver instanceClassId dev-runtime, got %#v" , contextPayload ["instanceClassId" ])
386+ }
387+
388+ stored := & spritzv1.Spritz {}
389+ if err := s .client .Get (context .Background (), client.ObjectKey {Name : "devbox-lake" , Namespace : s .namespace }, stored ); err != nil {
390+ t .Fatalf ("expected created spritz resource: %v" , err )
391+ }
392+ if stored .Spec .ServiceAccountName != "dev-agent-ag-123" {
393+ t .Fatalf ("expected resolved service account name, got %q" , stored .Spec .ServiceAccountName )
394+ }
395+ if stored .Spec .RuntimePolicy == nil {
396+ t .Fatal ("expected resolved runtimePolicy to be stored" )
397+ }
398+ if stored .Spec .RuntimePolicy .NetworkProfile != "dev-cluster-only" {
399+ t .Fatalf ("expected runtime policy networkProfile, got %#v" , stored .Spec .RuntimePolicy )
400+ }
401+ if stored .Spec .RuntimePolicy .Revision != "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
402+ t .Fatalf ("expected runtime policy revision, got %#v" , stored .Spec .RuntimePolicy )
403+ }
404+ }
405+
296406func TestCreateSpritzProvisionerPresetResolverReplaysWithResolvedBinding (t * testing.T ) {
297407 s := newCreateSpritzTestServer (t )
298408 configureProvisionerTestServer (s )
@@ -628,6 +738,159 @@ func TestCreateSpritzProvisionerRejectsManualServiceAccountForResolverRequiredFi
628738 }
629739}
630740
741+ func TestCreateSpritzProvisionerRejectsManualRuntimePolicyForResolverRequiredField (t * testing.T ) {
742+ s := newCreateSpritzTestServer (t )
743+ configureProvisionerTestServer (s )
744+ s .presets = presetCatalog {
745+ byID : []runtimePreset {{
746+ ID : "devbox" ,
747+ Name : "Devbox" ,
748+ Image : "example.com/devbox:latest" ,
749+ NamePrefix : "devbox" ,
750+ InstanceClass : "dev-runtime" ,
751+ }},
752+ }
753+ s .provisioners .allowedPresetIDs = map [string ]struct {}{"devbox" : {}}
754+ s .instanceClasses = instanceClassCatalog {
755+ byID : map [string ]instanceClass {
756+ "dev-runtime" : {
757+ ID : "dev-runtime" ,
758+ Version : "v1" ,
759+ Creation : instanceClassCreationPolicy {
760+ RequireOwner : true ,
761+ RequiredResolvedFields : []string {
762+ requiredResolvedFieldRuntimePolicy ,
763+ },
764+ },
765+ },
766+ },
767+ }
768+
769+ e := echo .New ()
770+ secured := e .Group ("" , s .authMiddleware ())
771+ secured .POST ("/api/spritzes" , s .createSpritz )
772+
773+ body := []byte (`{
774+ "presetId":"devbox",
775+ "ownerId":"user-123",
776+ "idempotencyKey":"manual-runtime-policy",
777+ "spec":{
778+ "runtimePolicy":{
779+ "networkProfile":"dev-cluster-only",
780+ "mountProfile":"dev-default",
781+ "exposureProfile":"internal-acp",
782+ "revision":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
783+ }
784+ }
785+ }` )
786+ req := httptest .NewRequest (http .MethodPost , "/api/spritzes" , bytes .NewReader (body ))
787+ req .Header .Set (echo .HeaderContentType , echo .MIMEApplicationJSON )
788+ req .Header .Set ("X-Spritz-User-Id" , "zenobot" )
789+ req .Header .Set ("X-Spritz-Principal-Type" , "service" )
790+ req .Header .Set ("X-Spritz-Principal-Scopes" , "spritz.instances.create,spritz.instances.assign_owner" )
791+ rec := httptest .NewRecorder ()
792+
793+ e .ServeHTTP (rec , req )
794+
795+ if rec .Code != http .StatusBadRequest {
796+ t .Fatalf ("expected status 400, got %d: %s" , rec .Code , rec .Body .String ())
797+ }
798+ if ! strings .Contains (rec .Body .String (), "resolver-produced field" ) {
799+ t .Fatalf ("expected resolver-produced field error, got %s" , rec .Body .String ())
800+ }
801+ }
802+
803+ func TestCreateSpritzProvisionerRejectsManualRuntimePolicyWhenResolverOnlySetsServiceAccount (t * testing.T ) {
804+ s := newCreateSpritzTestServer (t )
805+ configureProvisionerTestServer (s )
806+ resolver := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
807+ defer r .Body .Close ()
808+ w .Header ().Set ("Content-Type" , "application/json" )
809+ _ = json .NewEncoder (w ).Encode (map [string ]any {
810+ "status" : "resolved" ,
811+ "mutations" : map [string ]any {
812+ "spec" : map [string ]string {
813+ "serviceAccountName" : "dev-agent-ag-123" ,
814+ },
815+ },
816+ })
817+ }))
818+ defer resolver .Close ()
819+
820+ s .presets = presetCatalog {
821+ byID : []runtimePreset {{
822+ ID : "devbox" ,
823+ Name : "Devbox" ,
824+ Image : "example.com/devbox:latest" ,
825+ NamePrefix : "devbox" ,
826+ InstanceClass : "dev-runtime" ,
827+ }},
828+ }
829+ s .provisioners .allowedPresetIDs = map [string ]struct {}{"devbox" : {}}
830+ s .instanceClasses = instanceClassCatalog {
831+ byID : map [string ]instanceClass {
832+ "dev-runtime" : {
833+ ID : "dev-runtime" ,
834+ Version : "v1" ,
835+ Creation : instanceClassCreationPolicy {
836+ RequireOwner : true ,
837+ RequiredResolvedFields : []string {
838+ requiredResolvedFieldRuntimePolicy ,
839+ requiredResolvedFieldServiceAccountName ,
840+ },
841+ },
842+ },
843+ },
844+ }
845+ s .extensions = extensionRegistry {
846+ resolvers : []configuredResolver {{
847+ id : "runtime-binding" ,
848+ extensionType : extensionTypeResolver ,
849+ operation : extensionOperationPresetCreateResolve ,
850+ match : extensionMatchRule {
851+ presetIDs : map [string ]struct {}{"devbox" : {}},
852+ },
853+ transport : configuredHTTPTransport {
854+ url : resolver .URL ,
855+ timeout : time .Second ,
856+ },
857+ }},
858+ }
859+
860+ e := echo .New ()
861+ secured := e .Group ("" , s .authMiddleware ())
862+ secured .POST ("/api/spritzes" , s .createSpritz )
863+
864+ body := []byte (`{
865+ "presetId":"devbox",
866+ "ownerId":"user-123",
867+ "idempotencyKey":"manual-runtime-policy-with-resolver",
868+ "spec":{
869+ "runtimePolicy":{
870+ "networkProfile":"dev-cluster-only",
871+ "mountProfile":"dev-default",
872+ "exposureProfile":"internal-acp",
873+ "revision":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
874+ }
875+ }
876+ }` )
877+ req := httptest .NewRequest (http .MethodPost , "/api/spritzes" , bytes .NewReader (body ))
878+ req .Header .Set (echo .HeaderContentType , echo .MIMEApplicationJSON )
879+ req .Header .Set ("X-Spritz-User-Id" , "zenobot" )
880+ req .Header .Set ("X-Spritz-Principal-Type" , "service" )
881+ req .Header .Set ("X-Spritz-Principal-Scopes" , "spritz.instances.create,spritz.instances.assign_owner" )
882+ rec := httptest .NewRecorder ()
883+
884+ e .ServeHTTP (rec , req )
885+
886+ if rec .Code != http .StatusBadRequest {
887+ t .Fatalf ("expected status 400, got %d: %s" , rec .Code , rec .Body .String ())
888+ }
889+ if ! strings .Contains (rec .Body .String (), requiredResolvedFieldRuntimePolicy ) {
890+ t .Fatalf ("expected runtimePolicy resolver-produced field error, got %s" , rec .Body .String ())
891+ }
892+ }
893+
631894func TestProvisionerRestoreStoredPayloadRestoresResolverMetadata (t * testing.T ) {
632895 tx := & provisionerCreateTransaction {body : & createRequest {}}
633896 raw , err := createResolvedProvisionerPayload (createRequest {
0 commit comments