feat: move termix identity into a plugin (C6)

This commit is contained in:
LukeGus committed 2026-09-24 20:50:54 -05:00
1 parent 6e56c90aac
commit 4dbce08379
157 files changed
+20743 -5766

No files matched your search

@@ -69,8 +69,9 @@ describe("generated schemas", () => {
expect(tablesOf(pgSchema)).toEqual(tablesOf(sqliteSchema));
expect(tablesOf(mysqlSchema)).toEqual(tablesOf(sqliteSchema));
// Guard against a generator that silently emits nothing.
expect(tablesOf(sqliteSchema).length).toBeGreaterThan(40);
// Guard against a generator that silently emits nothing. Core keeps
// shrinking as feature tables move into plugins, so this stays loose.
expect(tablesOf(sqliteSchema).length).toBeGreaterThan(25);
});
it("maps each column to the right storage type per dialect", () => {
@@ -1,224 +0,0 @@
import { sql } from "drizzle-orm";
import { afterEach, describe, expect, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DataCrypto } from "../../../utils/data-crypto.js";
import { TermixIdentityCaRepository } from "../../../database/repositories/termix-identity-ca-repository.js";
describe("TermixIdentityCaRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
vi.restoreAllMocks();
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite = vi.fn()): Promise<{
repo: TermixIdentityCaRepository;
onWrite: ReturnType<typeof vi.fn>;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash');
INSERT INTO termix_identities (id, user_id, handle)
VALUES (7, 'user-1', 'alice');
`);
return {
repo: new TermixIdentityCaRepository(context, onWrite),
onWrite,
};
}
function mockCrypto(): void {
vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "encryptRecord").mockImplementation(
(_tableName, record) =>
({
...record,
privateKey: "encrypted-ca-private",
}) as typeof record,
);
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
(_tableName, record) =>
({
...record,
privateKey:
record.privateKey === "encrypted-ca-private"
? "decrypted-ca-private"
: record.privateKey,
}) as typeof record,
);
}
it("creates CA private keys with the real row id before encryption", async () => {
const { repo, onWrite } = await createRepository();
mockCrypto();
const created = await repo.createEncryptedForUser("user-1", {
identityId: 7,
userId: "user-1",
publicKey: "ssh-ed25519 public",
privateKey: "plain-ca-private",
validityDays: 120,
});
const [raw] = (await adapter!.query(
sql`SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`,
)) as {
id: number;
public_key: string;
private_key: string;
validity_days: number;
}[];
expect(created.privateKey).toBe("decrypted-ca-private");
expect(raw.private_key).toBe("encrypted-ca-private");
expect(raw.public_key).toBe("ssh-ed25519 public");
expect(raw.validity_days).toBe(120);
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
{ id: raw.id, privateKey: "plain-ca-private" },
"user-1",
Buffer.from("user-key"),
);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("reads public CA metadata without decrypting private key material", async () => {
const { repo } = await createRepository();
const decryptSpy = vi.spyOn(DataCrypto, "decryptRecord");
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days)
VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
await expect(repo.findPublicByIdentityId(7)).resolves.toEqual({
publicKey: "ssh-ed25519 public",
validityDays: 45,
});
expect(decryptSpy).not.toHaveBeenCalled();
});
it("decrypts CA private keys through the user data boundary", async () => {
const { repo } = await createRepository();
mockCrypto();
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days)
VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
const ca = await repo.findDecryptedByIdentityId("user-1", 7);
expect(ca).toMatchObject({
identityId: 7,
publicKey: "ssh-ed25519 public",
privateKey: "decrypted-ca-private",
validityDays: 45,
});
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
expect.objectContaining({ identityId: 7 }),
"user-1",
Buffer.from("user-key"),
);
});
it("updates CA private keys through encrypted writes", async () => {
const { repo, onWrite } = await createRepository();
mockCrypto();
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 old', 'encrypted-ca-private', 45)`,
);
onWrite.mockClear();
const updated = await repo.updateEncryptedForIdentity("user-1", 7, {
publicKey: "ssh-ed25519 new",
privateKey: "plain-updated-ca-private",
validityDays: 90,
});
const [raw] = (await adapter!.query(
sql`SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`,
)) as {
public_key: string;
private_key: string;
validity_days: number;
}[];
expect(updated).toMatchObject({
publicKey: "ssh-ed25519 new",
privateKey: "decrypted-ca-private",
validityDays: 90,
});
expect(raw).toEqual({
public_key: "ssh-ed25519 new",
private_key: "encrypted-ca-private",
validity_days: 90,
});
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
expect.objectContaining({
privateKey: "plain-updated-ca-private",
}),
"user-1",
Buffer.from("user-key"),
);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes CA rows through the write boundary", async () => {
const { repo, onWrite } = await createRepository();
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
onWrite.mockClear();
await expect(repo.deleteByIdentityId(7)).resolves.toBe(true);
await expect(repo.deleteByIdentityId(7)).resolves.toBe(false);
expect(
(
await adapter!.query(
sql`SELECT COUNT(*) AS count FROM termix_identity_ca`,
)
).map((row) => Number((row as { count: unknown }).count)),
).toEqual([0]);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes CA rows for a user", async () => {
const { repo, onWrite } = await createRepository();
await adapter!.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-2', 'bob', 'hash')`,
);
await adapter!.run(
sql`INSERT INTO termix_identities (id, user_id, handle) VALUES (8, 'user-2', 'bob')`,
);
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (8, 'user-2', 'ssh-ed25519 other', 'encrypted-other', 90)`,
);
onWrite.mockClear();
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(
await adapter!.query(
sql`SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id`,
),
).toEqual([{ user_id: "user-2", public_key: "ssh-ed25519 other" }]);
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -1,170 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TermixIdentityRepository } from "../../../database/repositories/termix-identity-repository.js";
describe("TermixIdentityRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite = vi.fn()): Promise<{
repo: TermixIdentityRepository;
onWrite: ReturnType<typeof vi.fn>;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
(10, 'user-1', 'cred-10', 'root', 'password'),
(20, 'user-1', 'cred-20', 'root', 'password');
`);
return {
repo: new TermixIdentityRepository(context, onWrite),
onWrite,
};
}
it("creates, updates, finds, and deletes identities", async () => {
const { repo, onWrite } = await createRepository();
const created = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: "workstation keys",
});
expect(created.id).toBeGreaterThan(0);
expect(await repo.isHandleTaken("alice")).toBe(true);
expect(await repo.findIdentityForUser("user-1")).toMatchObject({
handle: "alice",
});
expect(await repo.findIdentityByHandle("alice")).toMatchObject({
userId: "user-1",
});
const updated = await repo.updateIdentityForUser("user-1", {
handle: "alice-renamed",
description: null,
});
expect(updated).toMatchObject({
handle: "alice-renamed",
description: null,
});
await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(true);
await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(false);
await expect(repo.findIdentityForUser("user-1")).resolves.toBeNull();
expect(onWrite).toHaveBeenCalledTimes(3);
});
it("creates, lists, updates, deletes, and links public keys", async () => {
const { repo, onWrite } = await createRepository();
const identity = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: null,
});
onWrite.mockClear();
const first = await repo.createKey({
identityId: identity.id,
userId: "user-1",
publicKey: "ssh-ed25519 AAAA1",
keyType: "ssh-ed25519",
algorithm: "ED25519",
label: "laptop",
comment: null,
source: "credential",
credentialId: 10,
});
await repo.createKey({
identityId: identity.id,
userId: "user-1",
publicKey: "ssh-rsa AAAA2",
keyType: "ssh-rsa",
algorithm: "RSA",
label: "disabled",
comment: null,
source: "manual",
credentialId: 20,
enabled: false,
});
expect(await repo.listKeysByIdentityId(identity.id)).toHaveLength(2);
expect(await repo.listEnabledKeysByIdentityId(identity.id)).toMatchObject([
{ id: first.id, publicKey: "ssh-ed25519 AAAA1" },
]);
expect(await repo.listLinkedCredentialIds(identity.id)).toEqual([10]);
const updated = await repo.updateKeyForUser("user-1", first.id, {
enabled: false,
label: "revoked",
});
expect(updated).toMatchObject({ enabled: false, label: "revoked" });
await expect(
repo.findKeyForUser("user-1", first.id),
).resolves.toMatchObject({ label: "revoked" });
await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe(true);
await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe(
false,
);
expect(onWrite).toHaveBeenCalledTimes(4);
});
it("deletes identities and keys for a user", async () => {
const { repo, onWrite } = await createRepository();
const userIdentity = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: null,
});
const otherIdentity = await repo.createIdentity({
userId: "user-2",
handle: "bob",
description: null,
});
await repo.createKey({
identityId: userIdentity.id,
userId: "user-1",
publicKey: "ssh-ed25519 AAAA1",
keyType: "ssh-ed25519",
algorithm: "ED25519",
source: "manual",
});
await repo.createKey({
identityId: otherIdentity.id,
userId: "user-2",
publicKey: "ssh-ed25519 AAAA2",
keyType: "ssh-ed25519",
algorithm: "ED25519",
source: "manual",
});
onWrite.mockClear();
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
identitiesDeleted: 1,
keysDeleted: 1,
});
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
identitiesDeleted: 0,
keysDeleted: 0,
});
expect(await repo.findIdentityForUser("user-1")).toBeNull();
expect(await repo.listKeysByIdentityId(userIdentity.id)).toEqual([]);
expect(await repo.findIdentityForUser("user-2")).toMatchObject({
handle: "bob",
});
expect(await repo.listKeysByIdentityId(otherIdentity.id)).toHaveLength(1);
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,7 @@
/**
* The OPKSSH, Step CA and Vault redirect URIs identity providers were registered with before
* 2.9 must keep reaching the plugin's callback, query intact.
* 2.9 must keep reaching the plugin's callback, query intact, and the Termix
* ID resolver URLs servers fetch must keep reaching the plugin's resolver.
*/
import http from "node:http";
@@ -8,7 +9,7 @@ import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const { registerHostAuthCompatRoutes } =
const { registerHostAuthCompatRoutes, registerTermixIdCompatRoutes } =
await import("../../../database/routes/host-compat-routes.js");
let server: http.Server;
@@ -19,6 +20,9 @@ beforeEach(async () => {
registerHostAuthCompatRoutes(router);
const app = express();
app.use("/host", router);
const termixId = express.Router();
registerTermixIdCompatRoutes(termixId);
app.use("/termix-id", termixId);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
@@ -88,3 +92,25 @@ describe("the old Vault callback", () => {
}
});
});
describe("the old Termix ID resolver URLs", () => {
it.each([
["/termix-id/u/alice", "/plugin-api/termix-identity/u/alice"],
[
"/termix-id/u/alice/ED25519",
"/plugin-api/termix-identity/u/alice/ED25519",
],
["/termix-id/u/alice/ca", "/plugin-api/termix-identity/u/alice/ca"],
])("permanently redirects %s", async (path, target) => {
const response = await fetch(`${base}${path}`, { redirect: "manual" });
expect(response.status).toBe(308);
expect(response.headers.get("location")).toBe(target);
});
it("does not forward the old management routes", async () => {
const response = await fetch(`${base}/termix-id/me`, {
redirect: "manual",
});
expect(response.status).toBe(404);
});
});
@@ -1,127 +0,0 @@
import { describe, it, expect } from "vitest";
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import { execFileSync } from "child_process";
import {
generateCa,
signUserCertificate,
ed25519RawFromLine,
} from "../../../database/routes/ssh-certificate.js";
function publicKeyObjectFromLine(line: string) {
const raw = ed25519RawFromLine(line);
if (!raw) throw new Error("not ed25519");
return crypto.createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
format: "jwk",
});
}
// Split a cert blob into the signed body and the raw 64-byte ed25519 signature.
function splitCert(certLine: string): { body: Buffer; rawSig: Buffer } {
const blob = Buffer.from(certLine.split(/\s+/)[1], "base64");
// trailing signature string = str( str("ssh-ed25519") + str(64-byte sig) )
const sigBlobLen = 4 + "ssh-ed25519".length + 4 + 64; // 83
const body = blob.subarray(0, blob.length - (4 + sigBlobLen));
const rawSig = blob.subarray(blob.length - 64);
return { body, rawSig };
}
describe("generateCa", () => {
it("produces a valid ed25519 public line and PKCS8 private key", () => {
const ca = generateCa();
expect(ca.publicKeyLine.startsWith("ssh-ed25519 ")).toBe(true);
expect(ed25519RawFromLine(ca.publicKeyLine)?.length).toBe(32);
expect(ca.privateKeyPem).toContain("BEGIN PRIVATE KEY");
// The PEM must load as a usable signing key.
expect(() =>
crypto.createPrivateKey({
key: ca.privateKeyPem,
format: "pem",
type: "pkcs8",
}),
).not.toThrow();
});
});
describe("signUserCertificate", () => {
it("returns null for non-ed25519 user keys", () => {
const ca = generateCa();
const cert = signUserCertificate({
userPublicKeyLine: "ssh-rsa AAAAB3Nz",
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "x",
principals: [],
validAfter: 0,
validBefore: 1,
});
expect(cert).toBeNull();
});
it("produces a cert whose signature verifies against the CA key", () => {
const ca = generateCa();
const user = generateCa(); // reuse: a valid ed25519 public line
const cert = signUserCertificate({
userPublicKeyLine: user.publicKeyLine,
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "termix:@alice",
principals: ["root", "ubuntu"],
validAfter: 1000,
validBefore: 2000,
});
expect(cert).not.toBeNull();
expect(cert!.startsWith("ssh-ed25519-cert-v01@openssh.com ")).toBe(true);
const { body, rawSig } = splitCert(cert!);
const caPub = publicKeyObjectFromLine(ca.publicKeyLine);
expect(crypto.verify(null, body, caPub, rawSig)).toBe(true);
// A different CA must NOT verify.
const otherPub = publicKeyObjectFromLine(generateCa().publicKeyLine);
expect(crypto.verify(null, body, otherPub, rawSig)).toBe(false);
});
it("is accepted and correctly parsed by ssh-keygen -L", () => {
let sshKeygen: string;
try {
sshKeygen = execFileSync("ssh-keygen", ["--help"], { encoding: "utf8" });
void sshKeygen;
} catch (e) {
// ssh-keygen prints usage to stderr and exits non-zero for --help; that's
// fine — it means the binary exists. Only skip if it's truly missing.
if ((e as { code?: string }).code === "ENOENT") return;
}
const ca = generateCa();
const user = generateCa();
const now = Math.floor(Date.now() / 1000);
const cert = signUserCertificate({
userPublicKeyLine: user.publicKeyLine,
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "termix-test-id",
principals: ["deploy"],
validAfter: now,
validBefore: now + 3600,
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cert-"));
const file = path.join(dir, "id-cert.pub");
try {
fs.writeFileSync(file, cert + "\n");
const out = execFileSync("ssh-keygen", ["-L", "-f", file], {
encoding: "utf8",
});
expect(out).toContain("user certificate");
expect(out).toContain('Key ID: "termix-test-id"');
expect(out).toContain("deploy");
expect(out).toMatch(/permit-pty/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -1,95 +0,0 @@
import { describe, it, expect } from "vitest";
import {
classifyAlgo,
parsePublicKey,
matchesAlgoFilter,
MAX_PUBLIC_KEY_LENGTH,
} from "../../../database/routes/termix-id-keys.js";
// Build a valid OpenSSH public-key line for a given type by encoding a wire
// blob whose first string field equals the type (what parsePublicKey checks).
function makeKey(type: string, comment = ""): string {
const typeBuf = Buffer.from(type, "utf8");
const header = Buffer.alloc(4);
header.writeUInt32BE(typeBuf.length, 0);
const body = Buffer.alloc(40); // arbitrary trailing key material
const blob = Buffer.concat([header, typeBuf, body]).toString("base64");
return `${type} ${blob}${comment ? ` ${comment}` : ""}`;
}
describe("classifyAlgo", () => {
it("maps known types to normalized groups", () => {
expect(classifyAlgo("ssh-rsa")).toBe("RSA");
expect(classifyAlgo("rsa-sha2-512")).toBe("RSA");
expect(classifyAlgo("ssh-ed25519")).toBe("ED25519");
expect(classifyAlgo("ecdsa-sha2-nistp256")).toBe("ECDSA");
expect(classifyAlgo("ssh-dss")).toBe("DSA");
expect(classifyAlgo("sk-ssh-ed25519@openssh.com")).toBe("ED25519-SK");
expect(classifyAlgo("sk-ecdsa-sha2-nistp256@openssh.com")).toBe("ECDSA-SK");
});
it("falls back by substring for unknown variants", () => {
expect(classifyAlgo("ecdsa-sha2-nistp999")).toBe("ECDSA");
expect(classifyAlgo("rsa-sha2-256-cert")).toBe("RSA");
expect(classifyAlgo("something-weird")).toBe("SOMETHING-WEIRD");
});
});
describe("parsePublicKey", () => {
it("parses a valid ed25519 key and extracts the comment", () => {
const parsed = parsePublicKey(makeKey("ssh-ed25519", "alice@laptop"));
expect(parsed).not.toBeNull();
expect(parsed?.type).toBe("ssh-ed25519");
expect(parsed?.algorithm).toBe("ED25519");
expect(parsed?.comment).toBe("alice@laptop");
// Comment is stripped from the normalized (dedupe) form.
expect(parsed?.normalized.includes("alice@laptop")).toBe(false);
});
it("parses SK (FIDO) key types", () => {
expect(
parsePublicKey(makeKey("sk-ssh-ed25519@openssh.com"))?.algorithm,
).toBe("ED25519-SK");
});
it.each([
[null],
[undefined],
[""],
[" "],
["ssh-ed25519"], // missing blob
["ssh-ed25519 not_base64!!"], // bad base64 charset
["ssh-rsa AAAAB3Nz"], // blob whose embedded type != declared type
])("rejects malformed input %p", (input) => {
expect(parsePublicKey(input as string)).toBeNull();
});
it("rejects an over-length line (amplification guard)", () => {
const valid = makeKey("ssh-ed25519");
const padded = valid + " " + "A".repeat(MAX_PUBLIC_KEY_LENGTH);
expect(padded.length).toBeGreaterThan(MAX_PUBLIC_KEY_LENGTH);
expect(parsePublicKey(padded)).toBeNull();
});
it("rejects a blob whose embedded type does not match the prefix", () => {
// Declared ssh-rsa but the wire blob says ssh-ed25519.
const blob = makeKey("ssh-ed25519").split(" ")[1];
expect(parsePublicKey(`ssh-rsa ${blob}`)).toBeNull();
});
});
describe("matchesAlgoFilter", () => {
it("returns all keys when no filter", () => {
expect(matchesAlgoFilter("ED25519", null)).toBe(true);
});
it("matches exactly and is case-insensitive", () => {
expect(matchesAlgoFilter("ED25519", "ed25519")).toBe(true);
expect(matchesAlgoFilter("RSA", "RSA")).toBe(true);
});
it("does NOT let ED25519 match ED25519-SK (the over-match bug)", () => {
expect(matchesAlgoFilter("ED25519-SK", "ED25519")).toBe(false);
expect(matchesAlgoFilter("ECDSA-SK", "ECDSA")).toBe(false);
});
});
@@ -1,162 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockSelect = vi.fn();
const mockUpdate = vi.fn();
const mockInsert = vi.fn();
const mockDelete = vi.fn();
vi.mock("../../../database/db/index.js", () => ({
db: {
select: mockSelect,
update: mockUpdate,
insert: mockInsert,
delete: mockDelete,
},
}));
vi.mock("../../../utils/logger.js", () => ({
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = "user-1";
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(),
getRequestMeta: vi.fn(() => ({})),
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) },
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentTermixIdentityCaRepository: vi.fn(() => ({
findPublicByIdentityId: vi.fn(),
findDecryptedByIdentityId: vi.fn(),
createEncryptedForUser: vi.fn(),
updateEncryptedForIdentity: vi.fn(),
deleteByIdentityId: vi.fn(),
})),
createCurrentTermixIdentityRepository: vi.fn(() => ({
findIdentityForUser: vi.fn(),
findIdentityByHandle: vi.fn(),
isHandleTaken: vi.fn(),
createIdentity: vi.fn(),
updateIdentityForUser: vi.fn(),
deleteIdentityForUser: vi.fn(),
listKeysByIdentityId: vi.fn(),
listEnabledKeysByIdentityId: vi.fn(),
listLinkedCredentialIds: vi.fn(),
createKey: vi.fn(),
updateKeyForUser: vi.fn(),
deleteKeyForUser: vi.fn(),
findKeyForUser: vi.fn(),
})),
}));
vi.mock("../../../database/routes/termix-id-keys.js", () => ({
termixIdKeysRouter: { use: vi.fn() },
matchesAlgoFilter: vi.fn(() => true),
}));
// Chainable Drizzle stub — supports arbitrary method chains and resolves via .then()
function makeChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {};
const methods = [
"from",
"where",
"set",
"values",
"returning",
"orderBy",
"limit",
"and",
"eq",
];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
(chain as unknown as Promise<unknown>).then = (
cb: (v: unknown) => unknown,
eb?: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).then(cb, eb);
(chain as unknown as Promise<unknown>).catch = (
cb: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).catch(cb);
return chain;
}
const IDENTITY_ROW = { id: 42, userId: "user-1", handle: "alice" };
describe("GET /termix-id/linked-credentials", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty list when user has no identity", async () => {
// First select (getIdentityForUser) returns nothing; second should not be called
mockSelect.mockReturnValueOnce(makeChain([]));
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
expect(router).toBeDefined();
}, 15_000);
it("returns empty list when identity has no keys", async () => {
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW])) // identity lookup
.mockReturnValueOnce(makeChain([])); // keys lookup
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
});
it("returns deduplicated credentialIds for enabled keys", async () => {
const keys = [
{ credentialId: 10 },
{ credentialId: 20 },
{ credentialId: 10 }, // duplicate
];
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
.mockReturnValueOnce(makeChain(keys));
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
});
it("excludes keys with null credentialId", async () => {
const keys = [{ credentialId: null }, { credentialId: 5 }];
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
.mockReturnValueOnce(makeChain(keys));
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
});
});