mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-02 08:35:24 +00:00
* feat: let the login page send the token back to the mobile app * feat: add host-scripts entry to the plugin sdk * feat: let plugins say how they show in the mobile app * feat: frame host for running one plugin view outside the shell * fix: build the SDK host modules in production mode * chore: note the SDK host build fix in the changelog * feat: list plugin engine ranges for the mobile app * feat: host color and icon columns that sync * chore: update translations and plugin pins * fix: test * fix: show source address when rejecting proxy auth * fix: deleting your own account removes all of its data * fix(deps): update body-parser to 2.4.0 (#1736) * fix: body-parser 보안 패치 반영 * chore: 자동 검사를 막던 기존 JSON 서식 정리 --------- Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> * fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735) * fix: honor shared credentials on shared hosts (#1730) * fix: honor shared credentials on shared hosts and in personal SSH auth * fix: snapshot shared SSH auth from a credential shared with the host owner * fix: size DataView list columns by the table's own width (#1713) * fix: size DataView list columns by the table's own width The list grid was built from every column, so columns hidden below md/lg left empty tracks and the visible cells landed under the wrong headers. Each width now gets a template of only its visible columns (CSS variables + container queries on the table, so a narrow panel counts, not the window), the header uses the same gap as the rows, and rows use a minimum height so wrapped cells no longer overlap. * test: wait for the title bar offset effect before asserting The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height is set in a later passive effect, so reading it right after findByText raced (3-5 of 40 runs under load, on main too). * chore: rerun CI against dev-26.10.6 * fix: unlock passphrase keys once per connect burst (#1737) * feat: give ssh transports the host origin and sync id (#1717) * fix: wake other clients after a sync push (Plugin-Sync#1) * feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4) * feat: rail items can show a status dot (Plugin-Sync#5) * fix: add the desktop shared copy banner text (#1709) * feat: sidebar preference for alternating rows (#1712) * fix: striped rows text * feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36) * fix: show the logo in the macos menu bar (#1728) * fix: number shortcuts skip the home tab (#1724) * fix: show plugins that could not be downloaded as failed (#1714) * fix: pass the host id as text when opening its editor * fix: retry a plugin bundle that was not ready yet (#1708) * fix: let commonjs plugins fall back to core packages on the desktop * fix: let non docker installs switch nginx to https (#1711) * feat: remove a folder share from its hosts too (#1719) * chore: changelog for merged pull requests * fix: accept the release .sig when installing a plugin from a file * feat: folder logins for shared hosts, read only plugin sync, full desktop builds * fix: test * fix: stop dev runner restart loops and narrow plugin watchers * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.10.6 --------- Co-authored-by: renovys <renovy@gmail.com> Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> Co-authored-by: Sebastian Cao <cycsmail@gmail.com> Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com> Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
161 lines
4.0 KiB
TypeScript
161 lines
4.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { Request, RequestHandler, Response } from "express";
|
|
|
|
const state = vi.hoisted(() => ({
|
|
users: new Map<
|
|
string,
|
|
{
|
|
id: string;
|
|
username: string;
|
|
isAdmin: boolean;
|
|
isExternal: boolean;
|
|
passwordHash: string;
|
|
}
|
|
>(),
|
|
adminCount: 1,
|
|
deleted: [] as string[],
|
|
}));
|
|
|
|
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
|
|
|
|
vi.mock("../../../utils/logger.js", () => ({
|
|
authLogger: {
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
info: vi.fn(),
|
|
success: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("bcryptjs", () => ({
|
|
default: {
|
|
compare: async (password: string, hash: string) =>
|
|
hash === `hash:${password}`,
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
|
createCurrentUserRepository: () => ({
|
|
findById: async (id: string) => state.users.get(id) ?? null,
|
|
countAdmins: async () => state.adminCount,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../../database/routes/delete-user-data.js", () => ({
|
|
deleteUserAndRelatedData: async (userId: string) => {
|
|
state.deleted.push(userId);
|
|
},
|
|
}));
|
|
|
|
const { registerUserDeleteAccountRoutes } =
|
|
await import("../../../database/routes/user-delete-account-routes.js");
|
|
|
|
let handler: RequestHandler | null = null;
|
|
registerUserDeleteAccountRoutes(
|
|
{
|
|
delete: (_path: string, ...handlers: RequestHandler[]) => {
|
|
handler = handlers[handlers.length - 1];
|
|
},
|
|
} as unknown as import("express").Router,
|
|
(_req, _res, next) => next(),
|
|
);
|
|
|
|
async function deleteAccount(userId: string, body: Record<string, unknown>) {
|
|
const req = { userId, body } as unknown as Request;
|
|
const res = {
|
|
statusCode: 200,
|
|
jsonBody: null as Record<string, unknown> | null,
|
|
status(code: number) {
|
|
this.statusCode = code;
|
|
return this;
|
|
},
|
|
json(payload: Record<string, unknown>) {
|
|
this.jsonBody = payload;
|
|
return this;
|
|
},
|
|
};
|
|
await handler!(req, res as unknown as Response, () => {});
|
|
return res;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
state.adminCount = 1;
|
|
state.deleted = [];
|
|
state.users = new Map([
|
|
[
|
|
"u1",
|
|
{
|
|
id: "u1",
|
|
username: "user",
|
|
isAdmin: false,
|
|
isExternal: false,
|
|
passwordHash: "hash:secret",
|
|
},
|
|
],
|
|
[
|
|
"admin1",
|
|
{
|
|
id: "admin1",
|
|
username: "admin",
|
|
isAdmin: true,
|
|
isExternal: false,
|
|
passwordHash: "hash:secret",
|
|
},
|
|
],
|
|
[
|
|
"sso1",
|
|
{
|
|
id: "sso1",
|
|
username: "sso",
|
|
isAdmin: false,
|
|
isExternal: true,
|
|
passwordHash: "hash:secret",
|
|
},
|
|
],
|
|
]);
|
|
});
|
|
|
|
describe("DELETE /delete-account", () => {
|
|
it("runs the full user cleanup", async () => {
|
|
const res = await deleteAccount("u1", { password: "secret" });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(state.deleted).toEqual(["u1"]);
|
|
});
|
|
|
|
it("needs a password", async () => {
|
|
const res = await deleteAccount("u1", {});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(state.deleted).toEqual([]);
|
|
});
|
|
|
|
it("refuses a wrong password", async () => {
|
|
const res = await deleteAccount("u1", { password: "nope" });
|
|
expect(res.statusCode).toBe(401);
|
|
expect(state.deleted).toEqual([]);
|
|
});
|
|
|
|
it("refuses external accounts", async () => {
|
|
const res = await deleteAccount("sso1", { password: "secret" });
|
|
expect(res.statusCode).toBe(403);
|
|
expect(state.deleted).toEqual([]);
|
|
});
|
|
|
|
it("refuses the last admin", async () => {
|
|
const res = await deleteAccount("admin1", { password: "secret" });
|
|
expect(res.statusCode).toBe(403);
|
|
expect(state.deleted).toEqual([]);
|
|
});
|
|
|
|
it("lets an admin go when another admin is left", async () => {
|
|
state.adminCount = 2;
|
|
const res = await deleteAccount("admin1", { password: "secret" });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(state.deleted).toEqual(["admin1"]);
|
|
});
|
|
|
|
it("404s for an unknown user", async () => {
|
|
const res = await deleteAccount("ghost", { password: "secret" });
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|