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
7 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 −0
package.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": {

view file

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);

view file

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+}

view file

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 }

view file

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 = {

view file

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+}

view file

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+});

view file