aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-webdav-sizes.js
blob: 68cb90be69541faa2aac0960b892e4589a4bd252 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
// node modules/test/test-webdav-sizes.js
// test-webdav-sizes: two WebDAV defects that both lied to the user.
//
// (a) webdav-ls sizes. nginx renders __list__ with `autoindex_exact_size off`,
//     so the HTML carries humanised sizes ('21K', '45K', '1.2M'). _parseAutoindex
//     did `parseInt(sizeStr) || 0`, which drops the suffix: '21K' -> 21 bytes, and
//     _formatSize() then printed '21 B' for a 21 397-byte file. The PROPFIND path
//     already gets exact bytes from getcontentlength and must stay exact.
// (b) webdav-pull into a missing local directory. It called fs.writeFile() and
//     ignored the return value — virtual-fs returns false when the parent chain is
//     absent — then printed 'Pulled: ... (N bytes)' for a write that never happened.
//
// Run: node modules/test/test-webdav-sizes.js
// Set WEBDAV_SRC=<path> to point the harness at another copy of webdav.js
// (used to prove the test is RED against the pre-fix file).
const fs = require('fs'), path = require('path');
const SRC = process.env.WEBDAV_SRC || path.join(__dirname, '..', 'ext', 'webdav.js');

// --- browser stubs -------------------------------------------------------
const store = {};
global.localStorage = {
    getItem: k => (k in store ? store[k] : null),
    setItem: (k, v) => { store[k] = String(v); },
    removeItem: k => { delete store[k]; }
};
global.window = global;
global.location = { pathname: '/spaces/chatstudio/newos4/' };
global.document = {
    cookie: '',
    addEventListener: () => {},
    removeEventListener: () => {},
    getElementById: () => null,
    querySelector: () => null,
    createElement: () => ({ style: {}, appendChild: () => {}, setAttribute: () => {},
                            addEventListener: () => {}, remove: () => {} }),
    body: { appendChild: () => {} }
};
global.fetch = async () => ({ ok: false, status: 401, statusText: 'Unauthorized',
                              text: async () => '', json: async () => ({}) });

// The PROPFIND path builds a DOM. Feed it pre-cooked response objects as JSON:
// parseFromString(JSON) -> { querySelectorAll() } is all _parsePropfind uses.
function fakeDoc(xml) {
    const rows = JSON.parse(xml);
    return {
        querySelectorAll: () => rows.map(r => ({
            querySelector: (sel) => ({
                textContent: sel.indexOf('href') >= 0 ? (r.href || '')
                    : sel.indexOf('collection') >= 0 ? (r.collection || '')
                    : (r.size || '')
            })
        }))
    };
}
global.DOMParser = function () { this.parseFromString = fakeDoc; };

let WebDAVModule;
try {
    WebDAVModule = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn WebDAVModule;')();
} catch (e) {
    console.log('FAIL could not load ' + SRC + ' — ' + e.message);
    process.exit(1);
}
if (typeof WebDAVModule !== 'function') {
    console.log('FAIL webdav.js did not expose WebDAVModule (got ' + typeof WebDAVModule + ')');
    process.exit(1);
}

let fails = 0, passes = 0;
function ok(name, cond, detail) {
    if (cond) { passes++; return; }
    fails++;
    console.log('FAIL ' + name + (detail !== undefined ? ' — ' + detail : ''));
}

// A VFS stand-in with virtual-fs's real semantics: writeFile() refuses to create
// a file whose parent directory does not exist (core/virtual-fs.js _resolvePath).
function makeVfs(opts) {
    opts = opts || {};
    const dirs = new Set(['/']);
    const files = {};
    const calls = [];
    const norm = p => '/' + String(p).split('/').filter(Boolean).join('/');
    const parentOf = p => norm(p).replace(/\/[^/]*$/, '') || '/';
    return {
        files, calls,
        mkdirRecursive(p) {
            calls.push('mkdir:' + p);
            if (opts.mkdirFails) return false;
            let cur = '';
            for (const seg of String(p).split('/').filter(Boolean)) { cur += '/' + seg; dirs.add(cur); }
            return true;
        },
        writeFile(p, c) {
            calls.push('write:' + p);
            if (opts.writeAlwaysFails) return false;
            if (!dirs.has(parentOf(p))) return false;   // missing parent -> false
            files[norm(p)] = c;
            return true;
        },
        readFile(p) { return (norm(p) in files) ? files[norm(p)] : null; }
    };
}

function makeModule(vfs) {
    const m = new WebDAVModule();
    const out = [];
    m.terminal = {
        print: (msg, cls) => out.push({ msg: String(msg), cls: cls || '' }),
        fs: vfs
    };
    m.config = { url: 'https://webx11.duckdns.org/spaces/chatstudio/', username: 'chatstudio', password: 'tok' };
    m.registerCommands();
    return { m, out };
}
const said = (out, re) => out.some(o => re.test(o.msg));

(async () => {
    // ---------------- (a) humanised autoindex sizes ----------------
    const dir = makeModule(makeVfs()).m;

    // Exactly the shape nginx emits with autoindex_exact_size off: link, date, size.
    const html =
        '<html><body><h1>Index of /</h1><hr><pre>'
        + '<a href="../">../</a>                                          02-Nov-2026 09:13    -\n'
        + '<a href="CHANGELOG.md">CHANGELOG.md</a>                     02-Nov-2026 09:14   21K\n'
        + '<a href="downloads/">downloads/</a>                           02-Nov-2026 09:14    -\n'
        + '<a href="small.txt">small.txt</a>                             02-Nov-2026 09:15   512\n'
        + '<a href="big.bin">big.bin</a>                                 02-Nov-2026 09:16   1.2M\n'
        + '<a href="huge.bin">huge.bin</a>                               02-Nov-2026 09:17   2G\n'
        + '<a href="memgraph.html">memgraph.html</a>                     02-Nov-2026 09:18   45K\n'
        + '</pre><hr></body></html>';
    const entries = dir._parseAutoindex(html);
    const byName = {};
    for (const e of entries) byName[e.name] = e;

    ok('A1 autoindex parsed 6 entries (not the 0-byte fallback)', entries.length === 6,
        entries.length + ': ' + entries.map(e => e.name + '=' + e.size).join(','));
    ok('A0 no entry fell back to a 0 size', entries.every(e => e.type === 'dir' || e.size > 0),
        entries.map(e => e.name + '=' + e.size).join(','));
    ok('A1b 21K is 21504 bytes, not 21', byName['CHANGELOG.md'] && byName['CHANGELOG.md'].size === 21 * 1024,
        byName['CHANGELOG.md'] && byName['CHANGELOG.md'].size);
    ok('A2 45K is 46080 bytes, not 45', byName['memgraph.html'] && byName['memgraph.html'].size === 45 * 1024,
        byName['memgraph.html'] && byName['memgraph.html'].size);
    ok('A3 1.2M is 1.2*1024*1024', byName['big.bin'] && byName['big.bin'].size === Math.round(1.2 * 1024 * 1024),
        byName['big.bin'] && byName['big.bin'].size);
    ok('A4 2G is 2*1024^3', byName['huge.bin'] && byName['huge.bin'].size === 2 * 1024 * 1024 * 1024,
        byName['huge.bin'] && byName['huge.bin'].size);
    ok('A5 plain numeric size stays exact', byName['small.txt'] && byName['small.txt'].size === 512,
        byName['small.txt'] && byName['small.txt'].size);
    ok('A6 directories keep size 0', byName['downloads'] && byName['downloads'].size === 0,
        byName['downloads'] && byName['downloads'].size);

    // The user-visible symptom: webdav-ls printed '21 B' for a 21 KB file.
    ok('A7 _formatSize(21K) no longer renders bytes', dir._formatSize(byName['CHANGELOG.md'].size) === '21.0 KB',
        dir._formatSize(byName['CHANGELOG.md'].size));
    ok('A8 webdav-ls line shows KB not B', dir._formatSize(45 * 1024) === '45.0 KB', dir._formatSize(45 * 1024));

    // Suffix parser edge cases (case-insensitive, optional i/B, junk, missing).
    const psize = s => { try { return dir._parseAutoindexSize(s); } catch (e) { return NaN; } };
    ok('A9 lowercase k', psize('21k') === 21 * 1024, psize('21k'));
    ok('A10 "1KiB" style', psize('1KiB') === 1024, psize('1KiB'));
    ok('A11 integer KB', psize('1KB') === 1024, psize('1KB'));
    ok('A12 \'-\' / junk -> 0', psize('-') === 0 && psize('garbage') === 0,
        psize('-') + '/' + psize('garbage'));

    // PROPFIND must NOT regress: exact getcontentlength bytes, untouched.
    const pf = dir._parsePropfind(JSON.stringify([
        { href: '/spaces/chatstudio/newos4/', collection: 'collection', size: '' },
        { href: '/spaces/chatstudio/newos4/CHANGELOG.md', collection: '', size: '21397' }
    ]));
    const pfe = pf.filter(e => e.name === 'CHANGELOG.md')[0];
    ok('A13 PROPFIND keeps exact 21397 bytes', !!pfe && pfe.size === 21397, pfe && pfe.size);

    // ---------------- (b) webdav-pull into a missing dir ----------------
    // B1: the reported bug — pull to notes/deep/a.txt with no such local dirs.
    {
        const vfs = makeVfs();
        const { m, out } = makeModule(vfs);
        m._webdavRequest = async () => ({ ok: true, text: async () => 'HELLO-WEBDAV' });
        await m.commands['webdav-pull'](['notes/deep/a.txt', 'notes/deep/a.txt']);
        ok('B1 pull into missing dir succeeds and saves the file',
            said(out, /^Pulled: /) && vfs.readFile('/notes/deep/a.txt') === 'HELLO-WEBDAV',
            out.map(o => o.msg).join(' | ') + ' | read=' + JSON.stringify(vfs.readFile('/notes/deep/a.txt')));
        ok('B2 the file really exists locally', vfs.readFile('/notes/deep/a.txt') === 'HELLO-WEBDAV',
            JSON.stringify(vfs.readFile('/notes/deep/a.txt')));
        ok('B3 parent chain was created before the write',
            vfs.calls.indexOf('mkdir:notes/deep') >= 0 &&
            vfs.calls.indexOf('mkdir:notes/deep') < vfs.calls.indexOf('write:notes/deep/a.txt'),
            vfs.calls.join(' > '));
        ok('B4 byte count is the fetched length', said(out, /\(12 bytes\)$/), out.map(o => o.msg).join(' | '));
    }

    // B5: write still fails (e.g. path is a directory) -> error, NO success line.
    {
        const vfs = makeVfs({ writeAlwaysFails: true });
        const { m, out } = makeModule(vfs);
        m._webdavRequest = async () => ({ ok: true, text: async () => 'DATA' });
        await m.commands['webdav-pull'](['x/y.txt', 'x/y.txt']);
        ok('B5 refused write reports failure, not success',
            !said(out, /^Pulled: /) && said(out, /^Pull failed: /), out.map(o => o.msg).join(' | '));
    }

    // B6: cannot even create the directory -> error, NO success line.
    {
        const vfs = makeVfs({ mkdirFails: true });
        const { m, out } = makeModule(vfs);
        m._webdavRequest = async () => ({ ok: true, text: async () => 'DATA' });
        await m.commands['webdav-pull'](['x/y.txt', 'x/y.txt']);
        ok('B6 mkdir failure reports failure, not success',
            !said(out, /^Pulled: /) && said(out, /^Pull failed: /), out.map(o => o.msg).join(' | '));
        ok('B7 nothing was written when mkdir failed', vfs.calls.indexOf('write:x/y.txt') === -1,
            vfs.calls.join(' > '));
    }

    // B8: plain filename in an existing root dir still works (no spurious mkdir).
    {
        const vfs = makeVfs();
        const { m, out } = makeModule(vfs);
        m._webdavRequest = async () => ({ ok: true, text: async () => 'ROOTFILE' });
        await m.commands['webdav-pull'](['file.txt']);
        ok('B8 root-level pull still works', said(out, /^Pulled: /) && vfs.readFile('/file.txt') === 'ROOTFILE',
            out.map(o => o.msg).join(' | '));
        ok('B9 no mkdir for a root-level file', vfs.calls.filter(c => c.startsWith('mkdir:')).length === 0,
            vfs.calls.join(' > '));
    }

    // A14. the parser accepts G, so the formatter must render GB (it printed '3072.0 MB'
    // for a 3 GB file) and must not print 'NaN MB' for a missing size.
    ok('A14a 2 GiB renders as GB', WebDAVModule.prototype._formatSize.call(null, 2 * 1024 * 1024 * 1024) === '2.0 GB', WebDAVModule.prototype._formatSize.call(null, 2 * 1024 * 1024 * 1024));
    ok('A14b undefined does not render NaN', WebDAVModule.prototype._formatSize.call(null, undefined) === '—', WebDAVModule.prototype._formatSize.call(null, undefined));
    ok('A14c 0 bytes is still B', WebDAVModule.prototype._formatSize.call(null, 0) === '0 B', WebDAVModule.prototype._formatSize.call(null, 0));

    console.log('test-webdav-sizes: ' + passes + ' passed, ' + fails + ' failed');
    process.exit(fails ? 1 : 0);
})().catch(e => {
    console.log('FAIL harness threw — ' + (e && e.stack || e));
    process.exit(1);
});