@tak-ps/node-tak 12.23.1 → 12.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/api/credentials.d.ts +3 -1
  6. package/dist/lib/api/credentials.js +23 -10
  7. package/dist/lib/api/credentials.js.map +1 -1
  8. package/dist/lib/api/mission-layer.d.ts +2 -3
  9. package/dist/lib/api/mission-layer.js +5 -3
  10. package/dist/lib/api/mission-layer.js.map +1 -1
  11. package/dist/lib/api/mission.d.ts +436 -2
  12. package/dist/lib/api/mission.js +38 -11
  13. package/dist/lib/api/mission.js.map +1 -1
  14. package/dist/lib/api.js +5 -1
  15. package/dist/lib/api.js.map +1 -1
  16. package/dist/lib/utils/html-error.d.ts +32 -0
  17. package/dist/lib/utils/html-error.js +172 -0
  18. package/dist/lib/utils/html-error.js.map +1 -0
  19. package/dist/test/html-error.test.d.ts +1 -0
  20. package/dist/test/html-error.test.js +115 -0
  21. package/dist/test/html-error.test.js.map +1 -0
  22. package/dist/test/mission-layer.test.d.ts +1 -0
  23. package/dist/test/mission-layer.test.js +105 -0
  24. package/dist/test/mission-layer.test.js.map +1 -0
  25. package/dist/test/mission.test.d.ts +1 -0
  26. package/dist/test/mission.test.js +137 -0
  27. package/dist/test/mission.test.js.map +1 -0
  28. package/dist/tsconfig.tsbuildinfo +1 -1
  29. package/index.ts +1 -0
  30. package/lib/api/credentials.ts +23 -9
  31. package/lib/api/mission-layer.ts +7 -6
  32. package/lib/api/mission.ts +40 -11
  33. package/lib/api.ts +5 -1
  34. package/lib/utils/html-error.ts +208 -0
  35. package/package.json +3 -3
  36. package/test/html-error.test.ts +139 -0
  37. package/test/mission-layer.test.ts +137 -0
  38. package/test/mission.test.ts +173 -0
@@ -0,0 +1,208 @@
1
+ import Err from '@openaddresses/batch-error';
2
+ import xmljs from '@tak-ps/xml-js';
3
+ import type { Element } from '@tak-ps/xml-js';
4
+
5
+ /**
6
+ * TAK Server (Tomcat) returns HTML "Exception Report" pages on failure.
7
+ * Parse them into a human readable message + plain-text details
8
+ * so clients don't render raw HTML.
9
+ */
10
+ export interface HTMLErrorDetails {
11
+ /** <title> or <h1> */
12
+ title?: string;
13
+ /** <p><b>Label</b> value</p> pairs - ie: Type, Message, Description */
14
+ fields: Record<string, string>;
15
+ /** <pre> stack traces */
16
+ exception?: string;
17
+ /** Visible text fallback */
18
+ text: string;
19
+ }
20
+
21
+ const HIDDEN = new Set(['style', 'script', 'head']);
22
+
23
+ function collapse(input: string): string {
24
+ return input.replace(/\s+/g, ' ').trim();
25
+ }
26
+
27
+ /** Linear-time tag stripper for malformed HTML - drops the contents of HIDDEN elements */
28
+ function stripTags(html: string): string {
29
+ const lower = html.toLowerCase();
30
+ let out = '';
31
+ let i = 0;
32
+
33
+ while (i < html.length) {
34
+ const open = html.indexOf('<', i);
35
+ if (open === -1) {
36
+ out += html.slice(i);
37
+ break;
38
+ }
39
+
40
+ out += html.slice(i, open) + ' ';
41
+
42
+ const close = html.indexOf('>', open);
43
+ if (close === -1) break;
44
+
45
+ const tag = lower.slice(open + 1, close).trim().split(/\s/, 1)[0];
46
+ i = close + 1;
47
+
48
+ if (HIDDEN.has(tag)) {
49
+ const end = lower.indexOf(`</${tag}`, i);
50
+ if (end === -1) break;
51
+ i = html.indexOf('>', end);
52
+ if (i === -1) break;
53
+ i += 1;
54
+ }
55
+ }
56
+
57
+ return collapse(out);
58
+ }
59
+
60
+ function textOf(el: Element, opts: { skipPre?: boolean } = {}): string {
61
+ if (el.type === 'text') return String(el.text ?? '');
62
+ if (el.type !== 'element' || !el.elements) return '';
63
+ if (opts.skipPre && el.name === 'pre') return '';
64
+
65
+ return el.elements.map((child) => textOf(child, opts)).join('');
66
+ }
67
+
68
+ function* walk(el: Element, skipHidden = true): Generator<Element> {
69
+ if (el.type === 'element' && el.name && skipHidden && HIDDEN.has(el.name.toLowerCase())) return;
70
+
71
+ if (el.type === 'element') yield el;
72
+
73
+ for (const child of el.elements || []) yield* walk(child, skipHidden);
74
+ }
75
+
76
+ function find(root: Element, name: string): Element | undefined {
77
+ for (const el of walk(root, false)) {
78
+ if (el.name?.toLowerCase() === name) return el;
79
+ }
80
+ }
81
+
82
+ export function isHTML(body: unknown): body is string {
83
+ if (typeof body !== 'string') return false;
84
+
85
+ const head = body.slice(0, 512).trimStart().toLowerCase();
86
+
87
+ return head.startsWith('<!doctype html')
88
+ || head.startsWith('<html')
89
+ || /^<(head|body|title|h1)[\s>]/.test(head);
90
+ }
91
+
92
+ /** Returns null if body is not HTML */
93
+ export function parseHTMLError(body: unknown): HTMLErrorDetails | null {
94
+ if (!isHTML(body)) return null;
95
+
96
+ const details: HTMLErrorDetails = {
97
+ fields: {},
98
+ text: '',
99
+ };
100
+
101
+ let root: Element;
102
+ try {
103
+ root = xmljs.xml2js(body, { compact: false }) as Element;
104
+ } catch {
105
+ // Not well-formed XHTML - fall back to visible text
106
+ details.text = stripTags(body);
107
+
108
+ return details;
109
+ }
110
+
111
+ const title = find(root, 'title') || find(root, 'h1');
112
+ if (title && collapse(textOf(title))) details.title = collapse(textOf(title));
113
+
114
+ const pres: string[] = [];
115
+ let text = '';
116
+
117
+ for (const el of walk(root)) {
118
+ const name = el.name?.toLowerCase();
119
+
120
+ if (name === 'p' && el.elements?.[0]?.name?.toLowerCase() === 'b') {
121
+ const label = collapse(textOf(el.elements[0]));
122
+ const value = collapse(el.elements.slice(1).map((child) => textOf(child)).join(''));
123
+
124
+ if (label && value) details.fields[label] = value;
125
+ } else if (name === 'pre') {
126
+ const value = textOf(el)
127
+ .split('\n')
128
+ .map((line) => line.trimEnd())
129
+ .join('\n')
130
+ .trim();
131
+
132
+ if (value) pres.push(value);
133
+ } else if (name === 'body') {
134
+ text = collapse(textOf(el, { skipPre: true }));
135
+ }
136
+ }
137
+
138
+ if (pres.length) details.exception = pres.join('\n\n');
139
+
140
+ details.text = text || collapse(textOf(root, { skipPre: true }));
141
+
142
+ return details;
143
+ }
144
+
145
+ /** Most useful human readable line from the page */
146
+ export function summarizeHTMLError(details: HTMLErrorDetails, status?: number): string {
147
+ const message = details.fields.Message
148
+ || details.fields.message
149
+ || details.fields.Description
150
+ || details.fields.description;
151
+
152
+ if (message) return message;
153
+
154
+ if (details.exception) {
155
+ // "<ExceptionClass>: <message>"
156
+ const first = details.exception.split('\n')[0].trim();
157
+ const colon = first.indexOf(': ');
158
+ if (colon !== -1 && colon < first.length - 2) return first.slice(colon + 2).trim();
159
+ }
160
+
161
+ if (details.title) return details.title;
162
+
163
+ if (details.text) return details.text.length > 200 ? details.text.slice(0, 197) + '...' : details.text;
164
+
165
+ return status ? `Status Code: ${status}` : 'Unknown TAK Server Error';
166
+ }
167
+
168
+ /** Plain-text breakdown for an "Advanced" details pane */
169
+ export function formatHTMLError(details: HTMLErrorDetails): string {
170
+ const lines: string[] = [];
171
+
172
+ if (details.title) lines.push(details.title);
173
+
174
+ for (const [label, value] of Object.entries(details.fields)) {
175
+ if (value) lines.push(`${label}: ${value}`);
176
+ }
177
+
178
+ if (details.exception) {
179
+ if (lines.length) lines.push('');
180
+ lines.push(details.exception);
181
+ }
182
+
183
+ if (!lines.length && details.text) lines.push(details.text);
184
+
185
+ return lines.join('\n');
186
+ }
187
+
188
+ /**
189
+ * TAK Server HTML error page - `safe` is the summary, `details` the full breakdown
190
+ */
191
+ export class TAKServerError extends Err {
192
+ details: string;
193
+ html: string;
194
+ parsed: HTMLErrorDetails;
195
+
196
+ constructor(status: number, html: string, parsed?: HTMLErrorDetails) {
197
+ const details = parsed || parseHTMLError(html) || {
198
+ fields: {},
199
+ text: stripTags(html)
200
+ };
201
+
202
+ super(status, null, summarizeHTMLError(details, status));
203
+
204
+ this.parsed = details;
205
+ this.details = formatHTMLError(details);
206
+ this.html = html;
207
+ }
208
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tak-ps/node-tak",
3
3
  "type": "module",
4
- "version": "12.23.1",
4
+ "version": "12.25.0",
5
5
  "description": "Lightweight JavaScript library for communicating with TAK Server",
6
6
  "author": "Nick Ingalls <nick@ingalls.ca>",
7
7
  "main": "dist/index.js",
@@ -37,14 +37,14 @@
37
37
  "@tak-ps/xml-js": "^2.0.3"
38
38
  },
39
39
  "peerDependencies": {
40
- "@tak-ps/node-cot": "^v14.30.1"
40
+ "@tak-ps/node-cot": "^14.52.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@eslint/js": "^10.0.1",
44
44
  "@inquirer/prompts": "^8.0.1",
45
45
  "@types/node": "^26.0.0",
46
46
  "@types/pem": "^1.14.4",
47
- "c8": "^11.0.0",
47
+ "c8": "^12.0.0",
48
48
  "eslint": "^10.0.0",
49
49
  "tsx": "^4.19.4",
50
50
  "typedoc": "^0.28.1",
@@ -0,0 +1,139 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import Err from '@openaddresses/batch-error';
4
+ import {
5
+ TAKServerError,
6
+ isHTML,
7
+ parseHTMLError,
8
+ summarizeHTMLError,
9
+ formatHTMLError,
10
+ } from '../lib/utils/html-error.js';
11
+
12
+ const TOMCAT = `<!doctype html><html lang="en"><head><title>HTTP Status 500 &ndash; Internal Server Error</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 500 &ndash; Internal Server Error</h1><hr class="line" /><p><b>Type</b> Exception Report</p><p><b>Message</b> Exception performing TAK Server authentication</p><p><b>Description</b> The server encountered an unexpected condition that prevented it from fulfilling the request.</p><p><b>Exception</b></p><pre>org.springframework.security.authentication.BadCredentialsException: Exception performing TAK Server authentication
13
+ \tcom.bbn.marti.util.spring.TakAuthenticationProvider.authenticateCore(TakAuthenticationProvider.java:190)
14
+ \tcom.bbn.marti.util.spring.TakAuthenticationProvider.authenticate(TakAuthenticationProvider.java:244)
15
+ \torg.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:181)
16
+ </pre><p><b>Note</b> The full stack trace of the root cause is available in the server logs.</p><hr class="line" /><h3>Apache Tomcat/9.0.86</h3></body></html>`;
17
+
18
+ test('html-error: isHTML', () => {
19
+ assert.equal(isHTML(TOMCAT), true);
20
+ assert.equal(isHTML(' <html><body>x</body></html>'), true);
21
+ assert.equal(isHTML('<h1>HTTP Status 404 – Not Found</h1>'), true);
22
+ assert.equal(isHTML('{"message":"nope"}'), false);
23
+ assert.equal(isHTML('Plain text failure'), false);
24
+ assert.equal(isHTML(null), false);
25
+ assert.equal(isHTML({}), false);
26
+ });
27
+
28
+ test('html-error: entities are decoded', () => {
29
+ const parsed = parseHTMLError('<html><body><p><b>Message</b> a &ndash; b &amp; &lt;c&gt; &#39;d&#39; &#x41;</p></body></html>');
30
+ assert.ok(parsed);
31
+ assert.equal(parsed.fields.Message, 'a – b & <c> \'d\' A');
32
+ });
33
+
34
+ test('html-error: malformed HTML strips hidden blocks & unterminated tags', () => {
35
+ const parsed = parseHTMLError('<html><script>let a = "<b>";</script><body><STYLE>b {x:y}</STYLE><p class="x">Gateway <b>Timeout</b><script>never closed');
36
+ assert.ok(parsed);
37
+ assert.equal(parsed.text, 'Gateway Timeout');
38
+ assert.equal(parseHTMLError('<html><body>Broken <tag')?.text, 'Broken');
39
+
40
+ // Pathological input scales linearly (CodeQL polynomial regex finding)
41
+ const time = (n: number) => {
42
+ const big = '<html>' + '<head\t'.repeat(n) + '<'.repeat(n);
43
+ const start = performance.now();
44
+ assert.ok(parseHTMLError(big));
45
+ return performance.now() - start;
46
+ };
47
+ time(1000); // warm up
48
+ const small = time(10000);
49
+ const large = time(40000);
50
+ assert.ok(large < Math.max(small * 10, 50), `4x input took ${large}ms vs ${small}ms`);
51
+ });
52
+
53
+ test('html-error: TAKServerError from non-HTML body', () => {
54
+ const err = new TAKServerError(502, 'upstream <b>down</b>');
55
+ assert.equal(err.message, 'upstream down');
56
+ assert.equal(err.details, 'upstream down');
57
+ });
58
+
59
+ test('html-error: malformed HTML falls back to visible text', () => {
60
+ const parsed = parseHTMLError('<html><head><style>b {x:y}</style></head><body><p>Bad Gateway<br>upstream unavailable</body></html>');
61
+ assert.ok(parsed);
62
+ assert.equal(parsed.title, undefined);
63
+ assert.equal(parsed.text, 'Bad Gateway upstream unavailable');
64
+ assert.equal(summarizeHTMLError(parsed, 502), 'Bad Gateway upstream unavailable');
65
+ });
66
+
67
+ test('html-error: parseHTMLError - Tomcat Exception Report', () => {
68
+ const parsed = parseHTMLError(TOMCAT);
69
+ assert.ok(parsed);
70
+
71
+ assert.equal(parsed.title, 'HTTP Status 500 – Internal Server Error');
72
+ assert.deepEqual(parsed.fields, {
73
+ Type: 'Exception Report',
74
+ Message: 'Exception performing TAK Server authentication',
75
+ Description: 'The server encountered an unexpected condition that prevented it from fulfilling the request.',
76
+ Note: 'The full stack trace of the root cause is available in the server logs.',
77
+ });
78
+
79
+ assert.ok(parsed.exception);
80
+ assert.ok(parsed.exception.startsWith('org.springframework.security.authentication.BadCredentialsException: Exception performing TAK Server authentication\n'));
81
+ assert.ok(parsed.exception.includes('TakAuthenticationProvider.java:244'));
82
+ assert.ok(!parsed.exception.endsWith('\n'));
83
+
84
+ // Styles & exception body are excluded from the text fallback
85
+ assert.ok(!parsed.text.includes('font-family'));
86
+ assert.ok(!parsed.text.includes('TakAuthenticationProvider'));
87
+ assert.ok(parsed.text.includes('Apache Tomcat/9.0.86'));
88
+
89
+ assert.equal(summarizeHTMLError(parsed), 'Exception performing TAK Server authentication');
90
+
91
+ const formatted = formatHTMLError(parsed);
92
+ assert.equal(formatted.split('\n').slice(0, 6).join('\n'), [
93
+ 'HTTP Status 500 – Internal Server Error',
94
+ 'Type: Exception Report',
95
+ 'Message: Exception performing TAK Server authentication',
96
+ 'Description: The server encountered an unexpected condition that prevented it from fulfilling the request.',
97
+ 'Note: The full stack trace of the root cause is available in the server logs.',
98
+ '',
99
+ ].join('\n'));
100
+ assert.ok(formatted.endsWith('ProviderManager.authenticate(ProviderManager.java:181)'));
101
+ });
102
+
103
+ test('html-error: parseHTMLError - status page without Message', () => {
104
+ const parsed = parseHTMLError('<!doctype html><html><head><title>HTTP Status 404 &ndash; Not Found</title></head><body><h1>HTTP Status 404 &ndash; Not Found</h1><p><b>Type</b> Status Report</p><p><b>Description</b> The origin server did not find a current representation for the target resource.</p></body></html>');
105
+ assert.ok(parsed);
106
+ assert.equal(summarizeHTMLError(parsed, 404), 'The origin server did not find a current representation for the target resource.');
107
+ });
108
+
109
+ test('html-error: parseHTMLError - exception only', () => {
110
+ const parsed = parseHTMLError('<html><body><pre>java.lang.IllegalStateException: Mission already exists</pre></body></html>');
111
+ assert.ok(parsed);
112
+ assert.equal(summarizeHTMLError(parsed), 'Mission already exists');
113
+ assert.equal(formatHTMLError(parsed), 'java.lang.IllegalStateException: Mission already exists');
114
+ });
115
+
116
+ test('html-error: parseHTMLError - unstructured HTML', () => {
117
+ const parsed = parseHTMLError('<html><body><div>Service Unavailable</div></body></html>');
118
+ assert.ok(parsed);
119
+ assert.equal(parsed.title, undefined);
120
+ assert.equal(summarizeHTMLError(parsed, 503), 'Service Unavailable');
121
+ assert.equal(formatHTMLError(parsed), 'Service Unavailable');
122
+
123
+ assert.equal(parseHTMLError('not html'), null);
124
+ });
125
+
126
+ test('html-error: TAKServerError', () => {
127
+ const err = new TAKServerError(500, TOMCAT);
128
+
129
+ assert.ok(err instanceof Err);
130
+ assert.ok(err instanceof Error);
131
+ assert.equal(err.name, 'PublicError');
132
+ assert.equal(err.status, 500);
133
+ assert.equal(err.message, 'Exception performing TAK Server authentication');
134
+ assert.equal(err.safe, 'Exception performing TAK Server authentication');
135
+ assert.ok(err.details.startsWith('HTTP Status 500 – Internal Server Error\nType: Exception Report\n'));
136
+ assert.ok(err.details.includes('BadCredentialsException'));
137
+ assert.equal(err.html, TOMCAT);
138
+ assert.equal(err.parsed.fields.Type, 'Exception Report');
139
+ });
@@ -0,0 +1,137 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Readable } from 'node:stream';
4
+ import { Client } from 'undici';
5
+ import type { Dispatcher } from 'undici';
6
+ import TAKAPI from '../lib/api.js';
7
+ import { APIAuthCertificate } from '../lib/auth.js';
8
+
9
+ type RequestArgs = Parameters<Client['request']>[0];
10
+
11
+ function mockResponse(statusCode: number, contentType: string, body: string): Dispatcher.ResponseData {
12
+ return {
13
+ statusCode,
14
+ headers: { 'content-type': contentType },
15
+ body: Readable.from([Buffer.from(body)]),
16
+ trailers: {}
17
+ } as unknown as Dispatcher.ResponseData;
18
+ }
19
+
20
+ test('MissionLayer.attachUids files UIDs under a layer via the contents paths body', async () => {
21
+ const originalRequest = Client.prototype.request;
22
+
23
+ let captured: RequestArgs | undefined;
24
+
25
+ Client.prototype.request = async function(opts: RequestArgs): Promise<Dispatcher.ResponseData> {
26
+ captured = opts;
27
+ return mockResponse(200, 'text/plain', '');
28
+ };
29
+
30
+ try {
31
+ const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
32
+
33
+ await api.MissionLayer.attachUids('mission-1', 'layer-1', {
34
+ uids: ['uid-1', 'uid-2'],
35
+ creatorUid: 'alice'
36
+ });
37
+
38
+ assert.ok(captured);
39
+ assert.equal(captured.method, 'PUT');
40
+ assert.equal(captured.path, '/Marti/api/missions/mission-1/contents?creatorUid=alice');
41
+ assert.deepEqual(JSON.parse(String(captured.body)), {
42
+ paths: {
43
+ 'layer-1': [{ uids: ['uid-1', 'uid-2'] }]
44
+ }
45
+ });
46
+ } finally {
47
+ Client.prototype.request = originalRequest;
48
+ }
49
+ });
50
+
51
+ test('MissionLayer.attachUids uses the guid contents endpoint for GUID missions', async () => {
52
+ const originalRequest = Client.prototype.request;
53
+
54
+ let captured: RequestArgs | undefined;
55
+
56
+ Client.prototype.request = async function(opts: RequestArgs): Promise<Dispatcher.ResponseData> {
57
+ captured = opts;
58
+ return mockResponse(200, 'text/plain', '');
59
+ };
60
+
61
+ try {
62
+ const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
63
+
64
+ await api.MissionLayer.attachUids('11111111-2222-3333-4444-555555555555', 'layer-1', {
65
+ uids: ['uid-1'],
66
+ creatorUid: 'alice'
67
+ });
68
+
69
+ assert.ok(captured);
70
+ assert.equal(captured.method, 'PUT');
71
+ assert.equal(
72
+ captured.path,
73
+ '/Marti/api/missions/guid/11111111-2222-3333-4444-555555555555/contents?creatorUid=alice'
74
+ );
75
+ } finally {
76
+ Client.prototype.request = originalRequest;
77
+ }
78
+ });
79
+
80
+ test('MissionLayer.setParent re-parents layers via query params', async () => {
81
+ const originalRequest = Client.prototype.request;
82
+
83
+ let captured: RequestArgs | undefined;
84
+
85
+ Client.prototype.request = async function(opts: RequestArgs): Promise<Dispatcher.ResponseData> {
86
+ captured = opts;
87
+ return mockResponse(200, 'text/plain', '');
88
+ };
89
+
90
+ try {
91
+ const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
92
+
93
+ await api.MissionLayer.setParent('mission-1', {
94
+ layerUids: ['uid-1', 'uid-2'],
95
+ parentUid: 'layer-2',
96
+ creatorUid: 'alice'
97
+ });
98
+
99
+ assert.ok(captured);
100
+ assert.equal(captured.method, 'PUT');
101
+ assert.equal(
102
+ captured.path,
103
+ '/Marti/api/missions/mission-1/layers/parent?layerUid=uid-1&layerUid=uid-2&parentUid=layer-2&creatorUid=alice'
104
+ );
105
+ } finally {
106
+ Client.prototype.request = originalRequest;
107
+ }
108
+ });
109
+
110
+ test('MissionLayer.setParent omits parentUid to move a layer to the mission root', async () => {
111
+ const originalRequest = Client.prototype.request;
112
+
113
+ let captured: RequestArgs | undefined;
114
+
115
+ Client.prototype.request = async function(opts: RequestArgs): Promise<Dispatcher.ResponseData> {
116
+ captured = opts;
117
+ return mockResponse(200, 'text/plain', '');
118
+ };
119
+
120
+ try {
121
+ const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
122
+
123
+ await api.MissionLayer.setParent('11111111-2222-3333-4444-555555555555', {
124
+ layerUids: ['uid-1'],
125
+ creatorUid: 'alice'
126
+ });
127
+
128
+ assert.ok(captured);
129
+ assert.equal(captured.method, 'PUT');
130
+ assert.equal(
131
+ captured.path,
132
+ '/Marti/api/missions/guid/11111111-2222-3333-4444-555555555555/layers/parent?layerUid=uid-1&creatorUid=alice'
133
+ );
134
+ } finally {
135
+ Client.prototype.request = originalRequest;
136
+ }
137
+ });
@@ -0,0 +1,173 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Readable } from 'node:stream';
4
+ import { Client } from 'undici';
5
+ import type { Dispatcher } from 'undici';
6
+ import TAKAPI from '../lib/api.js';
7
+ import { APIAuthCertificate } from '../lib/auth.js';
8
+ import { MissionSubscriberRole } from '../lib/api/mission.js';
9
+
10
+ type RequestArgs = Parameters<Client['request']>[0];
11
+
12
+ function mockResponse(statusCode: number, contentType: string, body: string): Dispatcher.ResponseData {
13
+ return {
14
+ statusCode,
15
+ headers: { 'content-type': contentType },
16
+ body: Readable.from([Buffer.from(body)]),
17
+ trailers: {}
18
+ } as unknown as Dispatcher.ResponseData;
19
+ }
20
+
21
+ test('Mission.setRole sends role as a bare query param and returns void', async () => {
22
+ const originalRequest = Client.prototype.request;
23
+
24
+ let captured: RequestArgs | undefined;
25
+
26
+ Client.prototype.request = async function(opts: RequestArgs): Promise<Dispatcher.ResponseData> {
27
+ captured = opts;
28
+ return mockResponse(200, 'text/plain', '');
29
+ };
30
+
31
+ try {
32
+ const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
33
+
34
+ const res = await api.Mission.setRole('mission-1', {
35
+ clientUid: 'client-1',
36
+ username: 'alice',
37
+ role: MissionSubscriberRole.MISSION_OWNER
38
+ });
39
+
40
+ assert.equal(res, undefined);
41
+ assert.ok(captured);
42
+ assert.equal(captured.method, 'PUT');
43
+ assert.equal(
44
+ captured.path,
45
+ '/Marti/api/missions/mission-1/role?clientUid=client-1&username=alice&role=MISSION_OWNER'
46
+ );
47
+ } finally {
48
+ Client.prototype.request = originalRequest;
49
+ }
50
+ });
51
+
52
+ test('Mission.subscriptionRoles parses a list of subscribers with roles', async () => {
53
+ const originalRequest = Client.prototype.request;
54
+
55
+ Client.prototype.request = async function(): Promise<Dispatcher.ResponseData> {
56
+ return mockResponse(200, 'application/json', JSON.stringify({
57
+ data: [{
58
+ clientUid: 'client-1',
59
+ username: 'alice',
60
+ createTime: '2026-01-01T00:00:00.000Z',
61
+ role: {
62
+ permissions: ['MISSION_READ', 'MISSION_WRITE'],
63
+ type: 'MISSION_SUBSCRIBER'
64
+ }
65
+ }]
66
+ }));
67
+ };
68
+
69
+ try {
70
+ const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
71
+
72
+ const res = await api.Mission.subscriptionRoles('mission-1');
73
+
74
+ assert.equal(res.data.length, 1);
75
+ assert.equal(res.data[0].username, 'alice');
76
+ assert.equal(res.data[0].role.type, 'MISSION_SUBSCRIBER');
77
+ } finally {
78
+ Client.prototype.request = originalRequest;
79
+ }
80
+ });
81
+
82
+ test('Mission.unsubscribe issues a DELETE with uid query param', async () => {
83
+ const originalRequest = Client.prototype.request;
84
+
85
+ let captured: RequestArgs | undefined;
86
+
87
+ Client.prototype.request = async function(opts: RequestArgs): Promise<Dispatcher.ResponseData> {
88
+ captured = opts;
89
+ return mockResponse(200, 'text/plain', '');
90
+ };
91
+
92
+ try {
93
+ const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
94
+
95
+ await api.Mission.unsubscribe('mission-1', { uid: 'client-1' });
96
+
97
+ assert.ok(captured);
98
+ assert.equal(captured.method, 'DELETE');
99
+ assert.equal(captured.path, '/Marti/api/missions/mission-1/subscription?uid=client-1');
100
+ } finally {
101
+ Client.prototype.request = originalRequest;
102
+ }
103
+ });
104
+
105
+ test('Mission.latestFeats parses each CoT individually and isolates invalid CoTs', async () => {
106
+ const originalRequest = Client.prototype.request;
107
+
108
+ let captured: RequestArgs | undefined;
109
+
110
+ Client.prototype.request = async function(opts: RequestArgs): Promise<Dispatcher.ResponseData> {
111
+ captured = opts;
112
+ return mockResponse(200, 'application/xml', `
113
+ <events>
114
+ <event version="2.0" uid="uid-valid" type="a-f-G" time="2026-08-11T00:00:00.000Z" start="2026-08-11T00:00:00.000Z" stale="2026-08-11T00:05:00.000Z" how="h-g-i-g-o">
115
+ <point lat="1.1" lon="2.2" hae="0.0" ce="9999999.0" le="9999999.0"/>
116
+ <detail>
117
+ <contact callsign="ALPHA"/>
118
+ </detail>
119
+ </event>
120
+ <event version="2.0" uid="uid-poisoned" type="a-f-G" time="2026-08-11T00:00:00.000Z" start="2026-08-11T00:00:00.000Z" stale="2026-08-11T00:05:00.000Z" how="h-g-i-g-o">
121
+ <detail>
122
+ <contact callsign="BRAVO"/>
123
+ </detail>
124
+ </event>
125
+ </events>
126
+ `);
127
+ };
128
+
129
+ try {
130
+ const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
131
+
132
+ const res = await api.Mission.latestFeats('mission-1');
133
+
134
+ assert.ok(captured);
135
+ assert.equal(captured.method, 'GET');
136
+ assert.equal(captured.path, '/Marti/api/missions/mission-1/cot');
137
+
138
+ assert.equal(res.features.length, 1);
139
+ assert.equal(res.features[0].id, 'uid-valid');
140
+ assert.equal(res.features[0].properties.callsign, 'ALPHA');
141
+
142
+ assert.equal(res.invalid.length, 1);
143
+ assert.ok(res.invalid[0].error.length > 0);
144
+
145
+ const event = res.invalid[0].feature as {
146
+ _attributes?: Record<string, unknown>;
147
+ detail?: { contact?: { _attributes?: Record<string, unknown> } };
148
+ };
149
+
150
+ assert.equal(event._attributes?.uid, 'uid-poisoned');
151
+ assert.equal(event.detail?.contact?._attributes?.callsign, 'BRAVO');
152
+ } finally {
153
+ Client.prototype.request = originalRequest;
154
+ }
155
+ });
156
+
157
+ test('Mission.latestFeats returns empty results for an empty document', async () => {
158
+ const originalRequest = Client.prototype.request;
159
+
160
+ Client.prototype.request = async function(): Promise<Dispatcher.ResponseData> {
161
+ return mockResponse(200, 'application/xml', '<events></events>');
162
+ };
163
+
164
+ try {
165
+ const api = new TAKAPI(new URL('https://tak.example.com'), new APIAuthCertificate('cert', 'key'));
166
+
167
+ const res = await api.Mission.latestFeats('mission-1');
168
+
169
+ assert.deepEqual(res, { features: [], invalid: [] });
170
+ } finally {
171
+ Client.prototype.request = originalRequest;
172
+ }
173
+ });