Harden renderer account IPC boundary
Move secret-bearing account and settings state behind main-process boundaries for Task 1B. Renderer snapshots now expose RendererSettings plus safe account metadata instead of full AppSettings, with provider tokens, passwords, API keys, archive passwords, and notification URLs excluded from renderer-bound state. Add write-only account create, replace, update-secret, and delete IPC commands, validate renderer settings updates against the safe shape, and keep account command results limited to safe settings, safe accounts, and stable account IDs. Preserve existing account behavior while removing renderer secret access: blank replace secrets retain stored main-process secrets, Mega-Debrid API/Web pools stay mode-specific, Debrid-Link key metadata migrates by stable key ID, and delete/enable operations target stable account or provider identities. Remove obsolete renderer-side account status helpers from the old settings snapshot flow. Add focused coverage for all supported renderer account kinds, secret-free UiSnapshot serialization, preload account command forwarding, malformed payload error sanitization, Mega-Debrid preferApi preservation, Debrid-Link key metadata migration, account edit safety, settings UI, debug server settings payloads, link export, and visual fixtures.
This commit is contained in:
+81
-304
@@ -1,305 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import {
|
||||
applyAccountEdit,
|
||||
buildAccountEditCheckSettings,
|
||||
createAccountEditState,
|
||||
removeAccountTarget,
|
||||
validateAccountEdit,
|
||||
validateAccountEditStatuses,
|
||||
type AccountEditTarget
|
||||
} from "../src/renderer/account-edit";
|
||||
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
|
||||
function megaTarget(login: string): AccountEditTarget {
|
||||
return {
|
||||
type: "mega",
|
||||
rowKey: `mega-${getMegaDebridAccountId(login)}`,
|
||||
kind: "megadebrid-api",
|
||||
service: "megadebrid-api",
|
||||
accountId: getMegaDebridAccountId(login)
|
||||
};
|
||||
}
|
||||
|
||||
function debridLinkTarget(token: string): AccountEditTarget {
|
||||
return {
|
||||
type: "debridlink",
|
||||
rowKey: `dl-${getDebridLinkApiKeyId(token)}`,
|
||||
kind: "debridlink-api",
|
||||
service: "debridlink",
|
||||
keyId: getDebridLinkApiKeyId(token)
|
||||
};
|
||||
}
|
||||
|
||||
describe("account-specific editing", () => {
|
||||
it("changes only the selected Mega-Debrid account and preserves sibling order and mode settings", () => {
|
||||
const oldId = getMegaDebridAccountId("second@example.com");
|
||||
const newId = getMegaDebridAccountId("renamed@example.com");
|
||||
const firstId = getMegaDebridAccountId("first@example.com");
|
||||
const webId = getMegaDebridAccountId("web@example.com");
|
||||
const settings = {
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createRendererState } from "../src/main/renderer-state";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import {
|
||||
buildAccountDeleteCommand,
|
||||
buildAccountReplaceCommand,
|
||||
createAccountEditState,
|
||||
validateAccountEdit,
|
||||
type AccountEditTarget
|
||||
} from "../src/renderer/account-edit";
|
||||
|
||||
function megaTarget(identity: string): AccountEditTarget {
|
||||
const accountId = getMegaDebridAccountId(identity);
|
||||
return {
|
||||
type: "mega",
|
||||
rowKey: `mega-megadebrid-api-${accountId}`,
|
||||
kind: "megadebrid-api",
|
||||
service: "megadebrid-api",
|
||||
accountId
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderer-safe account editing", () => {
|
||||
it("opens an existing account without reading its stored secret", () => {
|
||||
const identity = "safe-edit@example.test";
|
||||
const state = createRendererState({
|
||||
...defaultSettings(),
|
||||
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass\nweb@example.com:web-pass",
|
||||
megaDebridApiCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass",
|
||||
megaDebridWebCredentials: "web@example.com:web-pass",
|
||||
megaLogin: "first@example.com",
|
||||
megaPassword: "first-pass",
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridPreferApi: false,
|
||||
megaDebridDisabledAccountIds: [oldId, firstId, webId],
|
||||
megaDebridApiDisabledAccountIds: [oldId, firstId],
|
||||
megaDebridWebDisabledAccountIds: [webId],
|
||||
megaDebridAccountDailyLimitBytes: { [oldId]: 15 * GIB, [firstId]: 9 * GIB },
|
||||
megaDebridAccountDailyUsageBytes: { [oldId]: 4 * GIB, [firstId]: 2 * GIB },
|
||||
megaDebridAccountTotalUsageBytes: { [oldId]: 40 * GIB, [firstId]: 20 * GIB },
|
||||
debridAccountStatuses: {
|
||||
[oldId]: { accountId: oldId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 },
|
||||
[firstId]: { accountId: firstId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
|
||||
}
|
||||
};
|
||||
const state = {
|
||||
...createAccountEditState(megaTarget("second@example.com"), settings),
|
||||
login: "renamed@example.com",
|
||||
password: "new-pass",
|
||||
dailyLimitGb: "25,5"
|
||||
};
|
||||
|
||||
expect(validateAccountEdit(state, settings)).toBeNull();
|
||||
const next = applyAccountEdit(settings, state);
|
||||
|
||||
expect(next.megaCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass\nweb@example.com:web-pass");
|
||||
expect(next.megaDebridApiCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass");
|
||||
expect(next.megaDebridWebCredentials).toBe("web@example.com:web-pass");
|
||||
expect(next.megaLogin).toBe("first@example.com");
|
||||
expect(next.megaPassword).toBe("first-pass");
|
||||
expect(next.megaDebridApiEnabled).toBe(true);
|
||||
expect(next.megaDebridWebEnabled).toBe(true);
|
||||
expect(next.megaDebridPreferApi).toBe(false);
|
||||
expect(next.megaDebridDisabledAccountIds).toEqual([firstId, newId, webId]);
|
||||
expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [firstId]: 9 * GIB, [newId]: Math.floor(25.5 * GIB) });
|
||||
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [firstId]: 2 * GIB });
|
||||
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [firstId]: 20 * GIB });
|
||||
expect(next.debridAccountStatuses).toEqual({
|
||||
[firstId]: { accountId: firstId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Mega-Debrid usage when only the password changes", () => {
|
||||
const id = getMegaDebridAccountId("user@example.com");
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaCredentials: "user@example.com:old-pass",
|
||||
megaLogin: "user@example.com",
|
||||
megaPassword: "old-pass",
|
||||
megaDebridAccountDailyLimitBytes: { [id]: 8 * GIB },
|
||||
megaDebridAccountDailyUsageBytes: { [id]: 3 * GIB },
|
||||
megaDebridAccountTotalUsageBytes: { [id]: 33 * GIB }
|
||||
};
|
||||
const state = { ...createAccountEditState(megaTarget("user@example.com"), settings), password: "new-pass" };
|
||||
const next = applyAccountEdit(settings, state);
|
||||
|
||||
expect(next.megaCredentials).toBe("user@example.com:new-pass");
|
||||
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [id]: 3 * GIB });
|
||||
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [id]: 33 * GIB });
|
||||
});
|
||||
|
||||
it("preserves exact account limits when the rounded display value is left unchanged", () => {
|
||||
const megaLogin = "user@example.com";
|
||||
const megaId = getMegaDebridAccountId(megaLogin);
|
||||
const key = "debrid-link-token";
|
||||
const keyId = getDebridLinkApiKeyId(key);
|
||||
const exactLimit = Math.floor(10.05 * GIB);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaCredentials: `${megaLogin}:pass`,
|
||||
megaLogin,
|
||||
megaPassword: "pass",
|
||||
megaDebridAccountDailyLimitBytes: { [megaId]: exactLimit },
|
||||
debridLinkApiKeys: key,
|
||||
debridLinkApiKeyDailyLimitBytes: { [keyId]: exactLimit }
|
||||
};
|
||||
|
||||
const megaNext = applyAccountEdit(settings, createAccountEditState(megaTarget(megaLogin), settings));
|
||||
const debridLinkNext = applyAccountEdit(settings, createAccountEditState(debridLinkTarget(key), settings));
|
||||
|
||||
expect(megaNext.megaDebridAccountDailyLimitBytes[megaId]).toBe(exactLimit);
|
||||
expect(debridLinkNext.debridLinkApiKeyDailyLimitBytes[keyId]).toBe(exactLimit);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only Mega-Debrid passwords and empty or mismatched check results", () => {
|
||||
const login = "user@example.com";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaCredentials: `${login}:pass`,
|
||||
megaLogin: login,
|
||||
megaPassword: "pass"
|
||||
};
|
||||
const state = { ...createAccountEditState(megaTarget(login), settings), password: " " };
|
||||
|
||||
expect(validateAccountEdit(state, settings)).toMatch(/Passwort/i);
|
||||
expect(validateAccountEditStatuses(state, [])).toMatch(/keinen Account/i);
|
||||
expect(validateAccountEditStatuses(state, [{
|
||||
accountId: "mda_other",
|
||||
provider: "megadebrid",
|
||||
label: "Account 1",
|
||||
maskedLogin: "ot***er",
|
||||
valid: true,
|
||||
isPremium: true,
|
||||
premiumUntilMs: null,
|
||||
message: "OK",
|
||||
checkedAt: 1
|
||||
}])).toMatch(/falschen Account/i);
|
||||
});
|
||||
|
||||
it("rejects duplicate Mega-Debrid logins and missing row targets", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass"
|
||||
};
|
||||
const duplicate = {
|
||||
...createAccountEditState(megaTarget("second@example.com"), settings),
|
||||
login: "first@example.com"
|
||||
};
|
||||
|
||||
expect(validateAccountEdit(duplicate, settings)).toMatch(/bereits vorhanden/i);
|
||||
expect(() => createAccountEditState(megaTarget("missing@example.com"), settings)).toThrow(/nicht gefunden/i);
|
||||
});
|
||||
|
||||
it("replaces only the selected Debrid-Link key and migrates its own metadata", () => {
|
||||
const keyA = "token-a";
|
||||
const keyB = "token-b";
|
||||
const keyC = "token-c";
|
||||
const newKey = "token-b-new";
|
||||
const idA = getDebridLinkApiKeyId(keyA);
|
||||
const idB = getDebridLinkApiKeyId(keyB);
|
||||
const idNew = getDebridLinkApiKeyId(newKey);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
debridLinkApiKeys: `${keyA}\n${keyB}\n${keyC}`,
|
||||
debridLinkDisabledKeyIds: [idB, idA],
|
||||
debridLinkApiKeyDailyLimitBytes: { [idA]: 5 * GIB, [idB]: 10 * GIB },
|
||||
debridLinkApiKeyDailyUsageBytes: { [idA]: 2 * GIB, [idB]: 4 * GIB },
|
||||
debridLinkApiKeyTotalUsageBytes: { [idA]: 12 * GIB, [idB]: 24 * GIB }
|
||||
};
|
||||
const state = {
|
||||
...createAccountEditState(debridLinkTarget(keyB), settings),
|
||||
token: newKey,
|
||||
dailyLimitGb: "12"
|
||||
};
|
||||
const next = applyAccountEdit(settings, state);
|
||||
|
||||
expect(next.debridLinkApiKeys).toBe(`${keyA}\n${newKey}\n${keyC}`);
|
||||
expect(next.debridLinkDisabledKeyIds).toEqual([idA, idNew]);
|
||||
expect(next.debridLinkApiKeyDailyLimitBytes).toEqual({ [idA]: 5 * GIB, [idNew]: 12 * GIB });
|
||||
expect(next.debridLinkApiKeyDailyUsageBytes).toEqual({ [idA]: 2 * GIB });
|
||||
expect(next.debridLinkApiKeyTotalUsageBytes).toEqual({ [idA]: 12 * GIB });
|
||||
});
|
||||
|
||||
it("edits a single login without changing unrelated credentials", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
ddownloadLogin: "old@example.com",
|
||||
ddownloadPassword: "old-pass",
|
||||
linkSnappyLogin: "keep@example.com",
|
||||
linkSnappyPassword: "keep-pass"
|
||||
};
|
||||
const target: AccountEditTarget = {
|
||||
type: "single",
|
||||
rowKey: "svc-ddownload",
|
||||
kind: "ddownload-login",
|
||||
service: "ddownload",
|
||||
provider: "ddownload"
|
||||
};
|
||||
const state = {
|
||||
...createAccountEditState(target, settings),
|
||||
login: "new@example.com",
|
||||
password: "new-pass",
|
||||
dailyLimitGb: "7"
|
||||
};
|
||||
const next = applyAccountEdit(settings, state);
|
||||
|
||||
expect(next.ddownloadLogin).toBe("new@example.com");
|
||||
expect(next.ddownloadPassword).toBe("new-pass");
|
||||
expect(next.linkSnappyLogin).toBe("keep@example.com");
|
||||
expect(next.linkSnappyPassword).toBe("keep-pass");
|
||||
expect(next.providerDailyLimitBytes.ddownload).toBe(7 * GIB);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only passwords for direct login accounts", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
ddownloadLogin: "user@example.com",
|
||||
ddownloadPassword: "password",
|
||||
linkSnappyLogin: "member@example.com",
|
||||
linkSnappyPassword: "secret"
|
||||
};
|
||||
const ddownloadTarget: AccountEditTarget = {
|
||||
type: "single",
|
||||
rowKey: "svc-ddownload",
|
||||
kind: "ddownload-login",
|
||||
service: "ddownload",
|
||||
provider: "ddownload"
|
||||
};
|
||||
const linkSnappyTarget: AccountEditTarget = {
|
||||
type: "single",
|
||||
rowKey: "svc-linksnappy",
|
||||
kind: "linksnappy-login",
|
||||
service: "linksnappy",
|
||||
provider: "linksnappy"
|
||||
};
|
||||
|
||||
expect(validateAccountEdit({ ...createAccountEditState(ddownloadTarget, settings), password: " " }, settings)).toMatch(/Passwort/i);
|
||||
expect(validateAccountEdit({ ...createAccountEditState(linkSnappyTarget, settings), password: "\t" }, settings)).toMatch(/Passwort/i);
|
||||
});
|
||||
|
||||
it("builds a targeted check snapshot without invalid sibling accounts", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass",
|
||||
megaLogin: "first@example.com",
|
||||
megaPassword: "first-pass",
|
||||
debridLinkApiKeys: "one\ntwo"
|
||||
};
|
||||
const target = megaTarget("second@example.com");
|
||||
const state = createAccountEditState(target, settings);
|
||||
const checkSettings = buildAccountEditCheckSettings(settings, state);
|
||||
|
||||
expect(checkSettings.megaCredentials).toBe("second@example.com:second-pass");
|
||||
expect(checkSettings.megaLogin).toBe("second@example.com");
|
||||
expect(checkSettings.megaPassword).toBe("second-pass");
|
||||
expect(checkSettings.debridLinkApiKeys).toBe("");
|
||||
});
|
||||
|
||||
it("removes only the selected Mega-Debrid account and all metadata belonging to it", () => {
|
||||
const removeId = getMegaDebridAccountId("second@example.com");
|
||||
const keepId = getMegaDebridAccountId("first@example.com");
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass",
|
||||
megaLogin: "first@example.com",
|
||||
megaPassword: "first-pass",
|
||||
megaDebridDisabledAccountIds: [removeId, keepId],
|
||||
megaDebridAccountDailyLimitBytes: { [removeId]: 2, [keepId]: 1 },
|
||||
megaDebridAccountDailyUsageBytes: { [removeId]: 4, [keepId]: 3 },
|
||||
megaDebridAccountTotalUsageBytes: { [removeId]: 6, [keepId]: 5 },
|
||||
debridAccountStatuses: {
|
||||
[removeId]: { accountId: removeId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 },
|
||||
[keepId]: { accountId: keepId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
|
||||
}
|
||||
};
|
||||
const next = removeAccountTarget(settings, megaTarget("second@example.com"));
|
||||
|
||||
expect(next.megaCredentials).toBe("first@example.com:first-pass");
|
||||
expect(next.megaDebridDisabledAccountIds).toEqual([keepId]);
|
||||
expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [keepId]: 1 });
|
||||
expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [keepId]: 3 });
|
||||
expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [keepId]: 5 });
|
||||
expect(next.debridAccountStatuses).toEqual({
|
||||
[keepId]: { accountId: keepId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }
|
||||
});
|
||||
});
|
||||
});
|
||||
megaCredentials: `${identity}:fixture-stored-secret-4dH8`,
|
||||
megaDebridApiCredentials: `${identity}:fixture-stored-secret-4dH8`,
|
||||
megaDebridApiEnabled: true
|
||||
});
|
||||
|
||||
const edit = createAccountEditState(megaTarget(identity), state.accounts);
|
||||
|
||||
expect(edit.login).toBe(identity);
|
||||
expect(edit.password).toBe("");
|
||||
expect(JSON.stringify(edit)).not.toContain("fixture-stored-secret-4dH8");
|
||||
});
|
||||
|
||||
it("builds a replace command whose blank secret retains the main-process value", () => {
|
||||
const identity = "safe-edit@example.test";
|
||||
const renderer = createRendererState({
|
||||
...defaultSettings(),
|
||||
megaCredentials: `${identity}:fixture-stored-secret-4dH8`,
|
||||
megaDebridApiCredentials: `${identity}:fixture-stored-secret-4dH8`,
|
||||
megaDebridApiEnabled: true
|
||||
});
|
||||
const edit = createAccountEditState(megaTarget(identity), renderer.accounts);
|
||||
|
||||
expect(validateAccountEdit(edit, renderer.accounts)).toBeNull();
|
||||
expect(buildAccountReplaceCommand(edit)).toEqual(expect.objectContaining({
|
||||
action: "replace",
|
||||
accountId: getMegaDebridAccountId(identity),
|
||||
identity,
|
||||
secret: ""
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects duplicate identities using safe account metadata", () => {
|
||||
const first = "first-safe@example.test";
|
||||
const second = "second-safe@example.test";
|
||||
const renderer = createRendererState({
|
||||
...defaultSettings(),
|
||||
megaCredentials: `${first}:fixture-first-secret-1aB2\n${second}:fixture-second-secret-3cD4`,
|
||||
megaDebridApiCredentials: `${first}:fixture-first-secret-1aB2\n${second}:fixture-second-secret-3cD4`,
|
||||
megaDebridApiEnabled: true
|
||||
});
|
||||
const edit = { ...createAccountEditState(megaTarget(second), renderer.accounts), login: first };
|
||||
|
||||
expect(validateAccountEdit(edit, renderer.accounts)).toMatch(/bereits vorhanden/i);
|
||||
});
|
||||
|
||||
it("builds an identity-only delete command", () => {
|
||||
const target = megaTarget("delete-safe@example.test");
|
||||
expect(buildAccountDeleteCommand(target)).toEqual({
|
||||
action: "delete",
|
||||
kind: "megadebrid-api",
|
||||
accountId: target.type === "mega" ? target.accountId : ""
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user