Fix: Granting staff access to appointments

This commit is contained in:
Karl Ludwig Weise
2026-04-28 20:54:47 +02:00
parent a8481a96ba
commit 7a955b3a70
10 changed files with 568 additions and 14 deletions
+1 -1
View File
@@ -1036,7 +1036,7 @@
"reason": "Sie erhalten diese E-Mail, weil jemand für die PIN für Ihr Konto geändert hat."
},
"userInvite": {
"subject": "E-Mail Adresse bestätigen für {tenant}",
"subject": "E-Mail Adresse bestätigen",
"introduction": "willkommen beim Terminbuchungsportal von {tenant}. Bitte bestätige Deine E-Mail Adresse.",
"action": "E-Mail Adresse bestätigen",
"hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.",
+20
View File
@@ -1247,6 +1247,26 @@ export class UnifiedAppointmentCrypto {
return data.staffPublicKeys;
}
async fetchStaffPublicKeysByStaff(tenantId: string): Promise<StaffPublicKey[]> {
const response = await fetch(
`/api/tenants/${tenantId}/appointments/staff-public-keys-by-staff`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.bookingAccessToken}`,
},
},
);
if (!response.ok) {
throw new Error(`Failed to fetch staff public keys for staff: ${response.statusText}`);
}
const data = await response.json();
return data.staffPublicKeys;
}
private async bootstrapNewClientAccess(tenantId: string): Promise<void> {
if (!this.tunnelId || !this.clientKeyPair) {
throw new Error("Bootstrap requires generated client tunnel and key pair");
@@ -35,7 +35,7 @@
step = "add-staff-key-shares";
// For each tunnel: decrypt currentStaffEncryptedTunnelKey with current user private key
const allPublicKeys = await cryptoClient.fetchStaffPublicKeys(tenantId);
const allPublicKeys = await cryptoClient.fetchStaffPublicKeysByStaff(tenantId);
const newUserPublicKeys = allPublicKeys.filter((x) => x.userId === entity.id);
if (newUserPublicKeys.length === 0) {
@@ -5,6 +5,7 @@ import { BackendError, InternalError, logError, ValidationError } from "$lib/ser
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { AppointmentService } from "$lib/server/services/appointment-service";
// Register OpenAPI documentation
registerOpenAPIRoute("/auth/register", "POST", {
@@ -154,6 +155,20 @@ export const POST: RequestHandler = async ({ params, cookies, request, url }) =>
counter: verificationResult.counter,
});
// Set user to ACCESS_GRANTED, if no appointments exists
try {
const user = await UserService.getUserByEmail(body.email);
if (user.tenantId) {
const appointmentService = await AppointmentService.forTenant(user.tenantId);
const hasAppointments = await appointmentService.hasAppointments();
if (!hasAppointments) {
await UserService.updateUser(user.id, { confirmationState: "ACCESS_GRANTED" });
}
}
} catch {
// Silent fail for now
}
return json(
{
message: "User account now has a passkey.",
@@ -228,8 +228,6 @@ export const GET: RequestHandler = async ({ params, locals }) => {
throw new ValidationError("Tenant ID and appointment ID are required");
}
// checkPermission(locals, tenantId, true);
log.debug("Getting appointment by ID", {
tenantId,
appointmentId,
@@ -0,0 +1,123 @@
import { logger } from "$lib/logger";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { StaffCryptoService } from "$lib/server/services/staff-crypto.service";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { checkPermission } from "$lib/server/utils/permissions";
import { json, type RequestHandler } from "@sveltejs/kit";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys-by-staff", "GET", {
summary: "Get staff public keys",
description:
"Returns public encryption keys for all staff members. Requires a valid access token.",
tags: ["GrantAccess"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
responses: {
"200": {
description: "Staff public keys retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
staffPublicKeys: {
type: "array",
items: {
type: "object",
properties: {
userId: {
type: "string",
format: "uuid",
description: "Staff member's user ID",
},
publicKey: {
type: "string",
description: "ML-KEM-768 public key (hex encoded)",
example: "deadbeef123456789abcdef...",
},
},
required: ["userId", "publicKey"],
},
description: "List of staff public keys for encryption",
},
},
required: ["staffPublicKeys"],
},
},
},
},
"400": {
description: "Invalid tenant ID",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Missing or invalid cookie access token",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error or no staff keys found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
/**
* GET /api/tenants/[id]/appointments/staff-public-keys-by-staff
*
* Returns the public keys of all staff members for encryption.
* Requires booking access token from verify-challenge or bootstrap-verify endpoint.
*/
export const GET: RequestHandler = async ({ params, locals }) => {
try {
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
checkPermission(locals, tenantId, false);
logger.info("Fetching staff public keys", { tenantId });
// Get staff public keys for encryption
const staffCryptoService = new StaffCryptoService();
const staffPublicKeys = await staffCryptoService.getStaffPublicKeys(tenantId);
if (staffPublicKeys.length === 0) {
logger.warn("No staff public keys found for tenant", { tenantId });
throw new InternalError("No staff members with encryption keys found");
}
logger.info("Successfully retrieved staff public keys", {
tenantId,
staffCount: staffPublicKeys.length,
});
return json({ staffPublicKeys });
} catch (error) {
logError(logger)("Failed to fetch staff public keys", error);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,385 @@
import { logger } from "$lib/logger";
import { StaffCryptoService } from "$lib/server/services/staff-crypto.service";
import { AuthenticationError, AuthorizationError } from "$lib/server/utils/errors";
import { checkPermission } from "$lib/server/utils/permissions";
import type { RequestEvent } from "@sveltejs/kit";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GET } from "../+server";
// Mock dependencies
vi.mock("$lib/logger");
vi.mock("$lib/server/services/staff-crypto.service");
vi.mock("$lib/server/utils/permissions");
vi.mock("$lib/server/openapi");
const mockCookies = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
};
const createMockRequestEvent = (tenantId: string, locals: unknown = {}): RequestEvent =>
({
params: { id: tenantId },
request: new Request(
`http://localhost/api/tenants/${tenantId}/appointments/staff-public-keys-by-staff`,
),
url: new URL(
`http://localhost/api/tenants/${tenantId}/appointments/staff-public-keys-by-staff`,
),
route: { id: "/api/tenants/[id]/appointments/staff-public-keys-by-staff" },
locals,
cookies: mockCookies,
fetch: global.fetch,
getClientAddress: () => "127.0.0.1",
isDataRequest: false,
platform: undefined,
setHeaders: vi.fn(),
depends: vi.fn(),
parent: vi.fn(),
}) as unknown as RequestEvent;
describe("GET /api/tenants/[id]/appointments/staff-public-keys-by-staff", () => {
const mockTenantId = "550e8400-e29b-41d4-a716-446655440000";
const mockStaffId1 = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
const mockStaffId2 = "f47ac10b-58cc-4372-a567-0e02b2c3d480";
const mockStaffPublicKeys = [
{
userId: mockStaffId1,
publicKey: "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678",
passkeyId: "key1",
},
{
userId: mockStaffId2,
publicKey: "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678",
passkeyId: "key2",
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(logger.info).mockImplementation(() => {});
vi.mocked(logger.warn).mockImplementation(() => {});
});
describe("Success Cases", () => {
it("should return staff public keys for authenticated user with valid cookie", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
vi.mocked(checkPermission).mockImplementationOnce(() => {});
vi.mocked(StaffCryptoService.prototype.getStaffPublicKeys).mockResolvedValueOnce(
mockStaffPublicKeys,
);
const event = createMockRequestEvent(mockTenantId, locals);
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.staffPublicKeys).toEqual(mockStaffPublicKeys);
expect(data.staffPublicKeys).toHaveLength(2);
});
it("should return multiple staff public keys", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
vi.mocked(checkPermission).mockImplementationOnce(() => {});
const manyStaffKeys = Array.from({ length: 5 }, (_, i) => ({
userId: `staff-${i}`,
publicKey: `key-${i}${"a".repeat(60)}`,
passkeyId: `passkey-${i}`,
}));
vi.mocked(StaffCryptoService.prototype.getStaffPublicKeys).mockResolvedValueOnce(
manyStaffKeys,
);
const event = createMockRequestEvent(mockTenantId, locals);
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.staffPublicKeys).toHaveLength(5);
expect(data.staffPublicKeys[0]).toHaveProperty("userId");
expect(data.staffPublicKeys[0]).toHaveProperty("publicKey");
});
it("should call StaffCryptoService with correct tenant ID", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
vi.mocked(checkPermission).mockImplementationOnce(() => {});
const mockService = {
getStaffPublicKeys: vi.fn().mockResolvedValueOnce(mockStaffPublicKeys),
};
vi.mocked(StaffCryptoService).mockImplementationOnce(
() => mockService as unknown as StaffCryptoService,
);
const event = createMockRequestEvent(mockTenantId, locals);
await GET(event);
expect(mockService.getStaffPublicKeys).toHaveBeenCalledWith(mockTenantId);
expect(mockService.getStaffPublicKeys).toHaveBeenCalledOnce();
});
it("should call checkPermission with tenant ID and false flag", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
vi.mocked(checkPermission).mockImplementationOnce(() => {});
vi.mocked(StaffCryptoService.prototype.getStaffPublicKeys).mockResolvedValueOnce(
mockStaffPublicKeys,
);
const event = createMockRequestEvent(mockTenantId, locals);
await GET(event);
expect(vi.mocked(checkPermission)).toHaveBeenCalledWith(locals, mockTenantId, false);
expect(vi.mocked(checkPermission)).toHaveBeenCalledOnce();
});
});
describe("Validation & Error Cases", () => {
it("should return 422 when tenant ID is missing (ValidationError from SvelteKit)", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
const event = createMockRequestEvent("", locals);
const response = await GET(event);
// ValidationError returns 422 in SvelteKit
expect(response.status).toBe(422);
const data = await response.json();
expect(data).toHaveProperty("error");
});
it("should return 401 when user is not authenticated", async () => {
vi.mocked(checkPermission).mockImplementationOnce(() => {
throw new AuthenticationError("Authentication required");
});
const locals = { user: null };
const event = createMockRequestEvent(mockTenantId, locals);
const response = await GET(event);
expect(response.status).toBe(401);
const data = await response.json();
expect(data).toHaveProperty("error");
});
it("should return 401 when locals are missing user", async () => {
vi.mocked(checkPermission).mockImplementationOnce(() => {
throw new AuthenticationError("Authentication required");
});
const event = createMockRequestEvent(mockTenantId, {});
const response = await GET(event);
expect(response.status).toBe(401);
const data = await response.json();
expect(data).toHaveProperty("error");
});
it("should return 403 when user lacks permission for tenant", async () => {
vi.mocked(checkPermission).mockImplementationOnce(() => {
throw new AuthorizationError("Insufficient permissions");
});
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: "different-tenant-id",
},
};
const event = createMockRequestEvent(mockTenantId, locals);
const response = await GET(event);
expect(response.status).toBe(403);
const data = await response.json();
expect(data).toHaveProperty("error");
});
it("should return 500 when no staff keys are found", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
vi.mocked(checkPermission).mockImplementationOnce(() => {});
vi.mocked(StaffCryptoService.prototype.getStaffPublicKeys).mockResolvedValueOnce([]);
const event = createMockRequestEvent(mockTenantId, locals);
const response = await GET(event);
expect(response.status).toBe(500);
const data = await response.json();
expect(data).toHaveProperty("error");
});
it("should return 500 when StaffCryptoService throws an error", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
vi.mocked(checkPermission).mockImplementationOnce(() => {});
const serviceError = new Error("Database connection failed");
vi.mocked(StaffCryptoService.prototype.getStaffPublicKeys).mockRejectedValueOnce(
serviceError,
);
const event = createMockRequestEvent(mockTenantId, locals);
const response = await GET(event);
expect(response.status).toBe(500);
});
});
describe("Logging", () => {
it("should log info when successfully fetching staff keys", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
vi.mocked(checkPermission).mockImplementationOnce(() => {});
vi.mocked(StaffCryptoService.prototype.getStaffPublicKeys).mockResolvedValueOnce(
mockStaffPublicKeys,
);
const event = createMockRequestEvent(mockTenantId, locals);
await GET(event);
expect(vi.mocked(logger.info)).toHaveBeenCalledWith("Fetching staff public keys", {
tenantId: mockTenantId,
});
expect(vi.mocked(logger.info)).toHaveBeenCalledWith(
"Successfully retrieved staff public keys",
{
tenantId: mockTenantId,
staffCount: 2,
},
);
});
it("should log warning when no staff keys found", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
vi.mocked(checkPermission).mockImplementationOnce(() => {});
vi.mocked(StaffCryptoService.prototype.getStaffPublicKeys).mockResolvedValueOnce([]);
const event = createMockRequestEvent(mockTenantId, locals);
await GET(event);
expect(vi.mocked(logger.warn)).toHaveBeenCalledWith("No staff public keys found for tenant", {
tenantId: mockTenantId,
});
});
});
describe("Response Format", () => {
it("should return properly formatted JSON response", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
vi.mocked(checkPermission).mockImplementationOnce(() => {});
vi.mocked(StaffCryptoService.prototype.getStaffPublicKeys).mockResolvedValueOnce(
mockStaffPublicKeys,
);
const event = createMockRequestEvent(mockTenantId, locals);
const response = await GET(event);
expect(response.headers.get("content-type")).toContain("application/json");
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toHaveProperty("staffPublicKeys");
expect(Array.isArray(data.staffPublicKeys)).toBe(true);
});
it("should include complete staff key objects with userId and publicKey", async () => {
const locals = {
user: {
userId: "booking-user-id",
role: "GUEST",
tenantId: mockTenantId,
},
};
vi.mocked(checkPermission).mockImplementationOnce(() => {});
vi.mocked(StaffCryptoService.prototype.getStaffPublicKeys).mockResolvedValueOnce(
mockStaffPublicKeys,
);
const event = createMockRequestEvent(mockTenantId, locals);
const response = await GET(event);
const data = await response.json();
data.staffPublicKeys.forEach(
(key: { userId: string; publicKey: string; passkeyId: string }) => {
expect(key).toHaveProperty("userId");
expect(key).toHaveProperty("publicKey");
expect(key).toHaveProperty("passkeyId");
expect(typeof key.userId).toBe("string");
expect(typeof key.publicKey).toBe("string");
},
);
});
});
});
@@ -140,14 +140,12 @@ registerOpenAPIRoute("/tenants/{id}/appointments/tunnels/add-staff-key-shares",
const requestSchema = z.object({
staffUserId: z.string().uuid("Invalid staff user ID format"),
keyShares: z
.array(
z.object({
tunnelId: z.string().uuid("Invalid tunnel ID format"),
encryptedTunnelKey: z.string().min(1, "Encrypted tunnel key cannot be empty"),
}),
)
.min(1, "At least one key share is required"),
keyShares: z.array(
z.object({
tunnelId: z.string().uuid("Invalid tunnel ID format"),
encryptedTunnelKey: z.string().min(1, "Encrypted tunnel key cannot be empty"),
}),
),
});
export const POST: RequestHandler = async ({ params, locals, request }) => {
@@ -4,6 +4,7 @@ import { BackendError, InternalError, logError, ValidationError } from "$lib/ser
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/calendar", "GET", {
@@ -129,6 +130,8 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
throw new ValidationError("Tenant ID is required");
}
checkPermission(locals, tenantId, false);
// Parse query parameters for date range
const startDateParam = url.searchParams.get("startDate");
const endDateParam = url.searchParams.get("endDate");
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { GET } from "../+server";
import { ScheduleService } from "$lib/server/services/schedule-service";
import { checkPermission } from "$lib/server/utils/permissions";
// Mock the ScheduleService
vi.mock("$lib/server/services/schedule-service", () => ({
@@ -9,6 +10,9 @@ vi.mock("$lib/server/services/schedule-service", () => ({
},
}));
// Mock checkPermission
vi.mock("$lib/server/utils/permissions");
// Mock logger
vi.mock("$lib/logger", () => ({
default: {
@@ -35,6 +39,8 @@ describe("Calendar API", () => {
mockGetSchedule.mockReset();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(ScheduleService.forTenant).mockResolvedValue(mockScheduleService as any);
// Mock checkPermission to succeed by default
vi.mocked(checkPermission).mockImplementation(() => {});
});
const createRequest = (tenantId: string, startDate?: string, endDate?: string) => {
@@ -45,7 +51,13 @@ describe("Calendar API", () => {
return {
params: { id: tenantId },
url,
locals: {}, // Add locals object
locals: {
user: {
id: "user-123",
tenantId,
role: "GUEST",
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
};
@@ -143,7 +155,7 @@ describe("Calendar API", () => {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-02T00:00:00.000Z",
timeZone: "UTC",
staffUserId: undefined,
staffUserId: "user-123",
});
});