Merge branch 'unstable' into security-fix-disable-dev-mode-6861215856535760775

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-08 13:26:56 -05:00
commit f99f3f5ea8
20 files changed
+2659 -1717

No files matched your search

+95
View File
@@ -305,3 +305,98 @@ await (async () => {
assert.ok(detail);
assert.equal(detail.pubkey, SAMPLE_PUBKEY);
})();
// === Tests for loadOwnProfile logic ===
await (async () => {
resetState();
const PUBKEY = SAMPLE_PUBKEY;
// Test Fast Relay Success
{
const mockNostrClient = {
relays: ["wss://fast1.com", "wss://fast2.com", "wss://slow.com"],
pool: {
list: async (relays) => {
const url = relays[0];
if (url === "wss://fast1.com") {
return [{
pubkey: PUBKEY,
created_at: 100,
content: JSON.stringify({ name: "Fast User" })
}];
}
return new Promise(() => {}); // Hang others
}
},
handleEvent: () => {}
};
const service = new AuthService({ nostrClient: mockNostrClient });
// Override setProfileCacheEntry to avoid side effects or errors from real implementation if not needed,
// but here we just let it run. However, the service needs nostrClient.pool
// We need to make sure setProfileCacheEntry doesn't fail.
// It imports from cache.js which uses profileCache.
// profileCache is imported from state/profileCache.js.
// The previous tests didn't seem to mock setProfileCacheEntry.
const profile = await service.loadOwnProfile(PUBKEY);
assert.equal(profile.name, "Fast User");
}
// Test All Relays Fail
{
const mockNostrClient = {
relays: ["wss://fail1.com", "wss://fail2.com"],
pool: {
list: async () => {
throw new Error("Failed");
}
},
handleEvent: () => {}
};
const service = new AuthService({ nostrClient: mockNostrClient });
const profile = await service.loadOwnProfile(PUBKEY);
assert.equal(profile.name, "Unknown");
}
// Test Timeout Fallback
{
let backgroundResolve;
const backgroundPromise = new Promise(r => backgroundResolve = r);
const mockNostrClient = {
relays: ["wss://fast-fail.com", "wss://fast-fail2.com", "wss://background-ok.com", "wss://background-ok-2.com"],
pool: {
list: async (relays) => {
const url = relays[0];
if (url.includes("fast")) {
// Simulate timeout or error
throw new Error("Fast fail");
}
if (url.includes("background")) {
await backgroundPromise;
return [{
pubkey: PUBKEY,
created_at: 200,
content: JSON.stringify({ name: "Background User" })
}];
}
return [];
}
},
handleEvent: () => {}
};
const service = new AuthService({ nostrClient: mockNostrClient });
const profile = await service.loadOwnProfile(PUBKEY);
assert.equal(profile.name, "Unknown");
// We resolve background promise to let background logic finish eventually (though test finishes here)
backgroundResolve();
}
})();
+73
View File
@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createKidsScorerStage } from "../../js/feedEngine/kidsScoring.js";
test("kids scorer correctly identifies dominant positive component", async () => {
const stage = createKidsScorerStage({
weights: {
w_age: 0.2,
w_edu: 0.2,
w_author: 0.2,
w_pop: 0.2,
w_fresh: 0.2,
w_risk: 0
}
});
// Create an item where one component is clearly dominant
// Age appropriateness: 1.0 (perfect duration + tags)
// Others: 0
const items = [
{
video: {
id: "v1",
pubkey: "pk1",
duration: 300, // 5 min (perfect for toddler/preschool)
tags: [["t", "preschool"], ["t", "learning"]], // Matches preferred
created_at: Date.now() / 1000 - 10000000, // Old
viewCount: 0
},
metadata: {},
},
];
const why = [];
const context = {
runtime: {
ageGroup: "preschool",
trustedAuthors: []
},
addWhy: (detail) => why.push(detail)
};
await stage(items, context);
assert.equal(why.length, 1);
assert.equal(why[0].reason, "age-appropriateness");
assert.ok(why[0].value > 0);
});
test("kids scorer picks educational-boost when dominant", async () => {
const stage = createKidsScorerStage({
educationalTags: ["math"],
weights: { w_age: 0, w_edu: 1, w_author: 0, w_pop: 0, w_fresh: 0, w_risk: 0 }
});
const items = [{
video: {
id: "v2",
tags: [["t", "math"]],
duration: 300
},
metadata: {}
}];
const why = [];
await stage(items, {
runtime: { educationalTags: ["math"] },
addWhy: (d) => why.push(d)
});
assert.equal(why.length, 1);
assert.equal(why[0].reason, "educational-boost");
});
+47
View File
@@ -165,6 +165,53 @@ describe("NostrClient", () => {
});
});
describe("fetchVideos", () => {
it("should delegate to subscribeVideos and return result on eose", async () => {
let subOpts = null;
// Override subscribeVideos to verify delegation
client.subscribeVideos = (cb, opts) => {
subOpts = opts;
return {
on: (evt, handler) => {
if (evt === "eose") setTimeout(handler, 0);
},
unsub: () => {},
};
};
// Mock helpers
client.getActiveVideos = () => [{ id: "v1" }];
client.populateNip71MetadataForVideos = async () => {};
client.applyRootCreatedAt = () => {};
const result = await client.fetchVideos({ limit: 5 });
assert.equal(result[0].id, "v1");
assert.equal(subOpts.limit, 5);
// Ensure we force full fetch by default
assert.equal(subOpts.since, 0);
});
it("should respect since parameter if provided", async () => {
let subOpts = null;
client.subscribeVideos = (cb, opts) => {
subOpts = opts;
return {
on: (evt, handler) => {
if (evt === "eose") setTimeout(handler, 0);
},
unsub: () => {},
};
};
client.getActiveVideos = () => [];
client.populateNip71MetadataForVideos = async () => {};
client.applyRootCreatedAt = () => {};
await client.fetchVideos({ since: 12345 });
assert.equal(subOpts.since, 12345);
});
});
describe("publishVideo", () => {
it("should throw if not logged in", async () => {
await assert.rejects(
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, beforeEach, mock } from "node:test";
import assert from "node:assert/strict";
import { Nip46Connector } from "../../js/nostr/nip46Connector.js";
const VALID_HEX_KEY = "0000000000000000000000000000000000000000000000000000000000000001";
const VALID_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000002";
// Mock dependencies
const mockDeps = {
parseNip46ConnectionString: mock.fn(),
generateNip46Secret: mock.fn(),
readStoredNip46Session: mock.fn(),
writeStoredNip46Session: mock.fn(),
clearStoredNip46Session: mock.fn(),
normalizeNip46EncryptionAlgorithm: mock.fn(),
decryptNip46Session: mock.fn(),
attemptDecryptNip46HandshakePayload: mock.fn(),
sanitizeNip46Metadata: mock.fn((m) => m || {}),
resolveNip46Relays: mock.fn((r) => r || []),
normalizeNostrPubkey: mock.fn((k) => k),
encodeHexToNpub: mock.fn((k) => "npub" + k),
bytesToHex: mock.fn((b) => VALID_HEX_KEY),
signEventWithPrivateKey: mock.fn(),
Nip46RpcClient: class MockRpcClient {
constructor() {
this.destroy = mock.fn();
this.ensureSubscription = mock.fn(() => Promise.resolve());
this.connect = mock.fn(() => Promise.resolve());
this.getUserPubkey = mock.fn(() => Promise.resolve(VALID_PUBKEY));
this.metadata = {};
}
},
ensureNostrTools: mock.fn(async () => ({
generateSecretKey: () => new Uint8Array(32),
getPublicKey: () => VALID_PUBKEY,
})),
getCachedNostrTools: mock.fn(() => ({
generateSecretKey: () => new Uint8Array(32),
getPublicKey: () => VALID_PUBKEY,
})),
NIP46_RPC_KIND: 24133,
NIP46_HANDSHAKE_TIMEOUT_MS: 100,
NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS: 3,
};
describe("Nip46Connector", () => {
let connector;
let mockNostrClient;
beforeEach(() => {
// Reset mocks
Object.values(mockDeps).forEach((m) => {
if (m.mock) m.mock.resetCalls();
});
mockNostrClient = {
pool: {
publish: mock.fn(() => Promise.resolve()),
sub: mock.fn(() => ({
on: mock.fn(),
unsub: mock.fn(),
})),
},
ensurePool: mock.fn(async () => mockNostrClient.pool),
connectToRelays: mock.fn(async () => []),
emitRemoteSignerChange: mock.fn(),
relays: ["wss://relay.example.com"],
};
connector = new Nip46Connector(mockNostrClient, mockDeps);
});
describe("createKeyPair", () => {
it("should generate a new key pair if none provided", async () => {
mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "mock-secret");
const result = await connector.createKeyPair();
assert.deepEqual(result, {
privateKey: VALID_HEX_KEY,
publicKey: VALID_PUBKEY,
});
});
it("should use existing keys if provided", async () => {
const result = await connector.createKeyPair(
VALID_HEX_KEY,
VALID_PUBKEY
);
assert.deepEqual(result, {
privateKey: VALID_HEX_KEY,
publicKey: VALID_PUBKEY,
});
});
});
describe("prepareHandshake", () => {
it("should parse connection string and return details", async () => {
mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "handshake-secret");
const result = await connector.prepareHandshake({
connectionString: "bunker://...",
});
assert.equal(result.clientPrivateKey, VALID_HEX_KEY);
assert.equal(result.secret, "handshake-secret");
assert.match(result.connectionString, /^nostrconnect:\/\//);
});
});
describe("connect", () => {
it("should connect directly if remote pubkey is known", async () => {
const options = {
connectionString: "bunker://remote-pub?relay=wss://relay.com",
clientPrivateKey: VALID_HEX_KEY,
};
mockDeps.parseNip46ConnectionString.mock.mockImplementationOnce(() => ({
relays: ["wss://relay.com"],
remotePubkey: VALID_PUBKEY,
type: "bunker",
}));
mockDeps.normalizeNip46EncryptionAlgorithm.mock.mockImplementationOnce(() => "nip44");
mockDeps.normalizeNostrPubkey.mock.mockImplementation((k) => k);
const result = await connector.connect(options);
assert.equal(result.pubkey, VALID_PUBKEY);
assert.ok(result.client instanceof mockDeps.Nip46RpcClient);
assert.equal(mockDeps.writeStoredNip46Session.mock.callCount(), 1);
});
});
describe("reconnectStored", () => {
it("should throw if no stored session", async () => {
mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => null);
await assert.rejects(connector.reconnectStored(), /No remote signer session is stored/);
});
it("should reconnect using stored session", async () => {
mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => ({
userPubkey: "user-pub",
remotePubkey: "remote-pub",
relays: [],
clientPrivateKey: "client-priv",
secret: "secret",
}));
mockDeps.decryptNip46Session.mock.mockImplementationOnce((s) => s);
const result = await connector.reconnectStored();
assert.equal(result.pubkey, VALID_PUBKEY);
assert.ok(result.client instanceof mockDeps.Nip46RpcClient);
});
});
describe("disconnect", () => {
it("should clear stored session and emit change", async () => {
await connector.disconnect({ keepStored: false });
assert.equal(mockDeps.clearStoredNip46Session.mock.callCount(), 1);
});
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
normalizeHexString,
normalizeHexId,
normalizeHexPubkey,
HEX64_REGEX,
} from "../../js/utils/hex.js";
describe("hex utils", () => {
describe("normalizeHexString", () => {
it("should return empty string for non-string inputs", () => {
assert.equal(normalizeHexString(undefined), "");
assert.equal(normalizeHexString(null), "");
assert.equal(normalizeHexString(123), "");
assert.equal(normalizeHexString({}), "");
});
it("should return empty string for empty or whitespace-only strings", () => {
assert.equal(normalizeHexString(""), "");
assert.equal(normalizeHexString(" "), "");
});
it("should trim and lowercase valid hex strings", () => {
assert.equal(normalizeHexString("ABC"), "abc");
assert.equal(normalizeHexString(" DEF "), "def");
assert.equal(normalizeHexString("AbCdEf"), "abcdef");
});
});
describe("aliases", () => {
it("should export normalizeHexId as an alias", () => {
assert.equal(normalizeHexId, normalizeHexString);
});
it("should export normalizeHexPubkey as an alias", () => {
assert.equal(normalizeHexPubkey, normalizeHexString);
});
});
describe("HEX64_REGEX", () => {
it("should match valid 64-character hex strings", () => {
const validHex = "a".repeat(64);
assert.match(validHex, HEX64_REGEX);
const mixedCase = "A".repeat(32) + "b".repeat(32);
assert.match(mixedCase, HEX64_REGEX);
});
it("should not match strings with incorrect length", () => {
assert.doesNotMatch("a".repeat(63), HEX64_REGEX);
assert.doesNotMatch("a".repeat(65), HEX64_REGEX);
});
it("should not match strings with non-hex characters", () => {
const invalidChar = "a".repeat(63) + "g";
assert.doesNotMatch(invalidChar, HEX64_REGEX);
});
it("should not match empty strings", () => {
assert.doesNotMatch("", HEX64_REGEX);
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import { sanitizeProfileMediaUrl } from "../../js/utils/profileMedia.js";
test("sanitizeProfileMediaUrl handles non-string inputs", () => {
assert.equal(sanitizeProfileMediaUrl(null), "");
assert.equal(sanitizeProfileMediaUrl(undefined), "");
assert.equal(sanitizeProfileMediaUrl(123), "");
assert.equal(sanitizeProfileMediaUrl({}), "");
});
test("sanitizeProfileMediaUrl handles empty or whitespace-only strings", () => {
assert.equal(sanitizeProfileMediaUrl(""), "");
assert.equal(sanitizeProfileMediaUrl(" "), "");
assert.equal(sanitizeProfileMediaUrl("\t\n"), "");
});
test("sanitizeProfileMediaUrl trims whitespace and removes quotes", () => {
assert.equal(sanitizeProfileMediaUrl(" http://example.com "), "https://example.com");
assert.equal(sanitizeProfileMediaUrl("'http://example.com'"), "https://example.com");
assert.equal(sanitizeProfileMediaUrl('"http://example.com"'), "https://example.com");
assert.equal(sanitizeProfileMediaUrl(" ' http://example.com ' "), "https://example.com");
});
test("sanitizeProfileMediaUrl allows data:image/ URLs", () => {
const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
assert.equal(sanitizeProfileMediaUrl(dataUrl), dataUrl);
});
test("sanitizeProfileMediaUrl allows blob: URLs", () => {
const blobUrl = "blob:http://example.com/550e8400-e29b-41d4-a716-446655440000";
assert.equal(sanitizeProfileMediaUrl(blobUrl), blobUrl);
});
test("sanitizeProfileMediaUrl rejects specific placeholder images", () => {
assert.equal(sanitizeProfileMediaUrl("images/robot.png"), "");
});
test("sanitizeProfileMediaUrl normalizes IPFS URLs", () => {
assert.equal(sanitizeProfileMediaUrl("ipfs://CID"), "https://ipfs.io/ipfs/CID");
assert.equal(sanitizeProfileMediaUrl("ipfs/CID"), "https://ipfs.io/ipfs/CID");
});
test("sanitizeProfileMediaUrl handles protocol-relative URLs", () => {
assert.equal(sanitizeProfileMediaUrl("//example.com/image.jpg"), "https://example.com/image.jpg");
});
test("sanitizeProfileMediaUrl allows relative paths", () => {
assert.equal(sanitizeProfileMediaUrl("/assets/image.jpg"), "/assets/image.jpg");
assert.equal(sanitizeProfileMediaUrl("./image.jpg"), "./image.jpg");
assert.equal(sanitizeProfileMediaUrl("../image.jpg"), "../image.jpg");
assert.equal(sanitizeProfileMediaUrl("assets/image.jpg"), "assets/image.jpg");
});
test("sanitizeProfileMediaUrl adds protocol to domains and localhost", () => {
assert.equal(sanitizeProfileMediaUrl("localhost:8080/image.jpg"), "http://localhost:8080/image.jpg");
assert.equal(sanitizeProfileMediaUrl("example.com/image.jpg"), "https://example.com/image.jpg");
assert.equal(sanitizeProfileMediaUrl("sub.example.com/image.jpg"), "https://sub.example.com/image.jpg");
// IP address pattern in code: (?:\d{1,3}\.){3}\d{1,3}
assert.equal(sanitizeProfileMediaUrl("127.0.0.1:8080/image.jpg"), "http://127.0.0.1:8080/image.jpg");
});
test("sanitizeProfileMediaUrl coerces http to https except for localhost", () => {
assert.equal(sanitizeProfileMediaUrl("http://example.com/image.jpg"), "https://example.com/image.jpg");
assert.equal(sanitizeProfileMediaUrl("http://localhost:8080/image.jpg"), "http://localhost:8080/image.jpg");
assert.equal(sanitizeProfileMediaUrl("http://127.0.0.1:8080/image.jpg"), "http://127.0.0.1:8080/image.jpg");
});
test("sanitizeProfileMediaUrl rejects unsupported patterns", () => {
// The code logs a warning and returns "" for things that don't match localhost or domain patterns
// if they don't start with http/https
assert.equal(sanitizeProfileMediaUrl("javascript:alert(1)"), "");
assert.equal(sanitizeProfileMediaUrl("ftp://example.com/file"), ""); // Starts with ftp, not http/https/ipfs
});
+117 -3
View File
@@ -3,13 +3,22 @@ import { test } from "node:test";
import { validateInstanceConfig } from "../config/validate-config.js";
// Common valid overrides to use as a base
const VALID_OVERRIDES = {
ADMIN_SUPER_NPUB: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
PLATFORM_FEE_PERCENT: 5,
PLATFORM_LUD16_OVERRIDE: "[email protected]",
};
test("validateInstanceConfig succeeds with valid configuration", () => {
assert.doesNotThrow(() => validateInstanceConfig(VALID_OVERRIDES));
});
test(
"validateInstanceConfig throws when platform fee is positive without a platform override",
() => {
const overrides = {
ADMIN_SUPER_NPUB:
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
PLATFORM_FEE_PERCENT: 5,
...VALID_OVERRIDES,
PLATFORM_LUD16_OVERRIDE: " ",
};
@@ -19,3 +28,108 @@ test(
});
}
);
test("validateInstanceConfig throws when ADMIN_SUPER_NPUB is empty", () => {
const overrides = {
...VALID_OVERRIDES,
ADMIN_SUPER_NPUB: " ",
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /ADMIN_SUPER_NPUB is required/,
});
});
test("validateInstanceConfig throws when ADMIN_SUPER_NPUB does not start with npub", () => {
const overrides = {
...VALID_OVERRIDES,
ADMIN_SUPER_NPUB: "invalid_npub",
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /ADMIN_SUPER_NPUB should be a bech32 npub/,
});
});
test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is negative", () => {
const overrides = {
...VALID_OVERRIDES,
PLATFORM_FEE_PERCENT: -1,
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive/,
});
});
test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is greater than 100", () => {
const overrides = {
...VALID_OVERRIDES,
PLATFORM_FEE_PERCENT: 101,
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive/,
});
});
test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is not finite", () => {
const overrides = {
...VALID_OVERRIDES,
PLATFORM_FEE_PERCENT: Infinity,
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /PLATFORM_FEE_PERCENT must be a finite number/,
});
});
test("validateInstanceConfig throws when THEME_ACCENT_OVERRIDES has invalid hex color", () => {
const overrides = {
...VALID_OVERRIDES,
THEME_ACCENT_OVERRIDES: {
light: {
accent: "invalid-hex",
},
},
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /must be a #RRGGBB hex color/,
});
});
test("validateInstanceConfig throws when THEME_ACCENT_OVERRIDES structure is invalid", () => {
const overrides = {
...VALID_OVERRIDES,
THEME_ACCENT_OVERRIDES: {
light: "not-an-object",
},
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /must be an object with accent overrides/,
});
});
test("validateInstanceConfig throws when optional URL is invalid", () => {
const overrides = {
...VALID_OVERRIDES,
BLOG_URL: "not-a-valid-url",
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /BLOG_URL must be a valid http\(s\) URL/,
});
});
test("validateInstanceConfig throws when optional URL has invalid protocol", () => {
const overrides = {
...VALID_OVERRIDES,
BLOG_URL: "ftp://example.com",
};
assert.throws(() => validateInstanceConfig(overrides), {
message: /BLOG_URL must be a valid http\(s\) URL/,
});
});