mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
## Thinking Path > - Paperclip is the open-source control plane people use to manage AI agents and their work. > - Its scheduler, routines, and heartbeat services decide when agents automatically begin work. > - Experimental per-worktree execution is useful for isolated development, but enabling it previously allowed automatic services to consider an existing backlog. > - A worktree activation must therefore create a durable eligibility boundary rather than merely toggle execution on. > - This pull request records an activation cutoff and applies it consistently to automatic routine and heartbeat dispatch. > - The result is that an enabled worktree executes only work created after its own activation, while non-worktree behavior remains unchanged. ## Linked Issues or Issue Description **Problem type:** Bug / safety regression **Summary:** Enabling experimental run execution in an existing worktree could start automatic scheduler, routine, watchdog, and heartbeat activity for work created before that worktree was explicitly armed. **Expected behavior:** A worktree that has execution enabled only considers automatically dispatched work created on or after its activation timestamp. Ambiguous activation state fails closed. Non-worktree instances keep their existing behavior. **Related public work:** Refs #8275 (runtime worktree policy gating); this PR adds an activation-time boundary for automatic execution rather than changing the general runtime policy. ## What Changed - Persist a worktree execution activation timestamp and originating instance ID; stamp them only when the experimental toggle changes from disabled to enabled. - Resolve activation state fail-closed when the cutoff is missing, invalid, disabled, or belongs to another instance. - Gate automatic routine scheduling, webhooks, watchdog activity, and heartbeat selection at the activation cutoff; manual runs remain available. - Share the canonical worktree truthy-environment helper across routine dispatch and agent inbox filtering. - Add cutoff and truthy-runtime regression coverage, plus experimental-settings UI states that explain armed and suppressed execution. ## Verification - `pnpm exec vitest run server/src/__tests__/routines-service.test.ts server/src/__tests__/instance-settings-service.test.ts` — passes: 2 files, 60 tests. - `pnpm --filter @paperclipai/server typecheck` — passes. - Existing CI completed successfully before the follow-up review fixes; this branch was rebased onto the latest `origin/master` before retesting. ## Risks - **Behavioral:** Automatic worktree execution is intentionally more restrictive; pre-existing work is suppressed until newly created after activation. - **Operational:** A malformed or cross-instance activation record fails closed, requiring an operator to disable and re-enable the experimental toggle on the intended worktree. - **Compatibility:** The worktree environment now accepts all canonical truthy values (`1`, `true`, `yes`, and `on`) consistently; non-worktree instances are unaffected. - **Branch metadata:** This existing execution-workspace branch predates the current naming rule and cannot be renamed under this task's workspace contract; the code and PR title do not include internal ticket references. > `ROADMAP.md` was checked; this targeted execution-safety fix does not duplicate planned core work. ## Model Used - Anthropic Claude Code — assisted with the original implementation; exact model identifier and context window were not recorded in the repository metadata. - OpenAI Codex CLI — assisted with PR preparation and review fixes; exact model identifier and context window are not exposed in this execution environment. Used with terminal tooling, code editing, and targeted test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
631 lines
20 KiB
TypeScript
631 lines
20 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockInstanceSettingsService = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
getGeneral: vi.fn(),
|
|
getExperimental: vi.fn(),
|
|
update: vi.fn(),
|
|
updateGeneral: vi.fn(),
|
|
updateExperimental: vi.fn(),
|
|
listCompanyIds: vi.fn(),
|
|
}));
|
|
const mockHeartbeatService = vi.hoisted(() => ({
|
|
buildIssueGraphLivenessAutoRecoveryPreview: vi.fn(),
|
|
reconcileIssueGraphLiveness: vi.fn(),
|
|
}));
|
|
const mockEnvironmentService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
}));
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
|
|
function registerModuleMocks() {
|
|
vi.doMock("../services/index.js", () => ({
|
|
heartbeatService: () => mockHeartbeatService,
|
|
instanceSettingsService: () => mockInstanceSettingsService,
|
|
logActivity: mockLogActivity,
|
|
}));
|
|
vi.doMock("../services/environments.js", () => ({
|
|
environmentService: () => mockEnvironmentService,
|
|
}));
|
|
}
|
|
|
|
async function createApp(actor: any) {
|
|
const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([
|
|
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
|
vi.importActual<typeof import("../routes/instance-settings.js")>("../routes/instance-settings.js"),
|
|
]);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = actor;
|
|
next();
|
|
});
|
|
app.use("/api", instanceSettingsRoutes({} as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
describe("instance settings routes", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.doUnmock("../services/index.js");
|
|
vi.doUnmock("../routes/instance-settings.js");
|
|
vi.doUnmock("../routes/authz.js");
|
|
vi.doUnmock("../middleware/index.js");
|
|
registerModuleMocks();
|
|
vi.clearAllMocks();
|
|
mockInstanceSettingsService.get.mockReset();
|
|
mockInstanceSettingsService.getGeneral.mockReset();
|
|
mockInstanceSettingsService.getExperimental.mockReset();
|
|
mockInstanceSettingsService.update.mockReset();
|
|
mockInstanceSettingsService.updateGeneral.mockReset();
|
|
mockInstanceSettingsService.updateExperimental.mockReset();
|
|
mockInstanceSettingsService.listCompanyIds.mockReset();
|
|
mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview.mockReset();
|
|
mockHeartbeatService.reconcileIssueGraphLiveness.mockReset();
|
|
mockEnvironmentService.getById.mockReset();
|
|
mockLogActivity.mockReset();
|
|
mockInstanceSettingsService.get.mockResolvedValue({
|
|
id: "instance-settings-1",
|
|
defaultEnvironmentId: null,
|
|
general: {
|
|
censorUsernameInLogs: false,
|
|
keyboardShortcuts: false,
|
|
feedbackDataSharingPreference: "prompt",
|
|
},
|
|
experimental: {
|
|
enableEnvironments: false,
|
|
enableIsolatedWorkspaces: false,
|
|
enableIssuePlanDecompositions: false,
|
|
enableExperimentalFileViewer: false,
|
|
enableCloudSync: false,
|
|
enableExternalObjects: false,
|
|
enableBuiltInAgents: false,
|
|
enableGoalsSidebarLink: false,
|
|
enableServerInfoDebugView: false,
|
|
autoRestartDevServerWhenIdle: false,
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
enableWorkspaceBranchReconcileForward: true,
|
|
enableWorkspaceDirtyQuarantineRepair: true,
|
|
enableWorktreeRunExecution: false,
|
|
worktreeRunExecutionActivatedAt: null,
|
|
worktreeRunExecutionActivationInstanceId: null,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
|
},
|
|
createdAt: "2026-06-20T00:00:00.000Z",
|
|
updatedAt: "2026-06-20T00:00:00.000Z",
|
|
});
|
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
|
censorUsernameInLogs: false,
|
|
keyboardShortcuts: false,
|
|
feedbackDataSharingPreference: "prompt",
|
|
});
|
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
|
enableEnvironments: false,
|
|
enableIsolatedWorkspaces: false,
|
|
enableIssuePlanDecompositions: false,
|
|
enableExperimentalFileViewer: false,
|
|
enableTaskWatchdogs: false,
|
|
enableCloudSync: false,
|
|
enableExternalObjects: false,
|
|
enableBuiltInAgents: false,
|
|
enableGoalsSidebarLink: false,
|
|
enableServerInfoDebugView: false,
|
|
autoRestartDevServerWhenIdle: false,
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
enableWorkspaceBranchReconcileForward: true,
|
|
enableWorkspaceDirtyQuarantineRepair: true,
|
|
enableWorktreeRunExecution: false,
|
|
worktreeRunExecutionActivatedAt: null,
|
|
worktreeRunExecutionActivationInstanceId: null,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
|
});
|
|
mockInstanceSettingsService.update.mockResolvedValue({
|
|
id: "instance-settings-1",
|
|
defaultEnvironmentId: "env-1",
|
|
general: {
|
|
censorUsernameInLogs: false,
|
|
keyboardShortcuts: false,
|
|
feedbackDataSharingPreference: "prompt",
|
|
},
|
|
experimental: {
|
|
enableEnvironments: true,
|
|
enableIsolatedWorkspaces: true,
|
|
enableIssuePlanDecompositions: true,
|
|
enableExperimentalFileViewer: true,
|
|
enableCloudSync: true,
|
|
enableExternalObjects: false,
|
|
enableBuiltInAgents: false,
|
|
enableGoalsSidebarLink: false,
|
|
enableServerInfoDebugView: false,
|
|
autoRestartDevServerWhenIdle: false,
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
enableWorkspaceBranchReconcileForward: true,
|
|
enableWorkspaceDirtyQuarantineRepair: true,
|
|
enableWorktreeRunExecution: false,
|
|
worktreeRunExecutionActivatedAt: null,
|
|
worktreeRunExecutionActivationInstanceId: null,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
|
},
|
|
createdAt: "2026-06-20T00:00:00.000Z",
|
|
updatedAt: "2026-06-20T01:00:00.000Z",
|
|
});
|
|
mockInstanceSettingsService.updateGeneral.mockResolvedValue({
|
|
id: "instance-settings-1",
|
|
general: {
|
|
censorUsernameInLogs: true,
|
|
keyboardShortcuts: true,
|
|
feedbackDataSharingPreference: "allowed",
|
|
},
|
|
});
|
|
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
|
|
id: "instance-settings-1",
|
|
experimental: {
|
|
enableEnvironments: true,
|
|
enableIsolatedWorkspaces: true,
|
|
enableIssuePlanDecompositions: true,
|
|
enableExperimentalFileViewer: true,
|
|
enableTaskWatchdogs: true,
|
|
enableCloudSync: true,
|
|
enableExternalObjects: false,
|
|
enableBuiltInAgents: true,
|
|
enableGoalsSidebarLink: false,
|
|
enableServerInfoDebugView: true,
|
|
autoRestartDevServerWhenIdle: false,
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
enableWorkspaceBranchReconcileForward: true,
|
|
enableWorkspaceDirtyQuarantineRepair: true,
|
|
enableWorktreeRunExecution: false,
|
|
worktreeRunExecutionActivatedAt: null,
|
|
worktreeRunExecutionActivationInstanceId: null,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
|
},
|
|
});
|
|
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
|
|
mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview.mockResolvedValue({
|
|
lookbackHours: 24,
|
|
cutoff: "2026-04-26T12:00:00.000Z",
|
|
generatedAt: "2026-04-27T12:00:00.000Z",
|
|
findings: 1,
|
|
recoverableFindings: 1,
|
|
skippedOutsideLookback: 0,
|
|
items: [],
|
|
});
|
|
mockHeartbeatService.reconcileIssueGraphLiveness.mockResolvedValue({
|
|
findings: 1,
|
|
autoRecoveryEnabled: true,
|
|
lookbackHours: 24,
|
|
cutoff: "2026-04-26T12:00:00.000Z",
|
|
escalationsCreated: 1,
|
|
existingEscalations: 0,
|
|
skipped: 0,
|
|
skippedAutoRecoveryDisabled: 0,
|
|
skippedOutsideLookback: 0,
|
|
escalationIssueIds: ["issue-2"],
|
|
});
|
|
mockEnvironmentService.getById.mockResolvedValue({
|
|
id: "env-1",
|
|
driver: "local",
|
|
status: "active",
|
|
config: {},
|
|
});
|
|
});
|
|
|
|
it("allows local board users to read and update experimental settings", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const getRes = await request(app).get("/api/instance/settings/experimental");
|
|
expect(getRes.status).toBe(200);
|
|
expect(getRes.body).toEqual({
|
|
enableEnvironments: false,
|
|
enableIsolatedWorkspaces: false,
|
|
enableIssuePlanDecompositions: false,
|
|
enableExperimentalFileViewer: false,
|
|
enableTaskWatchdogs: false,
|
|
enableCloudSync: false,
|
|
enableExternalObjects: false,
|
|
enableBuiltInAgents: false,
|
|
enableGoalsSidebarLink: false,
|
|
enableServerInfoDebugView: false,
|
|
autoRestartDevServerWhenIdle: false,
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
enableWorkspaceBranchReconcileForward: true,
|
|
enableWorkspaceDirtyQuarantineRepair: true,
|
|
enableWorktreeRunExecution: false,
|
|
worktreeRunExecutionActivatedAt: null,
|
|
worktreeRunExecutionActivationInstanceId: null,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
|
});
|
|
|
|
const patchRes = await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({ enableIsolatedWorkspaces: true });
|
|
|
|
expect(patchRes.status).toBe(200);
|
|
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
|
enableIsolatedWorkspaces: true,
|
|
});
|
|
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
|
}, 10_000);
|
|
|
|
it("strips server-managed worktree run execution fields before updating experimental settings", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({
|
|
enableWorktreeRunExecution: true,
|
|
worktreeRunExecutionActivatedAt: "2026-07-10T12:00:00.000Z",
|
|
worktreeRunExecutionActivationInstanceId: "copied-instance",
|
|
})
|
|
.expect(200);
|
|
|
|
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
|
enableWorktreeRunExecution: true,
|
|
});
|
|
});
|
|
|
|
it("allows local board users to read and update the instance default environment", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const getRes = await request(app).get("/api/instance/settings");
|
|
expect(getRes.status).toBe(200);
|
|
expect(getRes.body.defaultEnvironmentId).toBeNull();
|
|
|
|
const patchRes = await request(app)
|
|
.patch("/api/instance/settings")
|
|
.send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
|
|
|
expect(patchRes.status).toBe(200);
|
|
expect(mockInstanceSettingsService.update).toHaveBeenCalledWith({
|
|
defaultEnvironmentId: "11111111-1111-4111-8111-111111111111",
|
|
});
|
|
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("rejects unknown defaultEnvironmentId values with 422", async () => {
|
|
mockEnvironmentService.getById.mockResolvedValue(null);
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch("/api/instance/settings")
|
|
.send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toContain("Environment not found");
|
|
expect(mockInstanceSettingsService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows local board users to update guarded dev-server auto-restart", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({ autoRestartDevServerWhenIdle: true })
|
|
.expect(200);
|
|
|
|
expect(
|
|
mockInstanceSettingsService.updateExperimental.mock.calls.some(
|
|
([patch]) => patch?.autoRestartDevServerWhenIdle === true,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("allows local board users to update external object detection", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({ enableExternalObjects: true })
|
|
.expect(200);
|
|
|
|
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
|
enableExternalObjects: true,
|
|
});
|
|
});
|
|
|
|
it("allows local board users to update built-in agents", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({ enableBuiltInAgents: true })
|
|
.expect(200);
|
|
|
|
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
|
enableBuiltInAgents: true,
|
|
});
|
|
});
|
|
|
|
it("allows local board users to update the goals sidebar link", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({ enableGoalsSidebarLink: true })
|
|
.expect(200);
|
|
|
|
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
|
enableGoalsSidebarLink: true,
|
|
});
|
|
});
|
|
|
|
it("allows local board users to update the server info debug view", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({ enableServerInfoDebugView: true })
|
|
.expect(200);
|
|
|
|
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
|
enableServerInfoDebugView: true,
|
|
});
|
|
});
|
|
|
|
it("allows local board users to update issue graph liveness auto-recovery", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: 12,
|
|
})
|
|
.expect(200);
|
|
|
|
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
|
enableIssueGraphLivenessAutoRecovery: true,
|
|
issueGraphLivenessAutoRecoveryLookbackHours: 12,
|
|
});
|
|
});
|
|
|
|
it("previews issue graph liveness recovery candidates before enabling", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview")
|
|
.send({ lookbackHours: 12 })
|
|
.expect(200);
|
|
|
|
expect(res.body).toMatchObject({ lookbackHours: 24, recoverableFindings: 1 });
|
|
expect(mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview).toHaveBeenCalledWith({
|
|
lookbackHours: 12,
|
|
});
|
|
});
|
|
|
|
it("kicks off issue graph liveness recovery on demand", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
await request(app)
|
|
.post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/run")
|
|
.send({ lookbackHours: 12 })
|
|
.expect(200);
|
|
|
|
expect(mockHeartbeatService.reconcileIssueGraphLiveness).toHaveBeenCalledWith({
|
|
runId: null,
|
|
force: true,
|
|
lookbackHours: 12,
|
|
});
|
|
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("allows local board users to update environment controls", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({ enableEnvironments: true })
|
|
.expect(200);
|
|
|
|
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
|
enableEnvironments: true,
|
|
});
|
|
});
|
|
|
|
it("allows local board users to update task watchdog controls", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({ enableTaskWatchdogs: true })
|
|
.expect(200);
|
|
|
|
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
|
enableTaskWatchdogs: true,
|
|
});
|
|
});
|
|
|
|
it("allows non-admin board users with company access to read but not update experimental settings", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: ["company-1"],
|
|
});
|
|
|
|
await request(app).get("/api/instance/settings/experimental").expect(200);
|
|
|
|
await request(app)
|
|
.patch("/api/instance/settings/experimental")
|
|
.send({ enableTaskWatchdogs: true })
|
|
.expect(403);
|
|
|
|
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows local board users to read and update general settings", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "local-board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
});
|
|
|
|
const getRes = await request(app).get("/api/instance/settings/general");
|
|
expect(getRes.status).toBe(200);
|
|
expect(getRes.body).toEqual({
|
|
censorUsernameInLogs: false,
|
|
keyboardShortcuts: false,
|
|
feedbackDataSharingPreference: "prompt",
|
|
});
|
|
|
|
const patchRes = await request(app)
|
|
.patch("/api/instance/settings/general")
|
|
.send({
|
|
censorUsernameInLogs: true,
|
|
keyboardShortcuts: true,
|
|
feedbackDataSharingPreference: "allowed",
|
|
});
|
|
|
|
expect(patchRes.status).toBe(200);
|
|
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
|
|
censorUsernameInLogs: true,
|
|
keyboardShortcuts: true,
|
|
feedbackDataSharingPreference: "allowed",
|
|
});
|
|
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("allows non-admin board users to read general settings", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: ["company-1"],
|
|
});
|
|
|
|
const res = await request(app).get("/api/instance/settings/general");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({
|
|
censorUsernameInLogs: false,
|
|
keyboardShortcuts: false,
|
|
feedbackDataSharingPreference: "prompt",
|
|
});
|
|
});
|
|
|
|
it("rejects signed-in users without company access from reading general settings", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-2",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [],
|
|
memberships: [],
|
|
});
|
|
|
|
const res = await request(app).get("/api/instance/settings/general");
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects non-admin board users from updating general settings", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: ["company-1"],
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch("/api/instance/settings/general")
|
|
.send({ censorUsernameInLogs: true, keyboardShortcuts: true });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects agent callers", async () => {
|
|
const app = await createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
source: "agent_key",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch("/api/instance/settings/general")
|
|
.send({ feedbackDataSharingPreference: "not_allowed" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
|
|
});
|
|
});
|