feat: drop the 2.8 columns, moves and aliases kept for a downgrade

This commit is contained in:
LukeGus committed 2026-10-05 10:21:11 -05:00
1 parent d3d8269cb7
commit 61f46d2517
196 files changed
+29909 -10511

No files matched your search

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