• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / roku-deploy / 29445840586

15 Jul 2026 07:47PM UTC coverage: 97.246% (-2.8%) from 100.0%
29445840586

Pull #124

github

web-flow
Merge 941b0b11b into a183cee86
Pull Request #124: v4

730 of 761 branches covered (95.93%)

Branch coverage included in aggregate %.

379 of 387 new or added lines in 9 files covered. (97.93%)

9 existing lines in 3 files now uncovered.

965 of 982 relevant lines covered (98.27%)

31.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.16
/src/fetch.ts
1
import * as crypto from 'crypto';
3✔
2

3
// Module seam for `fetch` so tests can stub it. On Node 18, `fetch` is a lazy
4
// getter on `globalThis` (not an own property), so `sinon.stub(globalThis, 'fetch')`
5
// fails there — routing calls through this object gives a regular, stubbable export.
6
export const httpClient = {
3✔
7
    fetch: globalThis.fetch?.bind(globalThis)
9✔
8
};
9

10
/**
11
 * Issue an HTTP request with digest authentication.
12
 * Performs the two-step challenge/response dance: the first request
13
 * collects the `WWW-Authenticate` challenge, the second sends a computed
14
 * `Authorization` header. Response bodies are not consumed — callers get
15
 * the raw `Response` and inspect status/headers only.
16
 */
17
export async function fetchWithDigest(
3✔
18
    url: string,
19
    init: RequestInit & { method: string; username: string; password: string; timeout: number }
20
): Promise<Response> {
21
    const { username, password, timeout, ...fetchInit } = init;
9✔
22
    const method = fetchInit.method.toUpperCase();
9✔
23

24
    // Step 1 — issue the request unauthenticated to collect the challenge.
25
    const step1 = await fetchWithTimeout(url, fetchInit, timeout);
9✔
26
    if (step1.status !== 401) {
6✔
27
        return step1;
1✔
28
    }
29
    const wwwAuth = step1.headers.get('www-authenticate');
5✔
30
    if (!wwwAuth) {
5✔
31
        return step1;
1✔
32
    }
33

34
    // Step 2 — compute the digest response and retry.
35
    const challenge = parseDigestChallenge(wwwAuth);
4✔
36
    const uri = new URL(url).pathname;
4✔
37
    const authorization = buildDigestAuthorization({
4✔
38
        username: username,
39
        password: password,
40
        method: method,
41
        uri: uri,
42
        challenge: challenge
43
    });
44
    return fetchWithTimeout(url, {
4✔
45
        ...fetchInit,
46
        headers: { ...fetchInit.headers, Authorization: authorization }
47
    }, timeout);
48
}
49

50
function fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise<Response> {
51
    const controller = new AbortController();
13✔
52
    const timer = setTimeout(() => controller.abort(), timeout);
13✔
53
    return httpClient.fetch(url, { ...init, signal: controller.signal })
13✔
54
        .finally(() => clearTimeout(timer));
13✔
55
}
56

57
//parse the comma-separated key/value pairs out of a `WWW-Authenticate: Digest ...` header. Values may be bare or double-quoted.
58
export function parseDigestChallenge(header: string): Record<string, string> {
3✔
59
    const out: Record<string, string> = {};
4✔
60
    const body = header.replace(/^Digest\s+/i, '');
4✔
61
    const re = /([a-zA-Z]+)=(?:"((?:[^"\\]|\\.)*)"|([^,]+))/g;
4✔
62
    let m: RegExpExecArray | null;
63
    while ((m = re.exec(body)) !== null) {
4✔
64
        out[m[1].toLowerCase()] = m[2] ?? m[3].trim();
12!
65
    }
66
    return out;
4✔
67
}
68

69
function md5(input: string): string {
70
    return crypto.createHash('md5').update(input).digest('hex');
12✔
71
}
72

73
//build an RFC 2617 `Authorization: Digest ...` header from a parsed challenge.
74
export function buildDigestAuthorization(params: {
3✔
75
    username: string;
76
    password: string;
77
    method: string;
78
    uri: string;
79
    challenge: Record<string, string>;
80
}): string {
81
    const { username, password, method, uri, challenge } = params;
4✔
82
    const realm = challenge.realm ?? '';
4!
83
    const nonce = challenge.nonce ?? '';
4!
84
    const qop = challenge.qop;
4✔
85
    const algorithm = (challenge.algorithm ?? 'MD5').toUpperCase();
4!
86
    const cnonce = crypto.randomBytes(8).toString('hex');
4✔
87
    const nc = '00000001';
4✔
88

89
    const ha1 = algorithm === 'MD5-SESS'
4!
90
        ? md5(`${md5(`${username}:${realm}:${password}`)}:${nonce}:${cnonce}`)
91
        : md5(`${username}:${realm}:${password}`);
92
    const ha2 = md5(`${method}:${uri}`);
4✔
93
    const response = qop
4!
94
        ? md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`)
95
        : md5(`${ha1}:${nonce}:${ha2}`);
96

97
    const parts = [
4✔
98
        `username="${username}"`,
99
        `realm="${realm}"`,
100
        `nonce="${nonce}"`,
101
        `uri="${uri}"`,
102
        `algorithm=${algorithm}`,
103
        `response="${response}"`
104
    ];
105
    if (qop) {
4!
106
        parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
4✔
107
    }
108
    if (challenge.opaque) {
4!
UNCOV
109
        parts.push(`opaque="${challenge.opaque}"`);
×
110
    }
111
    return `Digest ${parts.join(', ')}`;
4✔
112
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc