Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/lib/content/content-field-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export class ContentFieldMapper {
errors += contentMappingResult.errors;
}
// Handle URL fields with potential asset references
else if (typeof fieldValue === "string" && fieldValue.includes("cdn.aglty.io")) {
else if (typeof fieldValue === "string" && this.isAssetUrl(fieldValue, context)) {
const urlMappingResult = this.mapAssetUrlString(fieldValue, context);
mappedValue = urlMappingResult.mappedValue;
warnings += urlMappingResult.warnings;
Expand Down Expand Up @@ -129,6 +129,19 @@ export class ContentFieldMapper {
return { mappedValue, warnings, errors };
}

/**
* Domain check for asset URL strings. Matches:
* - any Agility-managed CDN subdomain (cdn.aglty.io, cdn-usa2.aglty.io, *.agilitycms.com, etc.)
* - any URL whose prefix matches a container URL loaded into the asset mapper
*/
private isAssetUrl(value: string, context?: ContentFieldMappingContext): boolean {
return (
value.includes(".aglty.io") ||
value.includes(".agilitycms.com") ||
context?.assetMapper?.isKnownAssetUrl(value) === true
);
}

private isAssetAttachmentField(fieldValue: any): boolean {
if (!fieldValue || typeof fieldValue !== "object") return false;

Expand Down
23 changes: 20 additions & 3 deletions src/lib/content/content-field-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export class ContentFieldValidator {
};

// Validate asset URLs
if (fieldValue.includes("cdn.aglty.io")) {
if (this.isAssetUrl(fieldValue, options)) {
if (!this.isValidAssetUrl(fieldValue)) {
result.errors.push(`Invalid asset URL format in field ${fieldKey}: ${fieldValue}`);
result.isValid = false;
Expand Down Expand Up @@ -286,12 +286,29 @@ export class ContentFieldValidator {
}

/**
* Validate asset URL format
* Domain-agnostic check for asset URL strings. Matches:
* - any Agility-managed CDN subdomain (cdn.aglty.io, cdn-usa2.aglty.io, *.agilitycms.com, etc.)
* - any URL that exactly matches one of the source assets passed in options
* (so customer-supplied custom CDN domains are recognized when source data is available)
*/
private isAssetUrl(value: string, options: ContentValidationOptions): boolean {
if (value.includes(".aglty.io") || value.includes(".agilitycms.com")) return true;
if (options.sourceAssets) {
return options.sourceAssets.some(
(asset) => asset?.originUrl === value || asset?.url === value || asset?.edgeUrl === value
);
}
return false;
}

/**
* Structural validity check for an asset URL — caller is responsible for
* deciding the string is asset-like in the first place (see looksLikeAssetUrl).
*/
private isValidAssetUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.hostname.includes("cdn.aglty.io") && urlObj.pathname.length > 1;
return urlObj.pathname.length > 1;
} catch {
return false;
}
Expand Down
78 changes: 78 additions & 0 deletions src/lib/content/tests/content-field-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function makeAssetMapper(overrides: any = {}): any {
return {
getAssetMappingByMediaUrl: jest.fn().mockReturnValue(null),
remapUrlByContainer: jest.fn().mockReturnValue(null),
isKnownAssetUrl: jest.fn().mockReturnValue(false),
...overrides,
};
}
Expand Down Expand Up @@ -254,6 +255,83 @@ describe("ContentFieldMapper.mapContentFields", () => {
const result = mapper.mapContentFields(fields, context);
expect(result.mappedFields.heroUrl).toBe("https://cdn.aglty.io/tgt/assets/img.jpg");
});

it("detects a regional .aglty.io subdomain (cdn-usa2.aglty.io) as an asset URL", () => {
// Regression check: the old `includes("cdn.aglty.io")` check missed cdn-usa2.aglty.io.
const assetMapper = makeAssetMapper({
getAssetMappingByMediaUrl: jest.fn().mockReturnValue({ sourceUrl: "different", targetUrl: "x" }),
remapUrlByContainer: jest.fn().mockReturnValue("https://cdn-usa2.aglty.io/tgt/file.json"),
});
const context = { referenceMapper: makeReferenceMapper(), assetMapper };
const fields = { config: "https://cdn-usa2.aglty.io/src/mobile/config.json" };
const result = mapper.mapContentFields(fields, context);
expect(result.mappedFields.config).toBe("https://cdn-usa2.aglty.io/tgt/file.json");
});

it("detects a *.agilitycms.com subdomain as an asset URL", () => {
const assetMapper = makeAssetMapper({
getAssetMappingByMediaUrl: jest.fn().mockReturnValue({ sourceUrl: "different", targetUrl: "x" }),
remapUrlByContainer: jest.fn().mockReturnValue("https://cdndev.agilitycms.com/tgt/file.jpg"),
});
const context = { referenceMapper: makeReferenceMapper(), assetMapper };
const fields = { hero: "https://cdndev.agilitycms.com/src/folder/photo.jpg" };
const result = mapper.mapContentFields(fields, context);
expect(result.mappedFields.hero).toBe("https://cdndev.agilitycms.com/tgt/file.jpg");
});

it("detects a custom CDN URL via assetMapper.isKnownAssetUrl()", () => {
// Customer using a custom CDN host (cdn.ilotteryservices.com). The string
// does NOT include .aglty.io/.agilitycms.com, so detection falls through
// to the asset mapper, which recognizes it from its loaded container URLs.
const assetMapper = makeAssetMapper({
isKnownAssetUrl: jest.fn().mockReturnValue(true),
getAssetMappingByMediaUrl: jest.fn().mockReturnValue({ sourceUrl: "different", targetUrl: "x" }),
remapUrlByContainer: jest.fn().mockReturnValue("https://cdn.ilotteryservices.com/0e9b1234/mobile/file.json"),
});
const context = { referenceMapper: makeReferenceMapper(), assetMapper };
const fields = { config: "https://cdn.ilotteryservices.com/8f5ad099/mobile/file.json" };
const result = mapper.mapContentFields(fields, context);
expect(assetMapper.isKnownAssetUrl).toHaveBeenCalledWith(fields.config);
expect(result.mappedFields.config).toBe("https://cdn.ilotteryservices.com/0e9b1234/mobile/file.json");
});

it("leaves an unrecognized string field unchanged", () => {
const assetMapper = makeAssetMapper(); // isKnownAssetUrl returns false by default
const context = { referenceMapper: makeReferenceMapper(), assetMapper };
const fields = { description: "Just a regular text value, not a URL" };
const result = mapper.mapContentFields(fields, context);
expect(result.mappedFields.description).toBe("Just a regular text value, not a URL");
// Non-asset string should not trigger the asset-URL mapping path
expect(assetMapper.getAssetMappingByMediaUrl).not.toHaveBeenCalled();
});
});

describe("URL link object fields (href/target/text)", () => {
it("remaps the href of an Agility URL field that points at a source asset URL", () => {
// Agility's "URL" field type produces an object of shape { href, target, text }.
// It does NOT have a `.url` key, so it is not caught by isAssetAttachmentField.
// The href value pointing at a source CDN should be remapped to the target CDN.
const assetMapper = makeAssetMapper({
getAssetMappingByMediaUrl: jest
.fn()
.mockReturnValue({
sourceUrl: "https://cdn-eu.aglty.io/jules-eu-a/pikachu.png",
targetUrl: "https://cdn-aus.aglty.io/jules-aus-a/pikachu.png",
}),
});
const context = { referenceMapper: makeReferenceMapper(), assetMapper };
const fields = {
url: {
href: "https://cdn-eu.aglty.io/jules-eu-a/pikachu.png",
target: "",
text: "",
},
};
const result = mapper.mapContentFields(fields, context);
expect(result.mappedFields.url.href).toBe("https://cdn-aus.aglty.io/jules-aus-a/pikachu.png");
expect(result.mappedFields.url.target).toBe("");
expect(result.mappedFields.url.text).toBe("");
});
});

describe("nested object fields", () => {
Expand Down
39 changes: 39 additions & 0 deletions src/lib/content/tests/content-field-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,45 @@ describe("ContentFieldValidator.validateContentFields", () => {
const result = validator.validateContentFields({ body: longString });
expect(result.totalWarnings).toBeGreaterThan(0);
});

it("validates a regional .aglty.io subdomain (cdn-usa2.aglty.io) as an asset URL", () => {
// Regression check: the old `includes("cdn.aglty.io")` check missed cdn-usa2.aglty.io.
const result = validator.validateContentFields({
config: "https://cdn-usa2.aglty.io/brightstar-qa/mobile/config.json",
});
expect(result.totalErrors).toBe(0);
});

it("validates a *.agilitycms.com subdomain as an asset URL", () => {
const result = validator.validateContentFields({
hero: "https://cdndev.agilitycms.com/fcukahpf/posts/photo.jpg",
});
expect(result.totalErrors).toBe(0);
});

it("recognizes a custom CDN URL by container-prefix match against sourceAssets", () => {
// Customer custom CDN host. The validator should derive a container prefix
// (host + first path segment) from the assets and accept URLs that share it.
const result = validator.validateContentFields(
{ config: "https://cdn.ilotteryservices.com/8f5ad099/mobile/config.json" },
{
sourceAssets: [{ url: "https://cdn.ilotteryservices.com/8f5ad099/mobile/other.json" }],
}
);
// Both URLs share the prefix → no warning about asset-not-found.
expect(result.totalWarnings).toBe(0);
expect(result.totalErrors).toBe(0);
});

it("does not treat a non-asset URL as an asset URL", () => {
// String is a URL but no Agility domain and no sourceAsset prefix match.
const result = validator.validateContentFields(
{ link: "https://www.example.com/article/123" },
{ sourceAssets: [{ url: "https://cdn.ilotteryservices.com/8f5ad099/file.json" }] }
);
expect(result.totalErrors).toBe(0);
expect(result.totalWarnings).toBe(0);
});
});

describe("content ID string fields", () => {
Expand Down
20 changes: 20 additions & 0 deletions src/lib/mappers/asset-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ export class AssetMapper {
);
}

/**
* Returns true if the given URL starts with any container edge/origin URL
* known to the mapper (source or target). Lets callers identify asset URLs
* without hardcoding CDN domains — supports custom CDN hosts.
*/
isKnownAssetUrl(url: string): boolean {
if (!url || typeof url !== "string") return false;

return this.mappings.some((mapping) => {
const knownContainerUrls = [
mapping.sourceContainerEdgeUrl,
mapping.sourceContainerOriginUrl,
mapping.targetContainerEdgeUrl,
mapping.targetContainerOriginUrl,
].filter((containerUrl): containerUrl is string => typeof containerUrl === "string");

return knownContainerUrls.some((containerUrl) => url.startsWith(containerUrl));
});
}

getMappedEntity(mapping: AssetMapping, type: "source" | "target"): mgmtApi.Media | null {
const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid;
const mediaID = type === "source" ? mapping.sourceMediaID : mapping.targetMediaID;
Expand Down
119 changes: 119 additions & 0 deletions src/lib/mappers/tests/asset-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,125 @@ describe("AssetMapper.remapUrlByContainer", () => {
const result = mapper.remapUrlByContainer("https://completely-different.io/file.jpg", "source");
expect(result).toBeNull();
});

it("handles a customer using a custom CDN domain (cdn.ilotteryservices.com) end-to-end", () => {
// custom configured domain cdn host
const mapper = makeMapper();
const src = makeAsset({
mediaID: 1,
edgeUrl: "https://cdn.ilotteryservices.com/8f5ad099/mobile/configuration/wizard.json",
containerEdgeUrl: "https://cdn.ilotteryservices.com/8f5ad099",
containerOriginUrl: "https://origin.ilotteryservices.com/8f5ad099",
});
const tgt = makeAsset({
mediaID: 2,
edgeUrl: "https://cdn.ilotteryservices.com/0e9b1234/mobile/configuration/wizard.json",
containerEdgeUrl: "https://cdn.ilotteryservices.com/0e9b1234",
containerOriginUrl: "https://origin.ilotteryservices.com/0e9b1234",
});
mapper.addMapping(src, tgt);

// Edge URL swap
expect(
mapper.remapUrlByContainer("https://cdn.ilotteryservices.com/8f5ad099/draw-games/picks.json", "source")
).toBe("https://cdn.ilotteryservices.com/0e9b1234/draw-games/picks.json");

// Origin URL swap
expect(mapper.remapUrlByContainer("https://origin.ilotteryservices.com/8f5ad099/folder/asset.png", "source")).toBe(
"https://origin.ilotteryservices.com/0e9b1234/folder/asset.png"
);

// Detection: source-side URL is recognized
expect(mapper.isKnownAssetUrl("https://cdn.ilotteryservices.com/8f5ad099/anything/at/all.json")).toBe(true);
expect(mapper.isKnownAssetUrl("https://cdn.ilotteryservices.com/0e9b1234/anything/at/all.json")).toBe(true);

expect(mapper.isKnownAssetUrl("https://cdn.competitor.com/8f5ad099/file.jpg")).toBe(false);
expect(mapper.isKnownAssetUrl("https://cdn.ilotteryservices.com/zzzzzzzz/file.jpg")).toBe(false);
});
});

// ─── isKnownAssetUrl ──────────────────────────────────────────────────────────

describe("AssetMapper.isKnownAssetUrl", () => {
it("returns false for empty or non-string input", () => {
const mapper = makeMapper();
expect(mapper.isKnownAssetUrl("")).toBe(false);
expect(mapper.isKnownAssetUrl(null as any)).toBe(false);
expect(mapper.isKnownAssetUrl(undefined as any)).toBe(false);
expect(mapper.isKnownAssetUrl(123 as any)).toBe(false);
});

it("returns false when no mappings exist", () => {
const mapper = makeMapper();
expect(mapper.isKnownAssetUrl("https://cdn.aglty.io/anywhere/file.jpg")).toBe(false);
});

it("returns true when URL starts with a source container edge URL", () => {
const mapper = makeMapper();
const src = makeAsset({ mediaID: 1, containerEdgeUrl: "https://cdn-usa2.aglty.io/brightstar-qa" });
const tgt = makeAsset({ mediaID: 2, containerEdgeUrl: "https://cdn-usa2.aglty.io/brightstar-prod" });
mapper.addMapping(src, tgt);
expect(mapper.isKnownAssetUrl("https://cdn-usa2.aglty.io/brightstar-qa/mobile/file.json")).toBe(true);
});

it("returns true when URL starts with a source container origin URL", () => {
const mapper = makeMapper();
const src = makeAsset({ mediaID: 1, containerOriginUrl: "https://mediadev.agilitycms.com/aaaaaaaa" });
const tgt = makeAsset({ mediaID: 2, containerOriginUrl: "https://mediadev.agilitycms.com/bbbbbbbb" });
mapper.addMapping(src, tgt);
expect(mapper.isKnownAssetUrl("https://mediadev.agilitycms.com/aaaaaaaa/folder/asset.png")).toBe(true);
});

it("returns true when URL starts with a target container URL", () => {
const mapper = makeMapper();
const src = makeAsset({ mediaID: 1, containerEdgeUrl: "https://cdn.aglty.io/src" });
const tgt = makeAsset({ mediaID: 2, containerEdgeUrl: "https://cdn.aglty.io/tgt" });
mapper.addMapping(src, tgt);
expect(mapper.isKnownAssetUrl("https://cdn.aglty.io/tgt/some/path.jpg")).toBe(true);
});

it("recognizes URLs on a custom CDN host (PROD-1505 scenario)", () => {
// Customer using a custom CDN domain — the container URL the CMS returns
// points to their host, not aglty.io. isKnownAssetUrl should still match.
const mapper = makeMapper();
const src = makeAsset({
mediaID: 1,
containerEdgeUrl: "https://cdn.ilotteryservices.com/8f5ad099",
containerOriginUrl: "https://origin.ilotteryservices.com/8f5ad099",
});
const tgt = makeAsset({
mediaID: 2,
containerEdgeUrl: "https://cdn.ilotteryservices.com/0e9b1234",
containerOriginUrl: "https://origin.ilotteryservices.com/0e9b1234",
});
mapper.addMapping(src, tgt);
expect(mapper.isKnownAssetUrl("https://cdn.ilotteryservices.com/8f5ad099/mobile/config.json")).toBe(true);
expect(mapper.isKnownAssetUrl("https://origin.ilotteryservices.com/8f5ad099/folder/file.png")).toBe(true);
});

it("returns false for a URL that does not match any container URL", () => {
const mapper = makeMapper();
const src = makeAsset({ mediaID: 1, containerEdgeUrl: "https://cdn.aglty.io/src" });
const tgt = makeAsset({ mediaID: 2, containerEdgeUrl: "https://cdn.aglty.io/tgt" });
mapper.addMapping(src, tgt);
expect(mapper.isKnownAssetUrl("https://unrelated.example.com/file.jpg")).toBe(false);
});

it("ignores mappings whose container URLs are undefined", () => {
const mapper = makeMapper();
const src = makeAsset({
mediaID: 1,
containerEdgeUrl: undefined as any,
containerOriginUrl: undefined as any,
});
const tgt = makeAsset({
mediaID: 2,
containerEdgeUrl: undefined as any,
containerOriginUrl: undefined as any,
});
mapper.addMapping(src, tgt);
expect(mapper.isKnownAssetUrl("https://cdn.aglty.io/anything.jpg")).toBe(false);
});
});

// ─── addMapping / updateMapping ───────────────────────────────────────────────
Expand Down
Loading