Compare commits

...
Author SHA1 Message Date
james.maesandCodex 2504129672 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:24:19 -05:00
james.maesandCodex b1ede9f887 ci: adopt central orb 0.7.12 and stable gates (#26)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:56:56 -05:00
james.maesandCodex 81552784e9 ci: centralize rc release completion in qqq orb (#25)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:26:29 -05:00
james.maesandCodex a68d557fb2 ci: adopt shared orb 0.7.11 (#24)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:01:55 -05:00
james.maes 36527b06ff Merge pull request #23 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 18:57:32 -05:00
james.maesandCodex 7c78003765 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:52:18 -05:00
james.maes ce127037e4 Merge pull request #19 from QRun-IO/feature/service-principal
fix(schedules): schedules and watches run as the data-analysis service principal
2026-10-16 13:33:34 -05:00
james.maes 96d2e48e47 Merge pull request #20 from QRun-IO/feature/data-analysis-business-home
feat(home): move Setup to admin and omit unpermitted home content
2026-10-16 10:19:27 -05:00
james.maesandClaude Sonnet 5.5 1d2c2b0029 feat(home): move Setup to admin and omit unpermitted home content
The Data Analysis home showed the engine Setup card (max rows, sampling,
cache, scheduler state) to every SAVED_READ user, and rendered
"Unavailable: READ permission required." shells for unreadable groups.

- dataAnalysisSetup is off the home widget list; it stays registered, gated
  to data_analysis.admin with DenyBehavior.HIDDEN. Hosts place
  DataAnalysisQBitProducer.SYSTEM_WIDGETS on their Admin area.
- Each home data widget requires READ on its table and is omitted from
  metadata otherwise.
- Overview groups the user cannot read are left out of the payload; no
  "Unavailable" text remains on the home.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:18:26 -05:00
james.maes 9d1d712040 Merge pull request #18 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:03:37 -05:00
james.maesandClaude Opus 5.5 2c1fa83cfd ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:19:18 -05:00
9 changed files with 298 additions and 89 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.7.12
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.7.12
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+1
View File
@@ -8,6 +8,7 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
- Scheduled analyses and watches work under OAuth2. When the host declares the `data-analysis` service principal (QQQ service identities, `DataAnalysisQBitConfig.withServicePrincipal`), schedule and watch inventory runs as that principal, and owner and recipient runs go on their behalf through it: their current access intersected with the principal's. The self-made `data-analysis-scheduler` session was rejected under OAuth2 (`Invalid session in request` on every `data_analysis_run_schedules` run). With no principal declared, behavior is unchanged. Needs the QQQ release with service identities (qqq#1255, #1256).
- QBit metadata now reports the Maven artifact version, including release versions, from a filtered build resource.
- The Data Analysis home no longer shows the engine Setup card or "Unavailable: READ permission required." shells. Setup is admin-only (hidden from everyone else) and off the home; hosts place `DataAnalysisQBitProducer.SYSTEM_WIDGETS` on their Admin area. Home widgets the user cannot read are omitted from metadata, and unreadable groups are left out of the overview.
### Added
@@ -23,8 +23,10 @@ package com.kingsrook.qbits.dataanalysis;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Properties;
import com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi;
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -45,6 +47,13 @@ public class DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalys
public static final String ARTIFACT_ID = "qbit-data-analysis";
public static final String VERSION = loadVersion();
/////////////////////////////////////////////////////////////////////////////////
// Widgets that show engine configuration. They are registered with the qbit //
// (admin-only, hidden from everyone else) but kept off the business home; the //
// host places them on its Admin area, e.g. withWidgets(SYSTEM_WIDGETS). //
/////////////////////////////////////////////////////////////////////////////////
public static final List<String> SYSTEM_WIDGETS = List.of(DataAnalysisHome.SETUP);
private DataAnalysisQBitConfig qBitConfig;
private String namespace;
@@ -28,14 +28,17 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitProducer;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.engine.DrillFilters;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
import com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
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.UseOtherPermissionNameCustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -54,6 +57,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
@@ -82,7 +86,39 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
return (List.of(widget(SETUP, "Setup", WidgetType.TABLE), widget(), widget(STARRED, "Starred analyses", WidgetType.TABLE), widget(FIRING, "Firing watches", WidgetType.TABLE), widget(RECENT, "My recent runs", WidgetType.TABLE)));
return (List.of(setupWidget(), widget().withPermissionRules(readRules(DataAnalysisTables.SAVED)),
widget(STARRED, "Starred analyses", WidgetType.TABLE).withPermissionRules(readRules(DataAnalysisTables.SAVED)),
widget(FIRING, "Firing watches", WidgetType.TABLE).withPermissionRules(readRules(DataAnalysisTables.WATCH)),
widget(RECENT, "My recent runs", WidgetType.TABLE).withPermissionRules(readRules(DataAnalysisTables.RUN))));
}
/*******************************************************************************
** Names of the widgets placed on the business home. Setup is system content and
** is deliberately absent; hosts place {@link DataAnalysisQBitProducer#SYSTEM_WIDGETS}
** on their Admin area instead.
*******************************************************************************/
public static List<String> homeWidgetNames()
{
return (widgets().stream().map(QWidgetMetaData::getName).filter(name -> !DataAnalysisQBitProducer.SYSTEM_WIDGETS.contains(name)).toList());
}
/*******************************************************************************
** The Setup card shows engine configuration, so only Data Analysis admins get it;
** everyone else has it omitted from metadata.
*******************************************************************************/
private static QWidgetMetaData setupWidget()
{
return (widget(SETUP, "Setup", WidgetType.TABLE).withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withDenyBehavior(DenyBehavior.HIDDEN).withCustomPermissionChecker(UseOtherPermissionNameCustomPermissionChecker.build(DataAnalysisPermissions.ADMIN))));
}
/*******************************************************************************
** A data widget is omitted from metadata for users who cannot read its table.
*******************************************************************************/
private static QPermissionRules readRules(String table)
{
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withDenyBehavior(DenyBehavior.HIDDEN)
.withCustomPermissionChecker(TableReadWidgetPermissionChecker.build(table)));
}
/*******************************************************************************
@@ -101,7 +137,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
public static QAppMetaData app(DataAnalysisQBitConfig config, List<QTableMetaData> tables)
{
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel())
.withIcon(new QIcon("insights")).withWidgets(widgets().stream().map(QWidgetMetaData::getName).toList())
.withIcon(new QIcon("insights")).withWidgets(homeWidgetNames())
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
.withSection(new QAppSection().withName("analyses").withLabel("Analyses").withIcon(new QIcon("insights")).withTables(DataAnalysisTables.NAV_TABLES));
app.setParentAppName(config.getParentAppName());
@@ -197,46 +233,45 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
}
/*******************************************************************************
** One standard group per subject, with real counts or an unavailable explanation.
** One standard group per subject the viewer can read; unreadable subjects are left out.
*******************************************************************************/
public static MultiStatisticsData statistics(Instant now) throws QException
{
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>();
var saved = group("Saved analyses", DataAnalysisTables.SAVED);
if(canRead(DataAnalysisTables.SAVED))
{
var saved = group("Saved analyses");
List<SavedAnalyses.Entry> entries = DataAnalysisRuntime.saved().list();
String user = SavedAnalyses.user();
saved.withStatisticList(List.of(stat("Mine", (int) entries.stream().filter(e -> e.owner().equals(user)).count(), DataAnalysisTables.SAVED),
stat("Shared", (int) entries.stream().filter(e -> Boolean.TRUE.equals(e.shared())).count(), DataAnalysisTables.SAVED),
stat("Starred", (int) entries.stream().filter(SavedAnalyses.Entry::starred).count(), DataAnalysisTables.SAVED)));
groups.add(saved);
}
groups.add(saved);
var watches = group("Watches", DataAnalysisTables.WATCH);
if(canRead(DataAnalysisTables.WATCH))
{
var watches = group("Watches");
watches.withStatisticList(List.of(stat("Enabled", count(DataAnalysisTables.WATCH, equal("enabled", true)), DataAnalysisTables.WATCH),
stat("Firing", count(DataAnalysisTables.WATCH, equal("state", "FIRING")), DataAnalysisTables.WATCH)));
groups.add(watches);
}
groups.add(watches);
var runs = group("My runs", DataAnalysisTables.RUN);
if(canRead(DataAnalysisTables.RUN))
{
var runs = group("My runs");
runs.withStatisticList(List.of(stat("Last 7 days", count(DataAnalysisTables.RUN, equal("user_id", SavedAnalyses.user())
.withCriteria("started_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
.withCriteria("started_at", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)), DataAnalysisTables.RUN)));
groups.add(runs);
}
groups.add(runs);
return (new MultiStatisticsData("Analyses", groups));
}
/*******************************************************************************
** Empty/denied data are different messages, not synthetic zeroes.
** A group is only built for a readable subject, so its subheader is always true.
*******************************************************************************/
private static MultiStatisticsData.StatisticsGroupData group(String label, String table)
private static MultiStatisticsData.StatisticsGroupData group(String label)
{
return (new MultiStatisticsData.StatisticsGroupData().withHeader(label).withStatisticList(List.of())
.withSubheader(canRead(table) ? "Visible to you" : "Unavailable: READ permission required."));
return (new MultiStatisticsData.StatisticsGroupData().withHeader(label).withStatisticList(List.of()).withSubheader("Visible to you"));
}
/*******************************************************************************
@@ -257,7 +292,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
{
columns.add(new TableData.Column(i == 0 ? "html" : "text", headers[i], "c" + i, null, "left"));
}
return (new TableData(label, columns, new ArrayList<>()).withNoRowsFoundHTML(canRead(table) ? empty : "Unavailable: READ permission required.")
return (new TableData(label, columns, new ArrayList<>()).withNoRowsFoundHTML(empty)
.withLinkText("View all").withLinkURL(QContext.getQInstance().getTablePath(table)).withRowsPerPage(10));
}
@@ -0,0 +1,88 @@
/*
* 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.dataanalysis.app;
import java.io.Serializable;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
/*******************************************************************************
** A widget is available only to sessions that can READ the table it summarizes.
** Contributes no catalog entry of its own: the table's READ permission is the
** permission.
*******************************************************************************/
public class TableReadWidgetPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
{
private static final String TABLE_NAME_PROPERTY = "tableName";
private String tableName;
/*******************************************************************************
** Code reference for the given table.
*******************************************************************************/
public static QCodeReferenceWithProperties build(String tableName)
{
return (new QCodeReferenceWithProperties(TableReadWidgetPermissionChecker.class, MapBuilder.of(TABLE_NAME_PROPERTY, tableName)));
}
/*******************************************************************************
** Read the table name from the code reference properties.
*******************************************************************************/
@Override
public void initialize(QCodeReference codeReference)
{
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
{
Map<String, Serializable> properties = withProperties.getProperties();
tableName = ValueUtils.getValueAsString(properties.get(TABLE_NAME_PROPERTY));
}
}
/*******************************************************************************
** Deny unless the session may READ the table.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
{
AbstractTableActionInput tableInput = new AbstractTableActionInput();
tableInput.setTableName(tableName);
PermissionsHelper.checkTablePermissionThrowing(tableInput, TablePermissionSubType.READ);
}
}
@@ -25,6 +25,9 @@ import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
@@ -69,12 +72,43 @@ class DataAnalysisHomeTest
}
}
QContext.init(instance, TestSupport.user("alice"));
assertThat(DataAnalysisHome.statistics(Instant.now()).getStatisticsGroupData()).allSatisfy(g ->
assertThat(DataAnalysisHome.statistics(Instant.now()).getStatisticsGroupData()).isEmpty();
assertThat(((TableData) DataAnalysisHome.render(DataAnalysisHome.FIRING, Instant.now())).getNoRowsFoundHTML()).doesNotContain("Unavailable");
}
@Test
void setupIsOffTheBusinessHomeAndAdminOnly() throws Exception
{
var instance = TestSupport.instance();
assertThat(DataAnalysisHome.homeWidgetNames()).doesNotContain(DataAnalysisHome.SETUP).containsExactly(DataAnalysisHome.HOME_WIDGET,
DataAnalysisHome.STARRED, DataAnalysisHome.FIRING, DataAnalysisHome.RECENT);
assertThat(instance.getApp("dataAnalysis").getWidgets()).doesNotContain(DataAnalysisHome.SETUP);
assertThat(DataAnalysisQBitProducer.SYSTEM_WIDGETS).containsExactly(DataAnalysisHome.SETUP);
assertThat(instance.getWidgets()).containsKey(DataAnalysisHome.SETUP);
QContext.init(instance, TestSupport.admin());
assertThat(PermissionsHelper.hasWidgetPermission(new AbstractActionInput(), DataAnalysisHome.SETUP)).isTrue();
assertThat(DataAnalysisHome.setup().getRows()).hasSize(5);
assertThat(new DataAnalysisHome().render(new RenderWidgetInput().withWidgetMetaData(instance.getWidget(DataAnalysisHome.SETUP))).getWidgetData()).isInstanceOf(TableData.class);
QContext.init(instance, TestSupport.user("alice").withPermissions(DataAnalysisPermissions.SAVED_READ));
assertThat(PermissionsHelper.hasWidgetPermission(new AbstractActionInput(), DataAnalysisHome.SETUP)).isFalse();
}
@Test
void savedReadOnlyUserGetsNoLockedShells() throws Exception
{
var instance = TestSupport.instance();
QContext.init(instance, TestSupport.user("alice").withPermissions(DataAnalysisPermissions.SAVED_READ));
List<String> visible = DataAnalysisHome.homeWidgetNames().stream().filter(name -> PermissionsHelper.hasWidgetPermission(new AbstractActionInput(), name)).toList();
assertThat(visible).containsExactly(DataAnalysisHome.HOME_WIDGET, DataAnalysisHome.STARRED);
for(String name : visible)
{
assertThat(g.getStatisticList()).isEmpty();
assertThat(g.getSubheader()).contains("Unavailable");
});
assertThat(((TableData) DataAnalysisHome.render(DataAnalysisHome.FIRING, Instant.now())).getNoRowsFoundHTML()).contains("Unavailable");
var data = DataAnalysisHome.render(name, Instant.now());
assertThat(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(data)).doesNotContain("Unavailable").doesNotContain("permission required");
}
var groups = DataAnalysisHome.statistics(Instant.now()).getStatisticsGroupData();
assertThat(groups).extracting(g -> g.getHeader()).containsExactly("Saved analyses");
}
@Test