mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move session sharing and collab rooms into a plugin (B12)
This commit is contained in:
1 parent
791d8656a1
commit
a276f6898c
207 files changed
+35771
-11072
No files matched your search
@@ -29,8 +29,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
|
||||
"audit_logs",
|
||||
"session_recordings",
|
||||
"api_keys",
|
||||
"session_shares",
|
||||
"session_share_participants",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SessionShareRepository } from "../../../database/repositories/session-share-repository.js";
|
||||
|
||||
describe("SessionShareRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SessionShareRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'owner-1', 'host-one', '10.0.0.1', 22, 'root', 'password'), (2, 'owner-1', 'host-two', '10.0.0.2', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new SessionShareRepository(context, onWrite);
|
||||
}
|
||||
|
||||
const FAR_FUTURE = "2999-01-01T00:00:00.000Z";
|
||||
const FAR_PAST = "2000-01-01T00:00:00.000Z";
|
||||
|
||||
it("creates a share and finds it by id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const created = await repo.create({
|
||||
id: "share-1",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-abc",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
id: "share-1",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
linkToken: "token-abc",
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
const found = await repo.findById("share-1");
|
||||
expect(found).toMatchObject({ id: "share-1", sessionId: "session-1" });
|
||||
});
|
||||
|
||||
it("findByLinkToken excludes revoked shares", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-revoked",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-revoked",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.findByLinkToken("token-revoked")).not.toBeNull();
|
||||
|
||||
await repo.revoke("share-revoked", "owner-1");
|
||||
|
||||
expect(await repo.findByLinkToken("token-revoked")).toBeNull();
|
||||
});
|
||||
|
||||
it("findByLinkToken excludes expired shares", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-expired",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-expired",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_PAST,
|
||||
});
|
||||
|
||||
expect(await repo.findByLinkToken("token-expired")).toBeNull();
|
||||
});
|
||||
|
||||
it("findByLinkToken returns active, non-expired, non-revoked shares", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-active",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "vnc",
|
||||
sessionId: "guac-session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-active",
|
||||
permissionLevel: "read-write",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const found = await repo.findByLinkToken("token-active");
|
||||
expect(found).toMatchObject({
|
||||
id: "share-active",
|
||||
protocol: "vnc",
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
});
|
||||
|
||||
it("findSharesTargetingUser returns only active user-targeted shares with host/owner metadata", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
id: "share-user-active",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "user",
|
||||
targetUserId: "guest-1",
|
||||
permissionLevel: "read-write",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
// Expired user share for the same target - must be excluded
|
||||
await repo.create({
|
||||
id: "share-user-expired",
|
||||
hostId: 2,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-2",
|
||||
shareType: "user",
|
||||
targetUserId: "guest-1",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_PAST,
|
||||
});
|
||||
|
||||
// Link share, not targeting a user - must be excluded even though it's active
|
||||
await repo.create({
|
||||
id: "share-link-active",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-3",
|
||||
shareType: "link",
|
||||
linkToken: "token-unrelated",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const shares = await repo.findSharesTargetingUser("guest-1");
|
||||
expect(shares).toHaveLength(1);
|
||||
expect(shares[0]).toMatchObject({
|
||||
id: "share-user-active",
|
||||
hostName: "host-one",
|
||||
ownerUsername: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("revoke only affects the requesting owner's own share", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-owned",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-owned",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.revoke("share-owned", "guest-1")).toBe(false);
|
||||
expect(await repo.revoke("share-owned", "owner-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("revokeAsAdmin revokes regardless of owner", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-admin-target",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-admin",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.revokeAsAdmin("share-admin-target")).toBe(true);
|
||||
expect(await repo.findByLinkToken("token-admin")).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteExpiredShares removes only expired rows", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-old",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-old",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_PAST,
|
||||
});
|
||||
await repo.create({
|
||||
id: "share-current",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-2",
|
||||
shareType: "link",
|
||||
linkToken: "token-current",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const deletedCount = await repo.deleteExpiredShares();
|
||||
expect(deletedCount).toBe(1);
|
||||
expect(await repo.findById("share-old")).toBeNull();
|
||||
expect(await repo.findById("share-current")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("touchShareUsage increments joinCount and sets lastJoinedAt", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-touch",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-touch",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
await repo.touchShareUsage("share-touch", "2026-01-01T00:00:00.000Z");
|
||||
let row = await repo.findById("share-touch");
|
||||
expect(row?.joinCount).toBe(1);
|
||||
expect(row?.lastJoinedAt).toBe("2026-01-01T00:00:00.000Z");
|
||||
|
||||
await repo.touchShareUsage("share-touch", "2026-01-02T00:00:00.000Z");
|
||||
row = await repo.findById("share-touch");
|
||||
expect(row?.joinCount).toBe(2);
|
||||
});
|
||||
|
||||
it("records and closes participant joins", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-participants",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-participants",
|
||||
permissionLevel: "read-write",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const participant = await repo.recordParticipantJoin(
|
||||
"share-participants",
|
||||
null,
|
||||
"Guest",
|
||||
);
|
||||
expect(participant).toMatchObject({
|
||||
shareId: "share-participants",
|
||||
userId: null,
|
||||
guestLabel: "Guest",
|
||||
});
|
||||
expect(participant.leftAt).toBeNull();
|
||||
|
||||
await repo.recordParticipantLeave(participant.id);
|
||||
});
|
||||
|
||||
it("write hook fires on mutating operations", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
id: "share-write-hook",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-write-hook",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
await repo.revoke("share-write-hook", "owner-1");
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deleteSharesForHost removes all shares for a host", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-host-1a",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-h1a",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
await repo.create({
|
||||
id: "share-host-1b",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-2",
|
||||
shareType: "link",
|
||||
linkToken: "token-h1b",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
await repo.create({
|
||||
id: "share-host-2",
|
||||
hostId: 2,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-3",
|
||||
shareType: "link",
|
||||
linkToken: "token-h2",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.deleteSharesForHost(1)).toBe(2);
|
||||
expect(await repo.findById("share-host-2")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -204,7 +204,7 @@ describe("plugins route", () => {
|
||||
id: "guest-plugin",
|
||||
manifestJson: JSON.stringify({
|
||||
capabilities: ["hosts:read"],
|
||||
contributes: { guest: true },
|
||||
contributes: { guest: true, guestViews: ["shared"] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -225,7 +225,10 @@ describe("plugins route", () => {
|
||||
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
|
||||
"guest-plugin",
|
||||
]);
|
||||
expect(body[0].contributes).toEqual({ guest: true });
|
||||
expect(body[0].contributes).toEqual({
|
||||
guest: true,
|
||||
guestViews: ["shared"],
|
||||
});
|
||||
expect(body[0]).not.toHaveProperty("capabilities");
|
||||
expect(body[0]).not.toHaveProperty("lastError");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user