aboutsummaryrefslogtreecommitdiff
path: root/scripts/test-webdav-paths.mjs
blob: d75f9f6217e29f7d7505aa5427ec5ba5e248e5f5 (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
// v4.6.16 regression test: WebDAV remote-path normalization.
// Reproduces the mindtech incident: AI passed full URLs / absolute
// '/spaces/<user>/' paths which got blindly joined onto the config URL,
// creating literal 'https:/' and nested 'spaces/<user>/' junk dirs.
// Run: node scripts/test-webdav-paths.mjs  (repo root = spaces/newos4)

// Run: node scripts/test-webdav-paths.mjs  (repo root = spaces/newos4)

import { readFileSync } from 'fs';

const src = readFileSync(new URL('../modules/ext/webdav.js', import.meta.url), 'utf8');

class TerminalStub {
    print() {}
}

// Mirrors ModuleManager.createModuleClass(): extract the first top-level class.
const cleanCode = src.replace(/^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*\s*/, '');
const classMatch = cleanCode.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/);
if (!classMatch) throw new Error('no module class found in webdav.js');
const WebDAVModule = new Function('window', 'localStorage', 'fetch', 'XMLHttpRequest', 'URL', 'crypto',
    'return ' + cleanCode + '\nreturn ' + classMatch[1])(
    { location: { pathname: '/spaces/mindtech/newos4/main.html' } },
    { getItem: () => null, setItem: () => {} },
    async () => { throw new Error('network disabled in test'); },
    function () { this.open = () => {}; this.setRequestHeader = () => {}; this.send = () => {}; },
    URL, undefined);

function makeMod() {
    const m = new WebDAVModule(new TerminalStub());
    m.config = { url: 'https://webx11.duckdns.org/spaces/mindtech/', username: 'mindtech', password: 'tok' };
    return m;
}

let pass = 0, fail = 0;
function check(name, cond, got) {
    if (cond) { pass++; console.log('  ok - ' + name); }
    else { fail++; console.log('  FAIL - ' + name + (got !== undefined ? '  got: ' + JSON.stringify(got) : '')); }
}

// --- _normalizeRemotePath ---
{
    const m = makeMod();
    const cases = [
        // [input, expected]
        [null, '/'],
        ['', '/'],
        ['file.txt', '/file.txt'],
        ['/file.txt', '/file.txt'],
        // the mindtech incident shapes:
        ['https://webx11.duckdns.org/spaces/mindtech/thaimassage-kiefersfelden/index-traditional.html',
         '/thaimassage-kiefersfelden/index-traditional.html'],
        ['/spaces/mindtech/thaimassage-kiefersfelden/index-professional.html',
         '/thaimassage-kiefersfelden/index-professional.html'],
        ['https://webx11.duckdns.org/spaces/mindtech', '/'],
        ['spaces/mindtech/skills/navigating-the-terminal.json', '/skills/navigating-the-terminal.json'],
        // relative + traversal + dupes:
        ['./a//b/../c.txt', '/a/c.txt'],
        ['/a/b/../../d.txt', '/d.txt'],
        ['/skills/', '/skills/'],  // trailing slash PRESERVED (nginx dav collection ops need it)
        ['backups', '/backups'],
        // foreign space stays intact (only own space prefix is stripped):
        ['/spaces/otheruser/file.txt', '/spaces/otheruser/file.txt'],
        ['https://evil.example.com/spaces/mindtech/x?query=1#frag', '/x'],
    ];
    for (const [input, expected] of cases) {
        const got = m._normalizeRemotePath(input);
        check(`normalize(${JSON.stringify(input)}) => ${expected}`, got === expected, got);
    }
}

// --- _absoluteUrl (MOVE/COPY Destination) ---
{
    const m = makeMod();
    const got = m._absoluteUrl('https://webx11.duckdns.org/spaces/mindtech/a/b.html');
    check('_absoluteUrl strips to full absolute URI',
          got === 'https://webx11.duckdns.org/spaces/mindtech/a/b.html', got);
    const got2 = m._absoluteUrl('c.html');
    check('_absoluteUrl builds from config base',
          got2 === 'https://webx11.duckdns.org/spaces/mindtech/c.html', got2);
}

// --- _ensureDir normalizes before MKCOL walk ---
{
    const m = makeMod();
    const mkcols = [];
    m._webdavRequest = async (method, path) => {
        if (method === 'MKCOL') mkcols.push(path);
        return { ok: true, status: 201 };
    };
    m._listDir = async () => { throw new Error('ENOENT'); };
    await m._ensureDir('https://webx11.duckdns.org/spaces/mindtech/thaimassage-kiefersfelden');
    check('_ensureDir MKCOLs normalized path (no https:/ junk, trailing slash for nginx)',
          mkcols.length === 1 && mkcols[0] === '/thaimassage-kiefersfelden/', mkcols);

    // Existing dir fast-path: __list__ probe succeeds -> no MKCOL at all
    const m2 = makeMod();
    const mkcols2 = [];
    m2._listDir = async (p) => { if (p === '/skills') return []; throw new Error('ENOENT'); };
    m2._webdavRequest = async (method, path) => { if (method === 'MKCOL') mkcols2.push(path); return { ok: true, status: 201 }; };
    await m2._ensureDir('/skills');
    check('_ensureDir skips MKCOL when dir already exists', mkcols2.length === 0, mkcols2);

    // Partial existing: /backups exists, /backups/2026 does not
    const m3 = makeMod();
    const mkcols3 = [];
    m3._listDir = async (p) => { if (p === '/backups') return []; throw new Error('ENOENT'); };
    m3._webdavRequest = async (method, path) => { if (method === 'MKCOL') mkcols3.push(path); return { ok: true, status: 201 }; };
    await m3._ensureDir('/backups/2026');
    check('_ensureDir MKCOLs only missing tail (trailing slash for nginx)',
          mkcols3.length === 1 && mkcols3[0] === '/backups/2026/', mkcols3);
}

// --- PUT via _webdavRequest hits normalized URL ---
{
    // The class closes over the injected fetch param — rebuild with a
    // recording fetch so we can assert the request URL.
    let requested = null;
    const recordFetch = async (url) => { requested = url; return { ok: true, status: 201 }; };
    const WebDAVModule2 = new Function('window', 'localStorage', 'fetch', 'XMLHttpRequest', 'URL', 'crypto',
        'return ' + cleanCode + '\nreturn ' + classMatch[1])(
        { location: { pathname: '/spaces/mindtech/newos4/main.html' } },
        { getItem: () => null, setItem: () => {} },
        recordFetch,
        function () { this.open = () => {}; this.setRequestHeader = () => {}; this.send = () => {}; },
        URL, undefined);
    const m = new WebDAVModule2(new TerminalStub());
    m.config = { url: 'https://webx11.duckdns.org/spaces/mindtech/', username: 'mindtech', password: 'tok' };
    m._resolvePassword = async () => {};
    m._getAuthHeader = () => ({});
    await m._webdavRequest('PUT', 'https://webx11.duckdns.org/spaces/mindtech/site/index.html', { body: 'x' });
    check('PUT goes to normalized URL',
          requested === 'https://webx11.duckdns.org/spaces/mindtech/site/index.html', requested);
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);