mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-25 03:34:13 +00:00
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!
403 lines
12 KiB
TypeScript
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,
|
|
});
|
|
}
|