mirror of
https://github.com/katelya77/K-Vault.git
synced 2026-05-06 14:00:20 +08:00
feat: 增加WebDAV认证回退机制,优化文件下载与连接检查逻辑
This commit is contained in:
22
README-EN.md
22
README-EN.md
@@ -158,6 +158,28 @@ docker compose --profile redis up -d --build
|
||||
|
||||
For full Docker guide, see [README-DOCKER.md](README-DOCKER.md).
|
||||
|
||||
### WebDAV Regression Validation (Works for Pages and Docker)
|
||||
|
||||
After deployment, run at least one WebDAV smoke check to verify the full flow: config test -> upload -> download -> delete.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
BASE_URL=https://your-domain \
|
||||
BASIC_USER=admin BASIC_PASS=your_password \
|
||||
SMOKE_STORAGE_TYPE=webdav \
|
||||
SMOKE_STORAGE_CONFIG_JSON='{"baseUrl":"https://dav.example.com","username":"u","password":"p","rootPath":"uploads"}' \
|
||||
node scripts/storage-regression.js
|
||||
```
|
||||
|
||||
Validation criteria:
|
||||
|
||||
- `webdav.connected` in `/api/status` must be `true`
|
||||
- `/api/storage/:id/test` must return `connected=true`
|
||||
- WebDAV `upload / download / delete` in the regression script must all pass
|
||||
|
||||
For Docker deployment, simply change `BASE_URL` to your self-hosted address, for example `http://localhost:8080`.
|
||||
|
||||
---
|
||||
|
||||
## Storage Configuration
|
||||
|
||||
22
README.md
22
README.md
@@ -136,6 +136,28 @@ docker compose --profile redis up -d --build
|
||||
|
||||
完整 Docker 说明请查看 [README-DOCKER.md](README-DOCKER.md)。
|
||||
|
||||
### WebDAV 回归验证(Cloudflare Pages / Docker 通用)
|
||||
|
||||
部署完成后,建议至少执行一次 WebDAV 烟测,确认“配置测试 -> 上传 -> 下载 -> 删除”完整闭环。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
BASE_URL=https://你的域名 \
|
||||
BASIC_USER=admin BASIC_PASS=your_password \
|
||||
SMOKE_STORAGE_TYPE=webdav \
|
||||
SMOKE_STORAGE_CONFIG_JSON='{"baseUrl":"https://dav.example.com","username":"u","password":"p","rootPath":"uploads"}' \
|
||||
node scripts/storage-regression.js
|
||||
```
|
||||
|
||||
校验标准:
|
||||
|
||||
- `/api/status` 中 `webdav.connected` 必须为 `true`
|
||||
- `/api/storage/:id/test` 必须返回 `connected=true`
|
||||
- 回归脚本中的 WebDAV `upload / download / delete` 三步必须全部通过
|
||||
|
||||
如果是 Docker 部署,只需把 `BASE_URL` 换成你的自托管地址,例如 `http://localhost:8080`。
|
||||
|
||||
### Docker 登录 API(curl 示例)
|
||||
|
||||
`/api/auth/login` 同时兼容两种请求体:
|
||||
|
||||
@@ -140,6 +140,21 @@ async function fetchDavWithAuthMode(config, method, storagePath = '', mode = 'no
|
||||
});
|
||||
}
|
||||
|
||||
function buildAuthAttemptModes(config) {
|
||||
const attemptedModes = [];
|
||||
const pushMode = (mode) => {
|
||||
if (!mode || attemptedModes.includes(mode)) return;
|
||||
attemptedModes.push(mode);
|
||||
};
|
||||
|
||||
pushMode(authMode(config));
|
||||
if (hasBearerAuth(config)) pushMode('bearer');
|
||||
if (hasBasicAuth(config)) pushMode('basic');
|
||||
pushMode('none');
|
||||
|
||||
return attemptedModes;
|
||||
}
|
||||
|
||||
async function ensureCollectionPath(config, storagePath) {
|
||||
const rootSegments = splitPath(config.rootPath);
|
||||
const fileSegments = splitPath(storagePath);
|
||||
@@ -220,16 +235,7 @@ export async function getWebDAVFile(storagePath, env = {}, options = {}) {
|
||||
headers.Range = options.range;
|
||||
}
|
||||
|
||||
const attemptedModes = [];
|
||||
const pushMode = (mode) => {
|
||||
if (!mode || attemptedModes.includes(mode)) return;
|
||||
attemptedModes.push(mode);
|
||||
};
|
||||
|
||||
pushMode(authMode(config));
|
||||
if (hasBearerAuth(config)) pushMode('bearer');
|
||||
if (hasBasicAuth(config)) pushMode('basic');
|
||||
pushMode('none');
|
||||
const attemptedModes = buildAuthAttemptModes(config);
|
||||
|
||||
let firstFailure = null;
|
||||
|
||||
@@ -294,65 +300,51 @@ export async function checkWebDAVConnection(env = {}) {
|
||||
let lastDetail = '';
|
||||
let lastStatus = null;
|
||||
|
||||
for (const rootUrl of rootCandidates) {
|
||||
const authHeaders = buildAuthHeaders(config, { Depth: '0' });
|
||||
const optionsResponse = await fetch(rootUrl, {
|
||||
method: 'OPTIONS',
|
||||
headers: authHeaders,
|
||||
});
|
||||
const attemptedModes = buildAuthAttemptModes(config);
|
||||
|
||||
if (optionsResponse.ok) {
|
||||
return {
|
||||
connected: true,
|
||||
configured: true,
|
||||
status: optionsResponse.status,
|
||||
message: 'Connected',
|
||||
};
|
||||
for (const mode of attemptedModes) {
|
||||
for (const rootUrl of rootCandidates) {
|
||||
const optionsResponse = await fetch(rootUrl, {
|
||||
method: 'OPTIONS',
|
||||
headers: buildAuthOverrideHeaders(config, mode, { Depth: '0' }),
|
||||
});
|
||||
|
||||
if (optionsResponse.ok) {
|
||||
return {
|
||||
connected: true,
|
||||
configured: true,
|
||||
status: optionsResponse.status,
|
||||
message: 'Connected',
|
||||
};
|
||||
}
|
||||
|
||||
if (![400, 401, 403, 405].includes(optionsResponse.status)) {
|
||||
lastStatus = optionsResponse.status;
|
||||
lastDetail = await decodeErrorTextSafe(optionsResponse);
|
||||
}
|
||||
|
||||
const propfindResponse = await fetch(rootUrl, {
|
||||
method: 'PROPFIND',
|
||||
headers: buildAuthOverrideHeaders(config, mode, {
|
||||
Depth: '0',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
}),
|
||||
body: propfindBody,
|
||||
});
|
||||
|
||||
const connected = propfindResponse.ok || propfindResponse.status === 207;
|
||||
if (connected) {
|
||||
return {
|
||||
connected: true,
|
||||
configured: true,
|
||||
status: propfindResponse.status,
|
||||
message: 'Connected',
|
||||
};
|
||||
}
|
||||
|
||||
lastStatus = propfindResponse.status;
|
||||
lastDetail = await decodeErrorTextSafe(propfindResponse);
|
||||
}
|
||||
|
||||
if ([401, 403].includes(optionsResponse.status)) {
|
||||
const detail = await decodeErrorTextSafe(optionsResponse);
|
||||
return {
|
||||
connected: false,
|
||||
configured: true,
|
||||
status: optionsResponse.status,
|
||||
message: 'Authentication failed',
|
||||
detail: detail || 'Authentication failed',
|
||||
};
|
||||
}
|
||||
|
||||
const propfindResponse = await fetch(rootUrl, {
|
||||
method: 'PROPFIND',
|
||||
headers: buildAuthHeaders(config, {
|
||||
Depth: '0',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
}),
|
||||
body: propfindBody,
|
||||
});
|
||||
|
||||
const connected = propfindResponse.ok || propfindResponse.status === 207;
|
||||
if (connected) {
|
||||
return {
|
||||
connected: true,
|
||||
configured: true,
|
||||
status: propfindResponse.status,
|
||||
message: 'Connected',
|
||||
};
|
||||
}
|
||||
|
||||
if ([401, 403].includes(propfindResponse.status)) {
|
||||
const detail = await decodeErrorTextSafe(propfindResponse);
|
||||
return {
|
||||
connected: false,
|
||||
configured: true,
|
||||
status: propfindResponse.status,
|
||||
message: 'Authentication failed',
|
||||
detail: detail || 'Authentication failed',
|
||||
};
|
||||
}
|
||||
|
||||
lastStatus = propfindResponse.status;
|
||||
lastDetail = await decodeErrorTextSafe(propfindResponse);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -133,6 +133,13 @@ async function checkHealthAndStatus() {
|
||||
}
|
||||
}
|
||||
logOk('/api/status includes all storage keys');
|
||||
|
||||
if (SMOKE_STORAGE_TYPE) {
|
||||
const smokeStatus = status.payload?.[SMOKE_STORAGE_TYPE];
|
||||
if (smokeStatus?.configured && !smokeStatus?.connected) {
|
||||
throw new Error(`/api/status reports ${SMOKE_STORAGE_TYPE} disconnected: ${smokeStatus.message || smokeStatus.detail || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseSmokeConfig() {
|
||||
@@ -189,6 +196,9 @@ async function storageCrudAndSelection() {
|
||||
});
|
||||
const testBody = createdTest.payload || {};
|
||||
const testResult = testBody.result || testBody;
|
||||
if (!testResult.connected) {
|
||||
throw new Error(`created storage test failed: ${testResult.detail || testResult.message || 'unknown error'}`);
|
||||
}
|
||||
logOk(`created storage test -> connected=${Boolean(testResult.connected)} status=${testResult.status || 'n/a'}`);
|
||||
|
||||
await request(`/api/storage/default/${encodeURIComponent(created.id)}`, {
|
||||
@@ -290,9 +300,9 @@ async function cleanupStorage(createdId, originalDefaultId) {
|
||||
async function main() {
|
||||
process.stdout.write(`K-Vault storage regression start\nBASE_URL=${BASE_URL}\n`);
|
||||
|
||||
await checkHealthAndStatus();
|
||||
await ensureLoginIfNeeded();
|
||||
const { createdId, originalDefault } = await storageCrudAndSelection();
|
||||
await checkHealthAndStatus();
|
||||
await uploadDownloadDeleteForEnabledStorages();
|
||||
await cleanupStorage(createdId, originalDefault);
|
||||
|
||||
|
||||
@@ -1088,19 +1088,24 @@ function createApp() {
|
||||
const id = decodeURIComponent(c.req.param('id'));
|
||||
const range = c.req.header('range');
|
||||
|
||||
const result = await uploadService.getFileResponse(id, range);
|
||||
if (!result) {
|
||||
return c.text('File not found', 404);
|
||||
try {
|
||||
const result = await uploadService.getFileResponse(id, range);
|
||||
if (!result) {
|
||||
return c.text('File not found', 404);
|
||||
}
|
||||
|
||||
const upstream = result.response;
|
||||
const headers = buildFileProxyHeaders(result, upstream.headers);
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('file proxy route error:', error);
|
||||
return c.text(`File proxy error: ${error?.message || 'Unknown error'}`, 502);
|
||||
}
|
||||
|
||||
const upstream = result.response;
|
||||
const headers = buildFileProxyHeaders(result, upstream.headers);
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
});
|
||||
|
||||
app.options('/file/:id', (c) => c.body(null, 204));
|
||||
@@ -1109,19 +1114,26 @@ function createApp() {
|
||||
const id = decodeURIComponent(c.req.param('id'));
|
||||
const range = c.req.header('range');
|
||||
|
||||
const result = await uploadService.getFileResponse(id, range);
|
||||
if (!result) {
|
||||
return c.body(null, 404);
|
||||
try {
|
||||
const result = await uploadService.getFileResponse(id, range);
|
||||
if (!result) {
|
||||
return c.body(null, 404);
|
||||
}
|
||||
|
||||
const upstream = result.response;
|
||||
const headers = buildFileProxyHeaders(result, upstream.headers);
|
||||
|
||||
return new Response(null, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('file proxy HEAD route error:', error);
|
||||
return c.body(null, 502, {
|
||||
'X-File-Proxy-Error': String(error?.message || 'Unknown error').slice(0, 200),
|
||||
});
|
||||
}
|
||||
|
||||
const upstream = result.response;
|
||||
const headers = buildFileProxyHeaders(result, upstream.headers);
|
||||
|
||||
return new Response(null, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/share/:id', async (c) => {
|
||||
@@ -1143,20 +1155,25 @@ function createApp() {
|
||||
return c.text('Invalid share signature.', 403);
|
||||
}
|
||||
|
||||
const result = await uploadService.getFileResponse(fileId, range);
|
||||
if (!result) {
|
||||
return c.text('File not found', 404);
|
||||
try {
|
||||
const result = await uploadService.getFileResponse(fileId, range);
|
||||
if (!result) {
|
||||
return c.text('File not found', 404);
|
||||
}
|
||||
|
||||
const upstream = result.response;
|
||||
const headers = buildFileProxyHeaders(result, upstream.headers);
|
||||
headers.set('Cache-Control', 'private, max-age=60');
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('share proxy route error:', error);
|
||||
return c.text(`Share proxy error: ${error?.message || 'Unknown error'}`, 502);
|
||||
}
|
||||
|
||||
const upstream = result.response;
|
||||
const headers = buildFileProxyHeaders(result, upstream.headers);
|
||||
headers.set('Cache-Control', 'private, max-age=60');
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
});
|
||||
|
||||
app.options('/share/:id', (c) => c.body(null, 204));
|
||||
|
||||
@@ -44,6 +44,29 @@ function authMode(config) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function hasBasicAuth(config) {
|
||||
return Boolean(config.username && config.password);
|
||||
}
|
||||
|
||||
function hasBearerAuth(config) {
|
||||
return Boolean(config.bearerToken);
|
||||
}
|
||||
|
||||
function buildAuthAttemptModes(config) {
|
||||
const attemptedModes = [];
|
||||
const pushMode = (mode) => {
|
||||
if (!mode || attemptedModes.includes(mode)) return;
|
||||
attemptedModes.push(mode);
|
||||
};
|
||||
|
||||
pushMode(authMode(config));
|
||||
if (hasBearerAuth(config)) pushMode('bearer');
|
||||
if (hasBasicAuth(config)) pushMode('basic');
|
||||
pushMode('none');
|
||||
|
||||
return attemptedModes;
|
||||
}
|
||||
|
||||
function decodeResponseTextSafe(response) {
|
||||
return response.text().catch(() => '');
|
||||
}
|
||||
@@ -83,6 +106,21 @@ class WebDAVStorageAdapter {
|
||||
return headers;
|
||||
}
|
||||
|
||||
getAuthHeadersForMode(mode = 'none', extra = {}) {
|
||||
const headers = { ...extra };
|
||||
if (mode === 'bearer' && hasBearerAuth(this.config)) {
|
||||
headers.Authorization = `Bearer ${this.config.bearerToken}`;
|
||||
return headers;
|
||||
}
|
||||
if (mode === 'basic' && hasBasicAuth(this.config)) {
|
||||
const token = Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64');
|
||||
headers.Authorization = `Basic ${token}`;
|
||||
return headers;
|
||||
}
|
||||
delete headers.Authorization;
|
||||
return headers;
|
||||
}
|
||||
|
||||
buildSegments(storageKey = '') {
|
||||
return [...splitPath(this.config.rootPath), ...splitPath(storageKey)];
|
||||
}
|
||||
@@ -100,6 +138,14 @@ class WebDAVStorageAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDavForMode(method, storageKey = '', mode = 'none', { headers = {}, body = null } = {}) {
|
||||
return fetch(this.buildUrl(storageKey), {
|
||||
method,
|
||||
headers: this.getAuthHeadersForMode(mode, headers),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
this.validate();
|
||||
|
||||
@@ -115,58 +161,49 @@ class WebDAVStorageAdapter {
|
||||
let lastStatus = null;
|
||||
let lastDetail = '';
|
||||
|
||||
for (const rootUrl of rootCandidates) {
|
||||
const optionsResponse = await fetch(rootUrl, {
|
||||
method: 'OPTIONS',
|
||||
headers: this.getAuthHeaders({ Depth: '0' }),
|
||||
});
|
||||
const attemptedModes = buildAuthAttemptModes(this.config);
|
||||
|
||||
if (optionsResponse.ok) {
|
||||
return {
|
||||
connected: true,
|
||||
status: optionsResponse.status,
|
||||
for (const mode of attemptedModes) {
|
||||
for (const rootUrl of rootCandidates) {
|
||||
const optionsResponse = await fetch(rootUrl, {
|
||||
method: 'OPTIONS',
|
||||
};
|
||||
}
|
||||
headers: this.getAuthHeadersForMode(mode, { Depth: '0' }),
|
||||
});
|
||||
|
||||
if (optionsResponse.status === 401 || optionsResponse.status === 403) {
|
||||
return {
|
||||
connected: false,
|
||||
status: optionsResponse.status,
|
||||
method: 'OPTIONS',
|
||||
detail: 'Authentication failed for WebDAV endpoint.',
|
||||
};
|
||||
}
|
||||
if (optionsResponse.ok) {
|
||||
return {
|
||||
connected: true,
|
||||
status: optionsResponse.status,
|
||||
method: 'OPTIONS',
|
||||
};
|
||||
}
|
||||
|
||||
const propfindResponse = await fetch(rootUrl, {
|
||||
method: 'PROPFIND',
|
||||
headers: this.getAuthHeaders({
|
||||
Depth: '0',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
}),
|
||||
body: propfindBody,
|
||||
});
|
||||
if (![400, 401, 403, 405].includes(optionsResponse.status)) {
|
||||
lastStatus = optionsResponse.status;
|
||||
lastDetail = await decodeResponseTextSafe(optionsResponse);
|
||||
}
|
||||
|
||||
const connected = propfindResponse.ok || propfindResponse.status === 207;
|
||||
if (connected) {
|
||||
return {
|
||||
connected: true,
|
||||
status: propfindResponse.status,
|
||||
const propfindResponse = await fetch(rootUrl, {
|
||||
method: 'PROPFIND',
|
||||
};
|
||||
}
|
||||
headers: this.getAuthHeadersForMode(mode, {
|
||||
Depth: '0',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
}),
|
||||
body: propfindBody,
|
||||
});
|
||||
|
||||
if (propfindResponse.status === 401 || propfindResponse.status === 403) {
|
||||
return {
|
||||
connected: false,
|
||||
status: propfindResponse.status,
|
||||
method: 'PROPFIND',
|
||||
detail: 'Authentication failed for WebDAV endpoint.',
|
||||
};
|
||||
}
|
||||
const connected = propfindResponse.ok || propfindResponse.status === 207;
|
||||
if (connected) {
|
||||
return {
|
||||
connected: true,
|
||||
status: propfindResponse.status,
|
||||
method: 'PROPFIND',
|
||||
};
|
||||
}
|
||||
|
||||
lastStatus = propfindResponse.status;
|
||||
lastDetail = await decodeResponseTextSafe(propfindResponse);
|
||||
lastStatus = propfindResponse.status;
|
||||
lastDetail = await decodeResponseTextSafe(propfindResponse);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -260,13 +297,42 @@ class WebDAVStorageAdapter {
|
||||
const headers = {};
|
||||
if (range) headers.Range = range;
|
||||
|
||||
const response = await this.fetchDav('GET', storageKey, { headers });
|
||||
if (!response.ok && response.status !== 206) {
|
||||
const attemptedModes = [];
|
||||
const pushMode = (mode) => {
|
||||
if (!mode || attemptedModes.includes(mode)) return;
|
||||
attemptedModes.push(mode);
|
||||
};
|
||||
|
||||
pushMode(authMode(this.config));
|
||||
if (hasBearerAuth(this.config)) pushMode('bearer');
|
||||
if (hasBasicAuth(this.config)) pushMode('basic');
|
||||
pushMode('none');
|
||||
|
||||
let firstFailure = null;
|
||||
|
||||
for (const mode of attemptedModes) {
|
||||
const response = await this.fetchDavForMode('GET', storageKey, mode, { headers });
|
||||
if (response.ok || response.status === 206) {
|
||||
return response;
|
||||
}
|
||||
if (response.status === 404) return null;
|
||||
const detail = await decodeResponseTextSafe(response);
|
||||
throw new Error(`WebDAV download failed (${response.status}): ${detail || 'Unknown error'}`);
|
||||
|
||||
if (!firstFailure) {
|
||||
const detail = await decodeResponseTextSafe(response);
|
||||
firstFailure = { status: response.status, detail };
|
||||
}
|
||||
|
||||
if (![400, 401, 403].includes(response.status)) {
|
||||
const detail = await decodeResponseTextSafe(response);
|
||||
throw new Error(`WebDAV download failed (${response.status}): ${detail || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
|
||||
if (firstFailure) {
|
||||
throw new Error(`WebDAV download failed (${firstFailure.status}): ${firstFailure.detail || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
throw new Error('WebDAV download failed: Unknown error');
|
||||
}
|
||||
|
||||
async delete({ storageKey }) {
|
||||
|
||||
109
test/server-webdav-adapter.test.js
Normal file
109
test/server-webdav-adapter.test.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const assert = require('assert');
|
||||
const { WebDAVStorageAdapter } = require('../server/lib/storage/adapters/webdav');
|
||||
|
||||
describe('Server WebDAV adapter download fallback', function () {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(function () {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('falls back from bearer to basic auth', async function () {
|
||||
const seenAuth = [];
|
||||
let callCount = 0;
|
||||
|
||||
global.fetch = async (_url, init = {}) => {
|
||||
callCount += 1;
|
||||
const auth = init?.headers?.Authorization || '';
|
||||
seenAuth.push(auth);
|
||||
if (callCount === 1) {
|
||||
return new Response('unauthorized', { status: 401 });
|
||||
}
|
||||
return new Response('ok', { status: 200 });
|
||||
};
|
||||
|
||||
const adapter = new WebDAVStorageAdapter({
|
||||
baseUrl: 'https://example.com/dav',
|
||||
bearerToken: 'bad-token',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
const response = await adapter.download({ storageKey: 'uploads/file.png' });
|
||||
const body = await response.text();
|
||||
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(body, 'ok');
|
||||
assert.strictEqual(callCount, 2);
|
||||
assert.ok(String(seenAuth[0]).startsWith('Bearer '));
|
||||
assert.ok(String(seenAuth[1]).startsWith('Basic '));
|
||||
});
|
||||
|
||||
it('falls back to anonymous when authenticated reads are rejected', async function () {
|
||||
const seenAuth = [];
|
||||
let callCount = 0;
|
||||
|
||||
global.fetch = async (_url, init = {}) => {
|
||||
callCount += 1;
|
||||
const auth = init?.headers?.Authorization || '';
|
||||
seenAuth.push(auth);
|
||||
if (callCount === 1) {
|
||||
return new Response('forbidden', { status: 403 });
|
||||
}
|
||||
return new Response('public', { status: 200 });
|
||||
};
|
||||
|
||||
const adapter = new WebDAVStorageAdapter({
|
||||
baseUrl: 'https://example.com/dav',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
const response = await adapter.download({ storageKey: 'uploads/public.png' });
|
||||
const body = await response.text();
|
||||
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(body, 'public');
|
||||
assert.strictEqual(callCount, 2);
|
||||
assert.ok(String(seenAuth[0]).startsWith('Basic '));
|
||||
assert.strictEqual(String(seenAuth[1]), '');
|
||||
});
|
||||
|
||||
it('returns null on 404', async function () {
|
||||
global.fetch = async () => new Response('missing', { status: 404 });
|
||||
|
||||
const adapter = new WebDAVStorageAdapter({
|
||||
baseUrl: 'https://example.com/dav',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
const response = await adapter.download({ storageKey: 'uploads/missing.png' });
|
||||
assert.strictEqual(response, null);
|
||||
});
|
||||
|
||||
it('testConnection falls back from bearer to basic auth', async function () {
|
||||
const seenAuth = [];
|
||||
|
||||
global.fetch = async (_url, init = {}) => {
|
||||
const auth = init?.headers?.Authorization || '';
|
||||
seenAuth.push(auth);
|
||||
if (String(auth).startsWith('Bearer ')) {
|
||||
return new Response('unauthorized', { status: 401 });
|
||||
}
|
||||
return new Response('<?xml version="1.0"?><multistatus/>', { status: 207 });
|
||||
};
|
||||
|
||||
const adapter = new WebDAVStorageAdapter({
|
||||
baseUrl: 'https://example.com/dav',
|
||||
bearerToken: 'bad-token',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
const result = await adapter.testConnection();
|
||||
assert.strictEqual(result.connected, true);
|
||||
assert.ok(String(seenAuth[0]).startsWith('Bearer '));
|
||||
assert.ok(seenAuth.some((value) => String(value).startsWith('Basic ')));
|
||||
});
|
||||
});
|
||||
@@ -83,4 +83,30 @@ describe('WebDAV download auth fallback', function () {
|
||||
const response = await getWebDAVFile('uploads/missing.png', env);
|
||||
assert.strictEqual(response, null);
|
||||
});
|
||||
|
||||
it('connection check falls back from bearer to basic auth', async function () {
|
||||
const authHeaders = [];
|
||||
|
||||
global.fetch = async (_url, init = {}) => {
|
||||
const auth = init?.headers?.Authorization || '';
|
||||
authHeaders.push(auth);
|
||||
if (String(auth).startsWith('Bearer ')) {
|
||||
return new Response('unauthorized', { status: 401 });
|
||||
}
|
||||
return new Response('<?xml version="1.0"?><multistatus/>', { status: 207 });
|
||||
};
|
||||
|
||||
const { checkWebDAVConnection } = await import('../functions/utils/webdav.js');
|
||||
const env = {
|
||||
WEBDAV_BASE_URL: 'https://example.com/dav',
|
||||
WEBDAV_BEARER_TOKEN: 'bad-token',
|
||||
WEBDAV_USERNAME: 'user',
|
||||
WEBDAV_PASSWORD: 'pass',
|
||||
};
|
||||
|
||||
const result = await checkWebDAVConnection(env);
|
||||
assert.strictEqual(result.connected, true);
|
||||
assert.ok(String(authHeaders[0]).startsWith('Bearer '));
|
||||
assert.ok(authHeaders.some((value) => String(value).startsWith('Basic ')));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user