@cosmicdrift/kumiko-bundled-features 0.124.0 → 0.125.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -6
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +199 -0
- package/src/admin-shell/__tests__/admin-shell.boot.test.ts +131 -0
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +33 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +33 -0
- package/src/admin-shell/constants.ts +11 -0
- package/src/admin-shell/feature.ts +134 -0
- package/src/admin-shell/i18n.ts +35 -0
- package/src/admin-shell/index.ts +17 -0
- package/src/admin-shell/overview-allowlist.ts +32 -0
- package/src/admin-shell/web/client-plugin.tsx +26 -0
- package/src/admin-shell/web/i18n.ts +16 -0
- package/src/admin-shell/web/index.ts +2 -0
- package/src/admin-shell/web/overview-layout.tsx +68 -0
- package/src/admin-shell/web/overview-query.ts +15 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +75 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +90 -0
- package/src/audit/__tests__/audit-screens.boot.test.ts +29 -0
- package/src/audit/__tests__/audit-security.integration.test.ts +151 -0
- package/src/audit/__tests__/audit.integration.test.ts +0 -4
- package/src/audit/constants.ts +5 -2
- package/src/audit/feature.ts +24 -1
- package/src/audit/handlers/list.query.ts +2 -2
- package/src/audit/i18n.ts +18 -0
- package/src/audit/web/audit-log-screen.tsx +136 -0
- package/src/audit/web/client-plugin.tsx +20 -0
- package/src/audit/web/i18n.ts +12 -0
- package/src/audit/web/index.ts +4 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +2 -2
- package/src/auth-email-password/web/use-shell-user.ts +5 -2
- package/src/cap-counter/__tests__/cap-list.boot.test.ts +27 -0
- package/src/cap-counter/__tests__/cap-list.security.integration.test.ts +53 -0
- package/src/cap-counter/constants.ts +2 -0
- package/src/cap-counter/feature.ts +20 -1
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +28 -0
- package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +91 -0
- package/src/compliance-profiles/constants.ts +15 -0
- package/src/compliance-profiles/feature.ts +19 -1
- package/src/compliance-profiles/handlers/set-profile.write.ts +2 -2
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/web/client-plugin.tsx +22 -0
- package/src/compliance-profiles/web/compliance-profile-screen.tsx +152 -0
- package/src/compliance-profiles/web/i18n.ts +25 -0
- package/src/compliance-profiles/web/index.ts +6 -0
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +27 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +106 -0
- package/src/delivery/constants.ts +13 -51
- package/src/delivery/feature.ts +19 -2
- package/src/delivery/handlers/log.query.ts +14 -6
- package/src/delivery/index.ts +1 -0
- package/src/delivery/public-names.ts +36 -0
- package/src/delivery/web/client-plugin.tsx +20 -0
- package/src/delivery/web/delivery-log-screen.tsx +103 -0
- package/src/delivery/web/i18n.ts +25 -0
- package/src/delivery/web/index.ts +3 -0
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +31 -0
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +69 -0
- package/src/feature-toggles/constants.ts +14 -0
- package/src/feature-toggles/feature.ts +14 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +1 -1
- package/src/feature-toggles/index.ts +6 -0
- package/src/feature-toggles/web/client-plugin.tsx +22 -0
- package/src/feature-toggles/web/i18n.ts +29 -0
- package/src/feature-toggles/web/index.ts +6 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +136 -0
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +36 -0
- package/src/jobs/__tests__/jobs-security.integration.test.ts +108 -0
- package/src/jobs/constants.ts +8 -1
- package/src/jobs/feature.ts +26 -0
- package/src/jobs/i18n.ts +32 -0
- package/src/jobs/web/client-plugin.tsx +22 -0
- package/src/jobs/web/i18n.ts +12 -0
- package/src/jobs/web/index.ts +4 -0
- package/src/jobs/web/job-run-detail-screen.tsx +169 -0
- package/src/jobs/web/job-runs-screen.tsx +148 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +44 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +295 -0
- package/src/tenant/constants.ts +5 -0
- package/src/tenant/feature.ts +16 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +2 -2
- package/src/tenant/handlers/invitations.query.ts +2 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/web/client-plugin.tsx +20 -0
- package/src/tenant/web/i18n.ts +43 -0
- package/src/tenant/web/index.ts +4 -0
- package/src/tenant/web/members-screen.tsx +265 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +7 -6
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Single job-run detail + logs. Route entityId = run uuid.
|
|
3
|
+
|
|
4
|
+
import { useDispatcher, useNav, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
6
|
+
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
7
|
+
import { JOB_RUNS_SCREEN_ID, JobHandlers, JobQueries } from "../constants";
|
|
8
|
+
|
|
9
|
+
type LogRow = {
|
|
10
|
+
readonly level: string;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
readonly timestamp: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type JobRunDetail = {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly jobName: string;
|
|
18
|
+
readonly status: string;
|
|
19
|
+
readonly startedAt: string;
|
|
20
|
+
readonly finishedAt?: string | null;
|
|
21
|
+
readonly duration?: number | null;
|
|
22
|
+
readonly error?: string | null;
|
|
23
|
+
readonly logs: readonly LogRow[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type State =
|
|
27
|
+
| { readonly kind: "loading" }
|
|
28
|
+
| { readonly kind: "missing" }
|
|
29
|
+
| { readonly kind: "error"; readonly message: string }
|
|
30
|
+
| { readonly kind: "ready"; readonly run: JobRunDetail };
|
|
31
|
+
|
|
32
|
+
export function JobRunDetailScreen(): ReactNode {
|
|
33
|
+
const t = useTranslation();
|
|
34
|
+
const { Banner, Button, Card, Heading, Text } = usePrimitives();
|
|
35
|
+
const dispatcher = useDispatcher();
|
|
36
|
+
const nav = useNav();
|
|
37
|
+
const runId = nav.route?.entityId;
|
|
38
|
+
const [state, setState] = useState<State>({ kind: "loading" });
|
|
39
|
+
const [retrying, setRetrying] = useState(false);
|
|
40
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
41
|
+
|
|
42
|
+
const load = useCallback(async (): Promise<void> => {
|
|
43
|
+
if (runId === undefined || runId === "") {
|
|
44
|
+
setState({ kind: "missing" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
setState({ kind: "loading" });
|
|
48
|
+
const res = await dispatcher.query<JobRunDetail | null>(JobQueries.details, { runId });
|
|
49
|
+
if (!res.isSuccess) {
|
|
50
|
+
setState({ kind: "error", message: res.error.message });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (res.data === null) {
|
|
54
|
+
setState({ kind: "missing" });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setState({ kind: "ready", run: res.data });
|
|
58
|
+
}, [dispatcher, runId]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
void load();
|
|
62
|
+
}, [load]);
|
|
63
|
+
|
|
64
|
+
const onRetry = async (): Promise<void> => {
|
|
65
|
+
if (runId === undefined || runId === "") return;
|
|
66
|
+
setActionError(null);
|
|
67
|
+
setRetrying(true);
|
|
68
|
+
const res = await dispatcher.write(JobHandlers.retry, { runId });
|
|
69
|
+
setRetrying(false);
|
|
70
|
+
if (!res.isSuccess) {
|
|
71
|
+
setActionError(res.error.message);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
nav.navigate({ screenId: JOB_RUNS_SCREEN_ID });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (state.kind === "loading") {
|
|
78
|
+
return (
|
|
79
|
+
<FormScreenShell testId="job-run-detail-screen">
|
|
80
|
+
<Text variant="small">{t("jobs.detail.loading")}</Text>
|
|
81
|
+
</FormScreenShell>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (state.kind === "missing") {
|
|
86
|
+
return (
|
|
87
|
+
<FormScreenShell testId="job-run-detail-screen">
|
|
88
|
+
<Banner variant="error">{t("jobs.detail.missing")}</Banner>
|
|
89
|
+
</FormScreenShell>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (state.kind === "error") {
|
|
94
|
+
return (
|
|
95
|
+
<FormScreenShell testId="job-run-detail-screen">
|
|
96
|
+
<Banner variant="error">{state.message}</Banner>
|
|
97
|
+
</FormScreenShell>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { run } = state;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<FormScreenShell testId="job-run-detail-screen" className="flex flex-col gap-6">
|
|
105
|
+
<Button
|
|
106
|
+
type="button"
|
|
107
|
+
variant="secondary"
|
|
108
|
+
onClick={() => nav.navigate({ screenId: JOB_RUNS_SCREEN_ID })}
|
|
109
|
+
testId="job-run-back"
|
|
110
|
+
>
|
|
111
|
+
{t("jobs.detail.back")}
|
|
112
|
+
</Button>
|
|
113
|
+
|
|
114
|
+
<Heading variant="page">{t("jobs.detail.title")}</Heading>
|
|
115
|
+
|
|
116
|
+
<Card slots={{ title: run.jobName }}>
|
|
117
|
+
<dl className="grid gap-3 text-sm">
|
|
118
|
+
<div>
|
|
119
|
+
<dt className="font-medium">{t("jobs.detail.field.status")}</dt>
|
|
120
|
+
<dd data-testid="job-run-status">{run.status}</dd>
|
|
121
|
+
</div>
|
|
122
|
+
<div>
|
|
123
|
+
<dt className="font-medium">{t("jobs.detail.field.id")}</dt>
|
|
124
|
+
<dd>
|
|
125
|
+
<Text variant="code">{run.id}</Text>
|
|
126
|
+
</dd>
|
|
127
|
+
</div>
|
|
128
|
+
{run.error !== undefined && run.error !== null && run.error !== "" && (
|
|
129
|
+
<div>
|
|
130
|
+
<dt className="font-medium">{t("jobs.detail.field.error")}</dt>
|
|
131
|
+
<dd>
|
|
132
|
+
<Banner variant="error">{run.error}</Banner>
|
|
133
|
+
</dd>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</dl>
|
|
137
|
+
</Card>
|
|
138
|
+
|
|
139
|
+
{run.status === "failed" && (
|
|
140
|
+
<Button
|
|
141
|
+
type="button"
|
|
142
|
+
variant="primary"
|
|
143
|
+
disabled={retrying}
|
|
144
|
+
loading={retrying}
|
|
145
|
+
onClick={() => void onRetry()}
|
|
146
|
+
testId="job-run-retry"
|
|
147
|
+
>
|
|
148
|
+
{retrying ? t("jobs.detail.retrying") : t("jobs.detail.retry")}
|
|
149
|
+
</Button>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{actionError !== null && <Banner variant="error">{actionError}</Banner>}
|
|
153
|
+
|
|
154
|
+
<Card slots={{ title: t("jobs.detail.logs") }}>
|
|
155
|
+
{run.logs.length === 0 ? (
|
|
156
|
+
<Text variant="small">{t("jobs.detail.logs.empty")}</Text>
|
|
157
|
+
) : (
|
|
158
|
+
<ul className="flex flex-col gap-1 font-mono text-sm" data-testid="job-run-logs">
|
|
159
|
+
{run.logs.map((log) => (
|
|
160
|
+
<li key={`${log.timestamp}-${log.level}-${log.message}`}>
|
|
161
|
+
<span className="text-muted-foreground">[{log.level}]</span> {log.message}
|
|
162
|
+
</li>
|
|
163
|
+
))}
|
|
164
|
+
</ul>
|
|
165
|
+
)}
|
|
166
|
+
</Card>
|
|
167
|
+
</FormScreenShell>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// SystemAdmin job-run list with link to detail screen.
|
|
3
|
+
|
|
4
|
+
import { useDispatcher, useNav, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
6
|
+
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
7
|
+
import { JOB_RUN_DETAIL_SCREEN_ID, JobQueries } from "../constants";
|
|
8
|
+
|
|
9
|
+
type JobRunRow = {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly jobName: string;
|
|
12
|
+
readonly status: string;
|
|
13
|
+
readonly startedAt: string;
|
|
14
|
+
readonly finishedAt?: string | null;
|
|
15
|
+
readonly duration?: number | null;
|
|
16
|
+
readonly error?: string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ListResponse = { readonly rows: readonly JobRunRow[] };
|
|
20
|
+
|
|
21
|
+
type State =
|
|
22
|
+
| { readonly kind: "loading" }
|
|
23
|
+
| { readonly kind: "error"; readonly message: string }
|
|
24
|
+
| { readonly kind: "ready"; readonly rows: readonly JobRunRow[] };
|
|
25
|
+
|
|
26
|
+
const STATUS_FILTER_OPTIONS = [
|
|
27
|
+
{ value: "", label: "all" },
|
|
28
|
+
{ value: "completed", label: "completed" },
|
|
29
|
+
{ value: "failed", label: "failed" },
|
|
30
|
+
{ value: "running", label: "running" },
|
|
31
|
+
{ value: "queued", label: "queued" },
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export function JobRunsScreen(): ReactNode {
|
|
35
|
+
const t = useTranslation();
|
|
36
|
+
const { Banner, Card, DataTable, Field, Heading, Input, Text } = usePrimitives();
|
|
37
|
+
const dispatcher = useDispatcher();
|
|
38
|
+
const nav = useNav();
|
|
39
|
+
const [state, setState] = useState<State>({ kind: "loading" });
|
|
40
|
+
const [statusFilter, setStatusFilter] = useState("");
|
|
41
|
+
|
|
42
|
+
const filterOptions = STATUS_FILTER_OPTIONS.map((opt) => ({
|
|
43
|
+
value: opt.value,
|
|
44
|
+
label: opt.value === "" ? t("jobs.runs.filter.all") : opt.label,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const refresh = useCallback(async (): Promise<void> => {
|
|
48
|
+
setState({ kind: "loading" });
|
|
49
|
+
const res = await dispatcher.query<ListResponse>(JobQueries.list, {
|
|
50
|
+
limit: 50,
|
|
51
|
+
...(statusFilter !== "" && {
|
|
52
|
+
status: statusFilter as "queued" | "running" | "completed" | "failed",
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
if (!res.isSuccess) {
|
|
56
|
+
setState({ kind: "error", message: res.error.message });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setState({ kind: "ready", rows: res.data.rows });
|
|
60
|
+
}, [dispatcher, statusFilter]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
void refresh();
|
|
64
|
+
}, [refresh]);
|
|
65
|
+
|
|
66
|
+
if (state.kind === "loading") {
|
|
67
|
+
return (
|
|
68
|
+
<FormScreenShell testId="job-runs-screen">
|
|
69
|
+
<Text variant="small">{t("jobs.runs.loading")}</Text>
|
|
70
|
+
</FormScreenShell>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (state.kind === "error") {
|
|
75
|
+
return (
|
|
76
|
+
<FormScreenShell testId="job-runs-screen">
|
|
77
|
+
<Banner variant="error">{state.message}</Banner>
|
|
78
|
+
</FormScreenShell>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<FormScreenShell testId="job-runs-screen" className="flex max-w-5xl flex-col gap-6">
|
|
84
|
+
<Heading variant="page">{t("jobs.runs.title")}</Heading>
|
|
85
|
+
|
|
86
|
+
<Field id="job-runs-status-filter" label={t("jobs.runs.filter.status")}>
|
|
87
|
+
<Input
|
|
88
|
+
kind="select"
|
|
89
|
+
id="job-runs-status-filter"
|
|
90
|
+
name="job-runs-status-filter"
|
|
91
|
+
value={statusFilter}
|
|
92
|
+
onChange={setStatusFilter}
|
|
93
|
+
options={filterOptions}
|
|
94
|
+
/>
|
|
95
|
+
</Field>
|
|
96
|
+
|
|
97
|
+
<Card options={{ padded: false }}>
|
|
98
|
+
<DataTable
|
|
99
|
+
testId="job-runs-table"
|
|
100
|
+
columns={[
|
|
101
|
+
{ field: "job", label: t("jobs.runs.col.job"), type: "string", sortable: false },
|
|
102
|
+
{ field: "status", label: t("jobs.runs.col.status"), type: "string", sortable: false },
|
|
103
|
+
{
|
|
104
|
+
field: "started",
|
|
105
|
+
label: t("jobs.runs.col.started"),
|
|
106
|
+
type: "string",
|
|
107
|
+
sortable: false,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
field: "duration",
|
|
111
|
+
label: t("jobs.runs.col.duration"),
|
|
112
|
+
type: "string",
|
|
113
|
+
sortable: false,
|
|
114
|
+
},
|
|
115
|
+
]}
|
|
116
|
+
rows={state.rows.map((row) => ({
|
|
117
|
+
id: row.id,
|
|
118
|
+
values: {
|
|
119
|
+
job: row.jobName,
|
|
120
|
+
status: row.status,
|
|
121
|
+
started: formatWhen(row.startedAt),
|
|
122
|
+
duration: row.duration ?? "—",
|
|
123
|
+
},
|
|
124
|
+
}))}
|
|
125
|
+
rowActions={[
|
|
126
|
+
{
|
|
127
|
+
id: "open",
|
|
128
|
+
label: t("jobs.runs.open"),
|
|
129
|
+
style: "secondary",
|
|
130
|
+
onTrigger: (row) =>
|
|
131
|
+
nav.navigate({ screenId: JOB_RUN_DETAIL_SCREEN_ID, entityId: row.id }),
|
|
132
|
+
},
|
|
133
|
+
]}
|
|
134
|
+
rowActionMode="inline"
|
|
135
|
+
emptyState={<Text variant="small">{t("jobs.runs.empty")}</Text>}
|
|
136
|
+
/>
|
|
137
|
+
</Card>
|
|
138
|
+
</FormScreenShell>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatWhen(value: string): string {
|
|
143
|
+
try {
|
|
144
|
+
return new Date(value).toLocaleString();
|
|
145
|
+
} catch {
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
5
|
+
import { createConfigFeature } from "../../config/feature";
|
|
6
|
+
import { MEMBERS_SCREEN_ID, TenantHandlers, TenantQueries } from "../constants";
|
|
7
|
+
import { createTenantFeature } from "../feature";
|
|
8
|
+
|
|
9
|
+
describe("tenant members screen + handler access alignment", () => {
|
|
10
|
+
const features = [createConfigFeature(), createTenantFeature()];
|
|
11
|
+
|
|
12
|
+
test("boot-validates with members screen registered", () => {
|
|
13
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("members screen is custom, access.admin-gated", () => {
|
|
17
|
+
const tenant = createTenantFeature();
|
|
18
|
+
const screen = tenant.screens[MEMBERS_SCREEN_ID];
|
|
19
|
+
expect(screen?.type).toBe("custom");
|
|
20
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
21
|
+
expect(screen.access.roles).toEqual(access.admin);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("members UI handlers share access.admin (screen ⊆ handler)", () => {
|
|
26
|
+
const tenant = createTenantFeature();
|
|
27
|
+
const adminRoles = [...access.admin];
|
|
28
|
+
expect(rolesOf(tenant.queryHandlers["members"]?.access)).toEqual(adminRoles);
|
|
29
|
+
expect(rolesOf(tenant.queryHandlers["invitations"]?.access)).toEqual(adminRoles);
|
|
30
|
+
expect(rolesOf(tenant.writeHandlers["cancel-invitation"]?.access)).toEqual(adminRoles);
|
|
31
|
+
// invite-create lives on auth feature — checked in tenant-security.integration.test.ts
|
|
32
|
+
void AuthHandlers;
|
|
33
|
+
void TenantHandlers;
|
|
34
|
+
void TenantQueries;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("updateMemberRoles stays SystemAdmin/system-only (not on members screen)", () => {
|
|
38
|
+
const tenant = createTenantFeature();
|
|
39
|
+
expect(rolesOf(tenant.writeHandlers["updateMemberRoles"]?.access)).toEqual([
|
|
40
|
+
"system",
|
|
41
|
+
"SystemAdmin",
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Security integration tests for tenant-admin surfaces (members + invite/cancel).
|
|
2
|
+
// Real HTTP via setupTestStack — no mocks. Proves access.admin alignment and
|
|
3
|
+
// tenant isolation before the MembersScreen ships to apps.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
|
+
import { access, type SessionUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import {
|
|
9
|
+
setupTestStack,
|
|
10
|
+
type TestStack,
|
|
11
|
+
unsafeCreateEntityTable,
|
|
12
|
+
unsafePushTables,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { expectErrorIncludes, rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
|
+
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
16
|
+
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
17
|
+
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
18
|
+
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
19
|
+
import { createConfigFeature } from "../../config";
|
|
20
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
21
|
+
import { configValuesTable } from "../../config/table";
|
|
22
|
+
import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
|
|
23
|
+
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
24
|
+
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
25
|
+
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
26
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
27
|
+
import { createUserFeature } from "../../user/feature";
|
|
28
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
29
|
+
import { seedUser } from "../../user/seeding";
|
|
30
|
+
import { MEMBERS_SCREEN_ID, TenantHandlers, TenantQueries } from "../constants";
|
|
31
|
+
import { createTenantFeature } from "../feature";
|
|
32
|
+
import { tenantInvitationEntity, tenantInvitationsTable } from "../invitation-table";
|
|
33
|
+
import { tenantMembershipsTable } from "../membership-table";
|
|
34
|
+
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
35
|
+
import { seedTenant, seedTenantMembership } from "../seeding";
|
|
36
|
+
|
|
37
|
+
const emailTransport = createInMemoryTransport();
|
|
38
|
+
const APP_ACCEPT_URL = "https://app.example.com/invite/accept";
|
|
39
|
+
const FORBIDDEN_ROLES = ["SystemAdmin", "system", "all", "anonymous"] as const;
|
|
40
|
+
|
|
41
|
+
let stack: TestStack;
|
|
42
|
+
let TENANT_A_ID: TenantId;
|
|
43
|
+
let TENANT_B_ID: TenantId;
|
|
44
|
+
let tenantAdminAId: string;
|
|
45
|
+
let regularUserBId: string;
|
|
46
|
+
|
|
47
|
+
function newTenantId(): TenantId {
|
|
48
|
+
return crypto.randomUUID() as TenantId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tenantAdminA(): SessionUser {
|
|
52
|
+
return { id: tenantAdminAId, tenantId: TENANT_A_ID, roles: ["TenantAdmin"] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function regularUserB(): SessionUser {
|
|
56
|
+
return { id: regularUserBId, tenantId: TENANT_B_ID, roles: ["User"] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
stack = await setupTestStack({
|
|
61
|
+
features: [
|
|
62
|
+
createConfigFeature(),
|
|
63
|
+
createUserFeature(),
|
|
64
|
+
createTenantFeature(),
|
|
65
|
+
createTemplateResolverFeature(),
|
|
66
|
+
createRendererFoundationFeature(),
|
|
67
|
+
createDeliveryFeature(),
|
|
68
|
+
createRendererSimpleFeature(),
|
|
69
|
+
createChannelEmailFeature({
|
|
70
|
+
transport: emailTransport,
|
|
71
|
+
renderer: simpleRenderer,
|
|
72
|
+
resolveEmail: async () => "unused@test.local",
|
|
73
|
+
}),
|
|
74
|
+
createAuthEmailPasswordFeature({
|
|
75
|
+
invite: { tokenTtlMinutes: 60, appUrl: APP_ACCEPT_URL },
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
extraContext: (deps) => ({
|
|
79
|
+
...createDeliveryTestContext(deps),
|
|
80
|
+
configResolver: createConfigResolver(),
|
|
81
|
+
}),
|
|
82
|
+
authConfig: {
|
|
83
|
+
membershipQuery: "tenant:query:memberships",
|
|
84
|
+
loginHandler: AuthHandlers.login,
|
|
85
|
+
invite: {
|
|
86
|
+
acceptHandler: AuthHandlers.inviteAccept,
|
|
87
|
+
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
88
|
+
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
94
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
95
|
+
await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
|
|
96
|
+
await unsafePushTables(stack.db, {
|
|
97
|
+
configValuesTable,
|
|
98
|
+
tenantMembershipsTable,
|
|
99
|
+
notificationPreferencesTable,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterAll(async () => {
|
|
104
|
+
await stack.cleanup();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
beforeEach(async () => {
|
|
108
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
109
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
110
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
|
|
111
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
112
|
+
emailTransport.sent.length = 0;
|
|
113
|
+
const keys = await stack.redis.redis.keys("invite:*");
|
|
114
|
+
if (keys.length > 0) await stack.redis.redis.del(...keys);
|
|
115
|
+
|
|
116
|
+
TENANT_A_ID = newTenantId();
|
|
117
|
+
TENANT_B_ID = newTenantId();
|
|
118
|
+
await seedTenant(stack.db, {
|
|
119
|
+
id: TENANT_A_ID,
|
|
120
|
+
key: `tenant-a-${TENANT_A_ID.slice(0, 8)}`,
|
|
121
|
+
name: "Tenant A",
|
|
122
|
+
});
|
|
123
|
+
await seedTenant(stack.db, {
|
|
124
|
+
id: TENANT_B_ID,
|
|
125
|
+
key: `tenant-b-${TENANT_B_ID.slice(0, 8)}`,
|
|
126
|
+
name: "Tenant B",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
({ id: tenantAdminAId } = await seedUser(stack.db, {
|
|
130
|
+
email: "admin-a@example.com",
|
|
131
|
+
displayName: "Admin A",
|
|
132
|
+
passwordHash: await hashPassword("pw-a-1234"),
|
|
133
|
+
emailVerified: true,
|
|
134
|
+
}));
|
|
135
|
+
await seedTenantMembership(stack.db, {
|
|
136
|
+
userId: tenantAdminAId,
|
|
137
|
+
tenantId: TENANT_A_ID,
|
|
138
|
+
roles: ["TenantAdmin"],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
({ id: regularUserBId } = await seedUser(stack.db, {
|
|
142
|
+
email: "user-b@example.com",
|
|
143
|
+
displayName: "User B",
|
|
144
|
+
passwordHash: await hashPassword("pw-b-1234"),
|
|
145
|
+
emailVerified: true,
|
|
146
|
+
}));
|
|
147
|
+
await seedTenantMembership(stack.db, {
|
|
148
|
+
userId: regularUserBId,
|
|
149
|
+
tenantId: TENANT_B_ID,
|
|
150
|
+
roles: ["User"],
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("access matrix: members screen handlers use access.admin", () => {
|
|
155
|
+
test("invite-create, members, invitations, cancel-invitation share access.admin", () => {
|
|
156
|
+
const adminRoles = [...access.admin];
|
|
157
|
+
expect(rolesOf(stack.registry.getWriteHandler(AuthHandlers.inviteCreate)?.access)).toEqual(
|
|
158
|
+
adminRoles,
|
|
159
|
+
);
|
|
160
|
+
expect(rolesOf(stack.registry.getQueryHandler(TenantQueries.members)?.access)).toEqual(
|
|
161
|
+
adminRoles,
|
|
162
|
+
);
|
|
163
|
+
expect(rolesOf(stack.registry.getQueryHandler(TenantQueries.invitations)?.access)).toEqual(
|
|
164
|
+
adminRoles,
|
|
165
|
+
);
|
|
166
|
+
expect(
|
|
167
|
+
rolesOf(stack.registry.getWriteHandler(TenantHandlers.cancelInvitation)?.access),
|
|
168
|
+
).toEqual(adminRoles);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("members screen access matches access.admin", () => {
|
|
172
|
+
const tenant = createTenantFeature();
|
|
173
|
+
const screen = tenant.screens[MEMBERS_SCREEN_ID];
|
|
174
|
+
expect(screen && "access" in screen && screen.access && "roles" in screen.access).toBe(true);
|
|
175
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
176
|
+
expect(screen.access.roles).toEqual(access.admin);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("TenantAdmin can use members-admin HTTP surface", () => {
|
|
182
|
+
test("TenantAdmin invites → row persisted in own tenant", async () => {
|
|
183
|
+
const result = (await stack.http.writeOk(
|
|
184
|
+
AuthHandlers.inviteCreate,
|
|
185
|
+
{ email: "new@example.com", role: "User" },
|
|
186
|
+
tenantAdminA(),
|
|
187
|
+
)) as { tenantId: string; email: string };
|
|
188
|
+
expect(result.tenantId).toBe(TENANT_A_ID);
|
|
189
|
+
const rows = await selectMany(stack.db, tenantInvitationsTable, { email: "new@example.com" });
|
|
190
|
+
expect(rows).toHaveLength(1);
|
|
191
|
+
expect(rows[0]?.["tenantId"]).toBe(TENANT_A_ID);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("TenantAdmin lists members and pending invitations for own tenant only", async () => {
|
|
195
|
+
await stack.http.writeOk(
|
|
196
|
+
AuthHandlers.inviteCreate,
|
|
197
|
+
{ email: "pending@example.com", role: "Editor" },
|
|
198
|
+
tenantAdminA(),
|
|
199
|
+
);
|
|
200
|
+
const members = await stack.http.queryOk<readonly { userId: string }[]>(
|
|
201
|
+
TenantQueries.members,
|
|
202
|
+
{},
|
|
203
|
+
tenantAdminA(),
|
|
204
|
+
);
|
|
205
|
+
expect(members.some((m) => m.userId === tenantAdminAId)).toBe(true);
|
|
206
|
+
const invitations = await stack.http.queryOk<readonly { email: string }[]>(
|
|
207
|
+
TenantQueries.invitations,
|
|
208
|
+
{},
|
|
209
|
+
tenantAdminA(),
|
|
210
|
+
);
|
|
211
|
+
expect(invitations).toHaveLength(1);
|
|
212
|
+
expect(invitations[0]?.email).toBe("pending@example.com");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("regular User is denied members-admin surface", () => {
|
|
217
|
+
test("403 on members, invitations, invite-create, cancel-invitation", async () => {
|
|
218
|
+
for (const [label, fn] of [
|
|
219
|
+
["members", () => stack.http.query(TenantQueries.members, {}, regularUserB())],
|
|
220
|
+
["invitations", () => stack.http.query(TenantQueries.invitations, {}, regularUserB())],
|
|
221
|
+
[
|
|
222
|
+
"invite-create",
|
|
223
|
+
() =>
|
|
224
|
+
stack.http.write(
|
|
225
|
+
AuthHandlers.inviteCreate,
|
|
226
|
+
{ email: "x@y.com", role: "User" },
|
|
227
|
+
regularUserB(),
|
|
228
|
+
),
|
|
229
|
+
],
|
|
230
|
+
] as const) {
|
|
231
|
+
const res = await fn();
|
|
232
|
+
expect(res.status, label).toBe(403);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("privilege escalation via invite role", () => {
|
|
238
|
+
test("TenantAdmin cannot invite reserved/global roles", async () => {
|
|
239
|
+
for (const role of FORBIDDEN_ROLES) {
|
|
240
|
+
const err = await stack.http.writeErr(
|
|
241
|
+
AuthHandlers.inviteCreate,
|
|
242
|
+
{ email: "escalate@example.com", role },
|
|
243
|
+
tenantAdminA(),
|
|
244
|
+
);
|
|
245
|
+
expectErrorIncludes(err, "access_denied");
|
|
246
|
+
}
|
|
247
|
+
const rows = await selectMany(stack.db, tenantInvitationsTable, {
|
|
248
|
+
email: "escalate@example.com",
|
|
249
|
+
});
|
|
250
|
+
expect(rows).toHaveLength(0);
|
|
251
|
+
expect(emailTransport.sent).toHaveLength(0);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("tenant isolation on cancel-invitation", () => {
|
|
256
|
+
test("TenantAdmin cannot cancel invitation belonging to another tenant", async () => {
|
|
257
|
+
const { id: adminBId } = await seedUser(stack.db, {
|
|
258
|
+
email: "admin-b@example.com",
|
|
259
|
+
displayName: "Admin B",
|
|
260
|
+
passwordHash: await hashPassword("pw-b-admin-1234"),
|
|
261
|
+
emailVerified: true,
|
|
262
|
+
});
|
|
263
|
+
await seedTenantMembership(stack.db, {
|
|
264
|
+
userId: adminBId,
|
|
265
|
+
tenantId: TENANT_B_ID,
|
|
266
|
+
roles: ["TenantAdmin"],
|
|
267
|
+
});
|
|
268
|
+
const adminB: SessionUser = { id: adminBId, tenantId: TENANT_B_ID, roles: ["TenantAdmin"] };
|
|
269
|
+
await stack.http.writeOk(
|
|
270
|
+
AuthHandlers.inviteCreate,
|
|
271
|
+
{ email: "cross@example.com", role: "User" },
|
|
272
|
+
adminB,
|
|
273
|
+
);
|
|
274
|
+
const rows = await selectMany(stack.db, tenantInvitationsTable, { email: "cross@example.com" });
|
|
275
|
+
const invitationId = rows[0]?.["id"] as string;
|
|
276
|
+
|
|
277
|
+
const err = await stack.http.writeErr(
|
|
278
|
+
TenantHandlers.cancelInvitation,
|
|
279
|
+
{ invitationId },
|
|
280
|
+
tenantAdminA(),
|
|
281
|
+
);
|
|
282
|
+
expectErrorIncludes(err, "invitation_not_found");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("updateMemberRoles not reachable by TenantAdmin", () => {
|
|
287
|
+
test("TenantAdmin gets access_denied on updateMemberRoles", async () => {
|
|
288
|
+
const err = await stack.http.writeErr(
|
|
289
|
+
TenantHandlers.updateMemberRoles,
|
|
290
|
+
{ userId: tenantAdminAId, tenantId: TENANT_A_ID, roles: ["User"] },
|
|
291
|
+
tenantAdminA(),
|
|
292
|
+
);
|
|
293
|
+
expectErrorIncludes(err, "access_denied");
|
|
294
|
+
});
|
|
295
|
+
});
|
package/src/tenant/constants.ts
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
// Feature name
|
|
8
8
|
export const TENANT_FEATURE = "tenant" as const;
|
|
9
9
|
|
|
10
|
+
export const MEMBERS_SCREEN_ID = "members" as const;
|
|
11
|
+
|
|
12
|
+
/** Closed allowlist for invite-role picker — never free text (escalation guard). */
|
|
13
|
+
export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Admin", "Editor"] as const;
|
|
14
|
+
|
|
10
15
|
// Qualified write handler names (QN format: scope:type:name)
|
|
11
16
|
export const TenantHandlers = {
|
|
12
17
|
create: "tenant:write:create",
|