Source code
Revision control
Copy as Markdown
Other Tools
Test Info: Warnings
- This test runs only with pattern: appname != 'thunderbird'
- Manifest: toolkit/components/contextualidentity/tests/unit/xpcshell.toml
"use strict";
const profileDir = do_get_profile();
const { ContextualIdentityService } = ChromeUtils.importESModule(
"moz-src:///toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs"
);
const TOPIC = "contextual-identity-site-association-changed";
const MAX_USER_CONTEXT_ID = -1 >>> 0;
let gCounter = 0;
function freshService() {
let path = PathUtils.join(
profileDir.path,
`test-site-associations-${gCounter++}.json`
);
return {
cis: ContextualIdentityService.createNewInstanceForTesting(path),
path,
};
}
function recordNotifications(callback) {
let observed = [];
let observer = subject => observed.push(subject.wrappedJSObject);
Services.obs.addObserver(observer, TOPIC);
try {
callback();
} finally {
Services.obs.removeObserver(observer, TOPIC);
}
return observed;
}
// set / get / remove and their notifications.
add_task(function basic() {
let { cis } = freshService();
equal(cis.getSiteAssociation("example.com"), 0, "No association by default");
let added = recordNotifications(() =>
cis.setSiteAssociation("example.com", 2)
);
equal(cis.getSiteAssociation("example.com"), 2, "Association was set");
equal(added.length, 1, "One notification on set");
equal(added[0].site, "example.com", "Notified host matches");
equal(added[0].userContextId, 2, "Notified userContextId matches");
let noop = recordNotifications(() =>
cis.setSiteAssociation("example.com", 2)
);
equal(noop.length, 0, "Re-setting the same association is a no-op");
let removed = recordNotifications(() =>
cis.removeSiteAssociation("example.com")
);
equal(cis.getSiteAssociation("example.com"), 0, "Association was removed");
equal(removed.length, 1, "One notification on remove");
equal(removed[0].site, "example.com", "Notified host matches");
equal(removed[0].userContextId, null, "userContextId is null on removal");
let missing = recordNotifications(() =>
cis.removeSiteAssociation("example.com")
);
equal(missing.length, 0, "Removing a missing association notifies nothing");
});
// A host belongs to a single container: re-associating moves it.
add_task(function oneToOne() {
let { cis } = freshService();
cis.setSiteAssociation("example.com", 2);
cis.setSiteAssociation("example.com", 3);
equal(cis.getSiteAssociation("example.com"), 3, "Association moved to 3");
Assert.deepEqual(
cis.getSiteAssociations(2),
[],
"Container 2 no longer has the host"
);
Assert.deepEqual(
cis.getSiteAssociations(3),
[{ site: "example.com", userContextId: 3 }],
"Container 3 owns the host"
);
});
// Hosts are normalized: lowercased and IDN-encoded.
add_task(function normalization() {
let { cis } = freshService();
cis.setSiteAssociation("EXAMPLE.COM", 2);
equal(cis.getSiteAssociation("example.com"), 2, "Case is normalized");
cis.setSiteAssociation("bücher.example", 3);
let stored = cis.getSiteAssociations(3);
equal(stored.length, 1, "IDN host stored");
ok(stored[0].site.includes("xn--"), "IDN host stored as punycode");
equal(
cis.getSiteAssociation("BÜCHER.example"),
3,
"IDN lookup matches regardless of case"
);
Assert.throws(
() => cis.setSiteAssociation("", 2),
/Invalid site/,
"Empty site is rejected"
);
Assert.throws(
() => cis.setSiteAssociation(" example.com ", 2),
/Invalid site/,
"Site with whitespace is rejected"
);
equal(
cis.getSiteAssociation("example .com"),
0,
"Lookup of a site with whitespace does not match"
);
equal(
recordNotifications(() => cis.removeSiteAssociation(" example.com "))
.length,
0,
"Removal of a site with whitespace is a no-op"
);
for (let invalid of [
"example.com:8080",
"example.com/path",
"user@example.com",
"exa%mple.com",
"[::1]",
"*",
"*.example.com",
]) {
Assert.throws(
() => cis.setSiteAssociation(invalid, 2),
/Invalid site/,
`${invalid} is not a valid host`
);
equal(
cis.getSiteAssociation(invalid),
0,
`Lookup of ${invalid} does not match`
);
equal(
recordNotifications(() => cis.removeSiteAssociation(invalid)).length,
0,
`Removal of ${invalid} is a no-op`
);
equal(cis.normalizeSite(invalid), null, `normalizeSite rejects ${invalid}`);
}
equal(
cis.normalizeSite("EXAMPLE.com"),
"example.com",
"normalizeSite lowercases a host"
);
equal(
cis.normalizeSite("BÜCHER.example"),
"xn--bcher-kva.example",
"normalizeSite encodes an IDN host"
);
});
// Associating to an unknown or non-public container is rejected.
add_task(function unknownContainer() {
let { cis } = freshService();
Assert.throws(
() => cis.setSiteAssociation("example.com", 99999),
/unknown container/,
"Unknown container is rejected"
);
equal(
cis.getSiteAssociation("example.com"),
0,
"Nothing was stored for the rejected call"
);
let thumbnailId = cis.getPrivateIdentity(
"userContextIdInternal.thumbnail"
).userContextId;
for (let internalId of [MAX_USER_CONTEXT_ID, thumbnailId]) {
Assert.throws(
() => cis.setSiteAssociation("example.com", internalId),
/unknown container/,
`Internal container ${internalId} is rejected`
);
equal(
cis.getSiteAssociation("example.com"),
0,
`Nothing was stored for internal container ${internalId}`
);
}
for (let invalid of ["2", 2.5, NaN, null, undefined]) {
Assert.throws(
() => cis.setSiteAssociation("example.com", invalid),
/Invalid container id/,
`${invalid} is not a valid container id`
);
equal(
cis.getSiteAssociation("example.com"),
0,
`Nothing was stored for container id ${invalid}`
);
}
});
// getSiteAssociations with and without a filter.
add_task(function query() {
let { cis } = freshService();
cis.setSiteAssociation("a.example", 2);
cis.setSiteAssociation("b.example", 2);
cis.setSiteAssociation("c.example", 3);
equal(cis.getSiteAssociations().length, 3, "All associations returned");
equal(
cis.getSiteAssociations(2).length,
2,
"Filtered associations for container 2"
);
equal(
cis.getSiteAssociations(3).length,
1,
"Filtered associations for container 3"
);
});
// Removing a container purges its associations and notifies once per host.
add_task(function purgeOnContainerRemoval() {
let { cis } = freshService();
cis.setSiteAssociation("a.example", 2);
cis.setSiteAssociation("b.example", 2);
cis.setSiteAssociation("c.example", 3);
let removed = recordNotifications(() => cis.remove(2));
equal(cis.getSiteAssociation("a.example"), 0, "a.example purged");
equal(cis.getSiteAssociation("b.example"), 0, "b.example purged");
equal(cis.getSiteAssociation("c.example"), 3, "Other container untouched");
equal(removed.length, 2, "One notification per purged host");
Assert.deepEqual(
removed.map(r => r.site).sort(),
["a.example", "b.example"],
"Purged hosts were notified"
);
ok(
removed.every(r => r.userContextId === null),
"Purge notifications carry a null userContextId"
);
});
// Associations survive a save/load round-trip via containers.json.
add_task(async function persistence() {
let { cis, path } = freshService();
cis.setSiteAssociation("example.com", 2);
cis.setSiteAssociation("work.example", 2);
await cis._saver.finalize();
let reloaded = ContextualIdentityService.createNewInstanceForTesting(path);
equal(reloaded.getSiteAssociation("example.com"), 2, "example.com reloaded");
equal(
reloaded.getSiteAssociation("work.example"),
2,
"work.example reloaded"
);
let raw = await IOUtils.readJSON(path);
Assert.deepEqual(
raw.siteAssociations,
{ "example.com": 2, "work.example": 2 },
"siteAssociations stored as a top-level host->userContextId map"
);
});
// A profile predating site associations loads with no associations.
add_task(async function backwardCompatibility() {
let path = PathUtils.join(
profileDir.path,
"test-site-associations-legacy.json"
);
await IOUtils.writeJSON(path, {
version: 5,
lastUserContextId: 4,
identities: [
{ userContextId: 2, public: true, icon: "briefcase", color: "orange" },
],
});
let cis = ContextualIdentityService.createNewInstanceForTesting(path);
Assert.deepEqual(
cis.getSiteAssociations(),
[],
"Legacy file without siteAssociations loads with no associations"
);
cis.setSiteAssociation("example.com", 2);
equal(
cis.getSiteAssociation("example.com"),
2,
"Associations can still be added to a legacy profile"
);
});
// The nsISiteContainerService XPCOM facet is backed by the same singleton.
add_task(function nativeInterface() {
let svc = Cc["@mozilla.org/site-container-service;1"].getService(
Ci.nsISiteContainerService
);
ContextualIdentityService.setSiteAssociation("native.example", 2);
equal(svc.lookup("native.example"), 2, "lookup reflects CIS state");
equal(svc.lookup("NATIVE.EXAMPLE"), 2, "lookup normalizes the host");
equal(svc.lookup("missing.example"), 0, "Unknown host returns 0");
equal(
svc.containerForNavigation(uri, 7),
2,
"containerForNavigation returns the association"
);
equal(
svc.containerForNavigation(other, 7),
7,
"containerForNavigation falls back to the baseline"
);
ContextualIdentityService.removeSiteAssociation("native.example");
equal(svc.lookup("native.example"), 0, "Association removed via CIS is gone");
});