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();