833 lines
35 KiB
TypeScript
833 lines
35 KiB
TypeScript
// @vitest-environment node
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
AppsEngineServiceError: class AppsEngineServiceError extends Error {
|
|
readonly messageKey: string;
|
|
constructor(messageKey: string) {
|
|
super(messageKey);
|
|
this.name = "AppsEngineServiceError";
|
|
this.messageKey = messageKey;
|
|
}
|
|
},
|
|
KwpOidcClientError: class KwpOidcClientError extends Error {
|
|
readonly code: string;
|
|
readonly diagnosticCode?: string;
|
|
constructor(code: string, options?: { diagnosticCode?: string }) {
|
|
super(code);
|
|
this.name = "KwpOidcClientError";
|
|
this.code = code;
|
|
this.diagnosticCode = options?.diagnosticCode;
|
|
}
|
|
},
|
|
KwpError: class KwpError extends Error {
|
|
readonly code: string;
|
|
readonly httpStatus?: number;
|
|
constructor(code: string, httpStatus?: number) {
|
|
super(code);
|
|
this.name = "KwpError";
|
|
this.code = code;
|
|
this.httpStatus = httpStatus;
|
|
}
|
|
},
|
|
context: { tenantId: "tenant-1", userId: "user-1" } as { tenantId?: string; userId?: string },
|
|
executeRaw: vi.fn(async () => 1),
|
|
findMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
createAuditEvent: vi.fn(async () => undefined),
|
|
activateOAuthConnection: vi.fn(async () => undefined),
|
|
encryptTenantField: vi.fn(),
|
|
decryptTenantField: vi.fn(),
|
|
discoverKwpMandants: vi.fn(),
|
|
readConfig: vi.fn(),
|
|
buildAuthorizationUrl: vi.fn(),
|
|
exchangeAuthorizationCode: vi.fn(),
|
|
createChallenge: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@hivegrid/db/rls-context.server", () => ({ getRlsExecutionContext: () => mocks.context }));
|
|
vi.mock("@hivegrid/db/audit-events.server", () => ({ createAuditEvent: mocks.createAuditEvent }));
|
|
vi.mock("@hivegrid/db/crypto/tenant-envelope.server", () => ({
|
|
encryptTenantField: mocks.encryptTenantField,
|
|
decryptTenantField: mocks.decryptTenantField,
|
|
}));
|
|
vi.mock("@hivegrid/integrations", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@hivegrid/integrations")>()),
|
|
KwpError: mocks.KwpError,
|
|
discoverKwpMandants: mocks.discoverKwpMandants,
|
|
}));
|
|
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
|
|
withTenantRLS: (_tenantId: string, callback: (tx: unknown) => Promise<unknown>) =>
|
|
callback({
|
|
$executeRaw: mocks.executeRaw,
|
|
dataConnection: {
|
|
findMany: mocks.findMany,
|
|
findFirst: mocks.findFirst,
|
|
create: mocks.create,
|
|
updateMany: mocks.updateMany,
|
|
},
|
|
}),
|
|
}));
|
|
vi.mock("~/utils/integrations/appsEngineService.server", () => ({
|
|
AppsEngineServiceError: mocks.AppsEngineServiceError,
|
|
activateOAuthConnection: mocks.activateOAuthConnection,
|
|
}));
|
|
vi.mock("./kwpOidcClient.server", () => ({
|
|
KwpOidcClientError: mocks.KwpOidcClientError,
|
|
readKwpOidcRuntimeConfig: mocks.readConfig,
|
|
buildKwpAuthorizationUrl: mocks.buildAuthorizationUrl,
|
|
exchangeKwpAuthorizationCode: mocks.exchangeAuthorizationCode,
|
|
}));
|
|
vi.mock("./kwpOAuthChallenge.server", () => ({
|
|
KWP_OAUTH_CHALLENGE_TTL_MS: 10 * 60_000,
|
|
createKwpOAuthChallenge: mocks.createChallenge,
|
|
}));
|
|
|
|
import {
|
|
beginKwpOAuthConnection,
|
|
cancelKwpOAuthConnectionAttempt,
|
|
completeKwpOAuthConnection,
|
|
finalizeKwpOAuthConnection,
|
|
KwpConnectError,
|
|
loadKwpMandantSelection,
|
|
} from "./KwpConnectionService.server";
|
|
|
|
const ACCESS_TOKEN = "access-token-sensitive";
|
|
const REFRESH_TOKEN = "refresh-token-sensitive";
|
|
const CLIENT_SECRET = "client-secret-sensitive";
|
|
const TOKEN_EXPIRES_AT = new Date("2099-07-20T13:05:00.000Z");
|
|
const PENDING_VERSION = new Date("2026-07-20T12:01:00.000Z");
|
|
const PENDING_CIPHERTEXT = "sealed-pending-bundle";
|
|
const PREVIOUS_CIPHERTEXT = "sealed-working-credentials";
|
|
const RESERVATION_EXPIRES_AT = new Date("2099-07-20T13:10:00.000Z");
|
|
|
|
const OAUTH_CREDENTIALS = {
|
|
accessToken: ACCESS_TOKEN,
|
|
refreshToken: REFRESH_TOKEN,
|
|
expiresAt: TOKEN_EXPIRES_AT.toISOString(),
|
|
issuer: "https://id.kwp-online.de/realms/kwp-federated",
|
|
clientId: "_extern-geerkens2",
|
|
clientSecret: CLIENT_SECRET,
|
|
customerCode: "geerkens2",
|
|
scopes: ["openid", "adresse-extern", "archiv-extern", "termin-extern", "vorgang-extern", "aktivitaet-extern", "offline_access"],
|
|
} as const;
|
|
|
|
const AVAILABLE_BUNDLE = {
|
|
kind: "kwp_mandant_selection_v2",
|
|
previous: null,
|
|
oauth: OAUTH_CREDENTIALS,
|
|
discovery: {
|
|
kind: "available",
|
|
options: [
|
|
{ id: 1, code: "001", label: "Geerkens GmbH" },
|
|
{ id: 2, code: "002", label: "Geerkens Service GmbH" },
|
|
],
|
|
},
|
|
issuedAt: "2026-07-20T12:00:30.000Z",
|
|
} as const;
|
|
|
|
const PREVIOUS_CONNECTION = {
|
|
credentialsCiphertext: PREVIOUS_CIPHERTEXT,
|
|
connectionStatus: "active",
|
|
tokenExpiresAt: "2099-07-20T13:00:00.000Z",
|
|
healthMessageKey: "integrations.kwp.health.active",
|
|
lastHealthCheck: "2026-07-20T11:59:00.000Z",
|
|
createdByUserId: "original-owner",
|
|
} as const;
|
|
|
|
const RESERVATION_BUNDLE = {
|
|
kind: "kwp_oauth_reservation_v1",
|
|
customerCode: "geerkens2",
|
|
previous: PREVIOUS_CONNECTION,
|
|
issuedAt: "2099-07-20T13:00:00.000Z",
|
|
expiresAt: RESERVATION_EXPIRES_AT.toISOString(),
|
|
} as const;
|
|
|
|
const MANUAL_BUNDLE = {
|
|
...AVAILABLE_BUNDLE,
|
|
discovery: { kind: "manual" },
|
|
} as const;
|
|
|
|
const RECONNECT_SELECTION_BUNDLE = {
|
|
...AVAILABLE_BUNDLE,
|
|
previous: PREVIOUS_CONNECTION,
|
|
} as const;
|
|
|
|
const EMPTY_AVAILABLE_BUNDLE = {
|
|
...AVAILABLE_BUNDLE,
|
|
discovery: { kind: "available", options: [] },
|
|
} as const;
|
|
|
|
const CHALLENGE = {
|
|
tenantId: "tenant-1",
|
|
tenantSlug: "acme",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
connectionVersion: "2026-07-20T12:00:00.000Z",
|
|
customerCode: "geerkens2",
|
|
state: "a".repeat(64),
|
|
nonce: "b".repeat(64),
|
|
codeVerifier: "v".repeat(64),
|
|
codeChallenge: "c".repeat(43),
|
|
createdAt: 1_725_000_000_000,
|
|
} as const;
|
|
|
|
function pendingRow(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "connection-1",
|
|
updatedAt: PENDING_VERSION,
|
|
credentialsCiphertext: PENDING_CIPHERTEXT,
|
|
tokenExpiresAt: TOKEN_EXPIRES_AT,
|
|
healthMessageKey: "integrations.kwp.health.mandantSelectionRequired",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function reservationRow(overrides: Record<string, unknown> = {}) {
|
|
return pendingRow({
|
|
updatedAt: new Date(CHALLENGE.connectionVersion),
|
|
tokenExpiresAt: RESERVATION_EXPIRES_AT,
|
|
healthMessageKey: "integrations.kwp.health.oauthPending",
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function mockPendingBundle(bundle: unknown = AVAILABLE_BUNDLE): void {
|
|
mocks.findFirst.mockResolvedValue(pendingRow());
|
|
mocks.decryptTenantField.mockResolvedValue(JSON.stringify(bundle));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.context = { tenantId: "tenant-1", userId: "user-1" };
|
|
mocks.findMany.mockResolvedValue([]);
|
|
mocks.findFirst.mockResolvedValue(reservationRow());
|
|
mocks.create.mockResolvedValue({ id: "connection-1", updatedAt: new Date(CHALLENGE.connectionVersion) });
|
|
mocks.updateMany.mockResolvedValue({ count: 1 });
|
|
mocks.encryptTenantField.mockResolvedValue(PENDING_CIPHERTEXT);
|
|
mocks.decryptTenantField.mockResolvedValue(JSON.stringify(RESERVATION_BUNDLE));
|
|
mocks.discoverKwpMandants.mockResolvedValue(AVAILABLE_BUNDLE.discovery.options);
|
|
mocks.readConfig.mockReturnValue({ customerCode: "geerkens2", hiveGridTenantId: "tenant-1" });
|
|
mocks.buildAuthorizationUrl.mockResolvedValue("https://id.kwp-online.de/auth?state=state");
|
|
mocks.createChallenge.mockReturnValue({ payload: CHALLENGE, cookie: "__Host-kwp=challenge; Secure; HttpOnly" });
|
|
mocks.exchangeAuthorizationCode.mockResolvedValue({ credentials: OAUTH_CREDENTIALS, tokenExpiresAt: TOKEN_EXPIRES_AT });
|
|
});
|
|
|
|
describe("beginKwpOAuthConnection", () => {
|
|
it("requires the entered customer code to match the tenant's provisioned pilot code", async () => {
|
|
await expect(beginKwpOAuthConnection({ tenantId: "tenant-1", tenantSlug: "acme", userId: "user-1", customerCode: "other" })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "not_configured" })
|
|
);
|
|
await expect(beginKwpOAuthConnection({ tenantId: "tenant-1", tenantSlug: "acme", userId: "user-1", customerCode: "bad/code" })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "not_configured" })
|
|
);
|
|
|
|
expect(mocks.findMany).not.toHaveBeenCalled();
|
|
expect(mocks.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("serializes and reserves one pending slot before creating the bound challenge", async () => {
|
|
await expect(beginKwpOAuthConnection({ tenantId: "tenant-1", tenantSlug: "acme", userId: "user-1", customerCode: "geerkens2" })).resolves.toEqual({
|
|
authorizationUrl: "https://id.kwp-online.de/auth?state=state",
|
|
setCookie: "__Host-kwp=challenge; Secure; HttpOnly",
|
|
});
|
|
|
|
expect(mocks.executeRaw).toHaveBeenCalledTimes(1);
|
|
expect(mocks.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
tenantId: "tenant-1",
|
|
appId: "kwp",
|
|
credentialsCiphertext: PENDING_CIPHERTEXT,
|
|
connectionStatus: "pending",
|
|
healthMessageKey: "integrations.kwp.health.oauthPending",
|
|
tokenExpiresAt: expect.any(Date),
|
|
createdByUserId: "user-1",
|
|
}),
|
|
select: { id: true, updatedAt: true },
|
|
});
|
|
expect(JSON.parse(mocks.encryptTenantField.mock.calls[0]?.[1] as string)).toEqual(
|
|
expect.objectContaining({ kind: "kwp_oauth_reservation_v1", customerCode: "geerkens2", previous: null })
|
|
);
|
|
expect(mocks.createChallenge).toHaveBeenCalledWith(expect.objectContaining({ customerCode: "geerkens2", connectionId: "connection-1" }));
|
|
expect(mocks.buildAuthorizationUrl).toHaveBeenCalledWith(expect.objectContaining({ customerCode: "geerkens2" }));
|
|
expect(mocks.createAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ action: "INTEGRATION_OAUTH_RESERVE" }));
|
|
});
|
|
|
|
it("invalidates an old pending attempt with an exact new generation before starting another", async () => {
|
|
const previousVersion = PENDING_VERSION;
|
|
mocks.findMany.mockResolvedValueOnce([{ id: "connection-1", updatedAt: previousVersion, connectionStatus: "pending" }]);
|
|
await beginKwpOAuthConnection({ tenantId: "tenant-1", tenantSlug: "acme", userId: "user-1", customerCode: "geerkens2" });
|
|
|
|
expect(mocks.create).not.toHaveBeenCalled();
|
|
const invalidation = mocks.updateMany.mock.calls[0]?.[0];
|
|
expect(invalidation).toEqual({
|
|
where: expect.objectContaining({
|
|
id: "connection-1",
|
|
tenantId: "tenant-1",
|
|
appId: "kwp",
|
|
archivedAt: null,
|
|
connectionStatus: "pending",
|
|
updatedAt: previousVersion,
|
|
}),
|
|
data: expect.objectContaining({
|
|
credentialsCiphertext: PENDING_CIPHERTEXT,
|
|
tokenExpiresAt: expect.any(Date),
|
|
connectionStatus: "pending",
|
|
healthMessageKey: "integrations.kwp.health.oauthPending",
|
|
createdByUserId: "user-1",
|
|
updatedAt: expect.any(Date),
|
|
}),
|
|
});
|
|
const nextVersion = invalidation?.data.updatedAt as Date;
|
|
expect(nextVersion.getTime()).toBeGreaterThan(previousVersion.getTime());
|
|
expect(mocks.createChallenge).toHaveBeenCalledWith(expect.objectContaining({ connectionVersion: nextVersion.toISOString() }));
|
|
expect(mocks.createAuditEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: "INTEGRATION_OAUTH_RESERVE", metadata: { appId: "kwp", created: false } })
|
|
);
|
|
|
|
mocks.findFirst.mockResolvedValueOnce(null);
|
|
await expect(
|
|
finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: previousVersion.toISOString(),
|
|
mandantBinding: { kind: "none" },
|
|
})
|
|
).rejects.toEqual(expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_state" }));
|
|
expect(mocks.activateOAuthConnection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reserves an active slot while preserving its full working snapshot", async () => {
|
|
mocks.findMany.mockResolvedValueOnce([{
|
|
id: "connection-1",
|
|
updatedAt: PENDING_VERSION,
|
|
connectionStatus: "active",
|
|
credentialsCiphertext: PREVIOUS_CIPHERTEXT,
|
|
tokenExpiresAt: new Date(PREVIOUS_CONNECTION.tokenExpiresAt),
|
|
healthMessageKey: PREVIOUS_CONNECTION.healthMessageKey,
|
|
lastHealthCheck: new Date(PREVIOUS_CONNECTION.lastHealthCheck),
|
|
createdByUserId: PREVIOUS_CONNECTION.createdByUserId,
|
|
}]);
|
|
await beginKwpOAuthConnection({ tenantId: "tenant-1", tenantSlug: "acme", userId: "user-1", customerCode: "geerkens2" });
|
|
|
|
expect(mocks.updateMany).toHaveBeenCalledWith({
|
|
where: expect.objectContaining({ connectionStatus: "active", updatedAt: PENDING_VERSION }),
|
|
data: expect.objectContaining({
|
|
credentialsCiphertext: PENDING_CIPHERTEXT,
|
|
connectionStatus: "pending",
|
|
healthMessageKey: "integrations.kwp.health.oauthPending",
|
|
updatedAt: expect.any(Date),
|
|
}),
|
|
});
|
|
expect(JSON.parse(mocks.encryptTenantField.mock.calls[0]?.[1] as string).previous).toEqual(PREVIOUS_CONNECTION);
|
|
});
|
|
|
|
it("restores the working connection immediately when authorization cannot start", async () => {
|
|
mocks.findMany.mockResolvedValueOnce([{
|
|
id: "connection-1",
|
|
updatedAt: PENDING_VERSION,
|
|
connectionStatus: "active",
|
|
credentialsCiphertext: PREVIOUS_CIPHERTEXT,
|
|
tokenExpiresAt: new Date(PREVIOUS_CONNECTION.tokenExpiresAt),
|
|
healthMessageKey: PREVIOUS_CONNECTION.healthMessageKey,
|
|
lastHealthCheck: new Date(PREVIOUS_CONNECTION.lastHealthCheck),
|
|
createdByUserId: PREVIOUS_CONNECTION.createdByUserId,
|
|
}]);
|
|
mocks.buildAuthorizationUrl.mockRejectedValueOnce(new Error("provider unavailable"));
|
|
|
|
await expect(
|
|
beginKwpOAuthConnection({ tenantId: "tenant-1", tenantSlug: "acme", userId: "user-1", customerCode: "geerkens2" })
|
|
).rejects.toEqual(expect.objectContaining<Partial<KwpConnectError>>({ code: "authorization_failed" }));
|
|
|
|
expect(mocks.updateMany).toHaveBeenCalledTimes(2);
|
|
expect(mocks.updateMany.mock.calls[1]?.[0]).toEqual(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
credentialsCiphertext: PREVIOUS_CIPHERTEXT,
|
|
connectionStatus: "active",
|
|
createdByUserId: "original-owner",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("carries the original active snapshot into a replacement browser attempt", async () => {
|
|
mocks.findMany.mockResolvedValueOnce([{
|
|
id: "connection-1",
|
|
updatedAt: PENDING_VERSION,
|
|
connectionStatus: "pending",
|
|
credentialsCiphertext: PENDING_CIPHERTEXT,
|
|
tokenExpiresAt: RESERVATION_EXPIRES_AT,
|
|
healthMessageKey: "integrations.kwp.health.oauthPending",
|
|
lastHealthCheck: null,
|
|
createdByUserId: "user-1",
|
|
}]);
|
|
|
|
await beginKwpOAuthConnection({ tenantId: "tenant-1", tenantSlug: "acme", userId: "user-1", customerCode: "geerkens2" });
|
|
|
|
expect(JSON.parse(mocks.encryptTenantField.mock.calls[0]?.[1] as string).previous).toEqual(PREVIOUS_CONNECTION);
|
|
});
|
|
|
|
it("rejects ambiguous rows and a lost attempt-generation CAS", async () => {
|
|
mocks.findMany.mockResolvedValueOnce([
|
|
{ id: "one", updatedAt: new Date(CHALLENGE.connectionVersion), connectionStatus: "pending" },
|
|
{ id: "two", updatedAt: new Date(CHALLENGE.connectionVersion), connectionStatus: "pending" },
|
|
]);
|
|
await expect(beginKwpOAuthConnection({ tenantId: "tenant-1", tenantSlug: "acme", userId: "user-1", customerCode: "geerkens2" })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "slot_conflict" })
|
|
);
|
|
|
|
mocks.findMany.mockResolvedValueOnce([{ id: "one", updatedAt: PENDING_VERSION, connectionStatus: "pending" }]);
|
|
mocks.updateMany.mockResolvedValueOnce({ count: 0 });
|
|
await expect(beginKwpOAuthConnection({ tenantId: "tenant-1", tenantSlug: "acme", userId: "user-1", customerCode: "geerkens2" })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "slot_conflict" })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("completeKwpOAuthConnection", () => {
|
|
it("discovers immediately and CAS-stages an encrypted pending bundle without activating", async () => {
|
|
await expect(completeKwpOAuthConnection({ callbackParameters: { code: "one-time-code", state: CHALLENGE.state }, challenge: CHALLENGE })).resolves.toEqual({
|
|
tenantId: "tenant-1",
|
|
tenantSlug: "acme",
|
|
connectionId: "connection-1",
|
|
});
|
|
|
|
expect(mocks.discoverKwpMandants).toHaveBeenCalledWith(expect.objectContaining({ accessToken: ACCESS_TOKEN, customerCode: "geerkens2" }));
|
|
const plaintext = mocks.encryptTenantField.mock.calls[0]?.[1] as string;
|
|
expect(JSON.parse(plaintext)).toEqual(
|
|
expect.objectContaining({
|
|
kind: "kwp_mandant_selection_v2",
|
|
previous: PREVIOUS_CONNECTION,
|
|
oauth: expect.objectContaining({ accessToken: ACCESS_TOKEN, refreshToken: REFRESH_TOKEN, clientSecret: CLIENT_SECRET }),
|
|
discovery: AVAILABLE_BUNDLE.discovery,
|
|
})
|
|
);
|
|
expect(JSON.parse(plaintext).oauth).not.toHaveProperty("mandantBinding");
|
|
expect(mocks.updateMany).toHaveBeenCalledWith({
|
|
where: expect.objectContaining({
|
|
id: "connection-1",
|
|
tenantId: "tenant-1",
|
|
appId: "kwp",
|
|
archivedAt: null,
|
|
updatedAt: new Date(CHALLENGE.connectionVersion),
|
|
credentialsCiphertext: PENDING_CIPHERTEXT,
|
|
tokenExpiresAt: RESERVATION_EXPIRES_AT,
|
|
healthMessageKey: "integrations.kwp.health.oauthPending",
|
|
}),
|
|
data: expect.objectContaining({
|
|
credentialsCiphertext: PENDING_CIPHERTEXT,
|
|
tokenExpiresAt: TOKEN_EXPIRES_AT,
|
|
connectionStatus: "pending",
|
|
createdByUserId: "user-1",
|
|
}),
|
|
});
|
|
expect(mocks.activateOAuthConnection).not.toHaveBeenCalled();
|
|
|
|
const persistedAndAudited = JSON.stringify([mocks.updateMany.mock.calls, mocks.createAuditEvent.mock.calls]);
|
|
expect(persistedAndAudited).not.toContain(ACCESS_TOKEN);
|
|
expect(persistedAndAudited).not.toContain(REFRESH_TOKEN);
|
|
expect(persistedAndAudited).not.toContain(CLIENT_SECRET);
|
|
});
|
|
|
|
it.each([
|
|
["403", new mocks.KwpError("mandant_discovery_unavailable", 403)],
|
|
["404", new mocks.KwpError("mandant_discovery_unavailable", 404)],
|
|
["invalid response", new mocks.KwpError("invalid_response", 200)],
|
|
["provider outage", new mocks.KwpError("provider_outage", 503)],
|
|
["rate limit", new mocks.KwpError("rate_limit_exceeded", 429)],
|
|
])("stages manual selection on the documented %s discovery fallback", async (_label, error) => {
|
|
mocks.discoverKwpMandants.mockRejectedValueOnce(error);
|
|
await completeKwpOAuthConnection({ callbackParameters: { code: "code", state: CHALLENGE.state }, challenge: CHALLENGE });
|
|
|
|
const plaintext = JSON.parse(mocks.encryptTenantField.mock.calls[0]?.[1] as string);
|
|
expect(plaintext.discovery).toEqual({ kind: "manual" });
|
|
expect(mocks.createAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ metadata: { appId: "kwp", discovery: "manual", optionCount: 0 } }));
|
|
});
|
|
|
|
it.each([
|
|
["401", new mocks.KwpError("auth_token_invalid", 401), "discovery_auth_token_invalid"],
|
|
["invalid credentials", new mocks.KwpError("invalid_credentials", 400), "discovery_invalid_credentials"],
|
|
["invalid relay host", new mocks.KwpError("relay_host_invalid"), "discovery_relay_host_invalid"],
|
|
["unconfirmed binding", new mocks.KwpError("mandant_unconfirmed"), "discovery_mandant_unconfirmed"],
|
|
])("fails closed on %s discovery errors and restores the previous connection", async (_label, error, diagnosticCode) => {
|
|
mocks.discoverKwpMandants.mockRejectedValueOnce(error);
|
|
await expect(completeKwpOAuthConnection({ callbackParameters: { code: "code", state: CHALLENGE.state }, challenge: CHALLENGE })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "connection_failed", diagnosticCode })
|
|
);
|
|
expect(mocks.encryptTenantField).not.toHaveBeenCalled();
|
|
expect(mocks.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
credentialsCiphertext: PREVIOUS_CIPHERTEXT,
|
|
connectionStatus: "active",
|
|
createdByUserId: "original-owner",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("normalizes a missing provider label and falls back manually on an invalid discovered option", async () => {
|
|
mocks.discoverKwpMandants.mockResolvedValueOnce([{ id: 7, code: "007" }]);
|
|
await completeKwpOAuthConnection({ callbackParameters: { code: "code", state: CHALLENGE.state }, challenge: CHALLENGE });
|
|
expect(JSON.parse(mocks.encryptTenantField.mock.calls[0]?.[1] as string).discovery).toEqual({
|
|
kind: "available",
|
|
options: [{ id: 7, code: "007", label: "007" }],
|
|
});
|
|
|
|
mocks.encryptTenantField.mockClear();
|
|
mocks.discoverKwpMandants.mockResolvedValueOnce([{ id: 7, code: "bad/code", label: "Invalid" }]);
|
|
await completeKwpOAuthConnection({ callbackParameters: { code: "code", state: CHALLENGE.state }, challenge: CHALLENGE });
|
|
expect(JSON.parse(mocks.encryptTenantField.mock.calls[0]?.[1] as string).discovery).toEqual({ kind: "manual" });
|
|
});
|
|
|
|
it("rejects a callback CAS race and preserves stable token-exchange diagnostics", async () => {
|
|
mocks.updateMany.mockResolvedValueOnce({ count: 0 });
|
|
await expect(completeKwpOAuthConnection({ callbackParameters: { code: "code", state: CHALLENGE.state }, challenge: CHALLENGE })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_state" })
|
|
);
|
|
|
|
mocks.exchangeAuthorizationCode.mockRejectedValueOnce(new mocks.KwpOidcClientError("invalid-token-response", { diagnosticCode: "missing_refresh_token" }));
|
|
await expect(completeKwpOAuthConnection({ callbackParameters: { code: "code", state: CHALLENGE.state }, challenge: CHALLENGE })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "exchange_failed", diagnosticCode: "missing_refresh_token" })
|
|
);
|
|
expect(mocks.updateMany).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ data: expect.objectContaining({ credentialsCiphertext: PREVIOUS_CIPHERTEXT, connectionStatus: "active" }) })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("cancelKwpOAuthConnectionAttempt", () => {
|
|
it("restores the exact pre-reconnect credentials, health and owner", async () => {
|
|
mockPendingBundle(RECONNECT_SELECTION_BUNDLE);
|
|
|
|
await cancelKwpOAuthConnectionAttempt({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
});
|
|
|
|
expect(mocks.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
updatedAt: PENDING_VERSION,
|
|
credentialsCiphertext: PENDING_CIPHERTEXT,
|
|
healthMessageKey: "integrations.kwp.health.mandantSelectionRequired",
|
|
}),
|
|
data: expect.objectContaining({
|
|
credentialsCiphertext: PREVIOUS_CIPHERTEXT,
|
|
connectionStatus: "active",
|
|
tokenExpiresAt: new Date(PREVIOUS_CONNECTION.tokenExpiresAt),
|
|
healthMessageKey: PREVIOUS_CONNECTION.healthMessageKey,
|
|
lastHealthCheck: new Date(PREVIOUS_CONNECTION.lastHealthCheck),
|
|
createdByUserId: PREVIOUS_CONNECTION.createdByUserId,
|
|
updatedAt: expect.any(Date),
|
|
}),
|
|
})
|
|
);
|
|
expect(mocks.createAuditEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: "INTEGRATION_OAUTH_CANCEL", metadata: { appId: "kwp", restored: true } })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("loadKwpMandantSelection", () => {
|
|
it("returns only the safe projection from the tenant/user-bound pending row", async () => {
|
|
mockPendingBundle();
|
|
const view = await loadKwpMandantSelection({ tenantId: "tenant-1", userId: "user-1", connectionId: "connection-1" });
|
|
|
|
expect(view).toEqual({
|
|
connectionId: "connection-1",
|
|
customerCode: "geerkens2",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mode: "available",
|
|
options: AVAILABLE_BUNDLE.discovery.options,
|
|
});
|
|
expect(JSON.stringify(view)).not.toContain(ACCESS_TOKEN);
|
|
expect(JSON.stringify(view)).not.toContain(REFRESH_TOKEN);
|
|
expect(JSON.stringify(view)).not.toContain(CLIENT_SECRET);
|
|
expect(mocks.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ tenantId: "tenant-1", connectionStatus: "pending", createdByUserId: "user-1" }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("clears an expired attempt with its exact ciphertext/version CAS", async () => {
|
|
const expired = { ...AVAILABLE_BUNDLE, oauth: { ...OAUTH_CREDENTIALS, expiresAt: "2020-01-01T00:00:00.000Z" } };
|
|
mocks.findFirst.mockResolvedValue(pendingRow({ tokenExpiresAt: new Date(expired.oauth.expiresAt) }));
|
|
mocks.decryptTenantField.mockResolvedValue(JSON.stringify(expired));
|
|
|
|
await expect(loadKwpMandantSelection({ tenantId: "tenant-1", userId: "user-1", connectionId: "connection-1" })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "selection_expired" })
|
|
);
|
|
expect(mocks.updateMany).toHaveBeenCalledWith({
|
|
where: expect.objectContaining({
|
|
connectionStatus: "pending",
|
|
createdByUserId: "user-1",
|
|
updatedAt: PENDING_VERSION,
|
|
credentialsCiphertext: PENDING_CIPHERTEXT,
|
|
}),
|
|
data: expect.objectContaining({ credentialsCiphertext: null, tokenExpiresAt: null, connectionStatus: "pending" }),
|
|
});
|
|
});
|
|
|
|
it("rejects an expired-attempt clear when its exact CAS generation was already replaced", async () => {
|
|
const expired = { ...AVAILABLE_BUNDLE, oauth: { ...OAUTH_CREDENTIALS, expiresAt: "2020-01-01T00:00:00.000Z" } };
|
|
mocks.findFirst.mockResolvedValue(pendingRow({ tokenExpiresAt: new Date(expired.oauth.expiresAt) }));
|
|
mocks.decryptTenantField.mockResolvedValue(JSON.stringify(expired));
|
|
mocks.updateMany.mockResolvedValueOnce({ count: 0 });
|
|
|
|
await expect(loadKwpMandantSelection({ tenantId: "tenant-1", userId: "user-1", connectionId: "connection-1" })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_state" })
|
|
);
|
|
});
|
|
|
|
it("rejects another identity and malformed encrypted state", async () => {
|
|
mocks.context = { tenantId: "tenant-1", userId: "other-user" };
|
|
await expect(loadKwpMandantSelection({ tenantId: "tenant-1", userId: "user-1", connectionId: "connection-1" })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_state" })
|
|
);
|
|
expect(mocks.findFirst).not.toHaveBeenCalled();
|
|
|
|
mocks.context = { tenantId: "tenant-1", userId: "user-1" };
|
|
mockPendingBundle({ kind: "unexpected" });
|
|
await expect(loadKwpMandantSelection({ tenantId: "tenant-1", userId: "user-1", connectionId: "connection-1" })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_state" })
|
|
);
|
|
|
|
mockPendingBundle({
|
|
...AVAILABLE_BUNDLE,
|
|
discovery: { kind: "available", options: [{ id: 1, code: "bad/code", label: "Invalid" }] },
|
|
});
|
|
await expect(loadKwpMandantSelection({ tenantId: "tenant-1", userId: "user-1", connectionId: "connection-1" })).rejects.toEqual(
|
|
expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_state" })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("finalizeKwpOAuthConnection", () => {
|
|
it("accepts only a discovered option, canonicalizes its label, and activates with the exact version fence", async () => {
|
|
mockPendingBundle();
|
|
await expect(
|
|
finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "selected", code: "002", label: "tampered browser label" },
|
|
})
|
|
).resolves.toEqual({ connectionId: "connection-1" });
|
|
|
|
expect(mocks.activateOAuthConnection).toHaveBeenCalledWith({
|
|
tenantId: "tenant-1",
|
|
connectionId: "connection-1",
|
|
appId: "kwp",
|
|
credentials: expect.objectContaining({
|
|
accessToken: ACCESS_TOKEN,
|
|
mandantBinding: { kind: "selected", code: "002", label: "Geerkens Service GmbH" },
|
|
}),
|
|
tokenExpiresAt: TOKEN_EXPIRES_AT,
|
|
actorUserId: "user-1",
|
|
expectedUpdatedAt: PENDING_VERSION,
|
|
});
|
|
expect(mocks.createAuditEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: "INTEGRATION_KWP_MANDANT_BIND",
|
|
metadata: { appId: "kwp", binding: "selected", source: "available" },
|
|
})
|
|
);
|
|
expect(JSON.stringify(mocks.createAuditEvent.mock.calls)).not.toContain("002");
|
|
});
|
|
|
|
it.each([{ kind: "none" } as const, { kind: "selected", code: "999" } as const])(
|
|
"rejects a non-listed binding when discovery returned options",
|
|
async (mandantBinding) => {
|
|
mockPendingBundle();
|
|
await expect(
|
|
finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding,
|
|
})
|
|
).rejects.toEqual(expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_selection" }));
|
|
expect(mocks.activateOAuthConnection).not.toHaveBeenCalled();
|
|
}
|
|
);
|
|
|
|
it("allows an explicit none or validated manual code only in manual/empty mode", async () => {
|
|
mockPendingBundle(MANUAL_BUNDLE);
|
|
await finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "none" },
|
|
});
|
|
expect(mocks.activateOAuthConnection).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
credentials: expect.objectContaining({ mandantBinding: { kind: "none" } }),
|
|
})
|
|
);
|
|
|
|
mocks.activateOAuthConnection.mockClear();
|
|
mockPendingBundle(MANUAL_BUNDLE);
|
|
await finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "selected", code: "007", label: "ignored" },
|
|
});
|
|
expect(mocks.activateOAuthConnection).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
credentials: expect.objectContaining({ mandantBinding: { kind: "selected", code: "007" } }),
|
|
})
|
|
);
|
|
|
|
mocks.activateOAuthConnection.mockClear();
|
|
mockPendingBundle(EMPTY_AVAILABLE_BUNDLE);
|
|
await finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "none" },
|
|
});
|
|
expect(mocks.activateOAuthConnection).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ credentials: expect.objectContaining({ mandantBinding: { kind: "none" } }) })
|
|
);
|
|
|
|
mocks.activateOAuthConnection.mockClear();
|
|
mockPendingBundle(EMPTY_AVAILABLE_BUNDLE);
|
|
await finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "selected", code: "008" },
|
|
});
|
|
expect(mocks.activateOAuthConnection).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ credentials: expect.objectContaining({ mandantBinding: { kind: "selected", code: "008" } }) })
|
|
);
|
|
});
|
|
|
|
it("rejects malformed manual codes and stale versions before provider I/O", async () => {
|
|
mockPendingBundle(MANUAL_BUNDLE);
|
|
await expect(
|
|
finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "selected", code: "bad/code" },
|
|
})
|
|
).rejects.toEqual(expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_selection" }));
|
|
|
|
mocks.findFirst.mockResolvedValueOnce(null);
|
|
await expect(
|
|
finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "none" },
|
|
})
|
|
).rejects.toEqual(expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_state" }));
|
|
expect(mocks.activateOAuthConnection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears the exact pending attempt and reports selection_expired after a Health 401", async () => {
|
|
mockPendingBundle(MANUAL_BUNDLE);
|
|
mocks.activateOAuthConnection.mockRejectedValueOnce(new mocks.AppsEngineServiceError("integrations.kwp.health.tokenInvalid"));
|
|
|
|
await expect(
|
|
finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "none" },
|
|
})
|
|
).rejects.toEqual(expect.objectContaining<Partial<KwpConnectError>>({ code: "selection_expired" }));
|
|
expect(mocks.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ updatedAt: PENDING_VERSION, credentialsCiphertext: PENDING_CIPHERTEXT }),
|
|
data: expect.objectContaining({ credentialsCiphertext: null, tokenExpiresAt: null, connectionStatus: "pending" }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("rejects a Health-401 clear when another attempt already won the CAS", async () => {
|
|
mockPendingBundle(MANUAL_BUNDLE);
|
|
mocks.activateOAuthConnection.mockRejectedValueOnce(new mocks.AppsEngineServiceError("integrations.kwp.health.tokenInvalid"));
|
|
mocks.updateMany.mockResolvedValueOnce({ count: 0 });
|
|
|
|
await expect(
|
|
finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "none" },
|
|
})
|
|
).rejects.toEqual(expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_state" }));
|
|
});
|
|
|
|
it("maps a lost activation CAS after the Health probe to invalid_state", async () => {
|
|
mockPendingBundle(MANUAL_BUNDLE);
|
|
mocks.activateOAuthConnection.mockRejectedValueOnce(new CatalogError(foundationErrors.notFound, { connectionId: "connection-1" }));
|
|
|
|
await expect(
|
|
finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "none" },
|
|
})
|
|
).rejects.toEqual(expect.objectContaining<Partial<KwpConnectError>>({ code: "invalid_state" }));
|
|
expect(mocks.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the pending attempt on non-401 provider rejection", async () => {
|
|
mockPendingBundle(MANUAL_BUNDLE);
|
|
mocks.activateOAuthConnection.mockRejectedValueOnce(new mocks.AppsEngineServiceError("integrations.kwp.health.scopeMissing"));
|
|
|
|
await expect(
|
|
finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "none" },
|
|
})
|
|
).rejects.toEqual(expect.objectContaining<Partial<KwpConnectError>>({ code: "connection_failed", diagnosticCode: "health_scope_missing" }));
|
|
expect(mocks.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps an unexpectedly unconfirmed Health result pending with a stable diagnostic", async () => {
|
|
mockPendingBundle(MANUAL_BUNDLE);
|
|
mocks.activateOAuthConnection.mockRejectedValueOnce(new mocks.AppsEngineServiceError("integrations.kwp.health.mandantUnconfirmed"));
|
|
|
|
await expect(
|
|
finalizeKwpOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
version: PENDING_VERSION.toISOString(),
|
|
mandantBinding: { kind: "none" },
|
|
})
|
|
).rejects.toEqual(expect.objectContaining<Partial<KwpConnectError>>({ code: "connection_failed", diagnosticCode: "health_mandant_unconfirmed" }));
|
|
expect(mocks.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
});
|