From 95339aba7bfb9618b92d85f7307fe5e20d83b137 Mon Sep 17 00:00:00 2001 From: TOMIKAWA Sotaro Date: Wed, 11 Dec 2024 15:24:15 +0900 Subject: [PATCH] fix: verify functions return map --- README.md | 33 +++++++- demo/src/App.svelte | 78 ++++++++++++++++- src/sim/verify.ts | 25 +++++- src/types.ts | 7 +- src/verify.ts | 30 ++++++- tests/node.test.ts | 198 ++++++++++++++++++++++++++++++-------------- 6 files changed, 290 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index eb03a2a..500f2fe 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Check apple-app-site-association file to verify universal links. +## Installation + +```sh +npm install -D universal-links-test +``` + ## Usage The `verify` function detects provided path lauches the app or not. @@ -13,10 +19,29 @@ If you don't use macOS, you can use `universal-links-test/sim` instead that simu import { verify, type AppleAppSiteAssociation } from "universal-links-test"; // import { verify, type AppleAppSiteAssociation } from "universal-links-test/sim"; -const json = { applinks: { /* ... */ } } satisfies AppleAppSiteAssociation; - -const result = await verify(json, "/universal-links/path?query#hash"); -console.log(result); // 'match' | 'block' | 'unset' +const json = { + applinks: { + details: [ + { + appID: "APP.ID1", + components: [{ "/": "/universal-links/path" }] + }, + { + appID: "APP.ID2", + components: [{ "/": "/universal-links/path", exclude: true }] + }, + { + appID: "APP.ID3", + components: [] + } + ] + } +} satisfies AppleAppSiteAssociation; + +const result: Map = await verify(json, "/universal-links/path?query#hash"); +console.log(result.get("APP.ID1")); // 'match' : Universal links are working +console.log(result.get("APP.ID2")); // 'block' : Universal links are blocked +console.log(result.get("APP.ID3")); // undefined ``` Use `swcutil` command programmatically: diff --git a/demo/src/App.svelte b/demo/src/App.svelte index 4e46c1d..0439010 100644 --- a/demo/src/App.svelte +++ b/demo/src/App.svelte @@ -1,5 +1,5 @@ @@ -66,10 +80,66 @@ -
+

Test Paths

-
    + + + + + + {#each appIds as appId (appId)} + + {/each} + + + + {#each paths as path (path.id)} + + + + {#await verify(path.value) then result} + {#each appIds as appId (appId)} + {@const res = result.get(appId)} + + {/each} + {/await} + + {/each} + +
    path{appId}
    + + + + + + + {res ?? "unset"} +
    +
diff --git a/src/sim/verify.ts b/src/sim/verify.ts index 41efa00..fefbdc4 100644 --- a/src/sim/verify.ts +++ b/src/sim/verify.ts @@ -1,5 +1,5 @@ import { validateAASA } from "../aasa.js"; -import type { CreateVerify, Verify } from "../types.js"; +import type { CreateVerify, ResultMap, Verify } from "../types.js"; import { resolveJson } from "./json.js"; import { match } from "./match.js"; @@ -10,7 +10,8 @@ export const createVerify: CreateVerify = (json) => { const aasa = await aasaPromise; if (!validateAASA(aasa)) throw new Error("Invalid AASA"); const details = aasa.applinks?.details; - if (!details || details.length === 0) return "unset"; + const ret: ResultMap = new Map(); + if (!details || details.length === 0) return ret; for (const detail of details) { if (!detail.appID && (!detail.appIDs || detail.appIDs.length === 0)) @@ -29,11 +30,27 @@ export const createVerify: CreateVerify = (json) => { aasa.applinks?.defaults?.caseSensitive; if (match(url, { ...component, percentEncoded, caseSensitive })) { - return component.exclude ? "block" : "match"; + const res = component.exclude ? "block" : "match"; + if (detail.appIDs) { + for (const appID of detail.appIDs) { + if (!ret.has(appID)) ret.set(appID, res); + } + } else if (detail.appID) { + if (!ret.has(detail.appID)) ret.set(detail.appID, res); + } + break; } } } - return "unset"; + return ret; }; }; + +/** + * Verify the URL with the apple-app-site-association file. + * This function simulates the `swcutil` command behavior. + * @param json apple-app-site-association file content or path + * @param path URL to verify + * @returns Map of appID and match/block + */ export const verify: Verify = (json, path) => createVerify(json)(path); diff --git a/src/types.ts b/src/types.ts index f13f96e..7c10012 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,9 @@ export type JsonOrPath = string | Record; export type PromiseOr = T | Promise; -export type VerifyResult = "match" | "block" | "unset"; +export type ResultMap = Map; +export type VerifyResult = "match" | "block"; export type CreateVerify = ( json: JsonOrPath, -) => (path: string) => Promise; -export type Verify = (json: JsonOrPath, path: string) => Promise; +) => (path: string) => Promise; +export type Verify = (json: JsonOrPath, path: string) => Promise; diff --git a/src/verify.ts b/src/verify.ts index 2b9f471..d905d44 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -8,9 +8,33 @@ export const createVerify: CreateVerify = (json) => async (path) => { url: path, }); if (res.status !== 0) throw new Error(res.stderr); + /** + * @example + * ``` + * { s = applinks, a = HOGE.com.example.app, d = www.example.com }: Pattern "/" matched. + * { s = applinks, a = FUGA.com.example.app, d = www.example.com }: Pattern "/" matched. + * { s = applinks, a = FOO.com.example.app, d = www.example.com }: Pattern "/" blocked match. + * { s = applinks, a = BAR.com.example.app, d = www.example.com }: Pattern "/" blocked match. + * ``` + */ const out = res.stdout.trimEnd(); - if (out.endsWith("matched.")) return "match"; - if (out.endsWith("blocked match.")) return "block"; - return "unset"; + const lines = out.split("\n"); + const resultMap = lines + .map((line) => { + const appID = line.match(/a = ([^,]+),/)?.[1]; + if (!appID) return undefined; + return [appID, line.endsWith("matched.") ? "match" : "block"] as const; + }) + .filter((x) => x !== undefined); + return new Map(resultMap); }; + +/** + * Verify the URL with the apple-app-site-association file. + * This function is a wrapper of the `swcutil` command. + * So you need to run on macOS and root permission is required. + * @param json apple-app-site-association file content or path + * @param path URL to verify + * @returns Map of appID and match/block + */ export const verify: Verify = (json, path) => createVerify(json)(path); diff --git a/tests/node.test.ts b/tests/node.test.ts index 4f5dcd9..ec204b4 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -1,110 +1,182 @@ import * as assert from "node:assert"; -import { test } from "node:test"; -import { - type AppleAppSiteAssociation, - type ApplinksDetailsComponents, - verify, -} from "universal-links-test"; +import { it } from "node:test"; +import { type ApplinksDetails, verify } from "universal-links-test"; import { verify as verifySim } from "universal-links-test/sim"; -const getJson = ( - components: ApplinksDetailsComponents[], -): AppleAppSiteAssociation => ({ - applinks: { details: [{ appID: "HOGE.com.example.app", components }] }, +const fromDetails = (...details: ApplinksDetails[]) => ({ + applinks: { details }, }); -test("empty json", async () => { +it("empty json", async () => { const json = {}; const path = "/"; - assert.strictEqual(await verify(json, path), "unset"); - assert.strictEqual(await verifySim(json, path), "unset"); + const expected = new Map(); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("invalid json", async () => { +it("invalid json", async () => { const json = "{[]}"; const path = "/"; await assert.rejects(() => verify(json, path)); await assert.rejects(() => verifySim(json, path)); }); -test("empty rule", async () => { - const json = getJson([]); +it("empty rule", async () => { + const json = fromDetails({ appID: "HOGE.com.example.app", components: [] }); const path = "/"; - assert.strictEqual(await verify(json, path), "unset"); - assert.strictEqual(await verifySim(json, path), "unset"); + const expected = new Map(); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("should match path", async () => { - const json = getJson([{ "/": "/search/" }]); +it("should match path", async () => { + const json = fromDetails({ + appID: "HOGE.com.example.app", + components: [{ "/": "/search/" }], + }); const path = "/search/"; - assert.strictEqual(await verify(json, path), "match"); - assert.strictEqual(await verifySim(json, path), "match"); + const expected = new Map([["HOGE.com.example.app", "match"]]); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("should exclude", async () => { - const json = getJson([ - { "#": "nondeeplinking", exclude: true }, - { "/": "/search/" }, - ]); +it("should exclude", async () => { + const json = fromDetails({ + appID: "HOGE.com.example.app", + components: [{ "#": "nondeeplinking", exclude: true }, { "/": "/search/" }], + }); const path = "/search/#nondeeplinking"; - assert.strictEqual(await verify(json, path), "block"); - assert.strictEqual(await verifySim(json, path), "block"); + const expected = new Map([["HOGE.com.example.app", "block"]]); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("default case sensitive", async () => { - const json = getJson([{ "/": "/search/" }]); +it("default case sensitive", async () => { + const json = fromDetails({ + appID: "HOGE.com.example.app", + components: [{ "/": "/search/" }], + }); const path = "/SEARCH/"; - assert.strictEqual(await verify(json, path), "unset"); - assert.strictEqual(await verifySim(json, path), "unset"); + const expected = new Map(); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("case insensitive", async () => { - const json = getJson([{ "/": "/search/", caseSensitive: false }]); +it("case insensitive", async () => { + const json = fromDetails({ + appID: "HOGE.com.example.app", + components: [{ "/": "/search/", caseSensitive: false }], + }); const path = "/SEARCH/"; - assert.strictEqual(await verify(json, path), "match"); - assert.strictEqual(await verifySim(json, path), "match"); + const expected = new Map([["HOGE.com.example.app", "match"]]); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("should match path with query string and hash", async () => { - const json = getJson([{ "/": "/search/" }]); +it("should match path with query string and hash", async () => { + const json = fromDetails({ + appID: "HOGE.com.example.app", + components: [{ "/": "/search/" }], + }); const path = "/search/?q=foo#bar"; - assert.strictEqual(await verify(json, path), "match"); - assert.strictEqual(await verifySim(json, path), "match"); + const expected = new Map([["HOGE.com.example.app", "match"]]); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("should match query string", async () => { - const json = getJson([{ "?": "key=value" }]); +it("should match query string", async () => { + const json = fromDetails({ + appID: "HOGE.com.example.app", + components: [{ "?": "key=value" }], + }); const path = "/?key=value"; - assert.strictEqual(await verify(json, path), "match"); - assert.strictEqual(await verifySim(json, path), "match"); + const expected = new Map([["HOGE.com.example.app", "match"]]); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("should not match query string with extras", async () => { - const json = getJson([{ "?": "key=value" }]); +it("should not match query string with extras", async () => { + const json = fromDetails({ + appID: "HOGE.com.example.app", + components: [{ "?": "key=value" }], + }); const path = "/?key=value&extra=value"; - assert.strictEqual(await verify(json, path), "unset"); - assert.strictEqual(await verifySim(json, path), "unset"); + const expected = new Map(); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("should match query string with key-value object", async () => { - const json = getJson([{ "?": { key: "value" } }]); +it("should match query string with key-value object", async () => { + const json = fromDetails({ + appID: "HOGE.com.example.app", + components: [{ "?": { key: "value" } }], + }); const path = "/?key=value"; - assert.strictEqual(await verify(json, path), "match"); - assert.strictEqual(await verifySim(json, path), "match"); + const expected = new Map([["HOGE.com.example.app", "match"]]); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("should match query string with key-value object with extras", async () => { - const json = getJson([{ "?": { key: "value" } }]); +it("should match query string with key-value object with extras", async () => { + const json = fromDetails({ + appID: "HOGE.com.example.app", + components: [{ "?": { key: "value" } }], + }); const path = "/?key=value&extra=value"; - assert.strictEqual(await verify(json, path), "match"); - assert.strictEqual(await verifySim(json, path), "match"); + const expected = new Map([["HOGE.com.example.app", "match"]]); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); +}); + +it("should unset appIDs and appID not provided", async () => { + const json = fromDetails({ components: [{ "/": "/search/" }] }); + const path = "/search/"; + const expected = new Map(); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); +}); + +it("should ignore appID if appIDs provided", async () => { + const json = fromDetails({ + appID: "FOO.com.example.app", + appIDs: [], + components: [{ "/": "/search/" }], + }); + const path = "/search/"; + const expected = new Map(); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); +}); + +it("should match multiple appIDs (appID is ignored)", async () => { + const json = fromDetails({ + appID: "FOO.com.example.app", + appIDs: ["BAR.com.example.app", "BAZ.com.example.app"], + components: [{ "/": "/search/" }], + }); + const path = "/search/"; + const expected = new Map([ + ["BAR.com.example.app", "match"], + ["BAZ.com.example.app", "match"], + ]); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); }); -test("should unset appIDs and appID not provided", async () => { - const json = { - applinks: { details: [{ components: [{ "/": "/search/" }] }] }, - }; +it("should handle multiple details", async () => { + const json = fromDetails( + { + appID: "HOGE.com.example.app", + components: [{ "/": "/search/" }], + }, + { + appID: "HOGE.com.example.app", + components: [{ "/": "/search/", exclude: true }], + }, + ); const path = "/search/"; - assert.strictEqual(await verify(json, path), "unset"); - assert.strictEqual(await verifySim(json, path), "unset"); + const expected = new Map([["HOGE.com.example.app", "match"]]); + assert.deepStrictEqual(await verify(json, path), expected); + assert.deepStrictEqual(await verifySim(json, path), expected); });