mirror of https://github.com/wg-easy/wg-easy
6 changed files with 89 additions and 7 deletions
@ -0,0 +1,9 @@ |
|||
import { randomBytes } from 'node:crypto'; |
|||
|
|||
/** |
|||
* Generates a cryptographically secure one-time link token. |
|||
* Returns a 64-character lowercase hex string (256 bits of entropy). |
|||
*/ |
|||
export function generateOtlToken(): string { |
|||
return randomBytes(32).toString('hex'); |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
import { describe, expect, test } from 'vitest'; |
|||
import { generateOtlToken } from '../../server/database/repositories/oneTimeLink/token'; |
|||
|
|||
describe('OTL token generation', () => { |
|||
test('generates a non-empty string', () => { |
|||
expect(typeof generateOtlToken()).toBe('string'); |
|||
expect(generateOtlToken().length).toBeGreaterThan(0); |
|||
}); |
|||
|
|||
test('token is a 64-character lowercase hex string', () => { |
|||
const token = generateOtlToken(); |
|||
expect(token).toMatch(/^[0-9a-f]{64}$/); |
|||
}); |
|||
|
|||
test('consecutive tokens are unique', () => { |
|||
const tokens = new Set(Array.from({ length: 10 }, generateOtlToken)); |
|||
expect(tokens.size).toBe(10); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,58 @@ |
|||
import { describe, expect, test, beforeEach } from 'vitest'; |
|||
import { createClient } from '@libsql/client'; |
|||
import { drizzle } from 'drizzle-orm/libsql'; |
|||
import { sql } from 'drizzle-orm'; |
|||
import * as schema from '../../server/database/schema'; |
|||
import { OneTimeLinkService } from '../../server/database/repositories/oneTimeLink/service'; |
|||
import { oneTimeLink as otlTable } from '../../server/database/repositories/oneTimeLink/schema'; |
|||
|
|||
async function createTestDb() { |
|||
const client = createClient({ url: ':memory:' }); |
|||
const db = drizzle({ client, schema }); |
|||
// SQLite does not enforce FK constraints by default, so we only need this table.
|
|||
await db.run(sql` |
|||
CREATE TABLE one_time_links_table ( |
|||
id INTEGER PRIMARY KEY, |
|||
one_time_link TEXT NOT NULL UNIQUE, |
|||
expires_at TEXT NOT NULL, |
|||
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), |
|||
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) |
|||
) |
|||
`);
|
|||
return db; |
|||
} |
|||
|
|||
describe('OneTimeLinkService upsert', () => { |
|||
let service: OneTimeLinkService; |
|||
let db: Awaited<ReturnType<typeof createTestDb>>; |
|||
|
|||
beforeEach(async () => { |
|||
db = await createTestDb(); |
|||
service = new OneTimeLinkService(db); |
|||
}); |
|||
|
|||
test('generates and stores a token for a new client', async () => { |
|||
await service.generate(1); |
|||
const rows = await db.select().from(otlTable); |
|||
expect(rows).toHaveLength(1); |
|||
expect(rows[0].oneTimeLink).toMatch(/^[0-9a-f]{64}$/); |
|||
}); |
|||
|
|||
test('regenerating for the same client updates both token and expiry', async () => { |
|||
await service.generate(1); |
|||
const [first] = await db.select().from(otlTable); |
|||
|
|||
// Small delay so the expiry timestamps are observably different.
|
|||
await new Promise((r) => setTimeout(r, 5)); |
|||
|
|||
await service.generate(1); |
|||
const rows = await db.select().from(otlTable); |
|||
|
|||
// Still exactly one row — no duplicate inserted.
|
|||
expect(rows).toHaveLength(1); |
|||
|
|||
const [second] = rows; |
|||
expect(second.oneTimeLink).not.toBe(first.oneTimeLink); |
|||
expect(second.expiresAt > first.expiresAt).toBe(true); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue