Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions express/backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion express/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"build": "tsc",
"watch": "nodemon --config nodemon.json src/index.ts",
"watch:debug": "nodemon --config nodemon.json --inspect-brk src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node --require ts-node/register --test test/*.test.js"
},
"author": "",
"license": "MIT",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ declare module "@iobroker/repochecker" {
queryStringParameters: {
url: string;
branch?: string;
githubToken?: string;
};
};

Expand Down
89 changes: 74 additions & 15 deletions express/backend/src/api/repochecker.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,84 @@
import { handler } from "@iobroker/repochecker";
import { CheckRequest, CheckResult, handler } from "@iobroker/repochecker";
import { Router } from "express";
import Cookies from "universal-cookie";
import cookiesMiddleware from "universal-cookie-express";
import { COOKIE_NAME_PORTAL_TOKEN } from "../auth";

const router = Router();

router.get("/api/repochecker/", async function (req, res) {
handler(
{
queryStringParameters: {
url: req.query.url as string,
branch: req.query.branch as string | undefined,
},
},
null,
(error, result) => {
// Each check performs many HTTP requests, so only a few may run at once;
// additional checks wait in a queue until a slot becomes free.
const MAX_PARALLEL_CHECKS = 3;
Comment thread
Apollon77 marked this conversation as resolved.
Outdated
let runningChecks = 0;
const waitingChecks: (() => void)[] = [];

// Only one check per user (GitHub token) may be running or queued at a time.
const activeTokens = new Set<string>();

async function withCheckSlot<T>(fn: () => Promise<T>): Promise<T> {
if (runningChecks >= MAX_PARALLEL_CHECKS) {
await new Promise<void>((resolve) => waitingChecks.push(resolve));
Comment thread
Apollon77 marked this conversation as resolved.
} else {
runningChecks++;
}
try {
return await fn();
} finally {
const next = waitingChecks.shift();
if (next) {
// hand the slot over directly to the next waiting check
next();
} else {
runningChecks--;
}
}
}

function runRepochecker(request: CheckRequest): Promise<CheckResult> {
return new Promise((resolve, reject) => {
handler(request, null, (error, result) => {
Comment on lines +50 to +52
if (error || !result) {
res.status(500).send(error);
reject(error || new Error("Repochecker returned no result"));
} else {
res.status(result.statusCode).send(result.body);
resolve(result);
}
},
);
});
});
}

router.get("/api/repochecker/", cookiesMiddleware(), async function (req, res) {
const cookies = (req as any)["universalCookies"] as Cookies;
const ghToken: string | undefined =
cookies.get(COOKIE_NAME_PORTAL_TOKEN) || undefined;

if (ghToken) {
if (activeTokens.has(ghToken)) {
res.status(429).send(
"A repository check is already running for your account; please try again later",
);
return;
}
activeTokens.add(ghToken);
}
try {
const result = await withCheckSlot(() =>
runRepochecker({
queryStringParameters: {
url: req.query.url as string,
branch: req.query.branch as string | undefined,
githubToken: ghToken,
},
}),
);
res.status(result.statusCode).send(result.body);
} catch {
console.error("Repochecker request failed");
res.status(500).send("Repochecker request failed");
} finally {
if (ghToken) {
activeTokens.delete(ghToken);
}
}
});

export default router;
136 changes: 136 additions & 0 deletions express/backend/test/repochecker.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const repochecker = require("@iobroker/repochecker");
const { COOKIE_NAME_PORTAL_TOKEN } = require("../src/auth");
const router = require("../src/api/repochecker").default;
const routeStack = router.stack[0].route.stack;
const route = routeStack[routeStack.length - 1].handle;

const url = "https://github.com/example/ioBroker.test";

function request(token, query = { url }) {
return {
query,
universalCookies: {
get: (name) => (name === COOKIE_NAME_PORTAL_TOKEN ? token : undefined),
},
};
}

function response() {
return {
status(code) {
this.statusCode = code;
return this;
},
send(body) {
this.body = body;
return this;
},
};
}

const tick = () => new Promise((resolve) => setImmediate(resolve));

test("passes the cookie token to repochecker without changing the environment", async (t) => {
const environment = { ...process.env };
let parameters;
t.mock.method(repochecker, "handler", (request, ctx, callback) => {
parameters = request.queryStringParameters;
assert.equal(ctx, null);
callback(null, { statusCode: 201, body: "checked" });
});

for (const token of ["test-token", undefined, ""]) {
const res = response();
await route(request(token, { url, branch: "main" }), res);
assert.deepEqual(parameters, {
url,
branch: "main",
githubToken: token || undefined,
});
assert.equal(res.statusCode, 201);
assert.equal(res.body, "checked");
}
assert.deepEqual({ ...process.env }, environment);
});

test("allows only one check per user at a time", async (t) => {
const pending = [];
t.mock.method(repochecker, "handler", (request, ctx, callback) => {
pending.push({ request, callback });
});
const first = response();
const firstCheck = route(request("user-token"), first);
await tick();

const second = response();
await route(request("user-token"), second);
assert.equal(second.statusCode, 429);
assert.equal(pending.length, 1);

const other = response();
const otherCheck = route(request("other-token"), other);
await tick();
assert.equal(pending.length, 2);
assert.equal(pending[1].request.queryStringParameters.githubToken, "other-token");

pending[0].callback(null, { statusCode: 200, body: "first" });
pending[1].callback(null, { statusCode: 200, body: "other" });
await Promise.all([firstCheck, otherCheck]);
assert.equal(first.body, "first");
assert.equal(other.body, "other");

const retry = response();
const retryCheck = route(request("user-token"), retry);
await tick();
pending[2].callback(null, { statusCode: 200, body: "retry" });
await retryCheck;
assert.equal(retry.statusCode, 200);
});

test("queues checks beyond the parallel limit", async (t) => {
const pending = [];
t.mock.method(repochecker, "handler", (request, ctx, callback) => {
pending.push({ request, callback });
});
const tokens = ["a", "b", "c", "d"];
const responses = tokens.map(() => response());
const checks = tokens.map((token, i) => route(request(token), responses[i]));
await tick();
assert.equal(pending.length, 3);

pending[0].callback(null, { statusCode: 200, body: "a" });
await tick();
assert.equal(pending.length, 4);
assert.equal(pending[3].request.queryStringParameters.githubToken, "d");

for (const p of pending.slice(1)) {
p.callback(null, { statusCode: 200, body: p.request.queryStringParameters.githubToken });
}
await Promise.all(checks);
assert.deepEqual(responses.map((r) => r.body), tokens);
});

test("does not expose errors or tokens in responses or logs", async (t) => {
const log = t.mock.method(console, "error", () => {});
const error = new Error("test-token");
const handler = t.mock.method(repochecker, "handler");
for (const implementation of [
(request, ctx, callback) => callback(error),
(request, ctx, callback) => callback(null),
() => {
throw error;
},
]) {
handler.mock.mockImplementation(implementation);
const res = response();
await route(request("test-token"), res);
assert.equal(res.statusCode, 500);
assert.equal(res.body, "Repochecker request failed");
}
assert.equal(log.mock.calls.length, 3);
for (const call of log.mock.calls) {
assert.deepEqual(call.arguments, ["Repochecker request failed"]);
}
});
1 change: 1 addition & 0 deletions express/backend/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"include": ["src"],
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */

Expand Down
Loading