3917c91388df
add the admin sign-in at /sign-in with an argon2id check, a per-address and global failure limit and a double-submit token, 30-day revocable sessions stored as hashes, sign-out, and the /admin guard requiring a session plus origin and form token on every post, with the status page (v3.0.34)
- time
- 2026-09-26 20:17 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- kepr 3 implementation
- parent
- 1c735cca38e4
↓ kepr-3917c913.tar.gzatlas at this save
files changed7 files
package.json+1 −1public/css/kepr.css+5 −0src/admin/console.ts+86 −0src/server/app.ts+12 −1src/server/db.ts+5 −0src/server/login.ts+136 −0tests/admin.test.ts+230 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.33",3+ "version": "3.0.34",44 "private": true,55 "type": "module",66 "scripts": {public/css/kepr.css+5 −0
@@ -1201,6 +1201,11 @@12011201 border-radius: 999px;12021202 padding: 4px 10px;12031203 }1204+button.issues-tab {1205+ font: inherit;1206+ background: none;1207+ cursor: pointer;1208+}12041209 .issues-tab.active {12051210 color: var(--accent);12061211 border-color: var(--accent-tint-12);src/admin/console.ts+86 −0
@@ -0,0 +1,86 @@1+import type { Context, Hono } from "hono";2+import { checkFormat, listRepos } from "../bucket/repo.ts";3+import type { SyncTarget } from "../bucket/sync.ts";4+import { type Html, html } from "../pages/html.ts";5+import { type PageContext, page } from "../pages/layout.ts";6+import { formToken } from "../server/login.ts";7+8+export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string };9+10+const SECTIONS = [11+ ["/admin", "status"],12+ ["/admin/repos", "repos"],13+ ["/admin/issues", "issues"],14+ ["/admin/releases", "releases"],15+ ["/admin/site", "site"],16+] as const;17+18+/** The hidden same-site token (§25) that every admin form posts back. */19+export function formField(c: Context): Html {20+ return html`<input type="hidden" name="form" value="${formToken(c)}">`;21+}22+23+/** An admin page: the site's own look, with the console's sections and sign-out on top. */24+export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {25+ const tab = ([href, name]: (typeof SECTIONS)[number]) =>26+ html`<a class="issues-tab${c.req.path === href ? " active" : ""}" href="${href}">${name}</a>`;27+ return page(ctx, {28+ title: `${title} · admin`,29+ description: "",30+ path: c.req.path,31+ body: html`<form class="issues-bar mono" method="post" action="/admin/sign-out">${SECTIONS.map(tab)}${formField(c)}<button class="issues-tab issues-new" type="submit">sign out</button></form>32+<h1 class="file-page-title">${title}</h1>33+${body}`,34+ });35+}36+37+// ponytail: counted once per process by walking the source, since there is no build step.38+let runningLines: number | null = null;39+async function sourceLines(): Promise<number> {40+ if (runningLines !== null) return runningLines;41+ let total = 0;42+ const root = `${import.meta.dir}/../..`;43+ for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) {44+ if (path.endsWith(".test.ts")) continue;45+ const text = await Bun.file(`${root}/${path}`).text();46+ total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);47+ }48+ runningLines = total;49+ return total;50+}51+52+/** §24's Status section at `/admin`: the bucket, each repo's format, and the running version. */53+export function registerStatus(app: Hono, ctx: AdminContext): void {54+ app.get("/admin", async (c) => {55+ let bucket: Html;56+ try {57+ const repos = await listRepos(ctx.sync.bucket);58+ const formats = await Promise.all(59+ repos.map(async (repo) => {60+ try {61+ await checkFormat(ctx.sync.bucket, repo);62+ return html`<li><span class="mono">${repo}</span> <span class="muted">format ok</span></li>`;63+ } catch (error) {64+ return html`<li><span class="mono">${repo}</span> <span class="issues-error">${String(error)}</span></li>`;65+ }66+ }),67+ );68+ bucket = html`<p>Bucket reachable: ${repos.length} repos.</p><ul>${formats}</ul>`;69+ } catch (error) {70+ bucket = html`<p class="issues-error" role="alert">Bucket unreachable: ${String(error)}</p>`;71+ }72+ const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {73+ version: string;74+ };75+ const lines = (await sourceLines()).toLocaleString("en-US");76+ return c.html(77+ adminPage(78+ c,79+ ctx,80+ "status",81+ html`<section class="repo-section markdown">${bucket}82+<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></section>`,83+ ),84+ );85+ });86+}src/server/app.ts+12 −1
@@ -3,6 +3,7 @@33 import { Hono } from "hono";44 import { bodyLimit } from "hono/body-limit";55 import { secureHeaders } from "hono/secure-headers";6+import { registerStatus } from "../admin/console.ts";67 import { openBucket } from "../bucket/reader.ts";78 import { isSafePath } from "../bucket/repo.ts";89 import { registerIssuePages } from "../issues/pages.ts";@@ -18,6 +19,7 @@1819 import { registerReleaseRoutes } from "../releases/routes.ts";1920 import { registerTarballRoutes } from "../tarball/archive.ts";2021 import type { Config } from "./config.ts";22+import { registerLogin } from "./login.ts";2123 2224 const MAX_BODY_BYTES = 64 * 1024;2325 const PUBLIC_DIR = join(import.meta.dir, "../../public");@@ -61,6 +63,15 @@6163 return c.body(await file.bytes(), 200, { "Content-Type": file.type });6264 });6365 const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };66+ const bucket = openBucket(config.bucket);67+ // Before every other route, so the admin guard runs ahead of each `/admin` handler.68+ registerLogin(app, ctx, config.adminPasswordHash);69+ const admin = {70+ ...ctx,71+ dataDir: config.dataDir,72+ sync: { bucket, kepr, mirrorDir: ctx.mirrorDir, tarballDir: join(config.dataDir, "tarballs") },73+ };74+ registerStatus(app, admin);6475 registerHousekeeping(app, ctx);6576 registerFrontPages(app, ctx);6677 registerDocsPages(app, ctx);@@ -72,6 +83,6 @@7283 registerReleasesPage(app, ctx);7384 registerIssuePages(app, ctx);7485 registerTarballRoutes(app, ctx, join(config.dataDir, "tarballs"));75- registerReleaseRoutes(app, ctx, openBucket(config.bucket));86+ registerReleaseRoutes(app, ctx, bucket);7687 return app;7788 }src/server/db.ts+5 −0
@@ -71,6 +71,11 @@7171 created_at INTEGER NOT NULL7272 );7373 CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);74+-- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept.75+CREATE TABLE IF NOT EXISTS sessions (76+ token_hash TEXT PRIMARY KEY,77+ expires_at INTEGER NOT NULL78+);7479 `;7580 7681 export type RepoSettings = {src/server/login.ts+136 −0
@@ -0,0 +1,136 @@1+import { createHash, randomBytes, timingSafeEqual } from "node:crypto";2+import type { Context, Hono } from "hono";3+import { deleteCookie, getCookie, setCookie } from "hono/cookie";4+import { html } from "../pages/html.ts";5+import { type PageContext, page } from "../pages/layout.ts";6+import { clientIp } from "./client.ts";7+8+const SESSION_SECONDS = 30 * 86400;9+const SESSION_COOKIE = "kepr_session";10+const SIGN_IN_COOKIE = "kepr_sign_in";11+const COOKIE = { httpOnly: true, secure: true, sameSite: "Strict", path: "/" } as const;12+const HEX64 = /^[0-9a-f]{64}$/;13+const FAILURE_WINDOW_MS = 15 * 60 * 1000;14+const FAILURES_PER_ADDRESS = 5;15+// The global backoff: past this many failures from anywhere, every sign-in waits out the window.16+const FAILURES_ANYWHERE = 30;17+18+// Failed sign-ins, oldest first, in memory: a restart forgets them, but an attacker can't cause one.19+const failures: { at: number; address: string }[] = [];20+// Addresses are keyed hashes even in memory, as issue filings store them (§23).21+const addressKey = randomBytes(32);22+23+const nowSeconds = () => Math.floor(Date.now() / 1000);24+const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");25+26+function sameText(a: string, b: string): boolean {27+ const [x, y] = [Buffer.from(a), Buffer.from(b)];28+ return x.length === y.length && timingSafeEqual(x, y);29+}30+31+/** The session cookie's token when it names a live session in kepr.db, else null. */32+function sessionToken(ctx: PageContext, c: Context): string | null {33+ const token = getCookie(c, SESSION_COOKIE);34+ if (!token || !HEX64.test(token)) return null;35+ const row = ctx.kepr36+ .query<{ n: number }, [string, number]>(37+ "SELECT 1 AS n FROM sessions WHERE token_hash = ? AND expires_at > ?",38+ )39+ .get(sha256(token), nowSeconds());40+ return row ? token : null;41+}42+43+/**44+ * The same-site token every admin form carries (§25): derived from the session's secret token, so45+ * a page on another site can neither read nor guess it, and it dies with the session.46+ */47+export function formToken(c: Context): string {48+ return sha256(`form:${getCookie(c, SESSION_COOKIE) ?? ""}`);49+}50+51+/**52+ * §24's login at the unlinked `/sign-in`, sessions, sign-out, and the guard in front of every53+ * `/admin` route: a live session, and for anything but GET a matching `Origin` and form token.54+ */55+export function registerLogin(app: Hono, ctx: PageContext, passwordHash: string): void {56+ const origin = `https://${ctx.domain}`;57+ const refuse = (c: Context, status: 401 | 403 | 429, text: string) => {58+ c.header("Cache-Control", "no-store");59+ const body = html`<h1 class="file-page-title">${text}</h1>`;60+ return c.html(page(ctx, { title: "admin", description: "", path: c.req.path, body }), status);61+ };62+63+ app.use("/admin/*", async (c, next) => {64+ const token = sessionToken(ctx, c);65+ if (!token) return refuse(c, 401, "Sign in first.");66+ if (c.req.method !== "GET") {67+ if (c.req.header("origin") !== origin) return refuse(c, 403, "Refused: wrong origin.");68+ const form = (await c.req.parseBody()).form;69+ if (typeof form !== "string" || !sameText(form, formToken(c))) {70+ return refuse(c, 403, "Refused: this form expired. Reload and try again.");71+ }72+ }73+ await next();74+ c.header("Cache-Control", "no-store");75+ });76+77+ app.get("/sign-in", (c) => signInPage(c, ctx, "", 200));78+79+ app.post("/sign-in", async (c) => {80+ const fields = await c.req.parseBody();81+ const cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";82+ const form = typeof fields.form === "string" ? fields.form : "";83+ if (c.req.header("origin") !== origin || !HEX64.test(cookie) || !sameText(form, cookie)) {84+ return refuse(c, 403, "Refused: this form expired. Reload and try again.");85+ }86+ const now = Date.now();87+ while (failures[0] && failures[0].at <= now - FAILURE_WINDOW_MS) failures.shift();88+ const address = createHash("sha256").update(addressKey).update(clientIp(c)).digest("hex");89+ const mine = failures.filter((failure) => failure.address === address).length;90+ if (failures.length >= FAILURES_ANYWHERE || mine >= FAILURES_PER_ADDRESS) {91+ c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000));92+ return signInPage(c, ctx, "Too many attempts. Try again later.", 429);93+ }94+ const password = typeof fields.password === "string" ? fields.password : "";95+ if (!(await Bun.password.verify(password, passwordHash))) {96+ failures.push({ at: now, address });97+ return signInPage(c, ctx, "That password is not right.", 401);98+ }99+ const token = randomBytes(32).toString("hex");100+ const at = nowSeconds();101+ ctx.kepr.query("DELETE FROM sessions WHERE expires_at <= ?").run(at);102+ ctx.kepr103+ .query("INSERT INTO sessions (token_hash, expires_at) VALUES (?, ?)")104+ .run(sha256(token), at + SESSION_SECONDS);105+ setCookie(c, SESSION_COOKIE, token, { ...COOKIE, maxAge: SESSION_SECONDS });106+ deleteCookie(c, SIGN_IN_COOKIE, COOKIE);107+ return c.redirect("/admin", 303);108+ });109+110+ app.post("/admin/sign-out", (c) => {111+ ctx.kepr112+ .query("DELETE FROM sessions WHERE token_hash = ?")113+ .run(sha256(getCookie(c, SESSION_COOKIE) ?? ""));114+ deleteCookie(c, SESSION_COOKIE, COOKIE);115+ return c.redirect("/", 303);116+ });117+}118+119+// A double-submit token: the form repeats a SameSite=Strict cookie that a cross-site post never has.120+function signInPage(c: Context, ctx: PageContext, error: string, status: 200 | 401 | 429) {121+ let cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";122+ if (!HEX64.test(cookie)) {123+ cookie = randomBytes(32).toString("hex");124+ setCookie(c, SIGN_IN_COOKIE, cookie, COOKIE);125+ }126+ c.header("Cache-Control", "no-store");127+ const body = html`<h1 class="file-page-title">sign in</h1>128+${error && html`<p class="issues-error mono" role="alert">${error}</p>`}129+<form class="issues-form" method="post" action="/sign-in">130+<input type="hidden" name="form" value="${cookie}">131+<label class="issues-label" for="password">password</label>132+<input class="issues-input" id="password" name="password" type="password" required autocomplete="current-password" autofocus>133+<button class="issues-submit" type="submit">sign in</button>134+</form>`;135+ return c.html(page(ctx, { title: "sign in", description: "", path: "/sign-in", body }), status);136+}tests/admin.test.ts+230 −0
@@ -0,0 +1,230 @@1+import type { Database } from "bun:sqlite";2+import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";3+import { mkdtemp } from "node:fs/promises";4+import { tmpdir } from "node:os";5+import { join } from "node:path";6+import type { Hono } from "hono";7+import { openBucket } from "../src/bucket/reader.ts";8+import { syncAll } from "../src/bucket/sync.ts";9+import { createApp } from "../src/server/app.ts";10+import type { Config } from "../src/server/config.ts";11+import { openKeprDb } from "../src/server/db.ts";12+import { makeRepos, writeRelease } from "./fixture.ts";13+14+const ORIGIN = "https://kepr.example";15+const PASSWORD = "correct horse battery staple";16+// The env Bun.serve passes: its requestIP gives the socket peer.17+const peer = (address: string) => ({ requestIP: () => ({ address }) });18+19+/** A browser against one app: a cookie jar, and the form token of the last admin page read. */20+class Browser {21+ cookies = new Map<string, string>();22+ form = "";23+ constructor(24+ readonly app: Hono,25+ readonly address = "203.0.113.5",26+ ) {}27+28+ async request(path: string, init: RequestInit = {}): Promise<Response> {29+ const headers = new Headers(init.headers);30+ headers.set("cookie", [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; "));31+ const response = await this.app.request(path, { ...init, headers }, peer(this.address));32+ for (const line of response.headers.getSetCookie()) {33+ const [pair = "", ...attributes] = line.split(";");34+ const [name = "", value = ""] = pair.split("=");35+ if (attributes.some((a) => /max-age=0/i.test(a.trim())) || value === "") {36+ this.cookies.delete(name);37+ } else this.cookies.set(name, value);38+ }39+ return response;40+ }41+42+ async get(path: string): Promise<Response> {43+ const response = await this.request(path);44+ const text = await response.clone().text();45+ this.form = /name="form" value="([^"]+)"/.exec(text)?.[1] ?? this.form;46+ return response;47+ }48+49+ post(path: string, fields: Record<string, string> | FormData, origin = ORIGIN) {50+ const body = fields instanceof FormData ? fields : new URLSearchParams(fields);51+ if (!(fields instanceof FormData) && !("form" in fields)) body.set("form", this.form);52+ else if (fields instanceof FormData && !fields.has("form")) body.set("form", this.form);53+ return this.request(path, { method: "POST", headers: origin ? { origin } : {}, body });54+ }55+56+ async signIn(password = PASSWORD): Promise<Response> {57+ await this.get("/sign-in");58+ const response = await this.post("/sign-in", { password });59+ if (response.status === 303) await this.get("/admin");60+ return response;61+ }62+}63+64+type Instance = { app: Hono; kepr: Database; dataDir: string; config: Config };65+66+async function instance(bucketDir: string, dataDir?: string): Promise<Instance> {67+ const dir = dataDir ?? (await mkdtemp(join(tmpdir(), "kepr-admin-")));68+ const kepr = openKeprDb(dir);69+ const bucket = { kind: "folder" as const, path: bucketDir };70+ await syncAll({71+ bucket: openBucket(bucket),72+ kepr,73+ mirrorDir: join(dir, "mirror"),74+ tarballDir: join(dir, "tarballs"),75+ });76+ const config: Config = {77+ domain: "kepr.example",78+ dataDir: dir,79+ adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"),80+ bucket,81+ };82+ return { app: createApp(config, kepr), kepr, dataDir: dir, config };83+}84+85+// Every admin route with sample parameters, from the app's own route table.86+function adminRoutes(app: Hono): { method: string; path: string }[] {87+ return app.routes88+ .filter((route) => route.path.startsWith("/admin") && route.method !== "ALL")89+ .map((route) => ({90+ method: route.method,91+ path: route.path92+ .replace(":repo", "app")93+ .replace(/:n\{[^}]+\}/, "1")94+ .replace(/:version(\{[^}]+\})?/, "1.0.0"),95+ }));96+}97+98+describe("admin sign-in, sessions and the guard", () => {99+ let inst: Instance;100+ let clock = Date.parse("2026-09-26T12:00:00Z");101+ const tick = (seconds: number) => {102+ clock += seconds * 1000;103+ setSystemTime(new Date(clock));104+ };105+106+ beforeAll(async () => {107+ setSystemTime(new Date(clock));108+ const repos = await makeRepos(["app"]);109+ await writeRelease(repos.bucketDir, "app", {110+ version: "1.0.0",111+ save: repos.saves.app as string,112+ files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],113+ });114+ inst = await instance(repos.bucketDir);115+ });116+ afterAll(() => setSystemTime());117+118+ test("the sign-in page sets a strict double-submit cookie and is never cached", async () => {119+ const response = await new Browser(inst.app).request("/sign-in");120+ expect(response.status).toBe(200);121+ expect(response.headers.get("cache-control")).toBe("no-store");122+ const cookie = response.headers.getSetCookie()[0] ?? "";123+ expect(cookie).toMatch(124+ /^kepr_sign_in=[0-9a-f]{64}; Path=\/; HttpOnly; Secure; SameSite=Strict$/,125+ );126+ });127+128+ test("the right password sets a 30-day strict session and opens the console", async () => {129+ const browser = new Browser(inst.app);130+ await browser.get("/sign-in");131+ const response = await browser.post("/sign-in", { password: PASSWORD });132+ expect(response.status).toBe(303);133+ expect(response.headers.get("location")).toBe("/admin");134+ const cookie = response.headers.getSetCookie().find((c) => c.startsWith("kepr_session="));135+ expect(cookie).toMatch(136+ /^kepr_session=[0-9a-f]{64}; Max-Age=2592000; Path=\/; HttpOnly; Secure; SameSite=Strict$/,137+ );138+ // Only the token's hash is stored.139+ const token = browser.cookies.get("kepr_session") ?? "";140+ const stored = inst.kepr141+ .query<{ token_hash: string }, []>("SELECT token_hash FROM sessions")142+ .all();143+ expect(stored.some((row) => row.token_hash === token)).toBe(false);144+ const admin = await browser.get("/admin");145+ expect(admin.status).toBe(200);146+ expect(admin.headers.get("cache-control")).toBe("no-store");147+ const text = await admin.text();148+ expect(text).toContain("Bucket reachable: 1 repos.");149+ expect(text).toContain("format ok");150+ expect(text).toMatch(/Running kepr 3\.0\.\d+: [\d,]+ lines of TypeScript/);151+ });152+153+ test("a wrong password, a foreign origin or a missing sign-in cookie is refused", async () => {154+ const browser = new Browser(inst.app, "198.51.100.1");155+ expect((await browser.signIn("wrong")).status).toBe(401);156+ expect(browser.cookies.has("kepr_session")).toBe(false);157+ await browser.get("/sign-in");158+ expect(159+ (await browser.post("/sign-in", { password: PASSWORD }, "https://evil.example")).status,160+ ).toBe(403);161+ expect((await browser.post("/sign-in", { password: PASSWORD }, "")).status).toBe(403);162+ browser.cookies.delete("kepr_sign_in");163+ expect((await browser.post("/sign-in", { password: PASSWORD })).status).toBe(403);164+ });165+166+ test("five failures lock one address out for 15 minutes, even with the right password", async () => {167+ const browser = new Browser(inst.app, "198.51.100.2");168+ for (let i = 0; i < 5; i++) expect((await browser.signIn("nope")).status).toBe(401);169+ const locked = await browser.signIn();170+ expect(locked.status).toBe(429);171+ expect(locked.headers.get("retry-after")).toBe("900");172+ expect((await new Browser(inst.app, "198.51.100.3").signIn()).status).toBe(303);173+ tick(901);174+ expect((await browser.signIn()).status).toBe(303);175+ });176+177+ test("thirty failures from anywhere back every sign-in off until the window passes", async () => {178+ for (let i = 0; i < 30; i++) {179+ expect((await new Browser(inst.app, `192.0.2.${i}`).signIn("nope")).status).toBe(401);180+ }181+ expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(429);182+ tick(901);183+ expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(303);184+ });185+186+ test("every admin route: signed out is 401; a post needs the origin and the form token", async () => {187+ const routes = adminRoutes(inst.app);188+ expect(routes.length).toBeGreaterThan(1);189+ const stranger = new Browser(inst.app);190+ for (const { method, path } of routes) {191+ const response =192+ method === "GET" ? await stranger.get(path) : await stranger.post(path, { form: "x" });193+ expect(`${method} ${path} ${response.status}`).toBe(`${method} ${path} 401`);194+ }195+ const browser = new Browser(inst.app);196+ await browser.signIn();197+ for (const { path } of routes.filter((route) => route.method === "POST")) {198+ const foreign = await browser.post(path, {}, "https://evil.example");199+ expect(`${path} ${foreign.status}`).toBe(`${path} 403`);200+ const bare = await browser.post(path, {}, "");201+ expect(`${path} ${bare.status}`).toBe(`${path} 403`);202+ const tokenless = await browser.post(path, { form: "" });203+ expect(`${path} ${tokenless.status}`).toBe(`${path} 403`);204+ const other = new Browser(inst.app);205+ await other.signIn();206+ const foreignToken = await browser.post(path, { form: other.form });207+ expect(`${path} ${foreignToken.status}`).toBe(`${path} 403`);208+ }209+ });210+211+ test("a session expires after 30 days", async () => {212+ const browser = new Browser(inst.app);213+ await browser.signIn();214+ tick(30 * 86400 - 10);215+ expect((await browser.get("/admin")).status).toBe(200);216+ tick(11);217+ expect((await browser.get("/admin")).status).toBe(401);218+ });219+220+ test("signing out revokes the session on the server, not just the cookie", async () => {221+ const browser = new Browser(inst.app);222+ await browser.signIn();223+ const token = browser.cookies.get("kepr_session") ?? "";224+ const out = await browser.post("/admin/sign-out", {});225+ expect(out.status).toBe(303);226+ expect(browser.cookies.has("kepr_session")).toBe(false);227+ browser.cookies.set("kepr_session", token);228+ expect((await browser.get("/admin")).status).toBe(401);229+ });230+});