Compare commits

...
10 changed files with 81 additions and 17 deletions
+2 -1
View File
@@ -12,6 +12,7 @@
import type { TFunction } from "i18next";
import { apiClient } from "@portal/api/http";
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
import { resolveRunOn } from "@app/policies/runOn";
import { runsToActivity, runsToStats } from "@app/policies/runs";
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
import type { ToolEndpoint } from "@app/types/toolApiTypes";
@@ -627,7 +628,7 @@ export function buildWireFromState(
scopeTypes: s.scopeTypes,
reviewerEmail: s.reviewerEmail,
fieldValues: s.fieldValues,
runOn: s.runOn ?? "upload",
runOn: resolveRunOn(s.runOn, entry.category.id),
outputMode: s.outputMode ?? "new_version",
outputName: s.outputName ?? "",
outputNamePosition: s.outputNamePosition ?? "suffix",
@@ -31,6 +31,7 @@ import {
type PolicyToolId,
type PolicyToolStep,
} from "@app/policies/operations";
import { resolveRunOn } from "@app/policies/runOn";
import { useSources } from "@portal/queries/sources";
import { fetchIntegrations } from "@portal/api/integrations";
import { errorMessage } from "@portal/api/http";
@@ -305,8 +306,8 @@ function PolicySetupWizardBody({
const [outputNamePosition, setOutputNamePosition] = useState<
"prefix" | "suffix" | "auto-number"
>(policy?.state.outputNamePosition ?? "suffix");
const [runOn, setRunOn] = useState<"upload" | "export">(
policy?.state.runOn ?? "upload",
const [runOn, setRunOn] = useState<"upload" | "export">(() =>
resolveRunOn(policy?.state.runOn, category.id),
);
// Policies run once; retry config has no UI. Preserve any saved values on
// edit and default new policies to no retries (run once).
@@ -645,7 +646,7 @@ function PolicySetupWizardBody({
inputSize="sm"
value={runOn}
onChange={(value) =>
setRunOn((value ?? "upload") as "upload" | "export")
setRunOn(resolveRunOn(value, category.id))
}
options={[
{
+1 -1
View File
@@ -44,7 +44,7 @@ export function seedPolicies(): WirePolicy[] {
output: {
type: "inline",
options: {
runOn: "upload",
runOn: "export",
mode: "new_version",
name: "",
position: "suffix",
@@ -69,9 +69,20 @@ describe("fromWirePolicy → round-trip", () => {
expect(decoded.steps).toEqual(FULL_STATE.steps);
});
it("defaults runOn to upload when missing", () => {
it("defaults a missing runOn to the category default (security → export)", () => {
const wire = toWirePolicy(FULL_STATE);
delete (wire.output.options as Record<string, unknown>).runOn;
expect(fromWirePolicy(wire).runOn).toBe("export");
});
it("defaults a missing runOn to upload for other categories", () => {
const wire = toWirePolicy({ ...FULL_STATE, categoryId: "classification" });
delete (wire.output.options as Record<string, unknown>).runOn;
expect(fromWirePolicy(wire).runOn).toBe("upload");
});
it("keeps an explicitly saved upload on a security policy", () => {
const wire = toWirePolicy(FULL_STATE);
expect(fromWirePolicy(wire).runOn).toBe("upload");
});
@@ -7,6 +7,7 @@
* `automation` blob and toolRegistry coupling.
*/
import { resolveRunOn } from "@app/policies/runOn";
import type {
PolicyDecodedState,
WireOutputOptions,
@@ -55,18 +56,19 @@ export function fromWirePolicy(policy: WirePolicy): PolicyDecodedState {
: raw.position === "auto-number"
? "auto-number"
: "prefix";
const categoryId = str(raw.categoryId);
return {
id: policy.id,
name: policy.name,
enabled: policy.enabled,
categoryId: str(raw.categoryId),
categoryId,
sources: Array.isArray(raw.sources) ? (raw.sources as string[]) : [],
scopeTypes: Array.isArray(raw.scopeTypes)
? (raw.scopeTypes as string[])
: [],
reviewerEmail: str(raw.reviewerEmail),
fieldValues: raw.fieldValues ?? {},
runOn: raw.runOn === "export" ? "export" : "upload",
runOn: resolveRunOn(raw.runOn, categoryId),
outputMode: raw.mode === "new_file" ? "new_file" : "new_version",
outputName: str(raw.name),
outputNamePosition: position,
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { defaultRunOn, resolveRunOn } from "@app/policies/runOn";
describe("defaultRunOn", () => {
it("defaults Security to export", () => {
expect(defaultRunOn("security")).toBe("export");
});
it("defaults any other, unknown or missing category to upload", () => {
for (const id of ["classification", "compliance", "nope", "", undefined]) {
expect(defaultRunOn(id)).toBe("upload");
}
});
});
describe("resolveRunOn", () => {
it("keeps an explicitly saved value over the category default", () => {
expect(resolveRunOn("upload", "security")).toBe("upload");
expect(resolveRunOn("export", "classification")).toBe("export");
});
it("falls back to the category default when unset or invalid", () => {
expect(resolveRunOn(undefined, "security")).toBe("export");
expect(resolveRunOn("nonsense", "security")).toBe("export");
expect(resolveRunOn(undefined, "classification")).toBe("upload");
});
});
@@ -0,0 +1,20 @@
/** Per-category default for the editor event a policy enforces on. */
export type PolicyRunOn = "upload" | "export";
const DEFAULT_RUN_ON: Record<string, PolicyRunOn> = {
security: "export",
};
export function defaultRunOn(categoryId: string | undefined): PolicyRunOn {
return DEFAULT_RUN_ON[categoryId ?? ""] ?? "upload";
}
/** An explicitly saved value wins; anything else falls back to the default. */
export function resolveRunOn(
value: unknown,
categoryId: string | undefined,
): PolicyRunOn {
if (value === "export" || value === "upload") return value;
return defaultRunOn(categoryId);
}
@@ -11,6 +11,7 @@
* using that registry.
*/
import { resolveRunOn } from "@app/policies/runOn";
import type { AutomationConfig } from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { PolicyFolderSettings } from "@app/types/policies";
@@ -301,9 +302,10 @@ export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
typeof v === "string" ? v : fallback;
const num = (v: unknown, fallback: number) =>
typeof v === "number" ? v : fallback;
const categoryId = str(meta.categoryId);
return {
id: policy.id,
categoryId: str(meta.categoryId),
categoryId,
name: policy.name,
enabled: policy.enabled,
automation: (output.automation as AutomationConfig | undefined) ?? null,
@@ -315,7 +317,7 @@ export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
fieldValues:
(meta.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {},
folder: {
runOn: meta.runOn === "export" ? "export" : "upload",
runOn: resolveRunOn(meta.runOn, categoryId),
// Legacy/missing output.mode defaults to new_version, not new_file.
outputMode: output.mode === "new_file" ? "new_file" : "new_version",
outputName: str(output.name),
@@ -6,12 +6,13 @@
*/
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import { defaultRunOn } from "@app/policies/runOn";
import type { PoliciesByCategory, PolicyState } from "@app/types/policies";
const STORAGE_KEY = "stirling-policies-state";
export const POLICIES_CHANGE_EVENT = "stirling:policies-changed";
function defaultState(): PolicyState {
function defaultState(categoryId: string): PolicyState {
// Unconfigured by default. The backend is the source of truth for what's
// actually configured + active; this is just the empty local-cache shape.
return {
@@ -26,8 +27,7 @@ function defaultState(): PolicyState {
outputMode: "new_version",
// No rename by default — the output keeps the input's filename.
outputName: "",
// Enforce on upload by default; export enforcement is the alternative.
runOn: "upload",
runOn: defaultRunOn(categoryId),
// Every catalog category is a shipped, built-in policy → default (not
// deletable).
isDefault: true,
@@ -54,7 +54,7 @@ export function loadPolicies(): PoliciesByCategory {
// category gets a default rather than being undefined.
const out: PoliciesByCategory = {};
loadPolicyCatalog().categories.forEach((cat, index) => {
const merged = { ...defaultState(), ...(parsed[cat.id] ?? {}) };
const merged = { ...defaultState(cat.id), ...(parsed[cat.id] ?? {}) };
// Migration: clear the obsolete persisted reviewer email so it re-defaults
// to the real signed-in user.
if (merged.reviewerEmail === STALE_REVIEWER_EMAIL)
@@ -91,7 +91,7 @@ export function updatePolicy(
// Fall back to defaults so a not-yet-seeded category id still yields a
// complete PolicyState rather than a partial.
[categoryId]: {
...defaultState(),
...defaultState(categoryId),
...current[categoryId],
...patch,
},
@@ -121,7 +121,7 @@ export function reorderPolicies(
/** Reset a category to its unconfigured default (the "Delete policy" action). */
export function resetPolicy(categoryId: string): PoliciesByCategory {
return updatePolicy(categoryId, {
...defaultState(),
...defaultState(categoryId),
configured: false,
status: "default",
// Drop the backing-folder + backend links (the caller deletes those).
@@ -136,7 +136,7 @@ export interface PolicyState {
/** Whether the rename rule is applied before ("prefix") or after ("suffix")
* the base filename, or as an auto-incrementing number. */
outputNamePosition?: "prefix" | "suffix" | "auto-number";
/** When the policy runs: on "upload" or before "export". Defaults to "upload". */
/** When the policy runs: on "upload" or before "export". See `defaultRunOn`. */
runOn?: "upload" | "export";
/**
* Execution order among policies that share a trigger. When several policies run