'use strict'; const assert = require('node:assert/strict'); const test = require('node:test'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { Store } = require('../src/store'); const { Auth } = require('../src/auth'); const { Grants } = require('../src/grants'); function tmpdir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'ms-test-')); } function fresh() { const store = new Store(tmpdir()); const auth = new Auth({ store, sessionSecret: 'test-secret' }); const grants = new Grants({ ttlMs: 1000 }); return { store, auth, grants }; } test('register/login issues a session tied to the user', () => { const { store, auth } = fresh(); const u = auth.register({ username: 'alice', password: 'pw' }); assert.ok(u); assert.equal(auth.verifyPassword('pw', store.findUserByUsername('alice')), true); assert.equal(auth.verifyPassword('nope', store.findUserByUsername('alice')), false); const token = auth.issueToken(u.id); const sess = auth.verifyToken(token); assert.equal(sess.uid, u.id); assert.ok(sess.sid); assert.equal(auth.verifyToken('garbage.token'), null); }); test('rooms: owner role, invite accept, editor/streamer roles', () => { const { store, auth } = fresh(); const alice = auth.register({ username: 'alice', password: 'pw' }); const bob = auth.register({ username: 'bob', password: 'pw' }); const room = store.createRoom({ name: 'Collab', ownerId: alice.id }); assert.equal(store.membership(room.id, alice.id).role, 'owner'); const invite = store.createInvite({ roomId: room.id, role: 'editor' }); const m = store.acceptInvite(invite, bob.id); assert.equal(m.role, 'editor'); // bob is now in the room; a second invite for streamer upgrades nothing (already member). assert.equal(store.roomsFor(bob.id).length, 1); assert.equal(store.findInviteByToken(invite.token), null); // consumed }); test('accounts store only ciphertext; owners own them', () => { const { store } = fresh(); const alice = store.createUser({ username: 'alice', passwordHash: 'x', passwordSalt: 'y' }); const bob = store.createUser({ username: 'bob', passwordHash: 'x', passwordSalt: 'y' }); const vault = store.createVault({ ownerId: alice.id, name: 'Main', salt: 's', serverWrapped: { iv: 'i', data: 'd' } }); const acct = store.createAccount({ ownerId: alice.id, vaultId: vault.id, provider: 'twitch', name: 'Twitch main', url: 'rtmp://live.twitch.tv/app', secretCiphertext: { iv: 'a', data: 'b' }, }); assert.equal(store.findAccount(acct.id).secretCiphertext.iv, 'a'); assert.equal(store.findAccount(acct.id).vaultId, vault.id); assert.equal(store.listAccountsFor(alice.id).length, 1); assert.equal(store.listAccountsFor(bob.id).length, 0); // Server-side state never has the plaintext key (it was never passed in). const raw = JSON.stringify(store.state); assert.ok(!raw.includes('sk_plaintext')); }); test('grants: grant → get → revoke → expiry', async () => { const { grants } = fresh(); grants.grant('acct-1', 'sk_abc', 'alice'); assert.equal(grants.get('acct-1'), 'sk_abc'); assert.deepEqual(grants.grantedAccountIds(), ['acct-1']); grants.revoke('acct-1'); assert.equal(grants.get('acct-1'), null); grants.grant('acct-2', 'sk_def', 'alice'); await new Promise((r) => setTimeout(r, 1100)); assert.equal(grants.get('acct-2'), null); // expired assert.deepEqual(grants.grantedAccountIds(), []); // revokeBy clears only that user's grants. grants.grant('a', '1', 'alice'); grants.grant('b', '2', 'bob'); grants.revokeBy('alice'); assert.equal(grants.get('a'), null); assert.equal(grants.get('b'), '2'); }); test('feed stream keys are unique and lookable', () => { const { store } = fresh(); const u = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' }); const room = store.createRoom({ name: 'R', ownerId: u.id }); const f1 = store.createFeed({ roomId: room.id, ownerId: u.id, name: 'Cam A' }); const f2 = store.createFeed({ roomId: room.id, ownerId: u.id, name: 'Cam B' }); assert.notEqual(f1.streamKey, f2.streamKey); assert.equal(store.findFeedByStreamKey(f1.streamKey).id, f1.id); }); test('account ciphertext is only on the owner row (data-level isolation)', () => { const { store } = fresh(); const alice = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' }); const bob = store.createUser({ username: 'b', passwordHash: 'x', passwordSalt: 'y' }); const vault = store.createVault({ ownerId: alice.id, name: 'V', salt: 's', serverWrapped: { iv: 'i', data: 'd' } }); store.createAccount({ ownerId: alice.id, vaultId: vault.id, provider: 'twitch', name: 'T', url: 'u', secretCiphertext: { iv: 'x', data: 'y' } }); // Only alice's account list has a row; bob's is empty, so there is no path // to alice's ciphertext from bob's session. assert.equal(store.listAccountsFor(alice.id).length, 1); assert.equal(store.listAccountsFor(bob.id).length, 0); // And the ciphertext is never stored as plaintext anywhere. assert.ok(!JSON.stringify(store.state).includes('sk_secret')); }); test('vaults: multiple per user, one default, removable', () => { const { store } = fresh(); const alice = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' }); const v1 = store.createVault({ ownerId: alice.id, name: 'Personal', salt: 's1', serverWrapped: { iv: 'i', data: 'd' } }); const v2 = store.createVault({ ownerId: alice.id, name: 'Work', salt: 's2', serverWrapped: { iv: 'i', data: 'd' } }); assert.equal(store.listVaultsFor(alice.id).length, 2); // First vault becomes the default automatically. assert.equal(store.findUserById(alice.id).defaultVaultId, v1.id); // Explicit default switch. store.setDefaultVault(alice.id, v2.id); assert.equal(store.findUserById(alice.id).defaultVaultId, v2.id); // Removing a vault detaches its accounts (ciphertext becomes unusable). const acct = store.createAccount({ ownerId: alice.id, vaultId: v1.id, provider: 'twitch', name: 'T', url: 'u', secretCiphertext: { iv: 'x', data: 'y' } }); store.removeVault(v1.id); assert.equal(store.listVaultsFor(alice.id).length, 1); assert.equal(store.findAccount(acct.id).vaultId, null); // Can't set someone else's vault as default. const mallory = store.createUser({ username: 'm', passwordHash: 'x', passwordSalt: 'y' }); assert.equal(store.setDefaultVault(mallory.id, v2.id), null); });