diff --git a/src/routes.js b/src/routes.js index 3f6986f..0705518 100644 --- a/src/routes.js +++ b/src/routes.js @@ -295,6 +295,41 @@ export default async function routes( }, ) + fastify.post( + '/projects/:projectPublicId/remoteDetectionAlerts', + { + schema: { + params: Type.Object({ + projectPublicId: BASE32_STRING_32_BYTES, + }), + body: schemas.remoteDetectionAlertToAdd, + response: { + 201: Type.Literal(''), + 403: { $ref: 'HttpError' }, + 404: { $ref: 'HttpError' }, + }, + }, + async preHandler(req) { + verifyBearerAuth(req) + await ensureProjectExists(this, req) + }, + }, + /** + * @this {FastifyInstance} + */ + async function (req, reply) { + const { projectPublicId } = req.params + const project = await this.comapeo.getProject(projectPublicId) + + await project.remoteDetectionAlert.create({ + schemaName: 'remoteDetectionAlert', + ...req.body, + }) + + reply.status(201).send() + }, + ) + fastify.get( '/projects/:projectPublicId/attachments/:driveDiscoveryId/:type/:name', { diff --git a/src/schemas.js b/src/schemas.js index fa192bd..e503623 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -44,3 +44,25 @@ export const observationResult = Type.Object({ ]), ), }) + +export const remoteDetectionAlertToAdd = Type.Object({ + detectionDateStart: dateTimeString, + detectionDateEnd: dateTimeString, + sourceId: Type.String({ minLength: 1 }), + metadata: Type.Record( + Type.String(), + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + Type.Array( + Type.Union([Type.Boolean(), Type.Number(), Type.String(), Type.Null()]), + ), + ]), + ), + geometry: Type.Object({ + type: Type.Literal('Point'), + coordinates: Type.Tuple([longitude, latitude]), + }), +}) diff --git a/test/add-alerts-endpoint.js b/test/add-alerts-endpoint.js new file mode 100644 index 0000000..8e79037 --- /dev/null +++ b/test/add-alerts-endpoint.js @@ -0,0 +1,241 @@ +import { MapeoManager } from '@comapeo/core' +import { valueOf } from '@comapeo/schema' +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' +import { generate } from '@mapeo/mock-data' +import { Value } from '@sinclair/typebox/value' + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { remoteDetectionAlertToAdd } from '../src/schemas.js' +import { + BEARER_TOKEN, + createTestServer, + getManagerOptions, + omit, + randomAddProjectBody, + randomProjectPublicId, + runWithRetries, +} from './test-helpers.js' + +/** @import { RemoteDetectionAlertValue } from '@comapeo/schema'*/ +/** @import { FastifyInstance } from 'fastify' */ + +test('returns a 403 if no auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'POST', + url: `/projects/${randomProjectPublicId()}/remoteDetectionAlerts`, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(generateAlert()), + }) + assert.equal(response.statusCode, 403) +}) + +test('returns a 403 if incorrect auth is provided', async (t) => { + const server = createTestServer(t) + + const projectPublicId = await addProject(server) + + const response = await server.inject({ + method: 'POST', + url: `/projects/${projectPublicId}/remoteDetectionAlerts`, + headers: { + Authorization: 'Bearer bad', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(generateAlert()), + }) + assert.equal(response.statusCode, 403) +}) + +test('returns a 403 if trying to add alerts to a non-existent project', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'POST', + url: `/projects/${randomProjectPublicId()}/remoteDetectionAlerts`, + headers: { + Authorization: 'Bearer bad', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(generateAlert()), + }) + assert.equal(response.statusCode, 403) +}) + +test('returns a 400 if trying to add invalid alerts', async (t) => { + const server = createTestServer(t) + + const projectPublicId = await addProject(server) + + const alertKeys = /** @type {const} */ ([ + 'detectionDateStart', + 'detectionDateEnd', + 'sourceId', + 'metadata', + 'geometry', + ]) + + const badAlerts = [ + {}, + { + ...generateAlert(), + detectionDateStart: 'not a date', + }, + { + ...generateAlert(), + detectionDateEnd: 'not a date', + }, + { + ...generateAlert(), + geometry: { + type: 'Point', + coordinates: [-181.01, 0], + }, + }, + { + ...generateAlert(), + geometry: { + type: 'Point', + coordinates: [181.01, 0], + }, + }, + { + ...generateAlert(), + geometry: { + type: 'Point', + coordinates: [0, -90.01], + }, + }, + { + ...generateAlert(), + geometry: { + type: 'Point', + coordinates: [0, 90.01], + }, + }, + { + ...generateAlert(), + geometry: { + type: 'MultiPoint', + coordinates: [ + [1, 2], + [3, 4], + ], + }, + }, + ...alertKeys.flatMap((keyToMessUp) => [ + omit(generateAlert(), keyToMessUp), + { ...generateAlert(), [keyToMessUp]: null }, + ]), + ] + + await Promise.all( + badAlerts.map(async (badAlert) => { + const body = JSON.stringify(badAlert) + assert( + !Value.Check(remoteDetectionAlertToAdd, body), + `test setup: ${body} should be invalid`, + ) + + const response = await server.inject({ + method: 'POST', + url: `/projects/${projectPublicId}/remoteDetectionAlerts`, + headers: { + Authorization: 'Bearer ' + BEARER_TOKEN, + 'Content-Type': 'application/json', + }, + body, + }) + assert.equal( + response.statusCode, + 400, + `${body} should be invalid and return a 400`, + ) + }), + ) +}) + +test('adding alerts', async (t) => { + const server = createTestServer(t) + const serverAddressPromise = server.listen() + + const manager = new MapeoManager(getManagerOptions()) + const projectId = await manager.createProject({ name: 'CoMapeo project' }) + const project = await manager.getProject(projectId) + t.after(() => project.close()) + + const serverAddress = await serverAddressPromise + const serverUrl = new URL(serverAddress) + await project.$member.addServerPeer(serverAddress, { + dangerouslyAllowInsecureConnections: true, + }) + + const alert = generateAlert() + + const response = await server.inject({ + authority: serverUrl.host, + method: 'POST', + url: `/projects/${projectId}/remoteDetectionAlerts`, + headers: { + Authorization: 'Bearer ' + BEARER_TOKEN, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(alert), + }) + assert.equal(response.statusCode, 201) + assert.equal(response.body, '') + + project.$sync.start() + project.$sync.connectServers() + + await project.$sync.waitForSync('full') + + // It's possible that the client thinks it's synced but doesn't know about + // the server's alert yet, so we try a few times. + await runWithRetries(3, async () => { + const alerts = await project.remoteDetectionAlert.getMany() + const hasOurAlert = alerts.some((a) => a.sourceId === alert.sourceId) + assert(hasOurAlert, 'alert was added and synced') + }) +}) + +/** + * @param {FastifyInstance} server + * @returns {Promise} a promise that resolves with the project's public ID + */ +async function addProject(server) { + const body = randomAddProjectBody() + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body, + }) + assert.equal(response.statusCode, 200, 'test setup: adding a project') + + const { projectKey } = body + return projectKeyToPublicId(Buffer.from(projectKey, 'hex')) +} + +/** + * @param {number} min + * @param {number} max + * @returns {number} + */ +const randomNumber = (min, max) => min + Math.random() * (max - min) +const randomLatitude = randomNumber.bind(null, -90, 90) +const randomLongitude = randomNumber.bind(null, -180, 180) + +function generateAlert() { + const remoteDetectionAlertDoc = generate('remoteDetectionAlert')[0] + assert(remoteDetectionAlertDoc) + return valueOf({ + ...remoteDetectionAlertDoc, + geometry: { + type: 'Point', + coordinates: [randomLongitude(), randomLatitude()], + }, + }) +} diff --git a/test/add-project-endpoint.js b/test/add-project-endpoint.js index cd8375c..beb574f 100644 --- a/test/add-project-endpoint.js +++ b/test/add-project-endpoint.js @@ -5,6 +5,7 @@ import test from 'node:test' import { createTestServer, + omit, randomAddProjectBody, randomHex, } from './test-helpers.js' @@ -210,16 +211,3 @@ test('adding the same project twice is idempotent', async (t) => { }) assert.equal(secondResponse.statusCode, 200) }) - -/** - * @template {object} T - * @template {keyof T} K - * @param {T} obj - * @param {K} key - * @returns {Omit} - */ -function omit(obj, key) { - const result = { ...obj } - delete result[key] - return result -} diff --git a/test/observations-endpoint.js b/test/observations-endpoint.js index c202d54..350eacf 100644 --- a/test/observations-endpoint.js +++ b/test/observations-endpoint.js @@ -7,13 +7,14 @@ import { map } from 'iterpal' import assert from 'node:assert/strict' import * as fs from 'node:fs/promises' import test from 'node:test' -import { setTimeout as delay } from 'node:timers/promises' import { BEARER_TOKEN, createTestServer, getManagerOptions, randomAddProjectBody, + randomProjectPublicId, + runWithRetries, } from './test-helpers.js' /** @import { ObservationValue } from '@comapeo/schema'*/ @@ -167,12 +168,6 @@ test('returning observations with fetchable attachments', async (t) => { ) }) -function randomProjectPublicId() { - return projectKeyToPublicId( - Buffer.from(randomAddProjectBody().projectKey, 'hex'), - ) -} - function generateObservation() { const observationDoc = generate('observation')[0] assert(observationDoc) @@ -195,23 +190,6 @@ function blobToAttachment(blob) { } } -/** - * @template T - * @param {number} retries - * @param {() => Promise} fn - * @returns {Promise} - */ -async function runWithRetries(retries, fn) { - for (let i = 0; i < retries - 1; i++) { - try { - return await fn() - } catch { - await delay(500) - } - } - return fn() -} - /** * @param {object} options * @param {FastifyInstance} options.server diff --git a/test/test-helpers.js b/test/test-helpers.js index a39b128..871a9e4 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -1,8 +1,12 @@ -import { KeyManager } from '@mapeo/crypto' +import { + KeyManager, + keyToPublicId as projectKeyToPublicId, +} from '@mapeo/crypto' import createFastify from 'fastify' import RAM from 'random-access-memory' import { randomBytes } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' import comapeoServer from '../src/app.js' @@ -78,3 +82,35 @@ export const randomAddProjectBody = () => ({ blob: randomHex(), }, }) + +export const randomProjectPublicId = () => projectKeyToPublicId(randomBytes(32)) + +/** + * @template {object} T + * @template {keyof T} K + * @param {T} obj + * @param {K} key + * @returns {Omit} + */ +export function omit(obj, key) { + const result = { ...obj } + delete result[key] + return result +} + +/** + * @template T + * @param {number} retries + * @param {() => Promise} fn + * @returns {Promise} + */ +export async function runWithRetries(retries, fn) { + for (let i = 0; i < retries - 1; i++) { + try { + return await fn() + } catch { + await delay(500) + } + } + return fn() +}