@@ -53,15 +53,19 @@ func newCreateSpritzAPI(t *testing.T, s *server) *echo.Echo {
5353}
5454
5555func newServiceCreateRequest (body []byte ) (* http.Request , * httptest.ResponseRecorder ) {
56+ return newServiceCreateRequestWithScopes (body ,
57+ scopeInstancesCreate ,
58+ scopeInstancesAssignOwner ,
59+ scopeExternalResolveViaCreate ,
60+ )
61+ }
62+
63+ func newServiceCreateRequestWithScopes (body []byte , scopes ... string ) (* http.Request , * httptest.ResponseRecorder ) {
5664 req := httptest .NewRequest (http .MethodPost , "/api/spritzes" , bytes .NewReader (body ))
5765 req .Header .Set (echo .HeaderContentType , echo .MIMEApplicationJSON )
5866 req .Header .Set ("X-Spritz-User-Id" , "zenobot" )
5967 req .Header .Set ("X-Spritz-Principal-Type" , "service" )
60- req .Header .Set ("X-Spritz-Principal-Scopes" , strings .Join ([]string {
61- scopeInstancesCreate ,
62- scopeInstancesAssignOwner ,
63- scopeExternalResolveViaCreate ,
64- }, "," ))
68+ req .Header .Set ("X-Spritz-Principal-Scopes" , strings .Join (scopes , "," ))
6569 return req , httptest .NewRecorder ()
6670}
6771
@@ -229,6 +233,130 @@ func TestCreateSpritzReplaysExternalOwnerProvisioningAfterResolverMappingChanges
229233 }
230234}
231235
236+ func TestCreateSpritzReplaysExternalOwnerProvisioningWhenResolverBecomesUnavailable (t * testing.T ) {
237+ s := newCreateSpritzTestServer (t )
238+ configureProvisionerTestServer (s )
239+ resolverCalls := 0
240+ configureExternalOwnerTestServer (s , fakeExternalOwnerResolver {
241+ resolve : func (_ context.Context , _ externalOwnerPolicy , _ principal , _ ownerRef , _ string ) (externalOwnerResolution , error ) {
242+ resolverCalls ++
243+ if resolverCalls == 1 {
244+ return externalOwnerResolution {
245+ Status : externalOwnerResolved ,
246+ OwnerID : "user-123" ,
247+ }, nil
248+ }
249+ return externalOwnerResolution {}, context .DeadlineExceeded
250+ },
251+ })
252+ e := newCreateSpritzAPI (t , s )
253+
254+ body := []byte (`{"presetId":"openclaw","ownerRef":{"type":"external","provider":"msteams","tenant":"72f988bf-86f1-41af-91ab-2d7cd011db47","subject":"6f0f9d4f-9b0e-4d52-8c3a-ef0fd64b9b9f"},"idempotencyKey":"teams-replay-unavailable"}` )
255+
256+ req1 , rec1 := newServiceCreateRequest (body )
257+ e .ServeHTTP (rec1 , req1 )
258+ if rec1 .Code != http .StatusCreated {
259+ t .Fatalf ("expected first create status 201, got %d: %s" , rec1 .Code , rec1 .Body .String ())
260+ }
261+
262+ req2 , rec2 := newServiceCreateRequest (body )
263+ e .ServeHTTP (rec2 , req2 )
264+ if rec2 .Code != http .StatusOK {
265+ t .Fatalf ("expected replay status 200, got %d: %s" , rec2 .Code , rec2 .Body .String ())
266+ }
267+ if resolverCalls != 1 {
268+ t .Fatalf ("expected replay to avoid a second resolver call, got %d calls" , resolverCalls )
269+ }
270+ }
271+
272+ func TestCreateSpritzRejectsExternalOwnerResolutionWithoutCreateScopes (t * testing.T ) {
273+ s := newCreateSpritzTestServer (t )
274+ configureProvisionerTestServer (s )
275+ resolverCalls := 0
276+ configureExternalOwnerTestServer (s , fakeExternalOwnerResolver {
277+ resolve : func (_ context.Context , _ externalOwnerPolicy , _ principal , _ ownerRef , _ string ) (externalOwnerResolution , error ) {
278+ resolverCalls ++
279+ return externalOwnerResolution {Status : externalOwnerUnresolved }, nil
280+ },
281+ })
282+ e := newCreateSpritzAPI (t , s )
283+
284+ body := []byte (`{"presetId":"openclaw","ownerRef":{"type":"external","provider":"msteams","tenant":"72f988bf-86f1-41af-91ab-2d7cd011db47","subject":"6f0f9d4f-9b0e-4d52-8c3a-ef0fd64b9b9f"},"idempotencyKey":"teams-no-create-scope"}` )
285+
286+ req , rec := newServiceCreateRequestWithScopes (body , scopeExternalResolveViaCreate )
287+ e .ServeHTTP (rec , req )
288+
289+ if rec .Code != http .StatusForbidden {
290+ t .Fatalf ("expected status 403, got %d: %s" , rec .Code , rec .Body .String ())
291+ }
292+ if resolverCalls != 0 {
293+ t .Fatalf ("expected resolver to be skipped when create scopes are missing, got %d calls" , resolverCalls )
294+ }
295+ }
296+
297+ func TestCreateRequestFingerprintCanonicalizesEquivalentOwnerInputs (t * testing.T ) {
298+ directFingerprint , err := createRequestFingerprint (createRequest {
299+ OwnerID : "user-123" ,
300+ Spec : spritzv1.SpritzSpec {
301+ Image : "example.com/spritz-openclaw:latest" ,
302+ },
303+ }, "spritz-test" , "" , "" , nil )
304+ if err != nil {
305+ t .Fatalf ("createRequestFingerprint failed for direct owner: %v" , err )
306+ }
307+
308+ ownerRefFingerprint , err := createRequestFingerprint (createRequest {
309+ OwnerRef : & ownerRef {
310+ Type : "owner" ,
311+ ID : "user-123" ,
312+ },
313+ Spec : spritzv1.SpritzSpec {
314+ Image : "example.com/spritz-openclaw:latest" ,
315+ },
316+ }, "spritz-test" , "" , "" , nil )
317+ if err != nil {
318+ t .Fatalf ("createRequestFingerprint failed for ownerRef owner: %v" , err )
319+ }
320+
321+ if directFingerprint != ownerRefFingerprint {
322+ t .Fatalf ("expected equivalent direct and ownerRef owner inputs to share a fingerprint" )
323+ }
324+
325+ lowerFingerprint , err := createRequestFingerprint (createRequest {
326+ OwnerRef : & ownerRef {
327+ Type : "external" ,
328+ Provider : "msteams" ,
329+ Tenant : "72f988bf-86f1-41af-91ab-2d7cd011db47" ,
330+ Subject : "6f0f9d4f-9b0e-4d52-8c3a-ef0fd64b9b9f" ,
331+ },
332+ Spec : spritzv1.SpritzSpec {
333+ Image : "example.com/spritz-openclaw:latest" ,
334+ },
335+ }, "spritz-test" , "" , "" , nil )
336+ if err != nil {
337+ t .Fatalf ("createRequestFingerprint failed for lowercase msteams identity: %v" , err )
338+ }
339+
340+ upperFingerprint , err := createRequestFingerprint (createRequest {
341+ OwnerRef : & ownerRef {
342+ Type : "external" ,
343+ Provider : "msteams" ,
344+ Tenant : "72F988BF-86F1-41AF-91AB-2D7CD011DB47" ,
345+ Subject : "6F0F9D4F-9B0E-4D52-8C3A-EF0FD64B9B9F" ,
346+ },
347+ Spec : spritzv1.SpritzSpec {
348+ Image : "example.com/spritz-openclaw:latest" ,
349+ },
350+ }, "spritz-test" , "" , "" , nil )
351+ if err != nil {
352+ t .Fatalf ("createRequestFingerprint failed for uppercase msteams identity: %v" , err )
353+ }
354+
355+ if lowerFingerprint != upperFingerprint {
356+ t .Fatalf ("expected equivalent msteams UUID casing to share a fingerprint" )
357+ }
358+ }
359+
232360func TestNormalizeCreateOwnerSupportsOwnerRefOwner (t * testing.T ) {
233361 body := & createRequest {
234362 OwnerRef : & ownerRef {Type : "owner" , ID : "user-123" },
0 commit comments