voice: fail loud on insecure-context join (mic blocked over HTTP) instead of silent reject — TDD

getUserMedia is only exposed in a secure context (HTTPS, or the localhost
exemption). Reached over plain HTTP on a LAN IP — the dev server from a phone
at http://192.168.x.x:8000 — iOS/Android leave navigator.mediaDevices
undefined, so join() fetched TURN creds (a confusing 200) then silently
rejected deep in a .then() with no mic prompt. Desktop works because
127.0.0.1 IS a secure context. (Not a regression — voice never worked on the
HTTP dev server from mobile.)

- voice-mesh.js: _micSupported() seam + an early INSECURE_CONTEXT reject in
  join() before any network, so the failure is fast + diagnosable.
- burger-btn.js: bindVoiceBtn catches the rejected join, rolls back the
  optimistic .in-call/dataset.inCall (so the next click retries), and surfaces
  a Brief — 'Voice needs HTTPS (or localhost) — your browser blocked the mic
  here.' — instead of failing invisibly.
- VoiceMeshSpec: +2 specs (join rejects INSECURE_CONTEXT; btn rolls back +
  Briefs on reject). Jasmine green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-29 21:29:42 -04:00
parent 6799749ede
commit da97c623c9
4 changed files with 169 additions and 1 deletions

View File

@@ -93,3 +93,66 @@ describe('voice-mesh mute', function () {
expect(seen.muted).toBe(true);
});
});
// Secure-context join guard — getUserMedia is undefined over plain HTTP on a
// LAN IP (dev server reached from a phone), so join must fail fast + loud
// rather than fetch TURN creds and silently reject with no mic prompt
// (diagnosed 2026-05-29).
describe('voice-mesh join guard', function () {
var vr;
beforeEach(function () { vr = window.VoiceRoom; });
afterEach(function () {
vr.localStream = null; vr.muted = false; vr._onState = null;
});
it('rejects with INSECURE_CONTEXT when the mic is unsupported', function (done) {
spyOn(vr, '_micSupported').and.returnValue(false);
vr.join('mysea-test').then(function () {
done.fail('join should reject without a secure-context mic');
}, function (e) {
expect(e.code).toBe('INSECURE_CONTEXT');
done();
});
});
});
// bindVoiceBtn (burger-btn.js) must surface a failed join + roll back the
// optimistic in-call state so the next click can retry.
describe('voice btn join failure surfacing', function () {
var vbtn, origVR, origBrief, banner;
beforeEach(function () {
vbtn = document.createElement('button');
vbtn.id = 'id_voice_btn';
vbtn.classList.add('active');
vbtn.setAttribute('data-room-id', 'mysea-x');
document.body.appendChild(vbtn);
origVR = window.VoiceRoom;
origBrief = window.Brief;
window.VoiceRoom = {
join: function () { return Promise.reject({ code: 'INSECURE_CONTEXT' }); },
};
banner = jasmine.createSpy('showBanner');
window.Brief = { showBanner: banner };
bindVoiceBtn();
});
afterEach(function () {
window.VoiceRoom = origVR;
window.Brief = origBrief;
vbtn.remove();
});
it('rolls back .in-call + surfaces a Brief when join rejects', function (done) {
vbtn.click();
expect(vbtn.classList.contains('in-call')).toBe(true); // optimistic
setTimeout(function () {
expect(vbtn.classList.contains('in-call')).toBe(false);
expect(vbtn.dataset.inCall).toBeUndefined();
expect(banner).toHaveBeenCalled();
expect(banner.calls.mostRecent().args[0].line_text)
.toContain('HTTPS');
done();
}, 0);
});
});