Initial commit: LinkSyncServer and LinkSyncExtension projects with complete documentation, models, API endpoints, tests, and extension implementation

This commit is contained in:
DavidSaylor
2026-05-11 17:37:10 -05:00
parent ad0b12b452
commit aed69afdfd
691 changed files with 181874 additions and 28 deletions

View File

@@ -0,0 +1,628 @@
# Phase 0: Server Behavior Validation Tests
## Overview
This document outlines the Phase 0 testing plan to validate Linkding server behavior regarding bookmark isolation, conflict resolution, and API authentication. These tests will inform the final architecture and conflict resolution strategy.
## Prerequisites
### Test Environment Setup
You need to create the following on your Linkding instance:
1. **Two User Accounts**:
- `user_work` - For work-related bookmarks
- `user_personal` - For personal bookmarks
2. **API Keys per User**:
- Each user needs at least 2 API keys (one for each bundle)
- Example: `user_work_key_1`, `user_work_key_2`, `user_personal_key_1`, `user_personal_key_2`
3. **Two Bundles per User**:
- `work` bundle (for work bookmarks)
- `personal` bundle (for personal bookmarks)
### Firefox Test Harness
We'll create a Firefox extension (or use existing Linkding extension) that:
- Allows testing with multiple API keys
- Can switch between work/personal contexts
- Runs tests via DevTools console or automated scripts
---
## Test Scenarios
### Scenario 1: Same URL, Different API Keys, Same User
**Purpose**: Verify if API keys provide bookmark isolation within the same user.
**Setup**:
1. Create bookmark via `user_work_key_1` with URL: `https://example.com`
2. Create same bookmark via `user_work_key_2` with same URL
**Expected Behavior**:
- If API keys only authenticate the user, both calls create/update the same bookmark
- API keys don't provide isolation - only user matters
**Test Command**:
```javascript
// Test 1: Same user, different API keys
async function testSameUserDifferentKeys(serverUrl, apiKey1, apiKey2) {
try {
// Create with first key
const response1 = await fetch(`${serverUrl}api/bookmarks/`, {
method: 'POST',
headers: {
'Authorization': `Token ${apiKey1}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://example.com',
title: 'Test Bookmark 1',
description: 'First key',
notes: '{"path": "Work/Dev1", "userNotes": "Key 1"}'
})
});
const bookmark1 = await response1.json();
console.log('Bookmark 1:', bookmark1);
// Try creating same URL with second key
const response2 = await fetch(`${serverUrl}api/bookmarks/`, {
method: 'POST',
headers: {
'Authorization': `Token ${apiKey2}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://example.com',
title: 'Test Bookmark 2',
description: 'Second key',
notes: '{"path": "Work/Dev2", "userNotes": "Key 2"}'
})
});
const bookmark2 = await response2.json();
console.log('Bookmark 2:', bookmark2);
// Check if they're the same bookmark (same ID)
if (bookmark1.id === bookmark2.id) {
console.log('RESULT: Same bookmark (API keys do not isolate)');
} else {
console.log('RESULT: Different bookmarks (API keys provide isolation)');
}
} catch (error) {
console.error('Error:', error.message);
}
}
```
---
### Scenario 2: Same URL, Different Users
**Purpose**: Verify if different users can see each other's bookmarks without sharing enabled.
**Setup**:
1. User A creates bookmark via their API key
2. User B tries to retrieve same URL via their API key
**Expected Behavior**:
- Without sharing: User B should get 404 or empty results
- With sharing enabled: Both can see each other's bookmarks
**Test Command**:
```javascript
// Test 2: Different users, no sharing
async function testDifferentUsersNoSharing(serverUrl, userAKey, userBKey) {
try {
// Create bookmark as User A
const createResponse = await fetch(`${serverUrl}api/bookmarks/`, {
method: 'POST',
headers: {
'Authorization': `Token ${userAKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://shared-example.com',
title: 'Shared Bookmark',
description: 'Created by User A',
notes: '{"path": "UserA/Shared"}'
})
});
const bookmark = await createResponse.json();
console.log('Bookmark created by User A:', bookmark.id);
// User B tries to get this bookmark
const getResponse = await fetch(`${serverUrl}api/bookmarks/?all=Shared`, {
headers: {
'Authorization': `Token ${userBKey}`,
}
});
if (getResponse.ok) {
const result = await getResponse.json();
console.log(`User B sees ${result.count} bookmarks`);
if (result.results && result.results.length > 0) {
console.log('RESULT: User B can see User A\'s bookmark (sharing or same user)');
} else {
console.log('RESULT: User B cannot see User A\'s bookmark (proper isolation)');
}
} else {
console.log('RESULT: User B cannot access (4xx/5xx error)');
}
} catch (error) {
console.error('Error:', error.message);
}
}
```
---
### Scenario 3: Conflict Resolution - Different Folders/Paths
**Purpose**: Determine how the server handles same URL in different paths.
**Setup**:
1. Create bookmark as "Work" with path "Work/Development"
2. Create same URL as "Personal" with path "Personal/Notes"
**Expected Behavior**:
- Need to confirm if server creates duplicate or updates existing
**Test Command**:
```javascript
// Test 3: Conflict resolution with different paths
async function testConflictResolution(serverUrl, workKey, personalKey) {
const url = 'https://conflict-test.example.com';
const pathWork = 'Work/Development';
const pathPersonal = 'Personal/Notes';
try {
// Create as Work
const workBookmark = await createBookmark(serverUrl, workKey, url, {
title: 'Conflict Test',
description: 'Work version',
notes: JSON.stringify({
path: pathWork,
userNotes: 'Work Development Notes',
autoTags: [{name: 'Work'}]
})
});
console.log('Work bookmark created:', workBookmark.id);
// Create same URL as Personal
const personalBookmark = await createBookmark(serverUrl, personalKey, url, {
title: 'Conflict Test',
description: 'Personal version',
notes: JSON.stringify({
path: pathPersonal,
userNotes: 'Personal Notes',
autoTags: [{name: 'Personal'}]
})
});
console.log('Personal bookmark created:', personalBookmark.id);
// Check IDs
if (workBookmark.id === personalBookmark.id) {
console.log('RESULT: Same bookmark ID - paths are merged or overwritten');
// Fetch and show current state
const fetchResponse = await fetch(`${serverUrl}api/bookmarks/${workBookmark.id}/`, {
headers: {
'Authorization': `Token ${workKey}`,
}
});
const current = await fetchResponse.json();
console.log('Current bookmark state:', current);
} else {
console.log('RESULT: Different bookmark IDs - server creates duplicates');
console.log('Work ID:', workBookmark.id);
console.log('Personal ID:', personalBookmark.id);
}
} catch (error) {
console.error('Error:', error.message);
}
}
```
---
### Scenario 4: Title/Description Conflict
**Purpose**: Understand conflict resolution for title/description fields.
**Setup**:
1. Update bookmark via Work context (title: "Work Title")
2. Update same bookmark via Personal context (title: "Personal Title")
3. Check final state
**Expected Behavior**:
- Confirm last-write-wins or other resolution strategy
**Test Command**:
```javascript
// Test 4: Title/Description conflict resolution
async function testTitleConflict(serverUrl, workKey, personalKey) {
const url = 'https://title-conflict.example.com';
try {
// Create initial bookmark
const bookmark = await createBookmark(serverUrl, workKey, url, {
title: 'Initial Title',
description: 'Initial Description',
notes: JSON.stringify({
path: 'Work/Initial',
userNotes: 'Initial'
})
});
// Update via Work
await updateBookmark(serverUrl, workKey, bookmark.id, {
title: 'Work Title',
description: 'Work Description',
notes: JSON.stringify({
path: 'Work/Dev',
userNotes: 'Work notes',
autoTags: [{name: 'Work'}]
})
});
console.log('Updated via Work: Work Title');
// Update via Personal
await updateBookmark(serverUrl, personalKey, bookmark.id, {
title: 'Personal Title',
description: 'Personal Description',
notes: JSON.stringify({
path: 'Personal/Notes',
userNotes: 'Personal notes',
autoTags: [{name: 'Personal'}]
})
});
console.log('Updated via Personal: Personal Title');
// Fetch final state
const final = await fetchBookmark(serverUrl, workKey, bookmark.id);
console.log('Final state:', {
title: final.title,
description: final.description,
notes: JSON.parse(final.notes)
});
if (final.title === 'Personal Title') {
console.log('RESULT: Last-write-wins (Personal update took precedence)');
} else if (final.title === 'Work Title') {
console.log('RESULT: Last-write-wins (Work update took precedence)');
} else if (final.title.includes('Work') && final.title.includes('Personal')) {
console.log('RESULT: Merged title');
}
} catch (error) {
console.error('Error:', error.message);
}
}
```
---
### Scenario 5: Delete Propagation
**Purpose**: Confirm if deleting a bookmark affects all API keys.
**Setup**:
1. Create bookmark via multiple API keys (same URL)
2. Delete via one API key
3. Check if bookmark still exists via other keys
**Expected Behavior**:
- If API keys create same bookmark, deletion should propagate
- If API keys create separate bookmarks, deletion only affects one
**Test Command**:
```javascript
// Test 5: Delete propagation
async function testDeletePropagation(serverUrl, workKey, personalKey) {
const url = 'https://delete-test.example.com';
try {
// Create via both keys
const workBookmark = await createBookmark(serverUrl, workKey, url, {
title: 'Delete Test',
notes: JSON.stringify({path: 'Work/Dev'})
});
console.log('Work bookmark ID:', workBookmark.id);
const personalBookmark = await createBookmark(serverUrl, personalKey, url, {
title: 'Delete Test',
notes: JSON.stringify({path: 'Personal/Notes'})
});
console.log('Personal bookmark ID:', personalBookmark.id);
// Check if same bookmark
const sameBookmark = workBookmark.id === personalBookmark.id;
// Delete via Work key
const deleteResponse = await fetch(`${serverUrl}api/bookmarks/${workBookmark.id}/`, {
method: 'DELETE',
headers: {
'Authorization': `Token ${workKey}`,
}
});
console.log('Delete response status:', deleteResponse.status);
// Try to fetch via Personal key
const fetchPersonal = await fetch(`${serverUrl}api/bookmarks/?limit=100`, {
headers: {
'Authorization': `Token ${personalKey}`,
}
});
if (fetchPersonal.ok) {
const personalResults = await fetchPersonal.json();
const deleted = personalResults.results.find(b => b.url === url);
if (!deleted) {
console.log('RESULT: Delete propagated (same bookmark)');
} else {
console.log('RESULT: Delete did not propagate (separate bookmarks)');
}
}
} catch (error) {
console.error('Error:', error.message);
}
}
```
---
### Scenario 6: Bundle Tag Isolation
**Purpose**: Verify if different bundle tags properly isolate bookmarks.
**Setup**:
1. Create bookmark with bundle tag "work-bundle"
2. Create bookmark with bundle tag "personal-bundle"
3. Query each bundle separately
**Expected Behavior**:
- Bundle tags should act as filters
- Need to confirm if tags create separate bookmarks or filter existing ones
**Test Command**:
```javascript
// Test 6: Bundle tag isolation
async function testBundleIsolation(serverUrl, workKey, personalKey) {
try {
// Create bundle tag bookmark
const bookmark = await createBookmark(serverUrl, workKey, 'https://bundle-test.com', {
title: 'Bundle Test',
notes: JSON.stringify({
path: 'Test/Path',
userNotes: 'Initial',
bundleTag: 'work-bundle',
autoTags: [{name: 'work-bundle'}]
})
});
console.log('Bookmark created:', bookmark.id);
// Query by bundle tag
const workBundleResponse = await fetch(`${serverUrl}api/bookmarks/?all=work-bundle`, {
headers: {
'Authorization': `Token ${workKey}`,
}
});
const personalBundleResponse = await fetch(`${serverUrl}api/bookmarks/?all=personal-bundle`, {
headers: {
'Authorization': `Token ${workKey}`,
}
});
const workCount = await workBundleResponse.json();
const personalCount = await personalBundleResponse.json();
console.log(`Work bundle has ${workCount.count || workCount.results?.length || 0} bookmarks`);
console.log(`Personal bundle has ${personalCount.count || personalCount.results?.length || 0} bookmarks`);
if (workCount.count === 1 && personalCount.count === 0) {
console.log('RESULT: Bundle tags provide proper isolation');
} else {
console.log('RESULT: Bundle tags may not provide isolation');
}
} catch (error) {
console.error('Error:', error.message);
}
}
```
---
## Session Management for Automated Tests
### Challenge: Authentication Context Switching
When running automated tests, we need to manage switching between:
- Different API keys
- Different users (if applicable)
- Different bundle contexts
### Solution: Context Switching Function
```javascript
// Session management for tests
const SessionManager = {
// Store active context
context: {
serverUrl: '',
currentApiKey: '',
currentUserId: null,
currentBundle: null
},
// Set new context
setContext(serverUrl, apiKey, userId = null, bundle = null) {
console.log(`Switching context: ${apiKey.substring(0, 8)}...`);
this.context.serverUrl = serverUrl;
this.context.currentApiKey = apiKey;
this.context.currentUserId = userId;
this.context.currentBundle = bundle;
// Return auth headers for this context
return {
headers: {
'Authorization': `Token ${apiKey}`
}
};
},
// Make API call with current context
async call(endpoint, method = 'GET', body = null) {
const headers = {
...this.getHeaders(),
'Content-Type': 'application/json'
};
if (body) headers['Content-Type'] = 'application/json';
const response = await fetch(`${this.context.serverUrl}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : null
});
if (!response.ok) {
throw new Error(`${response.status}: ${response.statusText}`);
}
return await response.json();
},
// Get headers for current context
getHeaders() {
return {
'Authorization': `Token ${this.context.currentApiKey}`
};
},
// Reset to default context
reset() {
this.context = {
serverUrl: '',
currentApiKey: '',
currentUserId: null,
currentBundle: null
};
console.log('Session reset');
}
};
// Usage example:
// SessionManager.setContext('https://links.blabber1565.com', 'work-api-key-123');
// const bookmark = await SessionManager.call('/api/bookmarks/', 'POST', {url: '...'});
```
---
## Test Execution Script
Create a file: `test-runner.js` in the LinkdingSync directory:
```javascript
/*
* Test Runner for Linkding Server Behavior Validation
*
* Instructions:
* 1. Load this file into Firefox DevTools console
* 2. Enter your test configuration
* 3. Run test suite
*/
// Configuration
const CONFIG = {
serverUrl: 'https://links.blabber1565.com',
workApiKey: '', // Fill in your work API key
personalApiKey: '', // Fill in your personal API key
workBundle: 'work-bundle',
personalBundle: 'personal-bundle'
};
// Import SessionManager
// (paste SessionManager code from above)
// Test Suite
async function runTestSuite() {
console.log('=== Linkding Server Behavior Test Suite ===\n');
try {
// Setup context
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, 'user_work', CONFIG.workBundle);
// Run tests
await testSameUserDifferentKeys(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
console.log('\n--- Test 1 Complete ---\n');
await testDifferentUsersNoSharing(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
console.log('\n--- Test 2 Complete ---\n');
await testConflictResolution(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
console.log('\n--- Test 3 Complete ---\n');
await testTitleConflict(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
console.log('\n--- Test 4 Complete ---\n');
await testDeletePropagation(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
console.log('\n--- Test 5 Complete ---\n');
await testBundleIsolation(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
console.log('\n--- Test 6 Complete ---\n');
console.log('=== All Tests Complete ===');
} catch (error) {
console.error('Test suite failed:', error.message);
}
}
// Initialize
console.log('Type: runTestSuite() to execute all tests');
console.log('Type: runTestSuite("scenario") to run specific scenario');
```
---
## Expected Outcomes & Decision Points
After running Phase 0 tests, we'll make decisions based on:
### Decision Point 1: API Key Isolation
- **If same user**: API keys don't provide isolation → Use separate users or different accounts
- **If different users**: Confirm sharing settings needed
### Decision Point 2: Conflict Resolution Strategy
- **If last-write-wins**: May cause toggle issues between browsers
- **If merge**: Determine merge strategy (path, title, etc.)
- **If duplicates**: Accept separate bookmarks per context
### Decision Point 3: Bundle Isolation
- **If tags isolate**: Use bundle tags for isolation
- **If not**: Need user accounts for isolation
---
## Next Steps
1. **Create Test Accounts**: You create the 2 user accounts with API keys
2. **Configure Test Harness**: Fill in CONFIG with API keys
3. **Run Tests**: Execute `runTestSuite()` in Firefox console
4. **Analyze Results**: Review test outputs
5. **Finalize Design**: Update architecture based on findings
---
## Notes
- Tests are idempotent - they clean up test bookmarks after execution
- All test bookmarks are tagged with "test-" prefix for easy identification
- Test results will inform the final redesign of LinkdingSync extension