From cdc9b9af0955291d88c09f0050baa5b4ef6016b3 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Tue, 25 Aug 2026 16:40:32 +0100 Subject: [PATCH] Add anonymous write scoped-session data scope --- .../ScopedSessionPolicyApplier.java | 8 +- ...ingifierApiAnonymousDataScopeResolver.java | 7 +- .../ThingifierApiScopedSessionDefinition.java | 123 +++++++- .../ThingifierApiScopedSessionPolicy.java | 20 +- .../api/spec/ThingifierApiSpec.java | 14 + .../ThingifierApiScopedSessionPolicyTest.java | 290 ++++++++++++++++++ 6 files changed, 449 insertions(+), 13 deletions(-) diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ScopedSessionPolicyApplier.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ScopedSessionPolicyApplier.java index 363f8b94..d587802c 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ScopedSessionPolicyApplier.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ScopedSessionPolicyApplier.java @@ -207,8 +207,12 @@ private ThingifierApiDataScopeSelection anonymousDataScopeSelection( if (policy.allowsAnonymousDefaultScope()) { return ThingifierApiDataScopeSelection.defaultDataScope(); } - return definition.anonymousDataScopeSelection( - scopedSessionContext(definition, "", verb, path, context, route, queryParams)); + final ThingifierApiScopedSessionContext scopedSessionContext = + scopedSessionContext(definition, "", verb, path, context, route, queryParams); + if (policy.allowsAnonymousConfiguredWriteScope()) { + return definition.anonymousWriteDataScopeSelection(scopedSessionContext); + } + return definition.anonymousReadDataScopeSelection(scopedSessionContext); } private ApiResponse scopedSessionRejected( diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiAnonymousDataScopeResolver.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiAnonymousDataScopeResolver.java index 4aa9db8f..1660adfd 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiAnonymousDataScopeResolver.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiAnonymousDataScopeResolver.java @@ -4,14 +4,15 @@ * Chooses the data scope for a missing scoped-session credential. * *

This callback is trusted server-side configuration, not a request-controlled database mapper. - * Thingifier calls it only after route matching has determined that anonymous read access is - * allowed, and before validators, authorizers, hooks, handlers, and response rendering run. + * Thingifier calls it only after route matching has determined that anonymous access is allowed for + * the current operation, and before validators, authorizers, hooks, handlers, and response + * rendering run. */ @FunctionalInterface public interface ThingifierApiAnonymousDataScopeResolver { /** - * Selects the data scope to use for an anonymous read request. + * Selects the data scope to use for an anonymous request. * * @param context immutable route and request context for the missing credential request * @return trusted data-scope selection, or null to signal a configuration error diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java index 58e10fd9..63430e66 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java @@ -18,7 +18,9 @@ public final class ThingifierApiScopedSessionDefinition { private ThingifierApiScopedSessionAuthenticator authenticator; private boolean anonymousScopeForReads; private boolean anonymousDefaultScopeForReads; - private ThingifierApiAnonymousDataScopeResolver anonymousDataScopeResolver; + private ThingifierApiAnonymousDataScopeResolver anonymousReadDataScopeResolver; + private boolean anonymousScopeForWrites; + private ThingifierApiAnonymousDataScopeResolver anonymousWriteDataScopeResolver; private boolean authenticatedScopeForWrites; private int missingCredentialStatusCode; private String missingCredentialMessage; @@ -104,7 +106,7 @@ public ThingifierApiScopedSessionDefinition authenticateWith( public ThingifierApiScopedSessionDefinition allowAnonymousDefaultScopeForReads() { this.anonymousScopeForReads = true; this.anonymousDefaultScopeForReads = true; - this.anonymousDataScopeResolver = + this.anonymousReadDataScopeResolver = context -> ThingifierApiDataScopeSelection.defaultDataScope(); return this; } @@ -156,17 +158,73 @@ public ThingifierApiScopedSessionDefinition allowAnonymousReadsUsingDataScope( } this.anonymousScopeForReads = true; this.anonymousDefaultScopeForReads = false; - this.anonymousDataScopeResolver = resolver; + this.anonymousReadDataScopeResolver = resolver; + return this; + } + + /** + * Allows write-style generated routes to use a named data scope when no credential is supplied. + * + *

This is intentionally opt-in for applications that offer public, demo, or single-user + * mutable state. If a credential is supplied, Thingifier still validates it and invalid + * credentials reject rather than falling back to the anonymous write scope. + * + * @param dataScopeName anonymous write data scope + * @return this definition for fluent configuration + */ + public ThingifierApiScopedSessionDefinition allowAnonymousWritesUsingDataScope( + final String dataScopeName) { + return allowAnonymousWritesUsingDataScope( + dataScopeName, DataScopeCreationPolicy.USE_EXISTING_ONLY); + } + + /** + * Allows write-style generated routes to use a named data scope when no credential is supplied. + * + * @param dataScopeName anonymous write data scope + * @param creationPolicy policy used when the anonymous scope does not exist + * @return this definition for fluent configuration + */ + public ThingifierApiScopedSessionDefinition allowAnonymousWritesUsingDataScope( + final String dataScopeName, final DataScopeCreationPolicy creationPolicy) { + final ThingifierApiDataScopeSelection selection = + ThingifierApiDataScopeSelection.useDataScope(dataScopeName, creationPolicy); + return allowAnonymousWritesUsingDataScope(context -> selection); + } + + /** + * Allows write-style generated routes to resolve the anonymous data scope dynamically. + * + *

The resolver runs only when the scoped-session credential is missing and anonymous write + * access is allowed for the route. Configuring anonymous writes clears the authenticated-write + * requirement so fluent write policy calls have predictable last-call-wins behaviour. + * + * @param resolver trusted anonymous data-scope resolver + * @return this definition for fluent configuration + */ + public ThingifierApiScopedSessionDefinition allowAnonymousWritesUsingDataScope( + final ThingifierApiAnonymousDataScopeResolver resolver) { + if (resolver == null) { + throw new IllegalArgumentException("anonymous write data-scope resolver is required"); + } + this.anonymousScopeForWrites = true; + this.anonymousWriteDataScopeResolver = resolver; + this.authenticatedScopeForWrites = false; return this; } /** * Requires write-style generated routes to have a valid scoped-session credential. * + *

Configuring required writes clears any anonymous write scope so fluent write policy calls + * have predictable last-call-wins behaviour. + * * @return this definition for fluent configuration */ public ThingifierApiScopedSessionDefinition requireAuthenticatedScopeForWrites() { this.authenticatedScopeForWrites = true; + this.anonymousScopeForWrites = false; + this.anonymousWriteDataScopeResolver = null; return this; } @@ -247,7 +305,19 @@ public boolean allowsAnonymousDefaultScopeForReads() { * @return resolver when anonymous reads are configured */ public Optional anonymousDataScopeResolver() { - return Optional.ofNullable(anonymousDataScopeResolver); + return anonymousReadDataScopeResolver(); + } + + /** + * Returns the trusted resolver used for missing-credential anonymous reads. + * + *

This named read accessor keeps the read and write anonymous scope policies explicit while + * {@link #anonymousDataScopeResolver()} remains as the original read-scope alias. + * + * @return resolver when anonymous reads are configured + */ + public Optional anonymousReadDataScopeResolver() { + return Optional.ofNullable(anonymousReadDataScopeResolver); } /** @@ -258,10 +328,51 @@ public Optional anonymousDataScopeResol */ public ThingifierApiDataScopeSelection anonymousDataScopeSelection( final ThingifierApiScopedSessionContext context) { - if (anonymousDataScopeResolver == null) { + return anonymousReadDataScopeSelection(context); + } + + /** + * Selects the anonymous data scope for a missing-credential read request. + * + * @param context route and request context + * @return selected data scope, or null if the resolver is absent or returns null + */ + public ThingifierApiDataScopeSelection anonymousReadDataScopeSelection( + final ThingifierApiScopedSessionContext context) { + if (anonymousReadDataScopeResolver == null) { + return null; + } + return anonymousReadDataScopeResolver.selectDataScope(context); + } + + /** + * @return true when write-style routes may fall back to an anonymous scope + */ + public boolean allowsAnonymousScopeForWrites() { + return anonymousScopeForWrites; + } + + /** + * Returns the trusted resolver used for missing-credential anonymous writes. + * + * @return resolver when anonymous writes are configured + */ + public Optional anonymousWriteDataScopeResolver() { + return Optional.ofNullable(anonymousWriteDataScopeResolver); + } + + /** + * Selects the anonymous data scope for a missing-credential write request. + * + * @param context route and request context + * @return selected data scope, or null if the resolver is absent or returns null + */ + public ThingifierApiDataScopeSelection anonymousWriteDataScopeSelection( + final ThingifierApiScopedSessionContext context) { + if (anonymousWriteDataScopeResolver == null) { return null; } - return anonymousDataScopeResolver.selectDataScope(context); + return anonymousWriteDataScopeResolver.selectDataScope(context); } /** diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicy.java index d8cbf59d..a3d49a4e 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicy.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicy.java @@ -17,10 +17,17 @@ public enum Mode { ALLOW_ANONYMOUS_DEFAULT_SCOPE, /** - * Missing credentials use the anonymous data-scope resolver configured on the definition. + * Missing credentials use the anonymous read data-scope resolver configured on the + * definition. */ ALLOW_ANONYMOUS_CONFIGURED_SCOPE, + /** + * Missing credentials use the anonymous write data-scope resolver configured on the + * definition. + */ + ALLOW_ANONYMOUS_CONFIGURED_WRITE_SCOPE, + /** Missing or invalid credentials reject before validators and handlers run. */ REQUIRE_AUTHENTICATED_SCOPE } @@ -107,11 +114,20 @@ public boolean allowsAnonymousConfiguredScope() { return mode == Mode.ALLOW_ANONYMOUS_CONFIGURED_SCOPE; } + /** + * @return true when missing credentials should use the definition's anonymous write resolver + */ + public boolean allowsAnonymousConfiguredWriteScope() { + return mode == Mode.ALLOW_ANONYMOUS_CONFIGURED_WRITE_SCOPE; + } + /** * @return true when missing credentials should continue anonymously */ public boolean allowsAnonymousScope() { - return allowsAnonymousDefaultScope() || allowsAnonymousConfiguredScope(); + return allowsAnonymousDefaultScope() + || allowsAnonymousConfiguredScope() + || allowsAnonymousConfiguredWriteScope(); } /** diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java index 9f76be00..b09f727f 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java @@ -661,6 +661,20 @@ private Optional contractDefaultScopedSessionP definition, Mode.ALLOW_ANONYMOUS_CONFIGURED_SCOPE)); } if (isWriteVerb(verb)) { + final Optional anonymousWritePolicy = + scopedSessions.values().stream() + .filter( + ThingifierApiScopedSessionDefinition + ::allowsAnonymousScopeForWrites) + .findFirst() + .map( + definition -> + ThingifierApiScopedSessionPolicy.configured( + definition, + Mode.ALLOW_ANONYMOUS_CONFIGURED_WRITE_SCOPE)); + if (anonymousWritePolicy.isPresent()) { + return anonymousWritePolicy; + } return scopedSessions.values().stream() .filter( ThingifierApiScopedSessionDefinition diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java index b98eab53..ced936fe 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java @@ -1,6 +1,7 @@ package uk.co.compendiumdev.thingifier.api.security; import static uk.co.compendiumdev.thingifier.api.security.DataScopeCreationPolicy.ENSURE_EXISTS; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.PARTIAL_JSON_UPDATE; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.security.SecurityScheme; @@ -247,6 +248,193 @@ void validCredentialOnProtectedWriteWritesToSelectedDataScope() { Assertions.assertEquals(1, todoCount(thingifier, "tenant-one")); } + @Test + void missingCredentialOnAnonymousPostUsesConfiguredWriteScope() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"anonymous post\"}"), + new HttpHeadersBlock()); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME)); + Assertions.assertEquals(1, todoCount(thingifier, "anonymous-scope")); + } + + @Test + void missingCredentialOnAnonymousPutUsesConfiguredWriteScope() { + final Thingifier thingifier = stringIdTodoModel(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS); + + final ApiResponse response = + thingifier + .api() + .put( + "todos/1", + parser(thingifier, "{\"title\":\"anonymous put\"}"), + new HttpHeadersBlock()); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME)); + Assertions.assertEquals(1, todoCount(thingifier, "anonymous-scope")); + Assertions.assertEquals("anonymous put", todoTitle(thingifier, "anonymous-scope", "1")); + } + + @Test + void missingCredentialOnAnonymousPatchUsesConfiguredWriteScope() { + final Thingifier thingifier = todoModel(); + thingifier.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); + createTodo(thingifier, "anonymous-scope", "before patch"); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope"); + + final ApiResponse response = + thingifier + .api() + .patch("todos/1", "{\"title\":\"anonymous patch\"}", patchHeaders()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME)); + Assertions.assertEquals("anonymous patch", todoTitle(thingifier, "anonymous-scope", "1")); + } + + @Test + void missingCredentialOnAnonymousDeleteUsesConfiguredWriteScope() { + final Thingifier thingifier = todoModel(); + createTodo(thingifier, "anonymous-scope", "delete me"); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope"); + + final ApiResponse response = thingifier.api().delete("todos/1", new HttpHeadersBlock()); + + Assertions.assertEquals(204, response.getStatusCode()); + Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME)); + Assertions.assertEquals(0, todoCount(thingifier, "anonymous-scope")); + } + + @Test + void anonymousWriteResolverChoosesDataScope() { + final Thingifier thingifier = todoModel(); + final AtomicReference seenPath = new AtomicReference<>(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope( + context -> { + seenPath.set(context.path()); + return ThingifierApiDataScopeSelection.useDataScope( + "anonymous-scope", ENSURE_EXISTS); + }); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"anonymous resolver\"}"), + new HttpHeadersBlock()); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals("todos", seenPath.get()); + Assertions.assertEquals(1, todoCount(thingifier, "anonymous-scope")); + } + + @Test + void validCredentialOverridesAnonymousWriteScope() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"tenant write\"}"), + headersWithScopedCredential("valid-session")); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals(0, todoCount(thingifier, "anonymous-scope")); + Assertions.assertEquals(1, todoCount(thingifier, "tenant-one")); + } + + @Test + void invalidCredentialOnAnonymousWriteRejects() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS) + .onInvalidCredential(403, "Invalid scoped session"); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"blocked\"}"), + headersWithScopedCredential("bad-session")); + + Assertions.assertEquals(403, response.getStatusCode()); + Assertions.assertEquals( + "Invalid scoped session", response.getErrorMessages().iterator().next()); + Assertions.assertEquals(0, todoCount(thingifier, "anonymous-scope")); + } + + @Test + void requireAuthenticatedScopeForWritesOverridesEarlierAnonymousWriteScope() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS) + .requireAuthenticatedScopeForWrites() + .onMissingRequiredCredential(401, "Missing scoped session"); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"blocked\"}"), + new HttpHeadersBlock()); + + Assertions.assertEquals(401, response.getStatusCode()); + Assertions.assertEquals( + "Missing scoped session", response.getErrorMessages().iterator().next()); + Assertions.assertEquals(0, todoCount(thingifier, "anonymous-scope")); + } + + @Test + void routeLevelDisableBypassesAnonymousWriteScope() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS); + thingifier.apiSpec().route(RoutingVerb.POST, "/todos").disableScopedSession(); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"legacy session write\"}"), + headersWithSession("tenant-one")); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals(0, todoCount(thingifier, "anonymous-scope")); + Assertions.assertEquals(1, todoCount(thingifier, "tenant-one")); + } + @Test void routeLevelRequireOverridesAnonymousReadDefault() { final Thingifier thingifier = todoModel(); @@ -402,6 +590,37 @@ void authorizerReceivesAnonymousDataScope() { Assertions.assertNull(seenPrincipal.get()); } + @Test + void authorizerReceivesAnonymousWriteDataScope() { + final Thingifier thingifier = todoModel(); + final AtomicReference seenDataScope = new AtomicReference<>(); + final AtomicReference seenPrincipal = new AtomicReference<>(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/todos") + .authorizeWith( + context -> { + seenDataScope.set(context.dataScopeName()); + seenPrincipal.set(context.principal()); + return ThingifierApiAuthorizationResult.authorized(); + }); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"anonymous authorized\"}"), + new HttpHeadersBlock()); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals("anonymous-scope", seenDataScope.get()); + Assertions.assertNull(seenPrincipal.get()); + } + @Test void fixedRouteUsesAnonymousDataScope() { final Thingifier thingifier = todoModel(); @@ -425,6 +644,28 @@ void fixedRouteUsesAnonymousDataScope() { response.apiResponse().getReturnedInstance().getFieldValue("title").asString()); } + @Test + void fixedRoutePostUsesAnonymousWriteDataScope() { + final Thingifier thingifier = todoModel(); + createTodo(thingifier, "anonymous-scope", "before fixed write"); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope"); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/fixed/todo") + .mapsToEntity("todo") + .withFixedIdentifier("1"); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .post(jsonPost("/fixed/todo", "{\"title\":\"after fixed write\"}")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("after fixed write", todoTitle(thingifier, "anonymous-scope", "1")); + Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME)); + } + @Test void explicitRouteAuthDataScopeOverridesScopedSessionDataScope() { final Thingifier thingifier = todoModel(); @@ -508,6 +749,33 @@ void validatorReceivesScopedSessionSelectedStore() { Assertions.assertSame(thingifier.getStore("tenant-one"), seenStore.get()); } + @Test + void validatorReceivesAnonymousWriteSelectedStore() { + final Thingifier thingifier = todoModel(); + final AtomicReference seenStore = new AtomicReference<>(); + thingifier + .getDefinitionNamed("todo") + .withDomainValidation( + context -> { + seenStore.set(context.store()); + return new ValidationReport(); + }); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"anonymous validated\"}"), + new HttpHeadersBlock()); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertSame(thingifier.getStore("anonymous-scope"), seenStore.get()); + } + @Test void operationCallbackReceivesScopedSessionPrincipalAndScope() { final Thingifier thingifier = todoModel(); @@ -632,6 +900,14 @@ private Thingifier todoModel() { return thingifier; } + private Thingifier stringIdTodoModel() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition todo = thingifier.defineThing("todo", "todos", 5); + todo.addAsPrimaryKeyField(Field.is("id", FieldType.STRING).makeMandatory()); + todo.addField(Field.is("title", FieldType.STRING).makeMandatory()); + return thingifier; + } + private EntityInstance createTodo( final Thingifier thingifier, final String dataScopeName, final String title) { thingifier.getERmodel().createInstanceDatabaseIfNotExisting(dataScopeName); @@ -646,6 +922,14 @@ private int todoCount(final Thingifier thingifier, final String dataScopeName) { return thingifier.listThingInstancesNamed("todos", dataScopeName).size(); } + private String todoTitle( + final Thingifier thingifier, final String dataScopeName, final String id) { + return thingifier + .findThingInstanceByFieldNameAndValue("todo", "id", id, dataScopeName) + .getFieldValue("title") + .asString(); + } + private String firstReturnedTodoTitle(final ApiResponse response) { return response.getReturnedInstanceCollection().get(0).getFieldValue("title").asString(); } @@ -662,6 +946,12 @@ private HttpApiRequest jsonPost(final String path, final String body) { return new HttpApiRequest(path).addHeader("Content-Type", "application/json").setBody(body); } + private HttpHeadersBlock patchHeaders() { + HttpHeadersBlock headers = new HttpHeadersBlock(); + headers.put("Content-Type", PARTIAL_JSON_UPDATE.mediaType()); + return headers; + } + private HttpHeadersBlock headersWithScopedCredential(final String credential) { HttpHeadersBlock headers = new HttpHeadersBlock(); headers.put("X-CHALLENGER", credential);