fix(playlist): mask query passwords on M3U cards, drop stale auto-detect prefills, doc the assembled-host exception

Addresses Copilot's suppressed review comments:

- The m3u-url candidate card no longer shows a get.php password in clear —
  the display masks the query value while the untouched URL still prefills
  the form.
- A picked candidate is dropped if the user switches to another method
  before its target form mounts, so it cannot lie in wait and prefill a
  later visit.
- The parser contract now names its one assembled-value exception
  (labeledHostUrl completing a scheme-less host with http:// and a labeled
  port).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
This commit is contained in:
Claude committed 2026-08-15 19:46:50 +00:00
1 parent ba19f51a12
commit 526e8abc3e
5 files changed
+57 -2

No files matched your search

@@ -283,6 +283,28 @@ describe('AddPlaylistDialogComponent', () => {
expect(patchValue).toHaveBeenCalledTimes(1);
});
it('drops the candidate when the user switches to another method first', () => {
const patchValue = jest.fn();
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
() => ({ form: { patchValue } })
);
component.onCandidateSelected({
kind: 'xtream',
confidence: 'high',
username: 'alice',
});
// The user clicks another tile before the xtream form mounted.
component.method.set('file');
applyPrefill();
// Coming back to the xtream form later must not prefill it.
component.method.set('xtream');
applyPrefill();
expect(patchValue).not.toHaveBeenCalled();
});
it('keeps the candidate pending while the target form does not exist yet', () => {
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
() => undefined
@@ -181,6 +181,14 @@ export class AddPlaylistDialogComponent {
if (!candidate) {
return;
}
// The user can click another method tile before the target form
// mounts; a candidate must not lie in wait and prefill a later visit
// to its form. Only the method the selection itself switched to may
// consume it.
if (this.method() !== METHOD_BY_CANDIDATE_KIND[candidate.kind]) {
this.pendingPrefill.set(null);
return;
}
switch (candidate.kind) {
case 'm3u-url': {
const child = this.urlUpload();
@@ -80,6 +80,20 @@ describe('AutoImportComponent', () => {
expect(emitted).toEqual([candidate]);
});
it('masks the query password of an M3U link on the card', () => {
fixture.detectChanges();
const rows = component.summaryRows({
kind: 'm3u-url',
confidence: 'low',
url: 'http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus',
});
expect(rows[0].value).toContain('username=alice');
expect(rows[0].value).toContain('password=••••••');
expect(JSON.stringify(rows)).not.toContain('s3cret');
});
it('masks the password in the summary rows', () => {
fixture.detectChanges();
@@ -130,7 +130,16 @@ export class AutoImportComponent implements OnInit {
};
switch (candidate.kind) {
case 'm3u-url':
push('HOME.URL_UPLOAD.PLAYLIST_URL', candidate.url);
// get.php links carry the password in their query — the card
// must not show it while every other password here is masked.
// The candidate keeps the untouched URL for prefilling.
push(
'HOME.URL_UPLOAD.PLAYLIST_URL',
candidate.url?.replace(
/([?&]password=)[^&#\s]*/i,
'$1••••••'
)
);
break;
case 'xtream':
push(
@@ -26,7 +26,9 @@ import {
* Unicode math alphabets (`𝚄𝚂𝙴𝚁➤`, `𝙿𝙰𝚂𝚂➤`) to slip past chat spam
* filters; the folding turns them back into the ASCII they denote, and
* real credentials/URLs are ASCII already, so values pass through it
* unchanged.
* unchanged. The one deliberate exception is `labeledHostUrl`, which
* completes a scheme-less labeled host with `http://` and a separately
* labeled port — an assembled value, landing in an editable form field.
* 2. **Detection proposes, it never decides.** The output prefills the
* existing import forms; classification authority stays with the
* behavioral probes those forms already run (portal discovery, Xtream