mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
chore: merge dev-2.9.1 into dev-26.10.0
This commit is contained in:
commit
77bcbdb3d6
1310 files changed
+102834
-51010
No files matched your search
@@ -521,6 +521,89 @@ describe("external identities", () => {
|
||||
).rejects.toMatchObject({ code: "user_not_allowed" });
|
||||
});
|
||||
|
||||
it.each(["true", "false"])(
|
||||
"does not duplicate an unscoped migrated account with provisioning %s",
|
||||
async (enabled) => {
|
||||
addUser({
|
||||
id: "old",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-1",
|
||||
ssoProviderId: null,
|
||||
});
|
||||
const { runExternalIdentityMigration } =
|
||||
await import("../../upgrade/external-identity-migration.js");
|
||||
await runExternalIdentityMigration();
|
||||
h.state.settings.set("oidc_auto_provision", enabled);
|
||||
await expect(
|
||||
runLogin(fakeRequest() as never, external, {
|
||||
methodId: "oidc",
|
||||
rememberMe: false,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "legacy_identity_conflict" });
|
||||
expect([...h.state.users.keys()]).toEqual(["old"]);
|
||||
expect(h.state.identities).toHaveLength(1);
|
||||
expect(h.state.identities[0]).toMatchObject({
|
||||
userId: "old",
|
||||
providerId: "legacy-oidc",
|
||||
subject: "sub-1",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps equal subjects on explicitly different providers separate", async () => {
|
||||
addUser({
|
||||
id: "other",
|
||||
username: "Other Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-1",
|
||||
ssoProviderId: 9,
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "other",
|
||||
providerId: "9",
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
});
|
||||
h.state.settings.set("oidc_auto_provision", "true");
|
||||
const result = await runLogin(fakeRequest() as never, external, {
|
||||
methodId: "oidc",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("session");
|
||||
expect(h.state.users.size).toBe(2);
|
||||
expect(
|
||||
h.state.identities.find((link) => link.providerId === "3")?.userId,
|
||||
).not.toBe("other");
|
||||
});
|
||||
|
||||
it("uses a verified provider link even when the old provider column is empty", async () => {
|
||||
addUser({
|
||||
id: "old",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-1",
|
||||
ssoProviderId: null,
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "old",
|
||||
providerId: "3",
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
});
|
||||
const result = await runLogin(fakeRequest() as never, external, {
|
||||
methodId: "oidc",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("session");
|
||||
expect(h.state.users.size).toBe(1);
|
||||
});
|
||||
|
||||
it("re-checks the allowed list for existing users", async () => {
|
||||
addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" });
|
||||
h.state.identities.push({
|
||||
|
||||
@@ -4,12 +4,13 @@ import { DATABASE_DIALECT_ENV } from "../../../database/db/dialect.js";
|
||||
// getDb() throws unless a database was initialized; the context's handle is
|
||||
// not what this file is about.
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => ({}),
|
||||
getDb: vi.fn(() => ({})),
|
||||
getSqlite: () => ({}),
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn(), triggerSave: vi.fn() },
|
||||
}));
|
||||
|
||||
const {
|
||||
createCurrentSessionRepository,
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
createCurrentRepositoryLazyWriteHook,
|
||||
@@ -86,3 +87,35 @@ describe("createCurrentRepositoryLazyWriteHook", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("wires SQLite activity to the periodic flush and token changes to immediate persistence", async () => {
|
||||
const saved = process.env[DATABASE_DIALECT_ENV];
|
||||
process.env[DATABASE_DIALECT_ENV] = "sqlite";
|
||||
const { getDb } = await import("../../../database/db/index.js");
|
||||
const { DatabaseSaveTrigger } =
|
||||
await import("../../../utils/database-save-trigger.js");
|
||||
const dirty = vi
|
||||
.spyOn(DatabaseSaveTrigger, "markDirty")
|
||||
.mockImplementation(() => {});
|
||||
const force = vi.spyOn(DatabaseSaveTrigger, "forceSave").mockResolvedValue();
|
||||
const query = {
|
||||
update: () => query,
|
||||
set: () => query,
|
||||
where: async () => ({ changes: 1 }),
|
||||
};
|
||||
vi.mocked(getDb).mockReturnValue(
|
||||
query as unknown as ReturnType<typeof getDb>,
|
||||
);
|
||||
try {
|
||||
const repo = createCurrentSessionRepository();
|
||||
await repo.touch("session-1");
|
||||
expect(dirty).toHaveBeenCalledOnce();
|
||||
expect(force).not.toHaveBeenCalled();
|
||||
await repo.updateToken("session-1", "new-token");
|
||||
expect(force).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV];
|
||||
else process.env[DATABASE_DIALECT_ENV] = saved;
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,7 @@ describe("UserRepository and SessionRepository", () => {
|
||||
async function createRepositories(options: {
|
||||
onUserWrite?: () => void | Promise<void>;
|
||||
onSessionWrite?: () => void | Promise<void>;
|
||||
onSessionActivity?: () => void | Promise<void>;
|
||||
}): Promise<{
|
||||
users: UserRepository;
|
||||
sessions: SessionRepository;
|
||||
@@ -28,6 +29,7 @@ describe("UserRepository and SessionRepository", () => {
|
||||
options: {
|
||||
onUserWrite?: () => void | Promise<void>;
|
||||
onSessionWrite?: () => void | Promise<void>;
|
||||
onSessionActivity?: () => void | Promise<void>;
|
||||
} = {},
|
||||
): Promise<{
|
||||
users: UserRepository;
|
||||
@@ -38,7 +40,11 @@ describe("UserRepository and SessionRepository", () => {
|
||||
|
||||
return {
|
||||
users: new UserRepository(context, options.onUserWrite),
|
||||
sessions: new SessionRepository(context, options.onSessionWrite),
|
||||
sessions: new SessionRepository(
|
||||
context,
|
||||
options.onSessionWrite,
|
||||
options.onSessionActivity,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,6 +234,46 @@ describe("UserRepository and SessionRepository", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("separates activity persistence from security-critical session writes", async () => {
|
||||
let writes = 0,
|
||||
activity = 0;
|
||||
const repo = await createRepositories({
|
||||
onSessionWrite: () => {
|
||||
writes++;
|
||||
},
|
||||
onSessionActivity: () => {
|
||||
activity++;
|
||||
},
|
||||
});
|
||||
await repo.users.create({
|
||||
id: "user-1",
|
||||
username: "user",
|
||||
passwordHash: "hash",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
});
|
||||
await repo.sessions.create({
|
||||
id: "session-1",
|
||||
userId: "user-1",
|
||||
jwtToken: "token",
|
||||
deviceType: "desktop",
|
||||
deviceInfo: "Firefox",
|
||||
createdAt: "2026-06-26T00:00:00.000Z",
|
||||
expiresAt: "2026-06-27T00:00:00.000Z",
|
||||
lastActiveAt: "2026-06-26T00:00:00.000Z",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
await repo.sessions.touch("session-1", "2026-06-26T00:01:00.000Z");
|
||||
expect(activity).toBe(1);
|
||||
expect(writes).toBe(1);
|
||||
await repo.sessions.touch("session-1", "2026-06-26T00:01:10.000Z");
|
||||
expect(activity).toBe(1);
|
||||
await repo.sessions.updateToken("session-1", "renewed");
|
||||
expect(writes).toBe(2);
|
||||
await repo.sessions.revoke("session-1");
|
||||
expect(writes).toBe(3);
|
||||
});
|
||||
|
||||
it("revokes all user sessions except an optional current session", async () => {
|
||||
const repo = await createRepositories();
|
||||
await repo.users.create({
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* A key credential whose private key is a secret reference (op://...) is
|
||||
* saved as given and resolved when connecting (#1394). Parsing the reference
|
||||
* as a key used to reject it, and an update kept the old private key.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
created: [] as Array<Record<string, unknown>>,
|
||||
updated: [] as Array<Record<string, unknown>>,
|
||||
existing: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
const pass = (_req: unknown, _res: unknown, next: () => void) => next();
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
|
||||
req.userId = "u1";
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware: () => pass,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({ requirePermission: () => pass }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../sync/shared-copy-guard.js", () => ({
|
||||
rejectSharedCopyWrites: () => pass,
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async () => {},
|
||||
getAuditUsername: async () => "u1",
|
||||
getRequestMeta: () => ({ ipAddress: null, userAgent: null }),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return { authLogger: log, sshLogger: log, databaseLogger: log };
|
||||
});
|
||||
vi.mock("../../../database/routes/credential-key-routes.js", () => ({
|
||||
registerCredentialKeyRoutes: () => {},
|
||||
}));
|
||||
vi.mock("../../../database/routes/credential-deploy-routes.js", () => ({
|
||||
registerCredentialDeployRoutes: () => {},
|
||||
}));
|
||||
vi.mock("../../../database/routes/credential-bulk-routes.js", () => ({
|
||||
registerCredentialBulkRoutes: () => {},
|
||||
}));
|
||||
vi.mock("../../../hosts/delete-credential.js", () => ({
|
||||
deleteOwnedCredential: async () => {},
|
||||
}));
|
||||
vi.mock("../../../hosts/connect/core-providers.js", () => ({
|
||||
ensureCoreSshAuthProviders: () => {},
|
||||
}));
|
||||
vi.mock("../../../hosts/connect/auth-provider-registry.js", () => ({
|
||||
listCredentialTypes: () => ["password", "key"],
|
||||
}));
|
||||
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({ resyncHostsForCredential: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/shared-credential-secrets-manager.js", () => ({
|
||||
SharedCredentialSecretsManager: {
|
||||
getInstance: () => ({ resyncCredential: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCredentialRepository: () => ({
|
||||
createEncryptedForUser: async (
|
||||
userId: string,
|
||||
data: Record<string, unknown>,
|
||||
) => {
|
||||
h.created.push(data);
|
||||
return { id: 1, userId, ...data };
|
||||
},
|
||||
findById: async () => (h.existing ? { id: 1, userId: "u1" } : null),
|
||||
findDecryptedByIdForUser: async () => h.existing,
|
||||
updateEncryptedForUser: async (
|
||||
_userId: string,
|
||||
_id: number,
|
||||
fields: Record<string, unknown>,
|
||||
) => {
|
||||
h.updated.push(fields);
|
||||
return { ...h.existing, ...fields };
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({}),
|
||||
createCurrentCredentialAccessRepository: () => ({}),
|
||||
createCurrentRoleRepository: () => ({}),
|
||||
createCurrentHostResolutionRepository: () => ({}),
|
||||
createCurrentHostRepository: () => ({}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/credentials.js");
|
||||
|
||||
// Never parsed here: only the update replacing it matters.
|
||||
const REAL_KEY = "stored-key-material";
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
h.created = [];
|
||||
h.updated = [];
|
||||
h.existing = null;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/credentials", router);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function send(method: string, path: string, body: unknown) {
|
||||
return fetch(`${base}/credentials${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("credentials with a secret reference key", () => {
|
||||
it("saves an op:// private key as given", async () => {
|
||||
const response = await send("POST", "", {
|
||||
name: "vault key",
|
||||
authType: "key",
|
||||
username: "root",
|
||||
key: "op://vault/item/private_key",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(h.created[0]).toMatchObject({
|
||||
key: "op://vault/item/private_key",
|
||||
privateKey: "op://vault/item/private_key",
|
||||
publicKey: null,
|
||||
detectedKeyType: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("still rejects a key that is neither a key nor a reference", async () => {
|
||||
const response = await send("POST", "", {
|
||||
name: "bad",
|
||||
authType: "key",
|
||||
key: "not a key",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("replaces the stored private key when updating to a reference", async () => {
|
||||
h.existing = {
|
||||
id: 1,
|
||||
userId: "u1",
|
||||
name: "k",
|
||||
authType: "key",
|
||||
key: REAL_KEY,
|
||||
privateKey: REAL_KEY,
|
||||
publicKey: "ssh-ed25519 AAAA old",
|
||||
};
|
||||
|
||||
const response = await send("PUT", "/1", {
|
||||
key: "op://vault/item/private_key",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.updated[0]).toMatchObject({
|
||||
key: "op://vault/item/private_key",
|
||||
privateKey: "op://vault/item/private_key",
|
||||
publicKey: null,
|
||||
detectedKeyType: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
update: vi.fn(),
|
||||
list: vi.fn(),
|
||||
applyPluginSettings: vi.fn(),
|
||||
applyDefaults: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: mocks.applyPluginSettings,
|
||||
@@ -14,6 +15,7 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
vi.mock("../../../hosts/defaults/index.js", () => ({
|
||||
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||
applyDefaultsAfterHostWrite: async () => {},
|
||||
applyDefaultsAfterHostWrites: mocks.applyDefaults,
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
@@ -141,6 +143,9 @@ it.each([20, "20"])(
|
||||
]);
|
||||
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("exportId");
|
||||
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("id");
|
||||
// One defaults pass for the whole import, not one per host.
|
||||
expect(mocks.applyDefaults).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.applyDefaults).toHaveBeenCalledWith([501, 502]);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
|
||||
const settings = vi.hoisted(() => new Map<string, string>());
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
settings.set(key, value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { error: vi.fn() },
|
||||
}));
|
||||
import { registerHostTagRoutes } from "../../../database/routes/host-tag-routes.js";
|
||||
|
||||
function app() {
|
||||
const server = express();
|
||||
server.use(express.json());
|
||||
registerHostTagRoutes(
|
||||
server,
|
||||
(req, res, next) =>
|
||||
req.headers.authorization ? next() : void res.sendStatus(401),
|
||||
(req, res, next) =>
|
||||
req.headers.authorization === "admin" ? next() : void res.sendStatus(403),
|
||||
);
|
||||
return server;
|
||||
}
|
||||
|
||||
beforeEach(() => settings.clear());
|
||||
describe("instance host tag catalog", () => {
|
||||
it("allows authenticated users to read the same admin-managed catalog", async () => {
|
||||
const server = app();
|
||||
await request(server)
|
||||
.put("/tags")
|
||||
.set("Authorization", "admin")
|
||||
.send({ tags: [" Linux ", "Domain Controller", "Linux"] })
|
||||
.expect(200, { tags: ["Linux", "Domain Controller"] });
|
||||
await request(server)
|
||||
.get("/tags")
|
||||
.set("Authorization", "member")
|
||||
.expect(200, { tags: ["Linux", "Domain Controller"] });
|
||||
});
|
||||
it("rejects anonymous reads and non-admin writes", async () => {
|
||||
await request(app()).get("/tags").expect(401);
|
||||
await request(app())
|
||||
.put("/tags")
|
||||
.set("Authorization", "member")
|
||||
.send({ tags: ["test"] })
|
||||
.expect(403);
|
||||
expect(settings.size).toBe(0);
|
||||
});
|
||||
it.each([null, [42], [" "], ["x".repeat(101)], Array(501).fill("tag")])(
|
||||
"rejects invalid catalogs",
|
||||
async (tags) => {
|
||||
await request(app())
|
||||
.put("/tags")
|
||||
.set("Authorization", "admin")
|
||||
.send({ tags })
|
||||
.expect(400);
|
||||
expect(settings.size).toBe(0);
|
||||
},
|
||||
);
|
||||
it("allows clearing suggestions without modifying hosts", async () => {
|
||||
settings.set("host_tag_catalog", '["Linux"]');
|
||||
await request(app())
|
||||
.put("/tags")
|
||||
.set("Authorization", "admin")
|
||||
.send({ tags: [] })
|
||||
.expect(200, { tags: [] });
|
||||
});
|
||||
|
||||
it("reads a damaged catalog as empty instead of failing", async () => {
|
||||
settings.set("host_tag_catalog", "{not json");
|
||||
await request(app())
|
||||
.get("/tags")
|
||||
.set("Authorization", "member")
|
||||
.expect(200, { tags: [] });
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,65 @@ describe("linking an external account to a password account", () => {
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("merges a 2.9.0 duplicate back into its 2.8 SSO account", async () => {
|
||||
h.users.set("orig", {
|
||||
id: "orig",
|
||||
username: "bob",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-9",
|
||||
});
|
||||
h.users.set("dup", {
|
||||
id: "dup",
|
||||
username: "bob-1",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: null,
|
||||
});
|
||||
h.identities.push(
|
||||
{ userId: "orig", providerId: "legacy-oidc", subject: "sub-9" },
|
||||
{ userId: "dup", providerId: "1", subject: "sub-9" },
|
||||
);
|
||||
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "dup",
|
||||
targetUsername: "bob",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.deleted).toEqual(["dup"]);
|
||||
expect(h.users.get("orig")!.oidcIdentifier).toBe("sub-9");
|
||||
expect(
|
||||
h.identities
|
||||
.filter((row) => row.userId === "orig")
|
||||
.map((row) => row.providerId),
|
||||
).toEqual(["legacy-oidc", "1"]);
|
||||
});
|
||||
|
||||
it("does not treat an unrelated SSO account as a duplicate target", async () => {
|
||||
h.users.set("other", {
|
||||
id: "other",
|
||||
username: "carol",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-x",
|
||||
});
|
||||
h.identities.push({
|
||||
userId: "other",
|
||||
providerId: "legacy-oidc",
|
||||
subject: "sub-x",
|
||||
});
|
||||
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "carol",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlinking", () => {
|
||||
|
||||
@@ -5,9 +5,14 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { copyPluginSdk } =
|
||||
const { copyPluginSdk, resourcesDirFor } =
|
||||
require("../../../../packaging/build/after-pack.cjs") as {
|
||||
copyPluginSdk: (resourcesDir: string, sdkDir?: string) => string;
|
||||
resourcesDirFor: (context: {
|
||||
electronPlatformName: string;
|
||||
appOutDir: string;
|
||||
packager: { appInfo: { productFilename: string } };
|
||||
}) => string;
|
||||
};
|
||||
|
||||
let dir: string;
|
||||
@@ -53,4 +58,30 @@ describe("afterPack plugin SDK copy", () => {
|
||||
"npm run build:sdk",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["darwin", "mas"])(
|
||||
"copies into the app bundle for %s builds",
|
||||
(electronPlatformName) => {
|
||||
const context = {
|
||||
electronPlatformName,
|
||||
appOutDir: "/out",
|
||||
packager: { appInfo: { productFilename: "Termix" } },
|
||||
};
|
||||
expect(resourcesDirFor(context)).toBe(
|
||||
path.join("/out", "Termix.app", "Contents", "Resources"),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["win32", "linux"])(
|
||||
"copies into resources for %s builds",
|
||||
(electronPlatformName) => {
|
||||
const context = {
|
||||
electronPlatformName,
|
||||
appOutDir: "/out",
|
||||
packager: { appInfo: { productFilename: "Termix" } },
|
||||
};
|
||||
expect(resourcesDirFor(context)).toBe(path.join("/out", "resources"));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -487,6 +487,16 @@ describe("resolveHostById", () => {
|
||||
expect(host.sshOptions).toEqual({ keepaliveCountMax: 3 });
|
||||
});
|
||||
|
||||
it("turns the stored force_keyboard_interactive text into a boolean", async () => {
|
||||
state.host = baseHost({ forceKeyboardInteractive: "false" });
|
||||
const off = (await resolveHostById(42, "owner")) as Record<string, unknown>;
|
||||
expect(off.forceKeyboardInteractive).toBe(false);
|
||||
|
||||
state.host = baseHost({ forceKeyboardInteractive: "true" });
|
||||
const on = (await resolveHostById(42, "owner")) as Record<string, unknown>;
|
||||
expect(on.forceKeyboardInteractive).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
|
||||
state.isAdminBypass = true;
|
||||
state.host = baseHost({
|
||||
|
||||
@@ -13,10 +13,13 @@ describe("HostSessionStatus", () => {
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith(7, true);
|
||||
|
||||
expect(status.hasActiveSession(7)).toBe(true);
|
||||
closeFirst();
|
||||
expect(status.hasActiveSession(7)).toBe(true);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeSecond();
|
||||
expect(status.hasActiveSession(7)).toBe(false);
|
||||
expect(listener).toHaveBeenLastCalledWith(7, false);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ function setup(
|
||||
loadSharedHostIds: async (userId) => shared[userId] ?? [],
|
||||
ping,
|
||||
pingThroughJumpHosts,
|
||||
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
|
||||
globalInterval: () => 60,
|
||||
emit: (payload) => emitted.push(payload),
|
||||
});
|
||||
@@ -74,6 +75,34 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("HostStatusService", () => {
|
||||
it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])(
|
||||
"skips probes while a session is open, including jump hosts: %j",
|
||||
async ({ jumpHosts }) => {
|
||||
const { service, ping, pingThroughJumpHosts } = setup([
|
||||
target(7, { jumpHosts }),
|
||||
]);
|
||||
active = service;
|
||||
const close = hostSessionStatus.register(7);
|
||||
try {
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flush();
|
||||
expect(service.get(7)?.status).toBe("online");
|
||||
expect(ping).not.toHaveBeenCalled();
|
||||
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
|
||||
close();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flush();
|
||||
expect(
|
||||
jumpHosts.length ? pingThroughJumpHosts : ping,
|
||||
).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("starts a user's own hosts and reports them online", async () => {
|
||||
const { service, emitted, ping } = setup([target(1), target(2)]);
|
||||
active = service;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { JumpProbePool } from "../../../hosts/status/jump-probe-pool.js";
|
||||
|
||||
function fakeClient(reachable = true) {
|
||||
const client = Object.assign(new EventEmitter(), {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn(
|
||||
(
|
||||
_srcIp: string,
|
||||
_srcPort: number,
|
||||
_host: string,
|
||||
_port: number,
|
||||
callback: (error: Error | null, stream?: { destroy(): void }) => void,
|
||||
) => {
|
||||
callback(
|
||||
reachable ? null : new Error("refused"),
|
||||
reachable ? { destroy: () => {} } : undefined,
|
||||
);
|
||||
},
|
||||
),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("JumpProbePool", () => {
|
||||
it("shares one chain across hosts behind the same hops", async () => {
|
||||
const client = fakeClient();
|
||||
const open = vi.fn(async () => client as never);
|
||||
const pool = new JumpProbePool(open);
|
||||
const hops = [{ hostId: 1 }, { hostId: 2 }];
|
||||
|
||||
const results = await Promise.all([
|
||||
pool.ping(hops, "u1", "10.0.0.3", 22),
|
||||
pool.ping(hops, "u1", "10.0.0.4", 22),
|
||||
pool.ping(hops, "u1", "10.0.0.5", 22),
|
||||
]);
|
||||
|
||||
expect(results).toEqual([true, true, true]);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(client.forwardOut).toHaveBeenCalledTimes(3);
|
||||
expect(client.end).not.toHaveBeenCalled();
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("keeps chains apart per user and hop list", async () => {
|
||||
const open = vi.fn(async () => fakeClient() as never);
|
||||
const pool = new JumpProbePool(open);
|
||||
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
|
||||
await pool.ping([{ hostId: 1 }], "u2", "a", 22);
|
||||
await pool.ping([{ hostId: 2 }], "u1", "a", 22);
|
||||
expect(open).toHaveBeenCalledTimes(3);
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("reports an unreachable target without dropping the chain", async () => {
|
||||
const client = fakeClient(false);
|
||||
const open = vi.fn(async () => client as never);
|
||||
const pool = new JumpProbePool(open);
|
||||
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
|
||||
expect(await pool.ping([{ hostId: 1 }], "u1", "b", 22)).toBe(false);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("retries a chain that failed to open", async () => {
|
||||
const open = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(fakeClient());
|
||||
const pool = new JumpProbePool(open);
|
||||
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
|
||||
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("opens a new chain after the old one closed or sat idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = fakeClient();
|
||||
const second = fakeClient();
|
||||
const third = fakeClient();
|
||||
const open = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce(second)
|
||||
.mockResolvedValueOnce(third);
|
||||
const pool = new JumpProbePool(open, 1000);
|
||||
|
||||
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
|
||||
first.emit("close");
|
||||
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
|
||||
expect(open).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(second.end).toHaveBeenCalled();
|
||||
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
|
||||
expect(open).toHaveBeenCalledTimes(3);
|
||||
pool.closeAll();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolve = vi.fn(async (_userId: string, ref: string) => `real:${ref}`);
|
||||
vi.mock("../../hosts/external-secrets.js", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
resolveSecretReference: (userId: string, ref: string) => resolve(userId, ref),
|
||||
}));
|
||||
|
||||
const { parseKeyForStorage, resolveKeyReferences } =
|
||||
await import("../../hosts/stored-ssh-key.js");
|
||||
|
||||
describe("parseKeyForStorage", () => {
|
||||
it("accepts a secret reference without parsing it", () => {
|
||||
expect(parseKeyForStorage(" op://v/i/private_key ")).toEqual({
|
||||
success: true,
|
||||
privateKey: "op://v/i/private_key",
|
||||
publicKey: "",
|
||||
keyType: "",
|
||||
reference: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not try to open a real key with a referenced passphrase", () => {
|
||||
const result = parseKeyForStorage("-----BEGIN...", "op://v/i/pass");
|
||||
expect(result).toMatchObject({ success: true, reference: true });
|
||||
});
|
||||
|
||||
it("parses anything else as a key", () => {
|
||||
const result = parseKeyForStorage("not a key");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reference).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeyReferences", () => {
|
||||
it("resolves only the values that are references", async () => {
|
||||
expect(await resolveKeyReferences("u1", "op://v/i/key", "plain")).toEqual({
|
||||
key: "real:op://v/i/key",
|
||||
passphrase: "plain",
|
||||
});
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,18 @@ vi.mock("../../hosts/connect/connect-host.js", () => ({
|
||||
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (hostId: number, userId: string) => ({
|
||||
id: hostId,
|
||||
userId,
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "stored-secret",
|
||||
}),
|
||||
resolveHostBySyncId: async () => null,
|
||||
}));
|
||||
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
hostStatusService: {
|
||||
reportLogin: (hostId: number, outcome: unknown) =>
|
||||
@@ -253,6 +265,50 @@ describe("ctx.ssh", () => {
|
||||
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
|
||||
});
|
||||
|
||||
it("connects a copied redacted host with its stored login", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const host = (await ssh.resolveHost(5))!;
|
||||
expect((host as Record<string, unknown>).password).toBeUndefined();
|
||||
expect(JSON.stringify(host)).not.toContain("stored-secret");
|
||||
for (const symbol of Object.getOwnPropertySymbols(host)) {
|
||||
expect(JSON.stringify((host as never)[symbol])).not.toContain(
|
||||
"stored-secret",
|
||||
);
|
||||
}
|
||||
|
||||
await ssh.connect({ ...host, port: host.port || 22 });
|
||||
expect(h.connects[0].target).toMatchObject({ password: "stored-secret" });
|
||||
|
||||
await ssh.connect({ ...host, password: "typed", authType: "password" });
|
||||
expect(h.connects[1].target).toMatchObject({ password: "typed" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.logins.map((login) => login.hostId)).toEqual([5, 5]);
|
||||
});
|
||||
|
||||
it("does not honor another plugin's resolved host", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const deps = {
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
};
|
||||
const first = createPluginSsh({
|
||||
...deps,
|
||||
bag: new DisposableBag("fixture"),
|
||||
});
|
||||
const second = createPluginSsh({
|
||||
...deps,
|
||||
bag: new DisposableBag("fixture"),
|
||||
});
|
||||
const host = (await first.resolveHost(5))!;
|
||||
await second.connect({ ...host });
|
||||
expect(h.connects[0].target).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("passes a given stream through to the pipeline, gated like any connect", async () => {
|
||||
const stream = { throughSource: true };
|
||||
const ssh = createPluginSsh({
|
||||
|
||||
@@ -117,6 +117,32 @@ describe("plugin user data", () => {
|
||||
expect(again).toMatchObject({ imported: 0, skipped: 1 });
|
||||
});
|
||||
|
||||
it("writes large tables in chunks and lets the event loop run between them", async () => {
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)",
|
||||
);
|
||||
sqlite.transaction(() => {
|
||||
for (let i = 0; i < 4999; i++) insert.run(`/p/${i}`);
|
||||
})();
|
||||
const file = new Database(":memory:");
|
||||
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
||||
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
file.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
const transaction = vi.spyOn(file, "transaction");
|
||||
let ticks = 0;
|
||||
const ticker = setInterval(() => ticks++, 0);
|
||||
|
||||
const written = await writeUserPluginTables(file, "u1");
|
||||
clearInterval(ticker);
|
||||
|
||||
expect(written).toBe(5000);
|
||||
expect(transaction).toHaveBeenCalled();
|
||||
expect(ticks).toBeGreaterThan(0);
|
||||
expect(
|
||||
file.prepare("SELECT COUNT(*) AS n FROM p_fixture_bookmark").get(),
|
||||
).toEqual({ n: 5000 });
|
||||
});
|
||||
|
||||
it("reads a pre-2.9 export by the legacy table name", async () => {
|
||||
const file = new Database(":memory:");
|
||||
file.exec(
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
triggerSave: vi.fn(),
|
||||
forceSave: vi.fn(async () => {}),
|
||||
batched: <T>(work: T) => work,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/system-crypto.js", () => ({
|
||||
|
||||
@@ -111,4 +111,20 @@ describe("runExternalIdentityMigration", () => {
|
||||
expect(second).toEqual({ identities: 0, skipped: 1 });
|
||||
expect(h.state.identities).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not recreate a legacy-oidc link that was moved to its provider", async () => {
|
||||
user("oidc-user", { oidcIdentifier: "sub-1" });
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "oidc-user",
|
||||
providerId: "1",
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
} as never);
|
||||
|
||||
const result = await runExternalIdentityMigration();
|
||||
|
||||
expect(result).toEqual({ identities: 0, skipped: 1 });
|
||||
expect(h.state.identities).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { repairSnippetsNoteColumn } from "../../upgrade/snippets-schema-migration.js";
|
||||
|
||||
let sqlite: Database.Database;
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(":memory:");
|
||||
});
|
||||
afterEach(() => {
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
describe("snippet note column upgrade", () => {
|
||||
it.each(["snippets", "p_snippets_snippets"])(
|
||||
"repairs %s without losing rows",
|
||||
(table) => {
|
||||
sqlite.exec(
|
||||
`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, user_id TEXT, content TEXT)`,
|
||||
);
|
||||
sqlite
|
||||
.prepare(`INSERT INTO "${table}" VALUES (7, 'user', 'echo hello')`)
|
||||
.run();
|
||||
expect(() => sqlite.prepare(`SELECT "is_note" FROM "${table}"`)).toThrow(
|
||||
/is_note/,
|
||||
);
|
||||
repairSnippetsNoteColumn(sqlite);
|
||||
if (table === "snippets") {
|
||||
sqlite.exec(
|
||||
readFileSync(
|
||||
"plugins/snippets/migrations/sqlite/0001_adopt_snippets_tables.sql",
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
}
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT id, content, is_note FROM p_snippets_snippets")
|
||||
.get(),
|
||||
).toEqual({ id: 7, content: "echo hello", is_note: 0 });
|
||||
sqlite.exec("UPDATE p_snippets_snippets SET is_note = 1");
|
||||
repairSnippetsNoteColumn(sqlite);
|
||||
expect(
|
||||
sqlite.prepare("SELECT is_note FROM p_snippets_snippets").get(),
|
||||
).toEqual({ is_note: 1 });
|
||||
},
|
||||
);
|
||||
|
||||
it("does not create tables on a fresh install", () => {
|
||||
repairSnippetsNoteColumn(sqlite);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 2.8 OIDC accounts with no provider id were filed under "legacy-oidc", but
|
||||
* the sso plugin signs in through the migrated provider row (#1381). They move
|
||||
* to that row when it is the only one from before 2.9 and the env provider is
|
||||
* not in use; anything ambiguous is left alone.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
warn: null as unknown as ReturnType<typeof vi.fn>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
h.warn = log.warn;
|
||||
return { databaseLogger: log };
|
||||
});
|
||||
|
||||
const { runSsoLegacyIdentityMigration } =
|
||||
await import("../../upgrade/sso-legacy-identity-migration.js");
|
||||
|
||||
const ENV = [
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_AUTHORIZATION_URL",
|
||||
"OIDC_TOKEN_URL",
|
||||
"OIDC_ENV_OVERRIDE",
|
||||
];
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
function setup(providers: Array<[number, string, number, number]>) {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, oidc_identifier TEXT);
|
||||
CREATE TABLE p_sso_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT 'x',
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
legacy_callback INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE user_external_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
email TEXT
|
||||
);
|
||||
`);
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO p_sso_providers (id, type, enabled, legacy_callback) VALUES (?, ?, ?, ?)",
|
||||
);
|
||||
for (const row of providers) insert.run(...row);
|
||||
h.db = drizzle(sqlite);
|
||||
}
|
||||
|
||||
function addUser(id: string, identifier: string, providerId: string) {
|
||||
sqlite
|
||||
.prepare("INSERT INTO users (id, oidc_identifier) VALUES (?, ?)")
|
||||
.run(id, identifier);
|
||||
const subject = identifier.replace(/^github:[^:]+:/, "");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(id, providerId, subject);
|
||||
}
|
||||
|
||||
function links() {
|
||||
return sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, provider_id, subject FROM user_external_identities ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const name of ENV) delete process.env[name];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of ENV) delete process.env[name];
|
||||
});
|
||||
|
||||
describe("runSsoLegacyIdentityMigration", () => {
|
||||
it("moves legacy OIDC sign-ins to the one migrated provider", async () => {
|
||||
setup([
|
||||
[1, "oidc", 1, 1],
|
||||
[2, "github", 1, 1],
|
||||
]);
|
||||
addUser("alice", "sub-a", "legacy-oidc");
|
||||
addUser("gh", "github:null:42", "legacy-oidc");
|
||||
|
||||
const result = await runSsoLegacyIdentityMigration();
|
||||
|
||||
expect(result).toEqual({ moved: 1, conflicts: 0 });
|
||||
expect(links()).toEqual([
|
||||
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
|
||||
{ user_id: "gh", provider_id: "legacy-oidc", subject: "42" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores OIDC providers added after the upgrade", async () => {
|
||||
setup([
|
||||
[1, "oidc", 1, 1],
|
||||
[5, "oidc", 1, 0],
|
||||
]);
|
||||
addUser("alice", "sub-a", "legacy-oidc");
|
||||
|
||||
await runSsoLegacyIdentityMigration();
|
||||
|
||||
expect(links()).toEqual([
|
||||
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves them when two providers are from before 2.9", async () => {
|
||||
setup([
|
||||
[1, "oidc", 1, 1],
|
||||
[2, "oidc", 1, 1],
|
||||
]);
|
||||
addUser("alice", "sub-a", "legacy-oidc");
|
||||
|
||||
expect(await runSsoLegacyIdentityMigration()).toEqual({
|
||||
moved: 0,
|
||||
conflicts: 0,
|
||||
});
|
||||
expect(links()[0]).toMatchObject({ provider_id: "legacy-oidc" });
|
||||
});
|
||||
|
||||
it("leaves them when the env provider signs in", async () => {
|
||||
setup([[1, "oidc", 1, 1]]);
|
||||
addUser("alice", "sub-a", "legacy-oidc");
|
||||
for (const name of ENV.slice(0, 5)) process.env[name] = "x";
|
||||
process.env.OIDC_ENV_OVERRIDE = "true";
|
||||
|
||||
await runSsoLegacyIdentityMigration();
|
||||
|
||||
expect(links()[0]).toMatchObject({ provider_id: "legacy-oidc" });
|
||||
});
|
||||
|
||||
it("still moves them when env settings exist but the row is what signs in", async () => {
|
||||
setup([[1, "oidc", 1, 1]]);
|
||||
addUser("alice", "sub-a", "legacy-oidc");
|
||||
for (const name of ENV.slice(0, 5)) process.env[name] = "x";
|
||||
|
||||
await runSsoLegacyIdentityMigration();
|
||||
|
||||
expect(links()[0]).toMatchObject({ provider_id: "1" });
|
||||
});
|
||||
|
||||
it("skips a subject a 2.9.0 duplicate account already holds", async () => {
|
||||
setup([[1, "oidc", 1, 1]]);
|
||||
addUser("alice", "sub-a", "legacy-oidc");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES ('dup', '1', 'sub-a')",
|
||||
)
|
||||
.run();
|
||||
|
||||
const result = await runSsoLegacyIdentityMigration();
|
||||
|
||||
expect(result).toEqual({ moved: 0, conflicts: 1 });
|
||||
expect(h.warn).toHaveBeenCalled();
|
||||
expect(links()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops the legacy link once the same account holds the real one", async () => {
|
||||
setup([[1, "oidc", 1, 1]]);
|
||||
addUser("alice", "sub-a", "legacy-oidc");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES ('alice', '1', 'sub-a')",
|
||||
)
|
||||
.run();
|
||||
|
||||
await runSsoLegacyIdentityMigration();
|
||||
|
||||
expect(links()).toEqual([
|
||||
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
setup([[1, "oidc", 1, 1]]);
|
||||
addUser("alice", "sub-a", "legacy-oidc");
|
||||
await runSsoLegacyIdentityMigration();
|
||||
expect(await runSsoLegacyIdentityMigration()).toEqual({
|
||||
moved: 0,
|
||||
conflicts: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,25 @@ describe("DatabaseSaveTrigger", () => {
|
||||
DatabaseSaveTrigger.cleanup();
|
||||
});
|
||||
|
||||
it("batches informational touches without scheduling per-session saves", async () => {
|
||||
vi.useFakeTimers();
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
DatabaseSaveTrigger.initialize(save);
|
||||
for (let minute = 0; minute < 4; minute++) {
|
||||
DatabaseSaveTrigger.markDirty();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
}
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(DatabaseSaveTrigger.isDirty).toBe(true);
|
||||
expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(false);
|
||||
const flushed = DatabaseSaveTrigger.forceSave("periodic_flush");
|
||||
// A save yields one event-loop turn, which fake timers also control.
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await flushed;
|
||||
expect(save).toHaveBeenCalledOnce();
|
||||
expect(DatabaseSaveTrigger.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("force saves through the initialized save function", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
DatabaseSaveTrigger.initialize(save);
|
||||
@@ -21,6 +40,18 @@ describe("DatabaseSaveTrigger", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lets the event loop turn before a force save resolves", async () => {
|
||||
DatabaseSaveTrigger.initialize(vi.fn().mockResolvedValue(undefined));
|
||||
let turned = false;
|
||||
setImmediate(() => {
|
||||
turned = true;
|
||||
});
|
||||
|
||||
await DatabaseSaveTrigger.forceSave("bulk_write");
|
||||
|
||||
expect(turned).toBe(true);
|
||||
});
|
||||
|
||||
it("debounces dirty saves and marks the database clean after saving", async () => {
|
||||
vi.useFakeTimers();
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -33,12 +64,24 @@ describe("DatabaseSaveTrigger", () => {
|
||||
expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(DatabaseSaveTrigger.isDirty).toBe(false);
|
||||
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
|
||||
});
|
||||
|
||||
it("saves within the max wait even while writes keep coming", async () => {
|
||||
vi.useFakeTimers();
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
DatabaseSaveTrigger.initialize(save);
|
||||
for (let second = 0; second < 40; second++) {
|
||||
void DatabaseSaveTrigger.triggerSave("sample");
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
}
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("queues a force save behind an in-flight save", async () => {
|
||||
let finishFirstSave: (() => void) | undefined;
|
||||
const firstSave = new Promise<void>((resolve) => {
|
||||
@@ -62,4 +105,77 @@ describe("DatabaseSaveTrigger", () => {
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses force saves inside a batch into one save", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
DatabaseSaveTrigger.initialize(save);
|
||||
|
||||
const result = await DatabaseSaveTrigger.batched(async (rows: number) => {
|
||||
for (let i = 0; i < rows; i++) {
|
||||
await DatabaseSaveTrigger.forceSave("row_write");
|
||||
}
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
return rows;
|
||||
})(50);
|
||||
|
||||
expect(result).toBe(50);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(DatabaseSaveTrigger.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("folds a nested batch into the outer one", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
DatabaseSaveTrigger.initialize(save);
|
||||
const inner = DatabaseSaveTrigger.batched(() =>
|
||||
DatabaseSaveTrigger.forceSave("inner_write"),
|
||||
);
|
||||
|
||||
await DatabaseSaveTrigger.batched(async () => {
|
||||
await inner();
|
||||
await inner();
|
||||
})();
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips the save when a batch wrote nothing", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
DatabaseSaveTrigger.initialize(save);
|
||||
|
||||
await DatabaseSaveTrigger.batched(async () => {})();
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still saves what a failed batch wrote", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
DatabaseSaveTrigger.initialize(save);
|
||||
|
||||
await expect(
|
||||
DatabaseSaveTrigger.batched(async () => {
|
||||
await DatabaseSaveTrigger.forceSave("row_write");
|
||||
throw new Error("boom");
|
||||
})(),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("saves immediately for work that outlives its batch", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
DatabaseSaveTrigger.initialize(save);
|
||||
let release: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let detached: Promise<void> | undefined;
|
||||
|
||||
await DatabaseSaveTrigger.batched(async () => {
|
||||
detached = gate.then(() => DatabaseSaveTrigger.forceSave("late_write"));
|
||||
})();
|
||||
release?.();
|
||||
await detached;
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user