feat: QQQ RC.7 followers provider and secured-data label rules #23
No files matched your search
+6
-1
@@ -6,6 +6,11 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade the default QQQ BOM to `4.1.0-RC.7` from Maven Central.
|
||||
- Register the core followers provider for alerts and other qbits, with event preferences, future-mute exclusion, system reads scoped to one record, and caller-owned recipient access checks.
|
||||
- Cache favorite/recent record labels only for FULL field access. Resolve comment/share notification labels per recipient with a table-label-and-id fallback. Use the typed core label-access API for email labels as well.
|
||||
- Verify RC.7 timeline audit masking and HIDDEN-field omission through enforced viewer queries, including canaries in values and messages.
|
||||
|
||||
- Switch the optional qbit-email integration to 0.1.0-RC.2 and its published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and enqueue-status handling. Narrow the email package allowance to the public API.
|
||||
|
||||
- Email through qbit-email 0.1.0-RC.1: new optional dependency and `EmailCollaborationNotifier` (templates `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder`), the default notifier when qbit-email is produced in the instance and no `notifier` is configured; `NoOpNotifier` otherwise. One NOTIFICATION message per recipient, no comment bodies, record labels only when field access allows. New `recipientEmailResolver` config hook; `CollaborationNotification` gains optional `sourceId` and `actorUserId`.
|
||||
@@ -38,7 +43,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
- Adopt core feature overrides, record capabilities and implied WRITE locks, reference metadata, mirrored locks, after-commit cleanup and summaries. Core registers feature permissions in the policy editor.
|
||||
- Replace `OtherUserAccess`/`UserSessionSupplier` with `CollaborationRecordAccessChecker` and core `UserSessionProviderInterface`; configure `withUserSessionProvider`.
|
||||
- Remove `ParentTableRestriction` and `TagFilterTranslator`; runtime `collaborationTags` filtering/search uses core subqueries. Retain the unpinned visibility fallback still missing from core.
|
||||
- Publish identifier-only realtime hints after commit. Selectable Tags output and the optional followers SPI await missing core hooks.
|
||||
- Publish identifier-only realtime hints after commit. Selectable Tags output awaits a core hook; the followers SPI is adopted above.
|
||||
|
||||
- Build against locally installed QQQ `4.1.0-SNAPSHOT` until RC.6 is released; `qqq-middleware-javalin` is a compile dependency. Object storage remains pinned to `0.1.0-RC.2`.
|
||||
|
||||
|
||||
@@ -11,16 +11,33 @@ Comment parents list the selected thread's root and replies. Feature-setting sco
|
||||
## Requirements
|
||||
|
||||
- Java 21.
|
||||
- QQQ `4.1.0-SNAPSHOT` installed from develop, including UI API extension point #1128. The build will move to `4.1.0-RC.6` once released; middleware Javalin is a compile dependency.
|
||||
- QQQ `4.1.0-RC.7` from Maven Central; middleware Javalin is a compile dependency.
|
||||
- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use the memory backend.
|
||||
- Attachments require `qbit-object-storage:0.1.0-RC.2` (a pinned dependency), installed with a PRIVATE bucket alias matching `attachmentsBucketName`. Configure its connection and credentials in the host; this qbit does not provision buckets.
|
||||
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-SNAPSHOT`, targeting RC.6.
|
||||
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-RC.7`.
|
||||
|
||||
## Followers and secured data
|
||||
|
||||
The instance registers a core `RecordFollowersProviderInterface` backed by
|
||||
`collaboration_follow`, so alerts and other qbits can use `RecordFollowers.get`.
|
||||
CHANGE and ALERT select `notifyOnChange`; COMMENT selects `notifyOnComment`.
|
||||
Future mutes are excluded, and disabling FOLLOW returns no subscribers. The
|
||||
provider reads only the requested table/record as the system. Consumers must
|
||||
check each recipient's record access before sending data.
|
||||
|
||||
Favorites and recents cache a record label only when every label field is FULL
|
||||
for the current session; otherwise the stored label is null. Lists continue to
|
||||
re-read labels as the viewer. Comment and share notification titles resolve
|
||||
labels in each recipient's session and fall back to the table label and id
|
||||
(for example, `Order #1042`) when label access is restricted or unavailable.
|
||||
Timeline audit reads use core's enforced query boundary: MASKED values are
|
||||
masked, HIDDEN details are omitted, and restricted audit messages are cleared.
|
||||
Comment bodies remain user content; realtime field-name filtering is handled by core.
|
||||
|
||||
## Demo server
|
||||
|
||||
`CollaborationDevServer` is a disposable, memory-backed Sales CRM for trying the
|
||||
Next dashboard and taking screenshots. It needs Java 21 and the locally installed
|
||||
QQQ `4.1.0-SNAPSHOT` from develop. No database, Docker or MinIO is needed. It binds
|
||||
Next dashboard and taking screenshots. It needs Java 21 and QQQ `4.1.0-RC.7` from Maven Central. No database, Docker or MinIO is needed. It binds
|
||||
to `127.0.0.1`; stopping the JVM discards edits and uploaded attachments.
|
||||
|
||||
Build the Next static export in `qqq-frontend-next`:
|
||||
@@ -563,7 +580,7 @@ host integrations as other collaboration tables.
|
||||
|
||||
## Core contracts and completed UI API
|
||||
|
||||
The qbit targets QQQ RC.6 using the locally installed `4.1.0-SNAPSHOT`. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata:
|
||||
The qbit uses QQQ `4.1.0-RC.7` from Maven Central. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata:
|
||||
|
||||
```java
|
||||
config.withDefaultFeatures(RecordFeature.FAVORITES, RecordFeature.RECENTS, RecordFeature.TIMELINE)
|
||||
@@ -595,7 +612,7 @@ New routes, all under `/qqq/v1/qbit/collaboration`:
|
||||
|
||||
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension stays at unreleased **1.0.0**; contract tooling accepts these additions without a bump.
|
||||
|
||||
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. The optional followers provider is skipped because `RecordFollowersProviderInterface` is absent from the installed jar. Selectable Tags values remain unavailable because the overlay has no output hook.
|
||||
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. `CollaborationFollowersProvider` supplies the core followers SPI for alerts and other consumers. Selectable Tags values remain unavailable because the overlay has no output hook.
|
||||
|
||||
|
||||
## Custom fields (phase f)
|
||||
|
||||
@@ -196,14 +196,14 @@
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
|
||||
<!-- imports the released 4.1 BOM, this profile is active by default and -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -68,6 +68,7 @@ public final class CoreCollaboration
|
||||
{
|
||||
config.applyFeatures(instance);
|
||||
var features = RecordFeaturesMetaData.ofOrWithNew(instance);
|
||||
features.withFollowersProvider(new QCodeReference(com.kingsrook.qbits.collaboration.follow.CollaborationFollowersProvider.class));
|
||||
features.withOverrideProvider(new QCodeReference(FeatureSettingsProvider.class));
|
||||
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.tags.TagsMetaDataProvider.class));
|
||||
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.CustomFieldsProvider.class));
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -55,7 +56,7 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolves targets only with released RC.5 APIs. Each list batches record label
|
||||
** Resolves targets through core permission and field-access APIs. Each list batches record label
|
||||
** refreshes by table, preserving table permissions and record security locks.
|
||||
** Next accepts the flat /app/name/ routes, including dotted process names.
|
||||
*******************************************************************************/
|
||||
@@ -131,6 +132,17 @@ public final class TargetResolver
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist record labels only when all label fields are FULL for this session.
|
||||
*******************************************************************************/
|
||||
public static String cacheableLabel(FavoriteEntry target)
|
||||
{
|
||||
return ("RECORD".equals(target.targetType())
|
||||
&& !FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(target.targetName())) ? null : target.label());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One QueryAction per referenced record table; no cached labels escape.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -288,7 +288,7 @@ public class CommentsService
|
||||
new FollowService().ensureFollower(user, table, id, "MENTIONED");
|
||||
if(!user.equals(PersonalRecords.currentUserId()))
|
||||
{
|
||||
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment", mention.getValueInteger("id"));
|
||||
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment on ", mention.getValueInteger("id"));
|
||||
notified.add(user);
|
||||
}
|
||||
}
|
||||
@@ -307,7 +307,7 @@ public class CommentsService
|
||||
if(!author.equals(follower.userId()) && !mentioned.contains(follower.userId()) && Boolean.TRUE.equals(follower.notifyOnComment())
|
||||
&& (follower.mutedUntil() == null || !follower.mutedUntil().isAfter(Instant.now())))
|
||||
{
|
||||
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on a followed record", null);
|
||||
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on ", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,7 +317,8 @@ public class CommentsService
|
||||
*******************************************************************************/
|
||||
private void notify(QRecord row, String user, CollaborationNotification.Category category, String title, Integer mentionId) throws QException
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(user, title, row.getValueString("body"), category,
|
||||
Notifications.send(new CollaborationNotification(user, title
|
||||
+ new CollaborationRecordAccessChecker().notificationLabel(user, row.getValueString("tableName"), row.getValueString("recordId")), row.getValueString("body"), category,
|
||||
row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId")))
|
||||
.withEvent(mentionId == null ? row.getValue("id") : mentionId, PersonalRecords.currentUserId()),
|
||||
mentionId == null ? null : () -> CollaborationRecords.update(CollaborationTables.MENTION,
|
||||
|
||||
@@ -70,7 +70,7 @@ public final class FavoritesService
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder", false)).withLimit(1));
|
||||
Integer order = last.isEmpty() ? 0 : Math.addExact(last.getFirst().getValueInteger("sortOrder"), 1);
|
||||
QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName)
|
||||
.withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("sortOrder", order);
|
||||
.withValue("recordId", target.recordId()).withValue("label", TargetResolver.cacheableLabel(target)).withValue("sortOrder", order);
|
||||
try
|
||||
{
|
||||
PersonalRecords.insert(CollaborationTables.FAVORITE, row);
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.follow;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowersProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
/*******************************************************************************
|
||||
** Core event subscribers; consumers must check each recipient's record access.
|
||||
*******************************************************************************/
|
||||
public class CollaborationFollowersProvider implements RecordFollowersProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Read only this parent's subscriptions as the system, independent of the caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<RecordFollower> getFollowers(QInstance instance, RecordReference reference, FollowEvent event) throws QException
|
||||
{
|
||||
List<RecordFollower> followers = new ArrayList<>();
|
||||
QContext.withTemporaryContext(new CapturedContext(instance, new QSystemUserSession(instance)), () ->
|
||||
{
|
||||
Instant now = Instant.now();
|
||||
String preference = event == FollowEvent.COMMENT ? "notifyOnComment" : "notifyOnChange";
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.FOLLOW,
|
||||
CollaborationRecords.parent(reference.tableName(), reference.primaryKey().toString())))
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean(preference))
|
||||
&& (row.getValueInstant("mutedUntil") == null || !row.getValueInstant("mutedUntil").isAfter(now)))
|
||||
{
|
||||
followers.add(new RecordFollower().withUserId(row.getValueString("userId"))
|
||||
.withNotifyOnChange(row.getValueBoolean("notifyOnChange")).withNotifyOnComment(row.getValueBoolean("notifyOnComment"))
|
||||
.withReason(row.getValueString("reason")));
|
||||
}
|
||||
}
|
||||
});
|
||||
return (followers);
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public final class RecentsService
|
||||
if(existing.isEmpty())
|
||||
{
|
||||
QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName)
|
||||
.withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("lastViewedAt", now).withValue("viewCount", 1);
|
||||
.withValue("recordId", target.recordId()).withValue("label", TargetResolver.cacheableLabel(target)).withValue("lastViewedAt", now).withValue("viewCount", 1);
|
||||
try
|
||||
{
|
||||
PersonalRecords.insert(CollaborationTables.RECENT, row);
|
||||
@@ -92,7 +92,7 @@ public final class RecentsService
|
||||
return;
|
||||
}
|
||||
PersonalRecords.update(CollaborationTables.RECENT, List.of(new QRecord().withValue("id", row.getValue("id"))
|
||||
.withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", target.label())));
|
||||
.withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", TargetResolver.cacheableLabel(target))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+6
-15
@@ -27,6 +27,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -34,7 +35,6 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -90,8 +90,7 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
|
||||
/*******************************************************************************
|
||||
** The record label as the recipient may see it: empty unless a session for the
|
||||
** recipient exists, the record is readable, and every label field is fully
|
||||
** accessible (FieldAccessHelper.isRecordLabelFullAccess, absent in older QQQ
|
||||
** versions, which means no label).
|
||||
** accessible under the recipient session's field-access policy.
|
||||
*******************************************************************************/
|
||||
public Optional<String> recordLabel(String userId, String table, Serializable recordId) throws QException
|
||||
{
|
||||
@@ -111,7 +110,7 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireVisibleParent(table, recordId);
|
||||
if(labelFullyAccessible(table))
|
||||
if(FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(table)))
|
||||
{
|
||||
GetInput input = new GetInput(table).withPrimaryKey(recordId).withShouldGenerateDisplayValues(true);
|
||||
QRecord record = new GetAction().executeForRecord(input);
|
||||
@@ -127,18 +126,10 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reflective guard: the helper only exists in QQQ versions with secured data.
|
||||
** Notification titles fall back to public table metadata and the record id.
|
||||
*******************************************************************************/
|
||||
private static boolean labelFullyAccessible(String table)
|
||||
public String notificationLabel(String userId, String table, Serializable recordId) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> helper = Class.forName("com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper");
|
||||
return (Boolean.TRUE.equals(helper.getMethod("isRecordLabelFullAccess", QTableMetaData.class).invoke(null, QContext.getQInstance().getTable(table))));
|
||||
}
|
||||
catch(ReflectiveOperationException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (recordLabel(userId, table, recordId).orElse(QContext.getQInstance().getTable(table).getLabel() + " #" + recordId));
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,8 @@ public class SharingService
|
||||
.withValue("recipientCouldRead", recipient.canRead()).withValue("sentAt", Instant.now()).withValue("parentDeleted", false));
|
||||
if(Boolean.TRUE.equals(recipient.canRead()))
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared " + link.label() + " with you", message,
|
||||
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared "
|
||||
+ new CollaborationRecordAccessChecker().notificationLabel(recipient.userId(), table, id) + " with you", message,
|
||||
CollaborationNotification.Category.SHARE, table, id, link.url()).withEvent(row.getValue("id"), from));
|
||||
new FollowService().ensureFollower(recipient.userId(), table, id, "SHARED");
|
||||
}
|
||||
|
||||
@@ -179,8 +179,9 @@ public class CustomFieldsTest extends PersonalServiceFixture
|
||||
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(CollaborationTables.FIELD_DEFINITION).withPrimaryKeys(List.of(row.getValue("id"))))).hasMessageContaining("archive");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("name", "renamed"), row)).hasMessageContaining("cannot change");
|
||||
assertThatThrownBy(() -> FieldDefinitions.options(new QRecord().withValue("options", "{}"))).hasMessageContaining("options");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("classification", "BAD"), row)).hasMessageContaining("classification");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("minValue", 5).withValue("maxValue", 1), row)).hasMessageContaining("Minimum");
|
||||
QRecord published = FieldDefinitions.get(row.getValue("id"));
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(published).withValue("classification", "BAD"), published)).hasMessageContaining("classification");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(published).withValue("minValue", 5).withValue("maxValue", 1), published)).hasMessageContaining("Minimum");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowers;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the installed provider through core's public event-subscriber facade.
|
||||
*******************************************************************************/
|
||||
class FollowersProviderTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preferences, mutes and parent scope apply even for callers without access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void resolvesEventSubscribersAsSystemWithoutFilteringRecipientAccess() throws Exception
|
||||
{
|
||||
seed("change", "document", "1", true, false, null, false);
|
||||
seed("comment", "document", "1", false, true, null, false);
|
||||
seed("both", "document", "1", true, true, Instant.now().minusSeconds(60), false);
|
||||
seed("muted", "document", "1", true, true, Instant.now().plusSeconds(3600), false);
|
||||
seed("neither", "document", "1", false, false, null, false);
|
||||
seed("other-record", "document", "2", true, true, null, false);
|
||||
seed("other-table", "other", "1", true, true, null, false);
|
||||
seed("deleted", "document", "1", true, true, null, true);
|
||||
RecordReference reference = new RecordReference("document", 1);
|
||||
for(QSession session : List.of(new QSession(), new QSystemUserSession(instance)))
|
||||
{
|
||||
QContext.init(instance, session);
|
||||
for(FollowEvent event : List.of(FollowEvent.ALERT, FollowEvent.CHANGE))
|
||||
{
|
||||
assertThat(RecordFollowers.get(instance, reference, event)).extracting(RecordFollower::getUserId)
|
||||
.containsExactlyInAnyOrder("change", "both");
|
||||
}
|
||||
assertThat(RecordFollowers.get(instance, reference, FollowEvent.COMMENT)).extracting(RecordFollower::getUserId)
|
||||
.containsExactlyInAnyOrder("comment", "both");
|
||||
assertThat(RecordFollowers.get(instance, reference, FollowEvent.CHANGE)).filteredOn(f -> f.getUserId().equals("change"))
|
||||
.singleElement().satisfies(f ->
|
||||
{
|
||||
assertThat(f.getNotifyOnChange()).isTrue();
|
||||
assertThat(f.getNotifyOnComment()).isFalse();
|
||||
assertThat(f.getReason()).isEqualTo("MANUAL");
|
||||
});
|
||||
assertThat(QContext.getQSession()).isSameAs(session);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing subscriptions do not override the host's FOLLOW feature switch.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void disabledFollowReturnsEmpty() throws Exception
|
||||
{
|
||||
seed("follower", "document", "1", true, true, null, false);
|
||||
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.FOLLOW, false);
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
for(FollowEvent event : FollowEvent.values())
|
||||
{
|
||||
assertThat(RecordFollowers.get(instance, new RecordReference("document", 1), event)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed persisted preferences directly, including subscriptions lacking access.
|
||||
*******************************************************************************/
|
||||
private void seed(String user, String table, String id, Boolean change, Boolean comment, Instant muted, Boolean deleted) throws Exception
|
||||
{
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.FOLLOW).withRecords(List.of(new QRecord()
|
||||
.withValue("userId", user).withValue("tableName", table).withValue("recordId", id).withValue("reason", "MANUAL")
|
||||
.withValue("notifyOnChange", change).withValue("notifyOnComment", comment).withValue("mutedUntil", muted)
|
||||
.withValue("parentDeleted", deleted))), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentsService;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier;
|
||||
import com.kingsrook.qbits.collaboration.notify.RecordingNotifier;
|
||||
import com.kingsrook.qbits.collaboration.recents.RecentsService;
|
||||
import com.kingsrook.qbits.collaboration.sharing.SharingService;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Cached and delivered labels respect the session that will receive the data.
|
||||
*******************************************************************************/
|
||||
class SecuredLabelsTest extends PersonalServiceFixture
|
||||
{
|
||||
private static RecordingNotifier recorder;
|
||||
|
||||
/*******************************************************************************
|
||||
** Non-FULL labels are never cached; lists always re-read as the current viewer.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cachesOnlyFullAccessLabelsAndRefreshesForViewer() throws Exception
|
||||
{
|
||||
for(FieldSecurityLock.Behavior behavior : List.of(FieldSecurityLock.Behavior.MASK, FieldSecurityLock.Behavior.DENY, FieldSecurityLock.Behavior.REVEAL))
|
||||
{
|
||||
restrictLabel(behavior);
|
||||
FavoritesService.star("RECORD", "document", 1);
|
||||
RecentsService.recordView("RECORD", "document", 1);
|
||||
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
|
||||
{
|
||||
assertThat(PersonalRecords.query(table, PersonalRecords.ownFilter())).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValueString("label")).isNull());
|
||||
}
|
||||
assertThat(FavoritesService.list()).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
|
||||
assertThat(RecentsService.list(50)).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
|
||||
FavoritesService.unstar("RECORD", "document", 1);
|
||||
PersonalRecords.delete(CollaborationTables.RECENT, PersonalRecords.query(CollaborationTables.RECENT, PersonalRecords.ownFilter()).stream().map(row -> row.getValue("id")).toList());
|
||||
}
|
||||
QContext.getQSession().withSecurityKeyValue("sensitive", "full");
|
||||
FavoritesService.star("RECORD", "document", 1);
|
||||
RecentsService.recordView("RECORD", "document", 1);
|
||||
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
|
||||
{
|
||||
assertThat(PersonalRecords.query(table, PersonalRecords.ownFilter())).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValueString("label")).isEqualTo("First document"));
|
||||
}
|
||||
login("alice");
|
||||
assertThat(FavoritesService.list()).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
|
||||
assertThat(RecentsService.list(50)).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
assertThat(PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.targetFilter("TABLE", "document", null)))
|
||||
.singleElement().satisfies(row -> assertThat(row.getValueString("label")).isEqualTo("Documents"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The sender's FULL label cannot leak to recipients with MASKED or HIDDEN access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notificationTitlesResolveLabelsPerRecipient() throws Exception
|
||||
{
|
||||
for(FieldSecurityLock.Behavior behavior : List.of(FieldSecurityLock.Behavior.MASK, FieldSecurityLock.Behavior.DENY))
|
||||
{
|
||||
restrictLabel(behavior);
|
||||
QContext.getQSession().withSecurityKeyValue("sensitive", "full");
|
||||
recorder = new RecordingNotifier();
|
||||
config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionProvider(new QCodeReference(TestSessions.class));
|
||||
new SharingService().share("document", 1, List.of("restricted", "full"), "Shared user content");
|
||||
new CommentsService().post("document", 1, "@[Restricted](user:restricted) @[Full](user:full)", null, null);
|
||||
assertThat(recorder.notifications()).hasSize(4);
|
||||
assertThat(recorder.notifications()).filteredOn(n -> n.recipientUserId().equals("restricted")).hasSize(2)
|
||||
.allSatisfy(n -> assertThat(n.title()).contains("Documents #1").doesNotContain("First document"));
|
||||
assertThat(recorder.notifications()).filteredOn(n -> n.recipientUserId().equals("full")).hasSize(2)
|
||||
.allSatisfy(n -> assertThat(n.title()).contains("First document"));
|
||||
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo("alice");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Field-lock override grants FULL to selected sessions only.
|
||||
*******************************************************************************/
|
||||
private void restrictLabel(FieldSecurityLock.Behavior behavior)
|
||||
{
|
||||
if(!instance.getSecurityKeyTypes().containsKey("sensitive"))
|
||||
{
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
|
||||
}
|
||||
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
|
||||
.withSecurityKeyType("sensitive").withDefaultBehavior(behavior).withOverrideValues(List.of("full")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipients share record access but have different label access.
|
||||
*******************************************************************************/
|
||||
public static class TestSessions implements UserSessionProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Build independent recipient sessions for real core field checks.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<QSession> sessionForUser(String userId)
|
||||
{
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference(userId)).withPermissions("document.read")
|
||||
.withSecurityKeyValue("tenant", "a");
|
||||
if(userId.equals("full"))
|
||||
{
|
||||
session.withSecurityKeyValue("sensitive", "full");
|
||||
}
|
||||
return (Optional.of(session));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture notifications before any external transport.
|
||||
*******************************************************************************/
|
||||
public static class TestNotifier implements CollaborationNotifier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve the payload for assertions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(CollaborationNotification notification)
|
||||
{
|
||||
recorder.notify(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ import com.kingsrook.qbits.collaboration.timeline.TimelinePage;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineService;
|
||||
import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldMaskStyle;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldValueMasker;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.personalization.TableMetaDataPersonalizerInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
@@ -182,6 +186,40 @@ public class TimelineServiceTest extends PersonalServiceFixture
|
||||
assertThat(timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items()).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.7 QueryAction masks audit values and removes HIDDEN detail rows as the viewer.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fieldTiersProtectTimelineValuesAndMessages() throws Exception
|
||||
{
|
||||
String canary = "TIMELINE-SECRET-CANARY";
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
|
||||
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
|
||||
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK).withMaskStyle(FieldMaskStyle.FULL));
|
||||
instance.getTable("document").getField("tenant").withFieldSecurityLock(new FieldSecurityLock()
|
||||
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.DENY));
|
||||
seedAudit(4, canary, 100);
|
||||
for(String field : List.of("title", "tenant"))
|
||||
{
|
||||
seed("auditDetail", new QRecord().withValue("id", field.equals("title") ? 10L : 11L).withValue("auditId", 4L)
|
||||
.withValue("fieldName", field).withValue("oldValue", canary).withValue("newValue", canary).withValue("message", canary));
|
||||
}
|
||||
TimelinePage page = timeline.timeline("document", 1, null, 50, Set.of("AUDIT"));
|
||||
assertThat(page.items()).filteredOn(item -> item.id().equals(4L)).singleElement().satisfies(item ->
|
||||
{
|
||||
assertThat(item.summary()).isNull();
|
||||
assertThat(item.details()).singleElement().satisfies(detail ->
|
||||
{
|
||||
assertThat(detail.fieldName()).isEqualTo("title");
|
||||
assertThat(detail.oldValue()).isEqualTo(FieldValueMasker.MASK);
|
||||
assertThat(detail.newValue()).isEqualTo(FieldValueMasker.MASK);
|
||||
assertThat(detail.message()).isNull();
|
||||
});
|
||||
});
|
||||
assertThat(page.items().stream().flatMap(item -> item.details().stream())).noneMatch(detail -> "tenant".equals(detail.fieldName()));
|
||||
assertThat(page.toString()).doesNotContain(canary, "Old", "New", "sensitive old", "sensitive new");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Real personalizer boundary, like core's audit screen.
|
||||
*******************************************************************************/
|
||||
|
||||
+24
-1
@@ -43,6 +43,8 @@ import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderI
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
@@ -159,7 +161,7 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
|
||||
assertThat(message.getValueString("linkedTableName")).isEqualTo("document");
|
||||
assertThat(message.getValueString("linkedRecordId")).isEqualTo("1");
|
||||
assertThat(message.getValueString("senderQbit")).isEqualTo("qbit-collaboration");
|
||||
assertThat(message.getValueString("subject")).contains("alice mentioned you on a record in Documents");
|
||||
assertThat(message.getValueString("subject")).contains("alice mentioned you on First document");
|
||||
assertThat(message.getValueString("subject") + message.getValueString("textBody") + message.getValueString("htmlBody")).doesNotContain("secret comment body");
|
||||
assertThat(message.getValueString("htmlBody")).contains("https://app.example.org/app/document/1");
|
||||
});
|
||||
@@ -175,6 +177,27 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restricted record labels never enter email subjects or rendered bodies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void maskedLabelUsesGenericEmailText() throws Exception
|
||||
{
|
||||
withEmail();
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
|
||||
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
|
||||
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK));
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
|
||||
assertThat(messages()).singleElement().satisfies(message ->
|
||||
{
|
||||
assertThat(message.getValueString("subject")).contains("alice mentioned you on a record in Documents");
|
||||
assertThat(message.getValueString("subject") + message.getValueString("textBody") + message.getValueString("htmlBody"))
|
||||
.doesNotContain("First document", "secret comment body");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each recipient gets their own message; the share and task kinds use their
|
||||
** own templates and key kinds; follow digests send no email.
|
||||
|
||||
@@ -52,6 +52,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
@@ -114,6 +116,26 @@ class RecentsServiceTest extends PersonalServiceFixture
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A refreshed recent discards its old cached label after access is restricted.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refreshClearsLabelWhenFieldAccessIsRestricted() throws Exception
|
||||
{
|
||||
at(START);
|
||||
RecentsService.recordView("RECORD", "document", 1);
|
||||
assertThat(rows().getFirst().getValueString("label")).isEqualTo("First document");
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
|
||||
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
|
||||
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK));
|
||||
at(START.plusSeconds(60));
|
||||
RecentsService.recordView("RECORD", "document", 1);
|
||||
assertThat(rows().getFirst().getValueString("label")).isNull();
|
||||
assertThat(rows().getFirst().getValueInteger("viewCount")).isEqualTo(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null ids also upsert; visibility is checked even inside the throttle window.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user