Compare commits
9 Commits
c0e06a09a6
...
6ec95c6f41
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ec95c6f41 | |||
| d2c614463c | |||
| d6e86c97f4 | |||
| 37e0171bdf | |||
| 9211b4e95d | |||
| b4d902ffb6 | |||
| 83a467a65e | |||
| ea4b78542e | |||
| 673e092414 |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.0",
|
||||
"name": "shsf-backend",
|
||||
"description": "Backend for SHSF",
|
||||
"private": true,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export const VERSION: {
|
||||
} = {
|
||||
type: "SHSF API",
|
||||
major: 2,
|
||||
minor: 0,
|
||||
minor: 1,
|
||||
patch: 0,
|
||||
toString() {
|
||||
return `${this.major}.${this.minor}.${this.patch}`;
|
||||
|
||||
@@ -120,6 +120,11 @@ export async function getExitCodeFromLog(triggerLog: TriggerLog): Promise<number
|
||||
return null;
|
||||
}
|
||||
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) {
|
||||
return parsed.exitCode;
|
||||
}
|
||||
|
||||
@@ -246,6 +246,27 @@ export async function executeLoadedHttpFunction(
|
||||
payloadHash as string,
|
||||
);
|
||||
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(
|
||||
ctr,
|
||||
JSON.parse(cached.result),
|
||||
|
||||
@@ -106,8 +106,9 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
// Includes overdue triggers (nextRun in the past): a missed
|
||||
// tick or server downtime must not permanently kill a cron.
|
||||
nextRun: {
|
||||
gte: now,
|
||||
lte: fiveMinutesFromNow,
|
||||
},
|
||||
},
|
||||
@@ -152,9 +153,9 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = interval.next();
|
||||
|
||||
if (next.getTime() <= now.getTime() + 1000) {
|
||||
// Fire based on the stored schedule so overdue triggers run instead
|
||||
// of waiting for (or missing) the next parsed boundary.
|
||||
if (cron.nextRun.getTime() <= now.getTime() + 1000) {
|
||||
const followingRun = interval.next().toDate();
|
||||
|
||||
await prisma.functionTrigger.update({
|
||||
@@ -225,7 +226,7 @@ export async function processCrons({ prisma, executeFunction }: SystemCronDepend
|
||||
})();
|
||||
} else {
|
||||
const secondsUntilNextRun = Math.round(
|
||||
(next.getTime() - now.getTime()) / 1000,
|
||||
(cron.nextRun.getTime() - now.getTime()) / 1000,
|
||||
);
|
||||
|
||||
if (secondsUntilNextRun <= 5) {
|
||||
@@ -285,6 +286,22 @@ export async function processStorageCleanup({ prisma }: SystemCronDependencies)
|
||||
} catch (error) {
|
||||
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");
|
||||
|
||||
@@ -193,7 +193,7 @@ export = new fileRouter.Path("/")
|
||||
await print(
|
||||
JSON.stringify({
|
||||
type: "end",
|
||||
exitCode: 0,
|
||||
exitCode: result?.exit_code ?? 0,
|
||||
output: output,
|
||||
result: result?.result,
|
||||
took: result?.tooks,
|
||||
@@ -229,7 +229,7 @@ export = new fileRouter.Path("/")
|
||||
{ mode: "dev_execute" },
|
||||
);
|
||||
|
||||
if (functionData.cache_enabled && result?.result) {
|
||||
if (functionData.cache_enabled && result?.exit_code === 0 && result?.result) {
|
||||
await setFunctionCache(
|
||||
functionData.id,
|
||||
payloadHash,
|
||||
|
||||
@@ -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 { OpenAPITags } from "../../../lib/openapi";
|
||||
|
||||
@@ -173,16 +174,27 @@ export = new fileRouter.Path("/")
|
||||
/**
|
||||
* POST /api/global/unlink
|
||||
* 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
|
||||
.ratelimit((limit) => limit.hits(5).window(10000).penalty(500))
|
||||
.document({
|
||||
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[],
|
||||
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: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -232,6 +244,28 @@ export = new fileRouter.Path("/")
|
||||
if (!data)
|
||||
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
|
||||
const linkStatus = await getLinkStatus();
|
||||
|
||||
|
||||
@@ -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 { OpenAPITags } from "../../../lib/openapi";
|
||||
|
||||
@@ -9,9 +10,20 @@ export = new fileRouter.Path("/").http(
|
||||
http
|
||||
.ratelimit((limit) => limit.hits(10).window(5000).penalty(50))
|
||||
.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[],
|
||||
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: {
|
||||
200: {
|
||||
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) => {
|
||||
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();
|
||||
return ctr.print({
|
||||
status: "OK",
|
||||
|
||||
@@ -8,6 +8,7 @@ export = new fileRouter.Path("/").http(
|
||||
"/api/global/showSecret",
|
||||
(http) =>
|
||||
http
|
||||
.ratelimit((limit) => limit.hits(3).window(10000).penalty(5000))
|
||||
.onRequest(async (ctr) => {
|
||||
const authCheck = await checkAuthentication(
|
||||
ctr.cookies.get(COOKIE),
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getSanitizedPayload,
|
||||
isSHSFBinaryEnvelope,
|
||||
SHSF_BINARY_TRANSPORT,
|
||||
} from "../../lib/Caching";
|
||||
} from "../lib/Caching";
|
||||
|
||||
describe("getSanitizedPayload", () => {
|
||||
it("returns non-object primitives as-is", async () => {
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
parseStoredEnvironmentVariables,
|
||||
serializeEnvironmentVariables,
|
||||
toDockerEnvironment,
|
||||
} from "../../lib/EnvironmentVariables";
|
||||
} from "../lib/EnvironmentVariables";
|
||||
|
||||
describe("EnvironmentVariables", () => {
|
||||
it("parses stored environment variables and ignores invalid entries", () => {
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
getAnalyticsRangeStart,
|
||||
normalizeAnalyticsRange,
|
||||
parseExecutionAnalyticsLog,
|
||||
} from "../../lib/FunctionAnalytics";
|
||||
} from "../lib/FunctionAnalytics";
|
||||
|
||||
describe("FunctionAnalytics helpers", () => {
|
||||
it("normalizes unknown ranges to 7d", () => {
|
||||
+18
-7
@@ -1,18 +1,29 @@
|
||||
import { TriggerLog } from '@prisma/client';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getExitCodeFromLog, stripHeadersFromPayload } from '../../lib/FunctionLogging';
|
||||
import { getExitCodeFromLog, stripHeadersFromPayload } from '../lib/FunctionLogging';
|
||||
|
||||
describe('getExitCodeFromLog', () => {
|
||||
it('should return the correct exit code',async () => {
|
||||
const expectedExitCode = 0; // Replace with the expected exit code for your test case
|
||||
const test = {
|
||||
const makeLog = (result: string) =>
|
||||
({
|
||||
createdAt: new Date(),
|
||||
functionId: 1,
|
||||
id: 1,
|
||||
result: JSON.stringify({ exitCode: expectedExitCode }),
|
||||
} as TriggerLog;
|
||||
result,
|
||||
}) 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);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import {
|
||||
hasConfiguredRateLimitBuckets,
|
||||
normalizeFunctionRateLimitConfig,
|
||||
resetFunctionRateLimitState,
|
||||
} from "../../lib/FunctionRateLimit";
|
||||
} from "../lib/FunctionRateLimit";
|
||||
|
||||
const fallbackWindowMs = parseInt(env.RATELIMIT || "0", 10) || 0;
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { getGitEditBlock } from "../../lib/GitEditGuards";
|
||||
import { getGitEditBlock } from "../lib/GitEditGuards";
|
||||
|
||||
describe("getGitEditBlock", () => {
|
||||
it("blocks edits when git_url is configured", async () => {
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
listGitAppFiles,
|
||||
removeGitMetadata,
|
||||
stripCredentialsFromUrl,
|
||||
} from "../../lib/GitOps";
|
||||
import { getFunctionBaseDir, getFunctionAppDir } from "../../lib/StoragePaths";
|
||||
} from "../lib/GitOps";
|
||||
import { getFunctionBaseDir, getFunctionAppDir } from "../lib/StoragePaths";
|
||||
|
||||
const testFunctionIds = new Set<number>();
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { executeLoadedHttpFunction } from "../../lib/HttpExecution";
|
||||
import { executeLoadedHttpFunction } from "../lib/HttpExecution";
|
||||
|
||||
const baseFunctionData = {
|
||||
id: 10,
|
||||
+6
-6
@@ -8,28 +8,28 @@ import {
|
||||
prepareRunnerTransport,
|
||||
readRunnerResult,
|
||||
revokeLegacyFunctionDbTokens,
|
||||
} from "../../lib/RunnerTransport";
|
||||
} from "../lib/RunnerTransport";
|
||||
import {
|
||||
FunctionStorageService,
|
||||
StorageServiceError,
|
||||
} from "../../lib/FunctionStorageService";
|
||||
} from "../lib/FunctionStorageService";
|
||||
import {
|
||||
DbComScriptCS,
|
||||
DbComScriptGO,
|
||||
DbComScriptPY,
|
||||
ShsfRuntimeScriptCS,
|
||||
} from "../../lib/RunnerScripts";
|
||||
} from "../lib/RunnerScripts";
|
||||
import {
|
||||
generateDotnetRunnerScript,
|
||||
generateGoRunnerWrapperCode,
|
||||
generatePythonRunnerScript,
|
||||
} from "../../lib/RunnerRuntimeScripts";
|
||||
} from "../lib/RunnerRuntimeScripts";
|
||||
|
||||
type TestStorageDb = NonNullable<
|
||||
ConstructorParameters<typeof FunctionStorageService>[0]
|
||||
>;
|
||||
|
||||
vi.mock("../../index.js", () => ({
|
||||
vi.mock("../index.js", () => ({
|
||||
prisma: {
|
||||
accessToken: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
@@ -209,7 +209,7 @@ describe("generated transport scripts", () => {
|
||||
|
||||
describe("legacy function DB token cleanup", () => {
|
||||
it("deletes hidden legacy function DB tokens", async () => {
|
||||
const { prisma } = await import("../../index.js");
|
||||
const { prisma } = await import("../index.js");
|
||||
await revokeLegacyFunctionDbTokens();
|
||||
expect(prisma.accessToken.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
getFunctionExecutionsDir,
|
||||
getGitRepoDir,
|
||||
getShsfDataRoot,
|
||||
} from "../../lib/StoragePaths";
|
||||
} from "../lib/StoragePaths";
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
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>) {
|
||||
const bodyState = {
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
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/**"],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# TODO.md
|
||||
|
||||
## P0 - Priority 0 (Critical)
|
||||
- [ ] 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 UI unit testing / Improve it
|
||||
- [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] Rewrite Runner.ts & split
|
||||
- [x] Docker image & Propper compose
|
||||
- [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)
|
||||
- [ ] 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] 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] 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
|
||||
- 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)
|
||||
- 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."
|
||||
|
||||
## P4 - Priority 4 (Trivial)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shsf",
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Icon } from "../ui/Icon";
|
||||
|
||||
export const inputClass =
|
||||
@@ -45,6 +46,12 @@ export function ModalFooter({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
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;
|
||||
return (
|
||||
<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";
|
||||
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 (
|
||||
<div className="bg-surface border border-white/[0.07] rounded-lg overflow-hidden hover:border-white/[0.12] transition-colors">
|
||||
<div
|
||||
@@ -36,9 +45,22 @@ const TriggerLogCard: React.FC<TriggerLogCardProps> = ({ log, expanded, onToggle
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text">
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text">
|
||||
{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>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
)) || (
|
||||
<p className="text-muted text-xs">No timing details available</p>
|
||||
|
||||
@@ -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
@@ -16,9 +16,9 @@ export const VERSION: {
|
||||
patch: number;
|
||||
toString: () => string;
|
||||
} = {
|
||||
type: "SHSF API",
|
||||
type: "SHSF UI",
|
||||
major: 2,
|
||||
minor: 0,
|
||||
minor: 1,
|
||||
patch: 0,
|
||||
toString() {
|
||||
return `${this.major}.${this.minor}.${this.patch}`;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
Reference in New Issue
Block a user