Compare commits

...

9 Commits

Author SHA1 Message Date
Space-Banane 6ec95c6f41 chore: bump version to 2.1.0 and update TODO
Deploy / Build (pull_request) Successful in 58s
Deploy / Test & Lint (pull_request) Successful in 51s
Deploy / Generate OpenAPI Release (pull_request) Has been skipped
Deploy / Build and Push Docker Image (pull_request) Has been skipped
Also aligns the hardcoded VERSION constants (backend said 2.0.0 while
package.json said 2.0.1) and corrects the UI VERSION type label from
'SHSF API' to 'SHSF UI'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:32:18 +02:00
Space-Banane d2c614463c fix(cache): stop caching failed dev runs and purge expired cache rows
The non-stream dev execution path cached any run that produced a
result, including failures; it now requires exit code 0, matching the
production HTTP path. Expired functionCache rows were only filtered on
read and accumulated forever — the storage-cleanup system cron now
deletes them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:30:59 +02:00
Space-Banane d6e86c97f4 fix(crons): recover overdue triggers instead of dropping them forever
processCrons only selected triggers with nextRun >= now, so any trigger
whose scheduled time passed while the server was down (or during tick
jitter) never matched the query again and silently stopped running.
Overdue triggers are now included, fire immediately based on their
stored nextRun, and reschedule onto the next future boundary.

Adds regression tests for overdue firing and null-nextRun init.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:29:25 +02:00
Space-Banane 37e0171bdf feat(ui): surface modal errors as toasts
Long modals rendered errors at the top of a scrollable body, so a
failed submit was invisible when the user was scrolled down. The shared
ModalError component now also fires a toast when a message appears,
covering all 20+ modals that use it; the inline box stays for
persistent context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:27:18 +02:00
Space-Banane 9211b4e95d fix(logs): make successful executions visible in function logs
Function logs looked like they only ever contained errors, for three
compounding reasons:

- Production cache hits returned early without persisting a log, so a
  cached (successful) function only ever logged its failures. Cache
  hits now persist a log entry marked as served from cache.
- The streaming dev-run endpoint hardcoded exitCode: 0 in its end
  event, so the editor console reported success even for failed runs.
  It now reports the real exit code.
- getExitCodeFromLog looked up 'exitCode' while the persisted key is
  'exit_code', so it returned null for every real log row. It now reads
  exit_code with a legacy exitCode fallback.

The trigger log UI also gains a Success/Exit-N badge per entry and its
timing rows now convert seconds to ms instead of mislabeling units.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:25:45 +02:00
Space-Banane b4d902ffb6 fix(global): require credentials for link-status and unlink endpoints
GET /api/global/link-status leaked the linked admin email to anyone,
and POST /api/global/unlink accepted that email plus the (also public)
instance UUID as its only proof — letting any unauthenticated caller
unlink an instance. Both now require a local Admin session/API key or
the instance secret via the x-shsf-insect header (for shsf.dev).

Also add a ratelimit to POST /api/global/showSecret, which verifies
the admin password and previously had no brute-force throttle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:20:19 +02:00
Space-Banane 83a467a65e test(ui): add unit tests for Modal and useShiftEnterSubmit hook
Also add src/setupTests.ts so @testing-library/jest-dom matchers are
available in all suites. The pre-existing DependencyManagerModal suite
failed only due to a stale Jest transform cache; it passes clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:17:13 +02:00
Space-Banane ea4b78542e test(backend): add unit tests for RunnerUtils helpers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:14:35 +02:00
Space-Banane 673e092414 test(backend): move tests to src/tests with standard .test.ts naming
Tests previously lived in src/__tests__/helpers without the .test.ts
suffix, relying on a loose vitest include glob. Move them to src/tests,
rename to *.test.ts, and tighten the vitest include pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:12:17 +02:00
33 changed files with 554 additions and 93 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "2.0.1", "version": "2.1.0",
"name": "shsf-backend", "name": "shsf-backend",
"description": "Backend for SHSF", "description": "Backend for SHSF",
"private": true, "private": true,
-43
View File
@@ -1,43 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { validateCronExpression } from "../../lib/Cron";
import { processGitPulls } from "../../lib/SystemCrons";
describe("validateCronExpression", () => {
it("returns true for a valid cron expression", async () => {
const result = await validateCronExpression("0 0 * * *");
expect(result).toBe(true);
});
it("returns false for an invalid cron expression", async () => {
const result = await validateCronExpression("invalid-cron");
expect(result).toBe(false);
});
});
describe("processGitPulls", () => {
it("passes configured git source directories to scheduled pulls", async () => {
const performGitPull = vi.fn().mockResolvedValue({ success: true, logs: "" });
const prisma = {
function: {
findMany: vi.fn().mockResolvedValue([
{
id: 910001,
name: "source-dir-fn",
git_pull_interval: 10,
git_source_dir: "functions/api",
},
]),
},
};
const dependencies: Parameters<typeof processGitPulls>[0] = {
prisma: prisma as unknown as Parameters<typeof processGitPulls>[0]["prisma"],
executeFunction: vi.fn() as unknown as Parameters<typeof processGitPulls>[0]["executeFunction"],
performGitPull: performGitPull as unknown as Parameters<typeof processGitPulls>[0]["performGitPull"],
};
await processGitPulls(dependencies);
expect(performGitPull).toHaveBeenCalledWith(910001, "functions/api");
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ export const VERSION: {
} = { } = {
type: "SHSF API", type: "SHSF API",
major: 2, major: 2,
minor: 0, minor: 1,
patch: 0, patch: 0,
toString() { toString() {
return `${this.major}.${this.minor}.${this.patch}`; return `${this.major}.${this.minor}.${this.patch}`;
+5
View File
@@ -120,6 +120,11 @@ export async function getExitCodeFromLog(triggerLog: TriggerLog): Promise<number
return null; return null;
} }
const parsed = JSON.parse(triggerLog.result); const parsed = JSON.parse(triggerLog.result);
// persistFunctionExecutionLog writes "exit_code"; "exitCode" is kept for
// older rows written before the key was standardised.
if (parsed && typeof parsed === "object" && "exit_code" in parsed) {
return parsed.exit_code;
}
if (parsed && typeof parsed === "object" && "exitCode" in parsed) { if (parsed && typeof parsed === "object" && "exitCode" in parsed) {
return parsed.exitCode; return parsed.exitCode;
} }
+21
View File
@@ -246,6 +246,27 @@ export async function executeLoadedHttpFunction(
payloadHash as string, payloadHash as string,
); );
if (cached) { if (cached) {
// Cache hits are still executions from the caller's perspective —
// without this, functions with caching enabled only ever log their
// failures (errors are never cached), making the log view look like
// the function does nothing but fail.
await dependencies.persistFunctionExecutionLog({
functionId: functionData.id,
functionData,
logs: "Result served from response cache — function code was not executed.",
output: cached.result,
payload: buildHttpLogPayload(identityValues, route),
exit_code: 0,
tooks: [
{
description: "Served from response cache",
value: 0,
timestamp: Date.now(),
},
],
ratelimit: loggedRateLimit,
});
return dependencies.handleFunctionResult( return dependencies.handleFunctionResult(
ctr, ctr,
JSON.parse(cached.result), JSON.parse(cached.result),
+22 -5
View File
@@ -106,8 +106,9 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
where: { where: {
OR: [ OR: [
{ {
// Includes overdue triggers (nextRun in the past): a missed
// tick or server downtime must not permanently kill a cron.
nextRun: { nextRun: {
gte: now,
lte: fiveMinutesFromNow, lte: fiveMinutesFromNow,
}, },
}, },
@@ -152,9 +153,9 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
continue; continue;
} }
const next = interval.next(); // Fire based on the stored schedule so overdue triggers run instead
// of waiting for (or missing) the next parsed boundary.
if (next.getTime() <= now.getTime() + 1000) { if (cron.nextRun.getTime() <= now.getTime() + 1000) {
const followingRun = interval.next().toDate(); const followingRun = interval.next().toDate();
await prisma.functionTrigger.update({ await prisma.functionTrigger.update({
@@ -225,7 +226,7 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
})(); })();
} else { } else {
const secondsUntilNextRun = Math.round( const secondsUntilNextRun = Math.round(
(next.getTime() - now.getTime()) / 1000, (cron.nextRun.getTime() - now.getTime()) / 1000,
); );
if (secondsUntilNextRun <= 5) { if (secondsUntilNextRun <= 5) {
@@ -285,6 +286,22 @@ export async function processStorageCleanup({ prisma }: SystemCronDependencies)
} catch (error) { } catch (error) {
storageLog.error({ err: error }, "Error during storage cleanup"); storageLog.error({ err: error }, "Error during storage cleanup");
} }
try {
// Expired cache rows are only ever filtered out on read; without this
// they accumulate in the table indefinitely.
const expiredCache = await prisma.functionCache.deleteMany({
where: {
expiresAt: { lt: now },
},
});
if (expiredCache.count > 0) {
storageLog.info({ count: expiredCache.count }, "Expired function cache entries cleaned up");
}
} catch (error) {
storageLog.error({ err: error }, "Error during function cache cleanup");
}
} }
const updateLog = createLogger("AUTO_UPDATE"); const updateLog = createLogger("AUTO_UPDATE");
+2 -2
View File
@@ -193,7 +193,7 @@ export = new fileRouter.Path("/")
await print( await print(
JSON.stringify({ JSON.stringify({
type: "end", type: "end",
exitCode: 0, exitCode: result?.exit_code ?? 0,
output: output, output: output,
result: result?.result, result: result?.result,
took: result?.tooks, took: result?.tooks,
@@ -229,7 +229,7 @@ export = new fileRouter.Path("/")
{ mode: "dev_execute" }, { mode: "dev_execute" },
); );
if (functionData.cache_enabled && result?.result) { if (functionData.cache_enabled && result?.exit_code === 0 && result?.result) {
await setFunctionCache( await setFunctionCache(
functionData.id, functionData.id,
payloadHash, payloadHash,
+37 -3
View File
@@ -1,4 +1,5 @@
import { fileRouter, INSTANCE_SECRET, prisma } from "../../.."; import { API_KEY_HEADER, COOKIE, fileRouter, INSTANCE_SECRET, prisma } from "../../..";
import { checkAuthentication } from "../../../lib/Authentication";
import { getLinkLock, getLinkStatus, getUUID, setLinkStatus } from "../../../lib/DataManager"; import { getLinkLock, getLinkStatus, getUUID, setLinkStatus } from "../../../lib/DataManager";
import { OpenAPITags } from "../../../lib/openapi"; import { OpenAPITags } from "../../../lib/openapi";
@@ -173,16 +174,27 @@ export = new fileRouter.Path("/")
/** /**
* POST /api/global/unlink * POST /api/global/unlink
* Removes the link between the external user and this instance. * Removes the link between the external user and this instance.
* Requires the email that was used to link and the instance UUID. * Requires the email that was used to link and the instance UUID,
* plus either an authenticated local Admin or the instance secret.
*/ */
.http("POST", "/api/global/unlink", (http) => .http("POST", "/api/global/unlink", (http) =>
http http
.ratelimit((limit) => limit.hits(5).window(10000).penalty(500)) .ratelimit((limit) => limit.hits(5).window(10000).penalty(500))
.document({ .document({
description: description:
"Unlinks the currently linked remote user from this instance. Requires the linked email and the instance UUID.", "Unlinks the currently linked remote user from this instance. Requires the linked email and the instance UUID, plus either an authenticated local Admin session/API key or the instance secret via the x-shsf-insect header.",
tags: ["Global"] as OpenAPITags[], tags: ["Global"] as OpenAPITags[],
operationId: "unlinkInstance", operationId: "unlinkInstance",
parameters: [
{
name: "x-shsf-insect",
in: "header",
required: false,
description:
"The instance secret. Alternative to session/API key authentication.",
schema: { type: "string" },
},
],
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@@ -232,6 +244,28 @@ export = new fileRouter.Path("/")
if (!data) if (!data)
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString()); return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
// 0. The linked email and instance UUID are visible to local admins,
// so they are not secrets. Require a real credential: either the
// instance secret (used by shsf.dev) or a local Admin session/API key.
const secretHeader = ctr.headers.get("x-shsf-insect");
const secretOk =
typeof secretHeader === "string" && secretHeader === INSTANCE_SECRET;
if (!secretOk) {
const authCheck = await checkAuthentication(
ctr.cookies.get(COOKIE),
ctr.headers.get(API_KEY_HEADER),
);
if (!authCheck.success || authCheck.user.role !== "Admin") {
return ctr.status(ctr.$status.UNAUTHORIZED).print({
status: "FAILED",
message:
"Unlinking requires the instance secret or an authenticated local Admin.",
});
}
}
// 1. Verify current link status // 1. Verify current link status
const linkStatus = await getLinkStatus(); const linkStatus = await getLinkStatus();
+41 -2
View File
@@ -1,4 +1,5 @@
import { fileRouter } from "../../.."; import { API_KEY_HEADER, COOKIE, fileRouter, INSTANCE_SECRET } from "../../..";
import { checkAuthentication } from "../../../lib/Authentication";
import { getLinkStatus } from "../../../lib/DataManager"; import { getLinkStatus } from "../../../lib/DataManager";
import { OpenAPITags } from "../../../lib/openapi"; import { OpenAPITags } from "../../../lib/openapi";
@@ -9,9 +10,20 @@ export = new fileRouter.Path("/").http(
http http
.ratelimit((limit) => limit.hits(10).window(5000).penalty(50)) .ratelimit((limit) => limit.hits(10).window(5000).penalty(50))
.document({ .document({
description: "Returns the link status of this instance.", description:
"Returns the link status of this instance. Requires an authenticated Admin session/API key, or the instance secret via the x-shsf-insect header.",
tags: ["Global"] as OpenAPITags[], tags: ["Global"] as OpenAPITags[],
operationId: "getLinkStatus", operationId: "getLinkStatus",
parameters: [
{
name: "x-shsf-insect",
in: "header",
required: false,
description:
"The instance secret. Alternative to session/API key authentication.",
schema: { type: "string" },
},
],
responses: { responses: {
200: { 200: {
description: "Returns the link status of this instance.", description: "Returns the link status of this instance.",
@@ -36,9 +48,36 @@ export = new fileRouter.Path("/").http(
}, },
}, },
}, },
401: { description: "Authentication failed." },
403: { description: "Authenticated user is not an Admin." },
}, },
}) })
.onRequest(async (ctr) => { .onRequest(async (ctr) => {
const secretHeader = ctr.headers.get("x-shsf-insect");
const secretOk =
typeof secretHeader === "string" && secretHeader === INSTANCE_SECRET;
if (!secretOk) {
const authCheck = await checkAuthentication(
ctr.cookies.get(COOKIE),
ctr.headers.get(API_KEY_HEADER),
);
if (!authCheck.success) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({
status: 401,
message: authCheck.message,
});
}
if (authCheck.user.role !== "Admin") {
return ctr.status(ctr.$status.FORBIDDEN).print({
status: 403,
message: "Admins only.",
});
}
}
const linkStatus = await getLinkStatus(); const linkStatus = await getLinkStatus();
return ctr.print({ return ctr.print({
status: "OK", status: "OK",
@@ -8,6 +8,7 @@ export = new fileRouter.Path("/").http(
"/api/global/showSecret", "/api/global/showSecret",
(http) => (http) =>
http http
.ratelimit((limit) => limit.hits(3).window(10000).penalty(5000))
.onRequest(async (ctr) => { .onRequest(async (ctr) => {
const authCheck = await checkAuthentication( const authCheck = await checkAuthentication(
ctr.cookies.get(COOKIE), ctr.cookies.get(COOKIE),
@@ -5,7 +5,7 @@ import {
getSanitizedPayload, getSanitizedPayload,
isSHSFBinaryEnvelope, isSHSFBinaryEnvelope,
SHSF_BINARY_TRANSPORT, SHSF_BINARY_TRANSPORT,
} from "../../lib/Caching"; } from "../lib/Caching";
describe("getSanitizedPayload", () => { describe("getSanitizedPayload", () => {
it("returns non-object primitives as-is", async () => { it("returns non-object primitives as-is", async () => {
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect, vi } from "vitest";
import { validateCronExpression } from "../lib/Cron";
import { processCrons, processGitPulls } from "../lib/SystemCrons";
describe("validateCronExpression", () => {
it("returns true for a valid cron expression", async () => {
const result = await validateCronExpression("0 0 * * *");
expect(result).toBe(true);
});
it("returns false for an invalid cron expression", async () => {
const result = await validateCronExpression("invalid-cron");
expect(result).toBe(false);
});
});
describe("processCrons", () => {
const buildDependencies = (trigger: Record<string, unknown>) => {
const update = vi.fn().mockResolvedValue({});
const executeFunction = vi
.fn()
.mockResolvedValue({ exit_code: 0, logs: "", result: null, tooks: [] });
const prisma = {
functionTrigger: {
findMany: vi.fn().mockResolvedValue([trigger]),
update,
},
functionFile: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const dependencies: Parameters<typeof processCrons>[0] = {
prisma: prisma as unknown as Parameters<typeof processCrons>[0]["prisma"],
executeFunction:
executeFunction as unknown as Parameters<typeof processCrons>[0]["executeFunction"],
performGitPull: vi.fn() as unknown as Parameters<
typeof processCrons
>[0]["performGitPull"],
};
return { dependencies, update, executeFunction };
};
it("fires overdue triggers instead of dropping them", async () => {
const overdue = {
id: 42,
functionId: 7,
name: "overdue-cron",
cron: "*/5 * * * *",
enabled: true,
nextRun: new Date(Date.now() - 60 * 60 * 1000), // missed an hour ago
data: null,
function: { id: 7 },
};
const { dependencies, update, executeFunction } = buildDependencies(overdue);
await processCrons(dependencies);
// the execution itself runs detached; wait for the microtask queue
await new Promise((resolve) => setTimeout(resolve, 0));
expect(executeFunction).toHaveBeenCalledTimes(1);
const rescheduleCall = update.mock.calls.find(
(call) => call[0]?.data?.nextRun instanceof Date,
);
expect(rescheduleCall).toBeDefined();
expect(rescheduleCall![0].data.nextRun.getTime()).toBeGreaterThan(Date.now());
});
it("initializes nextRun without executing when it is null", async () => {
const fresh = {
id: 43,
functionId: 7,
name: "fresh-cron",
cron: "*/5 * * * *",
enabled: true,
nextRun: null,
data: null,
function: { id: 7 },
};
const { dependencies, update, executeFunction } = buildDependencies(fresh);
await processCrons(dependencies);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(executeFunction).not.toHaveBeenCalled();
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 43 },
data: { nextRun: expect.any(Date) },
}),
);
});
});
describe("processGitPulls", () => {
it("passes configured git source directories to scheduled pulls", async () => {
const performGitPull = vi.fn().mockResolvedValue({ success: true, logs: "" });
const prisma = {
function: {
findMany: vi.fn().mockResolvedValue([
{
id: 910001,
name: "source-dir-fn",
git_pull_interval: 10,
git_source_dir: "functions/api",
},
]),
},
};
const dependencies: Parameters<typeof processGitPulls>[0] = {
prisma: prisma as unknown as Parameters<typeof processGitPulls>[0]["prisma"],
executeFunction: vi.fn() as unknown as Parameters<typeof processGitPulls>[0]["executeFunction"],
performGitPull: performGitPull as unknown as Parameters<typeof processGitPulls>[0]["performGitPull"],
};
await processGitPulls(dependencies);
expect(performGitPull).toHaveBeenCalledWith(910001, "functions/api");
});
});
@@ -4,7 +4,7 @@ import {
parseStoredEnvironmentVariables, parseStoredEnvironmentVariables,
serializeEnvironmentVariables, serializeEnvironmentVariables,
toDockerEnvironment, toDockerEnvironment,
} from "../../lib/EnvironmentVariables"; } from "../lib/EnvironmentVariables";
describe("EnvironmentVariables", () => { describe("EnvironmentVariables", () => {
it("parses stored environment variables and ignores invalid entries", () => { it("parses stored environment variables and ignores invalid entries", () => {
@@ -5,7 +5,7 @@ import {
getAnalyticsRangeStart, getAnalyticsRangeStart,
normalizeAnalyticsRange, normalizeAnalyticsRange,
parseExecutionAnalyticsLog, parseExecutionAnalyticsLog,
} from "../../lib/FunctionAnalytics"; } from "../lib/FunctionAnalytics";
describe("FunctionAnalytics helpers", () => { describe("FunctionAnalytics helpers", () => {
it("normalizes unknown ranges to 7d", () => { it("normalizes unknown ranges to 7d", () => {
@@ -1,18 +1,29 @@
import { TriggerLog } from '@prisma/client'; import { TriggerLog } from '@prisma/client';
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { getExitCodeFromLog, stripHeadersFromPayload } from '../../lib/FunctionLogging'; import { getExitCodeFromLog, stripHeadersFromPayload } from '../lib/FunctionLogging';
describe('getExitCodeFromLog', () => { describe('getExitCodeFromLog', () => {
it('should return the correct exit code',async () => { const makeLog = (result: string) =>
const expectedExitCode = 0; // Replace with the expected exit code for your test case ({
const test = {
createdAt: new Date(), createdAt: new Date(),
functionId: 1, functionId: 1,
id: 1, id: 1,
result: JSON.stringify({ exitCode: expectedExitCode }), result,
} as TriggerLog; }) as TriggerLog;
expect(await getExitCodeFromLog(test)).toBe(expectedExitCode); it('reads the exit_code key written by persistFunctionExecutionLog', async () => {
const log = makeLog(JSON.stringify({ exit_code: 137, tooks: [], output: '' }));
expect(await getExitCodeFromLog(log)).toBe(137);
});
it('falls back to the legacy exitCode key', async () => {
const log = makeLog(JSON.stringify({ exitCode: 0 }));
expect(await getExitCodeFromLog(log)).toBe(0);
});
it('returns null when no exit code is present', async () => {
const log = makeLog(JSON.stringify({ output: 'hi' }));
expect(await getExitCodeFromLog(log)).toBe(null);
}); });
}); });
@@ -12,7 +12,7 @@ import {
hasConfiguredRateLimitBuckets, hasConfiguredRateLimitBuckets,
normalizeFunctionRateLimitConfig, normalizeFunctionRateLimitConfig,
resetFunctionRateLimitState, resetFunctionRateLimitState,
} from "../../lib/FunctionRateLimit"; } from "../lib/FunctionRateLimit";
const fallbackWindowMs = parseInt(env.RATELIMIT || "0", 10) || 0; const fallbackWindowMs = parseInt(env.RATELIMIT || "0", 10) || 0;
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { getGitEditBlock } from "../../lib/GitEditGuards"; import { getGitEditBlock } from "../lib/GitEditGuards";
describe("getGitEditBlock", () => { describe("getGitEditBlock", () => {
it("blocks edits when git_url is configured", async () => { it("blocks edits when git_url is configured", async () => {
@@ -6,8 +6,8 @@ import {
listGitAppFiles, listGitAppFiles,
removeGitMetadata, removeGitMetadata,
stripCredentialsFromUrl, stripCredentialsFromUrl,
} from "../../lib/GitOps"; } from "../lib/GitOps";
import { getFunctionBaseDir, getFunctionAppDir } from "../../lib/StoragePaths"; import { getFunctionBaseDir, getFunctionAppDir } from "../lib/StoragePaths";
const testFunctionIds = new Set<number>(); const testFunctionIds = new Set<number>();
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { executeLoadedHttpFunction } from "../../lib/HttpExecution"; import { executeLoadedHttpFunction } from "../lib/HttpExecution";
const baseFunctionData = { const baseFunctionData = {
id: 10, id: 10,
@@ -8,28 +8,28 @@ import {
prepareRunnerTransport, prepareRunnerTransport,
readRunnerResult, readRunnerResult,
revokeLegacyFunctionDbTokens, revokeLegacyFunctionDbTokens,
} from "../../lib/RunnerTransport"; } from "../lib/RunnerTransport";
import { import {
FunctionStorageService, FunctionStorageService,
StorageServiceError, StorageServiceError,
} from "../../lib/FunctionStorageService"; } from "../lib/FunctionStorageService";
import { import {
DbComScriptCS, DbComScriptCS,
DbComScriptGO, DbComScriptGO,
DbComScriptPY, DbComScriptPY,
ShsfRuntimeScriptCS, ShsfRuntimeScriptCS,
} from "../../lib/RunnerScripts"; } from "../lib/RunnerScripts";
import { import {
generateDotnetRunnerScript, generateDotnetRunnerScript,
generateGoRunnerWrapperCode, generateGoRunnerWrapperCode,
generatePythonRunnerScript, generatePythonRunnerScript,
} from "../../lib/RunnerRuntimeScripts"; } from "../lib/RunnerRuntimeScripts";
type TestStorageDb = NonNullable< type TestStorageDb = NonNullable<
ConstructorParameters<typeof FunctionStorageService>[0] ConstructorParameters<typeof FunctionStorageService>[0]
>; >;
vi.mock("../../index.js", () => ({ vi.mock("../index.js", () => ({
prisma: { prisma: {
accessToken: { accessToken: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }), deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
@@ -209,7 +209,7 @@ describe("generated transport scripts", () => {
describe("legacy function DB token cleanup", () => { describe("legacy function DB token cleanup", () => {
it("deletes hidden legacy function DB tokens", async () => { it("deletes hidden legacy function DB tokens", async () => {
const { prisma } = await import("../../index.js"); const { prisma } = await import("../index.js");
await revokeLegacyFunctionDbTokens(); await revokeLegacyFunctionDbTokens();
expect(prisma.accessToken.deleteMany).toHaveBeenCalledWith({ expect(prisma.accessToken.deleteMany).toHaveBeenCalledWith({
where: { where: {
+123
View File
@@ -0,0 +1,123 @@
import type { FunctionFile } from "@prisma/client";
import { describe, expect, it } from "vitest";
import {
appendLogOutput,
findFileByNameIgnoreCase,
getRuntimeType,
isDotnetImage,
isHtmlStartupFile,
parseExecutionPayloadRoute,
resolveServeOnlyHtmlFileName,
truncateDbField,
} from "../lib/RunnerUtils";
import { DB_FIELD_LIMIT } from "../lib/RunnerTypes";
describe("truncateDbField", () => {
it("returns short values unchanged", () => {
expect(truncateDbField("hello")).toBe("hello");
});
it("truncates values over the DB field limit and appends a marker", () => {
const long = "x".repeat(DB_FIELD_LIMIT + 100);
const result = truncateDbField(long);
expect(result.length).toBe(DB_FIELD_LIMIT + "...[truncated for DB]".length);
expect(result.endsWith("...[truncated for DB]")).toBe(true);
});
});
describe("appendLogOutput", () => {
it("ignores whitespace-only additions", () => {
expect(appendLogOutput("existing", " \n ")).toBe("existing");
});
it("returns trimmed next when existing is empty", () => {
expect(appendLogOutput("", " new line \n")).toBe("new line");
});
it("joins existing and next with a single newline", () => {
expect(appendLogOutput("first\n", " second ")).toBe("first\nsecond");
});
});
describe("isDotnetImage / getRuntimeType", () => {
it("detects dotnet sdk images", () => {
expect(isDotnetImage("mcr.microsoft.com/dotnet/sdk:8.0")).toBe(true);
expect(isDotnetImage("python:3.12")).toBe(false);
});
it("maps images to runtime types", () => {
expect(getRuntimeType("mcr.microsoft.com/dotnet/sdk:8.0")).toBe("dotnet");
expect(getRuntimeType("python:3.12")).toBe("python");
expect(getRuntimeType("golang:1.22")).toBe("golang");
});
});
describe("isHtmlStartupFile", () => {
it("matches .html files case-insensitively", () => {
expect(isHtmlStartupFile("index.html")).toBe(true);
expect(isHtmlStartupFile("INDEX.HTML")).toBe(true);
});
it("rejects non-html and empty startup files", () => {
expect(isHtmlStartupFile("main.py")).toBe(false);
expect(isHtmlStartupFile(null)).toBe(false);
expect(isHtmlStartupFile(undefined)).toBe(false);
});
});
describe("parseExecutionPayloadRoute", () => {
it("extracts a string route from a JSON payload", () => {
expect(parseExecutionPayloadRoute('{"route":"/about"}')).toBe("/about");
});
it("returns null for non-string routes or invalid JSON", () => {
expect(parseExecutionPayloadRoute('{"route":42}')).toBe(null);
expect(parseExecutionPayloadRoute("not json")).toBe(null);
});
});
describe("resolveServeOnlyHtmlFileName", () => {
it("falls back to the startup file for empty/default/root routes", () => {
expect(resolveServeOnlyHtmlFileName("index.html", null)).toBe("index.html");
expect(resolveServeOnlyHtmlFileName("index.html", "default")).toBe("index.html");
expect(resolveServeOnlyHtmlFileName("index.html", "/")).toBe("index.html");
expect(resolveServeOnlyHtmlFileName("index.html", " ")).toBe("index.html");
});
it("strips query strings, fragments and leading slashes", () => {
expect(resolveServeOnlyHtmlFileName("index.html", "/about?x=1#top")).toBe(
"about.html",
);
});
it("appends .html when the route has no extension", () => {
expect(resolveServeOnlyHtmlFileName("index.html", "docs/intro")).toBe(
"docs/intro.html",
);
});
it("rejects path traversal attempts", () => {
expect(resolveServeOnlyHtmlFileName("index.html", "../secret")).toBe(null);
expect(resolveServeOnlyHtmlFileName("index.html", "a\\b")).toBe(null);
});
});
describe("findFileByNameIgnoreCase", () => {
const file = (name: string) => ({ name }) as FunctionFile;
it("prefers an exact-case match over a case-insensitive one", () => {
const files = [file("INDEX.HTML"), file("index.html")];
expect(findFileByNameIgnoreCase(files, "index.html")?.name).toBe("index.html");
});
it("falls back to a case-insensitive match", () => {
const files = [file("Index.Html")];
expect(findFileByNameIgnoreCase(files, "index.html")?.name).toBe("Index.Html");
});
it("returns undefined when nothing matches", () => {
expect(findFileByNameIgnoreCase([file("main.py")], "index.html")).toBe(
undefined,
);
});
});
@@ -8,7 +8,7 @@ import {
getFunctionExecutionsDir, getFunctionExecutionsDir,
getGitRepoDir, getGitRepoDir,
getShsfDataRoot, getShsfDataRoot,
} from "../../lib/StoragePaths"; } from "../lib/StoragePaths";
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { readRawRequestBodyFromMiddleware } from "../../lib/middlewares/executionBody"; import { readRawRequestBodyFromMiddleware } from "../lib/middlewares/executionBody";
function createCtr(method: string, chunks: Array<Buffer | string>) { function createCtr(method: string, chunks: Array<Buffer | string>) {
const bodyState = { const bodyState = {
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: 'node',
include: ['src/__tests__/**/*.test.ts', 'src/__tests__/**/*.ts'], include: ['src/tests/**/*.test.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', "./src/routes/**"], exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', "./src/routes/**"],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
+5 -5
View File
@@ -1,14 +1,14 @@
# TODO.md # TODO.md
## P0 - Priority 0 (Critical) ## P0 - Priority 0 (Critical)
- [ ] Add UI unit testing / Improve it - [x] Add UI unit testing / Improve it
- [ ] Add Backend unit testing / Improve it (eg. rename files, move into seperate testing folder etc etc /src/tests) - [x] Add Backend unit testing / Improve it (eg. rename files, move into seperate testing folder etc etc /src/tests)
- [x] Migrate env checking to a seperate module using zod - [x] Migrate env checking to a seperate module using zod
- [x] Rewrite Runner.ts & split - [x] Rewrite Runner.ts & split
- [x] Docker image & Propper compose - [x] Docker image & Propper compose
- [x] Remove the entire pip DOWNLOAD cache for shared functions as its a security risk - [x] Remove the entire pip DOWNLOAD cache for shared functions as its a security risk
- [x] Shift Enter Submits on modals (any modal) (add as a agent rule for the future) - [x] Shift Enter Submits on modals (any modal) (add as a agent rule for the future)
- [ ] Fix SHSF Global & Redo it - [ ] Fix SHSF Global & Redo it (security fixed: link-status/unlink now require admin or instance secret; full redo still open)
- [x] Built-in MCP Server & ready to copy Agentic commands ("claude mcp xxxx", "openclaw mcp add xxxxx", and codex ofc) // Seperate Agents Page & usecases for agents using shsf - [x] Built-in MCP Server & ready to copy Agentic commands ("claude mcp xxxx", "openclaw mcp add xxxxx", and codex ofc) // Seperate Agents Page & usecases for agents using shsf
- [x] Add cron and more mcp tools - [x] Add cron and more mcp tools
@@ -35,10 +35,10 @@
- [x] Add a way to manage function dependencies (eg. requirements.txt) from the UI - [x] Add a way to manage function dependencies (eg. requirements.txt) from the UI
- [x] Runner & Backend: Implement a Block for interactions on Functions while “Container ready.” not reached (pretty much wait for “[SHSF] Container ready.”). Message would be something like “Function is not ready yet.” - [x] Runner & Backend: Implement a Block for interactions on Functions while “Container ready.” not reached (pretty much wait for “[SHSF] Container ready.”). Message would be something like “Function is not ready yet.”
- [ ] Function Logs Update - [ ] Function Logs Update
- Investigate (Shows only Errors) - [x] Investigate (Shows only Errors) — cache hits were never logged, dev runs always reported exit 0, exit-code parsing used the wrong key
- Hide Specifics (regex blur) - Hide Specifics (regex blur)
- Toggle to only log Generic Headers - Toggle to only log Generic Headers
- [ ] any Modal(???) / function update: Scroll to top on error or move errors to toast (preferred) - [x] any Modal(???) / function update: Scroll to top on error or move errors to toast (preferred)
- [x] Remove "Error fetching files: File edits are disabled while git is configured for this function. Remove git configuration to edit files." - [x] Remove "Error fetching files: File edits are disabled while git is configured for this function. Remove git configuration to edit files."
## P4 - Priority 4 (Trivial) ## P4 - Priority 4 (Trivial)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "shsf", "name": "shsf",
"version": "2.0.1", "version": "2.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
+57
View File
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from "@testing-library/react";
import Modal, { ModalError } from "./Modal";
describe("Modal", () => {
it("renders title and children when open", () => {
render(
<Modal isOpen={true} onClose={jest.fn()} title="Test Modal">
<p>modal body</p>
</Modal>,
);
expect(screen.getByRole("heading", { name: "Test Modal" })).toBeInTheDocument();
expect(screen.getByText("modal body")).toBeInTheDocument();
});
it("renders nothing when closed", () => {
render(
<Modal isOpen={false} onClose={jest.fn()} title="Hidden">
<p>hidden body</p>
</Modal>,
);
expect(screen.queryByText("hidden body")).not.toBeInTheDocument();
});
it("closes on Escape", () => {
const onClose = jest.fn();
render(
<Modal isOpen={true} onClose={onClose} title="Esc">
<p>body</p>
</Modal>,
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not close on Escape while loading", () => {
const onClose = jest.fn();
render(
<Modal isOpen={true} onClose={onClose} title="Busy" isLoading={true}>
<p>body</p>
</Modal>,
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
});
describe("ModalError", () => {
it("renders the error message", () => {
render(<ModalError message="Something failed" />);
expect(screen.getByText("Something failed")).toBeInTheDocument();
});
it("renders nothing without a message", () => {
const { container } = render(<ModalError message={null} />);
expect(container).toBeEmptyDOMElement();
});
});
+7
View File
@@ -1,4 +1,5 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { toast } from "react-toastify";
import { Icon } from "../ui/Icon"; import { Icon } from "../ui/Icon";
export const inputClass = export const inputClass =
@@ -45,6 +46,12 @@ export function ModalFooter({ children }: { children: React.ReactNode }) {
} }
export function ModalError({ message }: { message?: string | null }) { export function ModalError({ message }: { message?: string | null }) {
// Modals can be taller than the viewport with the error box rendered at the
// top, so a toast makes the failure visible regardless of scroll position.
useEffect(() => {
if (message) toast.error(message);
}, [message]);
if (!message) return null; if (!message) return null;
return ( return (
<div className="px-3 py-2.5 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"> <div className="px-3 py-2.5 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
@@ -28,6 +28,15 @@ const TriggerLogCard: React.FC<TriggerLogCardProps> = ({ log, expanded, onToggle
"bg-background/40 border border-white/[0.07] rounded-lg p-3 overflow-auto"; "bg-background/40 border border-white/[0.07] rounded-lg p-3 overflow-auto";
const sectionHeaderCls = "text-xs font-medium text-muted uppercase tracking-wider mb-2"; const sectionHeaderCls = "text-xs font-medium text-muted uppercase tracking-wider mb-2";
let exitCode: number | null = null;
try {
const parsed = JSON.parse(log.result ?? "");
if (typeof parsed?.exit_code === "number") exitCode = parsed.exit_code;
else if (typeof parsed?.exitCode === "number") exitCode = parsed.exitCode;
} catch {
// leave exitCode null when the result is not parseable
}
return ( return (
<div className="bg-surface border border-white/[0.07] rounded-lg overflow-hidden hover:border-white/[0.12] transition-colors"> <div className="bg-surface border border-white/[0.07] rounded-lg overflow-hidden hover:border-white/[0.12] transition-colors">
<div <div
@@ -36,9 +45,22 @@ const TriggerLogCard: React.FC<TriggerLogCardProps> = ({ log, expanded, onToggle
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-text"> <div className="flex items-center gap-2">
{new Date(log.createdAt).toLocaleString()} <p className="text-sm font-medium text-text">
</p> {new Date(log.createdAt).toLocaleString()}
</p>
{exitCode !== null && (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${
exitCode === 0
? "bg-green-500/10 text-green-400 border border-green-500/20"
: "bg-red-500/10 text-red-400 border border-red-500/20"
}`}
>
{exitCode === 0 ? "Success" : `Exit ${exitCode}`}
</span>
)}
</div>
<p className="text-xs text-muted">Execution #{log.id}</p> <p className="text-xs text-muted">Execution #{log.id}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -128,7 +150,7 @@ const TriggerLogCard: React.FC<TriggerLogCardProps> = ({ log, expanded, onToggle
className="flex items-center justify-between py-1 border-b border-white/[0.04] last:border-0" className="flex items-center justify-between py-1 border-b border-white/[0.04] last:border-0"
> >
<span className="text-muted text-xs">{took.description}</span> <span className="text-muted text-xs">{took.description}</span>
<span className="text-text text-xs font-mono">{took.value} ms</span> <span className="text-text text-xs font-mono">{Math.round(took.value * 1000)} ms</span>
</div> </div>
)) || ( )) || (
<p className="text-muted text-xs">No timing details available</p> <p className="text-muted text-xs">No timing details available</p>
+46
View File
@@ -0,0 +1,46 @@
import { fireEvent, renderHook } from "@testing-library/react";
import { useShiftEnterSubmit } from "./useShiftEnterSubmit";
describe("useShiftEnterSubmit", () => {
it("fires the callback on Ctrl+Enter", () => {
const onSubmit = jest.fn();
renderHook(() => useShiftEnterSubmit(onSubmit));
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("fires the callback on Cmd+Enter (macOS)", () => {
const onSubmit = jest.fn();
renderHook(() => useShiftEnterSubmit(onSubmit));
fireEvent.keyDown(document, { key: "Enter", metaKey: true });
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("does not fire on plain Enter or Ctrl with other keys", () => {
const onSubmit = jest.fn();
renderHook(() => useShiftEnterSubmit(onSubmit));
fireEvent.keyDown(document, { key: "Enter" });
fireEvent.keyDown(document, { key: "s", ctrlKey: true });
expect(onSubmit).not.toHaveBeenCalled();
});
it("does not fire when disabled", () => {
const onSubmit = jest.fn();
renderHook(() => useShiftEnterSubmit(onSubmit, false));
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
expect(onSubmit).not.toHaveBeenCalled();
});
it("removes the listener on unmount", () => {
const onSubmit = jest.fn();
const { unmount } = renderHook(() => useShiftEnterSubmit(onSubmit));
unmount();
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
expect(onSubmit).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -16,9 +16,9 @@ export const VERSION: {
patch: number; patch: number;
toString: () => string; toString: () => string;
} = { } = {
type: "SHSF API", type: "SHSF UI",
major: 2, major: 2,
minor: 0, minor: 1,
patch: 0, patch: 0,
toString() { toString() {
return `${this.major}.${this.minor}.${this.patch}`; return `${this.major}.${this.minor}.${this.patch}`;
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom";