mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-24 23:17:04 +00:00
fix: preserve remote sync references (#1092)
This commit is contained in:
@@ -16,12 +16,12 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const SYNCED_ENTITY_TYPES = [
|
||||
"hosts",
|
||||
"sshCredentials",
|
||||
"sshFolders",
|
||||
"snippets",
|
||||
"snippetFolders",
|
||||
"vaultProfiles",
|
||||
"sshFolders",
|
||||
"snippetFolders",
|
||||
"hosts",
|
||||
"snippets",
|
||||
"dashboardServiceLinks",
|
||||
"homepageItems",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
||||
|
||||
export type SyncReferenceEntity = "sshCredentials" | "vaultProfiles";
|
||||
|
||||
interface SyncReference {
|
||||
field: string;
|
||||
syncField: string;
|
||||
entityType: SyncReferenceEntity;
|
||||
}
|
||||
|
||||
const HOST_REFERENCES: SyncReference[] = [
|
||||
{
|
||||
field: "credentialId",
|
||||
syncField: "credentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "rdpCredentialId",
|
||||
syncField: "rdpCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "vncCredentialId",
|
||||
syncField: "vncCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "telnetCredentialId",
|
||||
syncField: "telnetCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "vaultProfileId",
|
||||
syncField: "vaultProfileSyncId",
|
||||
entityType: "vaultProfiles",
|
||||
},
|
||||
];
|
||||
|
||||
const REFERENCES: Partial<Record<SyncEntityType, SyncReference[]>> = {
|
||||
hosts: HOST_REFERENCES,
|
||||
sshFolders: [HOST_REFERENCES[0]],
|
||||
};
|
||||
|
||||
export async function serializeSyncReferences(
|
||||
entityType: SyncEntityType,
|
||||
row: Record<string, unknown>,
|
||||
resolveSyncId: (
|
||||
entityType: SyncReferenceEntity,
|
||||
id: number,
|
||||
) => Promise<string | null>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const serialized = { ...row };
|
||||
for (const reference of REFERENCES[entityType] ?? []) {
|
||||
const id = serialized[reference.field];
|
||||
serialized[reference.syncField] =
|
||||
typeof id === "number"
|
||||
? await resolveSyncId(reference.entityType, id)
|
||||
: null;
|
||||
delete serialized[reference.field];
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
export async function deserializeSyncReferences(
|
||||
entityType: SyncEntityType,
|
||||
row: Record<string, unknown>,
|
||||
resolveId: (
|
||||
entityType: SyncReferenceEntity,
|
||||
syncId: string,
|
||||
) => Promise<number | null>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const deserialized = { ...row };
|
||||
for (const reference of REFERENCES[entityType] ?? []) {
|
||||
const syncId = deserialized[reference.syncField];
|
||||
delete deserialized[reference.syncField];
|
||||
delete deserialized[reference.field];
|
||||
|
||||
if (syncId == null) {
|
||||
deserialized[reference.field] = null;
|
||||
continue;
|
||||
}
|
||||
if (typeof syncId !== "string") {
|
||||
throw new Error(`Invalid ${reference.syncField}`);
|
||||
}
|
||||
|
||||
const id = await resolveId(reference.entityType, syncId);
|
||||
if (id === null) {
|
||||
throw new Error(
|
||||
`Missing ${reference.entityType} dependency ${reference.syncField}=${syncId}`,
|
||||
);
|
||||
}
|
||||
deserialized[reference.field] = id;
|
||||
}
|
||||
return deserialized;
|
||||
}
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
||||
import {
|
||||
deserializeSyncReferences,
|
||||
serializeSyncReferences,
|
||||
type SyncReferenceEntity,
|
||||
} from "./sync-references.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -65,11 +70,65 @@ const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
|
||||
};
|
||||
|
||||
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
|
||||
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
|
||||
|
||||
export function isValidEntityType(value: unknown): value is SyncEntityType {
|
||||
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
|
||||
}
|
||||
|
||||
async function findReferenceSyncId(
|
||||
context: RepositoryContext,
|
||||
entityType: SyncReferenceEntity,
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
if (entityType === "sshCredentials") {
|
||||
const [row] = await context.drizzle
|
||||
.select({ syncId: sshCredentials.syncId })
|
||||
.from(sshCredentials)
|
||||
.where(and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)))
|
||||
.limit(1);
|
||||
return row?.syncId ?? null;
|
||||
}
|
||||
|
||||
const [row] = await context.drizzle
|
||||
.select({ syncId: vaultProfiles.syncId })
|
||||
.from(vaultProfiles)
|
||||
.where(and(eq(vaultProfiles.id, id), eq(vaultProfiles.userId, userId)))
|
||||
.limit(1);
|
||||
return row?.syncId ?? null;
|
||||
}
|
||||
|
||||
async function findReferenceId(
|
||||
context: RepositoryContext,
|
||||
entityType: SyncReferenceEntity,
|
||||
syncId: string,
|
||||
userId: string,
|
||||
): Promise<number | null> {
|
||||
if (entityType === "sshCredentials") {
|
||||
const [row] = await context.drizzle
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.syncId, syncId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
const [row] = await context.drizzle
|
||||
.select({ id: vaultProfiles.id })
|
||||
.from(vaultProfiles)
|
||||
.where(
|
||||
and(eq(vaultProfiles.syncId, syncId), eq(vaultProfiles.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
function requireUserDataKey(userId: string): Buffer {
|
||||
return DataCrypto.validateUserAccess(userId);
|
||||
}
|
||||
@@ -173,8 +232,15 @@ router.get(
|
||||
.from(table as typeof hosts)
|
||||
.where(and(...conditions));
|
||||
|
||||
const decrypted = rows.map((row) =>
|
||||
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
|
||||
const decrypted = await Promise.all(
|
||||
rows.map((row) =>
|
||||
serializeSyncReferences(
|
||||
entityType,
|
||||
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
|
||||
(referenceType, id) =>
|
||||
findReferenceSyncId(context, referenceType, id, userId),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
res.json({ rows: decrypted });
|
||||
@@ -242,7 +308,13 @@ router.post(
|
||||
.limit(1);
|
||||
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
||||
|
||||
const writePayload = stripWritePayload(entityType, payload);
|
||||
const resolvedPayload = await deserializeSyncReferences(
|
||||
entityType,
|
||||
payload,
|
||||
(referenceType, referenceSyncId) =>
|
||||
findReferenceId(context, referenceType, referenceSyncId, userId),
|
||||
);
|
||||
const writePayload = stripWritePayload(entityType, resolvedPayload);
|
||||
const encryptedPayload = encryptIfNeeded(
|
||||
entityType,
|
||||
writePayload,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deserializeSyncReferences,
|
||||
serializeSyncReferences,
|
||||
} from "../../database/routes/sync-references.js";
|
||||
|
||||
describe("sync references", () => {
|
||||
it("serializes database-local host IDs as stable sync IDs", async () => {
|
||||
const row = await serializeSyncReferences(
|
||||
"hosts",
|
||||
{
|
||||
id: 7,
|
||||
credentialId: 12,
|
||||
rdpCredentialId: 13,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: 4,
|
||||
},
|
||||
async (entityType, id) => `${entityType}-${id}`,
|
||||
);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
credentialSyncId: "sshCredentials-12",
|
||||
rdpCredentialSyncId: "sshCredentials-13",
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
vaultProfileSyncId: "vaultProfiles-4",
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialId");
|
||||
expect(row).not.toHaveProperty("vaultProfileId");
|
||||
});
|
||||
|
||||
it("resolves stable sync IDs to IDs from the receiving database", async () => {
|
||||
const ids = new Map([
|
||||
["sshCredentials:credential-sync", 91],
|
||||
["vaultProfiles:vault-sync", 37],
|
||||
]);
|
||||
const row = await deserializeSyncReferences(
|
||||
"hosts",
|
||||
{
|
||||
credentialId: 12,
|
||||
credentialSyncId: "credential-sync",
|
||||
rdpCredentialSyncId: null,
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
vaultProfileSyncId: "vault-sync",
|
||||
},
|
||||
async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null,
|
||||
);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
credentialId: 91,
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: 37,
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialSyncId");
|
||||
});
|
||||
|
||||
it("rejects a row whose referenced dependency has not synced", async () => {
|
||||
await expect(
|
||||
deserializeSyncReferences(
|
||||
"sshFolders",
|
||||
{ credentialSyncId: "missing" },
|
||||
async () => null,
|
||||
),
|
||||
).rejects.toThrow("Missing sshCredentials dependency");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user