feat(help): guided empty states for every authentik table #33

Merged
KofTwentyTwo merged 2 commits from feature/tables-populated-defaults into develop 2026-10-14 22:31:48 +00:00
7 changed files with 487 additions and 0 deletions

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied by the new `AuthentikHelp` stand-in (`STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.7)`), which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes.
- `AuthentikQBitConfig.fullAccessHostPermissions` lets a host declare permission names core cannot enumerate (custom checkers, named permissions); full access expands to them at use time, for the instance holding that runtime's backend only.
- `AuthentikMaintenance.applyPolicy` lets reviewed host run-once jobs apply a policy to an exact group name through the existing audited Save Policy path. Dedicated maintenance identities, full-access expansion, canonical no-ops, context restoration, stale checks and optional two-person approval are covered; system-session writes remain forbidden. No HTTP route or process is registered.
+9
View File
@@ -45,6 +45,15 @@ Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill s
| App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report |
| Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group |
### Tables, starter data and empty screens
Nothing is seeded: there are no starter defaults to include in a changelog. Setup comes from `AuthentikQBitConfig` (URL, API token, features, read-only mode), and the Identity home's "Needs attention" card lists what is left. The tables are of two kinds:
- **API-backed (live):** `authentik_user`, `_group`, `_group_member` (virtual), `_session`, `_event`, `_application`, `_policy_binding`, `_blueprint`, `_role`, `_token`, `_invitation`. Every row is read from Authentik on each view and nothing is stored locally. An empty list means Authentik returned nothing to you: your role lacks the permission, the qbit's feature or invitation flow is not enabled, or Authentik has no such records. Read-only mode only blocks changes.
- **Local (the qbit's own records):** `authentik_admin_action` (audit trail), `authentik_approval_request`, `authentik_policy` and `authentik_token_scope`. They fill as people run actions, and are written only by the qbit.
Every table, and the app, ships help (`help/qbit-authentik.yaml`, the QQQ help-bundle format) that says what the table is for, how its rows get created and, for the live tables, what an empty list means. The Next UI shows it as the empty state. A host's own help on a table is never replaced, and a host's `tableNamePrefix` is applied to the bundle's table names. `AuthentikTablesGuardrailTest` fails when a table is added without runtime use, a documented reason it starts empty, and this help.
### Permissions
QQQ permissions, held as `<name>.hasAccess`, granted through the group attribute `qqq.io/permissions` and read by `AuthentikGroupPermissionMapper`:
+4
View File
@@ -1,5 +1,9 @@
# Upgrading qbit-authentik
## Table help bundle (unreleased)
Nothing to do. The qbit now applies `help/qbit-authentik.yaml` to the Identity app and its tables at the end of `produce()`, filling only help slots the host has not set. There is no schema change and nothing to seed or include in a Liquibase changelog: the API-backed tables are live Authentik data, and the four local tables fill as people use the actions.
## QQQ 4.1.0-RC.6 baseline (#20 / F19)
Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.6`
@@ -25,6 +25,7 @@ import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.app.AuthentikRelations;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
import com.kingsrook.qbits.authentik.processes.AuthentikProcesses;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -190,6 +191,7 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
{
qInstance.addProcess(process);
}
AuthentikHelp.apply(qInstance, qBitConfig);
LOG.info("Authentik QBit produced", logPair("version", VERSION), logPair("appKey", qBitConfig.getAppKey()),
logPair("features", qBitConfig.getEffectiveFeatures()), logPair("readOnly", qBitConfig.getReadOnly()));
@@ -0,0 +1,141 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.authentik.metadata;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-authentik.yaml (the QQQ help-bundle format) to the Identity
** app and its authentik_* tables: what each table is for and how its rows get
** created (for the API-backed tables: that they are read live, and what an
** empty list means), shown on the table screen and when it is empty.
**
** The bundle's keys use the default table prefix; a host's own tableNamePrefix
** is applied to them, so renamed tables get the same text.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.7): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
*******************************************************************************/
public final class AuthentikHelp
{
public static final String RESOURCE = "/help/qbit-authentik.yaml";
public static final String SLOT = "help";
private static final String DEFAULT_TABLE_PREFIX = "authentik_";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AuthentikHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = AuthentikHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-authentik".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every table the qbit produced. A host's own
** help on a table or the app is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance qInstance, AuthentikQBitConfig config) throws QException
{
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
{
String key = entry.getKey();
if(key.startsWith("table:"))
{
String tableKey = key.substring("table:".length(), key.indexOf(';'));
String unprefixed = tableKey.substring(DEFAULT_TABLE_PREFIX.length());
QTableMetaData table = qInstance.getTable(config.applyTablePrefix(unprefixed));
if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry.getValue()));
}
}
else if(key.startsWith("app:"))
{
QAppMetaData app = qInstance.getApp(config.applyPrefix(AuthentikAppMetaDataProducer.APP_NAME));
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(entry.getValue()));
}
}
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
}
}
@@ -0,0 +1,93 @@
bundle: qbit-authentik
version: 1
layer: QBIT
# Help for the Identity app and every authentik_* table, in the QQQ help-bundle
# format (qqq#1198). Table keys use the default table prefix (authentik_); a host
# with a different tableNamePrefix gets the same text on its renamed tables.
# AuthentikHelp applies it until the core HelpBundles registry ships in a QQQ
# release (STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.7)). Summaries stay under 120 characters.
#
# Two kinds of table live here. The API-backed tables hold no rows of their own:
# every list is read live from Authentik, so an empty list means "Authentik returned
# nothing to you", never "set this up". The local tables are the qbit's own audit and
# bookkeeping records, written only by the qbit's actions.
entries:
- key: app:authentikAdmin;slot:help
summary: "Manage Authentik users, groups, access and tokens from this app; Authentik stays the source of truth."
content: |
Nothing here needs seeding. Setup comes from the qbit's configuration (Authentik URL, API token, features, read-only mode), and the home's "Needs attention" card lists what is left to do. The user, group, session, event, application, binding, blueprint, role, token and invitation tables are read live from Authentik and have no rows in your database. Four small tables are the qbit's own records: admin actions (the audit trail), approval requests, saved policies and token scopes. They fill as people use the actions.
- key: table:authentik_user;slot:help
summary: "Authentik users, read live from Authentik: people, service accounts and external users."
content: |
**How rows get created:** you do not add rows to this table directly. The list is read live from Authentik on every view, and users are created with the Create User, Create Service Account and Invite User actions (when those features are on), which call the Authentik API and write an audit row. An empty list means Authentik returned no users to you: your role lacks the Identity read permission, the qbit is not enabled or its API token cannot read users, or Authentik has none. Read-only mode only blocks changes, not this list.
- key: table:authentik_group;slot:help
summary: "Authentik groups, read live, with their parents, members and the QQQ permissions they grant."
content: |
**How rows get created:** read live from Authentik on every view; create a group with the Create Group action (when the groups feature is on) or in Authentik itself. A group's permissions are the QQQ permissions its attributes grant, edited with Edit Policy or Edit Permissions And App Access. An empty list means Authentik returned no groups to you: no permission, the qbit not enabled, or the API token cannot read groups.
- key: table:authentik_group_member;slot:help
summary: "Who belongs to which group, assembled live from Authentik; not a stored table."
content: |
**How rows get created:** this is a virtual table. Every row is computed live from the groups' member lists, and there is nothing to seed. Add or remove a member with Add To Group, Remove From Group, Add Members or Remove Members (when the membership feature is on). An empty list means the group or user you are looking at has no members, or you lack the read permission, or the qbit is not enabled.
- key: table:authentik_session;slot:help
summary: "Signed-in sessions, read live from Authentik. Needs the audit read permission."
content: |
**How rows get created:** Authentik creates a session when someone signs in; the qbit only shows them. End a user's sessions with the Revoke Sessions action on the user (when the sessions feature is on). An empty list means nobody is signed in, or you lack the audit read permission, or the qbit is not enabled.
- key: table:authentik_event;slot:help
summary: "Authentik's own event log, read live. Needs the audit read permission."
content: |
**How rows get created:** Authentik writes an event for every sign-in, change and failure; the qbit writes only one `custom_qqq_admin_action` event after each admin action, whose id is kept on the matching Admin Actions row. An empty list means no events match, or you lack the audit read permission, or the qbit is not enabled.
- key: table:authentik_application;slot:help
summary: "Authentik applications (what people sign in to), read live and read-only."
content: |
**How rows get created:** applications are defined in Authentik, usually by a blueprint; the qbit shows them and which groups may open them. An empty list means Authentik has no applications you can see, or you lack the read permission, or the qbit is not enabled.
- key: table:authentik_policy_binding;slot:help
summary: "Which groups, users or policies are bound to an application, read live from Authentik."
content: |
**How rows get created:** bindings are created in Authentik, on an application. The qbit shows them as a child list on the application and on the group. An empty list means that application has no binding, or you lack the read permission, or the qbit is not enabled.
- key: table:authentik_blueprint;slot:help
summary: "Authentik blueprints (configuration as code) and whether each last applied cleanly, read live."
content: |
**How rows get created:** blueprints are files or OCI sources that Authentik discovers; the qbit only shows their status. An empty list means Authentik has no blueprint instance, or you lack the read permission, or the qbit is not enabled.
- key: table:authentik_role;slot:help
summary: "Authentik RBAC roles, read live. Roles are different from groups: they carry Authentik permissions."
content: |
**How rows get created:** roles are created in Authentik; the qbit shows the custom ones (managed system roles are hidden). An empty list means Authentik has no custom roles, or you lack the read permission, or the qbit is not enabled.
- key: table:authentik_token;slot:help
summary: "Authentik API tokens, read live. Metadata only: the key itself is never shown or stored."
content: |
**How rows get created:** create a token with the Create Token action (when the tokens feature is on); the key is delivered once and never stored. What each token may use is kept in Token Scopes. An empty list means no tokens exist, or you lack the audit read or token manage permission, or the qbit is not enabled.
- key: table:authentik_invitation;slot:help
summary: "Pending enrolment invitations, read live. Only present when an invitation flow is configured."
content: |
**How rows get created:** the Invite User action creates one through Authentik's invitation stage; the table exists only when the host sets `invitationFlow`. Cancel Invitation withdraws one. An empty list means no invitation is pending, or you lack the read permission.
- key: table:authentik_admin_action;slot:help
summary: "Audit trail of every change the qbit attempts in Authentik, written before the call is made."
content: |
**How rows get created:** every user, group, membership, credential, session, token and policy action writes one row first (status Running), then moves it to Succeeded, Failed or Refused; a change that needs a second person shows Waiting for approval. Rows are never edited by hand or deleted, so a mistake can be traced. The Authentik Event Pk column is the id of the matching `custom_qqq_admin_action` event in Authentik's Events, for cross-reference. An empty table means no action has been attempted yet.
- key: table:authentik_approval_request;slot:help
summary: "Changes waiting for a second person: granting authentik.* permissions needs two people."
content: |
**How rows get created:** when an operator grants authentik.* permissions, the qbit stores the change here instead of applying it. A different person with the Approve permission uses Approve And Apply or Reject; requests nobody reviews expire. An empty table means nothing is waiting.
- key: table:authentik_policy;slot:help
summary: "The last saved policy document for each group, with who saved it and when."
content: |
**How rows get created:** the Edit Policy action writes one row per group when a policy is saved (the live document stays in Authentik). Field-level history of changes is kept. Only present when the policies feature is on. An empty table means no policy has been saved yet; open a group and choose Edit Policy.
- key: table:authentik_token_scope;slot:help
summary: "What each token may use: apps, tables, processes, reports, widgets and operations. No key material."
content: |
**How rows get created:** Create Token writes a scope row when you give the token a scope (token scopes enabled), and the expiry reminder job stamps when it last reminded. Only present when the tokens feature is on. An empty table means no scoped token has been created yet.
@@ -0,0 +1,237 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.authentik;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
** the qbit should be fully using them"). For every authentik_* table:
** - it is used at runtime: an API-backed table through its backend (every row
** is read live from Authentik by AuthentikActionUtil, so it has no rows of
** its own); a local table by runtime code that reads or writes it, not only
** its metadata or the home screen;
** - it has nothing to seed (setup comes from config; every table is live data
** or a log), and says why in STARTS_EMPTY;
** - it has bundle help that says what it is for and how its rows get created
** (shown on the table screen and when it is empty).
** A new table fails this test until it is wired, explained and documented.
*******************************************************************************/
class AuthentikTablesGuardrailTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "authentik");
/*******************************************************************************
** The API-backed tables: rows are read live from Authentik, never stored.
*******************************************************************************/
static final List<String> API_TABLES = List.of("authentik_user", "authentik_group", "authentik_group_member", "authentik_session", "authentik_event",
"authentik_application", "authentik_policy_binding", "authentik_blueprint", "authentik_role", "authentik_token", "authentik_invitation");
/*******************************************************************************
** The qbit's own tables, with the constant runtime code uses to reach them.
*******************************************************************************/
static final Map<String, String> LOCAL_TABLES = Map.of(
"authentik_admin_action", "AuthentikLocalTables.ADMIN_ACTION",
"authentik_approval_request", "AuthentikLocalTables.APPROVAL_REQUEST",
"authentik_policy", "AuthentikLocalTables.POLICY",
"authentik_token_scope", "AuthentikLocalTables.TOKEN_SCOPE");
/*******************************************************************************
** Why no table is seeded.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
Map.entry("authentik_user", "live Authentik data, no rows of its own; setup is the qbit config"),
Map.entry("authentik_group", "live Authentik data, no rows of its own"),
Map.entry("authentik_group_member", "virtual: computed live from group member lists"),
Map.entry("authentik_session", "live Authentik data: signed-in sessions"),
Map.entry("authentik_event", "live Authentik data: its event log"),
Map.entry("authentik_application", "live Authentik data, defined by blueprints in Authentik"),
Map.entry("authentik_policy_binding", "live Authentik data, defined on applications in Authentik"),
Map.entry("authentik_blueprint", "live Authentik data: blueprint status"),
Map.entry("authentik_role", "live Authentik data: RBAC roles"),
Map.entry("authentik_token", "live Authentik data: token metadata, never the key"),
Map.entry("authentik_invitation", "live Authentik data: pending invitations"),
Map.entry("authentik_admin_action", "log: one row per action a person runs"),
Map.entry("authentik_approval_request", "log: a second-person approval is requested"),
Map.entry("authentik_policy", "history: written when a person saves a group policy"),
Map.entry("authentik_token_scope", "history: written when a scoped token is created"));
/*******************************************************************************
** Every produced table is accounted for here, and the other way round.
*******************************************************************************/
@Test
void everyTableIsKnown() throws Exception
{
QInstance instance = support().qInstance;
List<String> produced = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("authentik_")).sorted().toList();
assertThat(produced).containsExactlyInAnyOrderElementsOf(STARTS_EMPTY.keySet());
assertThat(STARTS_EMPTY.keySet()).containsAll(API_TABLES).containsAll(LOCAL_TABLES.keySet());
assertThat(STARTS_EMPTY).hasSize(API_TABLES.size() + LOCAL_TABLES.size());
}
/*******************************************************************************
** Every table is used at runtime: an API table through its backend, a local
** table by non-metadata code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
AdminTestSupport support = support();
for(String table : API_TABLES)
{
QTableMetaData metaData = support.qInstance.getTable(table);
assertThat(metaData).as(table).isNotNull();
assertThat(metaData.getBackendName()).as(table + " is served by the Authentik API backend").isEqualTo(support.config.getApiBackendName());
assertThat(AuthentikTables.get(specName(table))).as(table + " has an API spec").isNotNull();
}
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")))
.filter(p -> !p.endsWith("AuthentikLocalTables.java"))
.map(AuthentikTablesGuardrailTest::read).toList();
}
for(Map.Entry<String, String> local : LOCAL_TABLES.entrySet())
{
assertThat(runtimeSource.stream().anyMatch(source -> source.contains(local.getValue())))
.as(local.getKey() + " (" + local.getValue() + ") must be read or written by runtime code, not only declared").isTrue();
assertThat(support.qInstance.getTable(local.getKey()).getBackendName()).isEqualTo(AdminTestSupport.LOCAL);
}
}
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created,
** and what an empty list means; the API-backed ones say rows are live.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
QInstance instance = support().qInstance;
assertThat(AuthentikHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
for(String table : STARTS_EMPTY.keySet())
{
List<QHelpContent> help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(AuthentikHelp.SLOT);
assertThat(help).as(table + " table help").isNotEmpty();
String text = help.get(0).getContent();
assertThat(text).as(table + " help says how rows get created").contains("How rows get created");
assertThat(text).as(table + " help says what empty means").containsAnyOf("An empty list means", "An empty table means");
if(API_TABLES.contains(table))
{
assertThat(text).as(table + " help says rows are live").containsAnyOf("live", "Live");
}
}
assertThat(instance.getApp("authentikAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT);
for(Map.Entry<String, JsonNode> entry : AuthentikHelp.entries().entrySet())
{
String key = entry.getKey();
assertThat(key.startsWith("app:") || STARTS_EMPTY.containsKey(key.substring(6, key.indexOf(';')))).as("help names only real tables: " + key).isTrue();
}
}
/*******************************************************************************
** A host with its own table prefix gets the same help on the renamed tables,
** and a host's own help is never replaced.
*******************************************************************************/
@Test
void helpFollowsTheHostPrefixAndKeepsHostHelp() throws Exception
{
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withTableNamePrefix("idp");
QInstance idp = AuthentikTestSupport.produce(config);
assertThat(idp.getTable("idp_user").getHelpContent().get(AuthentikHelp.SLOT).get(0).getContent()).contains("How rows get created");
assertThat(idp.getApp("idpAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT);
QInstance hosted = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig());
hosted.getTable("authentik_user").getHelpContent().put(AuthentikHelp.SLOT, List.of(new QHelpContent().withContentAsMarkdown("host text")));
AuthentikHelp.apply(hosted, AuthentikTestSupport.unitConfig());
assertThat(hosted.getTable("authentik_user").getHelpContent().get(AuthentikHelp.SLOT)).hasSize(1);
assertThat(hosted.getTable("authentik_user").getHelpContent().get(AuthentikHelp.SLOT).get(0).getContent()).isEqualTo("host text");
}
/*******************************************************************************
** Every feature on, local tables on a memory backend, an invitation flow set.
*******************************************************************************/
private static AdminTestSupport support() throws Exception
{
return (AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS, AuthentikFeature.POLICIES, AuthentikFeature.USERS,
AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS)));
}
/*******************************************************************************
** "authentik_group_member" -> "groupMember" (the spec's own name).
*******************************************************************************/
private static String specName(String table)
{
String name = table.substring("authentik_".length());
for(String spec : AuthentikTables.all().keySet())
{
if(AuthentikQBitConfig.toSnakeCase(spec).equals(name))
{
return (spec);
}
}
return (name);
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}