mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) move apiserver tests to core, disentangling notifier+billing parts
Summary: This moves some more tests to core that would be useful for ANCT, which had been stuck in grist-saas due to some entanglements with sendgrid and billing. For sendgrid, I've moved around just enough material to permit the tests to run mostly unchanged. Ideally the interface to a notification system would be generalized, but that's a bigger project. Test Plan: checked that tests are likely to run as expected in core using preview laid out by ./buildtools/build_core.sh Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4149
This commit is contained in:
parent
7e57b8c7a7
commit
145138b7e9
173
app/gen-server/lib/NotifierTypes.ts
Normal file
173
app/gen-server/lib/NotifierTypes.ts
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
*
|
||||
* Grist notifications are currently half-baked.
|
||||
* There is a sendgrid based implementation for Grist Lab's SaaS, but
|
||||
* nothing self-hostable yet.
|
||||
*
|
||||
*/
|
||||
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import {StringUnion} from 'app/common/StringUnion';
|
||||
|
||||
/**
|
||||
* Structure of sendgrid email requests. Each request references a template
|
||||
* (stored on sendgrid site) and a list of people to send a copy of that template
|
||||
* to, along with the relevant values to use for template variables.
|
||||
*/
|
||||
export interface SendGridMail {
|
||||
personalizations: SendGridPersonalization[];
|
||||
from: SendGridAddress;
|
||||
reply_to: SendGridAddress;
|
||||
template_id: string;
|
||||
asm?: { // unsubscribe settings
|
||||
group_id: number;
|
||||
};
|
||||
mail_settings?: {
|
||||
bypass_list_management?: {
|
||||
enable: boolean;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendGridContact {
|
||||
contacts: [{
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
custom_fields?: Record<string, any>;
|
||||
}],
|
||||
list_ids?: string[];
|
||||
}
|
||||
|
||||
export interface SendGridAddress {
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SendGridPersonalization {
|
||||
to: SendGridAddress[];
|
||||
dynamic_template_data: {[key: string]: any};
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of sendgrid invite template. This is entirely under our control, it
|
||||
* is the information we choose to send to an email template for invites.
|
||||
*/
|
||||
|
||||
export interface SendGridInviteTemplate {
|
||||
user: FullUser;
|
||||
host: FullUser;
|
||||
resource: SendGridInviteResource;
|
||||
access: SendGridInviteAccess;
|
||||
}
|
||||
|
||||
export interface SendGridInviteResource {
|
||||
kind: SendGridInviteResourceKind;
|
||||
kindUpperFirst: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type SendGridInviteResourceKind = 'team site' | 'workspace' | 'document';
|
||||
|
||||
export interface SendGridInviteAccess {
|
||||
role: string;
|
||||
canEditAccess?: boolean;
|
||||
canEdit?: boolean;
|
||||
canView?: boolean;
|
||||
canManageBilling?: boolean;
|
||||
}
|
||||
|
||||
// Common parameters included in emails to active billing managers.
|
||||
export interface SendGridBillingTemplate {
|
||||
org: {id: number, name: string};
|
||||
orgUrl: string;
|
||||
billingUrl: string;
|
||||
}
|
||||
|
||||
export interface SendGridMemberChangeTemplate extends SendGridBillingTemplate {
|
||||
initiatingUser: FullUser;
|
||||
added: FullUser[];
|
||||
removed: FullUser[];
|
||||
org: {id: number, name: string};
|
||||
countBefore: number;
|
||||
countAfter: number;
|
||||
orgUrl: string;
|
||||
billingUrl: string;
|
||||
paidPlan: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format of sendgrid responses when looking up a user by email address using
|
||||
* SENDGRID.search
|
||||
*/
|
||||
export interface SendGridSearchResult {
|
||||
contact_count: number;
|
||||
result: SendGridSearchHit[];
|
||||
}
|
||||
|
||||
export interface SendGridSearchHit {
|
||||
id: string;
|
||||
email: string;
|
||||
list_ids: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative format of sendgrid responses when looking up a user by email
|
||||
* address using SENDGRID.searchByEmail
|
||||
* https://docs.sendgrid.com/api-reference/contacts/get-contacts-by-emails
|
||||
*/
|
||||
export interface SendGridSearchResultVariant {
|
||||
result: Record<string, SendGridSearchPossibleHit>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Documentation is contradictory on format of results when contacts not found, but if
|
||||
* something is found there should be a contact field.
|
||||
*/
|
||||
export interface SendGridSearchPossibleHit {
|
||||
contact?: SendGridSearchHit;
|
||||
}
|
||||
|
||||
export interface SendGridConfig {
|
||||
address: {
|
||||
from: {
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
},
|
||||
template: {
|
||||
invite?: string;
|
||||
billingManagerInvite?: string;
|
||||
memberChange?: string;
|
||||
trialPeriodEndingSoon?: string;
|
||||
twoFactorMethodAdded?: string;
|
||||
twoFactorMethodRemoved?: string;
|
||||
twoFactorPhoneNumberChanged?: string;
|
||||
twoFactorEnabled?: string;
|
||||
twoFactorDisabled?: string;
|
||||
},
|
||||
list: {
|
||||
singleUserOnboarding?: string;
|
||||
appSumoSignUps?: string;
|
||||
trial?: string;
|
||||
},
|
||||
unsubscribeGroup: {
|
||||
invites?: number;
|
||||
billingManagers?: number;
|
||||
},
|
||||
field?: {
|
||||
callScheduled?: string;
|
||||
userRef?: string;
|
||||
},
|
||||
}
|
||||
|
||||
export const TwoFactorEvents = StringUnion(
|
||||
'twoFactorMethodAdded',
|
||||
'twoFactorMethodRemoved',
|
||||
'twoFactorPhoneNumberChanged',
|
||||
'twoFactorEnabled',
|
||||
'twoFactorDisabled',
|
||||
);
|
||||
|
||||
export type TwoFactorEvent = typeof TwoFactorEvents.type;
|
@ -97,6 +97,7 @@ export function makeSimpleCreator(opts: {
|
||||
return notifier?.create(dbManager, gristConfig) ?? {
|
||||
get testPending() { return false; },
|
||||
async deleteUser() { /* do nothing */ },
|
||||
testSetSendMessageCallback() { return undefined; },
|
||||
};
|
||||
},
|
||||
ExternalStorage(purpose, extraPrefix) {
|
||||
|
@ -1,6 +1,12 @@
|
||||
import {SendGridConfig, SendGridMail} from 'app/gen-server/lib/NotifierTypes';
|
||||
|
||||
export interface INotifier {
|
||||
// for test purposes, check if any notifications are in progress
|
||||
readonly testPending: boolean;
|
||||
|
||||
deleteUser(userId: number): Promise<void>;
|
||||
|
||||
// Intercept outgoing messages for test purposes.
|
||||
// Return undefined if no notification system is available.
|
||||
testSetSendMessageCallback(op: (body: SendGridMail, description: string) => Promise<void>): SendGridConfig|undefined;
|
||||
}
|
||||
|
@ -17,7 +17,7 @@
|
||||
"test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'",
|
||||
"test:client": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'",
|
||||
"test:common": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'",
|
||||
"test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
|
||||
"test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -g \"${GREP_TESTS}\" -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
|
||||
"test:smoke": "mocha _build/test/nbrowser/Smoke.js",
|
||||
"test:docker": "./test/test_under_docker.sh",
|
||||
"test:python": "sandbox_venv3/bin/python sandbox/grist/runtests.py ${GREP_TESTS:+discover -p \"test*${GREP_TESTS}*.py\"}",
|
||||
|
2343
test/gen-server/ApiServer.ts
Normal file
2343
test/gen-server/ApiServer.ts
Normal file
File diff suppressed because it is too large
Load Diff
1689
test/gen-server/ApiServerAccess.ts
Normal file
1689
test/gen-server/ApiServerAccess.ts
Normal file
File diff suppressed because it is too large
Load Diff
69
test/gen-server/ApiServerBenchmark.ts
Normal file
69
test/gen-server/ApiServerBenchmark.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import axios from 'axios';
|
||||
import {configForUser} from 'test/gen-server/testUtils';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
|
||||
import {assert} from 'chai';
|
||||
|
||||
import {FlexServer} from 'app/server/lib/FlexServer';
|
||||
|
||||
import {createBenchmarkServer, removeConnection, setUpDB} from 'test/gen-server/seed';
|
||||
|
||||
let home: FlexServer;
|
||||
let homeUrl: string;
|
||||
|
||||
const chimpy = configForUser('Chimpy');
|
||||
|
||||
describe('ApiServerBenchmark', function() {
|
||||
|
||||
testUtils.setTmpLogLevel('error');
|
||||
|
||||
before(async function() {
|
||||
if (!process.env.ENABLE_BENCHMARKS) {
|
||||
this.skip();
|
||||
return;
|
||||
}
|
||||
this.timeout(600000);
|
||||
setUpDB(this);
|
||||
home = await createBenchmarkServer(0);
|
||||
homeUrl = home.getOwnUrl();
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
if (home) {
|
||||
await home.stopListening();
|
||||
await removeConnection();
|
||||
}
|
||||
});
|
||||
|
||||
it('GET /orgs returns in a timely manner', async function() {
|
||||
this.timeout(600000);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const resp = await axios.get(`${homeUrl}/api/orgs`, chimpy);
|
||||
assert(resp.data.length === 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Note the organization id which is being fetched.
|
||||
it('GET /orgs/{oid} returns in a timely manner', async function() {
|
||||
this.timeout(600000);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await axios.get(`${homeUrl}/api/orgs/1`, chimpy);
|
||||
}
|
||||
});
|
||||
|
||||
// Note the organization id which is being fetched.
|
||||
it('GET /orgs/{oid}/workspaces returns in a timely manner', async function() {
|
||||
this.timeout(600000);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await axios.get(`${homeUrl}/api/orgs/1/workspaces`, chimpy);
|
||||
}
|
||||
});
|
||||
|
||||
// Note the workspace ids which are being fetched.
|
||||
it('GET /workspaces/{wid} returns in a timely manner', async function() {
|
||||
this.timeout(600000);
|
||||
for (let wid = 0; wid < 100; wid++) {
|
||||
await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);
|
||||
}
|
||||
});
|
||||
});
|
174
test/gen-server/ApiServerBugs.ts
Normal file
174
test/gen-server/ApiServerBugs.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import axios from 'axios';
|
||||
import * as chai from 'chai';
|
||||
|
||||
import {configForUser} from 'test/gen-server/testUtils';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
|
||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||
|
||||
import {TestServer} from 'test/gen-server/apiUtils';
|
||||
|
||||
const assert = chai.assert;
|
||||
|
||||
let server: TestServer;
|
||||
let dbManager: HomeDBManager;
|
||||
let homeUrl: string;
|
||||
|
||||
const charon = configForUser('Charon');
|
||||
const chimpy = configForUser('Chimpy');
|
||||
const kiwi = configForUser('Kiwi');
|
||||
|
||||
const chimpyEmail = 'chimpy@getgrist.com';
|
||||
const kiwiEmail = 'kiwi@getgrist.com';
|
||||
const charonEmail = 'charon@getgrist.com';
|
||||
|
||||
// Tests specific complex scenarios that may have previously resulted in wrong behavior.
|
||||
describe('ApiServerBugs', function() {
|
||||
|
||||
testUtils.setTmpLogLevel('error');
|
||||
let userRef: (email: string) => Promise<string>;
|
||||
|
||||
before(async function() {
|
||||
server = new TestServer(this);
|
||||
homeUrl = await server.start();
|
||||
dbManager = server.dbManager;
|
||||
userRef = (email) => server.dbManager.getUserByLogin(email).then((user) => user!.ref);
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
// Re-create a bug scenario in which users being in normal groups and guests groups at the
|
||||
// same time resulted in them being dropped from groups arbitrarily on subsequent patches.
|
||||
it('should properly handle users in multiple groups at once', async function() {
|
||||
// Add Chimpy/Charon/Kiwi to 'Herring' doc and set inheritance to none. They
|
||||
// will become guests in the 'Fish' org along with their owner/viewer roles.
|
||||
const fishOrg = await dbManager.testGetId('Fish');
|
||||
const herringDoc = await dbManager.testGetId('Herring');
|
||||
const delta1 = {
|
||||
maxInheritedRole: null,
|
||||
users: {
|
||||
[kiwiEmail]: 'editors',
|
||||
[charonEmail]: 'viewers'
|
||||
}
|
||||
};
|
||||
let resp = await axios.patch(`${homeUrl}/api/docs/${herringDoc}/access`, {
|
||||
delta: delta1
|
||||
}, chimpy);
|
||||
assert.equal(resp.status, 200);
|
||||
// Ensure that the doc access is as expected.
|
||||
resp = await axios.get(`${homeUrl}/api/docs/${herringDoc}/access`, chimpy);
|
||||
assert.equal(resp.status, 200);
|
||||
assert.deepEqual(resp.data, {
|
||||
maxInheritedRole: null,
|
||||
users: [{
|
||||
id: 1,
|
||||
name: 'Chimpy',
|
||||
email: chimpyEmail,
|
||||
ref: await userRef(chimpyEmail),
|
||||
picture: null,
|
||||
parentAccess: "owners",
|
||||
access: "owners",
|
||||
isMember: true,
|
||||
}, {
|
||||
id: 2,
|
||||
name: 'Kiwi',
|
||||
email: kiwiEmail,
|
||||
ref: await userRef(kiwiEmail),
|
||||
picture: null,
|
||||
parentAccess: "editors",
|
||||
access: "editors",
|
||||
isMember: true,
|
||||
}, {
|
||||
id: 3,
|
||||
name: 'Charon',
|
||||
email: charonEmail,
|
||||
ref: await userRef(charonEmail),
|
||||
picture: null,
|
||||
parentAccess: "viewers",
|
||||
access: "viewers",
|
||||
isMember: true,
|
||||
}]
|
||||
});
|
||||
|
||||
// Remove Charon from the 'Fish' org and ensure that Chimpy and Kiwi still have
|
||||
// owner/editor roles on 'Fish'. Charon should no longer have guest access to the org.
|
||||
const delta2 = {
|
||||
users: {
|
||||
[charonEmail]: null
|
||||
}
|
||||
};
|
||||
resp = await axios.patch(`${homeUrl}/api/orgs/${fishOrg}/access`, {
|
||||
delta: delta2
|
||||
}, chimpy);
|
||||
assert.equal(resp.status, 200);
|
||||
resp = await axios.get(`${homeUrl}/api/orgs/${fishOrg}/access`, chimpy);
|
||||
assert.equal(resp.status, 200);
|
||||
assert.deepEqual(resp.data, {
|
||||
users: [{
|
||||
id: 1,
|
||||
name: 'Chimpy',
|
||||
email: chimpyEmail,
|
||||
ref: await userRef(chimpyEmail),
|
||||
picture: null,
|
||||
access: "owners",
|
||||
isMember: true,
|
||||
}, {
|
||||
id: 2,
|
||||
name: 'Kiwi',
|
||||
email: kiwiEmail,
|
||||
ref: await userRef(kiwiEmail),
|
||||
picture: null,
|
||||
access: "editors",
|
||||
isMember: true,
|
||||
}]
|
||||
});
|
||||
|
||||
// Charon should no longer have access to the 'Herring' doc, now that user access
|
||||
// is wiped entirely when removed from org.
|
||||
resp = await axios.get(`${homeUrl}/api/docs/${herringDoc}`, charon);
|
||||
assert.equal(resp.status, 403);
|
||||
resp = await axios.get(`${homeUrl}/api/orgs/${fishOrg}`, charon);
|
||||
assert.equal(resp.status, 403);
|
||||
|
||||
// Remove Kiwi as an editor from the 'Fish' org and ensure that Kiwi no longer has
|
||||
// access to 'Fish' or 'Herring'
|
||||
const delta3 = {
|
||||
users: {
|
||||
[kiwiEmail]: null
|
||||
}
|
||||
};
|
||||
resp = await axios.patch(`${homeUrl}/api/orgs/${fishOrg}/access`, {
|
||||
delta: delta3
|
||||
}, chimpy);
|
||||
assert.equal(resp.status, 200);
|
||||
resp = await axios.get(`${homeUrl}/api/docs/${herringDoc}`, kiwi);
|
||||
assert.equal(resp.status, 403);
|
||||
resp = await axios.get(`${homeUrl}/api/orgs/${fishOrg}`, kiwi);
|
||||
assert.equal(resp.status, 403);
|
||||
|
||||
// Restore initial access.
|
||||
const delta4 = {
|
||||
maxInheritedRole: "owners",
|
||||
users: {
|
||||
[charonEmail]: null,
|
||||
[kiwiEmail]: null
|
||||
}
|
||||
};
|
||||
resp = await axios.patch(`${homeUrl}/api/docs/${herringDoc}/access`, {
|
||||
delta: delta4
|
||||
}, chimpy);
|
||||
assert.equal(resp.status, 200);
|
||||
const delta5 = {
|
||||
users: {
|
||||
[kiwiEmail]: "editors",
|
||||
[charonEmail]: "viewers"
|
||||
}
|
||||
};
|
||||
resp = await axios.patch(`${homeUrl}/api/orgs/${fishOrg}/access`, {
|
||||
delta: delta5
|
||||
}, chimpy);
|
||||
assert.equal(resp.status, 200);
|
||||
});
|
||||
});
|
182
test/gen-server/ApiSession.ts
Normal file
182
test/gen-server/ApiSession.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||
import {AccessOptionWithRole} from 'app/gen-server/entity/Organization';
|
||||
import axios from 'axios';
|
||||
import {AxiosRequestConfig} from 'axios';
|
||||
import {assert} from 'chai';
|
||||
import omit = require('lodash/omit');
|
||||
import {TestServer} from 'test/gen-server/apiUtils';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
|
||||
const nobody: AxiosRequestConfig = {
|
||||
responseType: 'json',
|
||||
validateStatus: (status: number) => true
|
||||
};
|
||||
|
||||
describe('ApiSession', function() {
|
||||
|
||||
let server: TestServer;
|
||||
let serverUrl: string;
|
||||
testUtils.setTmpLogLevel('error');
|
||||
|
||||
const regular = 'chimpy@getgrist.com';
|
||||
|
||||
beforeEach(async function() {
|
||||
this.timeout(5000);
|
||||
server = new TestServer(this);
|
||||
serverUrl = await server.start();
|
||||
});
|
||||
|
||||
afterEach(async function() {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it('GET /api/session/access/active returns user and org (with access)', async function() {
|
||||
const cookie = await server.getCookieLogin('nasa', {email: regular, name: 'Chimpy'});
|
||||
|
||||
const resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie);
|
||||
assert.equal(resp.status, 200);
|
||||
assert.sameMembers(['user', 'org'], Object.keys(resp.data));
|
||||
assert.deepEqual(omit(resp.data.user, ['helpScoutSignature', 'ref']), {
|
||||
id: await server.dbManager.testGetId("Chimpy"),
|
||||
email: "chimpy@getgrist.com",
|
||||
name: "Chimpy",
|
||||
picture: null,
|
||||
});
|
||||
assert.deepEqual(omit(resp.data.org, ['billingAccount', 'createdAt', 'updatedAt']), {
|
||||
id: await server.dbManager.testGetId("NASA"),
|
||||
name: "NASA",
|
||||
access: "owners",
|
||||
domain: "nasa",
|
||||
host: null,
|
||||
owner: null
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /api/session/access/active returns org with billing account information', async function() {
|
||||
const cookie = await server.getCookieLogin('nasa', {email: regular, name: 'Chimpy'});
|
||||
|
||||
// Make Chimpy a billing account manager for NASA.
|
||||
await server.addBillingManager('Chimpy', 'nasa');
|
||||
|
||||
const resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie);
|
||||
assert.equal(resp.status, 200);
|
||||
assert.hasAllKeys(resp.data.org, ['id', 'name', 'access', 'domain', 'owner', 'billingAccount',
|
||||
'createdAt', 'updatedAt', 'host']);
|
||||
assert.deepEqual(resp.data.org.billingAccount,
|
||||
{ id: 1, individual: false, inGoodStanding: true, status: null,
|
||||
externalId: null, externalOptions: null,
|
||||
isManager: true, paid: false,
|
||||
product: { id: 1, name: 'Free', features: {workspaces: true, vanityDomain: true} } });
|
||||
|
||||
// Check that internally we have access to stripe ids.
|
||||
const userId = await server.dbManager.testGetId('Chimpy') as number;
|
||||
const org2 = await server.dbManager.getOrg({userId}, 'nasa');
|
||||
assert.hasAllKeys(org2.data!.billingAccount,
|
||||
['id', 'individual', 'inGoodStanding', 'status', 'stripeCustomerId',
|
||||
'stripeSubscriptionId', 'stripePlanId', 'product', 'paid', 'isManager',
|
||||
'externalId', 'externalOptions']);
|
||||
});
|
||||
|
||||
it('GET /api/session/access/active returns orgErr when org is forbidden', async function() {
|
||||
const cookie = await server.getCookieLogin('nasa', {email: 'kiwi@getgrist.com', name: 'Kiwi'});
|
||||
|
||||
const resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie);
|
||||
assert.equal(resp.status, 200);
|
||||
assert.sameMembers(['user', 'org', 'orgError'], Object.keys(resp.data));
|
||||
assert.deepEqual(omit(resp.data.user, ['helpScoutSignature', 'ref']), {
|
||||
id: await server.dbManager.testGetId("Kiwi"),
|
||||
email: "kiwi@getgrist.com",
|
||||
name: "Kiwi",
|
||||
picture: null,
|
||||
});
|
||||
assert.equal(resp.data.org, null);
|
||||
assert.deepEqual(resp.data.orgError, {
|
||||
status: 403,
|
||||
error: 'access denied'
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /api/session/access/active returns orgErr when org is non-existent', async function() {
|
||||
const cookie = await server.getCookieLogin('nasa', {email: 'kiwi@getgrist.com', name: 'Kiwi'});
|
||||
|
||||
const resp = await axios.get(`${serverUrl}/o/boing/api/session/access/active`, cookie);
|
||||
assert.equal(resp.status, 200);
|
||||
assert.sameMembers(['user', 'org', 'orgError'], Object.keys(resp.data));
|
||||
assert.deepEqual(omit(resp.data.user, ['helpScoutSignature', 'ref']), {
|
||||
id: await server.dbManager.testGetId("Kiwi"),
|
||||
email: "kiwi@getgrist.com",
|
||||
name: "Kiwi",
|
||||
picture: null,
|
||||
});
|
||||
assert.equal(resp.data.org, null);
|
||||
assert.deepEqual(resp.data.orgError, {
|
||||
status: 404,
|
||||
error: 'organization not found'
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/session/access/active can change user', async function() {
|
||||
// add two profiles
|
||||
const cookie = await server.getCookieLogin('nasa', {email: 'charon@getgrist.com', name: 'Charon'});
|
||||
await server.getCookieLogin('pr', {email: 'kiwi@getgrist.com', name: 'Kiwi'});
|
||||
|
||||
// pick kiwi profile for fish org
|
||||
let resp = await axios.post(`${serverUrl}/o/fish/api/session/access/active`, {
|
||||
email: 'kiwi@getgrist.com'
|
||||
}, cookie);
|
||||
assert.equal(resp.status, 200);
|
||||
|
||||
// check kiwi profile stuck
|
||||
resp = await axios.get(`${serverUrl}/o/fish/api/session/access/active`, cookie);
|
||||
assert.equal(resp.data.user.email, 'kiwi@getgrist.com');
|
||||
|
||||
// ... and that it didn't affect other org
|
||||
resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie);
|
||||
assert.equal(resp.data.user.email, 'charon@getgrist.com');
|
||||
|
||||
// pick charon profile for fish org
|
||||
resp = await axios.post(`${serverUrl}/o/fish/api/session/access/active`, {
|
||||
email: 'charon@getgrist.com'
|
||||
}, cookie);
|
||||
assert.equal(resp.status, 200);
|
||||
|
||||
// check charon profile stuck
|
||||
resp = await axios.get(`${serverUrl}/o/fish/api/session/access/active`, cookie);
|
||||
assert.equal(resp.data.user.email, 'charon@getgrist.com');
|
||||
|
||||
// make sure bogus profile for fish org fails
|
||||
resp = await axios.post(`${serverUrl}/o/fish/api/session/access/active`, {
|
||||
email: 'nonexistent@getgrist.com'
|
||||
}, cookie);
|
||||
assert.equal(resp.status, 403);
|
||||
});
|
||||
|
||||
it('GET /api/session/access/all returns users and orgs', async function() {
|
||||
const cookie = await server.getCookieLogin('nasa', {email: 'charon@getgrist.com', name: 'Charon'});
|
||||
await server.getCookieLogin('pr', {email: 'kiwi@getgrist.com', name: 'Kiwi'});
|
||||
const resp = await axios.get(`${serverUrl}/o/pr/api/session/access/all`, cookie);
|
||||
assert.equal(resp.status, 200);
|
||||
assert.sameMembers(['users', 'orgs'], Object.keys(resp.data));
|
||||
assert.sameMembers(resp.data.users.map((user: UserProfile) => user.name),
|
||||
['Charon', 'Kiwi']);
|
||||
// In following list, 'Kiwiland' is the the merged personal org, and Chimpyland is not
|
||||
// listed explicitly.
|
||||
assert.sameMembers(resp.data.orgs.map((org: any) => org.name),
|
||||
['Abyss', 'Fish', 'Flightless', 'Kiwiland', 'NASA', 'Primately']);
|
||||
const fish = resp.data.orgs.find((org: any) => org.name === 'Fish');
|
||||
const accessOptions: AccessOptionWithRole[] = fish.accessOptions;
|
||||
assert.lengthOf(accessOptions, 2);
|
||||
assert.equal('editors', accessOptions.find(opt => opt.name === 'Kiwi')!.access);
|
||||
assert.equal('viewers', accessOptions.find(opt => opt.name === 'Charon')!.access);
|
||||
});
|
||||
|
||||
it('GET /api/session/access/all functions with anonymous access', async function() {
|
||||
const resp = await axios.get(`${serverUrl}/o/pr/api/session/access/all`, nobody);
|
||||
assert.equal(resp.status, 200);
|
||||
// No orgs listed without access
|
||||
assert.lengthOf(resp.data.orgs, 0);
|
||||
// A single anonymous user
|
||||
assert.lengthOf(resp.data.users, 1);
|
||||
assert.equal(resp.data.users[0].anonymous, true);
|
||||
});
|
||||
});
|
400
test/gen-server/AuthCaching.ts
Normal file
400
test/gen-server/AuthCaching.ts
Normal file
@ -0,0 +1,400 @@
|
||||
import {delay} from 'app/common/delay';
|
||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||
import {FlexServer} from 'app/server/lib/FlexServer';
|
||||
import log from 'app/server/lib/log';
|
||||
import {main as mergedServerMain} from 'app/server/mergedServerMain';
|
||||
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;
|
||||
homeServer = await mergedServerMain(0, ['home'],
|
||||
{logToConsole: false, externalStorage: false});
|
||||
homeUrl = homeServer.getOwnUrl();
|
||||
process.env.APP_HOME_URL = homeUrl;
|
||||
docsServer = await mergedServerMain(0, ['docs'],
|
||||
{logToConsole: false, externalStorage: false});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
172
test/gen-server/migrations.ts
Normal file
172
test/gen-server/migrations.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import {QueryRunner} from "typeorm";
|
||||
import * as roles from "app/common/roles";
|
||||
import {Organization} from 'app/gen-server/entity/Organization';
|
||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||
import {Permissions} from 'app/gen-server/lib/Permissions';
|
||||
import {assert} from 'chai';
|
||||
import {addSeedData, createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed';
|
||||
|
||||
import {Initial1536634251710 as Initial} from 'app/gen-server/migration/1536634251710-Initial';
|
||||
import {Login1539031763952 as Login} from 'app/gen-server/migration/1539031763952-Login';
|
||||
import {PinDocs1549313797109 as PinDocs} from 'app/gen-server/migration/1549313797109-PinDocs';
|
||||
import {UserPicture1549381727494 as UserPicture} from 'app/gen-server/migration/1549381727494-UserPicture';
|
||||
import {LoginDisplayEmail1551805156919 as DisplayEmail} from 'app/gen-server/migration/1551805156919-LoginDisplayEmail';
|
||||
import {LoginDisplayEmailNonNull1552416614755
|
||||
as DisplayEmailNonNull} from 'app/gen-server/migration/1552416614755-LoginDisplayEmailNonNull';
|
||||
import {Indexes1553016106336 as Indexes} from 'app/gen-server/migration/1553016106336-Indexes';
|
||||
import {Billing1556726945436 as Billing} from 'app/gen-server/migration/1556726945436-Billing';
|
||||
import {Aliases1561589211752 as Aliases} from 'app/gen-server/migration/1561589211752-Aliases';
|
||||
import {TeamMembers1568238234987 as TeamMembers} from 'app/gen-server/migration/1568238234987-TeamMembers';
|
||||
import {FirstLogin1569593726320 as FirstLogin} from 'app/gen-server/migration/1569593726320-FirstLogin';
|
||||
import {FirstTimeUser1569946508569 as FirstTimeUser} from 'app/gen-server/migration/1569946508569-FirstTimeUser';
|
||||
import {CustomerIndex1573569442552 as CustomerIndex} from 'app/gen-server/migration/1573569442552-CustomerIndex';
|
||||
import {ExtraIndexes1579559983067 as ExtraIndexes} from 'app/gen-server/migration/1579559983067-ExtraIndexes';
|
||||
import {OrgHost1591755411755 as OrgHost} from 'app/gen-server/migration/1591755411755-OrgHost';
|
||||
import {DocRemovedAt1592261300044 as DocRemovedAt} from 'app/gen-server/migration/1592261300044-DocRemovedAt';
|
||||
import {Prefs1596456522124 as Prefs} from 'app/gen-server/migration/1596456522124-Prefs';
|
||||
import {ExternalBilling1623871765992 as ExternalBilling} from 'app/gen-server/migration/1623871765992-ExternalBilling';
|
||||
import {DocOptions1626369037484 as DocOptions} from 'app/gen-server/migration/1626369037484-DocOptions';
|
||||
import {Secret1631286208009 as Secret} from 'app/gen-server/migration/1631286208009-Secret';
|
||||
import {UserOptions1644363380225 as UserOptions} from 'app/gen-server/migration/1644363380225-UserOptions';
|
||||
import {GracePeriodStart1647883793388
|
||||
as GracePeriodStart} from 'app/gen-server/migration/1647883793388-GracePeriodStart';
|
||||
import {DocumentUsage1651469582887 as DocumentUsage} from 'app/gen-server/migration/1651469582887-DocumentUsage';
|
||||
import {Activations1652273656610 as Activations} from 'app/gen-server/migration/1652273656610-Activations';
|
||||
import {UserConnectId1652277549983 as UserConnectId} from 'app/gen-server/migration/1652277549983-UserConnectId';
|
||||
import {UserUUID1663851423064 as UserUUID} from 'app/gen-server/migration/1663851423064-UserUUID';
|
||||
import {UserRefUnique1664528376930 as UserUniqueRefUUID} from 'app/gen-server/migration/1664528376930-UserRefUnique';
|
||||
import {Forks1673051005072 as Forks} from 'app/gen-server/migration/1673051005072-Forks';
|
||||
import {ForkIndexes1678737195050 as ForkIndexes} from 'app/gen-server/migration/1678737195050-ForkIndexes';
|
||||
import {ActivationPrefs1682636695021 as ActivationPrefs} from 'app/gen-server/migration/1682636695021-ActivationPrefs';
|
||||
import {AssistantLimit1685343047786 as AssistantLimit} from 'app/gen-server/migration/1685343047786-AssistantLimit';
|
||||
|
||||
const home: HomeDBManager = new HomeDBManager();
|
||||
|
||||
const migrations = [Initial, Login, PinDocs, UserPicture, DisplayEmail, DisplayEmailNonNull,
|
||||
Indexes, Billing, Aliases, TeamMembers, FirstLogin, FirstTimeUser,
|
||||
CustomerIndex, ExtraIndexes, OrgHost, DocRemovedAt, Prefs,
|
||||
ExternalBilling, DocOptions, Secret, UserOptions, GracePeriodStart,
|
||||
DocumentUsage, Activations, UserConnectId, UserUUID, UserUniqueRefUUID,
|
||||
Forks, ForkIndexes, ActivationPrefs, AssistantLimit];
|
||||
|
||||
// Assert that the "members" acl rule and group exist (or not).
|
||||
function assertMembersGroup(org: Organization, exists: boolean) {
|
||||
const memberAcl = org.aclRules.find(_aclRule => _aclRule.group.name === roles.MEMBER);
|
||||
if (!exists) {
|
||||
assert.isUndefined(memberAcl);
|
||||
} else {
|
||||
assert.isDefined(memberAcl);
|
||||
assert.equal(memberAcl!.permissions, Permissions.VIEW);
|
||||
assert.isDefined(memberAcl!.group);
|
||||
assert.equal(memberAcl!.group.name, roles.MEMBER);
|
||||
}
|
||||
}
|
||||
|
||||
describe('migrations', function() {
|
||||
|
||||
before(function() {
|
||||
setUpDB(this);
|
||||
});
|
||||
|
||||
beforeEach(async function() {
|
||||
await home.connect();
|
||||
await createInitialDb(home.connection, false);
|
||||
});
|
||||
|
||||
afterEach(async function() {
|
||||
await removeConnection();
|
||||
});
|
||||
|
||||
// a test to exercise the rollback scripts a bit
|
||||
it('can migrate, do full rollback, and migrate again', async function() {
|
||||
this.timeout(60000);
|
||||
const runner = home.connection.createQueryRunner();
|
||||
for (const migration of migrations) {
|
||||
await (new migration()).up(runner);
|
||||
}
|
||||
for (const migration of migrations.slice().reverse()) {
|
||||
await (new migration()).down(runner);
|
||||
}
|
||||
for (const migration of migrations) {
|
||||
await (new migration()).up(runner);
|
||||
}
|
||||
await addSeedData(home.connection);
|
||||
// if we made it this far without an exception, then the rollback scripts must
|
||||
// be doing something.
|
||||
});
|
||||
|
||||
it('can correctly switch display_email column to non-null with data', async function() {
|
||||
this.timeout(60000);
|
||||
const sqlite = home.connection.driver.options.type === 'sqlite';
|
||||
// sqlite migrations need foreign keys turned off temporarily
|
||||
if (sqlite) { await home.connection.query("PRAGMA foreign_keys = OFF;"); }
|
||||
const runner = home.connection.createQueryRunner();
|
||||
for (const migration of migrations) {
|
||||
await (new migration()).up(runner);
|
||||
}
|
||||
await addSeedData(home.connection);
|
||||
// migrate back until just before display_email column added, so we have no
|
||||
// display_emails
|
||||
for (const migration of migrations.slice().reverse()) {
|
||||
await (new migration()).down(runner);
|
||||
if (migration.name === DisplayEmail.name) { break; }
|
||||
}
|
||||
// now check DisplayEmail and DisplayEmailNonNull succeed with data in the db.
|
||||
await (new DisplayEmail()).up(runner);
|
||||
await (new DisplayEmailNonNull()).up(runner);
|
||||
if (sqlite) { await home.connection.query("PRAGMA foreign_keys = ON;"); }
|
||||
});
|
||||
|
||||
// a test to ensure the TeamMember migration works on databases with existing content
|
||||
it('can perform TeamMember migration with seed data set', async function() {
|
||||
this.timeout(30000);
|
||||
const runner = home.connection.createQueryRunner();
|
||||
// Perform full up migration and add the seed data.
|
||||
for (const migration of migrations) {
|
||||
await (new migration()).up(runner);
|
||||
}
|
||||
await addSeedData(home.connection);
|
||||
const initAclCount = await getAclRowCount(runner);
|
||||
const initGroupCount = await getGroupRowCount(runner);
|
||||
|
||||
// Assert that members groups are present to start.
|
||||
for (const org of (await getAllOrgs(runner))) { assertMembersGroup(org, true); }
|
||||
|
||||
// Perform down TeamMembers migration with seed data and assert members groups are removed.
|
||||
await (new TeamMembers()).down(runner);
|
||||
const downMigratedOrgs = await getAllOrgs(runner);
|
||||
for (const org of downMigratedOrgs) { assertMembersGroup(org, false); }
|
||||
// Assert that the correct number of ACLs and groups were removed.
|
||||
assert.equal(await getAclRowCount(runner), initAclCount - downMigratedOrgs.length);
|
||||
assert.equal(await getGroupRowCount(runner), initGroupCount - downMigratedOrgs.length);
|
||||
|
||||
// Perform up TeamMembers migration with seed data and assert members groups are added.
|
||||
await (new TeamMembers()).up(runner);
|
||||
for (const org of (await getAllOrgs(runner))) { assertMembersGroup(org, true); }
|
||||
// Assert that the correct number of ACLs and groups were re-added.
|
||||
assert.equal(await getAclRowCount(runner), initAclCount);
|
||||
assert.equal(await getGroupRowCount(runner), initGroupCount);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns all orgs in the database with aclRules and groups joined.
|
||||
*/
|
||||
function getAllOrgs(queryRunner: QueryRunner): Promise<Organization[]> {
|
||||
const orgQuery = queryRunner.manager.createQueryBuilder()
|
||||
.select('orgs')
|
||||
.from(Organization, 'orgs')
|
||||
.leftJoinAndSelect('orgs.aclRules', 'org_acl_rules')
|
||||
.leftJoinAndSelect('org_acl_rules.group', 'org_groups');
|
||||
return orgQuery.getMany();
|
||||
}
|
||||
|
||||
async function getAclRowCount(queryRunner: QueryRunner): Promise<number> {
|
||||
const rows = await queryRunner.query(`SELECT id FROM acl_rules`);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function getGroupRowCount(queryRunner: QueryRunner): Promise<number> {
|
||||
const rows = await queryRunner.query(`SELECT id FROM groups`);
|
||||
return rows.length;
|
||||
}
|
Loading…
Reference in New Issue
Block a user