diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore
new file mode 100644
index 000000000..26d33521a
--- /dev/null
+++ b/android/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/android/.idea/AndroidProjectSystem.xml b/android/.idea/AndroidProjectSystem.xml
new file mode 100644
index 000000000..4a53bee8c
--- /dev/null
+++ b/android/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/appInsightsSettings.xml b/android/.idea/appInsightsSettings.xml
new file mode 100644
index 000000000..0d48ca536
--- /dev/null
+++ b/android/.idea/appInsightsSettings.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml
new file mode 100644
index 000000000..b86273d94
--- /dev/null
+++ b/android/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml
new file mode 100644
index 000000000..ca16a995c
--- /dev/null
+++ b/android/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/deviceManager.xml b/android/.idea/deviceManager.xml
new file mode 100644
index 000000000..91f95584d
--- /dev/null
+++ b/android/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/migrations.xml b/android/.idea/migrations.xml
new file mode 100644
index 000000000..f8051a6f9
--- /dev/null
+++ b/android/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml
new file mode 100644
index 000000000..b2c751a35
--- /dev/null
+++ b/android/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml
new file mode 100644
index 000000000..16660f1d8
--- /dev/null
+++ b/android/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml
index d5fccc538..a9452d723 100644
--- a/android/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -1,170 +1,9 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 036d09bc5..76d05a5fa 100644
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,5 +1,6 @@
-
+
-
\ No newline at end of file
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 036d09bc5..76d05a5fa 100644
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,5 +1,6 @@
-
+
-
\ No newline at end of file
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index c023e5059..8e6f215c7 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
index 2127973b2..f4dca6ffe 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
new file mode 100644
index 000000000..432096701
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
index b441f37d6..c552742b8 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 72905b854..28535304d 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
index 8ed0605c2..89032dc21 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
new file mode 100644
index 000000000..61dd9c061
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
index 9502e47a2..60b723b5f 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 4d1e07710..986424b00 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
index df0f15880..69c0471c0 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
new file mode 100644
index 000000000..a34b7eec0
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
index 853db043d..bac183684 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 6cdf97c11..cd7f3e6b5 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
index 2960cbb61..4b17e3950 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 000000000..944dfc63b
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
index 8e3093a86..2f04961b8 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 46de6e255..54525245f 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
index d2ea9abed..cd72e0a0a 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 000000000..849034dd8
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
index a40d73e9c..b90f78be9 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml
deleted file mode 100644
index c5d5899fd..000000000
--- a/android/app/src/main/res/values/ic_launcher_background.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- #FFFFFF
-
\ No newline at end of file
diff --git a/android/build.gradle b/android/build.gradle
index 2bf797b65..6b9f30a03 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
- classpath 'com.android.tools.build:gradle:9.1.1'
+ classpath 'com.android.tools.build:gradle:9.4.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 5f38436fc..e2b7b1cc1 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/api/package.json b/api/package.json
index 7d5cd8493..92c0fa012 100644
--- a/api/package.json
+++ b/api/package.json
@@ -5,9 +5,9 @@
"description": "Facilitate ETL operations to bring non-TAK sources into a TAK Server",
"main": "index.js",
"scripts": {
- "test": "StackName='test' tsx --test --test-concurrency=1 test/*.test.ts",
+ "test": "StackName='test' tsx --test --test-concurrency=8 --test-force-exit --test-timeout=60000 test/*.test.ts",
"precoverage": "mkdir -p dist/test/fixtures/ && mkdir -p dist/test/data/ && cp test/fixtures/* dist/test/fixtures/ && cp test/data/* dist/test/data/ && npm run build",
- "coverage": "StackName='test' c8 --reporter=lcov --reporter html node --test --test-concurrency=1 dist/test/*.test.js",
+ "coverage": "StackName='test' c8 --reporter=lcov --reporter html node --test --test-concurrency=8 --test-force-exit --test-timeout=60000 dist/test/*.test.js",
"build": "tsc && (cp .env* dist/ || true) && cp ./icons dist/icons -r && cp ./fonts dist/fonts -r && cp package.json dist/ && cp migrations/ dist/ -r && mkdir -p dist/web/public && cp web/public/CloudTAKLogo.svg dist/web/public/ && cp web/public/CloudTAKLogoText.svg dist/web/public/",
"typegen": "openapi-typescript http://localhost:5001/api/swagger --output derived-types.d.ts",
"lint": "eslint index.ts common/ stateful/ stateless/ test/",
diff --git a/api/stateless/routes/marti-mission.ts b/api/stateless/routes/marti-mission.ts
index 798006e32..60948072d 100644
--- a/api/stateless/routes/marti-mission.ts
+++ b/api/stateless/routes/marti-mission.ts
@@ -331,7 +331,7 @@ export default async function router(schema: Schema, config: ConfigStateless) {
group: 'MartiMissions',
description: 'Helper API to list missions',
query: Type.Composite([
- MissionListInput,
+ Type.Omit(MissionListInput, ['sort']),
Type.Object({
sort: Type.String({
default: 'createTime',
diff --git a/api/test/connection-reconnect.srv.test.ts b/api/test/connection-reconnect.srv.test.ts
index 8698a3460..b884d181c 100644
--- a/api/test/connection-reconnect.srv.test.ts
+++ b/api/test/connection-reconnect.srv.test.ts
@@ -89,7 +89,7 @@ test('Connection - becomes live after TAK server sends a ping reply', async () =
try {
flight.tak.streamingWrite(pingReplyXml());
- await waitForConnectionStatus('live', 3000);
+ await waitForConnectionStatus('live', 10000);
} catch (err) {
assert.ifError(err);
}
@@ -99,7 +99,7 @@ test('Connection - drops to dead when the TAK streaming connection is reset (sim
try {
flight.tak.restartStreaming();
- await waitForConnectionStatus('dead', 3000);
+ await waitForConnectionStatus('dead', 10000);
} catch (err) {
assert.ifError(err);
}
@@ -109,11 +109,11 @@ test('Connection - reconnects and becomes live after TAK server comes back', asy
try {
// Without the race condition fix this times out - the stale socket's
// close event destroys the new socket before the TLS handshake finishes.
- await waitForStreamingReconnect(5000);
+ await waitForStreamingReconnect(10000);
flight.tak.streamingWrite(pingReplyXml());
- await waitForConnectionStatus('live', 3000);
+ await waitForConnectionStatus('live', 10000);
} catch (err) {
assert.ifError(err);
}
diff --git a/api/test/db.ts b/api/test/db.ts
new file mode 100644
index 000000000..26c828637
--- /dev/null
+++ b/api/test/db.ts
@@ -0,0 +1,133 @@
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+import postgres from 'postgres';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import { migrate } from 'drizzle-orm/postgres-js/migrator';
+import drop from './drop.js';
+
+// Arbitrary advisory lock key shared by every test worker on the cluster
+const LOCK_ID = 727282;
+
+const migrations = new URL('../migrations/', import.meta.url);
+
+let worker: { name: string; connstr: string } | undefined;
+
+export function baseConnstr(): string {
+ return process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test';
+}
+
+function withDatabase(connstr: string, database: string): string {
+ const url = new URL(connstr);
+ url.pathname = `/${database}`;
+ return url.toString();
+}
+
+/**
+ * Templates are keyed by the migration set they were built from so a
+ * branch switch that changes migrations transparently rebuilds them
+ */
+function migrationsHash(): string {
+ const hash = crypto.createHash('sha1');
+ for (const file of fs.readdirSync(migrations).sort()) {
+ if (!file.endsWith('.sql')) continue;
+ hash.update(file);
+ hash.update(fs.readFileSync(new URL(file, migrations)));
+ }
+ return hash.digest('hex').slice(0, 8);
+}
+
+function alive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (err) {
+ return (err as NodeJS.ErrnoException).code === 'EPERM';
+ }
+}
+
+/**
+ * Return a connection string for a database private to this process
+ *
+ * Test files run in parallel processes, so each one gets its own copy of a
+ * fully migrated template database - cloning a template is far cheaper than
+ * running the migrations and lets files share a single Postgres cluster
+ * without interfering with each other
+ *
+ * @param [opts.reset=true] Truncate all tables if the database already exists
+ */
+export async function testDatabase(opts: { reset?: boolean } = {}): Promise {
+ if (worker) {
+ if (opts.reset !== false) await drop(worker.connstr);
+ return worker.connstr;
+ }
+
+ const base = baseConnstr();
+ const baseName = new URL(base).pathname.slice(1);
+ const template = `${baseName}_tpl_${migrationsHash()}`;
+ const name = `${baseName}_w${process.pid}`;
+
+ // A single session holds the advisory lock for the duration of the
+ // provisioning and releases it implicitly when it ends
+ const client = postgres(base, { max: 1, onnotice: () => {} });
+
+ try {
+ await client`SELECT pg_advisory_lock(${LOCK_ID})`;
+
+ const exists = await client`SELECT 1 FROM pg_database WHERE datname = ${template}`;
+
+ if (!exists.length) {
+ const stale = await client`SELECT datname FROM pg_database WHERE datname LIKE ${baseName + '_tpl_%'}`;
+ for (const row of stale) {
+ await client.unsafe(`DROP DATABASE "${row.datname}" WITH (FORCE)`);
+ }
+
+ await client.unsafe(`CREATE DATABASE "${template}"`);
+
+ // CREATE DATABASE ... TEMPLATE fails while anything is connected
+ // to the template, so the migration session must be fully closed
+ const sql = postgres(withDatabase(base, template), { max: 1, onnotice: () => {} });
+ try {
+ await migrate(drizzle(sql), { migrationsFolder: migrations.pathname });
+ } finally {
+ await sql.end();
+ }
+ }
+
+ const orphans = await client`SELECT datname FROM pg_database WHERE datname LIKE ${baseName + '_w%'}`;
+ for (const row of orphans) {
+ const pid = Number(row.datname.slice(baseName.length + 2));
+ if (Number.isInteger(pid) && pid !== process.pid && alive(pid)) continue;
+ await client.unsafe(`DROP DATABASE "${row.datname}" WITH (FORCE)`);
+ }
+
+ await client.unsafe(`CREATE DATABASE "${name}" TEMPLATE "${template}"`);
+ } finally {
+ await client.end();
+ }
+
+ // Every parallel worker shares one Postgres cluster, so keep each pool
+ // small enough that a full run stays under the default max_connections
+ const connstr = new URL(withDatabase(base, name));
+ connstr.searchParams.set('max', '2');
+
+ worker = { name, connstr: connstr.toString() };
+
+ return worker.connstr;
+}
+
+/**
+ * Drop this process's database - all remaining connections are terminated
+ */
+export async function dropTestDatabase(): Promise {
+ if (!worker) return;
+
+ const client = postgres(baseConnstr(), { max: 1, onnotice: () => {} });
+
+ try {
+ await client.unsafe(`DROP DATABASE IF EXISTS "${worker.name}" WITH (FORCE)`);
+ } finally {
+ await client.end();
+ }
+
+ worker = undefined;
+}
diff --git a/api/test/flight.ts b/api/test/flight.ts
index 19d8513b9..758e30f7d 100644
--- a/api/test/flight.ts
+++ b/api/test/flight.ts
@@ -11,7 +11,8 @@ import fs from 'fs';
import api from '../index.js';
import ConfigStateful from '../stateful/config.js';
import ConfigStateless from '../stateless/config.js';
-import drop from './drop.js';
+import { testDatabase, dropTestDatabase } from './db.js';
+import withFileLock from './lock.js';
import { pathToRegexp } from 'path-to-regexp';
import test from 'node:test';
import assert from 'node:assert';
@@ -19,8 +20,6 @@ import UserControl from '../stateless/lib/control/user.js';
import { Ajv } from 'ajv';
import type { FormatsPlugin } from 'ajv-formats';
import * as ajvFormats from 'ajv-formats';
-import * as pgtypes from '../common/schema.js';
-import { Pool } from '@openaddresses/batch-generic';
const ajv = (ajvFormats.default as unknown as FormatsPlugin)(new Ajv({ allErrors: true }));
/**
@@ -51,6 +50,7 @@ export default class Flight {
stateful?: ConfigStateful;
srv?: any; // TODO: HTTP Server
base: string;
+ connstr?: string;
schema?: object;
routes: Record;
token: Record;
@@ -63,10 +63,10 @@ export default class Flight {
}
/**
- * Clear and restore an empty database schema
+ * Provision an empty, fully migrated database for this process
*
* @param {Object} [opts] Options
- * @param {boolean} [opts.dropdb=true] Should the database be dropped
+ * @param {boolean} [opts.dropdb=true] Should an existing database be truncated
* @param {boolean} [opts.takserver=false] Should the MockTAKServer be started
*/
init(opts: { dropdb?: boolean; takserver?: boolean } = {}) {
@@ -75,16 +75,7 @@ export default class Flight {
test('start: database', async () => {
try {
- if (dropdb) {
- const connstr = process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test';
- await drop(connstr);
-
- const pool = await Pool.connect(connstr, pgtypes, {
- migrationsFolder: (new URL('../migrations', import.meta.url)).pathname,
- });
-
- await pool.end();
- }
+ this.connstr = await testDatabase({ reset: dropdb });
} catch (err) {
assert.ifError(err);
}
@@ -251,8 +242,10 @@ export default class Flight {
*/
takeoff(custom = {}) {
test('test server takeoff', async () => {
+ if (!this.connstr) throw new Error('flight.init() must be called before flight.takeoff()');
+
const envArgs = {
- postgres: process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test',
+ postgres: this.connstr,
silent: true,
noevents: true,
nosinks: true,
@@ -310,32 +303,36 @@ export default class Flight {
const key = `/tmp/cloudtak-test-${name}.key`;
const cert = `/tmp/cloudtak-test-${name}.cert`;
- if (
- !fs.existsSync(key)
+ const stale = () => !fs.existsSync(key)
|| !fs.existsSync(cert)
- || fs.statSync(cert).mtimeMs < fs.statSync(this.tak.keys.cert).mtimeMs
- ) {
- CP.execSync(`
- openssl req \
- -newkey rsa:4096 \
- -keyout ${key} \
- -out /tmp/cloudtak-test-${name}.csr \
- -nodes \
- -subj "${subject}" \
- 2> /dev/null
- `);
-
- CP.execSync(`
- openssl x509 \
- -req \
- -in /tmp/cloudtak-test-${name}.csr \
- -CA ${this.tak.keys.cert} \
- -CAkey ${this.tak.keys.key} \
- -out ${cert} \
- -set_serial 01 \
- -days 365 \
- 2> /dev/null
- `);
+ || fs.statSync(cert).mtimeMs < fs.statSync(this.tak.keys.cert).mtimeMs;
+
+ if (stale()) {
+ withFileLock('certs', () => {
+ if (!stale()) return;
+
+ CP.execSync(`
+ openssl req \
+ -newkey rsa:4096 \
+ -keyout ${key} \
+ -out /tmp/cloudtak-test-${name}.csr \
+ -nodes \
+ -subj "${subject}" \
+ 2> /dev/null
+ `);
+
+ CP.execSync(`
+ openssl x509 \
+ -req \
+ -in /tmp/cloudtak-test-${name}.csr \
+ -CA ${this.tak.keys.cert} \
+ -CAkey ${this.tak.keys.key} \
+ -out ${cert} \
+ -set_serial 01 \
+ -days 365 \
+ 2> /dev/null
+ `);
+ });
}
return { key, cert };
@@ -463,15 +460,20 @@ export default class Flight {
*/
landing() {
test('test server landing - api', async () => {
- if (this._tak) {
- assert.equal(
- this._tak.unhandledMartiRequests.length, 0,
- `Unhandled Marti requests detected: ${this._tak.unhandledMartiRequests.join(', ')}`,
- );
+ try {
+ if (this._tak) {
+ assert.equal(
+ this._tak.unhandledMartiRequests.length, 0,
+ `Unhandled Marti requests detected: ${this._tak.unhandledMartiRequests.join(', ')}`,
+ );
+ }
+ } finally {
+ // Always release the servers so a failed assertion can't leave
+ // open handles that keep the process alive forever
+ if (this.srv) await this.srv.close();
+ if (this._tak) await this._tak.close();
+ await dropTestDatabase();
}
-
- if (this.srv) await this.srv.close();
- if (this._tak) await this._tak.close();
});
}
}
diff --git a/api/test/hub-split.srv.test.ts b/api/test/hub-split.srv.test.ts
index 1f34d3579..8a12c4ea6 100644
--- a/api/test/hub-split.srv.test.ts
+++ b/api/test/hub-split.srv.test.ts
@@ -6,11 +6,10 @@ import ConfigStateful from '../stateful/config.js';
import ConfigStateless from '../stateless/config.js';
import server from '../index.js';
import type ServerManager from '../common/server.js';
+import { testDatabase } from './db.js';
process.env.HUB_RPC_PORT = '0';
-const POSTGRES = process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test';
-
const flight = new Flight();
flight.init({ takserver: true });
@@ -23,7 +22,7 @@ let apiBase = '';
test('Split: boot a hub-mode server', async () => {
const config = await ConfigStateful.env({
- postgres: POSTGRES,
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
@@ -42,7 +41,7 @@ test('Split: boot an api-mode server pointed at the hub', async () => {
if (!rpcAddress || typeof rpcAddress !== 'object') throw new Error('Could not determine Hub RPC port');
const config = await ConfigStateless.env({
- postgres: POSTGRES,
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
@@ -66,7 +65,7 @@ test('Split: boot an api-mode server pointed at the hub', async () => {
test('Split: api mode requires a hub URL', async () => {
await assert.rejects(ConfigStateless.env({
- postgres: POSTGRES,
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
diff --git a/api/test/lock.ts b/api/test/lock.ts
new file mode 100644
index 000000000..209d6bb10
--- /dev/null
+++ b/api/test/lock.ts
@@ -0,0 +1,45 @@
+import fs from 'node:fs';
+
+const sleeper = new Int32Array(new SharedArrayBuffer(4));
+
+/**
+ * Run fn while holding an exclusive cross-process lock
+ *
+ * Parallel test workers share the generated certificates under /tmp, so
+ * their creation must be serialized. mkdir is atomic on every platform,
+ * which makes a directory a dependable mutex for synchronous code
+ */
+export default function withFileLock(name: string, fn: () => T): T {
+ const dir = `/tmp/cloudtak-test-${name}.lock`;
+ const deadline = Date.now() + 120_000;
+
+ for (;;) {
+ try {
+ fs.mkdirSync(dir);
+ break;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
+
+ try {
+ // Left behind by a process that died while holding the lock
+ if (Date.now() - fs.statSync(dir).mtimeMs > 60_000) fs.rmdirSync(dir);
+ } catch {
+ // Another waiter removed it first
+ }
+
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${dir}`, { cause: err });
+
+ Atomics.wait(sleeper, 0, 0, 50);
+ }
+ }
+
+ try {
+ return fn();
+ } finally {
+ try {
+ fs.rmdirSync(dir);
+ } catch {
+ // Reclaimed as stale by another waiter
+ }
+ }
+}
diff --git a/api/test/marti-mission.srv.test.ts b/api/test/marti-mission.srv.test.ts
index 960d76a46..ce8e482ef 100644
--- a/api/test/marti-mission.srv.test.ts
+++ b/api/test/marti-mission.srv.test.ts
@@ -81,9 +81,9 @@ test('GET: api/marti/mission - Sorted List (Oldest => Newest)', async () => {
assert.equal(res.body.items[2].name, 'Mission C');
} catch (err) {
assert.ifError(err);
+ } finally {
+ flight.tak.reset();
}
-
- flight.tak.reset();
});
test('GET: api/marti/mission - Sorted List (Newest => Oldest)', async () => {
@@ -157,9 +157,9 @@ test('GET: api/marti/mission - Sorted List (Newest => Oldest)', async () => {
assert.equal(res.body.items[2].name, 'Mission A');
} catch (err) {
assert.ifError(err);
+ } finally {
+ flight.tak.reset();
}
-
- flight.tak.reset();
});
test('GET: api/marti/mission - Filter Groups', async () => {
@@ -244,9 +244,9 @@ test('GET: api/marti/mission - Filter Groups', async () => {
assert.equal(res2.body.items[2].name, 'Mission C');
} catch (err) {
assert.ifError(err);
+ } finally {
+ flight.tak.reset();
}
-
- flight.tak.reset();
});
test('PATCH: api/marti/missions/:guid - returns refreshed groups after update', async () => {
@@ -344,9 +344,9 @@ test('PATCH: api/marti/missions/:guid - returns refreshed groups after update',
assert.deepEqual(res.body.groups, ['updated-group']);
} catch (err) {
assert.ifError(err);
+ } finally {
+ flight.tak.reset();
}
-
- flight.tak.reset();
});
flight.landing();
diff --git a/api/test/search.test.ts b/api/test/search.test.ts
index 7328888ce..298cb3689 100644
--- a/api/test/search.test.ts
+++ b/api/test/search.test.ts
@@ -3,6 +3,7 @@ import assert from 'node:assert';
import AGOL from '../stateless/lib/search/agol.js';
import ArcGISTokenManager from '../stateless/lib/search/arcgis-token-manager.js';
import Config from '../common/config.js';
+import { testDatabase, dropTestDatabase } from './db.js';
test('AGOL - constructor with tokenManager', async () => {
const mockConfig = {
@@ -13,7 +14,7 @@ test('AGOL - constructor with tokenManager', async () => {
const tokenManager = new ArcGISTokenManager(mockConfig);
const config = await Config.env({
- postgres: process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test',
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
@@ -32,7 +33,7 @@ test('AGOL - constructor with tokenManager', async () => {
test('AGOL - constructor without tokenManager', async () => {
const config = await Config.env({
- postgres: process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test',
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
@@ -51,7 +52,7 @@ test('AGOL - constructor without tokenManager', async () => {
test('AGOL - API URLs are correctly set', async () => {
const config = await Config.env({
- postgres: process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test',
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
@@ -69,7 +70,7 @@ test('AGOL - API URLs are correctly set', async () => {
test('AGOL - route method handles empty features', async () => {
const config = await Config.env({
- postgres: process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test',
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
@@ -104,7 +105,7 @@ test('AGOL - route method handles empty features', async () => {
test('AGOL - route method processes valid route data', async () => {
const config = await Config.env({
- postgres: process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test',
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
@@ -145,7 +146,7 @@ test('AGOL - route method processes valid route data', async () => {
test('AGOL - error handling for different error codes', async () => {
const config = await Config.env({
- postgres: process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test',
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
@@ -174,7 +175,7 @@ test('AGOL - error handling for different error codes', async () => {
test('AGOL - validates route input parameters', async () => {
const config = await Config.env({
- postgres: process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test',
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
@@ -205,7 +206,7 @@ test('AGOL - validates route input parameters', async () => {
test('AGOL - URL construction for different endpoints', async () => {
const config = await Config.env({
- postgres: process.env.POSTGRES || 'postgres://postgres@localhost:5432/tak_ps_etl_test',
+ postgres: await testDatabase({ reset: false }),
silent: true,
noevents: true,
nosinks: true,
@@ -227,3 +228,7 @@ test('AGOL - URL construction for different endpoints', async () => {
config.pg.end();
});
+
+test('cleanup', async () => {
+ await dropTestDatabase();
+});
diff --git a/api/test/tak-server.ts b/api/test/tak-server.ts
index c4336cef1..5f48b22cf 100644
--- a/api/test/tak-server.ts
+++ b/api/test/tak-server.ts
@@ -9,6 +9,7 @@ import stream2buffer from '../stateless/lib/stream.js';
import crypto from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
import fs from 'node:fs';
+import withFileLock from './lock.js';
/**
* Mocking Framework for CloudTAK <=> TAK Server API Interactions
@@ -92,20 +93,29 @@ export default class MockTAKServer {
// Generating the CA is comparatively expensive so reuse an existing one,
// regenerating weekly to stay well clear of certificate expiry
const maxCAAgeMs = 7 * 24 * 60 * 60 * 1000;
- if (
- !fs.existsSync(this.keys.cert)
+ const stale = () => !fs.existsSync(this.keys.cert)
|| !fs.existsSync(this.keys.key)
- || (Date.now() - fs.statSync(this.keys.cert).mtimeMs) > maxCAAgeMs
- ) {
- CP.execSync(`openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 365 -subj '/CN=localhost' -keyout ${this.keys.key} -out ${this.keys.cert} 2> /dev/null`);
- }
+ || (Date.now() - fs.statSync(this.keys.cert).mtimeMs) > maxCAAgeMs;
+
+ // Parallel test workers share the CA - regenerate and read it under
+ // a lock so every worker ends up trusting the same certificate
+ const ca = withFileLock('certs', () => {
+ if (stale()) {
+ CP.execSync(`openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 365 -subj '/CN=localhost' -keyout ${this.keys.key} -out ${this.keys.cert} 2> /dev/null`);
+ }
+
+ return {
+ cert: fs.readFileSync(this.keys.cert),
+ key: fs.readFileSync(this.keys.key),
+ };
+ });
this.streaming = tls.createServer({
- cert: fs.readFileSync(this.keys.cert),
- key: fs.readFileSync(this.keys.key),
+ cert: ca.cert,
+ key: ca.key,
requestCert: true,
rejectUnauthorized: true,
- ca: fs.readFileSync(this.keys.cert),
+ ca: ca.cert,
}, (socket) => {
this.sockets.add(socket);
this.streamingSockets.add(socket);
diff --git a/api/web/src/App.vue b/api/web/src/App.vue
index 6bf22ef57..b1e1f0e80 100644
--- a/api/web/src/App.vue
+++ b/api/web/src/App.vue
@@ -104,7 +104,7 @@ import ChannelChangeModal from './components/CloudTAK/Menu/ChannelChangeModal.vu
import NotificationToast from './components/CloudTAK/util/NotificationToast.vue';
import TAKNotification_ from './base/notification.ts';
const TAKNotification = TAKNotification_;
-import { supportsServiceWorker } from './utils/capacitor.ts';
+import { supportsServiceWorker, addBackButtonListener, minimizeApp } from './utils/capacitor.ts';
import { useObservable } from '@vueuse/rxjs';
import { from } from 'rxjs';
import { applyServiceWorkerUpdate } from './utils/service-worker.ts';
@@ -121,6 +121,7 @@ const mapStore = useMapStore();
const deviceStore = useDeviceStore();
let removeNotificationAction: (() => void) | undefined;
+let removeBackButton: (() => void) | undefined;
const toastNotifications = useObservable(
from(liveQuery(async () => {
@@ -222,6 +223,15 @@ onMounted(async () => {
window.addEventListener('sw:update-available', onSwUpdateAvailable);
}
+ removeBackButton = await addBackButtonListener(() => {
+ const back = router.options.history.state.back;
+ if (route.name === 'login' || !back || String(back).startsWith('/login')) {
+ void minimizeApp();
+ } else {
+ router.back();
+ }
+ });
+
// Deep link when the user taps a push notification (path from its payload)
removeNotificationAction = deviceStore.onNotificationAction((data) => {
if (data && typeof data.url === 'string' && data.url.startsWith('/')) {
@@ -247,6 +257,7 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('sw:update-available', onSwUpdateAvailable);
if (removeNotificationAction) removeNotificationAction();
+ if (removeBackButton) removeBackButton();
if (sessionExpiryTimer !== undefined) {
clearInterval(sessionExpiryTimer);
diff --git a/api/web/src/components/Login.vue b/api/web/src/components/Login.vue
index 69fa1d00d..7ad1dc722 100644
--- a/api/web/src/components/Login.vue
+++ b/api/web/src/components/Login.vue
@@ -723,14 +723,14 @@ function navigateAfterLogin() {
})();
if (resolved.matched.length > 0) {
- router.push(redirectPath);
+ router.replace(redirectPath);
} else if (isSafeRedirect) {
window.location.href = redirectPath;
} else {
- router.push("/");
+ router.replace("/");
}
} else {
- router.push("/");
+ router.replace("/");
}
}
diff --git a/api/web/src/stores/app.ts b/api/web/src/stores/app.ts
index b7eadb899..fdf3f83a9 100644
--- a/api/web/src/stores/app.ts
+++ b/api/web/src/stores/app.ts
@@ -154,7 +154,7 @@ export const useAppStore = defineStore('cloudtak-app', {
async routeLogin(): Promise {
const redirect = encodeURIComponent(window.location.pathname);
if (router.hasRoute('login')) {
- await router.push(`/login?redirect=${redirect}`);
+ await router.replace(`/login?redirect=${redirect}`);
} else {
window.location.href = `/login?redirect=${redirect}`;
}
diff --git a/api/web/src/utils/capacitor.ts b/api/web/src/utils/capacitor.ts
index 8b88c56de..25591f582 100644
--- a/api/web/src/utils/capacitor.ts
+++ b/api/web/src/utils/capacitor.ts
@@ -99,6 +99,23 @@ export async function whenForegrounded(): Promise {
});
}
+// Registering any listener disables Capacitor's default raw WebView goBack()
+export async function addBackButtonListener(
+ handler: () => void
+): Promise<() => void> {
+ if (!isNativePlatform()) {
+ return () => { /* no-op */ };
+ }
+
+ const listener = await App.addListener('backButton', handler);
+ return () => { void listener.remove(); };
+}
+
+export async function minimizeApp(): Promise {
+ if (!isNativePlatform()) return;
+ await App.minimizeApp();
+}
+
export async function openExternalUrl(url: string | URL): Promise {
const { stdurl } = await import('../std.ts');
const href = stdurl(url).toString();