Files
qbit-authentik/src/test/java/com/kingsrook/qbits/authentik/AuthentikTablesGuardrailTest.java
T
james.maesandClaude Opus 5.5 5cf668f8e3 feat(help): ship the qbit-authentik help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:17:00 -05:00

233 lines
12 KiB
Java

/*
* 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.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);
}
/*******************************************************************************
** 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));
}
}
}