Files
tranquil-pds/frontend/src/tests/mocks.ts
T
Johanna LarssonandTangled 3474ed588d Make legacy login alerts configurable
This adds security alerts section to the communication settings, with one new item: a toggle for enabling/disabling legacy login alerts. It's enabled by default. Disabling it means that you no longer get emails when logging in through a non-2FA enabled login flow, like createSession.

The reason I tackled this is that I have a bot account that automatically refreshes its session, using username and app password, and I'm close to having 100 of these emails in my inbox. I also tried to just disable TOTP but I wasn't allowed (I fixed that in a different PR).

English and Swedish translations were me, the rest were MyMemory. I imagine someone can improve on them after this is merged!
2026-09-11 19:54:37 +00:00

403 lines
12 KiB
TypeScript

import { vi } from "vitest";
import type { AppPassword, InviteCode, Session } from "../lib/api.ts";
import { _testResetState, _testSetState } from "../lib/auth.svelte.ts";
import { clearAllToasts, getToasts, toast } from "../lib/toast.svelte.ts";
import {
unsafeAsAccessToken,
unsafeAsDid,
unsafeAsEmail,
unsafeAsHandle,
unsafeAsInviteCode,
unsafeAsISODateString,
unsafeAsRefreshToken,
} from "../lib/types/branded.ts";
function createMockIndexedDB() {
const stores: Map<string, Map<string, unknown>> = new Map();
return {
open: vi.fn((_name: string, _version?: number) => {
const createTransaction = (_storeName: string, _mode?: string) => {
const tx = {
objectStore: (name: string) => {
if (!stores.has(name)) {
stores.set(name, new Map());
}
const store = stores.get(name)!;
return {
put: (value: unknown, key: string) => {
store.set(key, value);
return { result: undefined };
},
get: (key: string) => ({
result: store.get(key),
}),
};
},
oncomplete: null as (() => void) | null,
onerror: null as (() => void) | null,
};
setTimeout(() => tx.oncomplete?.(), 0);
return tx;
};
const request = {
result: {
objectStoreNames: { contains: () => true },
createObjectStore: vi.fn(),
transaction: createTransaction,
close: vi.fn(),
},
error: null,
onsuccess: null as (() => void) | null,
onerror: null as (() => void) | null,
onupgradeneeded: null as (() => void) | null,
};
setTimeout(() => {
request.onupgradeneeded?.();
request.onsuccess?.();
}, 0);
return request;
}),
};
}
export function setupIndexedDBMock(): void {
(globalThis as unknown as { indexedDB: unknown }).indexedDB =
createMockIndexedDB();
}
const originalPushState = globalThis.history.pushState.bind(globalThis.history);
const originalReplaceState = globalThis.history.replaceState.bind(
globalThis.history,
);
globalThis.history.pushState = (
data: unknown,
unused: string,
url?: string | URL | null,
) => {
originalPushState(data, unused, url);
if (url) {
const urlStr = typeof url === "string" ? url : url.toString();
Object.defineProperty(globalThis.location, "pathname", {
value: urlStr.split("?")[0],
writable: true,
configurable: true,
});
}
};
globalThis.history.replaceState = (
data: unknown,
unused: string,
url?: string | URL | null,
) => {
originalReplaceState(data, unused, url);
if (url) {
const urlStr = typeof url === "string" ? url : url.toString();
Object.defineProperty(globalThis.location, "pathname", {
value: urlStr.split("?")[0],
writable: true,
configurable: true,
});
}
};
export interface MockResponse {
ok: boolean;
status: number;
json: () => Promise<unknown>;
}
export type MockHandler = (
url: string,
options?: RequestInit,
) => MockResponse | Promise<MockResponse>;
const mockHandlers: Map<string, MockHandler> = new Map();
export function mockEndpoint(endpoint: string, handler: MockHandler): void {
mockHandlers.set(endpoint, handler);
}
export function mockEndpointOnce(endpoint: string, handler: MockHandler): void {
const originalHandler = mockHandlers.get(endpoint);
mockHandlers.set(endpoint, (url, options) => {
mockHandlers.set(endpoint, originalHandler!);
return handler(url, options);
});
}
export function clearMocks(): void {
mockHandlers.clear();
_testResetState();
clearAllToasts();
}
export function getErrorToasts(): string[] {
return getToasts()
.filter((t) => t.type === "error")
.map((t) => t.message);
}
export { getToasts, toast };
function extractEndpoint(url: string): string {
const match = url.match(/\/xrpc\/([^?]+)/);
if (match) return match[1];
const pathOnly = url.split("?")[0];
return pathOnly;
}
export function setupFetchMock(): void {
globalThis.fetch = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
const endpoint = extractEndpoint(url);
const handler = mockHandlers.get(endpoint);
if (handler) {
const result = await handler(url, init);
return {
ok: result.ok,
status: result.status,
json: result.json,
text: async () => JSON.stringify(await result.json()),
headers: new Headers(),
redirected: false,
statusText: result.ok ? "OK" : "Error",
type: "basic",
url,
clone: () => ({ ...result }) as Response,
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
} as Response;
}
return {
ok: false,
status: 404,
json: () =>
Promise.resolve({
error: "NotFound",
message: `No mock for ${endpoint}`,
}),
text: () =>
Promise.resolve(
JSON.stringify({
error: "NotFound",
message: `No mock for ${endpoint}`,
}),
),
headers: new Headers(),
redirected: false,
statusText: "Not Found",
type: "basic",
url,
clone: function () {
return this;
},
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
} as Response;
},
);
}
export function jsonResponse<T>(data: T, status = 200): MockResponse {
return {
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(data),
};
}
export function errorResponse(
error: string,
message: string,
status = 400,
): MockResponse {
return {
ok: false,
status,
json: () => Promise.resolve({ error, message }),
};
}
export const mockData = {
session: (overrides?: Partial<Session>): Session => {
const base = {
did: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
handle: unsafeAsHandle("testuser.test.tranquil.dev"),
accessJwt: unsafeAsAccessToken("mock-access-jwt-token"),
refreshJwt: unsafeAsRefreshToken("mock-refresh-jwt-token"),
contactKind: "email" as const,
email: unsafeAsEmail("test@example.com"),
emailConfirmed: true,
accountKind: "active" as const,
isAdmin: false,
};
return { ...base, ...overrides } as Session;
},
appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({
name: "Test App",
createdAt: unsafeAsISODateString(new Date().toISOString()),
...overrides,
}),
inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({
code: unsafeAsInviteCode("test-invite-123"),
available: 1,
disabled: false,
forAccount: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
createdBy: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
createdAt: unsafeAsISODateString(new Date().toISOString()),
uses: [],
...overrides,
}),
notificationPrefs: (overrides?: Record<string, unknown>) => ({
preferredChannel: "email",
email: "test@example.com",
discordUsername: null,
discordVerified: false,
telegramUsername: null,
telegramVerified: false,
signalUsername: null,
signalVerified: false,
legacyLoginAlerts: true,
...overrides,
}),
describeServer: (overrides?: Record<string, unknown>) => ({
availableUserDomains: ["test.tranquil.dev"],
inviteCodeRequired: false,
did: "did:web:test.tranquil.dev",
contact: {
email: "admin@test.tranquil.dev",
},
links: {
privacyPolicy: "https://example.com/privacy",
termsOfService: "https://example.com/tos",
},
selfHostedDidWebEnabled: true,
availableCommsChannels: ["email", "discord", "telegram", "signal"],
discordBotUsername: "test-bot",
discordAppId: "123456789",
telegramBotUsername: "test_tg_bot",
...overrides,
}),
serverStats: () => ({
userCount: 42,
repoCount: 42,
recordCount: 1234,
blobStorageBytes: 5678,
}),
describeRepo: (did: string) => ({
handle: "testuser.test.tranquil.dev",
did,
didDoc: {},
collections: [
"app.bsky.feed.post",
"app.bsky.feed.like",
"app.bsky.graph.follow",
],
handleIsCorrect: true,
}),
};
export function setupDefaultMocks(): void {
setupFetchMock();
setupIndexedDBMock();
mockEndpoint(
"com.atproto.server.getSession",
() => jsonResponse(mockData.session()),
);
mockEndpoint("com.atproto.server.createSession", (_url, options) => {
const body = JSON.parse((options?.body as string) || "{}");
if (body.identifier && body.password === "correctpassword") {
return jsonResponse(
mockData.session({ handle: body.identifier.replace("@", "") }),
);
}
return errorResponse(
"AuthenticationRequired",
"Invalid identifier or password",
401,
);
});
mockEndpoint(
"com.atproto.server.refreshSession",
() => jsonResponse(mockData.session()),
);
mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
mockEndpoint(
"com.atproto.server.listAppPasswords",
() => jsonResponse({ passwords: [mockData.appPassword()] }),
);
mockEndpoint("com.atproto.server.createAppPassword", (_url, options) => {
const body = JSON.parse((options?.body as string) || "{}");
return jsonResponse({
name: body.name,
password: "xxxx-xxxx-xxxx-xxxx",
createdAt: new Date().toISOString(),
});
});
mockEndpoint("com.atproto.server.revokeAppPassword", () => jsonResponse({}));
mockEndpoint(
"com.atproto.server.getAccountInviteCodes",
() => jsonResponse({ codes: [mockData.inviteCode()] }),
);
mockEndpoint(
"com.atproto.server.createInviteCode",
() => jsonResponse({ code: "new-invite-" + Date.now() }),
);
mockEndpoint(
"_account.getNotificationPrefs",
() => jsonResponse(mockData.notificationPrefs()),
);
mockEndpoint(
"_account.updateNotificationPrefs",
() => jsonResponse({ success: true }),
);
mockEndpoint(
"_account.getNotificationHistory",
() => jsonResponse({ notifications: [] }),
);
mockEndpoint(
"com.atproto.server.requestEmailUpdate",
() => jsonResponse({ tokenRequired: true }),
);
mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
mockEndpoint(
"com.atproto.server.requestAccountDelete",
() => jsonResponse({}),
);
mockEndpoint("com.atproto.server.deleteAccount", () => jsonResponse({}));
mockEndpoint(
"com.atproto.server.describeServer",
() => jsonResponse(mockData.describeServer()),
);
mockEndpoint("com.atproto.repo.describeRepo", (url) => {
const params = new URLSearchParams(url.split("?")[1]);
const repo = params.get("repo") || "did:web:test";
return jsonResponse(mockData.describeRepo(repo));
});
mockEndpoint(
"com.atproto.repo.listRecords",
() => jsonResponse({ records: [] }),
);
}
export function setupAuthenticatedUser(
sessionOverrides?: Partial<Session>,
): Session {
const session = mockData.session(sessionOverrides);
_testSetState({
session,
loading: false,
error: null,
});
return session;
}
export function setupUnauthenticatedUser(): void {
_testSetState({
session: null,
loading: false,
error: null,
});
}