Files
backspace/packages/server/src/utils/spaceInviteSnapshot.test.ts
T

198 lines
6.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { fetchSpaceInviteSnapshot, getLocalInviteSnapshot } from './spaceInviteSnapshot';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Module-level mutable DB state — the vi.mock factory below closes over these
// bindings via a getter, so reassignment in beforeEach is observed.
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
// Mock the ssrf module so tests don't need real DNS resolution.
// safeFetch mirrors the real contract: validate the URL first, then fetch — so a
// rejecting validator must prevent the fetch. Default: validateExternalUrl
// resolves (allow). Individual tests override via mockRejectedValueOnce.
vi.mock('./ssrf.js', () => {
const validateExternalUrl = vi.fn().mockResolvedValue(undefined);
const isPrivateIp = vi.fn().mockReturnValue(false);
const safeFetch = vi.fn(async (url: string, init?: RequestInit) => {
await validateExternalUrl(url);
return (global.fetch as unknown as typeof fetch)(url, init);
});
return { validateExternalUrl, isPrivateIp, safeFetch };
});
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
describe('fetchSpaceInviteSnapshot', () => {
const originalFetch = global.fetch;
beforeEach(() => { global.fetch = vi.fn() as any; });
afterEach(() => { global.fetch = originalFetch; });
it('returns snapshot when preview endpoint succeeds', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
spaceId: 'S1',
spaceName: 'Aether',
description: 'desc',
icon: null,
avatarColor: 'mint',
memberCount: 12,
instanceName: 'Backspace',
}),
});
const snap = await fetchSpaceInviteSnapshot('https://z.example', 'abc123');
expect(snap).toEqual({
spaceId: 'S1',
spaceName: 'Aether',
description: 'desc',
icon: null,
avatarColor: 'mint',
memberCount: 12,
instanceName: 'Backspace',
});
});
it('returns null when preview returns 404', async () => {
(global.fetch as any).mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) });
const snap = await fetchSpaceInviteSnapshot('https://z.example', 'badcode');
expect(snap).toBeNull();
});
it('returns null when fetch throws (network error)', async () => {
(global.fetch as any).mockRejectedValueOnce(new Error('ECONNREFUSED'));
const snap = await fetchSpaceInviteSnapshot('https://z.example', 'abc123');
expect(snap).toBeNull();
});
it('aborts after timeout', async () => {
(global.fetch as any).mockImplementationOnce((_url: string, opts: any) => {
return new Promise((_resolve, reject) => {
opts.signal?.addEventListener('abort', () => reject(new Error('aborted')));
});
});
const snap = await fetchSpaceInviteSnapshot('https://z.example', 'abc', 50);
expect(snap).toBeNull();
});
it('returns null when SSRF validator rejects the origin', async () => {
// Reject validation for this one call; safeFetch must bail before fetching.
const ssrf = await import('./ssrf.js');
(ssrf.validateExternalUrl as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('blocked'));
const fetchSpy = global.fetch as any;
const snap = await fetchSpaceInviteSnapshot('http://127.0.0.1:9200', 'abc');
expect(snap).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled(); // CRITICAL — the fetch must NOT happen
});
});
describe('getLocalInviteSnapshot', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
// Seed an owner user so the FK on spaces.ownerId is satisfied.
testDb.insert(schema.users).values({
id: 'owner-1',
username: 'owner',
displayName: null,
passwordHash: 'x',
status: 'offline',
isAdmin: 0,
isDeleted: 0,
discoverable: 1,
homeInstance: null,
homeUserId: null,
createdAt: Date.now(),
}).run();
});
it('returns snapshot for an existing local invite code', () => {
testDb.insert(schema.spaces).values({
id: 'S1',
name: 'Aether',
icon: null,
banner: null,
avatarColor: 'mint',
ownerId: 'owner-1',
inviteCode: 'abc123',
visibility: 'private',
description: 'a calm space',
createdAt: Date.now(),
}).run();
testDb.insert(schema.spaceMembers).values({
spaceId: 'S1',
userId: 'owner-1',
nickname: null,
joinedAt: Date.now(),
}).run();
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'TestHost',
updatedAt: Date.now(),
}).run();
const snap = getLocalInviteSnapshot('abc123');
expect(snap).toEqual({
spaceId: 'S1',
spaceName: 'Aether',
description: 'a calm space',
icon: null,
avatarColor: 'mint',
memberCount: 1,
instanceName: 'TestHost',
});
});
it('returns null for unknown code', () => {
expect(getLocalInviteSnapshot('does-not-exist')).toBeNull();
});
it('falls back to "Backspace" when no instance settings row exists', () => {
testDb.insert(schema.spaces).values({
id: 'S2',
name: 'NoSettings',
icon: null,
banner: null,
avatarColor: null,
ownerId: 'owner-1',
inviteCode: 'nosettings',
visibility: 'private',
description: null,
createdAt: Date.now(),
}).run();
const snap = getLocalInviteSnapshot('nosettings');
expect(snap).not.toBeNull();
expect(snap?.instanceName).toBe('Backspace');
expect(snap?.memberCount).toBe(0);
});
});