Skip to content
Open
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
12 changes: 12 additions & 0 deletions packages/dappfence/src/core/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ export const VERIFICATION_STATUS = Object.freeze({

/** @typedef {typeof VERIFICATION_STATUS[keyof typeof VERIFICATION_STATUS]} VerificationStatus */

/**
* The subset of status descriptions that actually reach the warning page.
* `recordSecurityViolation` short-circuits non-violation verdicts before
* recording a block, so MATCH / SKIPPED / REWRITE / CSP_PROTECTED /
* REDIRECT never appear on stored blocks. Dispatch tables that only
* handle violating cases key off this type.
* Hand-maintained because `verdict()` returns `isViolation: boolean` (not
* a literal), so tsc can't filter the enum by that flag automatically —
* if a new violating status is added to VERIFICATION_STATUS, extend here.
* @typedef {'FILE_UNTRUSTED' | 'DENIED_BY_RULE' | 'ERROR' | 'MANIFEST_UNTRUSTED'} ViolatingStatusName
*/

/**
* @typedef {'NULL_RESPONSE'
* | 'BODY_UNREADABLE'
Expand Down
17 changes: 15 additions & 2 deletions packages/dappfence/src/sw/__tests__/api-handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ function createMockAppStore() {
manifest: { files: { '/app.js': 'hash1' } },
}),
},
activeIdentityStore: {
getActiveIdentity: vi.fn().mockResolvedValue(undefined),
updateActiveIdentity: vi.fn().mockResolvedValue(undefined),
},
verificationResultsStore: {
get: vi.fn().mockResolvedValue([{ file: '/app.js', status: 'match' }]),
},
Expand Down Expand Up @@ -60,7 +64,14 @@ describe('createApiHandler', () => {
beforeEach(() => {
appStore = createMockAppStore();
onSecurityViolation = vi.fn();
handler = createApiHandler({ onSecurityViolation, appStore });
handler = createApiHandler({
onSecurityViolation,
appStore,
config: {
manifestSignatureType: 'ethereum-personal-sign',
manifestSignatureIdentity: '0xtest',
},
});
});

describe('authentication', () => {
Expand Down Expand Up @@ -263,7 +274,9 @@ describe('createApiHandler', () => {
const { activeBlocks } = decodeInlinedConfig(await res.text());

expect(activeBlocks[0].expectedHashes).toEqual([]);
expect(activeBlocks[0].actualHash).toBe('N/A');
// actualHash is null when unset; the template renders 'Not available'
// only for views that opt into the actual row.
expect(activeBlocks[0].actualHash).toBeNull();
expect(activeBlocks[0].occurrenceCount).toBe(1);
});

Expand Down
9 changes: 7 additions & 2 deletions packages/dappfence/src/sw/__tests__/manifest-loader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,17 @@ function makeSwContext({
};
}

function makeAppStore({ addLatestResult = MANIFEST_INFO } = {}) {
function makeAppStore({ addLatestResult = MANIFEST_INFO, activeIdentity } = {}) {
return {
trustedManifestStore: {
addLatest: vi.fn(() => Promise.resolve(addLatestResult)),
getLatest: vi.fn(() => Promise.resolve(undefined)),
getAll: vi.fn(() => Promise.resolve([])),
},
activeIdentityStore: {
getActiveIdentity: vi.fn(() => Promise.resolve(activeIdentity)),
updateActiveIdentity: vi.fn((identity) => Promise.resolve(identity)),
},
};
}

Expand Down Expand Up @@ -172,7 +176,8 @@ describe('fetchAndStoreManifest', () => {
expect(result.status).toBe(VERIFICATION_STATUS.MATCH);
expect(result.appVersion).toBe('v-ok');
expect(appStore.trustedManifestStore.addLatest).toHaveBeenCalledWith(
normalizeManifestData(VALID_PAYLOAD)
normalizeManifestData(VALID_PAYLOAD),
{ signatureType: 'secp256k1', identity: '0xABCDEF' }
);
});
});
Expand Down
217 changes: 181 additions & 36 deletions packages/dappfence/src/sw/__tests__/manifest-store.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createManifestStore } from '../storage/manifest-store.js';

const MOCK_SIGNER = { signatureType: 'ethereum-personal-sign', identity: '0xmocksigner' };

function createInMemoryStorage() {
const store = new Map();
return {
Expand Down Expand Up @@ -34,23 +36,29 @@ describe('createManifestStore', () => {
});

it('addLatest synthesizes a deterministic appVersion from manifest content', async () => {
const { appVersion } = await storage.trustedManifestStore.addLatest({
files: { '/a.js': 'h' },
});
const { appVersion } = await storage.trustedManifestStore.addLatest(
{ files: { '/a.js': 'h' } },
MOCK_SIGNER
);
expect(appVersion).toMatch(/^manifest-[A-Za-z0-9+/]{16}$/);
const dup = await storage.trustedManifestStore.addLatest({
files: { '/a.js': 'h' },
});
const dup = await storage.trustedManifestStore.addLatest(
{ files: { '/a.js': 'h' } },
MOCK_SIGNER
);
expect(dup.appVersion).toBe(appVersion);
const other = await storage.trustedManifestStore.addLatest({
files: { '/b.js': 'h2' },
});
const other = await storage.trustedManifestStore.addLatest(
{ files: { '/b.js': 'h2' } },
MOCK_SIGNER
);
expect(other.appVersion).not.toBe(appVersion);
});

it('addLatest stores a manifest retrievable by appVersion and via getLatest', async () => {
const manifestData = { files: { '/app.js': 'abc123', '/style.css': 'def456' } };
const { appVersion } = await storage.trustedManifestStore.addLatest(manifestData);
const { appVersion } = await storage.trustedManifestStore.addLatest(
manifestData,
MOCK_SIGNER
);

expect(await storage.trustedManifestStore.get(appVersion)).toEqual(manifestData);
expect(await storage.trustedManifestStore.getLatest()).toEqual(
Expand All @@ -68,29 +76,85 @@ describe('createManifestStore', () => {
metadata: { extensions: ['.js', '.wasm'] },
customField: { future: true },
};
const { appVersion } = await storage.trustedManifestStore.addLatest(manifestData);
const { appVersion } = await storage.trustedManifestStore.addLatest(
manifestData,
MOCK_SIGNER
);

expect(await storage.trustedManifestStore.get(appVersion)).toEqual(manifestData);
expect((await storage.trustedManifestStore.getLatest()).manifest).toEqual(manifestData);
});

it('getLatest returns the most recently added manifest', async () => {
await storage.trustedManifestStore.addLatest({ files: { '/a.js': 'x' } });
const second = await storage.trustedManifestStore.addLatest({
files: { '/b.js': 'y' },
});
await storage.trustedManifestStore.addLatest({ files: { '/a.js': 'x' } }, MOCK_SIGNER);
const second = await storage.trustedManifestStore.addLatest(
{ files: { '/b.js': 'y' } },
MOCK_SIGNER
);

const latest = await storage.trustedManifestStore.getLatest();
expect(latest.appVersion).toBe(second.appVersion);
expect(latest.manifest).toEqual({ files: { '/b.js': 'y' } });
});

describe('revokeManifests flag', () => {
it('replaces the entire list with just the new entry when flag is true', async () => {
await storage.trustedManifestStore.addLatest(
{ files: { '/old1.js': 'a' } },
MOCK_SIGNER
);
await storage.trustedManifestStore.addLatest(
{ files: { '/old2.js': 'b' } },
MOCK_SIGNER
);
expect(await storage.trustedManifestStore.getAll()).toHaveLength(2);

const { appVersion } = await storage.trustedManifestStore.addLatest(
{ files: { '/new.js': 'c' }, revokeManifests: true },
MOCK_SIGNER
);
const all = await storage.trustedManifestStore.getAll();
expect(all).toHaveLength(1);
expect(all[0].appVersion).toBe(appVersion);
expect(all[0].manifest).toEqual({
files: { '/new.js': 'c' },
revokeManifests: true,
});
});

it('still prepends normally when flag is absent or falsy', async () => {
await storage.trustedManifestStore.addLatest(
{ files: { '/old.js': 'a' } },
MOCK_SIGNER
);
await storage.trustedManifestStore.addLatest(
{ files: { '/new.js': 'b' }, revokeManifests: false },
MOCK_SIGNER
);
expect(await storage.trustedManifestStore.getAll()).toHaveLength(2);
});

it('updates the in-memory cache after a revoke', async () => {
await storage.trustedManifestStore.addLatest(
{ files: { '/old.js': 'a' } },
MOCK_SIGNER
);
await storage.trustedManifestStore.addLatest(
{ files: { '/new.js': 'b' }, revokeManifests: true },
MOCK_SIGNER
);
const latest = await storage.trustedManifestStore.getLatest();
expect(latest.manifest.files).toEqual({ '/new.js': 'b' });
});
});

it('addLatest caps entries at 20 (safety bound)', async () => {
const versions = [];
for (let i = 1; i <= 25; i++) {
const { appVersion } = await storage.trustedManifestStore.addLatest({
files: { [`/f${i}.js`]: `h${i}` },
});
const { appVersion } = await storage.trustedManifestStore.addLatest(
{ files: { [`/f${i}.js`]: `h${i}` } },
MOCK_SIGNER
);
versions.push(appVersion);
}
const all = await storage.trustedManifestStore.getAll();
Expand All @@ -103,11 +167,15 @@ describe('createManifestStore', () => {
});

it('re-adding an existing manifest dedups and promotes it to the front', async () => {
const a = await storage.trustedManifestStore.addLatest({ files: { '/a.js': 'x' } });
await storage.trustedManifestStore.addLatest({ files: { '/b.js': 'y' } });
const aAgain = await storage.trustedManifestStore.addLatest({
files: { '/a.js': 'x' },
});
const a = await storage.trustedManifestStore.addLatest(
{ files: { '/a.js': 'x' } },
MOCK_SIGNER
);
await storage.trustedManifestStore.addLatest({ files: { '/b.js': 'y' } }, MOCK_SIGNER);
const aAgain = await storage.trustedManifestStore.addLatest(
{ files: { '/a.js': 'x' } },
MOCK_SIGNER
);

expect(aAgain.appVersion).toBe(a.appVersion);
expect((await storage.trustedManifestStore.getLatest()).appVersion).toBe(a.appVersion);
Expand All @@ -119,36 +187,51 @@ describe('createManifestStore', () => {

it('prunes entries older than 24h on next addLatest', async () => {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const stale = await storage.trustedManifestStore.addLatest({
files: { '/old.js': 'gone' },
});
const stale = await storage.trustedManifestStore.addLatest(
{ files: { '/old.js': 'gone' } },
MOCK_SIGNER
);
expect(await storage.trustedManifestStore.get(stale.appVersion)).toBeDefined();

vi.setSystemTime(new Date('2026-01-02T00:00:01Z'));
const fresh = await storage.trustedManifestStore.addLatest({
files: { '/new.js': 'kept' },
});
const fresh = await storage.trustedManifestStore.addLatest(
{ files: { '/new.js': 'kept' } },
MOCK_SIGNER
);

expect(await storage.trustedManifestStore.get(stale.appVersion)).toBeUndefined();
expect(await storage.trustedManifestStore.get(fresh.appVersion)).toBeDefined();
});

it('does not prune entries younger than 24h', async () => {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const first = await storage.trustedManifestStore.addLatest({
files: { '/a.js': 'ha' },
});
const first = await storage.trustedManifestStore.addLatest(
{ files: { '/a.js': 'ha' } },
MOCK_SIGNER
);
vi.setSystemTime(new Date('2026-01-01T23:59:59Z'));
await storage.trustedManifestStore.addLatest({ files: { '/b.js': 'hb' } });
await storage.trustedManifestStore.addLatest(
{ files: { '/b.js': 'hb' } },
MOCK_SIGNER
);

expect(await storage.trustedManifestStore.get(first.appVersion)).toBeDefined();
});
});

it('getAll returns entries newest-first', async () => {
const a = await storage.trustedManifestStore.addLatest({ files: { '/a.js': 'ha' } });
const b = await storage.trustedManifestStore.addLatest({ files: { '/b.js': 'hb' } });
const c = await storage.trustedManifestStore.addLatest({ files: { '/c.js': 'hc' } });
const a = await storage.trustedManifestStore.addLatest(
{ files: { '/a.js': 'ha' } },
MOCK_SIGNER
);
const b = await storage.trustedManifestStore.addLatest(
{ files: { '/b.js': 'hb' } },
MOCK_SIGNER
);
const c = await storage.trustedManifestStore.addLatest(
{ files: { '/c.js': 'hc' } },
MOCK_SIGNER
);

const all = await storage.trustedManifestStore.getAll();
expect(all.map((e) => e.appVersion)).toEqual([
Expand All @@ -161,6 +244,68 @@ describe('createManifestStore', () => {
it('getAll returns an empty array when nothing is stored', async () => {
expect(await storage.trustedManifestStore.getAll()).toEqual([]);
});

describe('updateActiveIdentity', () => {
it('writes the supplied identity to the active-identity key', async () => {
await storage.activeIdentityStore.updateActiveIdentity({
signatureType: 'ethereum-personal-sign',
identity: '0xnewsigner',
});
const anchor = await storage.activeIdentityStore.getActiveIdentity();
expect(anchor.signatureType).toBe('ethereum-personal-sign');
expect(anchor.identity).toBe('0xnewsigner');
expect(typeof anchor.enrolledAt).toBe('number');
});

it('overwrites a prior anchor set by addLatest', async () => {
await storage.trustedManifestStore.addLatest(
{ files: { '/a.js': 'x' } },
{ signatureType: 'ethereum-personal-sign', identity: '0xoldsigner' }
);
await storage.activeIdentityStore.updateActiveIdentity({
signatureType: 'ethereum-personal-sign',
identity: '0xnewsigner',
});
const anchor = await storage.activeIdentityStore.getActiveIdentity();
expect(anchor.identity).toBe('0xnewsigner');
});

it('keeps the in-memory cache in sync so getActiveIdentity does not re-read IDB', async () => {
// Prime the cache with the first identity.
await storage.activeIdentityStore.updateActiveIdentity({
signatureType: 'ethereum-personal-sign',
identity: '0xfirst',
});
await storage.activeIdentityStore.getActiveIdentity();

// Wrap the underlying get to detect post-update IDB reads.
const backend = createInMemoryStorage();
const freshStorage = createManifestStore(backend);
await freshStorage.activeIdentityStore.updateActiveIdentity({
signatureType: 'ethereum-personal-sign',
identity: '0xcached',
});
const spy = vi.spyOn(backend, 'get');
const anchor = await freshStorage.activeIdentityStore.getActiveIdentity();
expect(anchor.identity).toBe('0xcached');
expect(spy).not.toHaveBeenCalled();
});

it('is idempotent — rewriting the same identity leaves the anchor identical in shape', async () => {
const first = await storage.activeIdentityStore.updateActiveIdentity({
signatureType: 'ethereum-personal-sign',
identity: '0xsame',
});
const second = await storage.activeIdentityStore.updateActiveIdentity({
signatureType: 'ethereum-personal-sign',
identity: '0xsame',
});
expect(second.signatureType).toBe(first.signatureType);
expect(second.identity).toBe(first.identity);
// enrolledAt is refreshed each call — that's expected, not a bug.
expect(typeof second.enrolledAt).toBe('number');
});
});
});

describe('verificationResults', () => {
Expand Down
Loading
Loading