/** * DC-082: Update-manager image-name parsing for docker-compose prefixed names. * * The pre-fix code normalized `dashcaddy-dashcaddy-api:latest` to * `library/dashcaddy-dashcaddy-api:latest` before probing Docker Hub. * Compose-prefixed names (single hyphen, no slash, lowercase) need to * split on the FIRST hyphen to recover `/` — that's * the actual upstream namespace for a compose-prefixed image. * * The fix also adds a "no upstream registry image, skip cleanly" path * for when the authed GET 401s against a compose-prefixed name (the * compose-prefixed image is built locally and not published to Docker * Hub). That should log as info, not error. */ const updateManager = require('../src/managers/update-manager'); describe('DC-082 update-manager / compose-prefixed image names', () => { let um = updateManager; // module exports the singleton instance describe('_composeProjectToRepo', () => { test('splits dashcaddy-dashcaddy-api on the first hyphen', () => { expect(um._composeProjectToRepo('dashcaddy-dashcaddy-api')).toBe('dashcaddy/dashcaddy-api'); }); test('splits myproject-myservice on the first hyphen', () => { expect(um._composeProjectToRepo('myproject-myservice')).toBe('myproject/myservice'); }); test('splits multi-hyphen names on the FIRST hyphen only', () => { // "myproj-grandchild-service" -> "myproj/grandchild-service" // (first hyphen is the project/service boundary; later hyphens are // part of the service name like docker-compose's `web-cache`). expect(um._composeProjectToRepo('myproj-grandchild-service')).toBe('myproj/grandchild-service'); }); test('returns null for slash-namespaced names (handled by other path)', () => { expect(um._composeProjectToRepo('dashcaddy/dashcaddy-api')).toBe(null); expect(um._composeProjectToRepo('library/nginx')).toBe(null); expect(um._composeProjectToRepo('ghcr.io/x/y')).toBe(null); }); test('returns null for Docker Official Image names (no hyphen)', () => { expect(um._composeProjectToRepo('nginx')).toBe(null); expect(um._composeProjectToRepo('alpine')).toBe(null); expect(um._composeProjectToRepo('node')).toBe(null); }); test('returns null for empty / malformed input', () => { expect(um._composeProjectToRepo('')).toBe(null); expect(um._composeProjectToRepo(null)).toBe(null); expect(um._composeProjectToRepo(undefined)).toBe(null); expect(um._composeProjectToRepo(123)).toBe(null); expect(um._composeProjectToRepo('-foo')).toBe(null); // leading hyphen expect(um._composeProjectToRepo('foo-')).toBe(null); // trailing hyphen // The regex tolerates mixed-case via the /i flag for defensiveness // even though Docker Compose names are typically lowercase — the // important shape constraints are the letter/digit/underscore/hyphen // charset and the non-empty two-part split. }); test('accepts names with underscores and digits (compose allows)', () => { expect(um._composeProjectToRepo('proj-v2_service')).toBe('proj/v2_service'); expect(um._composeProjectToRepo('dashcaddy-api-v2')).toBe('dashcaddy/api-v2'); }); test('rejects names with chars compose never produces', () => { // dot/colon/slash should never pass — they're either already-namespaced // or invalid in a Docker Compose service name. expect(um._composeProjectToRepo('foo:bar')).toBe(null); expect(um._composeProjectToRepo('foo.bar')).toBe(null); expect(um._composeProjectToRepo('foo/bar')).toBe(null); }); }); describe('_isNotPublishedError', () => { test('returns true for HTTP 401 + compose-prefixed remainder', () => { const err = new Error('Docker Hub registry returned HTTP 401 after auth'); expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(true); }); test('returns false for HTTP 401 with non-compose-prefixed remainder', () => { const err = new Error('Docker Hub registry returned HTTP 401 after auth'); expect(um._isNotPublishedError(err, 'nginx')).toBe(false); expect(um._isNotPublishedError(err, 'library/nginx')).toBe(false); expect(um._isNotPublishedError(err, 'dashcaddy/some-image')).toBe(false); }); test('returns false for non-401 errors', () => { const err = new Error('network timeout after 10s'); expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(false); }); test('returns false for malformed error or remainder', () => { expect(um._isNotPublishedError(null, 'dashcaddy-dashcaddy-api')).toBe(false); expect(um._isNotPublishedError({}, 'dashcaddy-dashcaddy-api')).toBe(false); expect(um._isNotPublishedError({ message: 'no string' }, 'dashcaddy-dashcaddy-api')).toBe(false); expect(um._isNotPublishedError(new Error('HTTP 401'), null)).toBe(false); expect(um._isNotPublishedError(new Error('HTTP 401'), '')).toBe(false); }); }); describe('getLatestImageDigest (mocked fetchWithReliability)', () => { let originalFetch; let originalFetchAuth; let originalFetchRetry; beforeEach(() => { originalFetch = um.fetchWithReliability.bind(um); originalFetchAuth = um.fetchAuthToken.bind(um); }); test('compose-prefixed name (dashcaddy-dashcaddy-api) probes dashcaddy/dashcaddy-api (NOT library/dashcaddy-dashcaddy-api)', async () => { const calls = []; um.fetchWithReliability = async (opts) => { calls.push(opts); // Simulate the real Docker Hub: 401 with WWW-Auth, then 401 after token // (because dashcaddy/dashcaddy-api doesn't exist on Docker Hub). if (calls.length === 1) { return { statusCode: 401, headers: { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:dashcaddy/dashcaddy-api:pull"', }, body: '', }; } return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}' }; }; um.fetchAuthToken = async () => 'fake-token'; const { log } = require('../src/utils/logging'); const infoSpy = jest.spyOn(log, 'info').mockImplementation(() => {}); const errorSpy = jest.spyOn(log, 'error').mockImplementation(() => {}); const result = await um.getLatestImageDigest('dashcaddy-dashcaddy-api:latest'); expect(result).toBe(null); // First probe must target /v2/dashcaddy/dashcaddy-api/manifests/latest // NOT /v2/library/dashcaddy-dashcaddy-api/manifests/latest const firstPath = calls[0].path; expect(firstPath).toBe('/v2/dashcaddy/dashcaddy-api/manifests/latest'); expect(firstPath).not.toContain('library/dashcaddy-dashcaddy-api'); // The 401 after auth should produce an INFO log about "no upstream" // NOT an error log. const infoMsgs = infoSpy.mock.calls.map((c) => c[1]); expect(infoMsgs).toContain('No upstream registry image — skipping update check'); const errorMsgs = errorSpy.mock.calls.map((c) => c[1]); expect(errorMsgs).not.toContain('Docker Hub registry returned HTTP 401 after auth'); infoSpy.mockRestore(); errorSpy.mockRestore(); }); test('official image (nginx) still probes library/nginx', async () => { const calls = []; um.fetchWithReliability = async (opts) => { calls.push(opts); if (calls.length === 1) { return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc123' }, body: '' }; } return { statusCode: 200, headers: {}, body: '' }; }; const result = await um.getLatestImageDigest('nginx:latest'); expect(result).toBe('sha256:abc123'); expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest'); }); test('library/nginx (explicit) probes library/nginx', async () => { const calls = []; um.fetchWithReliability = async (opts) => { calls.push(opts); if (calls.length === 1) { return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc' }, body: '' }; } return { statusCode: 200, headers: {}, body: '' }; }; const result = await um.getLatestImageDigest('library/nginx:latest'); expect(result).toBe('sha256:abc'); expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest'); }); test('ghcr.io/samiahmed7777/dashcaddy-api uses ghcr.io path (not docker hub)', async () => { const calls = []; um.fetchWithReliability = async (opts) => { calls.push(opts); return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:ghcr' }, body: '' }; }; const result = await um.getLatestImageDigest('ghcr.io/samiahmed7777/dashcaddy-api:latest'); expect(result).toBe('sha256:ghcr'); expect(calls[0].hostname).toBe('ghcr.io'); expect(calls[0].path).toBe('/v2/samiahmed7777/dashcaddy-api/manifests/latest'); }); test('returns null + skips cleanly when compose-prefixed image has no upstream', async () => { let callCount = 0; um.fetchWithReliability = async (opts) => { callCount += 1; if (callCount === 1) { return { statusCode: 401, headers: { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:myproj-myservice:pull"' }, body: '', }; } return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED"}]}' }; }; um.fetchAuthToken = async () => 'fake-token'; const result = await um.getLatestImageDigest('myproj-myservice:latest'); expect(result).toBe(null); // Probe targets the correct namespace (myproj/myservice), not library/. const firstCall = await (async () => { let p; um.fetchWithReliability = async (opts) => { p = opts; return { statusCode: 200, headers: {}, body: '' }; }; await um.getLatestImageDigest('myproj-myservice:latest'); return p; })(); expect(firstCall.path).toBe('/v2/myproj/myservice/manifests/latest'); }); afterEach(() => { um.fetchWithReliability = originalFetch; um.fetchAuthToken = originalFetchAuth; }); }); });