gristlabs_grist-core/test/gen-server/AuthCaching.ts
Leslie H 02cfcee84d
Make changes required for Desktop FS updates (#1099)
Make a set of changes required for Desktop FS improvements, see
https://github.com/gristlabs/grist-desktop/pull/42

---------

Co-authored-by: Spoffy <contact@spoffy.net>
Co-authored-by: Spoffy <4805393+Spoffy@users.noreply.github.com>
2024-09-16 21:01:58 -04:00

406 lines
18 KiB
TypeScript

import {delay} from 'app/common/delay';
import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {FlexServer} from 'app/server/lib/FlexServer';
import log from 'app/server/lib/log';
import {MergedServer} from 'app/server/MergedServer';
import axios from 'axios';
import {assert} from 'chai';
import * as fse from 'fs-extra';
import {tmpdir} from 'os';
import * as path from 'path';
import * as sinon from 'sinon';
import {TestSession} from 'test/gen-server/apiUtils';
import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed';
import {configForUser, getGristConfig} from 'test/gen-server/testUtils';
import {openClient} from 'test/server/gristClient';
import * as testUtils from 'test/server/testUtils';
async function createTestDir(ident: string): Promise<string> {
// Create a testDir of the form grist_test_{USER}_{SERVER_NAME}, removing any previous one.
const username = process.env.USER || "nobody";
const testDir = path.join(tmpdir(), `grist_test_${username}_${ident}`);
await fse.remove(testDir);
return testDir;
}
const chimpy = configForUser('Chimpy');
const kiwi = configForUser('Kiwi');
const charon = configForUser('Charon');
const chimpyEmail = 'chimpy@getgrist.com';
const kiwiEmail = 'kiwi@getgrist.com';
const charonEmail = 'charon@getgrist.com';
describe('AuthCaching', function() {
this.timeout(10000);
testUtils.setTmpLogLevel('error');
let homeServer: FlexServer, docsServer: FlexServer;
let session: TestSession;
let homeUrl: string;
let helloDocId: string;
const sandbox = sinon.createSandbox();
before(async function() {
const testDir = process.env.TESTDIR || await createTestDir('authcaching');
const testDocDir = path.join(testDir, "data");
await fse.mkdirs(testDocDir);
log.warn(`Test logs and data are at: ${testDir}/`);
setUpDB();
await createInitialDb();
process.env.GRIST_DATA_DIR = testDocDir;
const homeMS = await MergedServer.create(0, ['home'],
{logToConsole: false, externalStorage: false});
await homeMS.run();
homeServer = homeMS.flexServer;
homeUrl = homeServer.getOwnUrl();
process.env.APP_HOME_URL = homeUrl;
const docsMS = await MergedServer.create(0, ['docs'],
{logToConsole: false, externalStorage: false});
await docsMS.run();
docsServer = docsMS.flexServer;
// Helpers for getting cookie-based logins.
session = new TestSession(homeServer);
// Copy a fixture doc to make it accessible with the given docId.
helloDocId = (await homeServer.getHomeDBManager().testGetId('Jupiter')) as string;
const srcPath = path.resolve(testUtils.fixturesRoot, 'docs', 'Hello.grist');
await fse.copy(srcPath, path.resolve(docsServer.docsRoot, `${helloDocId}.grist`),
{ dereference: true });
// Add Kiwi to 'viewers' for this doc.
const resp = await axios.patch(`${homeUrl}/api/docs/${helloDocId}/access`,
{delta: {users: {[kiwiEmail]: 'viewers'}}},
chimpy);
assert.equal(resp.status, 200);
});
after(async function() {
delete process.env.GRIST_DATA_DIR;
delete process.env.APP_HOME_URL;
sandbox.restore();
await testUtils.captureLog('warn', async () => {
await docsServer.close();
await homeServer.close();
await removeConnection();
});
});
afterEach(async function() {
sandbox.restore();
});
function getDocTracker(dbManager: HomeDBManager) {
const forced = sandbox.spy(dbManager, "getDoc");
const cached = sandbox.spy(dbManager, "getDocAuthCached");
const impl = sandbox.spy(dbManager, "getDocImpl");
function getCallCounts() {
return {
forced: forced.callCount,
misses: impl.callCount - forced.callCount,
hits: cached.callCount - (impl.callCount - forced.callCount),
};
}
function reset() {
forced.resetHistory();
cached.resetHistory();
impl.resetHistory();
}
function getAndReset() {
const res = getCallCounts();
reset();
return res;
}
return {getCallCounts, reset, getAndReset};
}
function flushCache() {
homeServer.getHomeDBManager().flushDocAuthCache();
docsServer.getHomeDBManager().flushDocAuthCache();
}
function getDocCallTracker() {
return {
home: getDocTracker(homeServer.getHomeDBManager()),
docs: getDocTracker(docsServer.getHomeDBManager()),
};
}
it('should not cache direct call for doc metadata', async function() {
flushCache();
const getDocCalls = getDocCallTracker();
const resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}`, chimpy);
assert.equal(resp.data.name, 'Jupiter');
// This is a metadata-only call, so only home server is involved.
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 0});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
const resp2 = await axios.get(`${homeUrl}/api/docs/${helloDocId}`, chimpy);
assert.deepEqual(resp2.data, resp.data);
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 0});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
});
it('should cache DocApi + DocApiForwarder calls', async function() {
flushCache();
const getDocCalls = getDocCallTracker();
const resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, chimpy);
assert.deepInclude(resp.data, {E: ["HELLO", "", "", ""]});
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 1, hits: 0});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});
// Try an endpoint requiring editing permissions.
const resp2 = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Foo']}, chimpy);
assert.equal(resp2.status, 200);
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
const resp3 = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, chimpy);
assert.deepInclude(resp3.data, {E: ["HELLO", "", "", "", "FOO"]});
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
});
it('should cache DocAPI + DocApiForwarder no-access calls', async function() {
flushCache();
const getDocCalls = getDocCallTracker();
// Kiwi has view-only access. Check that it's checked, and is cached too.
let resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Bar']}, kiwi);
assert.equal(resp.status, 403);
assert.match(resp.data.error, /No write access/);
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 1, hits: 0});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});
// Second call is cached, but otherwise identical.
resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Bar']}, kiwi);
assert.equal(resp.status, 403);
assert.match(resp.data.error, /No write access/);
// The read/write distinction isn't checked by DocApiForwarder, so docsServer sees the request.
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
// View access works.
resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, kiwi);
assert.deepInclude(resp.data, {E: ["HELLO", "", "", "", "FOO"]});
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
// Charon has no access.
resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, charon);
assert.equal(resp.status, 403);
assert.match(resp.data.error, /No view access/);
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 1, hits: 0});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
// ...or write access (but the check is cached).
resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Bar']}, charon);
assert.equal(resp.status, 403);
assert.match(resp.data.error, /No view access/);
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
// docsServer never sees the request.
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
});
it('should not cache app.html endpoint', async function() {
flushCache();
const getDocCalls = getDocCallTracker();
const cookie = await session.getCookieLogin('nasa', {email: chimpyEmail, name: 'Chimpy'});
const resp1 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie);
// gristConfig should include results of the getDoc call.
const gristConfig = getGristConfig(resp1.data);
assert.hasAnyKeys(gristConfig.getDoc, [helloDocId]);
assert.deepInclude(gristConfig.getDoc![helloDocId], {name: 'Jupiter', id: helloDocId});
// All authentication and getDoc() call are made by homeServer, docsServer not yet in play
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 1});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
// No caching on subsequent call because we force a fresh fetch for this endpoint.
const resp2 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie);
assert.deepEqual(getGristConfig(resp2.data).getDoc, gristConfig.getDoc);
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 1});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
});
it('should cache openDoc and websocket methods', async function() {
flushCache();
const getDocCalls = getDocCallTracker();
const cli = await openClient(docsServer, chimpyEmail, 'nasa');
assert.equal((await cli.readMessage()).type, 'clientConnect');
const openDoc = await cli.send("openDoc", helloDocId);
assert.equal(openDoc.error, undefined);
assert.match(JSON.stringify(openDoc.data), /Table1/);
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});
// Read access
const table = await cli.send("fetchTable", 0, "Table1");
assert.includeMembers(table.data.tableData, ['TableData', 'Table1']);
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
// Write access
const auaResult = await cli.send("applyUserActions", 0,
[["UpdateRecord", "Table1", 1, {A: "auth-caching1"}]]);
await delay(200); // give a little time for change broadcast.
assert.isNumber(auaResult.data.actionNum);
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 2});
await cli.close();
});
it('should cache openDoc and websocket methods with access failures', async function() {
flushCache();
const getDocCalls = getDocCallTracker();
// Repeat with a view-only user (Kiwi)
let cli = await openClient(docsServer, kiwiEmail, 'nasa');
assert.equal((await cli.readMessage()).type, 'clientConnect');
let openDoc = await cli.send("openDoc", helloDocId);
assert.equal(openDoc.error, undefined);
assert.match(JSON.stringify(openDoc.data), /Table1/);
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});
// Kiwi has read access
const table = await cli.send("fetchTable", 0, "Table1");
assert.includeMembers(table.data.tableData, ['TableData', 'Table1']);
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
// Kiwi has NO write access.
const auaResult = await cli.send("applyUserActions", 0,
[["UpdateRecord", "Table1", 1, {A: "auth-caching2"}]]);
assert.deepEqual(auaResult.error, 'No write access');
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
// Charon has no access at all
cli = await openClient(docsServer, charonEmail, 'nasa');
assert.equal((await cli.readMessage()).type, 'clientConnect');
openDoc = await cli.send("openDoc", helloDocId);
assert.equal(openDoc.error, 'No view access');
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});
await cli.send("openDoc", helloDocId);
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
// Home server wasn't involved in this test case at all.
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0});
});
it('should cache across different kinds of calls', async function() {
// Fetch the document endpoint and follow with openDoc. Caching should apply.
flushCache();
const getDocCalls = getDocCallTracker();
const cookie = await session.getCookieLogin('nasa', {email: chimpyEmail, name: 'Chimpy'});
// app.html endpoint warms the cache for the home server.
const resp1 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie);
const gristConfig = getGristConfig(resp1.data);
assert.hasAnyKeys(gristConfig.getDoc, [helloDocId]);
assert.deepInclude(gristConfig.getDoc![helloDocId], {name: 'Jupiter', id: helloDocId});
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 1});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
// openDoc call warms the cache for the doc-worker.
const cli = await openClient(docsServer, chimpyEmail, 'nasa');
assert.equal((await cli.readMessage()).type, 'clientConnect');
const openDoc = await cli.send("openDoc", helloDocId);
assert.equal(openDoc.error, undefined);
assert.match(JSON.stringify(openDoc.data), /Table1/);
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});
// the caching applies to API calls for the same doc/user/org combination.
const resp = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, chimpy);
assert.equal(resp.status, 200);
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1});
});
it('should expire the cache after a timeout', async function() {
this.timeout(10000);
// Make an API call; change access; check that after a while, the change is noticed.
flushCache();
const getDocCalls = getDocCallTracker();
// Connect up websockets for Kiwi and Charon.
const kiwiCli = await openClient(docsServer, kiwiEmail, 'nasa');
assert.equal((await kiwiCli.readMessage()).type, 'clientConnect');
const charonCli = await openClient(docsServer, charonEmail, 'nasa');
assert.equal((await charonCli.readMessage()).type, 'clientConnect');
// Kiwi has access, Charon doesn't.
let resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi);
let resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon);
assert.equal(resp1.status, 200);
assert.equal(resp2.status, 403);
// home server sees both calls, but only forwards one to the doc-worker.
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 2, hits: 0});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0});
assert.equal((await kiwiCli.send("openDoc", helloDocId)).error, undefined);
assert.equal((await charonCli.send("openDoc", helloDocId)).error, 'No view access');
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 1});
// Use Chimpy's access to change access for both.
const resp = await axios.patch(`${homeUrl}/o/nasa/api/docs/${helloDocId}/access`,
{delta: {users: {[kiwiEmail]: null, [charonEmail]: 'viewers'}}},
chimpy);
assert.equal(resp.status, 200);
// Home's UserAPI methods don't call to getDoc() to check doc-level access, so access checks
// for Chimpy's patch-access call do not affect our counts.
assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0});
assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0});
// The change isn't visible immediately.
resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi);
resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon);
assert.equal(resp1.status, 200);
assert.equal(resp2.status, 403);
// But eventually it is. Should be within 5 seconds, we try up to 10.
let passed = false;
for (let i = 0; i < 50; i++) {
await delay(200);
try {
// Check if access changes are visible yet.
resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi);
resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon);
assert.equal(resp1.status, 403);
assert.equal(resp2.status, 200);
assert.equal((await kiwiCli.send("openDoc", helloDocId)).error, 'No view access');
assert.equal((await charonCli.send("openDoc", helloDocId)).error, undefined);
passed = true;
break;
} catch (err) {
continue;
}
}
assert.isTrue(passed);
const homeCalls = getDocCalls.home.getAndReset();
const docsCalls = getDocCalls.docs.getAndReset();
// There are many cache hits, but one set of misses that discovers the access changes.
assert.deepInclude(homeCalls, {forced: 0, misses: 2});
assert.deepInclude(docsCalls, {forced: 0, misses: 2});
assert.isAbove(homeCalls.hits, 10);
assert.isAbove(docsCalls.hits, 10);
});
});