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
10 changes: 8 additions & 2 deletions library/html-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,26 @@ class HtmlServer {
}

sendErrorResponse(res, templateName, error, statusCode = 500) {
if (res.headersSent) {
this.log.error('[HtmlServer] Cannot send error response - headers already sent:', error.message || error);
return;
}
const errorContent = `
<div class="alert alert-danger">
<h4>Error</h4>
<p>${escape(error.message || error)}</p>
</div>
`;

try {
const html = this.renderPage(templateName, 'Error', errorContent);
res.status(statusCode).setHeader('Content-Type', 'text/html');
res.send(html);
} catch (renderError) {
this.log.error('[HtmlServer] Error rendering error page:', renderError);
res.status(statusCode).send(`<h1>Error</h1><p>Failed to render error page: ${escape(renderError.message)}</p>`);
if (!res.headersSent) {
res.status(statusCode).send(`<h1>Error</h1><p>Failed to render error page: ${escape(renderError.message)}</p>`);
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/package-crawler.js
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ class PackageCrawler {
const dependencies = [];
if (packageJson.dependencies) {
for (const [dep, ver] of Object.entries(packageJson.dependencies)) {
dependencies.push(`${dep}@${ver}`);
dependencies.push(`${dep}#${ver}`);
}
}

Expand Down
3 changes: 2 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,9 @@ app.get('/', async (req, res) => {
serverLog.error('Error rendering root page:', error);
htmlServer.sendErrorResponse(res, 'root', error);
}
} else {
return serveFhirsmithHome(req, res);
}
return serveFhirsmithHome(req, res);
});

app.get('/fhirsmith', (req, res) => serveFhirsmithHome(req, res));
Expand Down
134 changes: 134 additions & 0 deletions tests/packages/package-dependencies.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
const sqlite3 = require('sqlite3').verbose();

describe('Package dependencies format', () => {

// Test that the crawler dependency format matches what the reader expects
describe('crawler dependency extraction', () => {
test('dependencies use # separator (FHIR convention)', () => {
// Simulate what the crawler does at package-crawler.js:518-524
const packageJson = {
dependencies: {
'hl7.fhir.r4.core': '4.0.1',
'hl7.fhir.uv.ips': '1.1.0'
}
};

const dependencies = [];
for (const [dep, ver] of Object.entries(packageJson.dependencies)) {
dependencies.push(`${dep}#${ver}`);
}

expect(dependencies).toEqual([
'hl7.fhir.r4.core#4.0.1',
'hl7.fhir.uv.ips#1.1.0'
]);
});

test('empty dependencies produces empty array', () => {
const packageJson = {};
const dependencies = [];
if (packageJson.dependencies) {
for (const [dep, ver] of Object.entries(packageJson.dependencies)) {
dependencies.push(`${dep}#${ver}`);
}
}
expect(dependencies).toEqual([]);
});
});

// Test that the reader (getPackageDependencies) correctly parses # format
describe('dependency parsing with # separator', () => {
let db;

beforeAll((done) => {
db = new sqlite3.Database(':memory:', (err) => {
if (err) return done(err);
db.run(`CREATE TABLE PackageDependencies (
PackageVersionKey INTEGER NOT NULL,
Dependency TEXT(128) NOT NULL
)`, done);
});
});

afterAll((done) => {
db.close(done);
});

test('parses dependencies stored with # separator', (done) => {
const deps = [
[1, 'hl7.fhir.r4.core#4.0.1'],
[1, 'hl7.fhir.uv.ips#1.1.0'],
[2, 'hl7.fhir.r5.core#5.0.0']
];

const inserts = deps.map(([key, dep]) =>
new Promise((resolve, reject) => {
db.run('INSERT INTO PackageDependencies VALUES (?, ?)', [key, dep],
(err) => err ? reject(err) : resolve());
})
);

Promise.all(inserts).then(() => {
// Replicate getPackageDependencies logic from packages.js:1702-1734
const packageVersionKeys = [1, 2];
const placeholders = packageVersionKeys.map(() => '?').join(',');
const sql = `SELECT PackageVersionKey, Dependency FROM PackageDependencies WHERE PackageVersionKey IN (${placeholders})`;

db.all(sql, packageVersionKeys, (err, rows) => {
if (err) return done(err);

const result = {};
for (const row of rows) {
if (!result[row.PackageVersionKey]) {
result[row.PackageVersionKey] = {};
}
const dependency = row.Dependency;
const hashIndex = dependency.indexOf('#');
if (hashIndex > 0) {
const depName = dependency.substring(0, hashIndex);
const depVersion = dependency.substring(hashIndex + 1);
result[row.PackageVersionKey][depName] = depVersion;
}
}

expect(result[1]).toEqual({
'hl7.fhir.r4.core': '4.0.1',
'hl7.fhir.uv.ips': '1.1.0'
});
expect(result[2]).toEqual({
'hl7.fhir.r5.core': '5.0.0'
});
done();
});
});
});

test('dependencies with @ separator are NOT parsed (old bug format)', (done) => {
db.run('INSERT INTO PackageDependencies VALUES (?, ?)', [3, 'hl7.fhir.r4.core@4.0.1'], (err) => {
if (err) return done(err);

db.all('SELECT PackageVersionKey, Dependency FROM PackageDependencies WHERE PackageVersionKey = 3', [], (err, rows) => {
if (err) return done(err);

const result = {};
for (const row of rows) {
if (!result[row.PackageVersionKey]) {
result[row.PackageVersionKey] = {};
}
const dependency = row.Dependency;
const hashIndex = dependency.indexOf('#');
if (hashIndex > 0) {
const depName = dependency.substring(0, hashIndex);
const depVersion = dependency.substring(hashIndex + 1);
result[row.PackageVersionKey][depName] = depVersion;
}
}

// @ format should NOT be parsed — this was the bug
expect(result[3]).toEqual({});
done();
});
});
});
});
});
75 changes: 75 additions & 0 deletions tests/server/headers-sent.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
const path = require('path');

const HtmlServer = require('../../library/html-server').constructor;

describe('sendErrorResponse headers-sent guard', () => {
let htmlServer;
let mockLog;

beforeAll(() => {
htmlServer = new HtmlServer();
mockLog = { error: jest.fn(), warn: jest.fn(), info: jest.fn() };
htmlServer.useLog(mockLog);

const templatePath = path.join(__dirname, '../../root-template.html');
htmlServer.loadTemplate('root', templatePath);
});

beforeEach(() => {
mockLog.error.mockClear();
});

test('does not throw when headers already sent', () => {
const res = {
headersSent: true,
status: jest.fn().mockReturnThis(),
setHeader: jest.fn(),
send: jest.fn(),
};

// Should not throw
htmlServer.sendErrorResponse(res, 'root', new Error('test error'));

// Should log the error instead of trying to send
expect(mockLog.error).toHaveBeenCalledWith(
expect.stringContaining('headers already sent'),
expect.stringContaining('test error')
);
// Should NOT attempt to send a response
expect(res.send).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

test('sends error response normally when headers not yet sent', () => {
const res = {
headersSent: false,
status: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
send: jest.fn(),
};

htmlServer.sendErrorResponse(res, 'root', new Error('something broke'));

expect(res.status).toHaveBeenCalledWith(500);
expect(res.send).toHaveBeenCalled();
const html = res.send.mock.calls[0][0];
expect(html).toContain('something broke');
});

test('falls back to plain HTML when template rendering fails', () => {
const res = {
headersSent: false,
status: jest.fn().mockReturnThis(),
setHeader: jest.fn(() => { throw new Error('setHeader fail'); }),
send: jest.fn(),
};

// 'nonexistent' template will cause renderPage to produce output,
// but setHeader throws, so it falls to the inner catch
htmlServer.sendErrorResponse(res, 'nonexistent', new Error('original error'));

expect(mockLog.error).toHaveBeenCalled();
// The fallback send should still be attempted
expect(res.send).toHaveBeenCalled();
});
});
48 changes: 48 additions & 0 deletions tests/tx/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,54 @@ describe('Search Worker', () => {
expect(r4Bundle.entry).toBeUndefined();
});

test('should include SUBSETTED tag with _summary=true', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _summary: 'true', _count: 5 })
.set('Accept', 'application/json');

expect(response.status).toBe(200);

if (response.body.entry && response.body.entry.length > 0) {
const resource = response.body.entry[0].resource;
expect(resource.meta).toBeDefined();
expect(resource.meta.tag).toBeDefined();
const subsetted = resource.meta.tag.find(t => t.code === 'SUBSETTED');
expect(subsetted).toBeDefined();
expect(subsetted.system).toBe('http://terminology.hl7.org/CodeSystem/v3-ObservationValue');
}
});

test('should include content element in CodeSystem summary', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _summary: 'true', url: 'http://hl7.org/fhir/administrative-gender' })
.set('Accept', 'application/json');

expect(response.status).toBe(200);

if (response.body.entry && response.body.entry.length > 0) {
const resource = response.body.entry[0].resource;
expect(resource.content).toBeDefined();
}
});

test('should include SUBSETTED tag with _elements', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _elements: 'url,name', _count: 5 })
.set('Accept', 'application/json');

expect(response.status).toBe(200);

if (response.body.entry && response.body.entry.length > 0) {
const resource = response.body.entry[0].resource;
expect(resource.meta).toBeDefined();
const subsetted = resource.meta.tag.find(t => t.code === 'SUBSETTED');
expect(subsetted).toBeDefined();
}
});

test('should return full resources with _summary=false', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
Expand Down
2 changes: 1 addition & 1 deletion tx/library/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ class Renderer {
}
}
} else {
li.tx(this.translate('VALUE_SET_CODES_FROM'));
li.tx(this.translate('VALUE_SET_CODES_FROM')+" ");
await this.renderLink(li,inc.system+(inc.version ? "|"+inc.version : ""));
li.tx(" "+ this.translate('VALUE_SET_WHERE')+" ");
li.startCommaList("and");
Expand Down
Loading
Loading