mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: drop the 2.8 columns, moves and aliases kept for a downgrade
This commit is contained in:
1 parent
d3d8269cb7
commit
61f46d2517
196 files changed
+29909
-10511
No files matched your search
@@ -291,33 +291,6 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
# 2.8 URLs that something outside Termix still calls: the Vault OIDC
|
||||
# callback, the Termix ID resolver and automation webhooks. The
|
||||
# backend redirects each to the plugin that serves it now (see
|
||||
# contributes.http in each plugin's manifest). New plugins never add
|
||||
# a block here.
|
||||
location = /vault/oidc/callback {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ^~ /termix-id/u/ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /automations/webhook/ {
|
||||
rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last;
|
||||
}
|
||||
|
||||
# Every plugin's HTTP routes. One block, no per-plugin entries: a
|
||||
# plugin serves at /plugin-api/<id>/ on the main backend and never
|
||||
# gets a port or a block of its own. Buffering is off so SSE streams
|
||||
|
||||
@@ -260,33 +260,6 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
# 2.8 URLs that something outside Termix still calls: the Vault OIDC
|
||||
# callback, the Termix ID resolver and automation webhooks. The
|
||||
# backend redirects each to the plugin that serves it now (see
|
||||
# contributes.http in each plugin's manifest). New plugins never add
|
||||
# a block here.
|
||||
location = /vault/oidc/callback {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ^~ /termix-id/u/ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /automations/webhook/ {
|
||||
rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last;
|
||||
}
|
||||
|
||||
# Every plugin's HTTP routes. One block, no per-plugin entries: a
|
||||
# plugin serves at /plugin-api/<id>/ on the main backend and never
|
||||
# gets a port or a block of its own. Buffering is off so SSE streams
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
ALTER TABLE `ssh_folders` DROP FOREIGN KEY `ssh_folders_credential_id_ssh_credentials_id_fk`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `terminal_config`;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `quick_actions`;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` DROP COLUMN `oidc_sub`;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` DROP COLUMN `oidc_sid`;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` DROP COLUMN `sso_provider_id`;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_folders` DROP COLUMN `credential_id`;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` DROP COLUMN `command_autocomplete`;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` DROP COLUMN `confirm_snippet_execution`;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` DROP COLUMN `custom_themes`;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` DROP COLUMN `terminal_defaults`;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` DROP COLUMN `terminal_macros`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `is_oidc`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `oidc_identifier`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `sso_provider_id`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `client_id`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `client_secret`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `issuer_url`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `authorization_url`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `token_url`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `identifier_path`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `name_path`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `scopes`;--> statement-breakpoint
|
||||
DELETE FROM `settings` WHERE `key` IN ('host_defaults', 'analytics_enabled', 'analytics_instance_id');--> statement-breakpoint
|
||||
UPDATE `ssh_data` SET `username` = REPLACE(`username`, '$oidc.preferred_username', '$external.username') WHERE `username` LIKE '%$oidc.preferred_username%';--> statement-breakpoint
|
||||
UPDATE `ssh_credentials` SET `username` = REPLACE(`username`, '$oidc.preferred_username', '$external.username') WHERE `username` LIKE '%$oidc.preferred_username%';--> statement-breakpoint
|
||||
UPDATE `host_defaults` SET `value` = REPLACE(`value`, '$oidc.preferred_username', '$external.username') WHERE `value` LIKE '%$oidc.preferred_username%';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `sessions` ADD `external_session_ref` text;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -463,6 +463,20 @@
|
||||
"when": 1790742086934,
|
||||
"tag": "0065_ambiguous_agent_zero",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 66,
|
||||
"version": "5",
|
||||
"when": 1791209297104,
|
||||
"tag": "0066_retire_2_8_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "5",
|
||||
"when": 1791209311841,
|
||||
"tag": "0067_external_session_ref",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
ALTER TABLE "ssh_folders" DROP CONSTRAINT "ssh_folders_credential_id_ssh_credentials_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" DROP COLUMN "terminal_config";--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" DROP COLUMN "quick_actions";--> statement-breakpoint
|
||||
ALTER TABLE "sessions" DROP COLUMN "oidc_sub";--> statement-breakpoint
|
||||
ALTER TABLE "sessions" DROP COLUMN "oidc_sid";--> statement-breakpoint
|
||||
ALTER TABLE "sessions" DROP COLUMN "sso_provider_id";--> statement-breakpoint
|
||||
ALTER TABLE "ssh_folders" DROP COLUMN "credential_id";--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" DROP COLUMN "command_autocomplete";--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" DROP COLUMN "confirm_snippet_execution";--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" DROP COLUMN "custom_themes";--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" DROP COLUMN "terminal_defaults";--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" DROP COLUMN "terminal_macros";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "is_oidc";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "oidc_identifier";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "sso_provider_id";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "client_id";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "client_secret";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "issuer_url";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "authorization_url";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "token_url";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "identifier_path";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "name_path";--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "scopes";--> statement-breakpoint
|
||||
DELETE FROM "settings" WHERE "key" IN ('host_defaults', 'analytics_enabled', 'analytics_instance_id');--> statement-breakpoint
|
||||
UPDATE "ssh_data" SET "username" = REPLACE("username", '$oidc.preferred_username', '$external.username') WHERE "username" LIKE '%$oidc.preferred_username%';--> statement-breakpoint
|
||||
UPDATE "ssh_credentials" SET "username" = REPLACE("username", '$oidc.preferred_username', '$external.username') WHERE "username" LIKE '%$oidc.preferred_username%';--> statement-breakpoint
|
||||
UPDATE "host_defaults" SET "value" = REPLACE("value", '$oidc.preferred_username', '$external.username') WHERE "value" LIKE '%$oidc.preferred_username%';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "sessions" ADD COLUMN "external_session_ref" text;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -463,6 +463,20 @@
|
||||
"when": 1790742086298,
|
||||
"tag": "0065_regular_joystick",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 66,
|
||||
"version": "7",
|
||||
"when": 1791209295939,
|
||||
"tag": "0066_retire_2_8_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "7",
|
||||
"when": 1791209310758,
|
||||
"tag": "0067_external_session_ref",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_ssh_folders` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`color` text,
|
||||
`icon` text,
|
||||
`sort_order` integer,
|
||||
`sync_id` text,
|
||||
`local_only` integer DEFAULT false NOT NULL,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_ssh_folders`("id", "user_id", "name", "color", "icon", "sort_order", "sync_id", "local_only", "created_at", "updated_at") SELECT "id", "user_id", "name", "color", "icon", "sort_order", "sync_id", "local_only", "created_at", "updated_at" FROM `ssh_folders`;--> statement-breakpoint
|
||||
DROP TABLE `ssh_folders`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_ssh_folders` RENAME TO `ssh_folders`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `ssh_folders_sync_id_unique` ON `ssh_folders` (`sync_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_ssh_folders_user_id` ON `ssh_folders` (`user_id`);--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `terminal_config`;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `quick_actions`;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` DROP COLUMN `oidc_sub`;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` DROP COLUMN `oidc_sid`;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` DROP COLUMN `sso_provider_id`;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` DROP COLUMN `command_autocomplete`;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` DROP COLUMN `confirm_snippet_execution`;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` DROP COLUMN `custom_themes`;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` DROP COLUMN `terminal_defaults`;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` DROP COLUMN `terminal_macros`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `is_oidc`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `oidc_identifier`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `sso_provider_id`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `client_id`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `client_secret`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `issuer_url`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `authorization_url`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `token_url`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `identifier_path`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `name_path`;--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `scopes`;--> statement-breakpoint
|
||||
DELETE FROM `settings` WHERE `key` IN ('host_defaults', 'analytics_enabled', 'analytics_instance_id');--> statement-breakpoint
|
||||
UPDATE `ssh_data` SET `username` = REPLACE(`username`, '$oidc.preferred_username', '$external.username') WHERE `username` LIKE '%$oidc.preferred_username%';--> statement-breakpoint
|
||||
UPDATE `ssh_credentials` SET `username` = REPLACE(`username`, '$oidc.preferred_username', '$external.username') WHERE `username` LIKE '%$oidc.preferred_username%';--> statement-breakpoint
|
||||
UPDATE `host_defaults` SET `value` = REPLACE(`value`, '$oidc.preferred_username', '$external.username') WHERE `value` LIKE '%$oidc.preferred_username%';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `sessions` ADD `external_session_ref` text;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -428,6 +428,20 @@
|
||||
"when": 1790742085632,
|
||||
"tag": "0060_fine_cerise",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 61,
|
||||
"version": "6",
|
||||
"when": 1791209294736,
|
||||
"tag": "0061_retire_2_8_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 62,
|
||||
"version": "6",
|
||||
"when": 1791209309626,
|
||||
"tag": "0062_external_session_ref",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -83,9 +83,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
|
||||
externalBrowserLogin: (authUrl, callbackPort) =>
|
||||
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
||||
// 2.8 name, read by older server login pages. Remove in 26.10.0.
|
||||
oidcSystemBrowserAuth: (authUrl, callbackPort) =>
|
||||
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
||||
|
||||
openExternalEditor: (fileData) =>
|
||||
ipcRenderer.invoke("open-external-editor", fileData),
|
||||
|
||||
@@ -18,7 +18,11 @@ const COMMANDS = {
|
||||
build,
|
||||
patch: async ({ cwd }) => applyPatches(cwd, readManifest(cwd).id),
|
||||
validate,
|
||||
test,
|
||||
// The tests exercise patched dependencies, so patch first like build does.
|
||||
test: async (options) => {
|
||||
applyPatches(options.cwd, readManifest(options.cwd).id);
|
||||
return test(options);
|
||||
},
|
||||
pack,
|
||||
sign,
|
||||
verify,
|
||||
@@ -36,7 +40,7 @@ if (!command || command === "--help" || command === "-h") {
|
||||
" build Bundle the plugin into dist/",
|
||||
" patch Apply the plugin's dependency patches (build does this too)",
|
||||
" validate Check manifest.json and the files it names",
|
||||
" test Run the plugin's vitest suite",
|
||||
" test Apply the patches, then run the plugin's vitest suite",
|
||||
" pack Write <id>-<version>.tmxplug of the built plugin [--out dir]",
|
||||
" sign Sign a .tmxplug with TERMIX_PLUGIN_SIGNING_KEY <file>",
|
||||
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
|
||||
|
||||
@@ -373,7 +373,7 @@ export interface PluginHttp {
|
||||
/**
|
||||
* The public base URL a request came in on: origin plus the install's base
|
||||
* path, no trailing slash. Honours forwarded headers from a trusted proxy,
|
||||
* BASE_PATH and OIDC_FORCE_HTTPS. For building absolute callback URLs.
|
||||
* BASE_PATH and EXTERNAL_FORCE_HTTPS. For building absolute callback URLs.
|
||||
*/
|
||||
baseUrl: (req: unknown) => string;
|
||||
}
|
||||
@@ -1368,10 +1368,9 @@ export type PluginVerifiedIdentity =
|
||||
sid?: string | null;
|
||||
};
|
||||
/**
|
||||
* The values a 2.8 install kept on the user row (`oidc_identifier`,
|
||||
* `sso_provider_id`). Only for a plugin that took over a 2.8 login
|
||||
* method: core finds pre-2.9 accounts by `identifier` and keeps writing
|
||||
* both for new users so a downgrade still works.
|
||||
* The identifier a 2.8 install kept for the user. Only for a plugin
|
||||
* that took over a 2.8 login method: core also checks `identifier`
|
||||
* against `allowedUsers`. `providerRowId` is no longer read.
|
||||
*/
|
||||
legacy?: { identifier: string; providerRowId?: number | null };
|
||||
/**
|
||||
|
||||
@@ -275,11 +275,6 @@ export interface PluginSettingsField {
|
||||
group?: string;
|
||||
/** Registered component id. Required when type is "custom". */
|
||||
component?: string;
|
||||
/**
|
||||
* Deprecated and ignored: host defaults cover every host field now. Still
|
||||
* accepted so an older manifest loads. Removed in 26.10.0.
|
||||
*/
|
||||
defaultFrom?: string;
|
||||
/**
|
||||
* Host scope only: whether the field can have a host default (server,
|
||||
* user or folder) that hosts follow until they set their own. On by
|
||||
@@ -644,7 +639,6 @@ const ALLOWED_SETTINGS_FIELD = [
|
||||
"group",
|
||||
"component",
|
||||
"hidden",
|
||||
"defaultFrom",
|
||||
"defaultable",
|
||||
"defaultLevels",
|
||||
"personal",
|
||||
@@ -1614,9 +1608,6 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
host.fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
const fieldAt = `${at}.fields[${index}]`;
|
||||
if ("defaultFrom" in raw && typeof raw.defaultFrom !== "string") {
|
||||
errors.push(`${fieldAt}.defaultFrom must be a string`);
|
||||
}
|
||||
for (const flag of ["defaultable", "personal"]) {
|
||||
if (flag in raw && typeof raw[flag] !== "boolean") {
|
||||
errors.push(`${fieldAt}.${flag} must be a boolean`);
|
||||
@@ -1670,7 +1661,6 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
for (const key of [
|
||||
"defaultFrom",
|
||||
"defaultable",
|
||||
"defaultLevels",
|
||||
"personal",
|
||||
|
||||
@@ -59,3 +59,18 @@ describe("plugin CLI before the SDK is built", () => {
|
||||
expect(result.stderr).not.toContain("ERR_MODULE_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin test", () => {
|
||||
it("applies the plugin's patches before running vitest", () => {
|
||||
const { cli, plugin, run } = fixture();
|
||||
fs.mkdirSync(path.join(plugin, "patches"));
|
||||
fs.writeFileSync(
|
||||
path.join(plugin, "patches", "mark.cjs"),
|
||||
'require("node:fs").writeFileSync("patched.txt", "yes");',
|
||||
);
|
||||
run(cli, "test");
|
||||
expect(fs.readFileSync(path.join(plugin, "patched.txt"), "utf8")).toBe(
|
||||
"yes",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -69,8 +69,11 @@ export async function verifyPasswordLogin(request: {
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
if (user.isOidc && (!user.passwordHash || user.passwordHash.trim() === "")) {
|
||||
authLogger.warn("OIDC-only user attempted traditional login", {
|
||||
if (
|
||||
user.isExternal &&
|
||||
(!user.passwordHash || user.passwordHash.trim() === "")
|
||||
) {
|
||||
authLogger.warn("External-only user attempted password login", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
userId: user.id,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Whether a user signs in through an external login (SSO, LDAP or any login
|
||||
* plugin) rather than only a password. Read from the is_oidc column, which
|
||||
* keeps its 2.8 name until 26.10.0 replaces it with user_external_identities.
|
||||
* plugin) rather than only a password: they have a user_external_identities
|
||||
* row. UserRepository fills isExternal.
|
||||
*/
|
||||
export function isExternalAccount(user: {
|
||||
isOidc?: boolean | number | null;
|
||||
isExternal?: boolean | null;
|
||||
}): boolean {
|
||||
return !!user.isOidc;
|
||||
return !!user.isExternal;
|
||||
}
|
||||
@@ -175,7 +175,7 @@ async function unlockUser(
|
||||
): Promise<void> {
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
if (identity.kind === "external" || user.isOidc) {
|
||||
if (identity.kind === "external" || user.isExternal) {
|
||||
try {
|
||||
await authManager.authenticateExternalUser(user.id, deviceType);
|
||||
} catch (error) {
|
||||
@@ -280,11 +280,7 @@ export async function runLogin(
|
||||
);
|
||||
}
|
||||
|
||||
const ssoClaims = {
|
||||
ssoProviderId: identity.ssoProviderId ?? null,
|
||||
oidcSub: identity.oidcSub ?? null,
|
||||
oidcSid: identity.oidcSid ?? null,
|
||||
};
|
||||
const ssoClaims = { externalSession: identity.externalSession ?? null };
|
||||
|
||||
if (
|
||||
factors.required.length > 0 &&
|
||||
@@ -499,9 +495,7 @@ export async function verifySecondFactorAndRespond(
|
||||
const session = await issueSession(req, user, {
|
||||
methodId: pending?.methodId ?? "password",
|
||||
rememberMe,
|
||||
ssoProviderId: pending?.ssoProviderId ?? null,
|
||||
oidcSub: pending?.oidcSub ?? null,
|
||||
oidcSid: pending?.oidcSid ?? null,
|
||||
externalSession: pending?.externalSession ?? null,
|
||||
});
|
||||
|
||||
consumePendingLogin(lookup.token);
|
||||
|
||||
@@ -30,12 +30,15 @@ type ExternalIdentity = Extract<VerifiedIdentity, { kind: "external" }>;
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** SSO auto-provisioning: the admin setting, or OIDC_ALLOW_REGISTRATION. */
|
||||
/** The admin setting that turns external auto-provisioning on. */
|
||||
export const EXTERNAL_AUTO_PROVISION_KEY = "external_auto_provision";
|
||||
|
||||
/** SSO auto-provisioning: the admin setting, or EXTERNAL_ALLOW_REGISTRATION. */
|
||||
async function isExternalProvisioningAllowed(): Promise<boolean> {
|
||||
try {
|
||||
if (
|
||||
await createCurrentSettingsRepository().getBoolean(
|
||||
"oidc_auto_provision",
|
||||
EXTERNAL_AUTO_PROVISION_KEY,
|
||||
false,
|
||||
)
|
||||
) {
|
||||
@@ -44,9 +47,12 @@ async function isExternalProvisioningAllowed(): Promise<boolean> {
|
||||
} catch {
|
||||
// fall through to the environment
|
||||
}
|
||||
return (
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true"
|
||||
);
|
||||
// OIDC_ALLOW_REGISTRATION is the 2.9 name, still read for one release.
|
||||
const allow =
|
||||
process.env.EXTERNAL_ALLOW_REGISTRATION ??
|
||||
process.env.OIDC_ALLOW_REGISTRATION ??
|
||||
"";
|
||||
return allow.trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,74 +156,12 @@ async function applyRoleSync(
|
||||
async function findLinkedUser(
|
||||
identity: ExternalIdentity,
|
||||
): Promise<UserRecord | null> {
|
||||
const identities = createCurrentUserAuthRepository();
|
||||
const users = createCurrentUserRepository();
|
||||
|
||||
const linked = await identities.findIdentity(
|
||||
const linked = await createCurrentUserAuthRepository().findIdentity(
|
||||
identity.provider,
|
||||
identity.subject,
|
||||
);
|
||||
if (linked) {
|
||||
const user = await users.findById(linked.userId);
|
||||
if (user) return user;
|
||||
}
|
||||
|
||||
// Accounts from before user_external_identities matched on the raw
|
||||
// identifier column; link them the first time they sign in. The column is
|
||||
// not scoped to a provider, so only an SSO account that has no link yet,
|
||||
// from the same provider when both say which, can be claimed this way.
|
||||
if (identity.legacyIdentifier) {
|
||||
const legacy = await users.findByExternalIdentifier(
|
||||
identity.legacyIdentifier,
|
||||
);
|
||||
if (legacy && (await mayClaimLegacyAccount(legacy, identity))) {
|
||||
await identities.linkIdentity({
|
||||
userId: legacy.id,
|
||||
providerId: identity.provider,
|
||||
subject: identity.subject,
|
||||
email: identity.email,
|
||||
});
|
||||
return legacy;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function mayClaimLegacyAccount(
|
||||
legacy: UserRecord,
|
||||
identity: ExternalIdentity,
|
||||
): Promise<boolean> {
|
||||
if (!legacy.isOidc) return false;
|
||||
if (
|
||||
legacy.ssoProviderId != null &&
|
||||
identity.ssoProviderId != null &&
|
||||
legacy.ssoProviderId !== identity.ssoProviderId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const links = await createCurrentUserAuthRepository().listIdentitiesForUser(
|
||||
legacy.id,
|
||||
);
|
||||
// An unscoped legacy subject may already have been migrated under a
|
||||
// different provider. Creating another account loses access to the old
|
||||
// data; claiming it automatically could cross an identity-provider boundary.
|
||||
if (
|
||||
legacy.ssoProviderId == null &&
|
||||
identity.ssoProviderId != null &&
|
||||
identity.legacyIdentifier === identity.subject &&
|
||||
links.some(
|
||||
(link) =>
|
||||
link.subject === identity.subject &&
|
||||
link.providerId !== identity.provider,
|
||||
)
|
||||
) {
|
||||
throw new LoginMethodError(
|
||||
"A legacy SSO account has an unresolved provider mapping. Ask an administrator to verify and repair its identity mapping before signing in.",
|
||||
409,
|
||||
"legacy_identity_conflict",
|
||||
);
|
||||
}
|
||||
return links.length === 0;
|
||||
if (!linked) return null;
|
||||
return createCurrentUserRepository().findById(linked.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,10 +193,6 @@ async function createUser(
|
||||
username: await freeUsername(identity.name || identity.subject, null),
|
||||
passwordHash: "",
|
||||
isAdmin: !!identity.isAdmin,
|
||||
isOidc: true,
|
||||
oidcIdentifier:
|
||||
identity.legacyIdentifier ?? `${identity.provider}:${identity.subject}`,
|
||||
ssoProviderId: identity.ssoProviderId ?? null,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
import type { ExternalSessionRef } from "./types.js";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -64,9 +65,7 @@ export interface IssueSessionOptions {
|
||||
rememberMe: boolean;
|
||||
/** Username the rate limiter counted attempts under, to clear them. */
|
||||
rateLimitUsername?: string;
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
externalSession?: ExternalSessionRef | null;
|
||||
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
|
||||
longLivedForApps?: boolean;
|
||||
}
|
||||
@@ -93,9 +92,7 @@ export async function issueSession(
|
||||
rememberMe: options.rememberMe,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
oidcSub: options.oidcSub ?? null,
|
||||
oidcSid: options.oidcSid ?? null,
|
||||
ssoProviderId: options.ssoProviderId ?? null,
|
||||
externalSession: options.externalSession ?? null,
|
||||
});
|
||||
|
||||
if (options.rateLimitUsername) {
|
||||
@@ -141,8 +138,6 @@ export async function issueSession(
|
||||
username: user.username,
|
||||
userId: user.id,
|
||||
is_external: isExternalAccount(user),
|
||||
// 2.8 name, kept until 26.10.0.
|
||||
is_oidc: !!user.isOidc,
|
||||
// Any second factor; the name is what 2.8 clients read.
|
||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||
user.id,
|
||||
|
||||
+13
-11
@@ -2,19 +2,23 @@ import type { PluginVerifiedIdentity } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export { LoginMethodError } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/** The logout claims a login carried, stored as sessions.external_session_ref. */
|
||||
export interface ExternalSessionRef {
|
||||
providerId?: number | null;
|
||||
sub?: string | null;
|
||||
sid?: string | null;
|
||||
}
|
||||
|
||||
/** Extra fields core's own methods may set. Plugins never see these. */
|
||||
interface CoreIdentityExtras {
|
||||
/**
|
||||
* The identifier string the user row carried before user_external_identities
|
||||
* existed ("ldap:<provider>:<id>", "github:<provider>:<id>" or a bare OIDC
|
||||
* subject). Found users get their identity row linked lazily; new users keep
|
||||
* the column filled so older code that reads it still works.
|
||||
* The identifier a 2.8 install kept for the user ("ldap:<provider>:<id>",
|
||||
* "github:<provider>:<id>" or a bare OIDC subject). Only checked against
|
||||
* the allowed-users list.
|
||||
*/
|
||||
legacyIdentifier?: string;
|
||||
/** SSO provider row the session came from, for back-channel logout. */
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
/** Kept on the session for an identity provider's back-channel logout. */
|
||||
externalSession?: ExternalSessionRef | null;
|
||||
/** Provider group to role mapping, applied on every login. */
|
||||
roleSync?: { desired: string[]; managed: string[] };
|
||||
/** Shown when the user's data key cannot be unlocked by this method. */
|
||||
@@ -30,8 +34,6 @@ export interface PendingLogin {
|
||||
userId: string;
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
externalSession?: ExternalSessionRef | null;
|
||||
createdAt: number;
|
||||
}
|
||||
+3
-11
@@ -65,15 +65,7 @@ export async function runCoreBootMigrations(): Promise<void> {
|
||||
await import("./utils/crypto-migration/private-shared-ssh-auth-migration.js");
|
||||
await runPrivateSharedSshAuthMigration();
|
||||
|
||||
const { runExternalIdentityMigration } =
|
||||
await import("./upgrade/external-identity-migration.js");
|
||||
await runExternalIdentityMigration();
|
||||
|
||||
const { runHostStatusConfigMigration } =
|
||||
await import("./utils/crypto-migration/host-status-config-migration.js");
|
||||
await runHostStatusConfigMigration();
|
||||
|
||||
const { runSshOptionsMigration } =
|
||||
await import("./utils/crypto-migration/ssh-options-migration.js");
|
||||
await runSshOptionsMigration();
|
||||
const { runExternalAutoProvisionMigration } =
|
||||
await import("./upgrade/external-auto-provision-migration.js");
|
||||
await runExternalAutoProvisionMigration();
|
||||
}
|
||||
@@ -630,15 +630,15 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const isOidcUser = !!user.isOidc;
|
||||
const isExternalUser = !!user.isExternal;
|
||||
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateExternalUser(
|
||||
if (isExternalUser) {
|
||||
const externalUnlocked = await authManager.authenticateExternalUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
if (!externalUnlocked) {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
});
|
||||
@@ -699,17 +699,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
oidc_identifier TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
issuer_url TEXT,
|
||||
authorization_url TEXT,
|
||||
token_url TEXT,
|
||||
identifier_path TEXT,
|
||||
name_path TEXT,
|
||||
scopes TEXT DEFAULT 'openid email profile'
|
||||
is_admin INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE settings (
|
||||
@@ -740,9 +730,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
jump_hosts TEXT,
|
||||
status_check_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status_check_interval INTEGER,
|
||||
terminal_config TEXT,
|
||||
ssh_options TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
@@ -805,31 +793,21 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
|
||||
const userRecord = user;
|
||||
const insertUser = exportDb.prepare(`
|
||||
INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, username, password_hash, is_admin)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
insertUser.run(
|
||||
userRecord.id,
|
||||
userRecord.username,
|
||||
"[EXPORTED_USER_NO_PASSWORD]",
|
||||
userRecord.isAdmin ? 1 : 0,
|
||||
userRecord.isOidc ? 1 : 0,
|
||||
userRecord.oidcIdentifier || null,
|
||||
userRecord.clientId || null,
|
||||
userRecord.clientSecret || null,
|
||||
userRecord.issuerUrl || null,
|
||||
userRecord.authorizationUrl || null,
|
||||
userRecord.tokenUrl || null,
|
||||
userRecord.identifierPath || null,
|
||||
userRecord.namePath || null,
|
||||
userRecord.scopes || null,
|
||||
);
|
||||
|
||||
const sshHosts =
|
||||
await createCurrentHostRepository().listDecryptedByUserId(userId);
|
||||
const insertHost = exportDb.prepare(`
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, ssh_options, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, port_knock_sequence, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, ssh_options, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, port_knock_sequence, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const decrypted of sshHosts) {
|
||||
@@ -856,9 +834,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
decrypted.jumpHosts || null,
|
||||
decrypted.statusCheckEnabled === false ? 0 : 1,
|
||||
decrypted.statusCheckInterval ?? null,
|
||||
decrypted.terminalConfig || null,
|
||||
decrypted.sshOptions || null,
|
||||
decrypted.quickActions || null,
|
||||
decrypted.notes || null,
|
||||
decrypted.useSocks5 ? 1 : 0,
|
||||
decrypted.socks5Host || null,
|
||||
@@ -1074,15 +1050,15 @@ app.post(
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const isOidcUser = !!userRecord.isOidc;
|
||||
const isExternalUser = !!userRecord.isExternal;
|
||||
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateExternalUser(
|
||||
if (isExternalUser) {
|
||||
const externalUnlocked = await authManager.authenticateExternalUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
if (!externalUnlocked) {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
});
|
||||
@@ -1198,7 +1174,6 @@ app.post(
|
||||
),
|
||||
jumpHosts: host.jump_hosts,
|
||||
...legacyStatusCheck(host),
|
||||
terminalConfig: host.terminal_config,
|
||||
// Exports from before 2.9.0 carry these in terminal_config.
|
||||
sshOptions:
|
||||
host.ssh_options ??
|
||||
@@ -1206,7 +1181,6 @@ app.post(
|
||||
terminalConfig: host.terminal_config,
|
||||
}) ??
|
||||
null,
|
||||
quickActions: host.quick_actions,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
|
||||
+267
-120
@@ -24,6 +24,16 @@ import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
|
||||
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
|
||||
import { connectRemoteDatabase } from "./connect.js";
|
||||
import { runRemoteMigrations } from "./migrate.js";
|
||||
import { assertNotPre29Database } from "./upgrade-guard.js";
|
||||
import {
|
||||
LEGACY_USERNAME_PLACEHOLDER_UPDATES,
|
||||
legacySudoMoves,
|
||||
RETIRED_COLUMNS,
|
||||
RETIRED_SETTINGS,
|
||||
REMOTE_RETIRED_COLUMNS,
|
||||
type LegacySudoRow,
|
||||
} from "./retired-columns.js";
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
import type { PortableDatabase } from "../repositories/database-context.js";
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
@@ -217,22 +227,21 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
|
||||
db = drizzle(sqlite, { schema });
|
||||
|
||||
await assertNotPre29Database(
|
||||
(name) =>
|
||||
!!sqlite
|
||||
.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
)
|
||||
.get(name),
|
||||
);
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
oidc_identifier TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
issuer_url TEXT,
|
||||
authorization_url TEXT,
|
||||
token_url TEXT,
|
||||
identifier_path TEXT,
|
||||
name_path TEXT,
|
||||
scopes TEXT DEFAULT 'openid email profile',
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -308,7 +317,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
force_keyboard_interactive TEXT,
|
||||
status_check_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status_check_interval INTEGER,
|
||||
terminal_config TEXT,
|
||||
notes TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
@@ -357,12 +365,10 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
credential_id INTEGER,
|
||||
sort_order INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
||||
@@ -672,6 +678,14 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// 2.9.1 added this fix, so a 2.9.0 install has not run it yet. It reads the
|
||||
// column migrateSchema drops.
|
||||
if (columnNames("users").includes("oidc_identifier")) {
|
||||
const { runSsoLegacyIdentityMigration } =
|
||||
await import("../../upgrade/sso-legacy-identity-migration.js");
|
||||
await runSsoLegacyIdentityMigration();
|
||||
}
|
||||
|
||||
migrateSchema();
|
||||
vacuumIfFreelistBloated();
|
||||
|
||||
@@ -782,6 +796,135 @@ const relaxPluginGrantGrantedBy = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const columnNames = (table: string): string[] =>
|
||||
(
|
||||
sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((col) => col.name);
|
||||
|
||||
/**
|
||||
* ssh_folders.credential_id became the folder's auth default. It carries a
|
||||
* foreign key, which ALTER TABLE DROP COLUMN refuses, so the table is rebuilt.
|
||||
*/
|
||||
const dropFolderCredentialColumn = () => {
|
||||
const columns = columnNames("ssh_folders");
|
||||
if (!columns.includes("credential_id")) return;
|
||||
|
||||
const kept = [
|
||||
"id",
|
||||
"user_id",
|
||||
"name",
|
||||
"color",
|
||||
"icon",
|
||||
"sort_order",
|
||||
"sync_id",
|
||||
"local_only",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
].filter((name) => columns.includes(name));
|
||||
const list = kept.map((name) => `"${name}"`).join(", ");
|
||||
|
||||
sqlite.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
sqlite.exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE ssh_folders_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
sort_order INTEGER,
|
||||
sync_id TEXT,
|
||||
local_only INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO ssh_folders_new (${list}) SELECT ${list} FROM ssh_folders;
|
||||
DROP TABLE ssh_folders;
|
||||
ALTER TABLE ssh_folders_new RENAME TO ssh_folders;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_ssh_folders_sync_id ON ssh_folders(sync_id);
|
||||
COMMIT;
|
||||
`);
|
||||
} catch (error) {
|
||||
if (sqlite.inTransaction) sqlite.exec("ROLLBACK");
|
||||
databaseLogger.warn("Failed to drop ssh_folders.credential_id", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
};
|
||||
|
||||
const moveLegacySudoPasswords = () => {
|
||||
if (!columnNames("ssh_data").includes("terminal_config")) return;
|
||||
try {
|
||||
const rows = sqlite
|
||||
.prepare(
|
||||
"SELECT id, terminal_config, sudo_password FROM ssh_data WHERE terminal_config LIKE '%sudoPassword%'",
|
||||
)
|
||||
.all() as LegacySudoRow[];
|
||||
const update = sqlite.prepare(
|
||||
"UPDATE ssh_data SET sudo_password = ? WHERE id = ?",
|
||||
);
|
||||
for (const move of legacySudoMoves(rows)) {
|
||||
update.run(move.sudoPassword, move.id);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to move legacy sudo passwords", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const dropRetiredColumns = () => {
|
||||
moveLegacySudoPasswords();
|
||||
for (const [table, retired] of Object.entries(RETIRED_COLUMNS)) {
|
||||
const columns = new Set(columnNames(table));
|
||||
for (const column of retired) {
|
||||
if (!columns.has(column)) continue;
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table} DROP COLUMN "${column}"`);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(`Failed to drop ${table}.${column}`, {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
dropFolderCredentialColumn();
|
||||
|
||||
// 2.8 spelled $external.username as $oidc.preferred_username.
|
||||
for (const statement of LEGACY_USERNAME_PLACEHOLDER_UPDATES) {
|
||||
try {
|
||||
sqlite.exec(statement);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to rename a legacy username placeholder", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`DELETE FROM settings WHERE key IN (${RETIRED_SETTINGS.map(() => "?").join(", ")})`,
|
||||
)
|
||||
.run(...RETIRED_SETTINGS);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to delete retired settings", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
repairSnippetsNoteColumn(sqlite);
|
||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||
@@ -789,7 +932,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "language", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "storage_mode", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "command_autocomplete", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "command_palette_enabled", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER");
|
||||
@@ -805,31 +947,15 @@ const migrateSchema = () => {
|
||||
"INTEGER",
|
||||
);
|
||||
addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "terminal_defaults", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "terminal_macros", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
const hadRegisteredAtColumn = (() => {
|
||||
try {
|
||||
sqlite.prepare(`SELECT "registered_at" FROM users LIMIT 1`).get();
|
||||
@@ -878,9 +1004,7 @@ const migrateSchema = () => {
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
|
||||
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
|
||||
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
|
||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
@@ -926,8 +1050,6 @@ const migrateSchema = () => {
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "status_check_interval", "INTEGER");
|
||||
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
|
||||
addColumnIfNotExists("ssh_data", "domain", "TEXT");
|
||||
@@ -1145,19 +1267,6 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec("ALTER TABLE ssh_folders ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn("Failed to add credential_id column to ssh_folders", {
|
||||
operation: "schema_migration",
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1177,7 +1286,6 @@ const migrateSchema = () => {
|
||||
{ column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" },
|
||||
{ column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
|
||||
{ column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
|
||||
{ column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
|
||||
{ column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" },
|
||||
{ column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" },
|
||||
@@ -1459,76 +1567,6 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
||||
|
||||
try {
|
||||
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
|
||||
cid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
}>;
|
||||
const legacyNotNullColumns = new Set([
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
"identifier_path",
|
||||
"name_path",
|
||||
"scopes",
|
||||
]);
|
||||
const hasStaleNotNull = usersTableInfo.some(
|
||||
(col) => legacyNotNullColumns.has(col.name) && col.notnull === 1,
|
||||
);
|
||||
|
||||
if (hasStaleNotNull) {
|
||||
const tempTableName = "users_temp_migration";
|
||||
const columnDefs = usersTableInfo
|
||||
.map((col) => {
|
||||
const parts = [`"${col.name}"`, col.type || "TEXT"];
|
||||
if (col.pk === 1) parts.push("PRIMARY KEY");
|
||||
if (col.notnull === 1 && !legacyNotNullColumns.has(col.name)) {
|
||||
parts.push("NOT NULL");
|
||||
}
|
||||
if (col.dflt_value !== null) {
|
||||
parts.push(`DEFAULT ${col.dflt_value}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
})
|
||||
.join(",\n ");
|
||||
const allColumns = usersTableInfo.map((col) => `"${col.name}"`).join(", ");
|
||||
|
||||
sqlite.exec(`PRAGMA foreign_keys = OFF`);
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ${tempTableName} (
|
||||
${columnDefs}
|
||||
);
|
||||
|
||||
INSERT INTO ${tempTableName} SELECT ${allColumns} FROM users;
|
||||
|
||||
DROP TABLE users;
|
||||
|
||||
ALTER TABLE ${tempTableName} RENAME TO users;
|
||||
`);
|
||||
sqlite.exec(`PRAGMA foreign_keys = ON`);
|
||||
|
||||
databaseLogger.info(
|
||||
"Successfully migrated users table to remove legacy OIDC NOT NULL constraints",
|
||||
{
|
||||
operation: "schema_migration_users_oidc_nullable",
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (migrationError) {
|
||||
databaseLogger.warn("Failed to migrate users table legacy OIDC columns", {
|
||||
operation: "schema_migration",
|
||||
error: migrationError,
|
||||
});
|
||||
}
|
||||
|
||||
// --- alerts begin ---
|
||||
// alert_rules, alert_rule_channels and alert_firings were only ever created
|
||||
// here, never declared in schema.ts, so they never existed on Postgres or
|
||||
@@ -1686,6 +1724,8 @@ const migrateSchema = () => {
|
||||
// they referenced, which defeats the point of keeping them.
|
||||
migrateAuditRetention(sqlite);
|
||||
|
||||
dropRetiredColumns();
|
||||
|
||||
// Runs last so every table and column added above already exists.
|
||||
createPerformanceIndexes(sqlite);
|
||||
|
||||
@@ -1832,6 +1872,94 @@ async function initializeDatabase(): Promise<void> {
|
||||
* synchronously, which better-sqlite3 allows and no remote driver does, so the
|
||||
* table is loaded once here before anything asks for it.
|
||||
*/
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
// node-postgres returns { rows }, mysql2 returns [rows, fields].
|
||||
if (Array.isArray(result)) return result[0] as T[];
|
||||
return ((result as { rows?: unknown[] }).rows ?? []) as T[];
|
||||
}
|
||||
|
||||
async function moveRemoteLegacySudoPasswords(remote: {
|
||||
execute: (query: SQL) => Promise<unknown>;
|
||||
}): Promise<void> {
|
||||
let rows: LegacySudoRow[];
|
||||
try {
|
||||
rows = resultRows<LegacySudoRow>(
|
||||
await remote.execute(
|
||||
sql`SELECT id, terminal_config, sudo_password FROM ssh_data WHERE terminal_config LIKE '%sudoPassword%'`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// A database created after the column left never had it.
|
||||
return;
|
||||
}
|
||||
for (const move of legacySudoMoves(rows)) {
|
||||
await remote.execute(
|
||||
sql`UPDATE ssh_data SET sudo_password = ${move.sudoPassword} WHERE id = ${move.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
|
||||
* longer knows them. Dropped by hand, only where they still exist.
|
||||
*/
|
||||
async function dropRemoteRetired(
|
||||
dialect: Exclude<DatabaseDialect, "sqlite">,
|
||||
remote: { execute: (query: SQL) => Promise<unknown> },
|
||||
): Promise<void> {
|
||||
const tables = Object.keys(REMOTE_RETIRED_COLUMNS);
|
||||
const schema =
|
||||
dialect === "postgres" ? sql`current_schema()` : sql`DATABASE()`;
|
||||
let present: Array<{ table_name: string; column_name: string }> = [];
|
||||
try {
|
||||
present = resultRows(
|
||||
await remote.execute(
|
||||
sql`SELECT table_name AS table_name, column_name AS column_name FROM information_schema.columns WHERE table_schema = ${schema} AND table_name IN (${sql.join(
|
||||
tables.map((table) => sql`${table}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not list retired columns", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existing = new Set(
|
||||
present.map((row) => `${row.table_name}.${row.column_name}`),
|
||||
);
|
||||
for (const [table, columns] of Object.entries(REMOTE_RETIRED_COLUMNS)) {
|
||||
for (const column of columns) {
|
||||
if (!existing.has(`${table}.${column}`)) continue;
|
||||
try {
|
||||
await remote.execute(
|
||||
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(`Failed to drop ${table}.${column}`, {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await remote.execute(
|
||||
sql`DELETE FROM settings WHERE ${sql.identifier("key")} IN (${sql.join(
|
||||
RETIRED_SETTINGS.map((key) => sql`${key}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to delete retired settings", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeRemoteDatabase(
|
||||
dialect: Exclude<DatabaseDialect, "sqlite">,
|
||||
): Promise<void> {
|
||||
@@ -1841,7 +1969,26 @@ async function initializeRemoteDatabase(
|
||||
});
|
||||
|
||||
db = await connectRemoteDatabase(dialect);
|
||||
const remote = db as unknown as { execute: (query: SQL) => Promise<unknown> };
|
||||
await assertNotPre29Database(async (name) => {
|
||||
try {
|
||||
await remote.execute(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await moveRemoteLegacySudoPasswords(remote);
|
||||
try {
|
||||
await remote.execute(sql`SELECT oidc_identifier FROM users WHERE 1 = 0`);
|
||||
const { runSsoLegacyIdentityMigration } =
|
||||
await import("../../upgrade/sso-legacy-identity-migration.js");
|
||||
await runSsoLegacyIdentityMigration();
|
||||
} catch {
|
||||
// The column is gone: the move ran before it was dropped.
|
||||
}
|
||||
await runRemoteMigrations(dialect, db);
|
||||
await dropRemoteRetired(dialect, remote);
|
||||
|
||||
// Imported here rather than at the top: factory.ts imports getDb from this
|
||||
// module, and a static import would close the cycle at module-load time.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* What 26.10.0 removes that 2.9.0 kept so a downgrade to 2.8 lost nothing.
|
||||
* Every 2.9 boot copied it into plugin settings, and the upgrade guard only
|
||||
* opens a database 2.9 has seen.
|
||||
*
|
||||
* Kept on purpose: columns a move only reads once the owner's data key opens
|
||||
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
|
||||
* domain), so a user who never signed in to 2.9 still gets them, and
|
||||
* ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place.
|
||||
*/
|
||||
|
||||
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
|
||||
export const REMOTE_RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
ssh_data: [
|
||||
"allow_session_sharing",
|
||||
"autostart_key",
|
||||
"autostart_key_password",
|
||||
"autostart_password",
|
||||
"default_path",
|
||||
"docker_config",
|
||||
"enable_ai_assistant",
|
||||
"enable_command_history",
|
||||
"enable_docker",
|
||||
"enable_file_manager",
|
||||
"enable_proxmox",
|
||||
"enable_proxmox_stats",
|
||||
"enable_rdp",
|
||||
"enable_session_logging",
|
||||
"enable_telnet",
|
||||
"enable_terminal",
|
||||
"enable_terminal_toolbar",
|
||||
"enable_tmux_monitor",
|
||||
"enable_tunnel",
|
||||
"enable_vnc",
|
||||
"enable_web_ui",
|
||||
"guacamole_config",
|
||||
"ignore_cert",
|
||||
"mac_address",
|
||||
"proxmox_config",
|
||||
"proxmox_stats_config",
|
||||
"rdp_ignore_cert",
|
||||
"rdp_port",
|
||||
"rdp_security",
|
||||
"scp_legacy",
|
||||
"security",
|
||||
"show_docker_in_sidebar",
|
||||
"show_file_manager_in_sidebar",
|
||||
"show_server_stats_in_sidebar",
|
||||
"show_terminal_in_sidebar",
|
||||
"show_tunnel_in_sidebar",
|
||||
"stats_config",
|
||||
"telnet_port",
|
||||
"tunnel_connections",
|
||||
"use_warpgate",
|
||||
"vnc_port",
|
||||
"web_ui_config",
|
||||
"wol_broadcast_address",
|
||||
],
|
||||
user_preferences: [
|
||||
"ai_assistant_enabled",
|
||||
"ai_read_only_commands",
|
||||
"rdp_defaults",
|
||||
],
|
||||
};
|
||||
|
||||
/** Everything SQLite drops; the first part the drizzle migrations drop too. */
|
||||
export const RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
users: [
|
||||
"is_oidc",
|
||||
"oidc_identifier",
|
||||
"sso_provider_id",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
"identifier_path",
|
||||
"name_path",
|
||||
"scopes",
|
||||
],
|
||||
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
||||
user_preferences: [
|
||||
"command_autocomplete",
|
||||
"terminal_defaults",
|
||||
"custom_themes",
|
||||
"confirm_snippet_execution",
|
||||
"terminal_macros",
|
||||
...REMOTE_RETIRED_COLUMNS.user_preferences,
|
||||
],
|
||||
ssh_data: [
|
||||
"terminal_config",
|
||||
"quick_actions",
|
||||
...REMOTE_RETIRED_COLUMNS.ssh_data,
|
||||
],
|
||||
};
|
||||
|
||||
/** Settings rows the plugins copied, and the markers of the copies. */
|
||||
export const RETIRED_SETTINGS = [
|
||||
"host_defaults",
|
||||
"analytics_enabled",
|
||||
"analytics_instance_id",
|
||||
"terminal_session_timeout_minutes",
|
||||
"terminal_session_persistence_enabled",
|
||||
"command_history_enabled",
|
||||
"touch_input_settings",
|
||||
"terminal_image_storage_mode",
|
||||
"terminal_image_local_dir",
|
||||
"terminal_image_host_path",
|
||||
"terminal_image_ttl_ms",
|
||||
"terminal_image_max_count",
|
||||
"terminal_image_max_storage_bytes",
|
||||
"session_sharing_globally_enabled",
|
||||
"guac_enabled",
|
||||
"guac_url",
|
||||
"step_ca_url",
|
||||
"step_ca_fingerprint",
|
||||
"step_ca_provisioner",
|
||||
"step_ca_private_endpoint_allowlist",
|
||||
"acme_ssl_settings",
|
||||
"global_metrics_interval",
|
||||
"metrics_history_retention_days",
|
||||
"tailscale_api_base_url",
|
||||
"tailscale_api_key",
|
||||
"ai_globally_enabled",
|
||||
"ai_private_endpoint_allowlist",
|
||||
"oidc_silent_login_default",
|
||||
"host_status_config_migrated",
|
||||
];
|
||||
|
||||
/**
|
||||
* 2.8 editors kept a host's sudo password inside terminal_config, and 2.9
|
||||
* still read it from there. Before the column goes it is copied into
|
||||
* sudo_password, as plaintext, which the field encryption reads as it is
|
||||
* and encrypts on the next save.
|
||||
*/
|
||||
export function legacySudoPassword(terminalConfig: unknown): string | null {
|
||||
let parsed = terminalConfig;
|
||||
try {
|
||||
while (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const value = (parsed as { sudoPassword?: unknown }).sudoPassword;
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
export interface LegacySudoRow {
|
||||
id: number;
|
||||
terminal_config: unknown;
|
||||
sudo_password: unknown;
|
||||
}
|
||||
|
||||
/** The hosts whose sudo password still only lives in terminal_config. */
|
||||
export function legacySudoMoves(
|
||||
rows: LegacySudoRow[],
|
||||
): Array<{ id: number; sudoPassword: string }> {
|
||||
return rows.flatMap((row) => {
|
||||
if (row.sudo_password) return [];
|
||||
const sudoPassword = legacySudoPassword(row.terminal_config);
|
||||
return sudoPassword ? [{ id: Number(row.id), sudoPassword }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the 2.8 `$oidc.preferred_username` placeholder to
|
||||
* `$external.username` wherever a host username is saved. Plain SQL that
|
||||
* runs the same on SQLite, Postgres and MySQL; the drizzle migrations carry
|
||||
* a copy.
|
||||
*/
|
||||
export const LEGACY_USERNAME_PLACEHOLDER_UPDATES = [
|
||||
"UPDATE ssh_data SET username = REPLACE(username, '$oidc.preferred_username', '$external.username') WHERE username LIKE '%$oidc.preferred_username%'",
|
||||
"UPDATE ssh_credentials SET username = REPLACE(username, '$oidc.preferred_username', '$external.username') WHERE username LIKE '%$oidc.preferred_username%'",
|
||||
"UPDATE host_defaults SET value = REPLACE(value, '$oidc.preferred_username', '$external.username') WHERE value LIKE '%$oidc.preferred_username%'",
|
||||
];
|
||||
@@ -26,19 +26,6 @@ export const users = mysqlTable("users", {
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
|
||||
isOidc: boolean("is_oidc").notNull().default(false),
|
||||
oidcIdentifier: text("oidc_identifier"),
|
||||
ssoProviderId: int("sso_provider_id"),
|
||||
clientId: text("client_id"),
|
||||
clientSecret: text("client_secret"),
|
||||
issuerUrl: text("issuer_url"),
|
||||
authorizationUrl: text("authorization_url"),
|
||||
tokenUrl: text("token_url"),
|
||||
identifierPath: text("identifier_path"),
|
||||
namePath: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||
donationModalDismissed: boolean("donation_modal_dismissed")
|
||||
.notNull()
|
||||
@@ -60,9 +47,8 @@ export const sessions = mysqlTable(
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
oidcSub: text("oidc_sub"),
|
||||
oidcSid: text("oidc_sid"),
|
||||
ssoProviderId: int("sso_provider_id"),
|
||||
// JSON of the identity provider's logout claims, for back-channel logout.
|
||||
externalSessionRef: text("external_session_ref"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
@@ -196,11 +182,9 @@ export const hosts = mysqlTable(
|
||||
.notNull()
|
||||
.default(true),
|
||||
statusCheckInterval: int("status_check_interval"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
// SSH connection options core's connect pipeline reads (keepalive,
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||
|
||||
@@ -377,9 +361,6 @@ export const sshFolders = mysqlTable(
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
credentialId: int("credential_id").references(() => sshCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Manual drag-to-reorder position among sibling folders. Null falls back
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: int("sort_order"),
|
||||
@@ -712,7 +693,6 @@ export const userPreferences = mysqlTable("user_preferences", {
|
||||
accentColor: text("accent_color"),
|
||||
language: text("language"),
|
||||
storageMode: text("storage_mode"),
|
||||
commandAutocomplete: boolean("command_autocomplete"),
|
||||
commandPaletteEnabled: boolean("command_palette_enabled"),
|
||||
showHostTags: boolean("show_host_tags"),
|
||||
hostTrayOnClick: boolean("host_tray_on_click"),
|
||||
@@ -720,16 +700,12 @@ export const userPreferences = mysqlTable("user_preferences", {
|
||||
expandAppRailOnHover: boolean("expand_app_rail_on_hover"),
|
||||
showPinAppRailButton: boolean("show_pin_app_rail_button"),
|
||||
foldersCollapsed: boolean("folders_collapsed"),
|
||||
confirmSnippetExecution: boolean("confirm_snippet_execution"),
|
||||
disableUpdateCheck: boolean("disable_update_check"),
|
||||
confirmTabClose: boolean("confirm_tab_close"),
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
|
||||
@@ -26,19 +26,6 @@ export const users = pgTable("users", {
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
|
||||
isOidc: boolean("is_oidc").notNull().default(false),
|
||||
oidcIdentifier: text("oidc_identifier"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
clientId: text("client_id"),
|
||||
clientSecret: text("client_secret"),
|
||||
issuerUrl: text("issuer_url"),
|
||||
authorizationUrl: text("authorization_url"),
|
||||
tokenUrl: text("token_url"),
|
||||
identifierPath: text("identifier_path"),
|
||||
namePath: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: boolean("donation_modal_dismissed")
|
||||
.notNull()
|
||||
@@ -60,9 +47,8 @@ export const sessions = pgTable(
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
oidcSub: text("oidc_sub"),
|
||||
oidcSid: text("oidc_sid"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
// JSON of the identity provider's logout claims, for back-channel logout.
|
||||
externalSessionRef: text("external_session_ref"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -196,11 +182,9 @@ export const hosts = pgTable(
|
||||
.notNull()
|
||||
.default(true),
|
||||
statusCheckInterval: integer("status_check_interval"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
// SSH connection options core's connect pipeline reads (keepalive,
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||
|
||||
@@ -377,9 +361,6 @@ export const sshFolders = pgTable(
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Manual drag-to-reorder position among sibling folders. Null falls back
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: integer("sort_order"),
|
||||
@@ -712,7 +693,6 @@ export const userPreferences = pgTable("user_preferences", {
|
||||
accentColor: text("accent_color"),
|
||||
language: text("language"),
|
||||
storageMode: text("storage_mode"),
|
||||
commandAutocomplete: boolean("command_autocomplete"),
|
||||
commandPaletteEnabled: boolean("command_palette_enabled"),
|
||||
showHostTags: boolean("show_host_tags"),
|
||||
hostTrayOnClick: boolean("host_tray_on_click"),
|
||||
@@ -720,16 +700,12 @@ export const userPreferences = pgTable("user_preferences", {
|
||||
expandAppRailOnHover: boolean("expand_app_rail_on_hover"),
|
||||
showPinAppRailButton: boolean("show_pin_app_rail_button"),
|
||||
foldersCollapsed: boolean("folders_collapsed"),
|
||||
confirmSnippetExecution: boolean("confirm_snippet_execution"),
|
||||
disableUpdateCheck: boolean("disable_update_check"),
|
||||
confirmTabClose: boolean("confirm_tab_close"),
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -14,19 +14,6 @@ export const users = sqliteTable("users", {
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
|
||||
oidcIdentifier: text("oidc_identifier"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
clientId: text("client_id"),
|
||||
clientSecret: text("client_secret"),
|
||||
issuerUrl: text("issuer_url"),
|
||||
authorizationUrl: text("authorization_url"),
|
||||
tokenUrl: text("token_url"),
|
||||
identifierPath: text("identifier_path"),
|
||||
namePath: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: integer("donation_modal_dismissed", {
|
||||
mode: "boolean",
|
||||
@@ -50,9 +37,8 @@ export const sessions = sqliteTable(
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
oidcSub: text("oidc_sub"),
|
||||
oidcSid: text("oidc_sid"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
// JSON of the identity provider's logout claims, for back-channel logout.
|
||||
externalSessionRef: text("external_session_ref"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -188,11 +174,9 @@ export const hosts = sqliteTable(
|
||||
.notNull()
|
||||
.default(true),
|
||||
statusCheckInterval: integer("status_check_interval"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
// SSH connection options core's connect pipeline reads (keepalive,
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
|
||||
|
||||
@@ -369,9 +353,6 @@ export const sshFolders = sqliteTable(
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Manual drag-to-reorder position among sibling folders. Null falls back
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: integer("sort_order"),
|
||||
@@ -704,7 +685,6 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
accentColor: text("accent_color"),
|
||||
language: text("language"),
|
||||
storageMode: text("storage_mode"),
|
||||
commandAutocomplete: integer("command_autocomplete", { mode: "boolean" }),
|
||||
commandPaletteEnabled: integer("command_palette_enabled", { mode: "boolean" }),
|
||||
showHostTags: integer("show_host_tags", { mode: "boolean" }),
|
||||
hostTrayOnClick: integer("host_tray_on_click", { mode: "boolean" }),
|
||||
@@ -716,16 +696,12 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
mode: "boolean",
|
||||
}),
|
||||
foldersCollapsed: integer("folders_collapsed", { mode: "boolean" }),
|
||||
confirmSnippetExecution: integer("confirm_snippet_execution", { mode: "boolean" }),
|
||||
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
|
||||
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 26.10.0 dropped the columns 2.9.0 kept for a downgrade to 2.8, and the
|
||||
* one-time 2.8 moves that copied them into plugins went with them. A 2.8
|
||||
* database has to pass through 2.9 first, or that data is lost.
|
||||
*
|
||||
* A 2.9 database has the plugins table; a 2.8 one has hosts but no plugins.
|
||||
*/
|
||||
export const PRE_29_DATABASE_ERROR =
|
||||
"This database is from Termix 2.8 or older. Upgrade to Termix 2.9.1 and start it once so it can move your data, then upgrade to this version.";
|
||||
|
||||
export async function assertNotPre29Database(
|
||||
hasTable: (name: string) => boolean | Promise<boolean>,
|
||||
): Promise<void> {
|
||||
if ((await hasTable("ssh_data")) && !(await hasTable("plugins"))) {
|
||||
throw new Error(PRE_29_DATABASE_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,6 @@ export class HostFolderRepository {
|
||||
name: string,
|
||||
color: string | null | undefined,
|
||||
icon: string | null | undefined,
|
||||
credentialId?: number | null,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<{ folder: HostFolderRecord; created: boolean }> {
|
||||
const existing = await this.findFolder(userId, name);
|
||||
@@ -123,8 +122,6 @@ export class HostFolderRepository {
|
||||
{
|
||||
color,
|
||||
icon,
|
||||
credentialId:
|
||||
credentialId === undefined ? existing.credentialId : credentialId,
|
||||
updatedAt: now,
|
||||
},
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
|
||||
@@ -140,7 +137,6 @@ export class HostFolderRepository {
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
credentialId: credentialId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||
import { hosts, sshCredentials } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
@@ -459,34 +459,6 @@ export class HostResolutionRepository {
|
||||
return this.decryptOne("ssh_credentials", rows[0], decryptUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the nearest assigned credential for a folder path, walking up
|
||||
* through parent folders (e.g. "Switches / Floor1" falls back to
|
||||
* "Switches" if the child folder has no credential of its own).
|
||||
*/
|
||||
async findFolderCredentialId(
|
||||
userId: string,
|
||||
folderPath: string,
|
||||
): Promise<number | null> {
|
||||
const segments = folderPath.split(" / ").filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const paths = segments.map((_, i) => segments.slice(0, i + 1).join(" / "));
|
||||
const rows = await this.context.drizzle
|
||||
.select({ name: sshFolders.name, credentialId: sshFolders.credentialId })
|
||||
.from(sshFolders)
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), inArray(sshFolders.name, paths)),
|
||||
);
|
||||
|
||||
const byName = new Map(rows.map((row) => [row.name, row.credentialId]));
|
||||
for (let i = paths.length - 1; i >= 0; i--) {
|
||||
const credentialId = byName.get(paths[i]);
|
||||
if (credentialId) return credentialId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private decryptOne<T extends Record<string, unknown>>(
|
||||
tableName: "ssh_data" | "ssh_credentials",
|
||||
record: T | undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq, inArray, like, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm";
|
||||
import { pluginSettings, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import {
|
||||
@@ -8,11 +8,22 @@ import {
|
||||
} from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type UserRecord = typeof users.$inferSelect;
|
||||
/** isExternal: the user has at least one external sign-in identity. */
|
||||
export type UserRecord = typeof users.$inferSelect & { isExternal: boolean };
|
||||
export type NewUserRecord = typeof users.$inferInsert;
|
||||
export type UserUpdate = Partial<Omit<NewUserRecord, "id">>;
|
||||
export type NewFirstLocalUserRecord = Omit<NewUserRecord, "isAdmin">;
|
||||
|
||||
// Qualified by hand: drizzle prints a column in a subquery without its table,
|
||||
// which would bind users.id to the identity row's own id.
|
||||
const userColumns = {
|
||||
...getTableColumns(users),
|
||||
isExternal:
|
||||
sql<boolean>`EXISTS (SELECT 1 FROM ${sql.identifier("user_external_identities")} WHERE ${sql.identifier("user_external_identities")}.${sql.identifier("user_id")} = ${sql.identifier("users")}.${sql.identifier("id")})`.mapWith(
|
||||
(value: unknown) => value === true || Number(value) === 1,
|
||||
),
|
||||
};
|
||||
|
||||
export class UserRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
@@ -20,7 +31,7 @@ export class UserRepository {
|
||||
) {}
|
||||
|
||||
async listAll(): Promise<UserRecord[]> {
|
||||
return this.context.drizzle.select().from(users);
|
||||
return this.context.drizzle.select(userColumns).from(users);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +53,7 @@ export class UserRepository {
|
||||
|
||||
const [rows, totalResult] = await Promise.all([
|
||||
this.context.drizzle
|
||||
.select()
|
||||
.select(userColumns)
|
||||
.from(users)
|
||||
.where(where)
|
||||
// Case-insensitive: a plain sort puts every capitalised name ahead of
|
||||
@@ -61,7 +72,7 @@ export class UserRepository {
|
||||
|
||||
async findById(id: string): Promise<UserRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.select(userColumns)
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
@@ -71,7 +82,7 @@ export class UserRepository {
|
||||
|
||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.select(userColumns)
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.limit(1);
|
||||
@@ -79,18 +90,6 @@ export class UserRepository {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByExternalIdentifier(
|
||||
oidcIdentifier: string,
|
||||
): Promise<UserRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.oidcIdentifier, oidcIdentifier))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByIds(ids: string[]): Promise<UserRecord[]> {
|
||||
const uniqueIds = Array.from(new Set(ids.filter(Boolean)));
|
||||
if (uniqueIds.length === 0) {
|
||||
@@ -98,7 +97,7 @@ export class UserRepository {
|
||||
}
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.select(userColumns)
|
||||
.from(users)
|
||||
.where(inArray(users.id, uniqueIds));
|
||||
}
|
||||
@@ -106,7 +105,9 @@ export class UserRepository {
|
||||
async create(user: NewUserRecord): Promise<UserRecord> {
|
||||
const rows = await insertReturning(this.context, users, user);
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
return (
|
||||
(await this.findById(rows[0].id)) ?? { ...rows[0], isExternal: false }
|
||||
);
|
||||
}
|
||||
|
||||
async createFirstLocalUser(
|
||||
@@ -118,7 +119,7 @@ export class UserRepository {
|
||||
}));
|
||||
|
||||
await this.afterWrite();
|
||||
return result;
|
||||
return { ...result, user: { ...result.user, isExternal: false } };
|
||||
}
|
||||
|
||||
async createFirstSsoUser(
|
||||
@@ -130,7 +131,7 @@ export class UserRepository {
|
||||
}));
|
||||
|
||||
await this.afterWrite();
|
||||
return result;
|
||||
return { ...result, user: { ...result.user, isExternal: true } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +147,7 @@ export class UserRepository {
|
||||
*/
|
||||
private async createCheckingIfFirst(
|
||||
build: (isFirstUser: boolean) => NewUserRecord,
|
||||
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
|
||||
): Promise<{ user: typeof users.$inferSelect; isFirstUser: boolean }> {
|
||||
if (this.context.dialect === "sqlite") {
|
||||
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
|
||||
is checked directly above, and better-sqlite3 rejects an async
|
||||
@@ -195,7 +196,7 @@ export class UserRepository {
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
return rows[0] ? this.findById(rows[0].id) : null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
|
||||
@@ -710,9 +710,6 @@ export function registerHostBulkRoutes(
|
||||
sudoPassword: hostData.sudoPassword || null,
|
||||
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
|
||||
...importedStatusCheck(hostData as Record<string, unknown>),
|
||||
terminalConfig: hostData.terminalConfig
|
||||
? JSON.stringify(hostData.terminalConfig)
|
||||
: null,
|
||||
// A 2.8 export carries these inside terminalConfig.
|
||||
sshOptions:
|
||||
sshOptionsForWrite({
|
||||
@@ -975,7 +972,6 @@ export function registerHostBulkRoutes(
|
||||
: null,
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: null,
|
||||
terminalConfig: null,
|
||||
sshOptions: null,
|
||||
forceKeyboardInteractive: "false",
|
||||
notes: null,
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
import { recompute } from "../../hosts/defaults/recompute.js";
|
||||
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
|
||||
import {
|
||||
folderCredentialIds,
|
||||
setFolderCredentialDefault,
|
||||
} from "../../hosts/defaults/service.js";
|
||||
|
||||
type HostFolderRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
@@ -143,10 +146,17 @@ export function registerHostFolderRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const folders =
|
||||
await createCurrentHostFolderRepository().listFolders(userId);
|
||||
const [folders, credentials] = await Promise.all([
|
||||
createCurrentHostFolderRepository().listFolders(userId),
|
||||
folderCredentialIds(userId),
|
||||
]);
|
||||
|
||||
res.json(folders);
|
||||
res.json(
|
||||
folders.map((folder) => ({
|
||||
...folder,
|
||||
credentialId: credentials.get(folder.id) ?? null,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch folders", err, {
|
||||
operation: "fetch_folders",
|
||||
@@ -238,7 +248,6 @@ export function registerHostFolderRoutes(
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
normalizedCredentialId,
|
||||
);
|
||||
if (typeof localOnly === "boolean") {
|
||||
await createCurrentHostFolderRepository().setLocalOnly(
|
||||
|
||||
@@ -62,6 +62,7 @@ export const OWNER_PRIVATE_AUTH_FIELDS = {
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Keys a 2.8 client's terminalConfig may carry that only the owner may set. */
|
||||
export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [
|
||||
"sudoPassword",
|
||||
"agentSocketPath",
|
||||
@@ -263,18 +264,10 @@ export function stripSensitiveFields(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...host };
|
||||
const terminalConfigForSudo =
|
||||
host.terminalConfig &&
|
||||
typeof host.terminalConfig === "object" &&
|
||||
!Array.isArray(host.terminalConfig)
|
||||
? (host.terminalConfig as Record<string, unknown>)
|
||||
: undefined;
|
||||
result.hasKey = !!host.key;
|
||||
result.hasKeyPassword = !!host.keyPassword;
|
||||
result.hasPassword = !!host.password;
|
||||
// 2.8 editors kept the sudo password inside terminal_config.
|
||||
result.hasSudoPassword =
|
||||
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
|
||||
result.hasSudoPassword = !!host.sudoPassword;
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
delete result[field];
|
||||
}
|
||||
@@ -283,11 +276,11 @@ export function stripSensitiveFields(
|
||||
typeof result.terminalConfig === "object" &&
|
||||
!Array.isArray(result.terminalConfig)
|
||||
) {
|
||||
const terminalConfig = {
|
||||
...(result.terminalConfig as Record<string, unknown>),
|
||||
};
|
||||
delete terminalConfig.sudoPassword;
|
||||
result.terminalConfig = terminalConfig;
|
||||
const { sudoPassword: _sudo, ...rest } = result.terminalConfig as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
result.terminalConfig = rest;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -397,44 +390,22 @@ export function parseSharedSource(
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sshOptions, and terminalConfig in the shape 2.8 clients and exports read:
|
||||
* core's own keys only, the SSH options. Everything else 2.8 kept there now
|
||||
* belongs to plugins' host settings, which they put back through their
|
||||
* hostPayloadLegacy. A sudo password 2.8 kept in terminal_config comes out as
|
||||
* sudoPassword, for the sanitizers to handle.
|
||||
* hostPayloadLegacy.
|
||||
*/
|
||||
export function hostTerminalExport(host: Record<string, unknown>): {
|
||||
sshOptions: HostSshOptions;
|
||||
terminalConfig?: Record<string, unknown>;
|
||||
sudoPassword?: unknown;
|
||||
} {
|
||||
const raw = parseJsonObject(host.terminalConfig);
|
||||
const sshOptions = parseSshOptions(
|
||||
host.sshOptions != null ? host.sshOptions : raw,
|
||||
);
|
||||
const sshOptions = parseSshOptions(host.sshOptions);
|
||||
const terminalConfig: Record<string, unknown> = { ...sshOptions };
|
||||
const legacySudo = !host.sudoPassword ? raw?.sudoPassword : undefined;
|
||||
return {
|
||||
sshOptions,
|
||||
terminalConfig:
|
||||
Object.keys(terminalConfig).length > 0 ? terminalConfig : undefined,
|
||||
...(legacySudo ? { sudoPassword: legacySudo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
/**
|
||||
* How a host update writes terminal_config and ssh_options, kept apart from
|
||||
* the route so it can be tested on its own.
|
||||
* How a host update writes ssh_options, kept apart from the route so it can
|
||||
* be tested on its own.
|
||||
*/
|
||||
|
||||
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
|
||||
import { parseSshOptions } from "../../hosts/ssh-options.js";
|
||||
import {
|
||||
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
||||
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
|
||||
} from "./host-normalizers.js";
|
||||
import { OWNER_PRIVATE_SSH_OPTION_FIELDS } from "./host-normalizers.js";
|
||||
|
||||
export function parseTerminalConfig(
|
||||
value: unknown,
|
||||
@@ -27,63 +24,41 @@ export function parseTerminalConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* terminal_config is merged into what is stored rather than replaced. The
|
||||
* 2.8 keys the editor no longer sends (the terminal's look, which the
|
||||
* ssh-terminal plugin copies into its host settings, and a sudo password 2.8
|
||||
* editors kept there) stay until 26.10.0 drops the column. A shared editor
|
||||
* never changes the owner's private keys in either JSON. Returns an error
|
||||
* for a terminalConfig that cannot be read.
|
||||
* A 2.8 client's terminalConfig only feeds sshOptions, which the route has
|
||||
* already taken from it. A shared editor never changes the owner's private
|
||||
* SSH options. Returns an error for a terminalConfig that cannot be read.
|
||||
*/
|
||||
export async function mergeStoredTerminalFields(
|
||||
export async function mergeStoredSshOptions(
|
||||
sshDataObj: Record<string, unknown>,
|
||||
hostData: Record<string, unknown>,
|
||||
hostId: number,
|
||||
ownerId: string,
|
||||
isOwner: boolean,
|
||||
): Promise<string | null> {
|
||||
const sentTerminalConfig =
|
||||
hostData.terminalConfig !== undefined && hostData.terminalConfig !== null;
|
||||
const incoming = sentTerminalConfig
|
||||
? parseTerminalConfig(hostData.terminalConfig)
|
||||
: null;
|
||||
if (sentTerminalConfig && !incoming) return "Invalid terminal config";
|
||||
if (
|
||||
hostData.terminalConfig !== undefined &&
|
||||
hostData.terminalConfig !== null &&
|
||||
!parseTerminalConfig(hostData.terminalConfig)
|
||||
) {
|
||||
return "Invalid terminal config";
|
||||
}
|
||||
delete sshDataObj.terminalConfig;
|
||||
|
||||
const sentSudoPassword = isOwner && hostData.sudoPassword !== undefined;
|
||||
if (!incoming && !sentSudoPassword && sshDataObj.sshOptions === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (isOwner || sshDataObj.sshOptions === undefined) return null;
|
||||
|
||||
const stored = await createCurrentHostResolutionRepository().findHostById(
|
||||
hostId,
|
||||
ownerId,
|
||||
);
|
||||
const storedConfig = parseTerminalConfig(stored?.terminalConfig) ?? {};
|
||||
|
||||
if (incoming || (sentSudoPassword && "sudoPassword" in storedConfig)) {
|
||||
const merged = { ...storedConfig, ...(incoming ?? {}) };
|
||||
if (!isOwner) {
|
||||
for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) {
|
||||
if (field in storedConfig) merged[field] = storedConfig[field];
|
||||
else delete merged[field];
|
||||
}
|
||||
const options = parseSshOptions(sshDataObj.sshOptions);
|
||||
const storedOptions = parseSshOptions(stored?.sshOptions);
|
||||
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
|
||||
if (storedOptions[field] !== undefined) {
|
||||
options[field] = storedOptions[field];
|
||||
} else {
|
||||
delete options[field];
|
||||
}
|
||||
// A sudo password saved on its own lives in the encrypted column.
|
||||
if (sentSudoPassword) delete merged.sudoPassword;
|
||||
sshDataObj.terminalConfig = JSON.stringify(merged);
|
||||
}
|
||||
|
||||
if (!isOwner && sshDataObj.sshOptions !== undefined) {
|
||||
const options = parseSshOptions(sshDataObj.sshOptions);
|
||||
const storedOptions = parseSshOptions(stored?.sshOptions ?? storedConfig);
|
||||
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
|
||||
if (storedOptions[field] !== undefined) {
|
||||
options[field] = storedOptions[field];
|
||||
} else {
|
||||
delete options[field];
|
||||
}
|
||||
}
|
||||
sshDataObj.sshOptions = JSON.stringify(options);
|
||||
}
|
||||
sshDataObj.sshOptions = JSON.stringify(options);
|
||||
return null;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ import {
|
||||
listHostProtocols,
|
||||
} from "../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
mergeStoredTerminalFields,
|
||||
mergeStoredSshOptions,
|
||||
parseTerminalConfig,
|
||||
} from "./host-terminal-fields.js";
|
||||
|
||||
@@ -316,11 +316,6 @@ router.post(
|
||||
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
||||
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
|
||||
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
|
||||
terminalConfig: terminalConfig
|
||||
? typeof terminalConfig === "string"
|
||||
? terminalConfig
|
||||
: JSON.stringify(terminalConfig)
|
||||
: null,
|
||||
sshOptions: sshOptionsForWrite({ sshOptions, terminalConfig }) ?? null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
notes: notes || null,
|
||||
@@ -889,11 +884,6 @@ router.put(
|
||||
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
||||
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
|
||||
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
|
||||
terminalConfig: terminalConfig
|
||||
? typeof terminalConfig === "string"
|
||||
? terminalConfig
|
||||
: JSON.stringify(terminalConfig)
|
||||
: null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
notes: notes || null,
|
||||
useSocks5: useSocks5 ? 1 : 0,
|
||||
@@ -1081,7 +1071,7 @@ router.put(
|
||||
}
|
||||
}
|
||||
|
||||
const terminalFieldsError = await mergeStoredTerminalFields(
|
||||
const terminalFieldsError = await mergeStoredSshOptions(
|
||||
sshDataObj,
|
||||
hostData,
|
||||
Number(hostId),
|
||||
@@ -1661,11 +1651,7 @@ router.get(
|
||||
}
|
||||
|
||||
const resolved = (await resolveHostCredentials(host, userId)) || host;
|
||||
let value = resolved[field];
|
||||
|
||||
if (!value && field === "sudoPassword") {
|
||||
value = hostTerminalExport(resolved).sudoPassword || null;
|
||||
}
|
||||
const value = resolved[field];
|
||||
|
||||
if (!value) {
|
||||
return res.status(404).json({ error: "No password set" });
|
||||
@@ -1775,10 +1761,7 @@ router.get(
|
||||
credentialId: resolvedHost.credentialId || null,
|
||||
overrideCredentialUsername:
|
||||
!!resolvedHost.overrideCredentialUsername,
|
||||
sudoPassword:
|
||||
resolvedHost.sudoPassword ||
|
||||
hostTerminalExport(resolvedHost).sudoPassword ||
|
||||
null,
|
||||
sudoPassword: resolvedHost.sudoPassword || null,
|
||||
jumpHosts: resolvedHost.jumpHosts
|
||||
? JSON.parse(resolvedHost.jumpHosts as string)
|
||||
: null,
|
||||
@@ -1911,9 +1894,7 @@ router.get(
|
||||
!!resolvedHost.overrideCredentialUsername,
|
||||
sudoPassword: shareMode
|
||||
? null
|
||||
: resolvedHost.sudoPassword ||
|
||||
hostTerminalExport(resolvedHost).sudoPassword ||
|
||||
null,
|
||||
: resolvedHost.sudoPassword || null,
|
||||
jumpHosts: resolvedHost.jumpHosts
|
||||
? JSON.parse(resolvedHost.jumpHosts as string)
|
||||
: null,
|
||||
|
||||
@@ -123,7 +123,6 @@ export function registerUserAdminRoutes(
|
||||
username: u.username,
|
||||
is_admin: u.isAdmin,
|
||||
is_external: isExternalAccount(u),
|
||||
is_oidc: u.isOidc,
|
||||
password_hash: u.passwordHash ? "set" : null,
|
||||
// Management-only details stay admin-eyes-only; regular users hit
|
||||
// this route to pick sharing targets.
|
||||
@@ -470,15 +469,6 @@ export function registerUserAdminRoutes(
|
||||
username,
|
||||
passwordHash: password_hash,
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuerUrl: "",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -631,7 +621,7 @@ export function registerUserAdminRoutes(
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
if (targetUser.isOidc && !targetUser.passwordHash) {
|
||||
if (targetUser.isExternal && !targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error: "This user authenticates through an external provider",
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export function registerUserExternalAccountRoutes(
|
||||
* /users/link-external-to-password:
|
||||
* post:
|
||||
* summary: Merge an external account into a password account
|
||||
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). A 2.8 SSO account whose only sign-in is the matching legacy OIDC link is accepted as the target too, to merge a duplicate made by 2.9.0. The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
|
||||
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). A 2.8 SSO account whose only sign-in is the matching legacy OIDC link is accepted as the target too, to merge a duplicate made by 2.9.0.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
@@ -86,7 +86,7 @@ export function registerUserExternalAccountRoutes(
|
||||
*/
|
||||
const link = async (req: Request, res: Response) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const externalUserId = req.body?.externalUserId ?? req.body?.oidcUserId;
|
||||
const externalUserId = req.body?.externalUserId;
|
||||
const targetUsername = req.body?.targetUsername;
|
||||
|
||||
if (
|
||||
@@ -143,11 +143,6 @@ export function registerUserExternalAccountRoutes(
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: true,
|
||||
oidcIdentifier:
|
||||
externalUser.oidcIdentifier ?? targetUser.oidcIdentifier,
|
||||
});
|
||||
await identities.moveIdentities(externalUserId, targetUser.id);
|
||||
|
||||
await authManager.revokeAllUserSessions(externalUserId);
|
||||
@@ -201,7 +196,7 @@ export function registerUserExternalAccountRoutes(
|
||||
* /users/unlink-external-from-password:
|
||||
* post:
|
||||
* summary: Remove external sign-in from a password account
|
||||
* description: Removes every external sign-in identity from an account that also has a password (admin only). The 2.8 path /users/unlink-oidc-from-password is accepted too.
|
||||
* description: Removes every external sign-in identity from an account that also has a password (admin only).
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
@@ -261,10 +256,6 @@ export function registerUserExternalAccountRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
await userRepository.update(targetUser.id, {
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
});
|
||||
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
|
||||
targetUser.id,
|
||||
);
|
||||
@@ -307,7 +298,4 @@ export function registerUserExternalAccountRoutes(
|
||||
|
||||
router.post("/link-external-to-password", authenticateJWT, link);
|
||||
router.post("/unlink-external-from-password", authenticateJWT, unlink);
|
||||
// 2.8 paths, kept until 26.10.0.
|
||||
router.post("/link-oidc-to-password", authenticateJWT, link);
|
||||
router.post("/unlink-oidc-from-password", authenticateJWT, unlink);
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export function registerUserPasswordResetRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (user.isOidc) {
|
||||
if (user.isExternal) {
|
||||
return res.json({
|
||||
message:
|
||||
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
isNativeAppRequest,
|
||||
syncSharedCredentialsForUserRoles,
|
||||
} from "../../auth/session-issuer.js";
|
||||
import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
@@ -184,15 +185,6 @@ router.post("/create", async (req, res) => {
|
||||
id,
|
||||
username,
|
||||
passwordHash: password_hash,
|
||||
isOidc: false,
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuerUrl: "",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -361,7 +353,7 @@ router.post("/proxy-login", async (req, res) => {
|
||||
return res.status(403).json({ error: "Proxy user must already exist" });
|
||||
}
|
||||
if (
|
||||
userRecord.isOidc ||
|
||||
userRecord.isExternal ||
|
||||
(await createCurrentUserAuthRepository().hasSecondFactor(userRecord.id))
|
||||
) {
|
||||
return res.status(409).json({
|
||||
@@ -600,8 +592,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
|
||||
const isDualAuth =
|
||||
hasPassword && isExternalAccount(user) && !!user.oidcIdentifier;
|
||||
const isDualAuth = hasPassword && isExternalAccount(user);
|
||||
|
||||
const showDonationModal = shouldShowDonationModal(
|
||||
user.registeredAt,
|
||||
@@ -613,8 +604,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
username: user.username,
|
||||
is_admin: !!user.isAdmin,
|
||||
is_external: isExternalAccount(user),
|
||||
// 2.8 name, kept until 26.10.0.
|
||||
is_oidc: !!user.isOidc,
|
||||
is_dual_auth: isDualAuth,
|
||||
// Any second factor; the name is what 2.8 clients read.
|
||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||
@@ -944,7 +933,7 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
|
||||
* /users/external-auto-provision:
|
||||
* get:
|
||||
* summary: Get the external account auto-create setting
|
||||
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin). The 2.8 path /users/oidc-auto-provision is accepted too.
|
||||
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin).
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
@@ -955,7 +944,7 @@ const getExternalAutoProvision: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"oidc_auto_provision",
|
||||
EXTERNAL_AUTO_PROVISION_KEY,
|
||||
false,
|
||||
),
|
||||
});
|
||||
@@ -972,7 +961,7 @@ const getExternalAutoProvision: RequestHandler = async (_req, res) => {
|
||||
* /users/external-auto-provision:
|
||||
* patch:
|
||||
* summary: Set the external account auto-create setting
|
||||
* description: Enables or disables creating an account on first external sign-in. The 2.8 path /users/oidc-auto-provision is accepted too.
|
||||
* description: Enables or disables creating an account on first external sign-in.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
@@ -1006,7 +995,7 @@ const setExternalAutoProvision: RequestHandler = async (req, res) => {
|
||||
return res.status(400).json({ error: "Invalid value for enabled" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"oidc_auto_provision",
|
||||
EXTERNAL_AUTO_PROVISION_KEY,
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
res.json({ enabled });
|
||||
@@ -1022,9 +1011,6 @@ router.patch(
|
||||
authenticateJWT,
|
||||
setExternalAutoProvision,
|
||||
);
|
||||
// 2.8 paths, kept until 26.10.0.
|
||||
router.get("/oidc-auto-provision", getExternalAutoProvision);
|
||||
router.patch("/oidc-auto-provision", authenticateJWT, setExternalAutoProvision);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
@@ -1317,7 +1303,7 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
if (userRecord.isOidc) {
|
||||
if (userRecord.isExternal) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"Cannot delete external authentication accounts through this endpoint",
|
||||
|
||||
@@ -34,14 +34,13 @@ export function pickResolvedPassword(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const USERNAME_PLACEHOLDER =
|
||||
/\$(?:external\.username|oidc\.preferred_username)/g;
|
||||
const USERNAME_PLACEHOLDER = /\$external\.username/g;
|
||||
|
||||
/**
|
||||
* Expands `$external.username` (or its 2.8 spelling `$oidc.preferred_username`)
|
||||
* in an SSH username to the name the connecting user signed in with through
|
||||
* SSO or LDAP. Returns the username unchanged if it has no placeholder or the
|
||||
* user has no external sign-in.
|
||||
* Expands `$external.username` in an SSH username to the name the connecting
|
||||
* user signed in with through SSO or LDAP: the subject of their first
|
||||
* external identity. Returns the username unchanged if it has no placeholder
|
||||
* or the user has no external sign-in.
|
||||
*/
|
||||
export async function expandExternalUsername(
|
||||
username: string | undefined,
|
||||
@@ -52,31 +51,14 @@ export async function expandExternalUsername(
|
||||
}
|
||||
|
||||
try {
|
||||
const { createCurrentUserRepository } =
|
||||
const { createCurrentUserAuthRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
let externalName = user?.oidcIdentifier;
|
||||
const identities =
|
||||
await createCurrentUserAuthRepository().listIdentitiesForUser(userId);
|
||||
const externalName = [...identities].sort((a, b) => a.id - b.id)[0]
|
||||
?.subject;
|
||||
if (!externalName) return username;
|
||||
|
||||
const match = /^ldap:(\d+):(.+)$/.exec(externalName);
|
||||
if (match) {
|
||||
// Only strip the prefix for a real LDAP identity, to prevent spoofing
|
||||
// through an SSO subject that happens to look like one.
|
||||
const { createCurrentUserAuthRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const identities =
|
||||
await createCurrentUserAuthRepository().listIdentitiesForUser(userId);
|
||||
if (
|
||||
identities.some(
|
||||
(identity) =>
|
||||
identity.providerId === `ldap:${match[1]}` &&
|
||||
identity.subject === match[2],
|
||||
)
|
||||
) {
|
||||
externalName = match[2];
|
||||
}
|
||||
}
|
||||
|
||||
return username.replace(USERNAME_PLACEHOLDER, () => externalName);
|
||||
} catch {
|
||||
return username;
|
||||
|
||||
@@ -258,6 +258,35 @@ export async function resolveForEditor(input: {
|
||||
return resolveAll(catalog, levels, input.hostId ?? null);
|
||||
}
|
||||
|
||||
/** Each folder's credential, from its auth default, keyed by folder id. */
|
||||
export async function folderCredentialIds(
|
||||
userId: string,
|
||||
): Promise<Map<number, number>> {
|
||||
const result = new Map<number, number>();
|
||||
const rows = await createCurrentHostDefaultsRepository().listOwnedBy(userId);
|
||||
for (const row of rows) {
|
||||
if (
|
||||
row.level !== "folder" ||
|
||||
row.folderId == null ||
|
||||
row.namespace !== CORE_NAMESPACE ||
|
||||
row.key !== "auth"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const auth = decodeDefaultValue(row.value) as {
|
||||
authType?: string;
|
||||
credentialId?: unknown;
|
||||
} | null;
|
||||
if (
|
||||
auth?.authType === "credential" &&
|
||||
typeof auth.credentialId === "number"
|
||||
) {
|
||||
result.set(row.folderId, auth.credentialId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder's credential is its auth default: hosts in the folder that follow
|
||||
* their defaults sign in with it. Null clears it.
|
||||
|
||||
@@ -106,37 +106,11 @@ export async function resolveHostById(
|
||||
host.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
|
||||
try {
|
||||
host.terminalConfig = JSON.parse(host.terminalConfig as string);
|
||||
} catch {
|
||||
host.terminalConfig = undefined;
|
||||
}
|
||||
}
|
||||
if (
|
||||
host.terminalConfig &&
|
||||
typeof host.terminalConfig === "object" &&
|
||||
!Array.isArray(host.terminalConfig)
|
||||
) {
|
||||
// 2.8 editors kept the sudo password inside terminal_config. It is only
|
||||
// ever handed out as sudoPassword, and only to the owner.
|
||||
const { sudoPassword: legacySudo, ...rest } = host.terminalConfig as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (ownerEquivalent && !host.sudoPassword && legacySudo) {
|
||||
host.sudoPassword = legacySudo;
|
||||
}
|
||||
host.terminalConfig = rest;
|
||||
}
|
||||
// Stored as the text "true"/"false"; the string "false" is truthy and would
|
||||
// make the password provider skip password auth.
|
||||
host.forceKeyboardInteractive =
|
||||
String(host.forceKeyboardInteractive) === "true";
|
||||
// A row the boot copy has not reached yet still has them in terminal_config.
|
||||
host.sshOptions = parseSshOptions(
|
||||
host.sshOptions != null ? host.sshOptions : host.terminalConfig,
|
||||
);
|
||||
host.sshOptions = parseSshOptions(host.sshOptions);
|
||||
if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) {
|
||||
try {
|
||||
host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string);
|
||||
@@ -157,25 +131,8 @@ export async function resolveHostById(
|
||||
sharedAuthResolution = await resolveRecipientSshAuth(host, hostId, userId);
|
||||
if (!sharedAuthResolution) return null;
|
||||
} else {
|
||||
let effectiveCredentialId = host.credentialId as number | null | undefined;
|
||||
if (
|
||||
!effectiveCredentialId &&
|
||||
host.authType === "credential" &&
|
||||
host.folder
|
||||
) {
|
||||
try {
|
||||
effectiveCredentialId = await repository.findFolderCredentialId(
|
||||
ownerId,
|
||||
host.folder as string,
|
||||
);
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to resolve folder credential for host", {
|
||||
operation: "host_resolver_folder_credential",
|
||||
hostId,
|
||||
error: getErrorMessage(e, "Unknown"),
|
||||
});
|
||||
}
|
||||
}
|
||||
const effectiveCredentialId = host.credentialId as
|
||||
number | null | undefined;
|
||||
|
||||
if (effectiveCredentialId) {
|
||||
try {
|
||||
|
||||
@@ -50,8 +50,7 @@ function optionalString(value: unknown): string | null | undefined {
|
||||
|
||||
/**
|
||||
* The SSH options in a value (a JSON string or an object), typed and with
|
||||
* anything else dropped. A terminal_config value works too, which is how the
|
||||
* 2.8 keys are read.
|
||||
* anything else dropped. A 2.8 client's terminalConfig works too.
|
||||
*/
|
||||
export function parseSshOptions(value: unknown): HostSshOptions {
|
||||
const source = asObject(value);
|
||||
|
||||
@@ -601,12 +601,13 @@ export function toCoreIdentity(
|
||||
returnTo: identity.returnTo,
|
||||
rememberMe: identity.rememberMe,
|
||||
legacyIdentifier: identity.legacy?.identifier,
|
||||
ssoProviderId:
|
||||
identity.logoutClaims?.providerId ??
|
||||
identity.legacy?.providerRowId ??
|
||||
null,
|
||||
oidcSub: identity.logoutClaims?.sub ?? null,
|
||||
oidcSid: identity.logoutClaims?.sid ?? null,
|
||||
externalSession: identity.logoutClaims
|
||||
? {
|
||||
providerId: identity.logoutClaims.providerId ?? null,
|
||||
sub: identity.logoutClaims.sub ?? null,
|
||||
sid: identity.logoutClaims.sid ?? null,
|
||||
}
|
||||
: null,
|
||||
roleSync: identity.roles
|
||||
? {
|
||||
desired: [...identity.roles.desired],
|
||||
@@ -864,7 +865,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
const { AuthManager } = await import("../utils/auth-manager.js");
|
||||
const revoked =
|
||||
await AuthManager.getInstance().revokeSessionsByExternalSession({
|
||||
ssoProviderId: match.providerId ?? null,
|
||||
providerId: match.providerId ?? null,
|
||||
sub: match.sub ?? null,
|
||||
sid: match.sid ?? null,
|
||||
});
|
||||
|
||||
+1
-10
@@ -58,15 +58,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
id,
|
||||
username: "local",
|
||||
passwordHash: "",
|
||||
isOidc: false,
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuerUrl: "",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -191,7 +182,7 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
createCurrentUserAuthRepository().listUserIdsWithSecondFactors(),
|
||||
]);
|
||||
const conflictingUser = users.some(
|
||||
(user) => user.isOidc || secondFactorUsers.has(user.id),
|
||||
(user) => user.isExternal || secondFactorUsers.has(user.id),
|
||||
);
|
||||
if (process.env.OIDC_CLIENT_ID || conflictingUser) {
|
||||
throw new Error(
|
||||
|
||||
@@ -67,11 +67,10 @@ const HOST_LOCAL_FIELDS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Columns kept until 26.10.0 that nothing reads any more: their values moved
|
||||
* into plugin host settings, which sync with the host's pluginSettings. They
|
||||
* stay off the wire so a stale local id never reaches the other side.
|
||||
* Columns 26.10.0 dropped. A 2.9 desktop still sends them; they never reach
|
||||
* the table.
|
||||
*/
|
||||
const HOST_RETIRED_FIELDS = ["quickActions"];
|
||||
const HOST_RETIRED_FIELDS = ["quickActions", "terminalConfig"];
|
||||
|
||||
let registered = false;
|
||||
|
||||
|
||||
@@ -5,9 +5,6 @@ export interface FakeUser {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
oidcIdentifier?: string | null;
|
||||
ssoProviderId?: number | null;
|
||||
}
|
||||
|
||||
/** In-memory stand-ins for the repositories the login pipeline touches. */
|
||||
@@ -37,18 +34,28 @@ export function createAuthState() {
|
||||
|
||||
export type AuthState = ReturnType<typeof createAuthState>;
|
||||
|
||||
/** The user as UserRepository returns it, with isExternal filled in. */
|
||||
function withIdentities(state: AuthState, user: FakeUser | undefined) {
|
||||
if (!user) return null;
|
||||
return {
|
||||
...user,
|
||||
isExternal: state.identities.some((row) => row.userId === user.id),
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeFactory(state: AuthState) {
|
||||
return {
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
findById: async (id: string) =>
|
||||
withIdentities(state, state.users.get(id)),
|
||||
findByUsername: async (username: string) =>
|
||||
[...state.users.values()].find((u) => u.username === username) ?? null,
|
||||
findByExternalIdentifier: async (identifier: string) =>
|
||||
[...state.users.values()].find(
|
||||
(u) => u.oidcIdentifier === identifier,
|
||||
) ?? null,
|
||||
withIdentities(
|
||||
state,
|
||||
[...state.users.values()].find((u) => u.username === username),
|
||||
),
|
||||
countAll: async () => state.users.size,
|
||||
listAll: async () => [...state.users.values()],
|
||||
listAll: async () =>
|
||||
[...state.users.values()].map((u) => withIdentities(state, u)),
|
||||
createFirstSsoUser: async (user: FakeUser) => {
|
||||
const isFirstUser = state.users.size === 0;
|
||||
const created = {
|
||||
|
||||
@@ -119,7 +119,6 @@ function addUser(
|
||||
username: "alice",
|
||||
passwordHash: bcrypt.hashSync(PASSWORD, 4),
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
...overrides,
|
||||
};
|
||||
h.state.users.set(user.id, user);
|
||||
@@ -461,7 +460,7 @@ describe("external identities", () => {
|
||||
email: "a@example.com",
|
||||
name: "Alice",
|
||||
legacyIdentifier: "sub-1",
|
||||
ssoProviderId: 3,
|
||||
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
|
||||
};
|
||||
|
||||
// The sso plugin's method, which marks itself external.
|
||||
@@ -487,7 +486,6 @@ describe("external identities", () => {
|
||||
expect(user).toMatchObject({
|
||||
username: "Alice",
|
||||
isAdmin: true,
|
||||
isOidc: true,
|
||||
});
|
||||
expect(h.state.identities).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -511,7 +509,7 @@ describe("external identities", () => {
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "registration_disabled" });
|
||||
|
||||
h.state.settings.set("oidc_auto_provision", "true");
|
||||
h.state.settings.set("external_auto_provision", "true");
|
||||
await expect(
|
||||
runLogin(
|
||||
fakeRequest() as never,
|
||||
@@ -521,45 +519,11 @@ 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,
|
||||
@@ -568,7 +532,7 @@ describe("external identities", () => {
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
});
|
||||
h.state.settings.set("oidc_auto_provision", "true");
|
||||
h.state.settings.set("external_auto_provision", "true");
|
||||
const result = await runLogin(fakeRequest() as never, external, {
|
||||
methodId: "oidc",
|
||||
rememberMe: false,
|
||||
@@ -584,10 +548,7 @@ describe("external identities", () => {
|
||||
addUser({
|
||||
id: "old",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-1",
|
||||
ssoProviderId: null,
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
@@ -605,7 +566,7 @@ describe("external identities", () => {
|
||||
});
|
||||
|
||||
it("re-checks the allowed list for existing users", async () => {
|
||||
addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" });
|
||||
addUser({ id: "u9", username: "Alice", passwordHash: "" });
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "u9",
|
||||
@@ -622,42 +583,10 @@ describe("external identities", () => {
|
||||
).rejects.toMatchObject({ code: "user_not_allowed" });
|
||||
});
|
||||
|
||||
it("finds an account from before the identity table by its old identifier", async () => {
|
||||
addUser({
|
||||
id: "old",
|
||||
username: "ldap-bob",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
});
|
||||
const result = await runLogin(
|
||||
fakeRequest() as never,
|
||||
{
|
||||
kind: "external",
|
||||
provider: "4",
|
||||
subject: "bob",
|
||||
name: "Bob",
|
||||
legacyIdentifier: "ldap:4:bob",
|
||||
ssoProviderId: 4,
|
||||
},
|
||||
{ methodId: "ldap", rememberMe: false },
|
||||
);
|
||||
expect(result.kind).toBe("session");
|
||||
expect(h.state.users.size).toBe(1);
|
||||
expect(h.state.identities).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: "old",
|
||||
providerId: "4",
|
||||
subject: "bob",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps admin in step with the provider's admin group", async () => {
|
||||
addUser({
|
||||
id: "u9",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
isAdmin: true,
|
||||
});
|
||||
@@ -680,7 +609,6 @@ describe("external identities", () => {
|
||||
addUser({
|
||||
id: "u9",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
});
|
||||
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
|
||||
@@ -703,7 +631,6 @@ describe("external identities", () => {
|
||||
addUser({
|
||||
id: "u9",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
});
|
||||
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
|
||||
@@ -729,9 +656,7 @@ describe("redirect logins", () => {
|
||||
subject: "sub-1",
|
||||
name: "Alice",
|
||||
returnTo: "https://termix.example",
|
||||
ssoProviderId: 3,
|
||||
oidcSub: "sub-1",
|
||||
oidcSid: "sid-1",
|
||||
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
|
||||
};
|
||||
|
||||
it("sets the cookie and redirects with success, carrying the SSO claims", async () => {
|
||||
@@ -748,9 +673,7 @@ describe("redirect logins", () => {
|
||||
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
oidcSub: "sub-1",
|
||||
oidcSid: "sid-1",
|
||||
ssoProviderId: 3,
|
||||
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -774,7 +697,6 @@ describe("redirect logins", () => {
|
||||
addUser({
|
||||
id: "u9",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
});
|
||||
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
|
||||
@@ -812,7 +734,9 @@ describe("redirect logins", () => {
|
||||
expect(verify.statusCode).toBe(200);
|
||||
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
|
||||
"u9",
|
||||
expect.objectContaining({ oidcSid: "sid-1", ssoProviderId: 3 }),
|
||||
expect.objectContaining({
|
||||
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -113,8 +113,8 @@ describe("generated schemas", () => {
|
||||
expect(auditFks).toHaveLength(1);
|
||||
expect(auditFks[0].onDelete).toBe("set null");
|
||||
|
||||
const folderFks = read(schema.sshFolders).foreignKeys;
|
||||
expect(folderFks.map((fk) => fk.onDelete).sort()).toEqual([
|
||||
const hostFks = read(schema.hosts).foreignKeys;
|
||||
expect([...new Set(hostFks.map((fk) => fk.onDelete))].sort()).toEqual([
|
||||
"cascade",
|
||||
"set null",
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* A database 2.9 left behind still has the 2.8 columns it kept for a
|
||||
* downgrade. Opening it in 26.10.0 drops them, keeps what still matters
|
||||
* (a sudo password kept in terminal_config, an SSO sign-in, the hosts and
|
||||
* folders themselves) and removes the settings rows the plugins copied.
|
||||
*/
|
||||
describe("dropping the columns 2.9.0 kept", () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-retire-"));
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_FILE_ENCRYPTION;
|
||||
delete process.env.ALLOW_EMPTY_DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function write29Database(): void {
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(`
|
||||
CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
tier TEXT NOT NULL DEFAULT 'available',
|
||||
source TEXT NOT NULL DEFAULT 'community',
|
||||
registry_id TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'disabled',
|
||||
last_error TEXT,
|
||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
auto_update INTEGER NOT NULL DEFAULT 0,
|
||||
manifest_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
oidc_identifier TEXT,
|
||||
client_secret TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
|
||||
CREATE TABLE ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL DEFAULT 'password',
|
||||
sudo_password TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
tunnel_connections TEXT,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
stats_config TEXT,
|
||||
rdp_user TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
theme TEXT,
|
||||
terminal_defaults TEXT,
|
||||
rdp_defaults TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash, is_oidc, oidc_identifier)
|
||||
VALUES ('u1', 'alice', 'hash', 1, 'sub-1');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
|
||||
VALUES (5, 'u1', 'cred', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username,
|
||||
terminal_config, tunnel_connections, enable_docker, rdp_user)
|
||||
VALUES
|
||||
(1, 'u1', 'web', '10.0.0.1', 22, '$oidc.preferred_username',
|
||||
'{"sudoPassword":"s3cret","theme":"nord"}', '[]', 1, 'admin'),
|
||||
(2, 'u1', 'db', '10.0.0.2', 22, 'root', NULL, NULL, 0, NULL);
|
||||
INSERT INTO ssh_folders (id, user_id, name, color, credential_id)
|
||||
VALUES (3, 'u1', 'prod', '#fff', 5);
|
||||
INSERT INTO user_preferences (user_id, theme, terminal_defaults, rdp_defaults)
|
||||
VALUES ('u1', 'dark', '{}', '{}');
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('guac_url', 'guacd:4822'),
|
||||
('analytics_enabled', 'true'),
|
||||
('allow_registration', 'false');
|
||||
`);
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
seed.close();
|
||||
}
|
||||
|
||||
const columns = (sqlite: Database.Database, table: string) =>
|
||||
(sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
|
||||
name: string;
|
||||
}>).map((col) => col.name);
|
||||
|
||||
it("drops the columns and rows and keeps the data that still matters", async () => {
|
||||
write29Database();
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
const sqlite = db.getSqlite();
|
||||
|
||||
expect(columns(sqlite, "users")).not.toContain("oidc_identifier");
|
||||
expect(columns(sqlite, "users")).not.toContain("is_oidc");
|
||||
expect(columns(sqlite, "ssh_data")).not.toEqual(
|
||||
expect.arrayContaining(["terminal_config"]),
|
||||
);
|
||||
for (const gone of [
|
||||
"terminal_config",
|
||||
"quick_actions",
|
||||
"tunnel_connections",
|
||||
"enable_docker",
|
||||
"stats_config",
|
||||
]) {
|
||||
expect(columns(sqlite, "ssh_data")).not.toContain(gone);
|
||||
}
|
||||
// A login moved only once its owner signs in stays until then.
|
||||
expect(columns(sqlite, "ssh_data")).toContain("rdp_user");
|
||||
expect(columns(sqlite, "ssh_folders")).not.toContain("credential_id");
|
||||
expect(columns(sqlite, "user_preferences")).not.toContain("rdp_defaults");
|
||||
expect(columns(sqlite, "user_preferences")).not.toContain(
|
||||
"terminal_defaults",
|
||||
);
|
||||
|
||||
const hosts = sqlite
|
||||
.prepare("SELECT id, username, sudo_password FROM ssh_data ORDER BY id")
|
||||
.all();
|
||||
expect(hosts).toEqual([
|
||||
{ id: 1, username: "$external.username", sudo_password: "s3cret" },
|
||||
{ id: 2, username: "root", sudo_password: null },
|
||||
]);
|
||||
expect(
|
||||
sqlite.prepare("SELECT name, color FROM ssh_folders").all(),
|
||||
).toEqual([{ name: "prod", color: "#fff" }]);
|
||||
expect(
|
||||
sqlite.prepare("SELECT theme FROM user_preferences").get(),
|
||||
).toEqual({ theme: "dark" });
|
||||
|
||||
const keys = (
|
||||
sqlite.prepare("SELECT key FROM settings").all() as Array<{
|
||||
key: string;
|
||||
}>
|
||||
).map((row) => row.key);
|
||||
expect(keys).toContain("allow_registration");
|
||||
expect(keys).not.toContain("guac_url");
|
||||
expect(keys).not.toContain("analytics_enabled");
|
||||
});
|
||||
|
||||
it("refuses a 2.8 database", async () => {
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL);
|
||||
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT NOT NULL);
|
||||
`);
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
seed.close();
|
||||
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await expect(db.initializeDatabase()).rejects.toThrow(/2\.8 or older/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
legacySudoMoves,
|
||||
legacySudoPassword,
|
||||
} from "../../../database/db/retired-columns.js";
|
||||
|
||||
describe("legacySudoPassword", () => {
|
||||
it("reads the sudo password out of a terminal_config value", () => {
|
||||
expect(legacySudoPassword('{"sudoPassword":"s3cret"}')).toBe("s3cret");
|
||||
expect(legacySudoPassword({ sudoPassword: "s3cret" })).toBe("s3cret");
|
||||
expect(legacySudoPassword(JSON.stringify('{"sudoPassword":"x"}'))).toBe(
|
||||
"x",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds nothing in anything else", () => {
|
||||
expect(legacySudoPassword(null)).toBeNull();
|
||||
expect(legacySudoPassword("{")).toBeNull();
|
||||
expect(legacySudoPassword('{"sudoPassword":""}')).toBeNull();
|
||||
expect(legacySudoPassword("[1]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacySudoMoves", () => {
|
||||
it("moves only hosts with no sudo password of their own", () => {
|
||||
expect(
|
||||
legacySudoMoves([
|
||||
{ id: 1, terminal_config: '{"sudoPassword":"a"}', sudo_password: null },
|
||||
{ id: 2, terminal_config: '{"sudoPassword":"b"}', sudo_password: "own" },
|
||||
{ id: 3, terminal_config: '{"theme":"nord"}', sudo_password: null },
|
||||
]),
|
||||
).toEqual([{ id: 1, sudoPassword: "a" }]);
|
||||
});
|
||||
});
|
||||
@@ -33,10 +33,28 @@ describe("share_ssh_auth backfill", () => {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A 2.6.0 database: hosts and shares exist, the column does not. */
|
||||
/**
|
||||
* Hosts and shares exist, the column does not. The plugins table is there
|
||||
* because 26.10.0 only opens a database 2.9 has already upgraded.
|
||||
*/
|
||||
function writePreUpgradeDatabase(): void {
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(`
|
||||
CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
tier TEXT NOT NULL DEFAULT 'available',
|
||||
source TEXT NOT NULL DEFAULT 'community',
|
||||
registry_id TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'disabled',
|
||||
last_error TEXT,
|
||||
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
auto_update INTEGER NOT NULL DEFAULT 0,
|
||||
manifest_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertNotPre29Database,
|
||||
PRE_29_DATABASE_ERROR,
|
||||
} from "../../../database/db/upgrade-guard.js";
|
||||
|
||||
const tables = (...names: string[]) => (name: string) => names.includes(name);
|
||||
|
||||
describe("assertNotPre29Database", () => {
|
||||
it("refuses a 2.8 database, which has hosts but no plugins table", async () => {
|
||||
await expect(assertNotPre29Database(tables("ssh_data"))).rejects.toThrow(
|
||||
PRE_29_DATABASE_ERROR,
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a database 2.9 has upgraded", async () => {
|
||||
await expect(
|
||||
assertNotPre29Database(tables("ssh_data", "plugins")),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("opens a new database", async () => {
|
||||
await expect(assertNotPre29Database(tables())).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,6 @@ describe("HostFolderRepository", () => {
|
||||
"prod",
|
||||
"#abcdef",
|
||||
"folder",
|
||||
undefined,
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
@@ -102,7 +101,6 @@ describe("HostFolderRepository", () => {
|
||||
"new",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
@@ -112,28 +110,6 @@ describe("HostFolderRepository", () => {
|
||||
expect(writes).toBe(2);
|
||||
});
|
||||
|
||||
it("assigns a credential to a folder and resolves it for nested paths", async () => {
|
||||
const { repository } = await createRepository();
|
||||
|
||||
await expect(
|
||||
repository.upsertMetadata(
|
||||
"user-1",
|
||||
"prod",
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
created: false,
|
||||
folder: { credentialId: 1 },
|
||||
});
|
||||
|
||||
const folders = await repository.listFolders("user-1");
|
||||
const prodFolder = folders.find((f) => f.name === "prod");
|
||||
expect(prodFolder?.credentialId).toBe(1);
|
||||
});
|
||||
|
||||
it("lists and deletes hosts and folder records in a folder tree", async () => {
|
||||
let writes = 0;
|
||||
const { repository } = await createRepository(() => {
|
||||
|
||||
@@ -44,11 +44,6 @@ describe("HostResolutionRepository", () => {
|
||||
(1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'),
|
||||
(2, 'user-1', 'db', '10.0.0.2', 22, 'admin', 'none', NULL, NULL),
|
||||
(3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'none', NULL, '[{"autoStart":false}]');
|
||||
INSERT INTO ssh_folders (user_id, name, credential_id)
|
||||
VALUES
|
||||
('user-1', 'switches', 7),
|
||||
('user-1', 'switches / floor1', NULL),
|
||||
('user-1', 'no-cred', NULL);
|
||||
INSERT INTO host_access (
|
||||
host_id, user_id, granted_by, permission_level
|
||||
)
|
||||
@@ -330,26 +325,6 @@ describe("HostResolutionRepository", () => {
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("resolves a folder's assigned credential, walking up to parent folders", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
await expect(
|
||||
repository.findFolderCredentialId("user-1", "switches"),
|
||||
).resolves.toBe(7);
|
||||
await expect(
|
||||
repository.findFolderCredentialId("user-1", "switches / floor1"),
|
||||
).resolves.toBe(7);
|
||||
await expect(
|
||||
repository.findFolderCredentialId("user-1", "no-cred"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findFolderCredentialId("user-1", "unknown"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findFolderCredentialId("user-1", ""),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
describe("listCredentialsByIdsForUser", () => {
|
||||
it("returns the owner's credentials keyed by id", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
@@ -19,11 +19,11 @@ describe("RbacAccessRepository", () => {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
|
||||
INSERT INTO users (id, username, password_hash, is_admin)
|
||||
VALUES
|
||||
('admin', 'admin', 'hash', 1, 0),
|
||||
('user-1', 'alice', 'hash', 0, 0),
|
||||
('owner-1', 'owner', 'hash', 0, 0);
|
||||
('admin', 'admin', 'hash', 1),
|
||||
('user-1', 'alice', 'hash', 0),
|
||||
('owner-1', 'owner', 'hash', 0);
|
||||
INSERT INTO roles (id, name, display_name, is_system)
|
||||
VALUES (7, 'ops', 'Operations', 0);
|
||||
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
|
||||
|
||||
@@ -18,8 +18,8 @@ describe("RoleRepository", () => {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
|
||||
VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0);
|
||||
INSERT INTO users (id, username, password_hash, is_admin)
|
||||
VALUES ('admin', 'admin', 'hash', 1), ('user-1', 'user', 'hash', 0);
|
||||
`);
|
||||
|
||||
return new RoleRepository(context, onWrite);
|
||||
|
||||
@@ -34,19 +34,19 @@ describe("UserPreferenceRepository", () => {
|
||||
reopenTabsOnLogin: true,
|
||||
theme: "dark",
|
||||
storageMode: "local",
|
||||
commandAutocomplete: true,
|
||||
commandPaletteEnabled: true,
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-1",
|
||||
reopenTabsOnLogin: true,
|
||||
theme: "dark",
|
||||
storageMode: "local",
|
||||
commandAutocomplete: true,
|
||||
commandPaletteEnabled: true,
|
||||
});
|
||||
|
||||
const updated = await repo.upsert("user-1", {
|
||||
theme: "light",
|
||||
commandAutocomplete: false,
|
||||
commandPaletteEnabled: false,
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
@@ -54,7 +54,7 @@ describe("UserPreferenceRepository", () => {
|
||||
reopenTabsOnLogin: true,
|
||||
theme: "light",
|
||||
storageMode: "local",
|
||||
commandAutocomplete: false,
|
||||
commandPaletteEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ describe("UserRepository and SessionRepository", () => {
|
||||
username: "admin",
|
||||
passwordHash: "hash",
|
||||
isAdmin: true,
|
||||
isOidc: false,
|
||||
});
|
||||
|
||||
expect(await repo.users.countAdmins()).toBe(1);
|
||||
@@ -69,13 +68,15 @@ describe("UserRepository and SessionRepository", () => {
|
||||
).toEqual(["user-1"]);
|
||||
expect(await repo.users.listByIds([])).toEqual([]);
|
||||
|
||||
const updated = await repo.users.update("user-1", {
|
||||
oidcIdentifier: "oidc:admin",
|
||||
});
|
||||
expect(updated?.oidcIdentifier).toBe("oidc:admin");
|
||||
expect((await repo.users.findByExternalIdentifier("oidc:admin"))?.id).toBe(
|
||||
"user-1",
|
||||
);
|
||||
const updated = await repo.users.update("user-1", { isAdmin: false });
|
||||
expect(updated).toMatchObject({ isAdmin: false, isExternal: false });
|
||||
|
||||
await adapter!.exec(`
|
||||
INSERT INTO user_external_identities (user_id, provider_id, subject)
|
||||
VALUES ('user-1', '3', 'sub-1');
|
||||
`);
|
||||
expect((await repo.users.findById("user-1"))?.isExternal).toBe(true);
|
||||
expect((await repo.users.listAll())[0].isExternal).toBe(true);
|
||||
|
||||
expect(await repo.users.delete("user-1")).toBe(true);
|
||||
expect(await repo.users.findById("user-1")).toBeNull();
|
||||
@@ -88,13 +89,11 @@ describe("UserRepository and SessionRepository", () => {
|
||||
id: "user-1",
|
||||
username: "first",
|
||||
passwordHash: "hash",
|
||||
isOidc: false,
|
||||
});
|
||||
const second = await repo.users.createFirstLocalUser({
|
||||
id: "user-2",
|
||||
username: "second",
|
||||
passwordHash: "hash",
|
||||
isOidc: false,
|
||||
});
|
||||
|
||||
expect(first.isFirstUser).toBe(true);
|
||||
@@ -112,27 +111,18 @@ describe("UserRepository and SessionRepository", () => {
|
||||
username: "first",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:provider:first",
|
||||
ssoProviderId: 1,
|
||||
});
|
||||
const providerAdmin = await repo.users.createFirstSsoUser({
|
||||
id: "user-2",
|
||||
username: "provider-admin",
|
||||
passwordHash: "",
|
||||
isAdmin: true,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:provider:admin",
|
||||
ssoProviderId: 1,
|
||||
});
|
||||
const regular = await repo.users.createFirstSsoUser({
|
||||
id: "user-3",
|
||||
username: "regular",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:provider:regular",
|
||||
ssoProviderId: 1,
|
||||
});
|
||||
|
||||
expect(first.isFirstUser).toBe(true);
|
||||
@@ -157,7 +147,6 @@ describe("UserRepository and SessionRepository", () => {
|
||||
username: "admin",
|
||||
passwordHash: "hash",
|
||||
isAdmin: true,
|
||||
isOidc: false,
|
||||
});
|
||||
await repo.users.update("user-1", { isAdmin: false });
|
||||
await repo.users.delete("user-1");
|
||||
@@ -172,7 +161,6 @@ describe("UserRepository and SessionRepository", () => {
|
||||
username: "user",
|
||||
passwordHash: "hash",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
});
|
||||
|
||||
await repo.sessions.create({
|
||||
@@ -208,7 +196,6 @@ describe("UserRepository and SessionRepository", () => {
|
||||
username: "user",
|
||||
passwordHash: "hash",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
});
|
||||
await repo.sessions.create({
|
||||
id: "session-1",
|
||||
@@ -250,7 +237,6 @@ describe("UserRepository and SessionRepository", () => {
|
||||
username: "user",
|
||||
passwordHash: "hash",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
});
|
||||
await repo.sessions.create({
|
||||
id: "session-1",
|
||||
@@ -281,7 +267,6 @@ describe("UserRepository and SessionRepository", () => {
|
||||
username: "user",
|
||||
passwordHash: "hash",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
});
|
||||
|
||||
for (const id of ["keep", "drop-1", "drop-2"]) {
|
||||
@@ -308,7 +293,6 @@ describe("UserRepository and SessionRepository", () => {
|
||||
username: "user",
|
||||
passwordHash: "hash",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
});
|
||||
|
||||
await repo.sessions.create({
|
||||
|
||||
@@ -96,9 +96,8 @@ it.each([false, true])(
|
||||
overwrite ? 19 : 71,
|
||||
expect.objectContaining({ pluginSettings: host.pluginSettings }),
|
||||
);
|
||||
expect(JSON.parse(saved.terminalConfig)).toEqual({
|
||||
macOptionIsMeta: false,
|
||||
});
|
||||
// The 2.8 terminalConfig reaches the plugin, never a host column.
|
||||
expect(saved).not.toHaveProperty("terminalConfig");
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("stripSensitiveFields", () => {
|
||||
expect(result.hasKey).toBe(false);
|
||||
});
|
||||
|
||||
it("detects sudo password stored only in nested terminalConfig", () => {
|
||||
it("strips a sudo password nested in terminalConfig", () => {
|
||||
const result = stripSensitiveFields({
|
||||
name: "web",
|
||||
terminalConfig: {
|
||||
@@ -292,7 +292,7 @@ describe("stripSensitiveFields", () => {
|
||||
sudoPassword: "nested-only-sudo",
|
||||
},
|
||||
});
|
||||
expect(result.hasSudoPassword).toBe(true);
|
||||
expect(result.hasSudoPassword).toBe(false);
|
||||
expect(
|
||||
(result.terminalConfig as Record<string, unknown>).sudoPassword,
|
||||
).toBeUndefined();
|
||||
@@ -483,25 +483,6 @@ describe("transformHostResponse terminal fields", () => {
|
||||
expect(host.terminalConfig).toEqual({ keepaliveInterval: 20 });
|
||||
});
|
||||
|
||||
it("reads the options out of terminal_config before the boot copy", () => {
|
||||
const host = row({
|
||||
sshOptions: null,
|
||||
terminalConfig: JSON.stringify({ agentForwarding: true }),
|
||||
});
|
||||
expect(host.sshOptions).toEqual({ agentForwarding: true });
|
||||
});
|
||||
|
||||
it("surfaces a 2.8 sudo password for the sanitizers to strip", () => {
|
||||
const host = row({
|
||||
sudoPassword: null,
|
||||
terminalConfig: JSON.stringify({ sudoPassword: "legacy" }),
|
||||
});
|
||||
const stripped = stripSensitiveFields(host);
|
||||
expect(stripped.hasSudoPassword).toBe(true);
|
||||
expect(stripped).not.toHaveProperty("sudoPassword");
|
||||
expect(JSON.stringify(stripped)).not.toContain("legacy");
|
||||
});
|
||||
|
||||
it("hides the owner's agent socket from a shared recipient", () => {
|
||||
const shared = sanitizeHostForRecipient(
|
||||
row({
|
||||
|
||||
@@ -10,17 +10,11 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mergeStoredTerminalFields, parseTerminalConfig } =
|
||||
const { mergeStoredSshOptions, parseTerminalConfig } =
|
||||
await import("../../../database/routes/host-terminal-fields.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stored = {
|
||||
terminalConfig: JSON.stringify({
|
||||
theme: "nord",
|
||||
sudoPassword: "legacy-sudo",
|
||||
agentSocketPath: "/owner/agent",
|
||||
startupSnippetId: 1,
|
||||
}),
|
||||
sshOptions: JSON.stringify({ agentSocketPath: "/owner/agent" }),
|
||||
};
|
||||
});
|
||||
@@ -31,10 +25,10 @@ async function merge(
|
||||
sshOptions?: string,
|
||||
) {
|
||||
const row: Record<string, unknown> = {
|
||||
terminalConfig: "replaced wholesale",
|
||||
terminalConfig: "from the payload",
|
||||
...(sshOptions !== undefined ? { sshOptions } : {}),
|
||||
};
|
||||
const error = await mergeStoredTerminalFields(row, hostData, 1, "o", isOwner);
|
||||
const error = await mergeStoredSshOptions(row, hostData, 1, "o", isOwner);
|
||||
return { error, row };
|
||||
}
|
||||
|
||||
@@ -47,43 +41,32 @@ describe("parseTerminalConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeStoredTerminalFields", () => {
|
||||
it("merges an editor save into the stored 2.8 keys instead of dropping them", async () => {
|
||||
describe("mergeStoredSshOptions", () => {
|
||||
it("never writes terminalConfig, which has no column", async () => {
|
||||
const { error, row } = await merge({
|
||||
terminalConfig: { startupSnippetId: 4 },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
expect(JSON.parse(row.terminalConfig as string)).toEqual({
|
||||
theme: "nord",
|
||||
sudoPassword: "legacy-sudo",
|
||||
agentSocketPath: "/owner/agent",
|
||||
startupSnippetId: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves terminal_config alone when the payload has none", async () => {
|
||||
const { row } = await merge({});
|
||||
expect(row).not.toHaveProperty("terminalConfig");
|
||||
});
|
||||
|
||||
it("drops the legacy sudo copy once the owner saves one on its own", async () => {
|
||||
const { row } = await merge({ sudoPassword: "" });
|
||||
expect(JSON.parse(row.terminalConfig as string)).not.toHaveProperty(
|
||||
"sudoPassword",
|
||||
it("leaves an owner's SSH options as sent", async () => {
|
||||
const { row } = await merge(
|
||||
{},
|
||||
true,
|
||||
JSON.stringify({ agentSocketPath: "/x" }),
|
||||
);
|
||||
expect(JSON.parse(row.sshOptions as string)).toEqual({
|
||||
agentSocketPath: "/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the owner's private keys through a shared editor's save", async () => {
|
||||
it("keeps the owner's private SSH options through a shared editor's save", async () => {
|
||||
const { row } = await merge(
|
||||
{ terminalConfig: { startupSnippetId: 2 } },
|
||||
{},
|
||||
false,
|
||||
JSON.stringify({ keepaliveInterval: 9 }),
|
||||
JSON.stringify({ keepaliveInterval: 9, agentSocketPath: "/editor" }),
|
||||
);
|
||||
expect(JSON.parse(row.terminalConfig as string)).toMatchObject({
|
||||
sudoPassword: "legacy-sudo",
|
||||
agentSocketPath: "/owner/agent",
|
||||
startupSnippetId: 2,
|
||||
});
|
||||
expect(JSON.parse(row.sshOptions as string)).toEqual({
|
||||
keepaliveInterval: 9,
|
||||
agentSocketPath: "/owner/agent",
|
||||
|
||||
@@ -13,10 +13,7 @@ interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash: string;
|
||||
oidcIdentifier?: string | null;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
@@ -30,14 +27,21 @@ const h = vi.hoisted(() => ({
|
||||
revoked: [] as string[],
|
||||
}));
|
||||
|
||||
function withIdentities(user: User | undefined) {
|
||||
if (!user) return null;
|
||||
return {
|
||||
...user,
|
||||
isExternal: h.identities.some((row) => row.userId === user.id),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => h.users.get(id) ?? null,
|
||||
findById: async (id: string) => withIdentities(h.users.get(id)),
|
||||
findByUsername: async (username: string) =>
|
||||
[...h.users.values()].find((user) => user.username === username) ?? null,
|
||||
update: async (id: string, changes: Partial<User>) => {
|
||||
Object.assign(h.users.get(id)!, changes);
|
||||
},
|
||||
withIdentities(
|
||||
[...h.users.values()].find((user) => user.username === username),
|
||||
),
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async (userId: string) =>
|
||||
@@ -84,7 +88,6 @@ beforeEach(async () => {
|
||||
id: "admin",
|
||||
username: "admin",
|
||||
isAdmin: true,
|
||||
isOidc: false,
|
||||
passwordHash: "x",
|
||||
},
|
||||
],
|
||||
@@ -94,9 +97,7 @@ beforeEach(async () => {
|
||||
id: "sso",
|
||||
username: "alice-sso",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "ldap:4:alice",
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -105,7 +106,6 @@ beforeEach(async () => {
|
||||
id: "local",
|
||||
username: "alice",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
},
|
||||
],
|
||||
@@ -149,16 +149,12 @@ function post(path: string, body: unknown, user = "admin") {
|
||||
}
|
||||
|
||||
describe("linking an external account to a password account", () => {
|
||||
it("moves the identities and the old identifier, then removes the external account", async () => {
|
||||
const response = await post("/link-oidc-to-password", {
|
||||
oidcUserId: "sso",
|
||||
it("moves the identities, then removes the external account", async () => {
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.users.get("local")).toMatchObject({
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:alice",
|
||||
});
|
||||
expect(h.identities).toEqual([
|
||||
{ userId: "local", providerId: "ldap:4", subject: "alice" },
|
||||
]);
|
||||
@@ -166,13 +162,13 @@ describe("linking an external account to a password account", () => {
|
||||
expect(h.deleted).toEqual(["sso"]);
|
||||
});
|
||||
|
||||
it("takes the new path and field name", async () => {
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
it("no longer takes the 2.8 path and field name", async () => {
|
||||
const response = await post("/link-oidc-to-password", {
|
||||
oidcUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.identities[0].userId).toBe("local");
|
||||
expect(response.status).toBe(404);
|
||||
expect(h.identities[0].userId).toBe("sso");
|
||||
});
|
||||
|
||||
it("refuses a target that already signs in externally", async () => {
|
||||
@@ -186,8 +182,8 @@ describe("linking an external account to a password account", () => {
|
||||
|
||||
it("is for admins only", async () => {
|
||||
const response = await post(
|
||||
"/link-oidc-to-password",
|
||||
{ oidcUserId: "sso", targetUsername: "alice" },
|
||||
"/link-external-to-password",
|
||||
{ externalUserId: "sso", targetUsername: "alice" },
|
||||
"local",
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
@@ -195,8 +191,8 @@ describe("linking an external account to a password account", () => {
|
||||
});
|
||||
|
||||
it("refuses a target that is not a password account", async () => {
|
||||
const response = await post("/link-oidc-to-password", {
|
||||
oidcUserId: "sso",
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "alice-sso",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
@@ -207,17 +203,13 @@ describe("linking an external account to a password account", () => {
|
||||
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" },
|
||||
@@ -231,7 +223,6 @@ describe("linking an external account to a password account", () => {
|
||||
|
||||
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")
|
||||
@@ -244,9 +235,7 @@ describe("linking an external account to a password account", () => {
|
||||
id: "other",
|
||||
username: "carol",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-x",
|
||||
});
|
||||
h.identities.push({
|
||||
userId: "other",
|
||||
@@ -264,22 +253,6 @@ describe("linking an external account to a password account", () => {
|
||||
|
||||
describe("unlinking", () => {
|
||||
it("drops the identities so the provider no longer signs in as the user", async () => {
|
||||
await post("/link-oidc-to-password", {
|
||||
oidcUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
const response = await post("/unlink-oidc-from-password", {
|
||||
userId: "local",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.users.get("local")).toMatchObject({
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
});
|
||||
expect(h.identities).toEqual([]);
|
||||
});
|
||||
|
||||
it("takes the new path", async () => {
|
||||
await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
@@ -291,8 +264,15 @@ describe("unlinking", () => {
|
||||
expect(h.identities).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to leave an account with no way in", async () => {
|
||||
it("no longer takes the 2.8 path", async () => {
|
||||
const response = await post("/unlink-oidc-from-password", {
|
||||
userId: "local",
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses to leave an account with no way in", async () => {
|
||||
const response = await post("/unlink-external-from-password", {
|
||||
userId: "sso",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
@@ -52,6 +52,14 @@ describe("pickResolvedPassword", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function identities(rows: Array<{ id: number; subject: string }>) {
|
||||
vi.doMock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async () => rows,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("expandExternalUsername", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@@ -62,29 +70,19 @@ describe("expandExternalUsername", () => {
|
||||
expect(await expandExternalUsername(undefined, "user-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expands the placeholder with the user's OIDC identifier", async () => {
|
||||
vi.doMock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({ oidcIdentifier: "jdoe" }),
|
||||
}),
|
||||
}));
|
||||
|
||||
it("expands the placeholder with the subject of the user's first identity", async () => {
|
||||
identities([
|
||||
{ id: 4, subject: "later" },
|
||||
{ id: 2, subject: "jdoe" },
|
||||
]);
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||
expect(await expand("$external.username", "user-1")).toBe("jdoe");
|
||||
expect(
|
||||
await expand("$external.username-$oidc.preferred_username", "user-1"),
|
||||
).toBe("jdoe-jdoe");
|
||||
expect(await expand("$external.username-x", "user-1")).toBe("jdoe-x");
|
||||
});
|
||||
|
||||
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
|
||||
vi.doMock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({ oidcIdentifier: null }),
|
||||
}),
|
||||
}));
|
||||
|
||||
it("no longer reads the 2.8 spelling", async () => {
|
||||
identities([{ id: 1, subject: "jdoe" }]);
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
@@ -92,81 +90,25 @@ describe("expandExternalUsername", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the placeholder as-is when the user has no external sign-in", async () => {
|
||||
identities([]);
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$external.username", "user-1")).toBe(
|
||||
"$external.username",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the username unchanged when the DB lookup throws", async () => {
|
||||
vi.doMock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => {
|
||||
createCurrentUserAuthRepository: () => {
|
||||
throw new Error("DB unavailable");
|
||||
},
|
||||
}));
|
||||
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"$oidc.preferred_username",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips the ldap:{providerId}: prefix for genuine LDAP users", async () => {
|
||||
vi.doMock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({
|
||||
oidcIdentifier: "ldap:1:jdoe",
|
||||
ssoProviderId: 1,
|
||||
}),
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async () => [
|
||||
{ providerId: "ldap:1", subject: "jdoe" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||
});
|
||||
|
||||
it("does not strip a spoofed ldap: identifier from a non-LDAP provider", async () => {
|
||||
vi.doMock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({
|
||||
oidcIdentifier: "ldap:1:admin",
|
||||
ssoProviderId: 1,
|
||||
}),
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async () => [
|
||||
{ providerId: "1", subject: "ldap:1:admin" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"ldap:1:admin",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not strip when the embedded provider id is not the user's provider", async () => {
|
||||
vi.doMock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({
|
||||
oidcIdentifier: "ldap:1:admin",
|
||||
ssoProviderId: 5,
|
||||
}),
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async () => [
|
||||
{ providerId: "ldap:5", subject: "admin" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"ldap:1:admin",
|
||||
expect(await expand("$external.username", "user-1")).toBe(
|
||||
"$external.username",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,20 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
const rows = vi.hoisted(() => ({
|
||||
owned: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listOwnedBy: async () => rows.owned,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [],
|
||||
}));
|
||||
|
||||
const { validateLevelChange } =
|
||||
const { folderCredentialIds, validateLevelChange } =
|
||||
await import("../../../hosts/defaults/service.js");
|
||||
const { buildCatalog } = await import("../../../hosts/defaults/catalog.js");
|
||||
|
||||
@@ -113,3 +121,39 @@ describe("validateLevelChange", () => {
|
||||
expect(change.errors["core.jumpHosts"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("folderCredentialIds", () => {
|
||||
it("reads each folder's credential from its auth default", async () => {
|
||||
rows.owned = [
|
||||
{
|
||||
level: "folder",
|
||||
folderId: 4,
|
||||
namespace: "core",
|
||||
key: "auth",
|
||||
value: JSON.stringify({ authType: "credential", credentialId: 7 }),
|
||||
},
|
||||
{
|
||||
level: "folder",
|
||||
folderId: 5,
|
||||
namespace: "core",
|
||||
key: "auth",
|
||||
value: JSON.stringify({ authType: "none" }),
|
||||
},
|
||||
{
|
||||
level: "user",
|
||||
folderId: null,
|
||||
namespace: "core",
|
||||
key: "auth",
|
||||
value: JSON.stringify({ authType: "credential", credentialId: 9 }),
|
||||
},
|
||||
{
|
||||
level: "folder",
|
||||
folderId: 6,
|
||||
namespace: "fx",
|
||||
key: "auth",
|
||||
value: JSON.stringify({ authType: "credential", credentialId: 2 }),
|
||||
},
|
||||
];
|
||||
expect([...(await folderCredentialIds("u1"))]).toEqual([[4, 7]]);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ const state = vi.hoisted(() => ({
|
||||
overrideCredentialId: null as number | null,
|
||||
credentials: new Map<string, Record<string, unknown>>(),
|
||||
auditCalls: [] as Record<string, unknown>[],
|
||||
folderCredentialId: null as number | null,
|
||||
sharedSecret: null as Record<string, unknown> | null,
|
||||
activeHostAccessId: null as number | null,
|
||||
snapshotForUserCalls: [] as Record<string, unknown>[],
|
||||
@@ -20,7 +19,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
findHostById: async () => (state.host ? { ...state.host } : null),
|
||||
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
||||
state.credentials.get(`${credentialId}:${userId}`) ?? null,
|
||||
findFolderCredentialId: async () => state.folderCredentialId,
|
||||
}),
|
||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||
findCredentialId: async () => state.overrideCredentialId,
|
||||
@@ -114,7 +112,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
sudoPassword: "owner-sudo",
|
||||
jumpHosts: null,
|
||||
tunnelConnections: null,
|
||||
terminalConfig: null,
|
||||
socks5ProxyChain: null,
|
||||
quickActions: null,
|
||||
overrideCredentialUsername: false,
|
||||
@@ -129,7 +126,6 @@ beforeEach(() => {
|
||||
state.overrideCredentialId = null;
|
||||
state.credentials.clear();
|
||||
state.auditCalls = [];
|
||||
state.folderCredentialId = null;
|
||||
state.sharedSecret = null;
|
||||
state.activeHostAccessId = null;
|
||||
state.snapshotForUserCalls = [];
|
||||
@@ -171,35 +167,6 @@ describe("resolveHostById", () => {
|
||||
expect(host.sudoPassword).toBe("owner-sudo");
|
||||
});
|
||||
|
||||
it("falls back to the host's folder-assigned credential when none is set on the host", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "credential",
|
||||
credentialId: null,
|
||||
folder: "switches",
|
||||
username: "",
|
||||
password: null,
|
||||
});
|
||||
state.folderCredentialId = 11;
|
||||
state.credentials.set("11:owner", {
|
||||
id: 11,
|
||||
username: "folder-user",
|
||||
authType: "password",
|
||||
password: "folder-pass",
|
||||
privateKey: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
});
|
||||
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.password).toBe("folder-pass");
|
||||
expect(host.username).toBe("folder-user");
|
||||
expect(host.authType).toBe("password");
|
||||
});
|
||||
|
||||
it("prefers the host's own credential over its folder's credential", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "credential",
|
||||
@@ -208,7 +175,6 @@ describe("resolveHostById", () => {
|
||||
username: "",
|
||||
password: null,
|
||||
});
|
||||
state.folderCredentialId = 11;
|
||||
state.credentials.set("9:owner", {
|
||||
id: 9,
|
||||
username: "host-user",
|
||||
@@ -281,7 +247,7 @@ describe("resolveHostById", () => {
|
||||
state.host = baseHost({
|
||||
authType: "agent",
|
||||
password: null,
|
||||
terminalConfig: JSON.stringify({
|
||||
sshOptions: JSON.stringify({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
}),
|
||||
});
|
||||
@@ -294,7 +260,7 @@ describe("resolveHostById", () => {
|
||||
authType: "agent",
|
||||
password: null,
|
||||
shareSshAuth: true,
|
||||
terminalConfig: JSON.stringify({
|
||||
sshOptions: JSON.stringify({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
}),
|
||||
});
|
||||
@@ -307,9 +273,6 @@ describe("resolveHostById", () => {
|
||||
expect(host.sshOptions).toEqual({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
});
|
||||
expect(host.terminalConfig).toEqual({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the recipient's credential instead of the owner's authentication", async () => {
|
||||
@@ -436,10 +399,7 @@ describe("resolveHostById", () => {
|
||||
password: "stale-owner-password",
|
||||
key: "stale-owner-key",
|
||||
credentialId: null,
|
||||
terminalConfig: JSON.stringify({
|
||||
theme: "termix",
|
||||
sudoPassword: "owner-sudo",
|
||||
}),
|
||||
sudoPassword: "owner-sudo",
|
||||
});
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
@@ -448,37 +408,13 @@ describe("resolveHostById", () => {
|
||||
expect(host.password).toBeNull();
|
||||
expect(host.key).toBeNull();
|
||||
expect(host.credentialId).toBeNull();
|
||||
// The owner's legacy sudo password never reaches a recipient.
|
||||
expect(host.terminalConfig).toEqual({ theme: "termix" });
|
||||
// The owner's sudo password never reaches a recipient.
|
||||
expect(host.sudoPassword).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the owner a 2.8 sudo password and SSH options kept in terminal_config", async () => {
|
||||
state.host = baseHost({
|
||||
sudoPassword: null,
|
||||
sshOptions: null,
|
||||
terminalConfig: JSON.stringify({
|
||||
sudoPassword: "legacy-sudo",
|
||||
keepaliveInterval: 12,
|
||||
theme: "nord",
|
||||
}),
|
||||
});
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.sudoPassword).toBe("legacy-sudo");
|
||||
expect(host.sshOptions).toEqual({ keepaliveInterval: 12 });
|
||||
expect(host.terminalConfig).toEqual({
|
||||
keepaliveInterval: 12,
|
||||
theme: "nord",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the ssh_options column once it is filled", async () => {
|
||||
it("reads the ssh_options column", async () => {
|
||||
state.host = baseHost({
|
||||
sshOptions: JSON.stringify({ keepaliveCountMax: 3 }),
|
||||
terminalConfig: JSON.stringify({ keepaliveInterval: 12 }),
|
||||
});
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
|
||||
@@ -199,7 +199,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("a plugin's external identity", () => {
|
||||
it("carries the 2.8 identifier, role map and logout claims into core", async () => {
|
||||
it("carries the role map and logout claims into core", async () => {
|
||||
setup();
|
||||
const identity = await getLoginMethod("corp-sso")!.callback!(
|
||||
fakeRequest() as never,
|
||||
@@ -211,7 +211,6 @@ describe("a plugin's external identity", () => {
|
||||
expect(result.kind).toBe("session");
|
||||
|
||||
const [user] = [...h.state.users.values()];
|
||||
expect(user).toMatchObject({ oidcIdentifier: "sub-1", ssoProviderId: 3 });
|
||||
expect(h.state.identities).toContainEqual(
|
||||
expect.objectContaining({ providerId: "3", subject: "sub-1" }),
|
||||
);
|
||||
@@ -219,9 +218,7 @@ describe("a plugin's external identity", () => {
|
||||
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
expect.objectContaining({
|
||||
ssoProviderId: 3,
|
||||
oidcSub: "sub-1",
|
||||
oidcSid: "sid-1",
|
||||
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -238,29 +235,6 @@ describe("a plugin's external identity", () => {
|
||||
expect(mapped).not.toHaveProperty("unlockError", "spoofed");
|
||||
expect(mapped.rateLimitUsername).toBeUndefined();
|
||||
});
|
||||
|
||||
it("finds a 2.8 user by the old identifier and links the identity", async () => {
|
||||
h.state.users.set("u-1", {
|
||||
id: "u-1",
|
||||
username: "Alice",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "sub-1",
|
||||
});
|
||||
setup();
|
||||
const identity = await getLoginMethod("corp-sso")!.callback!(
|
||||
fakeRequest() as never,
|
||||
);
|
||||
await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "corp-sso",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(h.state.users.size).toBe(1);
|
||||
expect(h.state.identities).toContainEqual(
|
||||
expect.objectContaining({ userId: "u-1", providerId: "3" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.auth.completeRedirectLogin", () => {
|
||||
@@ -306,7 +280,7 @@ describe("the other ctx.auth helpers", () => {
|
||||
const { auth } = setup();
|
||||
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
|
||||
expect(h.manager.revokeSessionsByExternalSession).toHaveBeenCalledWith({
|
||||
ssoProviderId: 3,
|
||||
providerId: 3,
|
||||
sub: null,
|
||||
sid: "sid-1",
|
||||
});
|
||||
@@ -402,8 +376,13 @@ describe("form methods and the external second factor setting", () => {
|
||||
username: "Bob",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "u-bob",
|
||||
providerId: "ldap:4",
|
||||
subject: "bob",
|
||||
email: null,
|
||||
});
|
||||
h.state.factors.push({
|
||||
userId: "u-bob",
|
||||
|
||||
@@ -474,7 +474,7 @@ describe("ctx.hosts", () => {
|
||||
password: "secret",
|
||||
key: "PRIVATE",
|
||||
autostartPassword: "x",
|
||||
terminalConfig: '{"keepaliveInterval":5,"fontSize":14}',
|
||||
sshOptions: '{"keepaliveInterval":5}',
|
||||
enableDocker: true,
|
||||
jumpHosts: "[]",
|
||||
},
|
||||
|
||||
@@ -107,7 +107,6 @@ function addUser(overrides: Record<string, unknown> = {}) {
|
||||
username: "alice",
|
||||
passwordHash: bcrypt.hashSync(PASSWORD, 4),
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
...overrides,
|
||||
};
|
||||
h.state.users.set(user.id as string, user as never);
|
||||
@@ -196,7 +195,6 @@ describe("external sign-in cannot claim another account", () => {
|
||||
email: null,
|
||||
name: "Alice",
|
||||
legacyIdentifier: "shared-id",
|
||||
ssoProviderId: 3,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -209,7 +207,7 @@ describe("external sign-in cannot claim another account", () => {
|
||||
external: true,
|
||||
}),
|
||||
);
|
||||
h.state.settings.set("oidc_auto_provision", "true");
|
||||
h.state.settings.set("external_auto_provision", "true");
|
||||
});
|
||||
|
||||
async function signIn(identity: Record<string, unknown> = external) {
|
||||
@@ -223,55 +221,21 @@ describe("external sign-in cannot claim another account", () => {
|
||||
return h.state.identities.find((row) => row.subject === "sub-1")?.userId;
|
||||
}
|
||||
|
||||
it("never links a local password account by its old identifier", async () => {
|
||||
addUser({ id: "local", oidcIdentifier: "shared-id", isOidc: false });
|
||||
await signIn();
|
||||
expect(linkedTo()).not.toBe("local");
|
||||
});
|
||||
|
||||
it("never links an account that already has an identity", async () => {
|
||||
addUser({
|
||||
id: "taken",
|
||||
username: "bob",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
oidcIdentifier: "shared-id",
|
||||
});
|
||||
it("never links an account that has no identity for this provider", async () => {
|
||||
addUser({ id: "local", username: "Alice" });
|
||||
addUser({ id: "sso", username: "bob", passwordHash: "" });
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "taken",
|
||||
userId: "sso",
|
||||
providerId: "9",
|
||||
subject: "bob",
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
});
|
||||
await signIn();
|
||||
expect(linkedTo()).not.toBe("taken");
|
||||
});
|
||||
|
||||
it("never links an account from a different provider", async () => {
|
||||
addUser({
|
||||
id: "other",
|
||||
username: "bob",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
oidcIdentifier: "shared-id",
|
||||
ssoProviderId: 7,
|
||||
});
|
||||
await signIn();
|
||||
expect(linkedTo()).not.toBe("other");
|
||||
});
|
||||
|
||||
it("still links a genuine 2.8 account on its first sign-in", async () => {
|
||||
addUser({
|
||||
id: "legacy",
|
||||
username: "Alice",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
oidcIdentifier: "shared-id",
|
||||
ssoProviderId: 3,
|
||||
});
|
||||
await signIn();
|
||||
expect(linkedTo()).toBe("legacy");
|
||||
expect(linkedTo()).not.toBe("local");
|
||||
expect(
|
||||
h.state.identities.find((row) => row.providerId === "3")?.userId,
|
||||
).not.toBe("sso");
|
||||
});
|
||||
|
||||
it("never gives a new account a local user's username", async () => {
|
||||
@@ -290,7 +254,6 @@ describe("external sign-in cannot claim another account", () => {
|
||||
id: "sso-user",
|
||||
username: "carol",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
|
||||
@@ -398,7 +398,7 @@ describe("host field options", () => {
|
||||
host: { enableKey: "on", enableLabelKey: "k", fields, ...extra },
|
||||
});
|
||||
|
||||
it("accepts enableDefault, defaultFrom, shareRead and ownerOnly", () => {
|
||||
it("accepts enableDefault, shareRead and ownerOnly", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host(
|
||||
@@ -407,7 +407,6 @@ describe("host field options", () => {
|
||||
key: "mode",
|
||||
type: "boolean",
|
||||
labelKey: "k",
|
||||
defaultFrom: "defaultOn",
|
||||
shareRead: "edit",
|
||||
ownerOnly: true,
|
||||
},
|
||||
@@ -418,7 +417,7 @@ describe("host field options", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("still accepts an old defaultFrom, but only as a string", () => {
|
||||
it("rejects the removed defaultFrom", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([
|
||||
@@ -429,11 +428,6 @@ describe("host field options", () => {
|
||||
defaultFrom: "missing",
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateManifest(
|
||||
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom: 1 }]),
|
||||
).join(),
|
||||
).toMatch(/defaultFrom/);
|
||||
});
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* The ACME settings migration: a 2.8 acme_ssl_settings row comes through into
|
||||
* the acme-ssl plugin's admin settings, the Cloudflare token stays encrypted
|
||||
* and leaves the old row, and a second run changes nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
legacy: {} as Record<string, string>,
|
||||
pluginRows: [] as Array<{
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}>,
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "acme-ssl" ? { id } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.legacy[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.legacy[key] = value;
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
) => state.pluginRows.find((row) => row.key === key) ?? null,
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
|
||||
state.pluginRows.push({ key, value, encrypted });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `enc:${value}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runAcmeSslSettingsMigration } from "../../upgrade/acme-ssl-settings-migration.js";
|
||||
|
||||
const value = (key: string) => {
|
||||
const row = state.pluginRows.find((candidate) => candidate.key === key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.legacy = {
|
||||
acme_ssl_settings: JSON.stringify({
|
||||
enabled: true,
|
||||
domain: "termix.example.com",
|
||||
email: "admin@example.com",
|
||||
challengeType: "dns-cloudflare",
|
||||
cloudflareToken: "cf-token",
|
||||
lastIssuedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
};
|
||||
state.pluginRows = [];
|
||||
state.pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runAcmeSslSettingsMigration", () => {
|
||||
it("moves every field and encrypts the token", async () => {
|
||||
const result = await runAcmeSslSettingsMigration();
|
||||
expect(result.moved.sort()).toEqual(
|
||||
[
|
||||
"autoRenew",
|
||||
"challengeType",
|
||||
"cloudflareToken",
|
||||
"domain",
|
||||
"email",
|
||||
].sort(),
|
||||
);
|
||||
expect(value("domain")).toBe("termix.example.com");
|
||||
expect(value("email")).toBe("admin@example.com");
|
||||
expect(value("challengeType")).toBe("dns-cloudflare");
|
||||
expect(value("autoRenew")).toBe(true);
|
||||
expect(value("cloudflareToken")).toBe("enc:cf-token");
|
||||
expect(
|
||||
state.pluginRows.find((row) => row.key === "cloudflareToken")?.encrypted,
|
||||
).toBe(true);
|
||||
|
||||
const left = JSON.parse(state.legacy.acme_ssl_settings);
|
||||
expect(left.cloudflareToken).toBeUndefined();
|
||||
expect(left.domain).toBe("termix.example.com");
|
||||
});
|
||||
|
||||
it("maps the webroot challenge and turns renewal off for manual", async () => {
|
||||
state.legacy.acme_ssl_settings = JSON.stringify({
|
||||
enabled: true,
|
||||
domain: "a.example",
|
||||
email: "a@example.com",
|
||||
challengeType: "http-webroot",
|
||||
});
|
||||
await runAcmeSslSettingsMigration();
|
||||
expect(value("challengeType")).toBe("http-01");
|
||||
expect(value("autoRenew")).toBe(true);
|
||||
|
||||
state.pluginRows = [];
|
||||
state.legacy.acme_ssl_settings = JSON.stringify({
|
||||
enabled: true,
|
||||
challengeType: "manual",
|
||||
});
|
||||
await runAcmeSslSettingsMigration();
|
||||
expect(value("autoRenew")).toBe(false);
|
||||
expect(value("challengeType")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("changes nothing on a second run or over values already set", async () => {
|
||||
await runAcmeSslSettingsMigration();
|
||||
const snapshot = JSON.stringify(state.pluginRows);
|
||||
const second = await runAcmeSslSettingsMigration();
|
||||
expect(second.moved).toEqual([]);
|
||||
expect(JSON.stringify(state.pluginRows)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("does nothing before the plugin row exists or without a legacy row", async () => {
|
||||
state.pluginInstalled = false;
|
||||
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
|
||||
state.pluginInstalled = true;
|
||||
state.legacy = {};
|
||||
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* The AI settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: the admin switch, the private endpoint list,
|
||||
* every user's opt-in, every host's switch and every provider key have to
|
||||
* reach the ai plugin, keys encrypted and gone from the table. Running it
|
||||
* twice must not duplicate or clobber anything.
|
||||
* Every provider key has to reach the ai plugin, encrypted and gone from the
|
||||
* table. Running it twice must not duplicate or clobber anything.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -127,10 +125,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import {
|
||||
allowlistToText,
|
||||
runAiSettingsMigration,
|
||||
} from "../../upgrade/ai-settings-migration.js";
|
||||
import { runAiSettingsMigration } from "../../upgrade/ai-settings-migration.js";
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = state.pluginRows.find(
|
||||
@@ -167,30 +162,7 @@ beforeEach(() => {
|
||||
state.deks = new Set(["u1", "u2"]);
|
||||
});
|
||||
|
||||
describe("allowlistToText", () => {
|
||||
it("turns the stored JSON array into one host per line", () => {
|
||||
expect(allowlistToText('[" a ", "", "b"]')).toBe("a\nb");
|
||||
expect(allowlistToText("[]")).toBe("");
|
||||
expect(allowlistToText("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAiSettingsMigration", () => {
|
||||
it("moves admin, user and host settings", async () => {
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(value("admin", null, "globallyEnabled")).toBe(true);
|
||||
expect(value("admin", null, "privateEndpoints")).toBe(
|
||||
"localhost\nollama.lan",
|
||||
);
|
||||
expect(value("user", "u1", "enabled")).toBe(true);
|
||||
expect(value("user", "u1", "allowReadOnlyCommands")).toBe(false);
|
||||
// Never asked stays unset, which the plugin reads as not enabled.
|
||||
expect(value("user", "u2", "enabled")).toBeUndefined();
|
||||
expect(value("host", "5", "enableAiAssistant")).toBe(true);
|
||||
expect(value("host", "6", "enableAiAssistant")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves each provider key into its owner's secrets, encrypted, and clears the column", async () => {
|
||||
await runAiSettingsMigration();
|
||||
|
||||
@@ -235,18 +207,18 @@ describe("runAiSettingsMigration", () => {
|
||||
expect(JSON.stringify(state.pluginRows)).toBe(first);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already has", async () => {
|
||||
it("does not overwrite a key the plugin already has", async () => {
|
||||
state.pluginRows.push({
|
||||
pluginId: "ai",
|
||||
scope: "admin",
|
||||
scopeId: null,
|
||||
key: "globallyEnabled",
|
||||
value: "false",
|
||||
encrypted: false,
|
||||
scope: "secret",
|
||||
scopeId: "u1",
|
||||
key: "provider:3",
|
||||
value: JSON.stringify("sysenc:kept"),
|
||||
encrypted: true,
|
||||
});
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(value("admin", null, "globallyEnabled")).toBe(false);
|
||||
expect(value("secret", "u1", "provider:3")).toBe("sysenc:kept");
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* The Docker host settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: every host with Docker on, and every host on
|
||||
* Podman, must come through into the docker plugin's host settings, whatever
|
||||
* shape the engine returned the flag in. Running it twice must not duplicate
|
||||
* or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pluginRows: [] as Array<{
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
hostRows: [] as Array<Record<string, unknown>>,
|
||||
pluginInstalled: true,
|
||||
columnsGone: false,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "docker" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
state.pluginRows.filter(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = state.pluginRows.find(
|
||||
(row) =>
|
||||
row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
selectRows: async () => {
|
||||
if (state.columnsGone) throw new Error("no such column: enable_docker");
|
||||
return state.hostRows;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import {
|
||||
dockerSettingsFromRow,
|
||||
runDockerSettingsMigration,
|
||||
} from "../../upgrade/docker-settings-migration.js";
|
||||
|
||||
const hostValues = (hostId: number) =>
|
||||
Object.fromEntries(
|
||||
state.pluginRows
|
||||
.filter((row) => row.scope === "host" && row.scopeId === String(hostId))
|
||||
.map((row) => [row.key, JSON.parse(row.value ?? "null")]),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
state.pluginRows = [];
|
||||
state.hostRows = [];
|
||||
state.pluginInstalled = true;
|
||||
state.columnsGone = false;
|
||||
});
|
||||
|
||||
describe("dockerSettingsFromRow", () => {
|
||||
it("reads SQLite integers, real booleans and strings", () => {
|
||||
expect(
|
||||
dockerSettingsFromRow({ id: 1, enable_docker: 1, docker_config: null }),
|
||||
).toEqual({ enableDocker: true });
|
||||
expect(
|
||||
dockerSettingsFromRow({
|
||||
id: 1,
|
||||
enable_docker: true,
|
||||
docker_config: null,
|
||||
}),
|
||||
).toEqual({ enableDocker: true });
|
||||
expect(
|
||||
dockerSettingsFromRow({ id: 1, enable_docker: 0, docker_config: null }),
|
||||
).toEqual({});
|
||||
expect(
|
||||
dockerSettingsFromRow({
|
||||
id: 1,
|
||||
enable_docker: false,
|
||||
docker_config: null,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps a Podman runtime and ignores the default or broken config", () => {
|
||||
expect(
|
||||
dockerSettingsFromRow({
|
||||
id: 1,
|
||||
enable_docker: 1,
|
||||
docker_config: '{"runtime":"podman"}',
|
||||
}),
|
||||
).toEqual({ enableDocker: true, containerRuntime: "podman" });
|
||||
expect(
|
||||
dockerSettingsFromRow({
|
||||
id: 1,
|
||||
enable_docker: 1,
|
||||
docker_config: '{"runtime":"docker"}',
|
||||
}),
|
||||
).toEqual({ enableDocker: true });
|
||||
expect(
|
||||
dockerSettingsFromRow({
|
||||
id: 1,
|
||||
enable_docker: 1,
|
||||
docker_config: "{oops",
|
||||
}),
|
||||
).toEqual({ enableDocker: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDockerSettingsMigration", () => {
|
||||
it("moves every host's docker options and skips hosts with nothing set", async () => {
|
||||
state.hostRows = [
|
||||
{ id: 1, enable_docker: 1, docker_config: null },
|
||||
{ id: 2, enable_docker: 1, docker_config: '{"runtime":"podman"}' },
|
||||
{ id: 3, enable_docker: 0, docker_config: null },
|
||||
];
|
||||
|
||||
const result = await runDockerSettingsMigration();
|
||||
|
||||
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0 });
|
||||
expect(hostValues(1)).toEqual({ enableDocker: true });
|
||||
expect(hostValues(2)).toEqual({
|
||||
enableDocker: true,
|
||||
containerRuntime: "podman",
|
||||
});
|
||||
expect(hostValues(3)).toEqual({});
|
||||
});
|
||||
|
||||
it("changes nothing when run twice", async () => {
|
||||
state.hostRows = [
|
||||
{ id: 1, enable_docker: 1, docker_config: '{"runtime":"podman"}' },
|
||||
];
|
||||
await runDockerSettingsMigration();
|
||||
const before = JSON.stringify(state.pluginRows);
|
||||
|
||||
const second = await runDockerSettingsMigration();
|
||||
|
||||
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1 });
|
||||
expect(JSON.stringify(state.pluginRows)).toBe(before);
|
||||
});
|
||||
|
||||
it("keeps a value already saved in the plugin", async () => {
|
||||
state.pluginRows.push({
|
||||
pluginId: "docker",
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "enableDocker",
|
||||
value: "false",
|
||||
});
|
||||
state.hostRows = [{ id: 1, enable_docker: 1, docker_config: null }];
|
||||
|
||||
await runDockerSettingsMigration();
|
||||
|
||||
expect(hostValues(1)).toEqual({ enableDocker: false });
|
||||
});
|
||||
|
||||
it("does nothing before the plugin row exists or once the columns are gone", async () => {
|
||||
state.hostRows = [{ id: 1, enable_docker: 1, docker_config: null }];
|
||||
state.pluginInstalled = false;
|
||||
expect(await runDockerSettingsMigration()).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
});
|
||||
|
||||
state.pluginInstalled = true;
|
||||
state.columnsGone = true;
|
||||
expect(await runDockerSettingsMigration()).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
});
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAuthState, type AuthState } from "../auth/auth-test-helpers.js";
|
||||
|
||||
const h = vi.hoisted(() => ({ state: null as unknown as AuthState }));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", async () => {
|
||||
const helpers = await import("../auth/auth-test-helpers.js");
|
||||
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
|
||||
return Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
(...args: unknown[]) =>
|
||||
(
|
||||
helpers.fakeFactory(h.state) as Record<
|
||||
string,
|
||||
(...a: unknown[]) => unknown
|
||||
>
|
||||
)[name](...args),
|
||||
]),
|
||||
);
|
||||
});
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runExternalIdentityMigration, parseLegacyIdentifier } =
|
||||
await import("../../upgrade/external-identity-migration.js");
|
||||
|
||||
function user(id: string, extra: Record<string, unknown>) {
|
||||
h.state.users.set(id, {
|
||||
id,
|
||||
username: id,
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.state = createAuthState();
|
||||
});
|
||||
|
||||
describe("parseLegacyIdentifier", () => {
|
||||
it("splits the prefixed forms and keeps bare subjects on their provider", () => {
|
||||
expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({
|
||||
providerId: "ldap:4",
|
||||
subject: "bob",
|
||||
});
|
||||
expect(parseLegacyIdentifier("github:7:42", null)).toEqual({
|
||||
providerId: "7",
|
||||
subject: "42",
|
||||
});
|
||||
expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({
|
||||
providerId: "ldap:4",
|
||||
subject: "cn=a:b",
|
||||
});
|
||||
expect(parseLegacyIdentifier("sub-123", 3)).toEqual({
|
||||
providerId: "3",
|
||||
subject: "sub-123",
|
||||
});
|
||||
expect(parseLegacyIdentifier("sub-123", null)).toEqual({
|
||||
providerId: "legacy-oidc",
|
||||
subject: "sub-123",
|
||||
});
|
||||
expect(parseLegacyIdentifier("github:null:42", null)).toEqual({
|
||||
providerId: "legacy-oidc",
|
||||
subject: "42",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runExternalIdentityMigration", () => {
|
||||
it("moves every identifier without touching the old data", async () => {
|
||||
user("ldap-user", { oidcIdentifier: "ldap:4:bob", ssoProviderId: 4 });
|
||||
user("gh-user", { oidcIdentifier: "github:7:42", ssoProviderId: 7 });
|
||||
user("oidc-user", {
|
||||
oidcIdentifier: "sub-123",
|
||||
ssoProviderId: 3,
|
||||
});
|
||||
user("env-user", { oidcIdentifier: "someone@example.com" });
|
||||
user("local", { isOidc: false, passwordHash: "hash" });
|
||||
|
||||
const result = await runExternalIdentityMigration();
|
||||
|
||||
expect(result).toEqual({ identities: 4, skipped: 0 });
|
||||
expect(
|
||||
h.state.identities.map(({ userId, providerId, subject }) => ({
|
||||
userId,
|
||||
providerId,
|
||||
subject,
|
||||
})),
|
||||
).toEqual([
|
||||
{ userId: "ldap-user", providerId: "ldap:4", subject: "bob" },
|
||||
{ userId: "gh-user", providerId: "7", subject: "42" },
|
||||
{ userId: "oidc-user", providerId: "3", subject: "sub-123" },
|
||||
{
|
||||
userId: "env-user",
|
||||
providerId: "legacy-oidc",
|
||||
subject: "someone@example.com",
|
||||
},
|
||||
]);
|
||||
// Lossless: the old column stays.
|
||||
expect(h.state.users.get("ldap-user")!.oidcIdentifier).toBe("ldap:4:bob");
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
user("ldap-user", { oidcIdentifier: "ldap:4:bob" });
|
||||
await runExternalIdentityMigration();
|
||||
const second = await 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);
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* The file-manager host-columns migration.
|
||||
*
|
||||
* An upgrade must be lossless: a host that had file manager disabled, a
|
||||
* custom default path, or SCP legacy mode on must keep that configuration
|
||||
* once the ssh_data columns are dropped. Running it twice must not duplicate
|
||||
* or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_file_manager: boolean;
|
||||
default_path: string | null;
|
||||
scp_legacy: boolean;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () =>
|
||||
hostRows.filter(
|
||||
(row) =>
|
||||
row.enable_file_manager === false ||
|
||||
row.default_path !== null ||
|
||||
row.scp_legacy,
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runFileManagerSettingsMigration } =
|
||||
await import("../../upgrade/file-manager-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runFileManagerSettingsMigration", () => {
|
||||
it("moves a host with file manager disabled into plugin settings", async () => {
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
enable_file_manager: false,
|
||||
default_path: "/srv/app",
|
||||
scp_legacy: true,
|
||||
});
|
||||
|
||||
const result = await runFileManagerSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableFileManager")).toBe(false);
|
||||
expect(storedValue(1, "defaultPath")).toBe("/srv/app");
|
||||
expect(storedValue(1, "scpLegacy")).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install with no non-default hosts", async () => {
|
||||
const result = await runFileManagerSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({
|
||||
id: 2,
|
||||
enable_file_manager: false,
|
||||
default_path: null,
|
||||
scp_legacy: false,
|
||||
});
|
||||
|
||||
await runFileManagerSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runFileManagerSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("moves each host independently", async () => {
|
||||
hostRows.push(
|
||||
{
|
||||
id: 1,
|
||||
enable_file_manager: false,
|
||||
default_path: null,
|
||||
scp_legacy: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
enable_file_manager: true,
|
||||
default_path: "/data",
|
||||
scp_legacy: false,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runFileManagerSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(2);
|
||||
expect(storedValue(1, "enableFileManager")).toBe(false);
|
||||
expect(storedValue(2, "defaultPath")).toBe("/data");
|
||||
});
|
||||
});
|
||||
@@ -1,311 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type PluginRow = {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, string>,
|
||||
pluginRows: [] as PluginRow[],
|
||||
defaults: [] as Array<{
|
||||
scope: Record<string, unknown>;
|
||||
namespace: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}>,
|
||||
hosts: [] as Array<Record<string, unknown>>,
|
||||
folders: [] as Array<{ id: number; userId: string; credentialId: number }>,
|
||||
folderCredential: new Map<string, number>(),
|
||||
}));
|
||||
|
||||
const manifests = vi.hoisted(() => [
|
||||
{
|
||||
id: "ssh-terminal",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [
|
||||
{ key: "fontSize", type: "number", default: 14 },
|
||||
{ key: "theme", type: "string", default: "termix" },
|
||||
{ key: "localEcho", type: "select", default: "auto" },
|
||||
{ key: "linkClickBehavior", type: "select", default: "confirm" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remote-desktop",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [{ key: "colorDepth", type: "select", default: "inherit" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "host-metrics", contributes: { settings: { host: { fields: [] } } } },
|
||||
]);
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => manifests,
|
||||
}));
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
sshFolders: { id: "id", userId: "userId", credentialId: "credentialId" },
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => {
|
||||
const scopeKey = (scope: Record<string, unknown>) =>
|
||||
scope.level === "admin"
|
||||
? "admin"
|
||||
: scope.level === "user"
|
||||
? `u:${scope.userId}`
|
||||
: `f:${scope.folderId}`;
|
||||
return {
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => db.settings[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
db.settings[key] = value;
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
listByKey: async (pluginId: string, scope: string, key: string) =>
|
||||
db.pluginRows.filter(
|
||||
(row) =>
|
||||
row.pluginId === pluginId && row.scope === scope && row.key === key,
|
||||
),
|
||||
getAll: async (pluginId: string, scope: string, scopeId: string) =>
|
||||
db.pluginRows.filter(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
db.pluginRows = db.pluginRows.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key
|
||||
),
|
||||
);
|
||||
db.pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
delete: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
) => {
|
||||
db.pluginRows = db.pluginRows.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listScope: async (scope: Record<string, unknown>) =>
|
||||
db.defaults.filter((row) => scopeKey(row.scope) === scopeKey(scope)),
|
||||
apply: async (
|
||||
scope: Record<string, unknown>,
|
||||
set: Array<{ namespace: string; key: string; value: string }>,
|
||||
) => {
|
||||
for (const entry of set) db.defaults.push({ scope, ...entry });
|
||||
},
|
||||
listHosts: async () => db.hosts,
|
||||
updateHosts: async (
|
||||
patches: Array<{ id: number; values: Record<string, unknown> }>,
|
||||
) => {
|
||||
for (const patch of patches) {
|
||||
Object.assign(
|
||||
db.hosts.find((host) => host.id === patch.id)!,
|
||||
patch.values,
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findFolderCredentialId: async (_userId: string, folder: string) =>
|
||||
db.folderCredential.get(folder) ?? null,
|
||||
}),
|
||||
createCurrentRepositoryContext: () => ({
|
||||
drizzle: {
|
||||
select: () => ({
|
||||
from: () => ({ where: async () => db.folders }),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { runHostDefaultsMigration } =
|
||||
await import("../../upgrade/host-defaults-migration.js");
|
||||
|
||||
function level(namespace: string, key: string, scopeLevel: string) {
|
||||
const row = db.defaults.find(
|
||||
(entry) =>
|
||||
entry.namespace === namespace &&
|
||||
entry.key === key &&
|
||||
entry.scope.level === scopeLevel,
|
||||
);
|
||||
return row ? JSON.parse(row.value) : undefined;
|
||||
}
|
||||
|
||||
function hostValue(hostId: number, pluginId: string, key: string) {
|
||||
const row = db.pluginRows.find(
|
||||
(entry) =>
|
||||
entry.pluginId === pluginId &&
|
||||
entry.scope === "host" &&
|
||||
entry.scopeId === String(hostId) &&
|
||||
entry.key === key,
|
||||
);
|
||||
return row?.value ? JSON.parse(row.value) : undefined;
|
||||
}
|
||||
|
||||
const plugin = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): PluginRow => ({
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.settings = {};
|
||||
db.pluginRows = [];
|
||||
db.defaults = [];
|
||||
db.hosts = [];
|
||||
db.folders = [];
|
||||
db.folderCredential = new Map();
|
||||
});
|
||||
|
||||
describe("runHostDefaultsMigration", () => {
|
||||
it("moves the old server defaults, dropping the credential and password", async () => {
|
||||
db.settings.host_defaults = JSON.stringify({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy",
|
||||
socks5Port: 1081,
|
||||
socks5Password: "secret",
|
||||
credentialId: 3,
|
||||
statusCheckEnabled: false,
|
||||
});
|
||||
db.pluginRows.push(
|
||||
plugin("ssh-terminal", "admin", null, "newHostFontSize", 18),
|
||||
plugin("host-metrics", "admin", null, "enabledForNewHosts", false),
|
||||
);
|
||||
db.folders = [{ id: 4, userId: "u1", credentialId: 9 }];
|
||||
|
||||
await runHostDefaultsMigration();
|
||||
|
||||
expect(level("core", "socks5", "admin")).toMatchObject({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy",
|
||||
socks5Port: 1081,
|
||||
});
|
||||
expect(JSON.stringify(db.defaults)).not.toContain("secret");
|
||||
expect(level("core", "statusCheckEnabled", "admin")).toBe(false);
|
||||
expect(level("ssh-terminal", "fontSize", "admin")).toBe(18);
|
||||
expect(level("host-metrics", "metricsEnabled", "admin")).toBe(false);
|
||||
expect(level("core", "auth", "folder")).toMatchObject({
|
||||
authType: "credential",
|
||||
credentialId: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves each user's terminal and remote desktop defaults to their own level", async () => {
|
||||
db.pluginRows.push(
|
||||
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
|
||||
fontSize: 20,
|
||||
}),
|
||||
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
|
||||
plugin("remote-desktop", "user", "u1", "colorDepth", "16"),
|
||||
plugin("remote-desktop", "user", "u1", "disableAudio", "inherit"),
|
||||
);
|
||||
await runHostDefaultsMigration();
|
||||
expect(level("ssh-terminal", "fontSize", "user")).toBe(20);
|
||||
expect(level("ssh-terminal", "localEcho", "user")).toBe("on");
|
||||
expect(level("remote-desktop", "colorDepth", "user")).toBe("16");
|
||||
expect(level("remote-desktop", "disableAudio", "user")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes in the look a host really had while it followed its user", async () => {
|
||||
db.hosts = [
|
||||
{ id: 1, userId: "u1", defaultOverrides: null },
|
||||
{ id: 2, userId: "u1", defaultOverrides: null },
|
||||
{ id: 3, userId: "u1", defaultOverrides: '{"core":[]}' },
|
||||
];
|
||||
db.pluginRows.push(
|
||||
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
|
||||
fontSize: 20,
|
||||
}),
|
||||
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
|
||||
plugin("ssh-terminal", "host", "1", "fontSize", 30),
|
||||
plugin("ssh-terminal", "host", "1", "localEcho", "default"),
|
||||
plugin("ssh-terminal", "host", "2", "inheritAppearance", false),
|
||||
plugin("ssh-terminal", "host", "2", "fontSize", 30),
|
||||
plugin("ssh-terminal", "host", "2", "localEcho", "off"),
|
||||
plugin("remote-desktop", "user", "u1", "colorDepth", "24"),
|
||||
);
|
||||
|
||||
await runHostDefaultsMigration();
|
||||
|
||||
// Followed the user: the user's look, and their echo mode.
|
||||
expect(hostValue(1, "ssh-terminal", "fontSize")).toBe(20);
|
||||
expect(hostValue(1, "ssh-terminal", "localEcho")).toBe("on");
|
||||
expect(hostValue(1, "remote-desktop", "colorDepth")).toBe("24");
|
||||
// Had its own look: kept.
|
||||
expect(hostValue(2, "ssh-terminal", "fontSize")).toBe(30);
|
||||
expect(hostValue(2, "ssh-terminal", "localEcho")).toBe("off");
|
||||
// Saved since host defaults: left alone.
|
||||
expect(hostValue(3, "ssh-terminal", "fontSize")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives a credential host its folder's credential", async () => {
|
||||
db.hosts = [
|
||||
{
|
||||
id: 1,
|
||||
userId: "u1",
|
||||
authType: "credential",
|
||||
credentialId: null,
|
||||
folder: "Prod",
|
||||
defaultOverrides: null,
|
||||
},
|
||||
];
|
||||
db.folderCredential.set("Prod", 12);
|
||||
await runHostDefaultsMigration();
|
||||
expect(db.hosts[0].credentialId).toBe(12);
|
||||
});
|
||||
|
||||
it("moves each level once", async () => {
|
||||
db.settings.host_defaults = JSON.stringify({ statusCheckEnabled: false });
|
||||
await runHostDefaultsMigration();
|
||||
db.defaults = [];
|
||||
await runHostDefaultsMigration();
|
||||
expect(level("core", "statusCheckEnabled", "admin")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* Host Metrics' settings leaving core: the two admin keys, the new-host
|
||||
* default inside host_defaults, and the metrics half of every host's
|
||||
* stats_config. Nothing may be lost, and a second run must change nothing.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
coreSettings: new Map<string, string>(),
|
||||
pluginRows: [] as Array<{
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
pluginInstalled: true,
|
||||
db: null as unknown,
|
||||
}));
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
state.pluginRows.find(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "host-metrics" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
state.pluginRows.filter(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) existing.value = value;
|
||||
else state.pluginRows.push({ scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
|
||||
await import("../../upgrade/host-metrics-settings-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = find(scope, scopeId, key) as Row | undefined;
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.coreSettings.clear();
|
||||
state.pluginRows = [];
|
||||
state.pluginInstalled = true;
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, stats_config TEXT)",
|
||||
);
|
||||
state.db = drizzle(sqlite);
|
||||
});
|
||||
|
||||
describe("hostSettingsFromStatsConfig", () => {
|
||||
it("keeps only what differs from the defaults", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(
|
||||
JSON.stringify({
|
||||
statusCheckEnabled: false,
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
useGlobalMetricsInterval: false,
|
||||
enabledWidgets: ["cpu", "disk"],
|
||||
excludedMounts: ["/snap"],
|
||||
monitoredMounts: [{ path: "/data", label: "Data" }, { bad: 1 }],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
enabledWidgets: ["cpu", "disk"],
|
||||
excludedMounts: ["/snap"],
|
||||
monitoredMounts: [{ path: "/data", label: "Data" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the host's interval while it followed the global one", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(JSON.stringify({ metricsInterval: 45 })),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("reads a value that was stringified twice", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(
|
||||
JSON.stringify(JSON.stringify({ metricsEnabled: false })),
|
||||
),
|
||||
).toEqual({ metricsEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runHostMetricsSettingsMigration", () => {
|
||||
it("moves the admin keys, the new-host default and every host", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
state.coreSettings.set("metrics_history_retention_days", "30");
|
||||
state.coreSettings.set(
|
||||
"host_defaults",
|
||||
JSON.stringify({ metricsEnabled: false, statusCheckEnabled: true }),
|
||||
);
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
|
||||
);
|
||||
insert.run(1, JSON.stringify({ metricsEnabled: false }));
|
||||
insert.run(2, JSON.stringify({ enabledWidgets: ["cpu"] }));
|
||||
insert.run(3, JSON.stringify({ statusCheckEnabled: false }));
|
||||
insert.run(4, null);
|
||||
|
||||
const result = await runHostMetricsSettingsMigration();
|
||||
|
||||
expect(result.moved.sort()).toEqual([
|
||||
"global_metrics_interval",
|
||||
"host_defaults.metricsEnabled",
|
||||
"metrics_history_retention_days",
|
||||
]);
|
||||
expect(value("admin", null, "metricsInterval")).toBe(20);
|
||||
expect(value("admin", null, "historyRetentionDays")).toBe(30);
|
||||
expect(value("admin", null, "enabledForNewHosts")).toBe(false);
|
||||
expect(value("host", "1", "metricsEnabled")).toBe(false);
|
||||
expect(value("host", "2", "enabledWidgets")).toEqual(["cpu"]);
|
||||
// Only the status half, which is core's: nothing for the plugin.
|
||||
expect(state.pluginRows.filter((row) => row.scopeId === "3")).toEqual([]);
|
||||
expect(result.hostsMoved).toBe(2);
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
|
||||
.run(1, JSON.stringify({ metricsEnabled: false }));
|
||||
await runHostMetricsSettingsMigration();
|
||||
|
||||
// Changed through the plugin after the upgrade.
|
||||
const interval = find("admin", null, "metricsInterval")!;
|
||||
interval.value = "90";
|
||||
const enabled = find("host", "1", "metricsEnabled")!;
|
||||
enabled.value = "true";
|
||||
|
||||
const second = await runHostMetricsSettingsMigration();
|
||||
expect(second).toEqual({ moved: [], hostsMoved: 0, hostsSkipped: 1 });
|
||||
expect(value("admin", null, "metricsInterval")).toBe(90);
|
||||
expect(value("host", "1", "metricsEnabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("waits until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
expect(await runHostMetricsSettingsMigration()).toEqual({
|
||||
moved: [],
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
});
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips values out of range", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "1");
|
||||
state.coreSettings.set("metrics_history_retention_days", "900");
|
||||
await runHostMetricsSettingsMigration();
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* The OPKSSH config move. A 2.8 install's config must reach the opkssh
|
||||
* plugin's folder, the old file must stay for a downgrade, and the install
|
||||
* must keep the redirect URI its identity providers know. Running it twice
|
||||
* must not overwrite anything.
|
||||
* plugin's folder and the old file must stay for a downgrade. Running it
|
||||
* twice must not overwrite anything.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
@@ -56,33 +55,22 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("runOpksshConfigMigration", () => {
|
||||
it("copies the config, keeps the old one and the old redirect URI, once", async () => {
|
||||
it("copies the config and keeps the old one, once", async () => {
|
||||
await fs.mkdir(path.dirname(legacy()), { recursive: true });
|
||||
await fs.writeFile(legacy(), "providers:\n - alias: google\n");
|
||||
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({
|
||||
copied: true,
|
||||
legacyCallback: true,
|
||||
});
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({ copied: true });
|
||||
expect(await fs.readFile(target(), "utf8")).toContain("alias: google");
|
||||
expect(await fs.readFile(legacy(), "utf8")).toContain("alias: google");
|
||||
expect(state.settings.get("legacyCallback")).toBe("true");
|
||||
expect(state.settings.has("legacyCallback")).toBe(false);
|
||||
|
||||
await fs.writeFile(target(), "providers:\n - alias: edited\n");
|
||||
state.settings.set("legacyCallback", "false");
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({
|
||||
copied: false,
|
||||
legacyCallback: false,
|
||||
});
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({ copied: false });
|
||||
expect(await fs.readFile(target(), "utf8")).toContain("alias: edited");
|
||||
expect(state.settings.get("legacyCallback")).toBe("false");
|
||||
});
|
||||
|
||||
it("does nothing on an install that never set up OPKSSH", async () => {
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({
|
||||
copied: false,
|
||||
legacyCallback: false,
|
||||
});
|
||||
expect(await runOpksshConfigMigration(dataDir)).toEqual({ copied: false });
|
||||
expect(state.settings.size).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* The Proxmox host-columns migration.
|
||||
*
|
||||
* An upgrade must be lossless: a host that already had Proxmox discovery or
|
||||
* stats configured must keep that configuration once the ssh_data columns
|
||||
* are dropped. Running it twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_proxmox: boolean;
|
||||
proxmox_config: string | null;
|
||||
enable_proxmox_stats: boolean;
|
||||
proxmox_stats_config: string | null;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () =>
|
||||
hostRows.filter(
|
||||
(row) =>
|
||||
row.enable_proxmox ||
|
||||
row.proxmox_config !== null ||
|
||||
row.enable_proxmox_stats ||
|
||||
row.proxmox_stats_config !== null,
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runProxmoxSettingsMigration } =
|
||||
await import("../../upgrade/proxmox-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runProxmoxSettingsMigration", () => {
|
||||
it("moves an enabled host's columns into plugin settings", async () => {
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
enable_proxmox: true,
|
||||
proxmox_config: JSON.stringify({ defaultAuthType: "password" }),
|
||||
enable_proxmox_stats: true,
|
||||
proxmox_stats_config: JSON.stringify({ nodeName: "pve1" }),
|
||||
});
|
||||
|
||||
const result = await runProxmoxSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableProxmox")).toBe(true);
|
||||
expect(storedValue(1, "proxmoxConfig")).toEqual({
|
||||
defaultAuthType: "password",
|
||||
});
|
||||
expect(storedValue(1, "enableProxmoxStats")).toBe(true);
|
||||
expect(storedValue(1, "proxmoxStatsConfig")).toEqual({ nodeName: "pve1" });
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install with no proxmox hosts", async () => {
|
||||
const result = await runProxmoxSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({
|
||||
id: 2,
|
||||
enable_proxmox: false,
|
||||
proxmox_config: JSON.stringify({ autoSyncEnabled: true }),
|
||||
enable_proxmox_stats: false,
|
||||
proxmox_stats_config: null,
|
||||
});
|
||||
|
||||
await runProxmoxSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runProxmoxSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("moves each host independently", async () => {
|
||||
hostRows.push(
|
||||
{
|
||||
id: 1,
|
||||
enable_proxmox: true,
|
||||
proxmox_config: null,
|
||||
enable_proxmox_stats: false,
|
||||
proxmox_stats_config: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
enable_proxmox: false,
|
||||
proxmox_config: null,
|
||||
enable_proxmox_stats: true,
|
||||
proxmox_stats_config: JSON.stringify({ nodeName: "pve2" }),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runProxmoxSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(2);
|
||||
expect(storedValue(1, "enableProxmox")).toBe(true);
|
||||
expect(storedValue(2, "proxmoxStatsConfig")).toEqual({ nodeName: "pve2" });
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* The Remote Desktop settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: the admin switch and guacd address, each
|
||||
* user's RDP defaults and every host's RDP/VNC/Telnet options must come
|
||||
* through into the plugin's own settings, whatever shape an old host had.
|
||||
* Running it twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
coreSettings: new Map<string, string>(),
|
||||
pluginRows: [] as Array<{
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
hostRows: [] as Array<Record<string, unknown>>,
|
||||
userRows: [] as Array<{ user_id: string; rdp_defaults: string | null }>,
|
||||
statements: [] as string[],
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
state.pluginRows.find(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "remote-desktop" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
state.pluginRows.filter(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
selectRows: async (query: { queryChunks?: unknown[] }) => {
|
||||
const text = JSON.stringify(query.queryChunks ?? query);
|
||||
return text.includes("user_preferences") ? state.userRows : state.hostRows;
|
||||
},
|
||||
runStatement: async (query: unknown) => {
|
||||
state.statements.push(JSON.stringify(query));
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runRemoteDesktopSettingsMigration, hostSettingsFromRow } =
|
||||
await import("../../upgrade/remote-desktop-settings-migration.js");
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = find(scope, scopeId, key) as Row | undefined;
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
function host(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: 1,
|
||||
port: 22,
|
||||
connection_type: "ssh",
|
||||
enable_rdp: 0,
|
||||
enable_vnc: 0,
|
||||
enable_telnet: 0,
|
||||
rdp_port: 3389,
|
||||
vnc_port: 5900,
|
||||
telnet_port: 23,
|
||||
rdp_security: null,
|
||||
rdp_ignore_cert: 0,
|
||||
security: null,
|
||||
ignore_cert: 0,
|
||||
guacamole_config: null,
|
||||
enable_terminal_toolbar: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.coreSettings.clear();
|
||||
state.pluginRows.length = 0;
|
||||
state.hostRows.length = 0;
|
||||
state.userRows.length = 0;
|
||||
state.statements.length = 0;
|
||||
state.pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runRemoteDesktopSettingsMigration", () => {
|
||||
it("moves the admin switch and guacd address", async () => {
|
||||
state.coreSettings.set("guac_enabled", "false");
|
||||
state.coreSettings.set("guac_url", "guacd:4822");
|
||||
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual(["guac_enabled", "guac_url"]);
|
||||
expect(value("admin", null, "enabled")).toBe(false);
|
||||
expect(value("admin", null, "guacdUrl")).toBe("guacd:4822");
|
||||
});
|
||||
|
||||
it("moves a host's options, reading SQLite's 0 and 1 as booleans", async () => {
|
||||
state.hostRows.push(
|
||||
host({
|
||||
id: 7,
|
||||
enable_rdp: 1,
|
||||
enable_vnc: 0,
|
||||
rdp_port: 3390,
|
||||
rdp_security: "nla",
|
||||
rdp_ignore_cert: 1,
|
||||
guacamole_config: JSON.stringify({ "color-depth": 24 }),
|
||||
enable_terminal_toolbar: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(result.hostsMoved).toBe(1);
|
||||
expect(value("host", "7", "enableRdp")).toBe(true);
|
||||
expect(value("host", "7", "enableVnc")).toBeUndefined();
|
||||
expect(value("host", "7", "rdpPort")).toBe(3390);
|
||||
expect(value("host", "7", "rdpSecurity")).toBe("nla");
|
||||
expect(value("host", "7", "rdpIgnoreCert")).toBe(true);
|
||||
expect(value("host", "7", "guacamoleConfig")).toEqual({
|
||||
"color-depth": 24,
|
||||
});
|
||||
expect(value("host", "7", "enableToolbar")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a plain SSH host alone", async () => {
|
||||
state.hostRows.push(host({ id: 3 }));
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
expect(result.hostsMoved).toBe(0);
|
||||
expect(state.pluginRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads real booleans too, as Postgres returns them", async () => {
|
||||
state.hostRows.push(host({ id: 8, enable_vnc: true, vnc_port: 5901 }));
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
expect(value("host", "8", "enableVnc")).toBe(true);
|
||||
expect(value("host", "8", "vncPort")).toBe(5901);
|
||||
});
|
||||
|
||||
it("moves each user's RDP defaults", async () => {
|
||||
state.userRows.push({
|
||||
user_id: "u1",
|
||||
rdp_defaults: JSON.stringify({
|
||||
colorDepth: 24,
|
||||
resizeMethod: "reconnect",
|
||||
disableCopy: true,
|
||||
enableWallpaper: false,
|
||||
}),
|
||||
});
|
||||
state.userRows.push({ user_id: "u2", rdp_defaults: null });
|
||||
|
||||
const result = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(result.usersMoved).toBe(1);
|
||||
expect(value("user", "u1", "colorDepth")).toBe("24");
|
||||
expect(value("user", "u1", "resizeMethod")).toBe("reconnect");
|
||||
expect(value("user", "u1", "disableCopy")).toBe("on");
|
||||
expect(value("user", "u1", "enableWallpaper")).toBe("off");
|
||||
});
|
||||
|
||||
it("changes nothing when run twice", async () => {
|
||||
state.coreSettings.set("guac_url", "guacd:4822");
|
||||
state.hostRows.push(host({ id: 7, enable_rdp: 1, rdp_port: 3390 }));
|
||||
state.userRows.push({
|
||||
user_id: "u1",
|
||||
rdp_defaults: JSON.stringify({ colorDepth: 16 }),
|
||||
});
|
||||
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
const firstRun = JSON.stringify(state.pluginRows);
|
||||
// Changed after the first run: must not be overwritten by the old values.
|
||||
find("host", "7", "rdpPort")!.value = JSON.stringify(4000);
|
||||
const second = await runRemoteDesktopSettingsMigration();
|
||||
|
||||
expect(second.moved).toEqual([]);
|
||||
expect(second.usersMoved).toBe(0);
|
||||
expect(second.hostsMoved).toBe(0);
|
||||
expect(second.hostsSkipped).toBe(1);
|
||||
expect(value("host", "7", "rdpPort")).toBe(4000);
|
||||
expect(JSON.parse(firstRun)).toHaveLength(state.pluginRows.length);
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.coreSettings.set("guac_url", "guacd:4822");
|
||||
state.hostRows.push(host({ id: 7, enable_rdp: 1 }));
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
expect(state.pluginRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hostSettingsFromRow", () => {
|
||||
it("turns a connection_type-only host into its protocol switch and port", () => {
|
||||
expect(
|
||||
hostSettingsFromRow(
|
||||
host({ connection_type: "vnc", port: 5905, vnc_port: null }),
|
||||
),
|
||||
).toEqual({ enableVnc: true, vncPort: 5905 });
|
||||
});
|
||||
|
||||
it("folds the older security and ignore_cert columns in", () => {
|
||||
expect(
|
||||
hostSettingsFromRow(
|
||||
host({
|
||||
enable_rdp: 1,
|
||||
rdp_security: null,
|
||||
security: "tls",
|
||||
rdp_ignore_cert: null,
|
||||
ignore_cert: 1,
|
||||
}),
|
||||
),
|
||||
).toEqual({ enableRdp: true, rdpSecurity: "tls", rdpIgnoreCert: true });
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* The session logging host-column migration.
|
||||
*
|
||||
* The legacy column defaulted to true, the plugin's enable switch defaults
|
||||
* to false, so every host needs an explicit row, not just the ones that
|
||||
* turned it off. Running it twice must not duplicate or clobber what it
|
||||
* moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_session_logging: boolean | number | null;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () => hostRows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runSessionRecordingSettingsMigration } =
|
||||
await import("../../upgrade/session-recording-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runSessionRecordingSettingsMigration", () => {
|
||||
it("moves an enabled host's switch into plugin settings", async () => {
|
||||
hostRows.push({ id: 1, enable_session_logging: true });
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableSessionRecording")).toBe(true);
|
||||
expect(
|
||||
settingsRows.every((row) => row.pluginId === "session-recording"),
|
||||
).toBe(true);
|
||||
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
|
||||
});
|
||||
|
||||
it("moves a disabled host's switch too, since the defaults flipped", async () => {
|
||||
hostRows.push({ id: 3, enable_session_logging: false });
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(3, "enableSessionRecording")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads SQLite 0 and 1 as off and on", async () => {
|
||||
hostRows.push({ id: 6, enable_session_logging: 0 });
|
||||
hostRows.push({ id: 7, enable_session_logging: 1 });
|
||||
|
||||
await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(storedValue(6, "enableSessionRecording")).toBe(false);
|
||||
expect(storedValue(7, "enableSessionRecording")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a null legacy value as on, matching the old column default", async () => {
|
||||
hostRows.push({ id: 4, enable_session_logging: null });
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(4, "enableSessionRecording")).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install", async () => {
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({ id: 2, enable_session_logging: true });
|
||||
|
||||
await runSessionRecordingSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
hostRows.push({ id: 5, enable_session_logging: true });
|
||||
await pluginSettingsRepository.set(
|
||||
"session-recording",
|
||||
"host",
|
||||
"5",
|
||||
"enableSessionRecording",
|
||||
"false",
|
||||
);
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(storedValue(5, "enableSessionRecording")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* The session sharing settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: an instance with sharing switched off, and a
|
||||
* host with sharing switched off, must stay off once the plugin reads its own
|
||||
* settings. Running it twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const blockedHostIds: number[] = [];
|
||||
const coreSettings = new Map<string, string>();
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () => blockedHostIds.map((id) => ({ id })),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runSessionSharingSettingsMigration } =
|
||||
await import("../../upgrade/session-sharing-settings-migration.js");
|
||||
|
||||
function stored(scopeId: string | null, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === scopeId && entry.key === key,
|
||||
);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
blockedHostIds.length = 0;
|
||||
coreSettings.clear();
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runSessionSharingSettingsMigration", () => {
|
||||
it("moves a disabled global switch into the admin setting", async () => {
|
||||
coreSettings.set("session_sharing_globally_enabled", "false");
|
||||
|
||||
const result = await runSessionSharingSettingsMigration();
|
||||
|
||||
expect(result.movedGlobal).toBe(true);
|
||||
expect(stored(null, "globallyEnabled")).toBe(false);
|
||||
expect(settingsRows[0]).toMatchObject({
|
||||
pluginId: "session-sharing",
|
||||
scope: "admin",
|
||||
});
|
||||
});
|
||||
|
||||
it("moves hosts that turned sharing off into host settings", async () => {
|
||||
blockedHostIds.push(4, 9);
|
||||
|
||||
const result = await runSessionSharingSettingsMigration();
|
||||
|
||||
expect(result.movedHosts).toBe(2);
|
||||
expect(stored("4", "allowSessionSharing")).toBe(false);
|
||||
expect(stored("9", "allowSessionSharing")).toBe(false);
|
||||
});
|
||||
|
||||
it("writes nothing when sharing was on everywhere", async () => {
|
||||
coreSettings.set("session_sharing_globally_enabled", "true");
|
||||
|
||||
const result = await runSessionSharingSettingsMigration();
|
||||
|
||||
expect(result).toEqual({ movedGlobal: false, movedHosts: 0, skipped: 0 });
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
coreSettings.set("session_sharing_globally_enabled", "false");
|
||||
blockedHostIds.push(2);
|
||||
|
||||
await runSessionSharingSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
const second = await runSessionSharingSettingsMigration();
|
||||
|
||||
expect(second.movedGlobal).toBe(false);
|
||||
expect(second.movedHosts).toBe(0);
|
||||
expect(second.skipped).toBe(2);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
blockedHostIds.push(5);
|
||||
await pluginSettingsRepository.set(
|
||||
"session-sharing",
|
||||
"host",
|
||||
"5",
|
||||
"allowSessionSharing",
|
||||
"true",
|
||||
);
|
||||
|
||||
const result = await runSessionSharingSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(stored("5", "allowSessionSharing")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* The snippets settings migration: host quick actions, the startup snippet
|
||||
* and the two snippet user preferences must come through into the snippets
|
||||
* plugin's settings, and running it twice must change nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const pluginRows: Row[] = [];
|
||||
const hostRows: Array<Record<string, unknown>> = [];
|
||||
const preferenceRows: Array<Record<string, unknown>> = [];
|
||||
let pluginInstalled = true;
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === "snippets" &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
pluginInstalled && id === "snippets" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) existing.value = value;
|
||||
else pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async (query: unknown) =>
|
||||
JSON.stringify(query).includes("user_preferences")
|
||||
? preferenceRows
|
||||
: hostRows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runSnippetsSettingsMigration, snippetHostSettingsFromRow } =
|
||||
await import("../../upgrade/snippets-settings-migration.js");
|
||||
|
||||
function value(scope: string, scopeId: string, key: string): unknown {
|
||||
const row = find(scope, scopeId, key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pluginRows.length = 0;
|
||||
hostRows.length = 0;
|
||||
preferenceRows.length = 0;
|
||||
pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("snippetHostSettingsFromRow", () => {
|
||||
it("reads quick actions and the startup snippet, dropping broken rows", () => {
|
||||
expect(
|
||||
snippetHostSettingsFromRow({
|
||||
quick_actions: JSON.stringify([
|
||||
{ name: "Restart", snippetId: 3 },
|
||||
{ name: "Broken", snippetId: "x" },
|
||||
{ name: "Str", snippetId: "4" },
|
||||
]),
|
||||
terminal_config: JSON.stringify({ fontSize: 14, startupSnippetId: 9 }),
|
||||
}),
|
||||
).toEqual({
|
||||
quickActions: [
|
||||
{ name: "Restart", snippetId: 3 },
|
||||
{ name: "Str", snippetId: 4 },
|
||||
],
|
||||
startupSnippetId: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads nothing from a row without either", () => {
|
||||
expect(
|
||||
snippetHostSettingsFromRow({
|
||||
quick_actions: "[]",
|
||||
terminal_config: JSON.stringify({ fontSize: 14 }),
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSnippetsSettingsMigration", () => {
|
||||
it("moves host and user values once", async () => {
|
||||
hostRows.push(
|
||||
{
|
||||
id: 1,
|
||||
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
|
||||
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
|
||||
},
|
||||
{ id: 2, quick_actions: null, terminal_config: "{}" },
|
||||
);
|
||||
preferenceRows.push(
|
||||
{ user_id: "u1", confirm_snippet_execution: 1, folders_collapsed: 0 },
|
||||
{
|
||||
user_id: "u2",
|
||||
confirm_snippet_execution: null,
|
||||
folders_collapsed: null,
|
||||
},
|
||||
);
|
||||
|
||||
const first = await runSnippetsSettingsMigration();
|
||||
expect(first).toEqual({ hostsMoved: 1, hostsSkipped: 0, usersMoved: 1 });
|
||||
expect(value("host", "1", "quickActions")).toEqual([
|
||||
{ name: "Up", snippetId: 2 },
|
||||
]);
|
||||
expect(value("host", "1", "startupSnippetId")).toBe(5);
|
||||
expect(value("user", "u1", "confirmExecution")).toBe(true);
|
||||
expect(value("user", "u1", "foldersCollapsed")).toBe(false);
|
||||
expect(find("user", "u2", "confirmExecution")).toBeUndefined();
|
||||
|
||||
const snapshot = JSON.stringify(pluginRows);
|
||||
const second = await runSnippetsSettingsMigration();
|
||||
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1, usersMoved: 0 });
|
||||
expect(JSON.stringify(pluginRows)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("never overwrites what the user already changed in the plugin", async () => {
|
||||
pluginRows.push({
|
||||
pluginId: "snippets",
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "startupSnippetId",
|
||||
value: "7",
|
||||
});
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
|
||||
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
|
||||
});
|
||||
await runSnippetsSettingsMigration();
|
||||
expect(value("host", "1", "startupSnippetId")).toBe(7);
|
||||
expect(find("host", "1", "quickActions")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing while the plugin is not installed", async () => {
|
||||
pluginInstalled = false;
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
|
||||
terminal_config: null,
|
||||
});
|
||||
await runSnippetsSettingsMigration();
|
||||
expect(pluginRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* The terminal's look and behavior moving out of ssh_data.terminal_config and
|
||||
* user_preferences into the ssh-terminal plugin's settings. Lossless, and a
|
||||
* second run changes nothing.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
rows: [] as Row[],
|
||||
installed: true,
|
||||
}));
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
state.rows.find(
|
||||
(row) =>
|
||||
row.pluginId === "ssh-terminal" &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.installed && id === "ssh-terminal" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAllForScopeIds: async (scope: string, scopeIds: string[]) =>
|
||||
state.rows.filter(
|
||||
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
|
||||
),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) existing.value = value;
|
||||
else state.rows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
runSshTerminalLookMigration,
|
||||
hostSettingsFromTerminalConfig,
|
||||
userSettingsFromPreferences,
|
||||
} = await import("../../upgrade/ssh-terminal-look-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
state.rows.length = 0;
|
||||
state.installed = true;
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, terminal_config TEXT);
|
||||
CREATE TABLE user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
terminal_defaults TEXT,
|
||||
custom_themes TEXT,
|
||||
command_autocomplete INTEGER
|
||||
);
|
||||
`);
|
||||
state.db = drizzle(sqlite);
|
||||
});
|
||||
|
||||
function host(id: number, key: string): unknown {
|
||||
const row = find("host", String(id), key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
function user(id: string, key: string): unknown {
|
||||
const row = find("user", id, key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
describe("hostSettingsFromTerminalConfig", () => {
|
||||
it("keeps a host that followed the user's look following it", () => {
|
||||
expect(
|
||||
hostSettingsFromTerminalConfig(
|
||||
JSON.stringify({
|
||||
autoTmux: true,
|
||||
localEcho: "on",
|
||||
keepaliveInterval: 30,
|
||||
startupSnippetId: 3,
|
||||
}),
|
||||
),
|
||||
).toEqual({ autoTmux: true, localEcho: "on" });
|
||||
});
|
||||
|
||||
it("opts a host with its own look out of the user's defaults", () => {
|
||||
expect(
|
||||
hostSettingsFromTerminalConfig({
|
||||
theme: "Termix Dark",
|
||||
fontSize: "16",
|
||||
cursorStyle: "wobble",
|
||||
bellStyle: "visual",
|
||||
}),
|
||||
).toEqual({
|
||||
inheritAppearance: false,
|
||||
theme: "termix",
|
||||
fontSize: 16,
|
||||
bellStyle: "visual",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads nothing out of garbage", () => {
|
||||
expect(hostSettingsFromTerminalConfig("not json")).toEqual({});
|
||||
expect(hostSettingsFromTerminalConfig(null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("userSettingsFromPreferences", () => {
|
||||
it("takes the defaults, saved themes and autocomplete", () => {
|
||||
expect(
|
||||
userSettingsFromPreferences({
|
||||
terminal_defaults: JSON.stringify({ fontSize: 18, theme: "nord" }),
|
||||
custom_themes: JSON.stringify([
|
||||
{ id: "a", name: "Mine", colors: { background: "#000" } },
|
||||
]),
|
||||
command_autocomplete: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
terminalDefaults: { fontSize: 18, theme: "nord" },
|
||||
customThemes: [{ id: "a", name: "Mine", colors: { background: "#000" } }],
|
||||
commandAutocomplete: true,
|
||||
});
|
||||
expect(userSettingsFromPreferences({ command_autocomplete: null })).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSshTerminalLookMigration", () => {
|
||||
function seed() {
|
||||
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(
|
||||
1,
|
||||
JSON.stringify({
|
||||
theme: "dracula",
|
||||
fontSize: 18,
|
||||
autoTmux: true,
|
||||
sudoPasswordAutoFill: true,
|
||||
keepaliveInterval: 30,
|
||||
}),
|
||||
);
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
|
||||
.run(2, JSON.stringify({ passwordPromptAutoFill: false }));
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
|
||||
.run(3, JSON.stringify({ keepaliveInterval: 10 }));
|
||||
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(4, null);
|
||||
sqlite
|
||||
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
|
||||
.run(
|
||||
"u1",
|
||||
JSON.stringify({ cursorBlink: false }),
|
||||
JSON.stringify([
|
||||
{ id: "t", name: "T", colors: { background: "#111" } },
|
||||
]),
|
||||
1,
|
||||
);
|
||||
sqlite
|
||||
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
|
||||
.run("u2", null, null, null);
|
||||
}
|
||||
|
||||
it("copies each host's terminal keys and each user's terminal preferences", async () => {
|
||||
seed();
|
||||
const result = await runSshTerminalLookMigration();
|
||||
|
||||
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0, usersMoved: 1 });
|
||||
expect(host(1, "inheritAppearance")).toBe(false);
|
||||
expect(host(1, "theme")).toBe("dracula");
|
||||
expect(host(1, "fontSize")).toBe(18);
|
||||
expect(host(1, "autoTmux")).toBe(true);
|
||||
expect(host(1, "sudoPasswordAutoFill")).toBe(true);
|
||||
expect(host(1, "keepaliveInterval")).toBeUndefined();
|
||||
expect(host(2, "passwordPromptAutoFill")).toBe(false);
|
||||
expect(host(2, "inheritAppearance")).toBeUndefined();
|
||||
expect(host(3, "keepaliveInterval")).toBeUndefined();
|
||||
expect(user("u1", "terminalDefaults")).toEqual({ cursorBlink: false });
|
||||
expect(user("u1", "customThemes")).toHaveLength(1);
|
||||
expect(user("u1", "commandAutocomplete")).toBe(true);
|
||||
expect(state.rows.some((row) => row.scopeId === "u2")).toBe(false);
|
||||
});
|
||||
|
||||
it("changes nothing on a second run, even after values were edited", async () => {
|
||||
seed();
|
||||
await runSshTerminalLookMigration();
|
||||
find("host", "1", "fontSize")!.value = JSON.stringify(12);
|
||||
find("user", "u1", "commandAutocomplete")!.value = JSON.stringify(false);
|
||||
const before = JSON.stringify(state.rows);
|
||||
|
||||
const second = await runSshTerminalLookMigration();
|
||||
|
||||
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 2, usersMoved: 0 });
|
||||
expect(JSON.stringify(state.rows)).toBe(before);
|
||||
expect(host(1, "fontSize")).toBe(12);
|
||||
});
|
||||
|
||||
it("waits for the plugin row", async () => {
|
||||
seed();
|
||||
state.installed = false;
|
||||
expect(await runSshTerminalLookMigration()).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
usersMoved: 0,
|
||||
});
|
||||
expect(state.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("copies nothing from a database without the columns", async () => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
state.db = drizzle(sqlite);
|
||||
expect(await runSshTerminalLookMigration()).toEqual({
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
usersMoved: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* The terminal macros migration: a user's saved macros must come through
|
||||
* into the ssh-terminal plugin's macros setting, and running it twice must
|
||||
* change nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const pluginRows: Row[] = [];
|
||||
const preferenceRows: Array<Record<string, unknown>> = [];
|
||||
|
||||
const find = (scopeId: string, key: string) =>
|
||||
pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === "ssh-terminal" &&
|
||||
row.scope === "user" &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) => (id === "ssh-terminal" ? { id } : null),
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (_p: string, _s: string, scopeId: string, key: string) =>
|
||||
find(scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scopeId, key);
|
||||
if (existing) existing.value = value;
|
||||
else pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({ all: async () => preferenceRows }),
|
||||
}));
|
||||
|
||||
const { runSshTerminalMacrosMigration, macrosFromPreferences } =
|
||||
await import("../../upgrade/ssh-terminal-macros-migration.js");
|
||||
|
||||
const macro = { id: "m1", name: "Deploy", steps: [{ id: "s", type: "send" }] };
|
||||
|
||||
beforeEach(() => {
|
||||
pluginRows.length = 0;
|
||||
preferenceRows.length = 0;
|
||||
});
|
||||
|
||||
describe("macrosFromPreferences", () => {
|
||||
it("keeps valid macros and drops the rest", () => {
|
||||
expect(
|
||||
macrosFromPreferences(JSON.stringify([macro, { id: 1 }, "x"])),
|
||||
).toEqual([macro]);
|
||||
expect(macrosFromPreferences("[]")).toBeNull();
|
||||
expect(macrosFromPreferences("{bad")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSshTerminalMacrosMigration", () => {
|
||||
it("moves each user's macros once", async () => {
|
||||
preferenceRows.push(
|
||||
{ user_id: "u1", terminal_macros: JSON.stringify([macro]) },
|
||||
{ user_id: "u2", terminal_macros: "[]" },
|
||||
);
|
||||
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 1 });
|
||||
expect(JSON.parse(find("u1", "macros")!.value!)).toEqual([macro]);
|
||||
expect(find("u2", "macros")).toBeUndefined();
|
||||
|
||||
const snapshot = JSON.stringify(pluginRows);
|
||||
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 0 });
|
||||
expect(JSON.stringify(pluginRows)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("leaves a user who already has macros in the plugin alone", async () => {
|
||||
pluginRows.push({
|
||||
pluginId: "ssh-terminal",
|
||||
scope: "user",
|
||||
scopeId: "u1",
|
||||
key: "macros",
|
||||
value: "[]",
|
||||
});
|
||||
preferenceRows.push({
|
||||
user_id: "u1",
|
||||
terminal_macros: JSON.stringify([macro]),
|
||||
});
|
||||
await runSshTerminalMacrosMigration();
|
||||
expect(find("u1", "macros")!.value).toBe("[]");
|
||||
});
|
||||
});
|
||||
@@ -1,241 +0,0 @@
|
||||
/**
|
||||
* The SSH terminal settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: an install's session timeout, command history
|
||||
* switch, touch tuning and image storage settings, and every host that turned
|
||||
* a terminal switch off, must come through into the plugin's own settings.
|
||||
* Running it twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_terminal: number | null;
|
||||
enable_terminal_toolbar: number | null;
|
||||
enable_command_history: number | null;
|
||||
}
|
||||
|
||||
const coreSettings = new Map<string, string>();
|
||||
const pluginRows: Row[] = [];
|
||||
const hostRows: HostRow[] = [];
|
||||
let pluginInstalled = true;
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
pluginInstalled && id === "ssh-terminal" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.encrypted = encrypted;
|
||||
return;
|
||||
}
|
||||
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () =>
|
||||
hostRows.filter(
|
||||
(row) =>
|
||||
row.enable_terminal === 0 ||
|
||||
row.enable_terminal_toolbar === 0 ||
|
||||
row.enable_command_history === 0,
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (plaintext: string) =>
|
||||
plaintext.startsWith("sysenc:") ? plaintext : `sysenc:${plaintext}`,
|
||||
}));
|
||||
|
||||
const { runSshTerminalSettingsMigration } =
|
||||
await import("../../upgrade/ssh-terminal-settings-migration.js");
|
||||
|
||||
function admin(key: string): unknown {
|
||||
const row = find("ssh-terminal", "admin", null, key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
function host(id: number, key: string): unknown {
|
||||
const row = find("ssh-terminal", "host", String(id), key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
coreSettings.clear();
|
||||
pluginRows.length = 0;
|
||||
hostRows.length = 0;
|
||||
pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runSshTerminalSettingsMigration", () => {
|
||||
it("moves the admin settings with their types, the local dir as a secret", async () => {
|
||||
coreSettings.set("terminal_session_timeout_minutes", "120");
|
||||
coreSettings.set("terminal_session_persistence_enabled", "false");
|
||||
coreSettings.set("command_history_enabled", "false");
|
||||
coreSettings.set(
|
||||
"touch_input_settings",
|
||||
JSON.stringify({ enabled: false, dragThresholdPx: 12 }),
|
||||
);
|
||||
coreSettings.set("terminal_image_storage_mode", "local");
|
||||
coreSettings.set("terminal_image_local_dir", "/data/images");
|
||||
coreSettings.set("terminal_image_max_count", "50");
|
||||
|
||||
const result = await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(result.moved).toHaveLength(7);
|
||||
expect(admin("sessionTimeoutMinutes")).toBe(120);
|
||||
expect(admin("sessionPersistence")).toBe(false);
|
||||
expect(admin("commandHistoryEnabled")).toBe(false);
|
||||
expect(admin("touchInput")).toEqual({
|
||||
enabled: false,
|
||||
dragThresholdPx: 12,
|
||||
});
|
||||
expect(admin("imageStorageMode")).toBe("local");
|
||||
expect(admin("imageMaxCount")).toBe(50);
|
||||
const localDir = find("ssh-terminal", "admin", null, "imageLocalDir");
|
||||
expect(localDir?.encrypted).toBe(true);
|
||||
expect(JSON.parse(localDir!.value!)).toBe("sysenc:/data/images");
|
||||
});
|
||||
|
||||
it("moves the terminal half of the admin host defaults to the new-host settings", async () => {
|
||||
coreSettings.set(
|
||||
"host_defaults",
|
||||
JSON.stringify({
|
||||
useSocks5: true,
|
||||
fontSize: 18,
|
||||
fontFamily: "Fira Code",
|
||||
theme: "Termix Dark",
|
||||
cursorStyle: "wobble",
|
||||
cursorBlink: false,
|
||||
autoTmux: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(admin("newHostFontSize")).toBe(18);
|
||||
expect(admin("newHostFontFamily")).toBe("Fira Code");
|
||||
expect(admin("newHostTheme")).toBe("termix");
|
||||
expect(admin("newHostCursorStyle")).toBeUndefined();
|
||||
expect(admin("newHostCursorBlink")).toBe(false);
|
||||
expect(admin("newHostAutoTmux")).toBe(true);
|
||||
});
|
||||
|
||||
it("copies each host that switched something off, keeping the rest on", async () => {
|
||||
hostRows.push(
|
||||
{
|
||||
id: 1,
|
||||
enable_terminal: 1,
|
||||
enable_terminal_toolbar: 0,
|
||||
enable_command_history: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
enable_terminal: 0,
|
||||
enable_terminal_toolbar: 1,
|
||||
enable_command_history: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
enable_terminal: 1,
|
||||
enable_terminal_toolbar: 1,
|
||||
enable_command_history: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(result.hostsMoved).toBe(2);
|
||||
expect(host(1, "enableTerminal")).toBe(true);
|
||||
expect(host(1, "enableTerminalToolbar")).toBe(false);
|
||||
expect(host(1, "enableCommandHistory")).toBe(true);
|
||||
expect(host(2, "enableTerminal")).toBe(false);
|
||||
expect(host(2, "enableCommandHistory")).toBe(false);
|
||||
// All on is the plugin's default, so it needs no row.
|
||||
expect(host(3, "enableTerminal")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("changes nothing on a second run, even after the admin changed a value", async () => {
|
||||
coreSettings.set("terminal_session_timeout_minutes", "120");
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
enable_terminal: 0,
|
||||
enable_terminal_toolbar: 1,
|
||||
enable_command_history: 1,
|
||||
});
|
||||
await runSshTerminalSettingsMigration();
|
||||
|
||||
const changed = find(
|
||||
"ssh-terminal",
|
||||
"admin",
|
||||
null,
|
||||
"sessionTimeoutMinutes",
|
||||
);
|
||||
changed!.value = JSON.stringify(15);
|
||||
const rowsBefore = pluginRows.length;
|
||||
|
||||
const second = await runSshTerminalSettingsMigration();
|
||||
|
||||
expect(second.moved).toEqual([]);
|
||||
expect(second.hostsMoved).toBe(0);
|
||||
expect(second.hostsSkipped).toBe(1);
|
||||
expect(pluginRows).toHaveLength(rowsBefore);
|
||||
expect(admin("sessionTimeoutMinutes")).toBe(15);
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
pluginInstalled = false;
|
||||
coreSettings.set("terminal_session_timeout_minutes", "120");
|
||||
const result = await runSshTerminalSettingsMigration();
|
||||
expect(result.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* The silent sign-in default moving from core settings into the sso plugin's
|
||||
* admin settings.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
core: new Map<string, string>(),
|
||||
plugin: new Map<string, string | null>(),
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "sso" ? { id } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.core.get(key) ?? null,
|
||||
delete: async (key: string) => {
|
||||
state.core.delete(key);
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
) => (state.plugin.has(key) ? { value: state.plugin.get(key) } : null),
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.plugin.set(key, value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runSsoSettingsMigration } from "../../upgrade/sso-settings-migration.js";
|
||||
|
||||
beforeEach(() => {
|
||||
state.core = new Map();
|
||||
state.plugin = new Map();
|
||||
state.pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runSsoSettingsMigration", () => {
|
||||
it("moves the core value into the sso admin setting", async () => {
|
||||
state.core.set("oidc_silent_login_default", "true");
|
||||
expect(await runSsoSettingsMigration()).toEqual({ moved: true });
|
||||
expect(state.plugin.get("silentLoginDefault")).toBe("true");
|
||||
expect(state.core.has("oidc_silent_login_default")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a value already in plugin settings", async () => {
|
||||
state.core.set("oidc_silent_login_default", "true");
|
||||
state.plugin.set("silentLoginDefault", "false");
|
||||
await runSsoSettingsMigration();
|
||||
expect(state.plugin.get("silentLoginDefault")).toBe("false");
|
||||
expect(state.core.has("oidc_silent_login_default")).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing twice or without the plugin", async () => {
|
||||
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
|
||||
state.core.set("oidc_silent_login_default", "true");
|
||||
state.pluginInstalled = false;
|
||||
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
|
||||
expect(state.core.get("oidc_silent_login_default")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* The Step CA settings migration: a 2.8 CA configuration and its private
|
||||
* endpoint allowlist come through into the step-ca plugin's admin settings,
|
||||
* the old redirect URI stays in use, and a second run changes nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
legacy: {} as Record<string, string>,
|
||||
pluginRows: [] as Array<{ key: string; value: string | null }>,
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "step-ca" ? { id } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.legacy[key] ?? null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
) => state.pluginRows.find((row) => row.key === key) ?? null,
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
|
||||
state.pluginRows.push({ key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runStepCaSettingsMigration } from "../../upgrade/step-ca-settings-migration.js";
|
||||
|
||||
const value = (key: string) => {
|
||||
const row = state.pluginRows.find((candidate) => candidate.key === key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.legacy = {
|
||||
step_ca_url: "https://ca.internal:9000",
|
||||
step_ca_fingerprint: "a".repeat(64),
|
||||
step_ca_provisioner: "oidc",
|
||||
step_ca_private_endpoint_allowlist: JSON.stringify([
|
||||
"ca.internal",
|
||||
"SSO.internal",
|
||||
]),
|
||||
};
|
||||
state.pluginRows = [];
|
||||
state.pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runStepCaSettingsMigration", () => {
|
||||
it("moves the 2.8 settings and keeps the old redirect URI", async () => {
|
||||
const result = await runStepCaSettingsMigration();
|
||||
expect(result.moved).toEqual([
|
||||
"step_ca_url",
|
||||
"step_ca_fingerprint",
|
||||
"step_ca_provisioner",
|
||||
"step_ca_private_endpoint_allowlist",
|
||||
]);
|
||||
expect(result.legacyCallback).toBe(true);
|
||||
expect(value("caUrl")).toBe("https://ca.internal:9000");
|
||||
expect(value("fingerprint")).toBe("a".repeat(64));
|
||||
expect(value("provisioner")).toBe("oidc");
|
||||
expect(value("privateEndpoints")).toBe("ca.internal, sso.internal");
|
||||
expect(value("legacyCallback")).toBe(true);
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
await runStepCaSettingsMigration();
|
||||
const before = JSON.stringify(state.pluginRows);
|
||||
const again = await runStepCaSettingsMigration();
|
||||
expect(again).toEqual({ moved: [], legacyCallback: false });
|
||||
expect(JSON.stringify(state.pluginRows)).toBe(before);
|
||||
});
|
||||
|
||||
it("never overwrites a value set in the plugin", async () => {
|
||||
state.pluginRows = [
|
||||
{ key: "caUrl", value: JSON.stringify("https://new.ca") },
|
||||
{ key: "legacyCallback", value: JSON.stringify(false) },
|
||||
];
|
||||
await runStepCaSettingsMigration();
|
||||
expect(value("caUrl")).toBe("https://new.ca");
|
||||
expect(value("legacyCallback")).toBe(false);
|
||||
expect(value("provisioner")).toBe("oidc");
|
||||
});
|
||||
|
||||
it("leaves an install that never used Step CA alone", async () => {
|
||||
state.legacy = {};
|
||||
const result = await runStepCaSettingsMigration();
|
||||
expect(result).toEqual({ moved: [], legacyCallback: false });
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
await runStepCaSettingsMigration();
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
/**
|
||||
* The Tailscale settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: an install that had a working API key must keep
|
||||
* working without anyone re-entering it. That is the whole point of the test,
|
||||
* and running it twice proves a restart cannot corrupt what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const coreSettings = new Map<string, string>();
|
||||
const pluginRows: Row[] = [];
|
||||
let pluginInstalled = true;
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
const existing = pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.encrypted = encrypted;
|
||||
return;
|
||||
}
|
||||
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
pluginInstalled && id === "tailscale" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
delete: async (key: string) => coreSettings.delete(key),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||
encryptSystemSecret: async (plaintext: string) =>
|
||||
!plaintext || plaintext.startsWith("sysenc:v1:")
|
||||
? plaintext
|
||||
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
||||
decryptSystemSecret: async (stored: string) =>
|
||||
stored.startsWith("sysenc:v1:")
|
||||
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
|
||||
"utf8",
|
||||
)
|
||||
: stored,
|
||||
}));
|
||||
|
||||
const { runTailscaleSettingsMigration } =
|
||||
await import("../../upgrade/tailscale-settings-migration.js");
|
||||
|
||||
function storedValue(key: string): unknown {
|
||||
const row = pluginRows.find((entry) => entry.key === key);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
coreSettings.clear();
|
||||
pluginRows.length = 0;
|
||||
pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runTailscaleSettingsMigration", () => {
|
||||
it("moves an existing key and base URL into plugin settings", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual([
|
||||
"tailscale_api_key",
|
||||
"tailscale_api_base_url",
|
||||
]);
|
||||
expect(storedValue("apiKey")).toMatch(/^sysenc:v1:/);
|
||||
expect(storedValue("apiBaseUrl")).toBe("https://headscale.example");
|
||||
});
|
||||
|
||||
it("encrypts the key, which was stored in plaintext before", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
|
||||
const row = pluginRows.find((entry) => entry.key === "apiKey")!;
|
||||
expect(row.encrypted).toBe(true);
|
||||
expect(row.value).not.toContain("tskey-api-legacy");
|
||||
});
|
||||
|
||||
it("leaves the base URL readable, since it is not a secret", async () => {
|
||||
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
|
||||
const row = pluginRows.find((entry) => entry.key === "apiBaseUrl")!;
|
||||
expect(row.encrypted).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the legacy rows so no plaintext key is left behind", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
|
||||
expect(coreSettings.has("tailscale_api_key")).toBe(false);
|
||||
expect(coreSettings.has("tailscale_api_base_url")).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(pluginRows));
|
||||
|
||||
const second = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(second.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("cannot double-encrypt the key across runs", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
const first = storedValue("apiKey");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
|
||||
expect(storedValue("apiKey")).toBe(first);
|
||||
expect(String(storedValue("apiKey")).match(/sysenc:v1:/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops a stale legacy row when the value was already moved", async () => {
|
||||
// An interrupted run, or a downgrade that rewrote the old key.
|
||||
await pluginSettingsRepository.set(
|
||||
"tailscale",
|
||||
"admin",
|
||||
null,
|
||||
"apiKey",
|
||||
JSON.stringify("sysenc:v1:already"),
|
||||
true,
|
||||
);
|
||||
coreSettings.set("tailscale_api_key", "tskey-plaintext-leftover");
|
||||
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.skipped).toContain("tailscale_api_key");
|
||||
expect(storedValue("apiKey")).toBe("sysenc:v1:already");
|
||||
expect(coreSettings.has("tailscale_api_key")).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install with no legacy rows", async () => {
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips an empty legacy value rather than storing one", async () => {
|
||||
coreSettings.set("tailscale_api_key", "");
|
||||
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for the plugin row, because of the foreign key", async () => {
|
||||
pluginInstalled = false;
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual([]);
|
||||
// Still there for the next boot, once the plugin is seeded.
|
||||
expect(coreSettings.get("tailscale_api_key")).toBe("tskey-api-legacy");
|
||||
});
|
||||
|
||||
it("moves each key independently", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual(["tailscale_api_key"]);
|
||||
expect(storedValue("apiBaseUrl")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* The telemetry settings migration: an instance that turned telemetry off in
|
||||
* 2.8 stays off, keeps its instance id, and a second run changes nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const coreSettings = new Map<string, string>();
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
let pluginInstalled = true;
|
||||
|
||||
const find = (pluginId: string, key: string) =>
|
||||
settingsRows.find((row) => row.pluginId === pluginId && row.key === key);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) => (pluginInstalled ? { id } : null),
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (pluginId: string, _scope: string, _id: null, key: string) =>
|
||||
find(pluginId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
_scope: string,
|
||||
_id: null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, key);
|
||||
if (existing) existing.value = value;
|
||||
else settingsRows.push({ pluginId, key, value });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runTelemetrySettingsMigration } =
|
||||
await import("../../upgrade/telemetry-settings-migration.js");
|
||||
|
||||
const stored = (key: string) => {
|
||||
const row = find("telemetry", key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
describe("runTelemetrySettingsMigration", () => {
|
||||
beforeEach(() => {
|
||||
coreSettings.clear();
|
||||
settingsRows.length = 0;
|
||||
pluginInstalled = true;
|
||||
});
|
||||
|
||||
it("moves a disabled switch and the instance id", async () => {
|
||||
coreSettings.set("analytics_enabled", "false");
|
||||
coreSettings.set("analytics_instance_id", "abc-123");
|
||||
|
||||
const result = await runTelemetrySettingsMigration();
|
||||
|
||||
expect(result).toEqual({ movedEnabled: true, movedInstanceId: true });
|
||||
expect(stored("enabled")).toBe(false);
|
||||
expect(stored("instanceId")).toBe("abc-123");
|
||||
});
|
||||
|
||||
it("writes nothing for an enabled instance without an id", async () => {
|
||||
coreSettings.set("analytics_enabled", "true");
|
||||
await runTelemetrySettingsMigration();
|
||||
expect(settingsRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is idempotent and never overwrites plugin values", async () => {
|
||||
coreSettings.set("analytics_enabled", "false");
|
||||
coreSettings.set("analytics_instance_id", "old");
|
||||
settingsRows.push({
|
||||
pluginId: "telemetry",
|
||||
key: "instanceId",
|
||||
value: JSON.stringify("new"),
|
||||
});
|
||||
|
||||
await runTelemetrySettingsMigration();
|
||||
const second = await runTelemetrySettingsMigration();
|
||||
|
||||
expect(second).toEqual({ movedEnabled: false, movedInstanceId: false });
|
||||
expect(stored("instanceId")).toBe("new");
|
||||
expect(settingsRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does nothing without the plugin row", async () => {
|
||||
pluginInstalled = false;
|
||||
coreSettings.set("analytics_enabled", "false");
|
||||
await runTelemetrySettingsMigration();
|
||||
expect(settingsRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* The tmux monitor host-column migration.
|
||||
*
|
||||
* An upgrade must be lossless: a host with the monitor on must keep it on
|
||||
* once the ssh_data column is dropped. Running it twice must not duplicate
|
||||
* or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_tmux_monitor: boolean;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () => hostRows.filter((row) => row.enable_tmux_monitor),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runTmuxMonitorSettingsMigration } =
|
||||
await import("../../upgrade/tmux-monitor-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runTmuxMonitorSettingsMigration", () => {
|
||||
it("moves a host's enabled switch into plugin settings", async () => {
|
||||
hostRows.push({ id: 1, enable_tmux_monitor: true });
|
||||
|
||||
const result = await runTmuxMonitorSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableTmuxMonitor")).toBe(true);
|
||||
expect(settingsRows.every((row) => row.pluginId === "tmux-monitor")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a disabled host untouched", async () => {
|
||||
hostRows.push({ id: 3, enable_tmux_monitor: false });
|
||||
|
||||
const result = await runTmuxMonitorSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(storedValue(3, "enableTmuxMonitor")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install", async () => {
|
||||
const result = await runTmuxMonitorSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({ id: 2, enable_tmux_monitor: true });
|
||||
|
||||
await runTmuxMonitorSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runTmuxMonitorSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
hostRows.push({ id: 5, enable_tmux_monitor: true });
|
||||
await pluginSettingsRepository.set(
|
||||
"tmux-monitor",
|
||||
"host",
|
||||
"5",
|
||||
"enableTmuxMonitor",
|
||||
"false",
|
||||
);
|
||||
|
||||
const result = await runTmuxMonitorSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(storedValue(5, "enableTmuxMonitor")).toBe(false);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user