diff --git a/Makefile b/Makefile --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ start: db build test-nginx -test: db +test: ./npm.sh run test watch: diff --git a/lib/app/app.js b/lib/app/app.js --- a/lib/app/app.js +++ b/lib/app/app.js @@ -17,6 +17,7 @@ const WwwServerFactory = locreq("lib/http/http.js"); const DatastoreMongoFactory = locreq("lib/datastore/db.js"); +const MetadataFactory = locreq("lib/app/metadata.js"); const load_base_chips = locreq("lib/app/load-base-chips"); const default_config = locreq("default_config.json"); @@ -26,6 +27,7 @@ class App { constructor(custom_config, manifest) { + this.status = "stopped"; this.Sealious = Sealious; this.setupEventEmitter(); @@ -55,6 +57,7 @@ load_base_chips(this); this.Datastore = new DatastoreMongoFactory(this); + this.Metadata = MetadataFactory(this); this.FieldType = Sealious.FieldType.bind(Sealious.FieldType, this); this.Collection = Sealious.Collection.bind(Sealious.Collection, this); @@ -100,6 +103,7 @@ } async start() { + this.status = "starting"; assert( ["dev", "production"].includes( this.ConfigManager.get("core.environment") @@ -110,12 +114,17 @@ await this.Datastore.start(); await this.Mail.init(); await this.ChipManager.start_chips(); + await this.emit("start"); + this.status = "running"; return this; } async stop() { + this.status = "stopping"; + await this.emit("stop"); await this.WwwServer.stop(); await this.Datastore.stop(); + this.status = "stopped"; } } diff --git a/lib/app/base-chips/collections/password-reset-intents.subtest.js b/lib/app/base-chips/collections/password-reset-intents.subtest.js --- a/lib/app/base-chips/collections/password-reset-intents.subtest.js +++ b/lib/app/base-chips/collections/password-reset-intents.subtest.js @@ -2,28 +2,10 @@ const axios = require("axios"); const assert = require("assert"); const { promise_timeout } = locreq("test_utils"); +const { with_running_app } = locreq("test_utils/with-test-app.js"); describe("password-reset-intents", () => { - let app = null; - const port = 8888; - const base_url = `http://localhost:${port}/api/v1`; - let smtp_api = null; - before(() => (smtp_api = TestApp.ConfigManager.get("tests.smtp_api_url"))); - - beforeEach(async () => { - app = new Sealious.App( - { - "www-server": { port: 8888 }, - upload_path: "/dev/null", - logger: { level: "emerg" }, - core: { environment: "production" }, - smtp: TestApp.ConfigManager.get("smtp"), - datastore_mongo: TestApp.ConfigManager.get("datastore_mongo"), - }, - TestApp.manifest - ); - - await app.start(); + async function create_a_user(app) { await app.run_action( new app.Sealious.SuperContext(), ["collections", "users"], @@ -34,71 +16,75 @@ password: "password", } ); - await axios.delete(`${smtp_api}/messages`); - }); + } - it("tells you if the email address doesn't exist", async () => { - try { - await axios.post(`${base_url}/collections/password-reset-intents`, { - email: "fake@example.com", - }); - } catch (e) { - assert.equal( - e.response.data.data.email.message, - "No users with email set to fake@example.com" - ); - return; - } - throw new Error("it didn't throw"); - }); - - it("allows anyone to create an intent, if the email exists", async () => { - const data = (await axios.post( - `${base_url}/collections/password-reset-intents`, - { - email: "user@example.com", + it("tells you if the email address doesn't exist", async () => + with_running_app(async ({ app, base_url }) => { + try { + await axios.post( + `${base_url}/api/v1/collections/password-reset-intents`, + { + email: "fake@example.com", + } + ); + } catch (e) { + assert.equal( + e.response.data.data.email.message, + "No users with email set to fake@example.com" + ); + return; } - )).data; - assert.deepEqual(data.body, { - email: "user@example.com", - token: "it's a secret to everybody", - }); - }); + throw new Error("it didn't throw"); + })); - it("tells you if the email address is malformed", async () => { - try { - await axios.post(`${base_url}/collections/password-reset-intents`, { - email: "incorrect-address", + it("allows anyone to create an intent, if the email exists", async () => + with_running_app(async ({ app, base_url }) => { + await create_a_user(app); + const data = (await axios.post( + `${base_url}/api/v1/collections/password-reset-intents`, + { + email: "user@example.com", + } + )).data; + assert.deepEqual(data.body, { + email: "user@example.com", + token: "it's a secret to everybody", }); - } catch (e) { - assert.equal( - e.response.data.data.email.message, - "incorrect-address is a not valid e-mail address." - ); - return; - } - throw new Error("it didn't throw"); - }); + })); - it("sends an email with the reset password link", async () => { - const data = (await axios.post( - `${base_url}/collections/password-reset-intents`, - { - email: "user@example.com", + it("tells you if the email address is malformed", async () => + with_running_app(async ({ base_url }) => { + try { + await axios.post( + `${base_url}/api/v1/collections/password-reset-intents`, + { + email: "incorrect-address", + } + ); + } catch (e) { + assert.equal( + e.response.data.data.email.message, + "incorrect-address is a not valid e-mail address." + ); + return; } - )).data; - const messages = (await axios.get(`${smtp_api}/messages`)).data; - assert.equal(messages.length, 1); - assert.equal(messages[0].recipients.length, 1); - assert.equal(messages[0].recipients[0], "<user@example.com>"); - }); + throw new Error("it didn't throw"); + })); - afterEach(async () => { - await Promise.all( - app.ChipManager.get_all_collections().map(collection_name => - app.Datastore.remove(collection_name, {}, "just_one" && false) - ) - ); - await app.stop(); - }); + it("sends an email with the reset password link", async () => + with_running_app(async ({ app, base_url, mail_api }) => { + await create_a_user(app); + const data = (await axios.post( + `${base_url}/api/v1/collections/password-reset-intents`, + { + email: "user@example.com", + } + )).data; + const messages = (await mail_api.get_messages()).filter( + message => message.recipients[0] == "<user@example.com>" + ); + assert(messages.length, 1); + assert.equal(messages[0].recipients.length, 1); + assert.equal(messages[0].recipients[0], "<user@example.com>"); + })); }); diff --git a/lib/app/base-chips/field-types/field-types.test.js b/lib/app/base-chips/field-types/field-types.test.js --- a/lib/app/base-chips/field-types/field-types.test.js +++ b/lib/app/base-chips/field-types/field-types.test.js @@ -1,3 +1,4 @@ describe("field types", () => { require("./single_reference.subtest.js"); + require("./reverse-single-reference.subtest.js"); }); diff --git a/lib/app/base-chips/field-types/reverse-single-reference.js b/lib/app/base-chips/field-types/reverse-single-reference.js new file mode 100644 --- /dev/null +++ b/lib/app/base-chips/field-types/reverse-single-reference.js @@ -0,0 +1,213 @@ +"use strict"; +const locreq = require("locreq")(__dirname); +const Promise = require("bluebird"); +const Errors = locreq("lib/response/error.js"); +const assert = require("assert"); + +function params_to_cache_key(collection, field_name, params) { + return `${collection.name}___${field_name}-reverse-single-reference(${ + params.collection.name + },${params.collection.field_name}).last_update`; +} + +async function update_cache( + app, + collection, + field_name, + params, + resource_ids = undefined +) { + let pipeline; + if (resource_ids) { + assert(Array.isArray(resource_ids)); + pipeline = [ + { $match: { [`body.${params.field_name}`]: { $in: resource_ids } } }, + ]; + } else { + pipeline = []; + } + pipeline.push({ + $group: { + _id: `$body.${params.field_name}`, + referenced_by: { $push: `$sealious_id` }, + }, + }); + const to_update = await app.Datastore.aggregate( + params.collection.name, + pipeline + ); + if (resource_ids) { + for (let resource_id of resource_ids) { + if (to_update.filter(e => e._id == resource_id).length === 0) { + to_update.push({ _id: resource_id, referenced_by: [] }); + } + } + } + for (let entry of to_update) { + await app.Datastore.update( + collection.name, + { sealious_id: entry._id }, + { $set: { [`body.${field_name}`]: entry.referenced_by } } + ); + } +} + +const reverse_single_reference_factory = app => { + return { + name: "reverse-single-reference", + + get_description: function() { + return "Shows which resources from given collection point to this resource in a given field."; + }, + + get_default_value: async () => [], + + is_proper_value: function(context, params, new_value) { + return context.is_super + ? Promise.resolve() + : Promise.reject("This is a read-only field"); + }, + + filter_to_query: async function(context, params, field_filter) { + if (typeof field_filter === "object") { + const matches = await app.run_action( + context, + ["collections", params.collection.name], + "show", + { + filter: field_filter, + [params.field_name]: "asdfasdfasdfasukyfgbyausdgbrfdaye", + } + ); + const ids = matches.map(resource => resource.id); + return { + $in: ids, + }; + } else { + return { + $eq: field_filter, + }; + } + }, + + format: function(context, params, decoded_value, format) { + // format can be "expand" or "deep-expand:<depth>", like "deep-expand:3" + if (!format) { + return decoded_value; // just the IDs + } + + const format_params = format.split(":"); + + if (format_params[0] === "expand" || format_params[0] === "deep-expand") { + if (decoded_value === undefined) { + return undefined; + } + const query_format = {}; + if (format_params[0] === "deep-expand" && format_params[1] > 1) { + for (const field_name in params.collection.fields) { + const field = params.collection.fields[field_name]; + if (field.type.name === "single_reference") { + query_format[field_name] = `deep-expand:${parseInt( + format_params[1] + ) - 1}`; + } + } + } + const resource_ids = decoded_value; + return Promise.map(resource_ids, async resource_id => + app.run_action( + context, + ["collections", params.collection.name, resource_id], + "show", + { format: query_format } + ) + ); + } else { + return decoded_value; + } + }, + + init: async (collection, field_name, params) => { + assert( + params.collection instanceof app.Sealious.Collection, + "'params.collection' should be an instance of Collection" + ); + assert( + params.collection.fields[params.field_name], + `Collection '${ + params.collection.name + }' does not contain a field named ${params.field_name}.` + ); + app.on("start", async () => { + const last_modified_resource_in_reference_collection = (await app.run_action( + new app.Sealious.SuperContext(), + ["collections", params.collection.name], + "show", + { + sort: { "last_modified_context.timestamp": "desc" }, + pagination: { items: 1 }, + } + ))[0]; + + if (last_modified_resource_in_reference_collection) { + const last_modified_resource_timestamp = + last_modified_resource_in_reference_collection.last_modified_context + .timestamp; + const last_field_cache_update = + (await app.Metadata.get( + params_to_cache_key(collection, field_name, params) + )) || 0; + if (last_modified_resource_timestamp > last_field_cache_update) { + await update_cache(app, collection, field_name, params); + await app.Metadata.set( + params_to_cache_key(collection, field_name, params), + Date.now() + ); + } + } + }); + app.on( + new RegExp(`post:collections\.${params.collection.name}:create`), + async (path, event_params, resource) => { + const referenced_id = resource.body[params.field_name]; + await update_cache(app, collection, field_name, params, [ + referenced_id, + ]); + } + ); + app.on( + new RegExp(`post:collections\.${params.collection.name}\..*:delete`), + async (path, event_params, resource) => { + const deleted_id = path[2]; + const affected = await app.Datastore.find(collection.name, { + [`body.${field_name}`]: deleted_id, + }); + const affected_ids = affected.map(document => document.sealious_id); + await update_cache(app, collection, field_name, params, affected_ids); + } + ); + app.on( + new RegExp(`post:collections\.${params.collection.name}\..*:edit`), + async (path, event_params, resource) => { + if (!event_params.hasOwnProperty(params.field_name)) return; + const edited_id = path[2]; + const no_longer_referenced = await app.Datastore.find( + collection.name, + { + [`body.${field_name}`]: edited_id, + } + ); + const affected_ids = no_longer_referenced.map( + document => document.sealious_id + ); + if (event_params[params.field_name]) { + affected_ids.push(event_params[params.field_name]); + } + await update_cache(app, collection, field_name, params, affected_ids); + } + ); + }, + }; +}; + +module.exports = reverse_single_reference_factory; diff --git a/lib/app/base-chips/field-types/reverse-single-reference.subtest.js b/lib/app/base-chips/field-types/reverse-single-reference.subtest.js new file mode 100644 --- /dev/null +++ b/lib/app/base-chips/field-types/reverse-single-reference.subtest.js @@ -0,0 +1,197 @@ +const assert = require("assert"); +const locreq = require("locreq")(__dirname); +const axios = require("axios"); +const { create_resource_as } = locreq("test_utils"); +const { with_stopped_app, with_running_app } = locreq( + "test_utils/with-test-app.js" +); +const DatastoreMongoFactory = locreq("lib/datastore/db.js"); + +describe("reverse-single-reference", () => { + async function create_referencing_collections(app, with_reverse) { + const A = app.createChip(app.Sealious.Collection, { + name: "A", + fields: [ + { + name: "reference_to_b", + type: "single_reference", + params: { collection: "B" }, + }, + { + name: "pairity", + type: "text", + }, + ], + }); + const B = app.createChip(app.Sealious.Collection, { + name: "B", + fields: [{ name: "number", type: "int" }], + }); + if (with_reverse) { + B.add_field({ + name: "references_in_a", + type: "reverse-single-reference", + params: { collection: A, field_name: "reference_to_b" }, + }); + } + } + + async function create_resources(app) { + const numbers = [1, 2, 3]; + const bs = []; + for (let number of numbers) { + const new_b = await app.run_action( + new app.Sealious.SuperContext(), + ["collections", "B"], + "create", + { number } + ); + bs.push(new_b); + } + for (let b of bs) { + for (let i = 1; i <= b.body.number; i++) { + await app.run_action( + new app.Sealious.SuperContext(), + ["collections", "A"], + "create", + { reference_to_b: b.id, pairity: i % 2 ? "odd" : "even" } + ); + } + } + } + + it("recreates the cached values if the field has just been added", async () => { + await with_stopped_app(async ({ app, dont_clear_database_on_stop }) => { + await create_referencing_collections(app, "with_reverse" && false); + await app.start(); + await create_resources(app); + dont_clear_database_on_stop(); + }); + await with_stopped_app(async ({ base_url, app, rest_api }) => { + await create_referencing_collections(app, "with_reverse" && true); + await app.start(); + const result = (await rest_api.get( + "/api/v1/collections/B?filter[number]=1" + ))[0]; + assert(result.body.references_in_a); + assert.equal(result.body.references_in_a.length, 1); + const result2 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=2" + ))[0]; + assert(result2.body.references_in_a); + assert.equal(result2.body.references_in_a.length, 2); + }); + }); + + it("updates the cached value when a new reference is created", async () => { + await with_stopped_app(async ({ app, rest_api }) => { + await create_referencing_collections(app, "with_reverse" && true); + await app.start(); + await create_resources(app); + const result2 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=2" + ))[0]; + assert(result2.body.references_in_a instanceof Array); + assert.equal(result2.body.references_in_a.length, 2); + }); + }); + + it("updates the cached value when an old reference is deleted", async () => { + await with_stopped_app(async ({ app, rest_api }) => { + await create_referencing_collections(app, "with_reverse" && true); + await app.start(); + await create_resources(app); + const result2 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=2" + ))[0]; + const referencing_id = result2.body.references_in_a[0]; + await rest_api.delete(`/api/v1/collections/A/${referencing_id}`); + const new_result2 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=2" + ))[0]; + assert.equal(new_result2.body.references_in_a.length, 1); + }); + }); + + it("updates the cached value when an old reference is edited to a new one", async () => { + await with_stopped_app(async ({ app, rest_api }) => { + await create_referencing_collections(app, "with_reverse" && true); + await app.start(); + await create_resources(app); + const result1 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=1" + ))[0]; + const result2 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=2" + ))[0]; + const referencing_id = result2.body.references_in_a[0]; + + await rest_api.patch(`/api/v1/collections/A/${referencing_id}`, { + reference_to_b: result1.id, + }); + const new_result2 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=2" + ))[0]; + assert.equal(new_result2.body.references_in_a.length, 1); + const new_result1 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=1" + ))[0]; + assert.equal(new_result1.body.references_in_a.length, 2); + }); + }); + + it("updates the cached value when an old reference is edited to an empty one", async () => { + await with_stopped_app(async ({ app, rest_api }) => { + await create_referencing_collections(app, "with_reverse" && true); + await app.start(); + await create_resources(app); + const result1 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=1" + ))[0]; + const result2 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=2" + ))[0]; + const referencing_id = result2.body.references_in_a[0]; + + await rest_api.patch(`/api/v1/collections/A/${referencing_id}`, { + reference_to_b: "", + }); + const new_result2 = (await rest_api.get( + "/api/v1/collections/B?filter[number]=2" + ))[0]; + assert.equal(new_result2.body.references_in_a.length, 1); + }); + }); + + it("allows to filter by a value of the referencing resource", async () => { + await with_stopped_app(async ({ app, rest_api }) => { + await create_referencing_collections(app, "with_reverse" && true); + await app.start(); + await create_resources(app); + let results = await rest_api.get( + "/api/v1/collections/B?filter[references_in_a][pairity]=non-existant" + ); + assert.equal(results.length, 0); + results = await rest_api.get( + "/api/v1/collections/B?filter[references_in_a][pairity]=odd" + ); + assert.equal(results.length, 3); + results = await rest_api.get( + "/api/v1/collections/B?filter[references_in_a][pairity]=even&filter[number]=3" + ); + assert.equal(results.length, 1); + }); + }); + + it("allows to display the full body of the referencing resources", async () => { + await with_stopped_app(async ({ app, rest_api }) => { + await create_referencing_collections(app, "with_reverse" && true); + await app.start(); + await create_resources(app); + let results = await rest_api.get( + "/api/v1/collections/B?format[references_in_a]=expand" + ); + assert(results[0].body.references_in_a[0].body); + }); + }); +}); diff --git a/lib/app/base-chips/field-types/single_reference.subtest.js b/lib/app/base-chips/field-types/single_reference.subtest.js --- a/lib/app/base-chips/field-types/single_reference.subtest.js +++ b/lib/app/base-chips/field-types/single_reference.subtest.js @@ -2,22 +2,11 @@ const locreq = require("locreq")(__dirname); const axios = require("axios"); const { create_resource_as } = locreq("test_utils"); +const { with_running_app } = locreq("test_utils/with-test-app.js"); describe("single_reference", () => { - let App = null; - const port = 8888; - const base_url = `http://localhost:${port}/api/v1`; - beforeEach(async () => { - App = new Sealious.App( - { - "www-server": { port: 8888 }, - upload_path: "/dev/null", - logger: { level: "emerg" }, - datastore_mongo: TestApp.ConfigManager.get("datastore_mongo"), - }, - TestApp.manifest - ); - App.createChip(Sealious.Collection, { + async function create_referencing_collections(app) { + app.createChip(app.Sealious.Collection, { name: "A", fields: [ { @@ -32,69 +21,72 @@ }, ], }); - App.createChip(Sealious.Collection, { + app.createChip(app.Sealious.Collection, { name: "B", fields: [{ name: "number", type: "int" }], }); - await App.start(); - }); + } - it("should not allow a value that is not an existing id", () => { - return axios - .post(`${base_url}/collections/A`, { - reference_to_b: "non-existing-id", - }) - .then(res => { - throw "This should not succeed"; - }) - .catch(res => - assert.equal( - res.response.data.data.reference_to_b.message, - "Nie masz dostępu do danego zasobu z kolekcji B lub on nie istnieje." - ) - ); - }); - - it("should allow a value that exists in B", async () => { - const b_id = (await axios.post(`${base_url}/collections/B`, { - number: 1, - })).data.id; - return axios.post(`${base_url}/collections/A`, { - reference_to_b: b_id, - }); - }); - - it("should not allow a value that exists in B but does not meet the filter criteria", async () => { - const b_id = (await axios.post(`${base_url}/collections/B`, { - number: 0, - })).data.id; - - return axios - .post(`${base_url}/collections/A`, { - filtered_reference_to_b: b_id, - }) - .then(response => { - throw "This should fail"; - }) - .catch(error => { - assert.equal( - error.response.data.data.filtered_reference_to_b.message, - "Nie masz dostępu do danego zasobu z kolekcji B lub on nie istnieje." + it("should not allow a value that is not an existing id", async () => + with_running_app(async ({ app, base_url }) => { + await create_referencing_collections(app); + return axios + .post(`${base_url}/api/v1/collections/A`, { + reference_to_b: "non-existing-id", + }) + .then(res => { + throw "This should not succeed"; + }) + .catch(res => + assert.equal( + res.response.data.data.reference_to_b.message, + "Nie masz dostępu do danego zasobu z kolekcji B lub on nie istnieje." + ) ); + })); + + it("should allow a value that exists in B", async () => + with_running_app(async ({ app, base_url }) => { + create_referencing_collections(app); + const b_id = (await axios.post(`${base_url}/api/v1/collections/B`, { + number: 1, + })).data.id; + return axios.post(`${base_url}/api/v1/collections/A`, { + reference_to_b: b_id, }); - }); + })); - it("should allow a value that exists in B but does not meet the filter criteria", async () => { - const b_id = (await axios.post(`${base_url}/collections/B`, { - number: 1, - })).data.id; + it("should not allow a value that exists in B but does not meet the filter criteria", async () => + with_running_app(async ({ app, base_url }) => { + create_referencing_collections(app); + const b_id = (await axios.post(`${base_url}/api/v1/collections/B`, { + number: 0, + })).data.id; - return axios.post(`${base_url}/collections/A`, { - filtered_reference_to_b: b_id, - }); - }); + return axios + .post(`${base_url}/api/v1/collections/A`, { + filtered_reference_to_b: b_id, + }) + .then(response => { + throw "This should fail"; + }) + .catch(error => { + assert.equal( + error.response.data.data.filtered_reference_to_b.message, + "Nie masz dostępu do danego zasobu z kolekcji B lub on nie istnieje." + ); + }); + })); + + it("should allow a value that exists in B but does not meet the filter criteria", async () => + with_running_app(async ({ app, base_url }) => { + create_referencing_collections(app); + const b_id = (await axios.post(`${base_url}/api/v1/collections/B`, { + number: 1, + })).data.id; - afterEach(async () => { - await App.stop(); - }); + return axios.post(`${base_url}/api/v1/collections/A`, { + filtered_reference_to_b: b_id, + }); + })); }); diff --git a/lib/app/base-chips/special_filters/IsReferencedByResourcesMatching.subtest.js b/lib/app/base-chips/special_filters/IsReferencedByResourcesMatching.subtest.js --- a/lib/app/base-chips/special_filters/IsReferencedByResourcesMatching.subtest.js +++ b/lib/app/base-chips/special_filters/IsReferencedByResourcesMatching.subtest.js @@ -5,29 +5,18 @@ const { create_resource_as } = locreq("test_utils"); const IsReferencedByResourcesMatching = require("./IsReferencedByResourcesMatching"); +const { with_running_app } = locreq("test_utils/with-test-app.js"); describe("IsReferencedByResourcesMatching", () => { - let App = null; - const port = 8888; - const base_url = `http://localhost:${port}/api/v1`; - beforeEach(async () => { - App = new Sealious.App( - { - "www-server": { port: 8888 }, - upload_path: "/dev/null", - logger: { level: "emerg" }, - datastore_mongo: TestApp.ConfigManager.get("datastore_mongo"), - }, - TestApp.manifest - ); - - const Users = App.ChipManager.get_chip("collection", "users"); + async function setup(app) { + const port = app.ConfigManager.get("www-server.port"); + const Users = app.ChipManager.get_chip("collection", "users"); Users.set_access_strategy({ create: "public", retrieve: "public", }); - const UsersRoles = App.createChip(Sealious.Collection, { + const UsersRoles = app.createChip(app.Sealious.Collection, { name: "users-roles", fields: [ { @@ -58,8 +47,6 @@ }), }); - await App.start(); - const users = [ { username: "admin", @@ -96,25 +83,21 @@ port, }) ); - }); + } it("returns only users with role matching `allowed_values`", () => - axios - .get(`${base_url}/collections/users/@staff`) - .then(resp => - resp.data.forEach(user => - assert( - user.body.username === "admin" || user.body.username === "moderator" - ) - ) - )); - - afterEach(async () => { - await Promise.all( - App.ChipManager.get_all_collections().map(collection_name => - App.Datastore.remove(collection_name, {}, "just_one" && false) - ) - ); - await App.stop(); - }); + with_running_app(async ({ app, base_url }) => { + await setup(app); + return axios + .get(`${base_url}/api/v1/collections/users/@staff`) + .then(resp => { + assert(resp.data.length > 0); + resp.data.forEach(user => + assert( + user.body.username === "admin" || + user.body.username === "moderator" + ) + ); + }); + })); }); diff --git a/lib/app/base-chips/special_filters/matches.subtest.js b/lib/app/base-chips/special_filters/matches.subtest.js --- a/lib/app/base-chips/special_filters/matches.subtest.js +++ b/lib/app/base-chips/special_filters/matches.subtest.js @@ -5,23 +5,12 @@ const { create_resource_as } = locreq("test_utils"); const matches = require("./matches"); +const { with_running_app } = locreq("test_utils/with-test-app.js"); describe("Matches", () => { - let App = null; - const port = 8888; - const base_url = `http://localhost:${port}/api/v1`; - beforeEach(async () => { - App = new Sealious.App( - { - "www-server": { port: 8888 }, - upload_path: "/dev/null", - logger: { level: "emerg" }, - datastore_mongo: TestApp.ConfigManager.get("datastore_mongo"), - }, - TestApp.manifest - ); - - App.createChip(Sealious.Collection, { + async function setup(app) { + const port = app.ConfigManager.get("www-server.port"); + app.createChip(Sealious.Collection, { name: "numbers", fields: [ { @@ -35,8 +24,6 @@ }, }); - await App.start(); - const numbers = [-2, -1, 0, 1, 2]; await Promise.map(numbers, n => create_resource_as({ @@ -45,31 +32,30 @@ port, }) ); - }); + } it("returns only positive numbers when using @positive filter", () => - axios - .get(`${base_url}/collections/numbers/@positive?sort[body.number]=asc`) - .then(resp => - assert.deepEqual(resp.data.map(resource => resource.body.number), [ - 1, - 2, - ]) - )); + with_running_app(async ({ app, base_url }) => { + await setup(app); + return axios + .get( + `${base_url}/api/v1/collections/numbers/@positive?sort[body.number]=asc` + ) + .then(resp => + assert.deepEqual(resp.data.map(resource => resource.body.number), [ + 1, + 2, + ]) + ); + })); it("returns empty array when using both @positive and @negative filters", () => - axios - .get(`${base_url}/collections/numbers/@positive/@negative`) - .then(resp => - assert.deepEqual(resp.data.map(resource => resource.body.number), []) - )); - - afterEach(async () => { - await Promise.all( - App.ChipManager.get_all_collections().map(collection_name => - App.Datastore.remove(collection_name, {}, "just_one" && false) - ) - ); - await App.stop(); - }); + with_running_app(async ({ app, base_url }) => { + await setup(app); + return axios + .get(`${base_url}/api/v1/collections/numbers/@positive/@negative`) + .then(resp => + assert.deepEqual(resp.data.map(resource => resource.body.number), []) + ); + })); }); diff --git a/lib/app/load-base-chips.js b/lib/app/load-base-chips.js --- a/lib/app/load-base-chips.js +++ b/lib/app/load-base-chips.js @@ -45,6 +45,7 @@ "value-existing-in-collection", "value-not-existing-in-collection", "secret-token", + "reverse-single-reference", ]); BaseChips.set(CalculatedFieldType, ["map-reduce", "aggregate", "custom"]); diff --git a/lib/app/metadata.js b/lib/app/metadata.js new file mode 100644 --- /dev/null +++ b/lib/app/metadata.js @@ -0,0 +1,24 @@ +const COLLECTION_NAME = "_metadata"; + +module.exports = app => ({ + async get(key) { + const matches = await app.Datastore.find("COLLECTION_NAME", { key }); + if (matches.length) { + return matches[0].value; + } else { + undefined; + } + }, + async set(key, value) { + const matches = await app.Datastore.find("COLLECTION_NAME", { key }); + if (matches.length) { + await app.Datastore.update( + "COLLECTION_NAME", + { key: key }, + { $set: { value: value } } + ); + } else { + await app.Datastore.insert("COLLECTION_NAME", { key, value }); + } + }, +}); diff --git a/lib/chip-types/collection.js b/lib/chip-types/collection.js --- a/lib/chip-types/collection.js +++ b/lib/chip-types/collection.js @@ -73,8 +73,13 @@ Collection.type_name = "collection"; Collection.pure = { - add_field: function(app, field_type, fields, field_declaration) { - const field_object = new Field(app, field_declaration, field_type); + add_field: function(app, field_type, fields, field_declaration, collection) { + const field_object = new Field( + app, + field_declaration, + field_type, + collection + ); const field_name = field_object.name; if (!fields[field_name]) { fields[field_name] = field_object; @@ -519,7 +524,7 @@ Collection.prototype = { add_field(field_declaration) { - return pure.add_field(this.app, this, this.fields, field_declaration); + return pure.add_field(this.app, this, this.fields, field_declaration, this); }, add_fields(field_declarations_array) { return pure.add_fields( diff --git a/lib/chip-types/field-type-default-methods.js b/lib/chip-types/field-type-default-methods.js --- a/lib/chip-types/field-type-default-methods.js +++ b/lib/chip-types/field-type-default-methods.js @@ -5,67 +5,73 @@ const FieldTypeDescription = require("../data-structures/field-type-description.js"); const default_methods = { - has_index: function(params){ + init: function() { + return null; + }, + has_index: function(params) { return false; }, - is_proper_value: function(context, params, new_value, old_value){ + is_proper_value: function(context, params, new_value, old_value) { return Promise.resolve(); }, - format: function(context, params, decoded_value, format_params){ + format: function(context, params, decoded_value, format_params) { return decoded_value; }, - encode: function(context, params, value_in_code){ + encode: function(context, params, value_in_code) { return value_in_code; }, - get_description: function(context, params){ + get_description: function(context, params) { return new FieldTypeDescription(this.name); }, - decode: function(context, params, value_in_database){ + decode: function(context, params, value_in_database) { return value_in_database; }, - filter_to_query: function(context, params, query){ - return Promise.resolve(this.encode(context, params, query)) - .then(function(encoded_value){ + filter_to_query: function(context, params, query) { + return Promise.resolve(this.encode(context, params, query)).then(function( + encoded_value + ) { return { $eq: encoded_value, }; }); }, - full_text_search_enabled: function(){ + full_text_search_enabled: function() { return false; }, - get_aggregation_stages: function(context, params, field_name, query_params){ + get_aggregation_stages: function(context, params, field_name, query_params) { const self = this; - if(!query_params || !query_params.filter) return Promise.resolve([]); + if (!query_params || !query_params.filter) return Promise.resolve([]); const expanded_filter = expandHash(query_params.filter); let field_filter = expanded_filter[field_name]; - if(field_filter && field_filter.length === 1 && field_filter[0] instanceof Array){ + if ( + field_filter && + field_filter.length === 1 && + field_filter[0] instanceof Array + ) { field_filter = field_filter[0]; // to fix an edge case where instead of array of values the array is wrapped within another array } - if(!(field_name in expanded_filter)){ + if (!(field_name in expanded_filter)) { return Promise.resolve([]); } - if(field_name in expanded_filter && field_filter === undefined) - return Promise.resolve([{$match: {[`body.${field_name}`]: {$exists: false}}}]); + if (field_name in expanded_filter && field_filter === undefined) + return Promise.resolve([ + { $match: { [`body.${field_name}`]: { $exists: false } } }, + ]); let new_filter = null; - if(field_filter instanceof Array){ + if (field_filter instanceof Array) { new_filter = Promise.all( - field_filter.map(function(element){ + field_filter.map(function(element) { return self.encode(context, params, element); }) - ) - .then((filters)=> { - return {$in: filters}; + ).then(filters => { + return { $in: filters }; }); - }else{ + } else { new_filter = self.filter_to_query(context, params, field_filter); } - return new_filter - .then(function(filter){ - return [ - {$match: {[`body.${field_name}`]: filter}}, - ]; - }); + return new_filter.then(function(filter) { + return [{ $match: { [`body.${field_name}`]: filter } }]; + }); }, }; diff --git a/lib/chip-types/field.js b/lib/chip-types/field.js --- a/lib/chip-types/field.js +++ b/lib/chip-types/field.js @@ -3,27 +3,29 @@ const default_methods = require("./field-type-default-methods.js"); const FieldType = locreq("lib/chip-types/field-type.js"); -function Field (app, declaration){ - +function Field(app, declaration, collection) { this.name = declaration.name; this.declaration = declaration; this.type = new FieldType(app, declaration.type); this.required = declaration.required || false; this.params = declaration.params || {}; + this.type.init(collection, declaration.name, this.params); const self = this; - for (const method_name in default_methods){ - this[method_name] = (function(method_name){ - return function(){ - const arguments_array = Object.keys(arguments).map((key)=>arguments[key]); + for (const method_name in default_methods) { + this[method_name] = (function(method_name) { + return function() { + const arguments_array = Object.keys(arguments).map( + key => arguments[key] + ); arguments_array.splice(1, 0, self.params); return self.type[method_name].apply(self.type, arguments_array); }; })(method_name); } - this.get_specification = function(){ + this.get_specification = function() { return { name: this.name, type: this.type, @@ -31,14 +33,17 @@ }; }; - this.get_aggregation_stages = function(context, query_params){ + this.get_aggregation_stages = function(context, query_params) { const self = this; return Promise.resolve( - self.type.get_aggregation_stages(context, self.params, self.name, query_params) + self.type.get_aggregation_stages( + context, + self.params, + self.name, + query_params + ) ); - }; - } module.exports = Field; diff --git a/lib/email/smtp-mailer.js b/lib/email/smtp-mailer.js --- a/lib/email/smtp-mailer.js +++ b/lib/email/smtp-mailer.js @@ -27,7 +27,7 @@ this.mail_config.from_address }>`, to, - subject, + subject: subject.toString(), text, html, attachments, diff --git a/lib/email/templates/simple.test.js b/lib/email/templates/simple.test.js --- a/lib/email/templates/simple.test.js +++ b/lib/email/templates/simple.test.js @@ -1,22 +1,21 @@ -const axios = require("axios"); +const locreq = require("locreq")(__dirname); const assert = require("assert"); +const { with_running_app } = locreq("test_utils/with-test-app.js"); describe("simpleTemplate", () => { - let smtp_api = null; - before(() => (smtp_api = TestApp.ConfigManager.get("tests.smtp_api_url"))); - it("sends an email", async () => { - await axios.delete(`${smtp_api}/messages`); - const message = await TestApp.EmailTemplates.Simple(TestApp, { - to: "test@example.com", - subject: "Congratulations!", - text: "Enlarge your 'seal' with herbal supplements", - }); - await message.send(TestApp); - const messages = (await axios.get(`${smtp_api}/messages`)).data; - assert.equal(messages.length, 1); - assert.equal( - messages[0].sender, - `<${TestApp.ConfigManager.get("email.from_address")}>` - ); - }); + it("sends an email", async () => + with_running_app(async ({ app, mail_api }) => { + const message = await app.EmailTemplates.Simple(app, { + to: "test@example.com", + subject: "Congratulations!", + text: "Enlarge your 'seal' with herbal supplements", + }); + await message.send(app); + const messages = await mail_api.get_messages(); + assert.equal(messages.length, 1); + assert.equal( + messages[0].sender, + `<${app.ConfigManager.get("email.from_address")}>` + ); + })); }); diff --git a/lib/http/routes/confirm-password-reset.test.js b/lib/http/routes/confirm-password-reset.test.js --- a/lib/http/routes/confirm-password-reset.test.js +++ b/lib/http/routes/confirm-password-reset.test.js @@ -1,11 +1,13 @@ +const locreq = require("locreq")(__dirname); const axios = require("axios"); +const assert = require("assert"); +const { with_running_app } = locreq("test_utils/with-test-app.js"); describe("confirm-password-reset", () => { - it("displays an html form", async () => { - const response = await axios.get( - `${ - TestApp.manifest.base_url - }/confirm-password-reset?token=kupcia&email=dupcia` - ); - }); + it("displays an html form", async () => + with_running_app(async ({ app, base_url }) => { + const response = await axios.get( + `${base_url}/confirm-password-reset?token=kupcia&email=dupcia` + ); + })); }); diff --git a/lib/http/routes/finalize-password-reset.test.js b/lib/http/routes/finalize-password-reset.test.js --- a/lib/http/routes/finalize-password-reset.test.js +++ b/lib/http/routes/finalize-password-reset.test.js @@ -3,28 +3,10 @@ const axios = require("axios"); const tough = require("tough-cookie"); const { promise_timeout, assert_throws_async } = locreq("test_utils"); +const { with_running_app } = locreq("test_utils/with-test-app.js"); describe("finalize password reset", () => { - let app = null; - const port = 8888; - const base_url = `http://localhost:${port}`; - let smtp_api = null; - before(() => (smtp_api = TestApp.ConfigManager.get("tests.smtp_api_url"))); - - beforeEach(async () => { - app = new Sealious.App( - { - "www-server": { port: 8888 }, - upload_path: "/dev/null", - logger: { level: "emerg" }, - core: { environment: "production" }, - smtp: TestApp.ConfigManager.get("smtp"), - datastore_mongo: TestApp.ConfigManager.get("datastore_mongo"), - }, - TestApp.manifest - ); - - await app.start(); + async function create_a_user(app) { await app.run_action( new app.Sealious.SuperContext(), ["collections", "users"], @@ -35,54 +17,55 @@ password: "password", } ); - await axios.delete(`${smtp_api}/messages`); - }); + } - it("allows to change a password (entire flow)", async () => { - const cookieJar = new tough.CookieJar(); - const options = { - jar: cookieJar, - withCredentials: true, - }; + it("allows to change a password (entire flow)", async () => + with_running_app(async ({ app, base_url, mail_api }) => { + await create_a_user(app); + const cookieJar = new tough.CookieJar(); + const options = { + jar: cookieJar, + withCredentials: true, + }; - await axios.post( - `${base_url}/api/v1/sessions`, - { username: "user", password: "password" }, - options - ); - await axios.delete(`${base_url}/api/v1/sessions/current`, options); - await axios.post(`${base_url}/api/v1/collections/password-reset-intents`, { - email: "user@example.com", - }); + await axios.post( + `${base_url}/api/v1/sessions`, + { username: "user", password: "password" }, + options + ); + await axios.delete(`${base_url}/api/v1/sessions/current`, options); + await axios.post( + `${base_url}/api/v1/collections/password-reset-intents`, + { + email: "user@example.com", + } + ); - const message = (await axios.get(`${smtp_api}/messages/1.html`)).data; - const token = message.match(/token=([^?&]+)/)[1]; - await axios.post(`${base_url}/finalize-password-reset`, { - email: "user@example.com", - token, - password: "new-password", - }); - await axios.post( - `${base_url}/api/v1/sessions`, - { username: "user", password: "new-password" }, - options - ); + const message_metadata = (await mail_api.get_messages()).filter( + message => message.recipients[0] == "<user@example.com>" + )[0]; + assert(message_metadata.subject); + + const message = await mail_api.get_message_by_id(message_metadata.id); - assert_throws_async(async () => { + const token = message.match(/token=([^?&]+)/)[1]; await axios.post(`${base_url}/finalize-password-reset`, { email: "user@example.com", token, - password: "using the same token twice hehehehhee", + password: "new-password", }); - }); - }); + await axios.post( + `${base_url}/api/v1/sessions`, + { username: "user", password: "new-password" }, + options + ); - afterEach(async () => { - await Promise.all( - app.ChipManager.get_all_collections().map(collection_name => - app.Datastore.remove(collection_name, {}, "just_one" && false) - ) - ); - await app.stop(); - }); + assert_throws_async(async () => { + await axios.post(`${base_url}/finalize-password-reset`, { + email: "user@example.com", + token, + password: "using the same token twice hehehehhee", + }); + }); + })); }); diff --git a/lib/http/routes/finalize-registration-intent.test.js b/lib/http/routes/finalize-registration-intent.test.js --- a/lib/http/routes/finalize-registration-intent.test.js +++ b/lib/http/routes/finalize-registration-intent.test.js @@ -3,67 +3,37 @@ const axios = require("axios"); const tough = require("tough-cookie"); const { promise_timeout, assert_throws_async } = locreq("test_utils"); +const { with_running_app } = locreq("test_utils/with-test-app.js"); describe("finalize registration", () => { - let app = null; - const port = 8888; - const base_url = `http://localhost:${port}`; - let smtp_api = null; - before(() => (smtp_api = TestApp.ConfigManager.get("tests.smtp_api_url"))); - - beforeEach(async () => { - app = new Sealious.App( - { - "www-server": { port: 8888 }, - upload_path: "/dev/null", - logger: { level: "error" }, - core: { environment: "production" }, - smtp: TestApp.ConfigManager.get("smtp"), - datastore_mongo: TestApp.ConfigManager.get("datastore_mongo"), - }, - TestApp.manifest - ); - - await app.start(); - await axios.delete(`${smtp_api}/messages`); - }); - - it("allows to register an account (entire flow)", async () => { - const cookieJar = new tough.CookieJar(); - const options = { - jar: cookieJar, - withCredentials: true, - }; - - await axios.post( - `${base_url}/api/v1/collections/registration-intents`, - { email: "user@example.com" }, - options - ); - - const message = (await axios.get(`${smtp_api}/messages/1.html`)).data; - const token = message.match(/token=([^?&]+)/)[1]; - - await axios.post(`${base_url}/finalize-registration-intent`, { - email: "user@example.com", - token, - password: "password", - username: "user", - }); - - await axios.post( - `${base_url}/api/v1/sessions`, - { username: "user", password: "password" }, - options - ); - }); - - afterEach(async () => { - await Promise.all( - app.ChipManager.get_all_collections().map(collection_name => - app.Datastore.remove(collection_name, {}, "just_one" && false) - ) - ); - await app.stop(); - }); + it("allows to register an account (entire flow)", async () => + with_running_app(async ({ app, base_url, mail_api }) => { + const cookieJar = new tough.CookieJar(); + const options = { + jar: cookieJar, + withCredentials: true, + }; + + await axios.post( + `${base_url}/api/v1/collections/registration-intents`, + { email: "user@example.com" }, + options + ); + + const message = await mail_api.get_message_by_id(1); + const token = message.match(/token=([^?&]+)/)[1]; + + await axios.post(`${base_url}/finalize-registration-intent`, { + email: "user@example.com", + token, + password: "password", + username: "user", + }); + + await axios.post( + `${base_url}/api/v1/sessions`, + { username: "user", password: "password" }, + options + ); + })); }); diff --git a/setup-test.js b/setup-test.js --- a/setup-test.js +++ b/setup-test.js @@ -5,47 +5,4 @@ const axiosCookieJarSupport = require("@3846masa/axios-cookiejar-support"); axiosCookieJarSupport(axios); -before(async () => { - global.TestApp = new Sealious.App( - { - upload_path: "/tmp", - datastore_mongo: { host: "db", password: "sealious-test" }, - smtp: { - host: "mailcatcher", - port: 1025, - user: "any", - password: "any", - }, - email: { - from_name: "Sealious test app", - from_address: "sealious@example.com", - }, - core: { environment: "production" }, - app: { version: "0.0.0-test" }, - logger: { level: "emerg" }, - tests: { - //non-standard, just for testing - smtp_api_url: "http://mailcatcher:1080", - }, - }, - { - name: "testing app", - logo: locreq.resolve("lib/assets/logo.png"), - default_language: "pl", - version: "0.0.0-test", - base_url: "http://localhost:8080", - colors: { - primary: "#4d394b", - }, - } - ); - global.Sealious = Sealious; - return TestApp.start().catch(error => { - console.error(error); - process.exit(1); - }); -}); - -after(async () => { - await TestApp.stop(); -}); +global.Sealious = Sealious; diff --git a/test_utils/with-test-app.js b/test_utils/with-test-app.js new file mode 100644 --- /dev/null +++ b/test_utils/with-test-app.js @@ -0,0 +1,96 @@ +const locreq = require("locreq")(__dirname); +const axios = require("axios"); + +module.exports = { + with_stopped_app: with_test_app.bind(global, "auto_start" && false), + with_running_app: with_test_app.bind(global, "auto_start" && true), +}; + +async function with_test_app(auto_start, fn) { + let app = null; + const port = 8888; + const base_url = `http://localhost:${port}`; + const smtp_api_url = "http://mailcatcher:1080"; + + app = new Sealious.App( + { + upload_path: "/tmp", + datastore_mongo: { host: "db", password: "sealious-test" }, + smtp: { + host: "mailcatcher", + port: 1025, + user: "any", + password: "any", + }, + email: { + from_name: "Sealious test app", + from_address: "sealious@example.com", + }, + core: { environment: "production" }, + app: { version: "0.0.0-test" }, + logger: { level: "emerg" }, + "www-server": { + port, + }, + }, + { + name: "testing app", + logo: locreq.resolve("lib/assets/logo.png"), + default_language: "pl", + version: "0.0.0-test", + base_url, + colors: { + primary: "#4d394b", + }, + admin_email: "admin@example.com", + } + ); + + let clear_database_on_stop = true; + + app.on("stop", async () => { + if (clear_database_on_stop) { + return Promise.all( + app.ChipManager.get_all_collections().map(collection_name => + app.Datastore.remove(collection_name, {}, "just_one" && false) + ) + ); + } + }); + + if (auto_start) { + await app.start(); + } + + try { + await axios.delete(`${smtp_api_url}/messages`); + + await fn({ + app, + base_url, + smtp_api_url, + mail_api: { + get_messages: async () => + (await axios.get(`${smtp_api_url}/messages`)).data, + get_message_by_id: async id => + (await axios.get(`${smtp_api_url}/messages/${id}.html`)).data, + }, + dont_clear_database_on_stop: () => (clear_database_on_stop = false), + rest_api: { + get: async url => (await axios.get(`${base_url}${url}`)).data, + delete: async url => (await axios.delete(`${base_url}${url}`)).data, + patch: async (url, data) => + (await axios.patch(`${base_url}${url}`, data)).data, + }, + }); + + if (app.status !== "stopped") { + await app.stop(); + } + } catch (e) { + if (app.status !== "stopped") { + await app.stop(); + } + throw e; + } +}