@7365admin1/layer-common 3.2.2-staging.197 → 3.2.2-staging.198

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.
@@ -153,6 +153,13 @@ const props = defineProps({
153
153
  type: Boolean,
154
154
  default: false,
155
155
  },
156
+ // The site this reader belongs to. Passed down from HidReaderManagement,
157
+ // which already has it as a prop from the page's route. Reader discovery is
158
+ // authorized against it server-side.
159
+ site: {
160
+ type: String,
161
+ default: "",
162
+ },
156
163
  });
157
164
 
158
165
  const emit = defineEmits<{
@@ -236,6 +243,7 @@ async function connectReader() {
236
243
  baseUrl: draft.baseUrl.trim(),
237
244
  username: draft.username.trim(),
238
245
  password: draft.password,
246
+ site: props.site,
239
247
  });
240
248
  const nestedData = response?.data;
241
249
  const data = typeof nestedData === "object" && nestedData !== null
@@ -111,6 +111,7 @@
111
111
  :mode="selectedReader ? 'edit' : 'add'"
112
112
  :reader="selectedReader"
113
113
  :loading="saving"
114
+ :site="site"
114
115
  @submit="saveReader"
115
116
  />
116
117
 
@@ -90,6 +90,23 @@ export type HidSipAccountData = {
90
90
  provisionedAt: string;
91
91
  };
92
92
 
93
+ /**
94
+ * True only for the specific validation error an API that predates core #1878
95
+ * returns when the discover body carries `site`. Deliberately narrow: after
96
+ * #1878 `site` is required, so this message can no longer be produced and the
97
+ * fallback below can never hide a real authorization refusal.
98
+ */
99
+ export function isUnknownSiteFieldError(error: unknown): boolean {
100
+ const record = typeof error === "object" && error !== null
101
+ ? error as Record<string, unknown>
102
+ : {};
103
+ const data = typeof record.data === "object" && record.data !== null
104
+ ? record.data as Record<string, unknown>
105
+ : {};
106
+ const message = String(data.message || record.message || "");
107
+ return message.includes("\"site\"") && message.includes("not allowed");
108
+ }
109
+
93
110
  export default function useHidAmico() {
94
111
  const basePath = "/api/access-management/hid";
95
112
 
@@ -260,11 +277,28 @@ export default function useHidAmico() {
260
277
  );
261
278
  }
262
279
 
263
- function discoverReader(payload: Pick<HidReaderPayload, "baseUrl" | "username" | "password">) {
264
- return useNuxtApp().$api<Record<string, unknown>>(`${basePath}/readers/discover`, {
265
- method: "POST",
266
- body: payload,
267
- });
280
+ async function discoverReader(
281
+ payload: Pick<HidReaderPayload, "baseUrl" | "username" | "password" | "site">,
282
+ ) {
283
+ const post = (body: Record<string, unknown>) =>
284
+ useNuxtApp().$api<Record<string, unknown>>(`${basePath}/readers/discover`, {
285
+ method: "POST",
286
+ body,
287
+ });
288
+
289
+ try {
290
+ return await post(payload as Record<string, unknown>);
291
+ } catch (error: unknown) {
292
+ // The discover endpoint only started accepting (and requiring) `site` in
293
+ // core #1878. An API that predates it validates the body with Joi's
294
+ // default allowUnknown:false and answers `"site" is not allowed`, so the
295
+ // web layer would break the moment it shipped ahead of the API. Retry once
296
+ // without `site` so both API versions work.
297
+ // ponytail: remove this fallback once core #1878 is deployed everywhere.
298
+ if (!isUnknownSiteFieldError(error)) throw error;
299
+ const { site: _site, ...legacy } = payload;
300
+ return await post(legacy as Record<string, unknown>);
301
+ }
268
302
  }
269
303
 
270
304
  function getUserPinStatus(readerId: string, hidUserId: string | number) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.2-staging.197",
5
+ "version": "3.2.2-staging.198",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -0,0 +1,102 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import test from "node:test";
6
+
7
+ import useHidAmico, { isUnknownSiteFieldError } from "../composables/useHidAmico.ts";
8
+
9
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
10
+
11
+ type Call = { url: string; body: Record<string, unknown> };
12
+
13
+ /**
14
+ * The composable calls the Nuxt auto-import `useNuxtApp().$api(...)`. Install a
15
+ * stub for it so the request body can be read without a Nuxt app.
16
+ */
17
+ function stubApi(handler: (call: Call) => unknown) {
18
+ const calls: Call[] = [];
19
+ (globalThis as Record<string, unknown>).useNuxtApp = () => ({
20
+ $api: (url: string, options: { body: Record<string, unknown> }) => {
21
+ const call = { url, body: options.body };
22
+ calls.push(call);
23
+ return Promise.resolve(handler(call));
24
+ },
25
+ });
26
+ return calls;
27
+ }
28
+
29
+ function fetchError(message: string) {
30
+ return Object.assign(new Error("400 Bad Request"), { data: { message } });
31
+ }
32
+
33
+ test("discoverReader sends site in the request body", async () => {
34
+ const calls = stubApi(() => ({ data: { deviceId: "D1", portals: [] } }));
35
+
36
+ await useHidAmico().discoverReader({
37
+ baseUrl: "https://reader.example",
38
+ username: "operator",
39
+ password: "secret",
40
+ site: "0123456789abcdef01234567",
41
+ });
42
+
43
+ assert.equal(calls.length, 1);
44
+ assert.equal(calls[0].url, "/api/access-management/hid/readers/discover");
45
+ assert.equal(calls[0].body.site, "0123456789abcdef01234567");
46
+ assert.equal(calls[0].body.baseUrl, "https://reader.example");
47
+ });
48
+
49
+ test("discoverReader retries without site when the API predates core #1878", async () => {
50
+ const calls = stubApi((call) => {
51
+ if ("site" in call.body) throw fetchError('"site" is not allowed');
52
+ return { data: { deviceId: "D1", portals: [] } };
53
+ });
54
+
55
+ const response = await useHidAmico().discoverReader({
56
+ baseUrl: "https://reader.example",
57
+ username: "operator",
58
+ password: "secret",
59
+ site: "0123456789abcdef01234567",
60
+ });
61
+
62
+ assert.equal(calls.length, 2);
63
+ assert.equal("site" in calls[1].body, false);
64
+ assert.equal(calls[1].body.baseUrl, "https://reader.example");
65
+ assert.deepEqual(response, { data: { deviceId: "D1", portals: [] } });
66
+ });
67
+
68
+ test("discoverReader does not retry a real refusal", async () => {
69
+ const calls = stubApi(() => {
70
+ throw fetchError("You are not authorized to manage readers for this site.");
71
+ });
72
+
73
+ await assert.rejects(
74
+ useHidAmico().discoverReader({
75
+ baseUrl: "https://reader.example",
76
+ username: "operator",
77
+ password: "secret",
78
+ site: "0123456789abcdef01234567",
79
+ }),
80
+ );
81
+ assert.equal(calls.length, 1);
82
+ });
83
+
84
+ test("isUnknownSiteFieldError only matches the unknown-key message", () => {
85
+ assert.equal(isUnknownSiteFieldError(fetchError('"site" is not allowed')), true);
86
+ assert.equal(isUnknownSiteFieldError(new Error('"site" is not allowed')), true);
87
+ assert.equal(isUnknownSiteFieldError(fetchError('"site" is required')), false);
88
+ assert.equal(isUnknownSiteFieldError(fetchError('"baseUrl" is not allowed')), false);
89
+ assert.equal(isUnknownSiteFieldError(fetchError("Unauthorized")), false);
90
+ assert.equal(isUnknownSiteFieldError(null), false);
91
+ });
92
+
93
+ test("the reader form takes site as a prop and puts it in the discover call", () => {
94
+ const source = readFileSync(join(root, "components", "HidReaderForm.vue"), "utf8");
95
+ assert.match(source, /site:\s*\{\s*type:\s*String/);
96
+ assert.match(source, /discoverReader\(\{[\s\S]*?site:\s*props\.site[\s\S]*?\}\)/);
97
+ });
98
+
99
+ test("reader management passes its site prop down to the form", () => {
100
+ const source = readFileSync(join(root, "components", "HidReaderManagement.vue"), "utf8");
101
+ assert.match(source, /<HidReaderForm[\s\S]*?:site="site"[\s\S]*?\/>/);
102
+ });