Compare commits
7 Commits
main
..
a18f96912d
| Author | SHA1 | Date | |
|---|---|---|---|
| a18f96912d | |||
| 6604ecb026 | |||
| bb86950632 | |||
| 52dd56ff06 | |||
| 29b176d2db | |||
| 04eb8727eb | |||
| 99e5758d4c |
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: playwright-cli
|
||||
description: Debug and automate the MyGO Web interface with the repository-pinned Playwright CLI. Use for headless browser inspection, login and file-flow debugging, screenshots, DOM snapshots, console or network diagnosis, Playwright test debugging, and browser-driven verification in this repository.
|
||||
---
|
||||
|
||||
# Debug MyGO with Playwright CLI
|
||||
|
||||
Use the Playwright CLI bundled with the locked `@playwright/test` dependency. Keep every browser action behind the repository npm script so a future CLI entry-point change is isolated to `web/package.json`.
|
||||
|
||||
## Use the repository command contract
|
||||
|
||||
Run browser commands from `web/`:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- <command> [arguments]
|
||||
```
|
||||
|
||||
The npm script changes to the repository root before starting Playwright. This makes the CLI discover `.playwright/cli.config.json` and keeps generated output under `.artifacts/playwright-cli/`.
|
||||
|
||||
Never invoke `playwright-cli`, `npx playwright-cli`, `npx playwright`, a global Playwright installation, or `@latest`. Do not install `@playwright/cli`; the locked stable `@playwright/test` package supplies the CLI.
|
||||
|
||||
## Prepare the local environment
|
||||
|
||||
Install locked JavaScript dependencies and the matching Chromium revision:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
mise exec -- npm ci
|
||||
mise exec -- npm run playwright:install:deps
|
||||
mise exec -- npm run playwright:install
|
||||
```
|
||||
|
||||
Treat the Debian system-library command as an environment setup step. Do not rerun it during ordinary debugging when the dependencies are already present.
|
||||
|
||||
For authenticated file workflows, start both services in separate long-running terminals:
|
||||
|
||||
```bash
|
||||
# Repository root: backend API on 127.0.0.1:10086.
|
||||
mise exec -- go run . serve
|
||||
|
||||
# web/: Vite on 127.0.0.1:5173, proxying /api to the backend.
|
||||
mise exec -- npm run dev -- --host 127.0.0.1
|
||||
```
|
||||
|
||||
Use existing local test credentials or credentials supplied for the task. Never write credentials into the Skill, source files, screenshots, or reports. Keep upload fixtures below 20 MB for the current milestone.
|
||||
|
||||
## Follow the debugging loop
|
||||
|
||||
1. Reproduce the problem with the narrowest existing test first. Confirm the test expresses the intended behavior before changing implementation code.
|
||||
2. Confirm the required server is reachable. The login page itself only needs Vite; login, list, upload, and download also need the Go backend and suitable local data.
|
||||
3. Open the page and read its accessibility snapshot:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- open http://127.0.0.1:5173/login
|
||||
mise exec -- npm run playwright:cli -- snapshot
|
||||
```
|
||||
|
||||
4. Use refs from the latest snapshot for interactions. Take another snapshot after navigation, modal changes, or substantial rerenders because refs can become stale.
|
||||
5. Inspect browser and API evidence before guessing:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- console
|
||||
mise exec -- npm run playwright:cli -- requests
|
||||
mise exec -- npm run playwright:cli -- request <number>
|
||||
```
|
||||
|
||||
6. Prefer snapshots for structure and screenshots for visual evidence:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- screenshot --filename=.artifacts/playwright-cli/mygo-debug.png
|
||||
```
|
||||
|
||||
7. Close every session when finished, including error paths:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- close
|
||||
mise exec -- npm run playwright:cli -- close-all
|
||||
```
|
||||
|
||||
Use a named session with `-s=<name>` when parallel or persistent investigations would otherwise collide. Keep sessions isolated unless the task explicitly requires saved state.
|
||||
|
||||
## Debug Playwright tests
|
||||
|
||||
Run the repository suite from `web/`:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run test:e2e
|
||||
```
|
||||
|
||||
For interactive debugging, start the test with `--debug=cli`, wait for its `tw-*` session name, then attach through the same npm wrapper. Read [playwright-tests.md](references/playwright-tests.md) before using this workflow.
|
||||
|
||||
## Load detailed references only when needed
|
||||
|
||||
- Read [element-attributes.md](references/element-attributes.md) when snapshots omit a required DOM attribute.
|
||||
- Read [playwright-tests.md](references/playwright-tests.md) when attaching to a paused Playwright Test run.
|
||||
- Read [request-mocking.md](references/request-mocking.md) when reproducing API failures with browser-level routes.
|
||||
- Read [running-code.md](references/running-code.md) for multi-step page evaluation that individual CLI commands cannot express.
|
||||
- Read [session-management.md](references/session-management.md) for named, persistent, attached, or parallel sessions.
|
||||
- Read [spec-driven-testing.md](references/spec-driven-testing.md) when planning, generating, or healing E2E scenarios.
|
||||
- Read [storage-state.md](references/storage-state.md) when inspecting or preserving cookies, local storage, or session storage.
|
||||
- Read [test-generation.md](references/test-generation.md) when translating an interactive session into Playwright test code.
|
||||
- Read [tracing.md](references/tracing.md) for trace capture and diagnosis.
|
||||
- Read [video-recording.md](references/video-recording.md) only when a video materially improves the debugging result.
|
||||
|
||||
## Preserve repository boundaries
|
||||
|
||||
Write screenshots, traces, videos, downloaded responses, and temporary upload fixtures only under ignored `.artifacts/` paths. Do not change application data, create accounts, or call destructive UI actions unless the task authorizes those effects. Report browser launch or sandbox failures instead of silently disabling the configured Chromium sandbox.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "MyGO Playwright CLI Debugging"
|
||||
short_description: "Headless browser debugging for MyGO Web"
|
||||
default_prompt: "Use $playwright-cli to inspect and debug the MyGO Web interface in a headless browser."
|
||||
@@ -0,0 +1,23 @@
|
||||
# Inspecting Element Attributes
|
||||
|
||||
When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- snapshot
|
||||
# snapshot shows a button as e7 but doesn't reveal its id or data attributes
|
||||
|
||||
# get the element's id
|
||||
mise exec -- npm run playwright:cli -- eval "el => el.id" e7
|
||||
|
||||
# get all CSS classes
|
||||
mise exec -- npm run playwright:cli -- eval "el => el.className" e7
|
||||
|
||||
# get a specific attribute
|
||||
mise exec -- npm run playwright:cli -- eval "el => el.getAttribute('data-testid')" e7
|
||||
mise exec -- npm run playwright:cli -- eval "el => el.getAttribute('aria-label')" e7
|
||||
|
||||
# get a computed style property
|
||||
mise exec -- npm run playwright:cli -- eval "el => getComputedStyle(el).display" e7
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# Running Playwright Tests
|
||||
|
||||
Run Playwright tests from `web/` through the repository script. To avoid opening the interactive HTML report, use the `PLAYWRIGHT_HTML_OPEN=never` environment variable.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
PLAYWRIGHT_HTML_OPEN=never mise exec -- npm run test:e2e --
|
||||
```
|
||||
|
||||
# Debugging Playwright Tests
|
||||
|
||||
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
|
||||
|
||||
**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished.
|
||||
|
||||
Once instructions containing a session name are printed, use `mise exec -- npm run playwright:cli --` to attach the session and explore the page.
|
||||
|
||||
```bash
|
||||
# Run the test
|
||||
PLAYWRIGHT_HTML_OPEN=never mise exec -- npm run test:e2e -- --debug=cli
|
||||
# ...
|
||||
# ... debugging instructions for "tw-abcdef" session ...
|
||||
# ...
|
||||
|
||||
# Attach to the test
|
||||
mise exec -- npm run playwright:cli -- attach tw-abcdef
|
||||
```
|
||||
|
||||
Keep the test running in the background while you explore and look for a fix.
|
||||
The test is paused at the start, so you should step over or pause at a particular location
|
||||
where the problem is most likely to be.
|
||||
|
||||
Every action you perform with `mise exec -- npm run playwright:cli --` generates corresponding Playwright TypeScript code.
|
||||
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
|
||||
|
||||
After fixing the test, stop the background test run. Rerun to check that test passes.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Request Mocking
|
||||
|
||||
Intercept, mock, modify, and block network requests.
|
||||
|
||||
## CLI Route Commands
|
||||
|
||||
```bash
|
||||
# Mock with custom status
|
||||
mise exec -- npm run playwright:cli -- route "**/*.jpg" --status=404
|
||||
|
||||
# Mock with JSON body
|
||||
mise exec -- npm run playwright:cli -- route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
|
||||
|
||||
# Mock with custom headers
|
||||
mise exec -- npm run playwright:cli -- route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
|
||||
|
||||
# Remove headers from requests
|
||||
mise exec -- npm run playwright:cli -- route "**/*" --remove-header=cookie,authorization
|
||||
|
||||
# List active routes
|
||||
mise exec -- npm run playwright:cli -- route-list
|
||||
|
||||
# Remove a route or all routes
|
||||
mise exec -- npm run playwright:cli -- unroute "**/*.jpg"
|
||||
mise exec -- npm run playwright:cli -- unroute
|
||||
```
|
||||
|
||||
## URL Patterns
|
||||
|
||||
```
|
||||
**/api/users - Exact path match
|
||||
**/api/*/details - Wildcard in path
|
||||
**/*.{png,jpg,jpeg} - Match file extensions
|
||||
**/search?q=* - Match query parameters
|
||||
```
|
||||
|
||||
## Advanced Mocking with run-code
|
||||
|
||||
For conditional responses, request body inspection, response modification, or delays:
|
||||
|
||||
### Conditional Response Based on Request
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.route('**/api/login', route => {
|
||||
const body = route.request().postDataJSON();
|
||||
if (body.username === 'admin') {
|
||||
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
|
||||
} else {
|
||||
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
|
||||
}
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Modify Real Response
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.route('**/api/user', async route => {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
json.isPremium = true;
|
||||
await route.fulfill({ response, json });
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Simulate Network Failures
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
|
||||
}"
|
||||
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
|
||||
```
|
||||
|
||||
### Delayed Response
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.route('**/api/slow', async route => {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
|
||||
});
|
||||
}"
|
||||
```
|
||||
@@ -0,0 +1,241 @@
|
||||
# Running Custom Playwright Code
|
||||
|
||||
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
|
||||
|
||||
## Syntax
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
// Your Playwright code here
|
||||
// Access page.context() for browser context operations
|
||||
}"
|
||||
```
|
||||
|
||||
You can also load the function from a file:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- run-code --filename=./my-script.js
|
||||
```
|
||||
|
||||
|
||||
The code must be a single function expression, it is wrapped in `(...)` and evaluated.
|
||||
import/export/require syntax is not supported.
|
||||
|
||||
## Geolocation
|
||||
|
||||
```bash
|
||||
# Grant geolocation permission and set location
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
|
||||
}"
|
||||
|
||||
# Set location to London
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
|
||||
}"
|
||||
|
||||
# Clear geolocation override
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.context().clearPermissions();
|
||||
}"
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
```bash
|
||||
# Grant multiple permissions
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.context().grantPermissions([
|
||||
'geolocation',
|
||||
'notifications',
|
||||
'camera',
|
||||
'microphone'
|
||||
]);
|
||||
}"
|
||||
|
||||
# Grant permissions for specific origin
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read'], {
|
||||
origin: 'https://example.com'
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Media Emulation
|
||||
|
||||
```bash
|
||||
# Emulate dark color scheme
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
}"
|
||||
|
||||
# Emulate light color scheme
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
}"
|
||||
|
||||
# Emulate reduced motion
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
}"
|
||||
|
||||
# Emulate print media
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
}"
|
||||
```
|
||||
|
||||
## Wait Strategies
|
||||
|
||||
```bash
|
||||
# Wait for network idle
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
}"
|
||||
|
||||
# Wait for specific element
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.locator('.loading').waitFor({ state: 'hidden' });
|
||||
}"
|
||||
|
||||
# Wait for function to return true
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.waitForFunction(() => window.appReady === true);
|
||||
}"
|
||||
|
||||
# Wait with timeout
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.locator('.result').waitFor({ timeout: 10000 });
|
||||
}"
|
||||
```
|
||||
|
||||
## Frames and Iframes
|
||||
|
||||
```bash
|
||||
# Work with iframe
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
const frame = page.locator('iframe#my-iframe').contentFrame();
|
||||
await frame.locator('button').click();
|
||||
}"
|
||||
|
||||
# Get all frames
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
const frames = page.frames();
|
||||
return frames.map(f => f.url());
|
||||
}"
|
||||
```
|
||||
|
||||
## File Downloads
|
||||
|
||||
```bash
|
||||
# Handle file download
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: 'Download' }).click();
|
||||
const download = await downloadPromise;
|
||||
await download.saveAs('./downloaded-file.pdf');
|
||||
return download.suggestedFilename();
|
||||
}"
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
```bash
|
||||
# Read clipboard (requires permission)
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read']);
|
||||
return await page.evaluate(() => navigator.clipboard.readText());
|
||||
}"
|
||||
|
||||
# Write to clipboard
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
|
||||
}"
|
||||
```
|
||||
|
||||
## Page Information
|
||||
|
||||
```bash
|
||||
# Get page title
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
return await page.title();
|
||||
}"
|
||||
|
||||
# Get current URL
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
return page.url();
|
||||
}"
|
||||
|
||||
# Get page content
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
return await page.content();
|
||||
}"
|
||||
|
||||
# Get viewport size
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
return page.viewportSize();
|
||||
}"
|
||||
```
|
||||
|
||||
## JavaScript Execution
|
||||
|
||||
```bash
|
||||
# Execute JavaScript and return result
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
return await page.evaluate(() => {
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
cookiesEnabled: navigator.cookieEnabled
|
||||
};
|
||||
});
|
||||
}"
|
||||
|
||||
# Pass arguments to evaluate
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
const multiplier = 5;
|
||||
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
|
||||
}"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```bash
|
||||
# Try-catch in run-code
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
|
||||
return 'clicked';
|
||||
} catch (e) {
|
||||
return 'element not found';
|
||||
}
|
||||
}"
|
||||
```
|
||||
|
||||
## Complex Workflows
|
||||
|
||||
```bash
|
||||
# Login and save state
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('secret');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await page.waitForURL('**/dashboard');
|
||||
await page.context().storageState({ path: 'auth.json' });
|
||||
return 'Login successful';
|
||||
}"
|
||||
|
||||
# Scrape data from multiple pages
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
const results = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.goto(\`https://example.com/page/\${i}\`);
|
||||
const items = await page.locator('.item').allTextContents();
|
||||
results.push(...items);
|
||||
}
|
||||
return results;
|
||||
}"
|
||||
```
|
||||
@@ -0,0 +1,225 @@
|
||||
# Browser Session Management
|
||||
|
||||
Run multiple isolated browser sessions concurrently with state persistence.
|
||||
|
||||
## Named Browser Sessions
|
||||
|
||||
Use `-s` flag to isolate browser contexts:
|
||||
|
||||
```bash
|
||||
# Browser 1: Authentication flow
|
||||
mise exec -- npm run playwright:cli -- -s=auth open https://app.example.com/login
|
||||
|
||||
# Browser 2: Public browsing (separate cookies, storage)
|
||||
mise exec -- npm run playwright:cli -- -s=public open https://example.com
|
||||
|
||||
# Commands are isolated by browser session
|
||||
mise exec -- npm run playwright:cli -- -s=auth fill e1 "user@example.com"
|
||||
mise exec -- npm run playwright:cli -- -s=public snapshot
|
||||
```
|
||||
|
||||
## Browser Session Isolation Properties
|
||||
|
||||
Each browser session has independent:
|
||||
- Cookies
|
||||
- LocalStorage / SessionStorage
|
||||
- IndexedDB
|
||||
- Cache
|
||||
- Browsing history
|
||||
- Open tabs
|
||||
|
||||
## Browser Session Commands
|
||||
|
||||
```bash
|
||||
# List all browser sessions
|
||||
mise exec -- npm run playwright:cli -- list
|
||||
|
||||
# Stop a browser session (close the browser)
|
||||
mise exec -- npm run playwright:cli -- close # stop the default browser
|
||||
mise exec -- npm run playwright:cli -- -s=mysession close # stop a named browser
|
||||
|
||||
# Stop all browser sessions
|
||||
mise exec -- npm run playwright:cli -- close-all
|
||||
|
||||
# Forcefully kill all daemon processes (for stale/zombie processes)
|
||||
mise exec -- npm run playwright:cli -- kill-all
|
||||
|
||||
# Delete browser session user data (profile directory)
|
||||
mise exec -- npm run playwright:cli -- delete-data # delete default browser data
|
||||
mise exec -- npm run playwright:cli -- -s=mysession delete-data # delete named browser data
|
||||
```
|
||||
|
||||
## Environment Variable
|
||||
|
||||
Set a default browser session name via environment variable:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_CLI_SESSION="mysession"
|
||||
mise exec -- npm run playwright:cli -- open example.com # Uses "mysession" automatically
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Concurrent Scraping
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Scrape multiple sites concurrently
|
||||
|
||||
# Start all browsers
|
||||
mise exec -- npm run playwright:cli -- -s=site1 open https://site1.com &
|
||||
mise exec -- npm run playwright:cli -- -s=site2 open https://site2.com &
|
||||
mise exec -- npm run playwright:cli -- -s=site3 open https://site3.com &
|
||||
wait
|
||||
|
||||
# Take snapshots from each
|
||||
mise exec -- npm run playwright:cli -- -s=site1 snapshot
|
||||
mise exec -- npm run playwright:cli -- -s=site2 snapshot
|
||||
mise exec -- npm run playwright:cli -- -s=site3 snapshot
|
||||
|
||||
# Cleanup
|
||||
mise exec -- npm run playwright:cli -- close-all
|
||||
```
|
||||
|
||||
### A/B Testing Sessions
|
||||
|
||||
```bash
|
||||
# Test different user experiences
|
||||
mise exec -- npm run playwright:cli -- -s=variant-a open "https://app.com?variant=a"
|
||||
mise exec -- npm run playwright:cli -- -s=variant-b open "https://app.com?variant=b"
|
||||
|
||||
# Compare
|
||||
mise exec -- npm run playwright:cli -- -s=variant-a screenshot
|
||||
mise exec -- npm run playwright:cli -- -s=variant-b screenshot
|
||||
```
|
||||
|
||||
### Persistent Profile
|
||||
|
||||
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
|
||||
|
||||
```bash
|
||||
# Use persistent profile (auto-generated location)
|
||||
mise exec -- npm run playwright:cli -- open https://example.com --persistent
|
||||
|
||||
# Use persistent profile with custom directory
|
||||
mise exec -- npm run playwright:cli -- open https://example.com --profile=/path/to/profile
|
||||
```
|
||||
|
||||
## Attaching to a Running Browser
|
||||
|
||||
Use `attach` to connect to a browser that is already running, instead of launching a new one.
|
||||
|
||||
### Attach by channel name
|
||||
|
||||
Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance".
|
||||
|
||||
```bash
|
||||
# Attach to Chrome
|
||||
mise exec -- npm run playwright:cli -- attach --cdp=chrome
|
||||
|
||||
# Attach to Chrome Canary
|
||||
mise exec -- npm run playwright:cli -- attach --cdp=chrome-canary
|
||||
|
||||
# Attach to Microsoft Edge
|
||||
mise exec -- npm run playwright:cli -- attach --cdp=msedge
|
||||
|
||||
# Attach to Edge Dev
|
||||
mise exec -- npm run playwright:cli -- attach --cdp=msedge-dev
|
||||
```
|
||||
|
||||
Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`.
|
||||
|
||||
When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=<name>` to override.
|
||||
|
||||
### Attach via CDP endpoint
|
||||
|
||||
Connect to a browser that exposes a Chrome DevTools Protocol endpoint:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- attach --cdp=http://localhost:9222
|
||||
```
|
||||
|
||||
### Attach via browser extension
|
||||
|
||||
Connect to a browser with the Playwright extension installed:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- attach --extension
|
||||
```
|
||||
|
||||
### Detach
|
||||
|
||||
Tear down an attached session without affecting the external browser:
|
||||
|
||||
```bash
|
||||
# Detach the default attached session
|
||||
mise exec -- npm run playwright:cli -- detach
|
||||
|
||||
# Detach a specific attached session
|
||||
mise exec -- npm run playwright:cli -- -s=msedge detach
|
||||
```
|
||||
|
||||
`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`.
|
||||
|
||||
## Default Browser Session
|
||||
|
||||
When `-s` is omitted, commands use the default browser session:
|
||||
|
||||
```bash
|
||||
# These use the same default browser session
|
||||
mise exec -- npm run playwright:cli -- open https://example.com
|
||||
mise exec -- npm run playwright:cli -- snapshot
|
||||
mise exec -- npm run playwright:cli -- close # Stops default browser
|
||||
```
|
||||
|
||||
## Browser Session Configuration
|
||||
|
||||
Configure a browser session with specific settings when opening:
|
||||
|
||||
```bash
|
||||
# Open with config file
|
||||
mise exec -- npm run playwright:cli -- open https://example.com --config=.playwright/my-cli.json
|
||||
|
||||
# Open with specific browser
|
||||
mise exec -- npm run playwright:cli -- open https://example.com --browser=firefox
|
||||
|
||||
# Open in headed mode
|
||||
mise exec -- npm run playwright:cli -- open https://example.com --headed
|
||||
|
||||
# Open with persistent profile
|
||||
mise exec -- npm run playwright:cli -- open https://example.com --persistent
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Name Browser Sessions Semantically
|
||||
|
||||
```bash
|
||||
# GOOD: Clear purpose
|
||||
mise exec -- npm run playwright:cli -- -s=github-auth open https://github.com
|
||||
mise exec -- npm run playwright:cli -- -s=docs-scrape open https://docs.example.com
|
||||
|
||||
# AVOID: Generic names
|
||||
mise exec -- npm run playwright:cli -- -s=s1 open https://github.com
|
||||
```
|
||||
|
||||
### 2. Always Clean Up
|
||||
|
||||
```bash
|
||||
# Stop browsers when done
|
||||
mise exec -- npm run playwright:cli -- -s=auth close
|
||||
mise exec -- npm run playwright:cli -- -s=scrape close
|
||||
|
||||
# Or stop all at once
|
||||
mise exec -- npm run playwright:cli -- close-all
|
||||
|
||||
# If browsers become unresponsive or zombie processes remain
|
||||
mise exec -- npm run playwright:cli -- kill-all
|
||||
```
|
||||
|
||||
### 3. Delete Stale Browser Data
|
||||
|
||||
```bash
|
||||
# Remove old browser data to free disk space
|
||||
mise exec -- npm run playwright:cli -- -s=oldsession delete-data
|
||||
```
|
||||
@@ -0,0 +1,305 @@
|
||||
# Spec-driven testing (plan → generate → heal)
|
||||
|
||||
End-to-end workflow for authoring and maintaining Playwright tests using `mise exec -- npm run playwright:cli --`. The three sections below can be used independently:
|
||||
|
||||
- **Planning** — explore the app, produce a spec file describing what to test.
|
||||
- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale.
|
||||
- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality.
|
||||
|
||||
All three lean on the same mechanic: run `mise exec -- npm run test:e2e -- --debug=cli` in the background, then `mise exec -- npm run playwright:cli -- attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics and [test-generation.md](test-generation.md) for how every `mise exec -- npm run playwright:cli --` action emits Playwright TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## 1. Planning
|
||||
|
||||
Goal: produce a spec file (e.g. `specs/<feature>.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file.
|
||||
|
||||
### 1.1 Prerequisite: workspace
|
||||
|
||||
Check the workspace has Playwright installed before anything else:
|
||||
|
||||
```bash
|
||||
# These confirm the MyGO Web workspace and locked CLI:
|
||||
test -f playwright.config.ts
|
||||
mise exec -- npm run playwright:cli -- --version
|
||||
```
|
||||
|
||||
If dependencies are missing, restore the repository lockfile instead of bootstrapping a new Playwright project:
|
||||
|
||||
```bash
|
||||
mise exec -- npm ci
|
||||
```
|
||||
|
||||
### 1.2 Prerequisite: seed test
|
||||
|
||||
A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins.
|
||||
|
||||
Minimum viable seed:
|
||||
|
||||
```ts
|
||||
// e2e/seed.spec.ts
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
await page.goto('https://example.com/');
|
||||
});
|
||||
```
|
||||
|
||||
Preferred — push navigation into a fixture so scenario tests reuse it:
|
||||
|
||||
```ts
|
||||
// e2e/fixtures.ts
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
export const test = baseTest.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.goto('https://example.com/');
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// e2e/seed.spec.ts
|
||||
import { test } from './fixtures';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
// Fixture already navigates. This empty body tells agents where to start.
|
||||
});
|
||||
```
|
||||
|
||||
If no seed exists, create one that at least navigates to the app.
|
||||
|
||||
### 1.3 Explore the app
|
||||
|
||||
Launch the app via the seed in the background and attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never mise exec -- npm run test:e2e -- e2e/seed.spec.ts --debug=cli
|
||||
# wait for "Debugging Instructions" and the session name tw-XXXX
|
||||
mise exec -- npm run playwright:cli -- attach tw-XXXX
|
||||
```
|
||||
|
||||
Resume so the seed runs, then probe the app:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- resume # resume so that seed test runs fully
|
||||
mise exec -- npm run playwright:cli -- snapshot # inventory of interactive elements
|
||||
mise exec -- npm run playwright:cli -- click e5 # follow a flow
|
||||
mise exec -- npm run playwright:cli -- eval "location.href" # read URL / state
|
||||
mise exec -- npm run playwright:cli -- show --annotate # ask the user to point at something
|
||||
```
|
||||
|
||||
Map out:
|
||||
|
||||
- Interactive surfaces (forms, buttons, lists, filters, modals).
|
||||
- Primary user journeys end-to-end.
|
||||
- Edge cases: empty states, validation errors, very long input, boundary values.
|
||||
- Persistence: reload, local/session storage, URL fragments.
|
||||
- Navigation: which controls change the URL, back/forward behaviour.
|
||||
|
||||
**Important**: Do not just open the app url with mise exec -- npm run playwright:cli --, always go through the test to capture any custom setup done there.
|
||||
**Important**: Stop the background test when done exploring.
|
||||
|
||||
### 1.4 Write the spec file
|
||||
|
||||
Save under `specs/<feature>.plan.md`. Use this structure:
|
||||
|
||||
```markdown
|
||||
# <Feature> Test Plan
|
||||
|
||||
## Application Overview
|
||||
|
||||
<One paragraph describing what the feature does and why it matters.>
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. <Group Name>
|
||||
|
||||
**Seed:** `e2e/seed.spec.ts`
|
||||
|
||||
#### 1.1. <kebab-case-scenario-name>
|
||||
|
||||
**File:** `e2e/<group>/<kebab-case-scenario-name>.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. <Concrete user step>
|
||||
- expect: <observable outcome>
|
||||
- expect: <another observable outcome>
|
||||
2. <Next step>
|
||||
- expect: <outcome>
|
||||
|
||||
#### 1.2. <next-scenario>
|
||||
...
|
||||
|
||||
### 2. <Next Group>
|
||||
|
||||
**Seed:** `e2e/seed.spec.ts`
|
||||
...
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Each scenario is independent and starts from the seed's fresh state — never chain scenarios.
|
||||
- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`).
|
||||
- Cover happy path, edge cases, validation, negative flows, persistence.
|
||||
- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`").
|
||||
- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generate
|
||||
|
||||
Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted.
|
||||
|
||||
### 2.1 Inputs
|
||||
|
||||
- **Spec file**, e.g. `specs/basic-operations.plan.md`.
|
||||
- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all.
|
||||
- **Seed file**, read from the `**Seed:**` line of the scenario's group.
|
||||
|
||||
### 2.2 Generate one scenario
|
||||
|
||||
For each target scenario, in sequence (never in parallel — scenarios share the seed session):
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never mise exec -- npm run test:e2e -- <seed-file> --debug=cli # background
|
||||
mise exec -- npm run playwright:cli -- attach tw-XXXX
|
||||
# resume
|
||||
```
|
||||
|
||||
**Do not** just open the app url with mise exec -- npm run playwright:cli --, always go through the test to capture any custom setup done there.
|
||||
|
||||
Walk the scenario's `Steps:` one by one with `mise exec -- npm run playwright:cli --`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected.
|
||||
|
||||
Every action prints the equivalent Playwright TypeScript (see [test-generation.md](test-generation.md)):
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- snapshot # find refs
|
||||
mise exec -- npm run playwright:cli -- fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...)
|
||||
mise exec -- npm run playwright:cli -- press Enter
|
||||
mise exec -- npm run playwright:cli -- click e7
|
||||
```
|
||||
|
||||
For each `- expect:` bullet, add an explicit assertion. See [test-generation.md](test-generation.md) for details.
|
||||
|
||||
Collect the generated code and write the test file at the path given in the spec:
|
||||
|
||||
```ts
|
||||
// spec: specs/basic-operations.plan.md
|
||||
// seed: e2e/seed.spec.ts
|
||||
import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file
|
||||
|
||||
test.describe('Signing in and out', () => {
|
||||
test('should sign in', async ({ page }) => {
|
||||
// 1. Navigate to the application
|
||||
// (handled by the seed fixture)
|
||||
|
||||
// 2. Type 'John Doe' into the username field
|
||||
await page.getByRole('textbox', { name: 'username' }).fill('John Doe');
|
||||
|
||||
// 3. Type password
|
||||
await page.getByRole('textbox', { name: 'password' }).fill('TestPassword');
|
||||
|
||||
// 4. Press Enter to submit
|
||||
await page.getByRole('textbox', { name: 'password' }).press('Enter');
|
||||
|
||||
await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal).
|
||||
- Prefix each numbered step with a `// N. <step text>` comment before its actions.
|
||||
- Use the describe group name verbatim from the spec (no `1.` ordinal).
|
||||
- Import from `./fixtures` if the project has one; otherwise `@playwright/test`.
|
||||
- **Important**: close the CLI session and stop the background test before moving to the next scenario.
|
||||
|
||||
### 2.3 Generate multiple scenarios
|
||||
|
||||
Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped.
|
||||
|
||||
### 2.4 Run generated tests
|
||||
|
||||
After generation, run the new tests once:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never mise exec -- npm run test:e2e -- e2e/<group>/<scenario>.spec.ts
|
||||
```
|
||||
|
||||
Any failure goes to Section 3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Heal
|
||||
|
||||
Goal: fix failing tests, and update the spec if the app's intended behaviour changed.
|
||||
|
||||
### 3.1 Find failing tests
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never mise exec -- npm run test:e2e --
|
||||
```
|
||||
|
||||
Record the list of failing `<file>:<line>` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile.
|
||||
|
||||
### 3.2 Debug one failure
|
||||
|
||||
Run the single failing test in debug mode in the background, then attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never mise exec -- npm run test:e2e -- e2e/<group>/<scenario>.spec.ts:<line> --debug=cli
|
||||
# wait for "Debugging Instructions" and the tw-XXXX session name
|
||||
mise exec -- npm run playwright:cli -- attach tw-XXXX
|
||||
```
|
||||
|
||||
The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- snapshot # did the element change / move / rename?
|
||||
mise exec -- npm run playwright:cli -- console # app-side errors?
|
||||
mise exec -- npm run playwright:cli -- requests # failed request? wrong payload?
|
||||
mise exec -- npm run playwright:cli -- show --annotate # ask the user to point somewhere
|
||||
```
|
||||
|
||||
Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs.
|
||||
|
||||
Rehearse the corrected interaction with `mise exec -- npm run playwright:cli --` — the generated code in the output is what you paste back into the test.
|
||||
|
||||
### 3.3 Apply the fix
|
||||
|
||||
Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green.
|
||||
|
||||
Never skip hooks or add sleeps as a fix. Never use `networkidle`.
|
||||
|
||||
### 3.4 Reconcile with the spec
|
||||
|
||||
Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test.
|
||||
|
||||
- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone.
|
||||
- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change.
|
||||
- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide:
|
||||
- the scenario id (e.g. `2.3`),
|
||||
- the spec lines that no longer match,
|
||||
- the observed app behaviour (quote a snapshot excerpt or a concrete outcome).
|
||||
|
||||
Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression).
|
||||
|
||||
### 3.5 Iteration and giving up
|
||||
|
||||
- Fix failures one at a time; rerun after each.
|
||||
- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
| For... | See |
|
||||
|---|---|
|
||||
| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) |
|
||||
| How `mise exec -- npm run playwright:cli --` actions become TS | [test-generation.md](test-generation.md) |
|
||||
| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) |
|
||||
| Managing the CLI browser session | [session-management.md](session-management.md) |
|
||||
@@ -0,0 +1,275 @@
|
||||
# Storage Management
|
||||
|
||||
Manage cookies, localStorage, sessionStorage, and browser storage state.
|
||||
|
||||
## Storage State
|
||||
|
||||
Save and restore complete browser state including cookies and storage.
|
||||
|
||||
### Save Storage State
|
||||
|
||||
```bash
|
||||
# Save to auto-generated filename (storage-state-{timestamp}.json)
|
||||
mise exec -- npm run playwright:cli -- state-save
|
||||
|
||||
# Save to specific filename
|
||||
mise exec -- npm run playwright:cli -- state-save .artifacts/playwright-cli/my-auth-state.json
|
||||
```
|
||||
|
||||
### Restore Storage State
|
||||
|
||||
```bash
|
||||
# Load storage state from file
|
||||
mise exec -- npm run playwright:cli -- state-load .artifacts/playwright-cli/my-auth-state.json
|
||||
|
||||
# Reload page to apply cookies
|
||||
mise exec -- npm run playwright:cli -- open https://example.com
|
||||
```
|
||||
|
||||
### Storage State File Format
|
||||
|
||||
The saved file contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "session_id",
|
||||
"value": "abc123",
|
||||
"domain": "example.com",
|
||||
"path": "/",
|
||||
"expires": 1893456000,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://example.com",
|
||||
"localStorage": [
|
||||
{ "name": "theme", "value": "dark" },
|
||||
{ "name": "user_id", "value": "12345" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cookies
|
||||
|
||||
### List All Cookies
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- cookie-list
|
||||
```
|
||||
|
||||
### Filter Cookies by Domain
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- cookie-list --domain=example.com
|
||||
```
|
||||
|
||||
### Filter Cookies by Path
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- cookie-list --path=/api
|
||||
```
|
||||
|
||||
### Get Specific Cookie
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- cookie-get session_id
|
||||
```
|
||||
|
||||
### Set a Cookie
|
||||
|
||||
```bash
|
||||
# Basic cookie
|
||||
mise exec -- npm run playwright:cli -- cookie-set session abc123
|
||||
|
||||
# Cookie with options
|
||||
mise exec -- npm run playwright:cli -- cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
|
||||
|
||||
# Cookie with expiration (Unix timestamp)
|
||||
mise exec -- npm run playwright:cli -- cookie-set remember_me token123 --expires=1893456000
|
||||
```
|
||||
|
||||
### Delete a Cookie
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- cookie-delete session_id
|
||||
```
|
||||
|
||||
### Clear All Cookies
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- cookie-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Cookies or Custom Options
|
||||
|
||||
For complex scenarios like adding multiple cookies at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.context().addCookies([
|
||||
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
|
||||
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
|
||||
]);
|
||||
}"
|
||||
```
|
||||
|
||||
## Local Storage
|
||||
|
||||
### List All localStorage Items
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- localstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- localstorage-get token
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- localstorage-set theme dark
|
||||
```
|
||||
|
||||
### Set JSON Value
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- localstorage-set user_settings '{"theme":"dark","language":"en"}'
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- localstorage-delete token
|
||||
```
|
||||
|
||||
### Clear All localStorage
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- localstorage-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Operations
|
||||
|
||||
For complex scenarios like setting multiple values at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('token', 'jwt_abc123');
|
||||
localStorage.setItem('user_id', '12345');
|
||||
localStorage.setItem('expires_at', Date.now() + 3600000);
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Session Storage
|
||||
|
||||
### List All sessionStorage Items
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- sessionstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- sessionstorage-get form_data
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- sessionstorage-set step 3
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- sessionstorage-delete step
|
||||
```
|
||||
|
||||
### Clear sessionStorage
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- sessionstorage-clear
|
||||
```
|
||||
|
||||
## IndexedDB
|
||||
|
||||
### List Databases
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
return await page.evaluate(async () => {
|
||||
const databases = await indexedDB.databases();
|
||||
return databases;
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Delete Database
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
indexedDB.deleteDatabase('myDatabase');
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authentication State Reuse
|
||||
|
||||
```bash
|
||||
# Step 1: Login and save state
|
||||
mise exec -- npm run playwright:cli -- open https://app.example.com/login
|
||||
mise exec -- npm run playwright:cli -- snapshot
|
||||
mise exec -- npm run playwright:cli -- fill e1 "user@example.com"
|
||||
mise exec -- npm run playwright:cli -- fill e2 "password123"
|
||||
mise exec -- npm run playwright:cli -- click e3
|
||||
|
||||
# Save the authenticated state
|
||||
mise exec -- npm run playwright:cli -- state-save .artifacts/playwright-cli/auth.json
|
||||
|
||||
# Step 2: Later, restore state and skip login
|
||||
mise exec -- npm run playwright:cli -- state-load .artifacts/playwright-cli/auth.json
|
||||
mise exec -- npm run playwright:cli -- open https://app.example.com/dashboard
|
||||
# Already logged in!
|
||||
```
|
||||
|
||||
### Save and Restore Roundtrip
|
||||
|
||||
```bash
|
||||
# Set up authentication state
|
||||
mise exec -- npm run playwright:cli -- open https://example.com
|
||||
mise exec -- npm run playwright:cli -- eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
|
||||
|
||||
# Save state to file
|
||||
mise exec -- npm run playwright:cli -- state-save .artifacts/playwright-cli/my-session.json
|
||||
|
||||
# ... later, in a new session ...
|
||||
|
||||
# Restore state
|
||||
mise exec -- npm run playwright:cli -- state-load .artifacts/playwright-cli/my-session.json
|
||||
mise exec -- npm run playwright:cli -- open https://example.com
|
||||
# Cookies and localStorage are restored!
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit storage state files containing auth tokens
|
||||
- Add `*.auth-state.json` to `.gitignore`
|
||||
- Delete state files after automation completes
|
||||
- Use environment variables for sensitive data
|
||||
- By default, sessions run in-memory mode which is safer for sensitive operations
|
||||
@@ -0,0 +1,134 @@
|
||||
# Test Generation
|
||||
|
||||
Generate Playwright test code automatically as you interact with the browser.
|
||||
|
||||
## How It Works
|
||||
|
||||
Every action you perform with `mise exec -- npm run playwright:cli --` generates corresponding Playwright TypeScript code.
|
||||
This code appears in the output and can be copied directly into your test files.
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```bash
|
||||
# Start a session
|
||||
mise exec -- npm run playwright:cli -- open https://example.com/login
|
||||
|
||||
# Take a snapshot to see elements
|
||||
mise exec -- npm run playwright:cli -- snapshot
|
||||
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
|
||||
|
||||
# Fill form fields - generates code automatically
|
||||
mise exec -- npm run playwright:cli -- fill e1 "user@example.com"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
|
||||
mise exec -- npm run playwright:cli -- fill e2 "password123"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
|
||||
mise exec -- npm run playwright:cli -- click e3
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
```
|
||||
|
||||
## Building a Test File
|
||||
|
||||
Collect the generated code into a Playwright test:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('login flow', async ({ page }) => {
|
||||
// Generated code from mise exec -- npm run playwright:cli -- session:
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Add assertions
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Semantic Locators
|
||||
|
||||
The generated code uses role-based locators when possible, which are more resilient:
|
||||
|
||||
```typescript
|
||||
// Generated (good - semantic)
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Avoid (fragile - CSS selectors)
|
||||
await page.locator('#submit-btn').click();
|
||||
```
|
||||
|
||||
### 2. Explore Before Recording
|
||||
|
||||
Take snapshots to understand the page structure before recording actions:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- open https://example.com
|
||||
mise exec -- npm run playwright:cli -- snapshot
|
||||
# Review the element structure
|
||||
mise exec -- npm run playwright:cli -- click e5
|
||||
```
|
||||
|
||||
### 3. Add Assertions Manually
|
||||
|
||||
Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers:
|
||||
|
||||
- `toBeVisible()` — element is rendered and visible
|
||||
- `toHaveText(text)` — element text content matches
|
||||
- `toHaveValue(value) / toBeEmpty()` — input/select value matches
|
||||
- `toBeChecked() / toBeUnchecked()` — checkbox state matches
|
||||
- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot
|
||||
|
||||
Use `mise exec -- npm run playwright:cli -- generate-locator <target>` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value.
|
||||
|
||||
When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead.
|
||||
|
||||
Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values.
|
||||
|
||||
```bash
|
||||
# Get a stable locator for an element ref to use in the assertion
|
||||
mise exec -- npm run playwright:cli -- --raw generate-locator e5
|
||||
# getByRole('button', { name: 'Submit' })
|
||||
|
||||
# Capture expected text content for toHaveText
|
||||
mise exec -- npm run playwright:cli -- --raw eval "el => el.textContent" e5
|
||||
|
||||
# Capture expected input value for toHaveValue/toBeEmpty
|
||||
mise exec -- npm run playwright:cli -- --raw eval "el => el.value" e5
|
||||
|
||||
# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked
|
||||
# (whole page, or use a ref to scope to a region)
|
||||
mise exec -- npm run playwright:cli -- --raw snapshot
|
||||
mise exec -- npm run playwright:cli -- --raw snapshot e5
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Generated action
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Manual assertions using the outputs above:
|
||||
await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible();
|
||||
await expect(page.getByTestId('main-header')).toHaveText('Welcome, user');
|
||||
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com');
|
||||
await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked();
|
||||
|
||||
// toMatchAriaSnapshot on the whole page, finds a matching region
|
||||
await expect(page).toMatchAriaSnapshot(`
|
||||
- heading "Welcome, user"
|
||||
- link /\\d+ new messages?/
|
||||
- button "Sign out"
|
||||
`);
|
||||
|
||||
// toMatchAriaSnapshot scoped to a region
|
||||
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
|
||||
- link "Home"
|
||||
- link /\\d+ new messages?/
|
||||
- link "Profile"
|
||||
`);
|
||||
```
|
||||
@@ -0,0 +1,139 @@
|
||||
# Tracing
|
||||
|
||||
Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Start trace recording
|
||||
mise exec -- npm run playwright:cli -- tracing-start
|
||||
|
||||
# Perform actions
|
||||
mise exec -- npm run playwright:cli -- open https://example.com
|
||||
mise exec -- npm run playwright:cli -- click e1
|
||||
mise exec -- npm run playwright:cli -- fill e2 "test"
|
||||
|
||||
# Stop trace recording
|
||||
mise exec -- npm run playwright:cli -- tracing-stop
|
||||
```
|
||||
|
||||
## Trace Output Files
|
||||
|
||||
When you start tracing, Playwright creates a `traces/` directory with several files:
|
||||
|
||||
### `trace-{timestamp}.trace`
|
||||
|
||||
**Action log** - The main trace file containing:
|
||||
- Every action performed (clicks, fills, navigations)
|
||||
- DOM snapshots before and after each action
|
||||
- Screenshots at each step
|
||||
- Timing information
|
||||
- Console messages
|
||||
- Source locations
|
||||
|
||||
### `trace-{timestamp}.network`
|
||||
|
||||
**Network log** - Complete network activity:
|
||||
- All HTTP requests and responses
|
||||
- Request headers and bodies
|
||||
- Response headers and bodies
|
||||
- Timing (DNS, connect, TLS, TTFB, download)
|
||||
- Resource sizes
|
||||
- Failed requests and errors
|
||||
|
||||
### `resources/`
|
||||
|
||||
**Resources directory** - Cached resources:
|
||||
- Images, fonts, stylesheets, scripts
|
||||
- Response bodies for replay
|
||||
- Assets needed to reconstruct page state
|
||||
|
||||
## What Traces Capture
|
||||
|
||||
| Category | Details |
|
||||
|----------|---------|
|
||||
| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
|
||||
| **DOM** | Full DOM snapshot before/after each action |
|
||||
| **Screenshots** | Visual state at each step |
|
||||
| **Network** | All requests, responses, headers, bodies, timing |
|
||||
| **Console** | All console.log, warn, error messages |
|
||||
| **Timing** | Precise timing for each operation |
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Debugging Failed Actions
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- tracing-start
|
||||
mise exec -- npm run playwright:cli -- open https://app.example.com
|
||||
|
||||
# This click fails - why?
|
||||
mise exec -- npm run playwright:cli -- click e5
|
||||
|
||||
mise exec -- npm run playwright:cli -- tracing-stop
|
||||
# Open trace to see DOM state when click was attempted
|
||||
```
|
||||
|
||||
### Analyzing Performance
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- tracing-start
|
||||
mise exec -- npm run playwright:cli -- open https://slow-site.com
|
||||
mise exec -- npm run playwright:cli -- tracing-stop
|
||||
|
||||
# View network waterfall to identify slow resources
|
||||
```
|
||||
|
||||
### Capturing Evidence
|
||||
|
||||
```bash
|
||||
# Record a complete user flow for documentation
|
||||
mise exec -- npm run playwright:cli -- tracing-start
|
||||
|
||||
mise exec -- npm run playwright:cli -- open https://app.example.com/checkout
|
||||
mise exec -- npm run playwright:cli -- fill e1 "4111111111111111"
|
||||
mise exec -- npm run playwright:cli -- fill e2 "12/25"
|
||||
mise exec -- npm run playwright:cli -- fill e3 "123"
|
||||
mise exec -- npm run playwright:cli -- click e4
|
||||
|
||||
mise exec -- npm run playwright:cli -- tracing-stop
|
||||
# Trace shows exact sequence of events
|
||||
```
|
||||
|
||||
## Trace vs Video vs Screenshot
|
||||
|
||||
| Feature | Trace | Video | Screenshot |
|
||||
|---------|-------|-------|------------|
|
||||
| **Format** | .trace file | .webm video | .png/.jpeg image |
|
||||
| **DOM inspection** | Yes | No | No |
|
||||
| **Network details** | Yes | No | No |
|
||||
| **Step-by-step replay** | Yes | Continuous | Single frame |
|
||||
| **File size** | Medium | Large | Small |
|
||||
| **Best for** | Debugging | Demos | Quick capture |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Start Tracing Before the Problem
|
||||
|
||||
```bash
|
||||
# Trace the entire flow, not just the failing step
|
||||
mise exec -- npm run playwright:cli -- tracing-start
|
||||
mise exec -- npm run playwright:cli -- open https://example.com
|
||||
# ... all steps leading to the issue ...
|
||||
mise exec -- npm run playwright:cli -- tracing-stop
|
||||
```
|
||||
|
||||
### 2. Clean Up Old Traces
|
||||
|
||||
Traces can consume significant disk space:
|
||||
|
||||
```bash
|
||||
# Remove traces older than 7 days
|
||||
find ../.artifacts/playwright-cli -type f -mtime +7 -delete
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Traces add overhead to automation
|
||||
- Large traces can consume significant disk space
|
||||
- Some dynamic content may not replay perfectly
|
||||
@@ -0,0 +1,143 @@
|
||||
# Video Recording
|
||||
|
||||
Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
|
||||
|
||||
## Basic Recording
|
||||
|
||||
```bash
|
||||
# Open browser first
|
||||
mise exec -- npm run playwright:cli -- open
|
||||
|
||||
# Start recording
|
||||
mise exec -- npm run playwright:cli -- video-start .artifacts/playwright-cli/demo.webm
|
||||
|
||||
# Add a chapter marker for section transitions
|
||||
mise exec -- npm run playwright:cli -- video-chapter "Getting Started" --description="Opening the homepage" --duration=2000
|
||||
|
||||
# Navigate and perform actions
|
||||
mise exec -- npm run playwright:cli -- goto https://example.com
|
||||
mise exec -- npm run playwright:cli -- snapshot
|
||||
mise exec -- npm run playwright:cli -- click e1
|
||||
|
||||
# Add another chapter
|
||||
mise exec -- npm run playwright:cli -- video-chapter "Filling Form" --description="Entering test data" --duration=2000
|
||||
mise exec -- npm run playwright:cli -- fill e2 "test input"
|
||||
|
||||
# Stop and save
|
||||
mise exec -- npm run playwright:cli -- video-stop
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Descriptive Filenames
|
||||
|
||||
```bash
|
||||
# Include context in filename
|
||||
mise exec -- npm run playwright:cli -- video-start .artifacts/playwright-cli/recordings/login-flow-2024-01-15.webm
|
||||
mise exec -- npm run playwright:cli -- video-start .artifacts/playwright-cli/recordings/checkout-test-run-42.webm
|
||||
```
|
||||
|
||||
### 2. Record entire hero scripts.
|
||||
|
||||
When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code.
|
||||
It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
|
||||
|
||||
1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight.
|
||||
2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses.
|
||||
3) Use mise exec -- npm run playwright:cli -- run-code --filename your-script.js
|
||||
|
||||
**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page.
|
||||
|
||||
```js
|
||||
async page => {
|
||||
await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } });
|
||||
await page.goto('https://demo.playwright.dev/todomvc');
|
||||
|
||||
// Show a chapter card — blurs the page and shows a dialog.
|
||||
// Blocks until duration expires, then auto-removes.
|
||||
// Use this for simple use cases, but always feel free to hand-craft your own beautiful
|
||||
// overlay via await page.screencast.showOverlay().
|
||||
await page.screencast.showChapter('Adding Todo Items', {
|
||||
description: 'We will add several items to the todo list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Perform action
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Show next chapter
|
||||
await page.screencast.showChapter('Verifying Results', {
|
||||
description: 'Checking the item appeared in the list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Add a sticky annotation that stays while you perform actions.
|
||||
// Overlays are pointer-events: none, so they won't block clicks.
|
||||
const annotation = await page.screencast.showOverlay(`
|
||||
<div style="position: absolute; top: 8px; right: 8px;
|
||||
padding: 6px 12px; background: rgba(0,0,0,0.7);
|
||||
border-radius: 8px; font-size: 13px; color: white;">
|
||||
✓ Item added successfully
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Perform more actions while the annotation is visible
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Remove the annotation when done
|
||||
await annotation.dispose();
|
||||
|
||||
// You can also highlight relevant locators and provide contextual annotations.
|
||||
const bounds = await page.getByText('Walk the dog').boundingBox();
|
||||
await page.screencast.showOverlay(`
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y}px;
|
||||
left: ${bounds.x}px;
|
||||
width: ${bounds.width}px;
|
||||
height: ${bounds.height}px;
|
||||
border: 1px solid red;">
|
||||
</div>
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y + bounds.height + 5}px;
|
||||
left: ${bounds.x + bounds.width / 2}px;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px;
|
||||
background: #808080;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: white;">Check it out, it is right above this text
|
||||
</div>
|
||||
`, { duration: 2000 });
|
||||
|
||||
await page.screencast.stop();
|
||||
}
|
||||
```
|
||||
|
||||
Embrace creativity, overlays are powerful.
|
||||
|
||||
### Overlay API Summary
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions |
|
||||
| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights |
|
||||
| `disposable.dispose()` | Remove a sticky overlay added without duration |
|
||||
| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays |
|
||||
|
||||
## Tracing vs Video
|
||||
|
||||
| Feature | Video | Tracing |
|
||||
|---------|-------|---------|
|
||||
| Output | WebM file | Trace file (viewable in Trace Viewer) |
|
||||
| Shows | Visual recording | DOM snapshots, network, console, actions |
|
||||
| Use case | Demos, documentation | Debugging, analysis |
|
||||
| Size | Larger | Smaller |
|
||||
|
||||
## Limitations
|
||||
|
||||
- Recording adds slight overhead to automation
|
||||
- Large recordings can consume significant disk space
|
||||
@@ -34,6 +34,10 @@ go.work.sum
|
||||
# Mise
|
||||
mise.local.toml
|
||||
|
||||
# Project-local development caches and browser artifacts
|
||||
/.cache/
|
||||
/.artifacts/
|
||||
|
||||
# MyGO configuration
|
||||
# Tracking config.example.yaml in version control
|
||||
config.yaml
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"browser": {
|
||||
"browserName": "chromium",
|
||||
"isolated": true,
|
||||
"launchOptions": {
|
||||
"channel": "chromium",
|
||||
"headless": true,
|
||||
"chromiumSandbox": true
|
||||
}
|
||||
},
|
||||
"outputDir": ".artifacts/playwright-cli",
|
||||
"outputMaxSize": 104857600
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
MyGO is a WebDisk (cloud drive) server, written in Go.
|
||||
|
||||
**Current status**: pre-alpha — project skeleton with no core functionality implemented yet.
|
||||
**Current status**: pre-alpha — basic JWT authentication and file transfer flows
|
||||
are available while their APIs and large-file behavior continue to evolve.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,8 +12,8 @@ MyGO is a WebDisk (cloud drive) server, written in Go.
|
||||
### v0
|
||||
|
||||
- [ ] CLI configuration management (`mygo config`)
|
||||
- [ ] User authentication (JWT)
|
||||
- [ ] File upload / download / management (HTTP API)
|
||||
- [x] User authentication (JWT)
|
||||
- [x] Basic file upload / download / management (HTTP API)
|
||||
- [ ] Admin endpoints
|
||||
- [ ] WebDAV support
|
||||
|
||||
@@ -39,7 +40,11 @@ MyGO is a WebDisk (cloud drive) server, written in Go.
|
||||
|
||||
## Frontend
|
||||
|
||||
The frontend uses React + TypeScript and communicates with the backend via HTTP API. Frontend source is maintained in `web/`.
|
||||
The frontend uses React + TypeScript and communicates with the backend via HTTP
|
||||
API. Its first milestone supports login, a root file list, single-file upload,
|
||||
and authenticated download. Frontend source is maintained in `web/`; see
|
||||
[`web/README.md`](web/README.md) for development, testing, and browser-debugging
|
||||
instructions.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ Rules:
|
||||
- JSON response DTOs live in HTTP packages. Domain and service result structs are not the public REST schema.
|
||||
- Domain models may use `json:"-"` for defensive redaction of sensitive fields; this is not a REST response contract.
|
||||
- Domain errors carry a protocol-neutral kind, a safe message, and an optional cause. HTTP status mapping happens only at the API boundary.
|
||||
- Repository query ports remain entity-oriented, while mutation ports expose business commands with operation-specific parameter types. Services never pass a mutable domain entity to a generic update or delete method.
|
||||
- Service constructors using split repository capabilities accept the smallest capability needed by that service. Their composition-time unions do not cross into business services.
|
||||
- Repository mutations use explicit columns and lifecycle/ownership predicates. Full-entity `Save` is prohibited so a write cannot accidentally change protected fields or revive a soft-deleted record.
|
||||
- Repository abstracts the database; Storage abstracts where bytes live, including staged upload promotion.
|
||||
- `internal/server` is the composition root — wires all dependencies together.
|
||||
|
||||
@@ -42,6 +45,19 @@ Rules:
|
||||
| | `internal/storage` | Storage backend interface + local disk impl, with staging and promotion | 🛠 WIP |
|
||||
| **Util** | `internal/auth` | JWT sign/verify (HS256), token type discrimination (access/refresh), password hashing (bcrypt), app passkey tokens | ✅ |
|
||||
| | `internal/api` | Unified JSON error response helpers and REST error-kind mapping | ✅ |
|
||||
| | `internal/testutil` | Test-only fixture helpers for privileged setup that production service ports intentionally do not expose | ✅ |
|
||||
|
||||
## Persistence Write Boundaries
|
||||
|
||||
- User registration accepts `RegisteredUserParams`; the repository fixes new users to non-admin, active state. Administrative deletion is a separate capability.
|
||||
- File creation, directory creation, metadata changes, and soft deletion use distinct commands. Metadata updates can only write `name`, `parent_id`, and GORM-maintained `updated_at`.
|
||||
- Refresh sessions are created, atomically consumed, idempotently revoked, or removed by explicit maintenance operations. Refresh token rotation consumes a session at most once.
|
||||
- Passkey creation fixes the credential type to the app-passkey type. Usage recording and revocation include credential-type and ownership conditions where applicable.
|
||||
- `app.WebApp` contains runtime services, storage, database lifecycle, configuration, and metadata. Repository implementations remain local to bootstrap and are injected directly into the services that require them.
|
||||
|
||||
## File Hierarchy Transactions
|
||||
|
||||
File child creation, movement, and directory deletion share one parent-locking protocol. PostgreSQL locks active owned source and parent rows in sorted ID order with `SELECT ... FOR UPDATE`; directory deletion checks active children while holding the directory lock. SQLite connections add `_txlock=immediate` to the configured DSN so the transaction acquires its write reservation before reading hierarchy state. These rules prevent a concurrent mutation from creating an active child below a deleted directory.
|
||||
|
||||
## API Routes (v0)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
**Consequences**:
|
||||
- Version is build metadata from `internal/app/version.go`, not a config-file field.
|
||||
- `app.WebApp` is the place to add future services, repositories, storage, and app metadata incrementally.
|
||||
- `app.WebApp` is the place to add future runtime services, storage, and app metadata incrementally; repository implementations stay inside composition setup.
|
||||
- Request ID middleware is not part of the current foundation; add it only with a logging/tracing/error-correlation design.
|
||||
|
||||
## 2026-04-29: Auth Refinements
|
||||
@@ -106,3 +106,43 @@
|
||||
- REST remains a handler/API concern, and future protocols can reuse services without HTTP leakage.
|
||||
- Error responses keep the same top-level shape, with optional `log_id` instead of embedding log references in the message.
|
||||
- Admin and auth middleware behavior is testable through service contracts rather than database access.
|
||||
|
||||
## 2026-07-15: Conceal File Resource Existence
|
||||
|
||||
**Context**: Returning permission-denied errors for cross-user file access allowed authenticated callers to distinguish another user's resource from a missing resource. Idempotent success for deleting missing files also conflicted with a consistent not-found concealment policy.
|
||||
|
||||
**Decisions**:
|
||||
|
||||
| Decision | Guidance |
|
||||
|----------|----------|
|
||||
| Conceal ownership | File and parent-directory ownership failures return the same not-found kind and safe message as missing or deleted resources. |
|
||||
| Strict delete result | Deleting an active owned file returns success once; missing, deleted, and cross-user targets return not found. |
|
||||
| Atomic soft delete | Repository deletes update only active rows and return `model.ErrNotFound` when no row changes. |
|
||||
|
||||
**Consequences**:
|
||||
- Authenticated file operations cannot use status codes or messages to determine whether another user's resource exists.
|
||||
- A successful first delete returns `204 No Content`; repeated deletes return `404 Not Found` while remaining idempotent in effect.
|
||||
- Unauthenticated requests remain `401 Unauthorized`, non-empty directory deletion remains `409 Conflict`, and permission-denied behavior outside the file boundary remains unchanged.
|
||||
|
||||
## 2026-07-16: Command-Scoped Persistence Mutations
|
||||
|
||||
**Context**: Generic `Repository.Update(*Entity)`, full-entity GORM `Save`, and broad delete methods exposed fields and lifecycle transitions that individual services were not authorized to perform. They also allowed a stale file entity to revive a soft-deleted row, left file parent checks vulnerable to concurrent deletion, and allowed two refresh requests to consume the same session.
|
||||
|
||||
**Decisions**:
|
||||
|
||||
| Decision | Guidance |
|
||||
|----------|----------|
|
||||
| Command-scoped writes | Every mutation has a use-case-specific method and parameter type. Mutable domain entities do not cross a service-to-repository write boundary. |
|
||||
| Capability interfaces | Auth and admin services accept only the query and command capabilities they require. Wider user and session interfaces exist only for construction and repository-focused consumers. |
|
||||
| Explicit mutation predicates | Updates name concrete columns and include ownership, credential type, and/or active-state predicates appropriate to the command. A zero-row guarded mutation maps to a domain not-found result. |
|
||||
| Transactional file hierarchy | Child creation, movement, and directory deletion revalidate hierarchy state in the same transaction. PostgreSQL locks relevant rows in sorted ID order; SQLite DSNs force `_txlock=immediate`. |
|
||||
| Atomic refresh consumption | Refresh rotates through one transaction that locks, returns, and deletes the session. A concurrent replay cannot issue a second token pair. Logout remains an idempotent revoke-by-hash command. |
|
||||
| Test-only privilege setup | Tests elevate users through `internal/testutil`; no production role mutation is added merely to prepare fixtures. |
|
||||
|
||||
**Consequences**:
|
||||
|
||||
- Protected user, file, session, and credential fields are absent from ordinary mutation inputs, reducing illegal write paths at compile time.
|
||||
- Deleted file rows cannot be revived by stale metadata updates, and successful hierarchy races cannot leave active orphan children.
|
||||
- PostgreSQL relies on row-level locking while SQLite serializes these hierarchy and session write transactions from their first database operation. This favors correctness over concurrent SQLite writers.
|
||||
- Adding a future profile, password, role, restore, or other mutation requires a named command and an explicit capability rather than extending a universal update method.
|
||||
- REST routes, payloads, status codes, database schema, and dependency set remain unchanged.
|
||||
|
||||
@@ -17,6 +17,7 @@ go build -o mygo .
|
||||
```bash
|
||||
go test ./...
|
||||
go test -v -run TestName ./internal/...
|
||||
go test -race ./internal/repository ./internal/service ./internal/server
|
||||
```
|
||||
|
||||
## Lint & Format
|
||||
@@ -52,6 +53,12 @@ go mod tidy # after adding/removing imports
|
||||
|
||||
Server config is loaded via viper from `config.yaml` (defaults in `internal/config/load.go`).
|
||||
|
||||
For SQLite, the repository preserves configured DSN parameters and adds
|
||||
`_txlock=immediate`. Write transactions therefore reserve the database before
|
||||
performing hierarchy or refresh-session reads, matching the locking assumptions
|
||||
used by the repository commands. Keep this behavior when supplying a SQLite URI
|
||||
such as `file:mygo.db?cache=shared`.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| CLI config management | ✅ | Viper YAML + env + flags, typed Duration config |
|
||||
| JWT authentication | ✅ | access + refresh tokens, refresh token in DB, app passkey support |
|
||||
| JWT authentication | ✅ | access + refresh tokens, atomic single-use refresh sessions, app passkey support |
|
||||
| Web API foundation | ✅ | WebApp composition, Gin router, graceful shutdown, `GET /api/v1/version` |
|
||||
| File upload/download/manage APIs | 🛠 WIP | REST API via Gin |
|
||||
| Admin endpoints | 🛠 WIP | user service boundary in place for superusers |
|
||||
@@ -21,13 +21,13 @@ Package-level implementation order (each task includes unit tests):
|
||||
4. `internal/api` — protocol-neutral error kind to REST response mapping ✅
|
||||
5. `internal/auth` — JWT utils ✅
|
||||
6. `internal/storage` — backend interface + local fs with staged upload promotion
|
||||
7. `internal/repository` — interfaces + GORM/SQLite impl ✅
|
||||
7. `internal/repository` — command-scoped mutation capabilities + GORM/SQLite transaction protocol ✅
|
||||
8. `internal/service` — auth, file, admin services ✅
|
||||
9. `internal/middleware` — logger, cors, auth ✅ (auth done; principal boundary enforced)
|
||||
10. `internal/handler` — auth, account, file, admin handlers 🛠 (HTTP DTO mapping in place)
|
||||
11. `internal/server` — Gin router, route registration, graceful shutdown ✅
|
||||
12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done)
|
||||
13. Integration tests
|
||||
13. Integration tests 🛠 (authenticated register → login → upload → list → download route flow covered with a small file; authentication bypass, invalid token, concealed cross-user resources, strict delete, atomic refresh consumption, and file hierarchy race boundaries covered)
|
||||
14. Architecture boundary tests ✅
|
||||
|
||||
## Future
|
||||
|
||||
@@ -17,3 +17,86 @@
|
||||
- The Web and future native clients consume the same client-neutral API contracts.
|
||||
- MyGO or a reverse proxy may host `web/dist` with an SPA fallback without changing the rendering model.
|
||||
- MyGO domain components own file-browser behavior and must not depend on Ant Design request behavior for business logic.
|
||||
|
||||
## 2026-07-14: Browser Authentication and Root File Workflow
|
||||
|
||||
**Context**: The first Web milestone needs to exercise the existing login, file
|
||||
list, upload, and download APIs without committing to the later directory,
|
||||
account, admin, or large-transfer designs.
|
||||
|
||||
**Decisions**:
|
||||
|
||||
| Area | Choice | Guidance |
|
||||
|------|--------|----------|
|
||||
| API topology | Same-origin `/api/v1` | Vite proxies `/api` to the local Go server. Production uses a same-origin reverse proxy; the milestone does not add CORS or Go static hosting. |
|
||||
| Browser session | Token pair in `sessionStorage` | Reloading the tab preserves the session, while closing the tab clears it. Do not add persistent login until the token transport design is revisited. |
|
||||
| Token refresh | Refresh once after a protected request returns 401 | Share one in-flight refresh across concurrent failures, store the rotated pair, and retry each request once. Clear the session if refresh or the retry fails. |
|
||||
| File scope | Root directory only | List root entries, upload one file to root, and download files. Show directories as non-interactive rows. |
|
||||
| Transfer model | Browser `FormData` upload and authenticated Blob download | This is intentionally limited to the small-file milestone; progress, streaming-to-disk, chunking, resume, and queues remain deferred. |
|
||||
|
||||
**Consequences**:
|
||||
- The API client owns bearer headers, error parsing, refresh coordination, and
|
||||
session invalidation; pages consume operation-specific functions.
|
||||
- TanStack Query caches are cleared whenever an authenticated session ends so
|
||||
one user cannot see another user's cached file metadata.
|
||||
- The file picker is independent of Ant Design upload request behavior, keeping
|
||||
transfer policy in MyGO code.
|
||||
- Handwritten TypeScript wire types remain temporary until an OpenAPI contract
|
||||
is available.
|
||||
|
||||
## 2026-07-14: Project-local Headless Browser Tooling
|
||||
|
||||
**Status**: Agent-debugging and browser-version decisions were superseded by
|
||||
the 2026-07-15 Playwright CLI decision below. The Playwright Test and
|
||||
project-local resource decisions remain active.
|
||||
|
||||
**Context**: Browser behavior needs deterministic E2E coverage and interactive
|
||||
agent debugging inside a long-lived headless Debian Incus container. Tooling
|
||||
should remain reproducible without placing browser binaries or generated
|
||||
artifacts in a developer's home directory.
|
||||
|
||||
**Decisions**:
|
||||
|
||||
| Area | Choice | Guidance |
|
||||
|------|--------|----------|
|
||||
| Regression tests | Playwright Test with bundled Chromium | Keep browser E2E tests separate from `npm run check` because browser installation is an explicit environment setup step. |
|
||||
| Agent debugging | Official Playwright MCP over project-scoped STDIO | Codex starts the locked local package from `.codex/config.toml`; do not use a global install, an `@latest` npx invocation, or a listening MCP service. |
|
||||
| Browser state | Headless, isolated, and sandboxed | Use bundled Chromium, discard the MCP profile after each session, and retain the Chromium sandbox supported by the Incus environment. |
|
||||
| Local resources | Repository `.cache/` and `.artifacts/` directories | `mise.toml` defines the portable npm and browser cache paths. Generated resources remain ignored by Git. |
|
||||
| Browser versions | Install both locked Playwright revisions | The stable test runner and current MCP package require different Chromium revisions; do not force either package onto an unsupported executable. |
|
||||
|
||||
**Consequences**:
|
||||
- Debian browser libraries are installed once in the Incus container root
|
||||
filesystem; npm packages, browsers, traces, screenshots, and MCP output stay
|
||||
project-scoped.
|
||||
- A fresh environment runs `npm ci`, the system dependency installer, and both
|
||||
browser install scripts before browser tests or MCP debugging.
|
||||
- Browser failures can retain traces, screenshots, and video under
|
||||
`.artifacts/playwright/` without adding generated files to source control.
|
||||
|
||||
## 2026-07-15: Stable Playwright CLI Agent Debugging
|
||||
|
||||
**Context**: The official Playwright MCP package pulled an alpha Playwright
|
||||
core alongside the stable test runner. That required a second Chromium
|
||||
revision and expanded the agent tool surface. Playwright 1.61.1 already
|
||||
provides the same browser-debugging command set through its embedded CLI.
|
||||
|
||||
**Decisions**:
|
||||
|
||||
| Area | Choice | Guidance |
|
||||
|------|--------|----------|
|
||||
| Agent entry point | Stable `playwright cli` behind the `playwright:cli` npm script | Run it from `web/` with `mise exec -- npm run playwright:cli --`; do not use a global install, standalone `@playwright/cli`, `playwright-cli`, or floating `npx`. |
|
||||
| Version ownership | `@playwright/test` is the only Playwright dependency | E2E tests and interactive debugging use Playwright 1.61.1 and Chromium revision 1228. |
|
||||
| Agent workflow | Repository Skill in `.agents/skills/playwright-cli` | Start from Playwright's generated Skill, then preserve the MyGO npm wrapper, headless workflow, artifact policy, and debugging principles when updating it. |
|
||||
| Browser policy | Bundled Chromium, headless, isolated, and sandboxed | `.playwright/cli.config.json` is the shared CLI configuration; do not silently disable the Chromium sandbox. |
|
||||
| Local resources | Repository `.cache/` and `.artifacts/` directories | `mise.toml` redirects XDG, npm, and browser caches; CLI output is limited to 100 MB under `.artifacts/playwright-cli/`. |
|
||||
|
||||
**Consequences**:
|
||||
- A fresh environment needs one locked npm install, the Debian browser
|
||||
libraries, and the stable Chromium install before E2E or CLI use.
|
||||
- Changing Playwright's CLI entry point only requires changing the npm script;
|
||||
the repository Skill and operator commands remain stable.
|
||||
- The official generated references remain available through progressive
|
||||
disclosure, while the main Skill carries only the MyGO-specific workflow.
|
||||
- The MCP server configuration, alpha Playwright core, and Chromium revision
|
||||
1232 are no longer required.
|
||||
|
||||
+19
-3
@@ -8,9 +8,23 @@
|
||||
- Production may serve `web/dist` from MyGO or a reverse proxy.
|
||||
- Shared API contracts should remain client-neutral and eventually be described by OpenAPI.
|
||||
|
||||
## Current Milestone
|
||||
|
||||
The browser client now provides the first authenticated file workflow:
|
||||
|
||||
- Email/password login through the shared REST API
|
||||
- Session-scoped access and refresh tokens with one automatic refresh retry
|
||||
- Protected application shell and root file list
|
||||
- Single-file upload to the root directory and authenticated download
|
||||
- Server-side pagination with 50 items per page
|
||||
|
||||
Directory navigation and management, multi-file queues, transfer progress,
|
||||
resumable or chunked transfers, account/profile/settings screens, and admin
|
||||
screens remain deferred.
|
||||
|
||||
## Foundation
|
||||
|
||||
The initial project contains only the framework and styling foundation:
|
||||
The project foundation contains:
|
||||
|
||||
- Node.js 24 and npm
|
||||
- Vite
|
||||
@@ -19,7 +33,11 @@ The initial project contains only the framework and styling foundation:
|
||||
- TanStack Query provider
|
||||
- Tailwind CSS 4 through its Vite plugin
|
||||
- Ant Design provider and components
|
||||
- Ant Design icons for application and file actions
|
||||
- Oxlint from the Vite template
|
||||
- Vitest for framework-independent client and session tests
|
||||
- Playwright Test with a headless Chromium smoke test
|
||||
- Project-scoped Playwright CLI and repository Skill for interactive browser debugging
|
||||
|
||||
Ant Design owns reusable UI components and theme tokens. Tailwind CSS is initially limited to application layout, spacing, and responsive utilities.
|
||||
|
||||
@@ -40,10 +58,8 @@ web/src/
|
||||
|
||||
Some dependencies are suggested for future implementation. Refer these only when the corresponding feature is implemented and propose better choices if any:
|
||||
|
||||
- `@ant-design/icons`: Add Ant Design-consistent application and action icons when real screens require them.
|
||||
- `openapi-typescript`: Generate TypeScript API types from the shared OpenAPI document.
|
||||
- `openapi-fetch`: Provide a small type-safe Fetch client based on generated OpenAPI types.
|
||||
- `openapi-react-query`: Connect generated OpenAPI operations to TanStack Query if handwritten query adapters become repetitive.
|
||||
- `zustand`: Manage a cross-route upload queue, bulk selection, or other complex client-only state if React state is insufficient.
|
||||
- `vitest`: Run unit and integration tests using the Vite toolchain.
|
||||
- `pdfjs-dist`: Preview PDF files in the browser when document preview is implemented.
|
||||
|
||||
@@ -18,10 +18,6 @@ type WebApp struct {
|
||||
Version string
|
||||
|
||||
DB *gorm.DB
|
||||
UserRepo repository.UserRepository
|
||||
SessionRepo repository.SessionRepository
|
||||
FileRepo repository.FileRepository
|
||||
CredentialRepo repository.CredentialRepository
|
||||
AuthService *service.AuthService
|
||||
AdminService *service.AdminService
|
||||
FileService *service.FileService
|
||||
@@ -65,10 +61,6 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) {
|
||||
Config: cfg,
|
||||
Version: AppVersion,
|
||||
DB: db,
|
||||
UserRepo: userRepo,
|
||||
SessionRepo: sessionRepo,
|
||||
FileRepo: fileRepo,
|
||||
CredentialRepo: credentialRepo,
|
||||
AuthService: authService,
|
||||
AdminService: adminService,
|
||||
FileService: fileService,
|
||||
@@ -78,10 +70,6 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) {
|
||||
|
||||
// NewWebApp creates a WebApp with pre-built dependencies (useful for testing).
|
||||
func NewWebApp(cfg *config.Config, db *gorm.DB,
|
||||
userRepo repository.UserRepository,
|
||||
sessionRepo repository.SessionRepository,
|
||||
fileRepo repository.FileRepository,
|
||||
credentialRepo repository.CredentialRepository,
|
||||
authService *service.AuthService,
|
||||
adminService *service.AdminService,
|
||||
fileService *service.FileService,
|
||||
@@ -91,10 +79,6 @@ func NewWebApp(cfg *config.Config, db *gorm.DB,
|
||||
Config: cfg,
|
||||
Version: AppVersion,
|
||||
DB: db,
|
||||
UserRepo: userRepo,
|
||||
SessionRepo: sessionRepo,
|
||||
FileRepo: fileRepo,
|
||||
CredentialRepo: credentialRepo,
|
||||
AuthService: authService,
|
||||
AdminService: adminService,
|
||||
FileService: fileService,
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func TestNewWebApp(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
webApp := NewWebApp(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
webApp := NewWebApp(cfg, nil, nil, nil, nil, nil)
|
||||
|
||||
if webApp.Config != cfg {
|
||||
t.Fatal("Config was not assigned")
|
||||
@@ -20,7 +20,7 @@ func TestNewWebApp(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCloseNilDB(t *testing.T) {
|
||||
webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil)
|
||||
if err := webApp.Close(); err != nil {
|
||||
t.Errorf("Close with nil DB should not error: %v", err)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,25 @@ func TestArchitecture_HTTPDoesNotImportRepositories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchitecture_RepositoriesDoNotUseFullEntitySave(t *testing.T) {
|
||||
pkgs := parsePackage(t, "repository")
|
||||
for _, pkg := range pkgs {
|
||||
for filename, file := range pkg.Files {
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if ok && selector.Sel.Name == "Save" {
|
||||
t.Fatalf("%s uses GORM Save; repository writes must use operation-specific columns", filename)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func packageImports(t *testing.T, dir string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -17,9 +17,10 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
"github.com/dhao2001/mygo/internal/testutil"
|
||||
)
|
||||
|
||||
func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
||||
func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -63,7 +64,7 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
||||
admin.DELETE("/users/:id", adminHandler.DeleteUser)
|
||||
}
|
||||
|
||||
return r, userRepo
|
||||
return r, userRepo, db
|
||||
}
|
||||
|
||||
// registerHelper creates a user via the HTTP endpoint and returns the user ID.
|
||||
@@ -113,24 +114,16 @@ func loginHelper(t *testing.T, r *gin.Engine, email, password string) string {
|
||||
}
|
||||
|
||||
// makeAdminHelper promotes a user to admin in the database.
|
||||
func makeAdminHelper(t *testing.T, userRepo repository.UserRepository, userID string) {
|
||||
func makeAdminHelper(t *testing.T, db *gorm.DB, userID string) {
|
||||
t.Helper()
|
||||
|
||||
user, err := userRepo.FindByID(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("find user %s: %v", userID, err)
|
||||
}
|
||||
user.IsAdmin = true
|
||||
if err := userRepo.Update(context.Background(), user); err != nil {
|
||||
t.Fatalf("update user: %v", err)
|
||||
}
|
||||
testutil.SetUserAdmin(t, db, userID, true)
|
||||
}
|
||||
|
||||
func TestAdminDeleteUser(t *testing.T) {
|
||||
r, userRepo := setupAdminRouter(t)
|
||||
r, _, db := setupAdminRouter(t)
|
||||
|
||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||
makeAdminHelper(t, userRepo, adminID)
|
||||
makeAdminHelper(t, db, adminID)
|
||||
|
||||
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||
|
||||
@@ -159,10 +152,10 @@ func TestAdminDeleteUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminDeleteUserForbidden(t *testing.T) {
|
||||
r, userRepo := setupAdminRouter(t)
|
||||
r, _, db := setupAdminRouter(t)
|
||||
|
||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||
makeAdminHelper(t, userRepo, adminID)
|
||||
makeAdminHelper(t, db, adminID)
|
||||
|
||||
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||
|
||||
@@ -181,7 +174,7 @@ func TestAdminDeleteUserForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminDeleteUserUnauthorized(t *testing.T) {
|
||||
r, _ := setupAdminRouter(t)
|
||||
r, _, _ := setupAdminRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/some-id", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -193,10 +186,10 @@ func TestAdminDeleteUserUnauthorized(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminGetUser(t *testing.T) {
|
||||
r, userRepo := setupAdminRouter(t)
|
||||
r, _, db := setupAdminRouter(t)
|
||||
|
||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||
makeAdminHelper(t, userRepo, adminID)
|
||||
makeAdminHelper(t, db, adminID)
|
||||
|
||||
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||
|
||||
@@ -231,15 +224,15 @@ func TestAdminGetUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminGetDeletedUser(t *testing.T) {
|
||||
r, userRepo := setupAdminRouter(t)
|
||||
r, userRepo, db := setupAdminRouter(t)
|
||||
|
||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||
makeAdminHelper(t, userRepo, adminID)
|
||||
makeAdminHelper(t, db, adminID)
|
||||
|
||||
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||
|
||||
// Soft-delete regular user directly via repo
|
||||
if err := userRepo.Delete(context.Background(), userID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(context.Background(), userID); err != nil {
|
||||
t.Fatalf("soft-delete user: %v", err)
|
||||
}
|
||||
|
||||
@@ -267,17 +260,17 @@ func TestAdminGetDeletedUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminListIncludesDeleted(t *testing.T) {
|
||||
r, userRepo := setupAdminRouter(t)
|
||||
r, userRepo, db := setupAdminRouter(t)
|
||||
|
||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||
makeAdminHelper(t, userRepo, adminID)
|
||||
makeAdminHelper(t, db, adminID)
|
||||
|
||||
// Create 2 regular users
|
||||
_ = registerHelper(t, r, "alice", "alice@example.com", "alicepass123")
|
||||
bobID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||
|
||||
// Soft-delete bob
|
||||
if err := userRepo.Delete(context.Background(), bobID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(context.Background(), bobID); err != nil {
|
||||
t.Fatalf("soft-delete user: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/storage"
|
||||
)
|
||||
|
||||
const testUserIDHeader = "X-Test-User-ID"
|
||||
|
||||
// inMemStore implements storage.StorageBackend with an in-memory map.
|
||||
type inMemStore struct {
|
||||
files map[string][]byte
|
||||
@@ -130,9 +132,9 @@ func setupFileRouterWithMaxUploadSizeAndStore(t *testing.T, maxUploadSize int64)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
|
||||
// Middleware that injects user_id from header (simulates AuthRequired).
|
||||
// Test-only middleware that injects user_id to simulate a successful AuthRequired call.
|
||||
r.Use(func(c *gin.Context) {
|
||||
userID := c.GetHeader("X-User-ID")
|
||||
userID := c.GetHeader(testUserIDHeader)
|
||||
if userID != "" {
|
||||
c.Set("user_id", userID)
|
||||
}
|
||||
@@ -163,7 +165,7 @@ func TestFileHandler_Upload(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -191,7 +193,7 @@ func TestFileHandler_UploadRejectsOversizedFile(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -211,7 +213,7 @@ func TestFileHandler_UploadAllowsExactMaxUploadSize(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -231,7 +233,7 @@ func TestFileHandler_UploadUnlimitedWhenMaxUploadSizeIsZero(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -249,7 +251,7 @@ func TestFileHandler_UploadNoFile(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -269,7 +271,7 @@ func TestFileHandler_UploadUnexpectedFieldDoesNotEchoFieldName(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -289,7 +291,7 @@ func TestFileHandler_UploadMalformedMultipartDoesNotLeakParserError(t *testing.T
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", strings.NewReader("not a valid multipart body"))
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary=mygo-boundary")
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -310,7 +312,7 @@ func TestFileHandler_CreateDir(t *testing.T) {
|
||||
body, _ := json.Marshal(gin.H{"name": "mydir"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -339,13 +341,13 @@ func TestFileHandler_List(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
// List files.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files", nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -374,7 +376,7 @@ func TestFileHandler_Get(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -383,7 +385,7 @@ func TestFileHandler_Get(t *testing.T) {
|
||||
|
||||
// Get metadata.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -404,7 +406,7 @@ func TestFileHandler_GetNotFound(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/files/nonexistent", nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -426,7 +428,7 @@ func TestFileHandler_Download(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -435,7 +437,7 @@ func TestFileHandler_Download(t *testing.T) {
|
||||
|
||||
// Download.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID+"/content", nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -462,7 +464,7 @@ func TestFileHandler_DownloadReadErrorAfterResponseStarted(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -474,7 +476,7 @@ func TestFileHandler_DownloadReadErrorAfterResponseStarted(t *testing.T) {
|
||||
store.failReads = true
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID+"/content", nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -498,7 +500,7 @@ func TestFileHandler_Update(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -509,7 +511,7 @@ func TestFileHandler_Update(t *testing.T) {
|
||||
updateBody, _ := json.Marshal(gin.H{"name": "newname.txt"})
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -538,7 +540,7 @@ func TestFileHandler_UpdateNoFields(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -548,7 +550,7 @@ func TestFileHandler_UpdateNoFields(t *testing.T) {
|
||||
updateBody, _ := json.Marshal(gin.H{})
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -569,7 +571,7 @@ func TestFileHandler_Delete(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -578,7 +580,7 @@ func TestFileHandler_Delete(t *testing.T) {
|
||||
|
||||
// Delete.
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/files/"+uploaded.ID, nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -586,9 +588,19 @@ func TestFileHandler_Delete(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String())
|
||||
}
|
||||
|
||||
// Repeated deletes return not found.
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/files/"+uploaded.ID, nil)
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("repeated delete status = %d, want %d; body = %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
|
||||
// Verify gone.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -597,7 +609,20 @@ func TestFileHandler_Delete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_ForbiddenAccess(t *testing.T) {
|
||||
func TestFileHandler_DeleteMissingReturnsNotFound(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/files/missing-file", nil)
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_CrossUserAccessReturnsNotFound(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
// Upload as user1.
|
||||
@@ -609,7 +634,7 @@ func TestFileHandler_ForbiddenAccess(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
req.Header.Set(testUserIDHeader, "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
@@ -618,11 +643,17 @@ func TestFileHandler_ForbiddenAccess(t *testing.T) {
|
||||
|
||||
// Try to access as user2.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
|
||||
req.Header.Set("X-User-ID", "user2")
|
||||
req.Header.Set(testUserIDHeader, "user2")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "file not found") {
|
||||
t.Errorf("body = %s, want file not found message", rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "access denied") {
|
||||
t.Errorf("body leaks authorization result: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CredentialTypeAppPasskey identifies an application passkey credential.
|
||||
const CredentialTypeAppPasskey = "app_passkey"
|
||||
|
||||
// Credential represents an alternative authentication credential for a user.
|
||||
// The primary password is stored on the User model; additional credentials
|
||||
// (app passkeys, WebAuthn, OAuth) are stored here with a type discriminator.
|
||||
|
||||
@@ -11,6 +11,10 @@ var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrUploadTooLarge = errors.New("upload exceeds maximum size")
|
||||
ErrParentNotFound = errors.New("parent resource not found")
|
||||
ErrParentNotDir = errors.New("parent resource is not a directory")
|
||||
ErrDirectoryNotEmpty = errors.New("directory is not empty")
|
||||
ErrInvalidMove = errors.New("invalid file move")
|
||||
)
|
||||
|
||||
// ErrorKind classifies domain errors without tying them to a transport.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepositoryCapabilitiesExcludeUnneededOperations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
capability reflect.Type
|
||||
forbidden []string
|
||||
}{
|
||||
{
|
||||
name: "auth user",
|
||||
capability: reflect.TypeFor[AuthUserRepository](),
|
||||
forbidden: []string{"MarkAdminDeleted", "ListIncludeDeleted"},
|
||||
},
|
||||
{
|
||||
name: "admin user",
|
||||
capability: reflect.TypeFor[AdminUserRepository](),
|
||||
forbidden: []string{"CreateRegisteredUser", "FindByEmail"},
|
||||
},
|
||||
{
|
||||
name: "auth session",
|
||||
capability: reflect.TypeFor[AuthSessionRepository](),
|
||||
forbidden: []string{"DeleteSessionsByUserID", "DeleteExpiredSessions"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, method := range tt.forbidden {
|
||||
if _, ok := tt.capability.MethodByName(method); ok {
|
||||
t.Fatalf("capability unexpectedly exposes %s", method)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,22 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// PasskeyParams contains the fields accepted when creating an app passkey.
|
||||
type PasskeyParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
Label string
|
||||
SecretHash string
|
||||
}
|
||||
|
||||
// CredentialRepository provides access to alternative credential records.
|
||||
type CredentialRepository interface {
|
||||
Create(ctx context.Context, cred *model.Credential) error
|
||||
FindByID(ctx context.Context, id string) (*model.Credential, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]model.Credential, error)
|
||||
FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error)
|
||||
FindByHash(ctx context.Context, hash string) (*model.Credential, error)
|
||||
UpdateLastUsed(ctx context.Context, id string) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
CreatePasskey(ctx context.Context, params PasskeyParams) error
|
||||
FindPasskeyByID(ctx context.Context, id string) (*model.Credential, error)
|
||||
ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error)
|
||||
FindPasskeyByHash(ctx context.Context, hash string) (*model.Credential, error)
|
||||
RecordPasskeyUsed(ctx context.Context, id string) error
|
||||
RevokeOwnedPasskey(ctx context.Context, userID, id string) error
|
||||
}
|
||||
|
||||
type credentialRepository struct {
|
||||
@@ -30,7 +37,14 @@ func NewCredentialRepository(db *gorm.DB) CredentialRepository {
|
||||
return &credentialRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *credentialRepository) Create(ctx context.Context, cred *model.Credential) error {
|
||||
func (r *credentialRepository) CreatePasskey(ctx context.Context, params PasskeyParams) error {
|
||||
cred := &model.Credential{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
Type: model.CredentialTypeAppPasskey,
|
||||
Label: params.Label,
|
||||
SecretHash: params.SecretHash,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Create(cred)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
@@ -41,9 +55,9 @@ func (r *credentialRepository) Create(ctx context.Context, cred *model.Credentia
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByID(ctx context.Context, id string) (*model.Credential, error) {
|
||||
func (r *credentialRepository) FindPasskeyByID(ctx context.Context, id string) (*model.Credential, error) {
|
||||
var cred model.Credential
|
||||
result := r.db.WithContext(ctx).First(&cred, "id = ?", id)
|
||||
result := r.db.WithContext(ctx).First(&cred, "id = ? AND type = ?", id, model.CredentialTypeAppPasskey)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@@ -53,27 +67,18 @@ func (r *credentialRepository) FindByID(ctx context.Context, id string) (*model.
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByUserID(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
func (r *credentialRepository) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
var creds []model.Credential
|
||||
result := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&creds)
|
||||
result := r.db.WithContext(ctx).Where("user_id = ? AND type = ?", userID, model.CredentialTypeAppPasskey).Find(&creds)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error) {
|
||||
var creds []model.Credential
|
||||
result := r.db.WithContext(ctx).Where("user_id = ? AND type = ?", userID, credType).Find(&creds)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByHash(ctx context.Context, hash string) (*model.Credential, error) {
|
||||
func (r *credentialRepository) FindPasskeyByHash(ctx context.Context, hash string) (*model.Credential, error) {
|
||||
var cred model.Credential
|
||||
result := r.db.WithContext(ctx).First(&cred, "secret_hash = ?", hash)
|
||||
result := r.db.WithContext(ctx).First(&cred, "secret_hash = ? AND type = ?", hash, model.CredentialTypeAppPasskey)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@@ -83,19 +88,27 @@ func (r *credentialRepository) FindByHash(ctx context.Context, hash string) (*mo
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) UpdateLastUsed(ctx context.Context, id string) error {
|
||||
func (r *credentialRepository) RecordPasskeyUsed(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
result := r.db.WithContext(ctx).Model(&model.Credential{}).Where("id = ?", id).Update("last_used_at", now)
|
||||
result := r.db.WithContext(ctx).Model(&model.Credential{}).
|
||||
Where("id = ? AND type = ?", id, model.CredentialTypeAppPasskey).
|
||||
Update("last_used_at", now)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ?", id)
|
||||
func (r *credentialRepository) RevokeOwnedPasskey(ctx context.Context, userID, id string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ? AND user_id = ? AND type = ?", id, userID, model.CredentialTypeAppPasskey)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ func setupCredentialRepo(t *testing.T) CredentialRepository {
|
||||
return NewCredentialRepository(db)
|
||||
}
|
||||
|
||||
func createPasskeyRecord(ctx context.Context, repo CredentialRepository, cred *model.Credential) error {
|
||||
return repo.CreatePasskey(ctx, PasskeyParams{
|
||||
ID: cred.ID, UserID: cred.UserID, Label: cred.Label, SecretHash: cred.SecretHash,
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialRepository_Create(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -36,7 +42,7 @@ func TestCredentialRepository_Create(t *testing.T) {
|
||||
SecretHash: "hash-abc",
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +54,11 @@ func TestCredentialRepository_CreateDuplicateHash(t *testing.T) {
|
||||
c1 := &model.Credential{ID: "cred-1", UserID: "user-1", Type: "app_passkey", Label: "A", SecretHash: "hash-abc"}
|
||||
c2 := &model.Credential{ID: "cred-2", UserID: "user-1", Type: "app_passkey", Label: "B", SecretHash: "hash-abc"}
|
||||
|
||||
if err := repo.Create(ctx, c1); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, c1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, c2)
|
||||
err := createPasskeyRecord(ctx, repo, c2)
|
||||
if err != model.ErrDuplicate {
|
||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||
}
|
||||
@@ -63,11 +69,11 @@ func TestCredentialRepository_FindByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "cred-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "cred-1")
|
||||
found, err := repo.FindPasskeyByID(ctx, "cred-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
@@ -80,13 +86,13 @@ func TestCredentialRepository_FindByIDNotFound(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.FindByID(ctx, "nonexistent")
|
||||
_, err := repo.FindPasskeyByID(ctx, "nonexistent")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByUserID(t *testing.T) {
|
||||
func TestCredentialRepository_ListPasskeys(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -95,12 +101,12 @@ func TestCredentialRepository_FindByUserID(t *testing.T) {
|
||||
c3 := &model.Credential{ID: "c-3", UserID: "user-2", Type: "app_passkey", Label: "C", SecretHash: "h3"}
|
||||
|
||||
for _, c := range []*model.Credential{c1, c2, c3} {
|
||||
if err := repo.Create(ctx, c); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, c); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
creds, err := repo.FindByUserID(ctx, "user-1")
|
||||
creds, err := repo.ListPasskeys(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserID = %v", err)
|
||||
}
|
||||
@@ -109,28 +115,24 @@ func TestCredentialRepository_FindByUserID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByUserIDAndType(t *testing.T) {
|
||||
func TestCredentialRepository_CreatePasskeyControlsType(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
c1 := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "A", SecretHash: "h1"}
|
||||
c2 := &model.Credential{ID: "c-2", UserID: "user-1", Type: "oauth", Label: "Github", SecretHash: "h2"}
|
||||
|
||||
for _, c := range []*model.Credential{c1, c2} {
|
||||
if err := repo.Create(ctx, c); err != nil {
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Label: "A", SecretHash: "h1"}
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
passkeys, err := repo.FindByUserIDAndType(ctx, "user-1", "app_passkey")
|
||||
passkeys, err := repo.ListPasskeys(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDAndType = %v", err)
|
||||
t.Fatalf("ListPasskeys = %v", err)
|
||||
}
|
||||
if len(passkeys) != 1 {
|
||||
t.Errorf("len(passkeys) = %d, want 1", len(passkeys))
|
||||
}
|
||||
if passkeys[0].Type != "app_passkey" {
|
||||
t.Errorf("type = %q, want %q", passkeys[0].Type, "app_passkey")
|
||||
if passkeys[0].Type != model.CredentialTypeAppPasskey {
|
||||
t.Errorf("type = %q, want %q", passkeys[0].Type, model.CredentialTypeAppPasskey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,11 +141,11 @@ func TestCredentialRepository_FindByHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "hash-find"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByHash(ctx, "hash-find")
|
||||
found, err := repo.FindPasskeyByHash(ctx, "hash-find")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByHash = %v", err)
|
||||
}
|
||||
@@ -157,15 +159,15 @@ func TestCredentialRepository_UpdateLastUsed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.UpdateLastUsed(ctx, "c-1"); err != nil {
|
||||
if err := repo.RecordPasskeyUsed(ctx, "c-1"); err != nil {
|
||||
t.Fatalf("UpdateLastUsed = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "c-1")
|
||||
found, err := repo.FindPasskeyByID(ctx, "c-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
@@ -179,16 +181,35 @@ func TestCredentialRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "c-1"); err != nil {
|
||||
if err := repo.RevokeOwnedPasskey(ctx, "user-1", "c-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
_, err := repo.FindByID(ctx, "c-1")
|
||||
_, err := repo.FindPasskeyByID(ctx, "c-1")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_RevokeOwnedPasskeyEnforcesOwner(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Label: "Phone", SecretHash: "h1"}
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.RevokeOwnedPasskey(ctx, "user-2", cred.ID); err != model.ErrNotFound {
|
||||
t.Fatalf("cross-owner revoke = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := repo.FindPasskeyByID(ctx, cred.ID); err != nil {
|
||||
t.Fatalf("cross-owner revoke removed passkey: %v", err)
|
||||
}
|
||||
if err := repo.RevokeOwnedPasskey(ctx, cred.UserID, cred.ID); err != nil {
|
||||
t.Fatalf("owner revoke = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -23,7 +25,7 @@ func Open(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
dialector = sqlite.Open(cfg.SQLite.Path)
|
||||
dialector = sqlite.Open(sqliteImmediateDSN(cfg.SQLite.Path))
|
||||
case "postgres":
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%d sslmode=%s",
|
||||
@@ -47,6 +49,20 @@ func Open(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func sqliteImmediateDSN(path string) string {
|
||||
base, rawQuery, found := strings.Cut(path, "?")
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
separator := "?"
|
||||
if found {
|
||||
separator = "&"
|
||||
}
|
||||
return path + separator + "_txlock=immediate"
|
||||
}
|
||||
query.Set("_txlock", "immediate")
|
||||
return base + "?" + query.Encode()
|
||||
}
|
||||
|
||||
// AutoMigrate runs schema migration for all domain models.
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
)
|
||||
|
||||
func TestSQLiteImmediateDSNPreservesParameters(t *testing.T) {
|
||||
dsn := sqliteImmediateDSN("file:test.db?cache=shared&_busy_timeout=9000&_txlock=deferred")
|
||||
base, rawQuery, found := strings.Cut(dsn, "?")
|
||||
if !found || base != "file:test.db" {
|
||||
t.Fatalf("DSN base = %q, want file:test.db", base)
|
||||
}
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
t.Fatalf("parse DSN query: %v", err)
|
||||
}
|
||||
if query.Get("cache") != "shared" || query.Get("_busy_timeout") != "9000" {
|
||||
t.Fatalf("DSN did not preserve parameters: %q", dsn)
|
||||
}
|
||||
if query.Get("_txlock") != "immediate" {
|
||||
t.Fatalf("_txlock = %q, want immediate", query.Get("_txlock"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSQLite(t *testing.T) {
|
||||
cfg := config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
|
||||
+218
-13
@@ -3,22 +3,54 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// UploadedFileParams contains persisted metadata for a completed upload.
|
||||
type UploadedFileParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
ParentID *string
|
||||
Name string
|
||||
Size int64
|
||||
MimeType string
|
||||
StoragePath string
|
||||
Hash string
|
||||
}
|
||||
|
||||
// DirectoryParams contains the fields accepted when creating a directory.
|
||||
type DirectoryParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
ParentID *string
|
||||
Name string
|
||||
}
|
||||
|
||||
// FileMetadataUpdate contains the user-editable metadata fields.
|
||||
// Nil fields are left unchanged.
|
||||
type FileMetadataUpdate struct {
|
||||
FileID string
|
||||
UserID string
|
||||
NewName *string
|
||||
NewParentID *string
|
||||
}
|
||||
|
||||
// FileRepository provides access to file records.
|
||||
type FileRepository interface {
|
||||
Create(ctx context.Context, file *model.File) error
|
||||
CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error)
|
||||
CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error)
|
||||
FindByID(ctx context.Context, id string) (*model.File, error)
|
||||
FindByUserID(ctx context.Context, userID string, offset, limit int) ([]model.File, int64, error)
|
||||
FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error)
|
||||
FindByParentIDPaginated(ctx context.Context, userID string, parentID *string, offset, limit int) ([]model.File, int64, error)
|
||||
FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error)
|
||||
Update(ctx context.Context, file *model.File) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error)
|
||||
SoftDeleteOwned(ctx context.Context, userID, fileID string) error
|
||||
}
|
||||
|
||||
type fileRepository struct {
|
||||
@@ -30,8 +62,57 @@ func NewFileRepository(db *gorm.DB) FileRepository {
|
||||
return &fileRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *fileRepository) Create(ctx context.Context, file *model.File) error {
|
||||
result := r.db.WithContext(ctx).Create(file)
|
||||
func (r *fileRepository) CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error) {
|
||||
file := &model.File{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
ParentID: params.ParentID,
|
||||
Name: params.Name,
|
||||
Size: params.Size,
|
||||
MimeType: params.MimeType,
|
||||
StoragePath: params.StoragePath,
|
||||
Hash: params.Hash,
|
||||
Status: model.StatusActive,
|
||||
IsDir: false,
|
||||
}
|
||||
if err := r.createEntry(ctx, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error) {
|
||||
dir := &model.File{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
ParentID: params.ParentID,
|
||||
Name: params.Name,
|
||||
Status: model.StatusActive,
|
||||
IsDir: true,
|
||||
}
|
||||
if err := r.createEntry(ctx, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) createEntry(ctx context.Context, file *model.File) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if file.ParentID != nil {
|
||||
locked, err := lockActiveOwnedFiles(tx, file.UserID, []string{*file.ParentID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parent, ok := locked[*file.ParentID]
|
||||
if !ok {
|
||||
return model.ErrParentNotFound
|
||||
}
|
||||
if !parent.IsDir {
|
||||
return model.ErrParentNotDir
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Create(file)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
@@ -39,6 +120,7 @@ func (r *fileRepository) Create(ctx context.Context, file *model.File) error {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) {
|
||||
@@ -106,18 +188,141 @@ func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string,
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) Update(ctx context.Context, file *model.File) error {
|
||||
result := r.db.WithContext(ctx).Save(file)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
func (r *fileRepository) UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error) {
|
||||
var updated model.File
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
ids := []string{params.FileID}
|
||||
if params.NewParentID != nil {
|
||||
if *params.NewParentID == params.FileID {
|
||||
return model.ErrInvalidMove
|
||||
}
|
||||
return nil
|
||||
ids = append(ids, *params.NewParentID)
|
||||
}
|
||||
|
||||
func (r *fileRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Model(&model.File{}).Where("id = ?", id).Update("status", model.StatusUserDeleted)
|
||||
locked, err := lockActiveOwnedFiles(tx, params.UserID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, ok := locked[params.FileID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
updates := make(map[string]any, 2)
|
||||
finalName := source.Name
|
||||
finalParentID := source.ParentID
|
||||
if params.NewName != nil {
|
||||
updates["name"] = *params.NewName
|
||||
finalName = *params.NewName
|
||||
}
|
||||
if params.NewParentID != nil {
|
||||
parent, ok := locked[*params.NewParentID]
|
||||
if !ok {
|
||||
return model.ErrParentNotFound
|
||||
}
|
||||
if !parent.IsDir {
|
||||
return model.ErrParentNotDir
|
||||
}
|
||||
updates["parent_id"] = *params.NewParentID
|
||||
finalParentID = params.NewParentID
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
var conflictCount int64
|
||||
if err := tx.Model(&model.File{}).
|
||||
Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ? AND id <> ?", params.UserID, finalParentID, finalName, model.StatusActive, params.FileID).
|
||||
Count(&conflictCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if conflictCount > 0 {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
|
||||
result := tx.Model(&model.File{}).
|
||||
Where("id = ? AND user_id = ? AND status = ?", params.FileID, params.UserID, model.StatusActive).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.First(&updated, "id = ? AND user_id = ? AND status = ?", source.ID, params.UserID, model.StatusActive)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return result.Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) SoftDeleteOwned(ctx context.Context, userID, fileID string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
locked, err := lockActiveOwnedFiles(tx, userID, []string{fileID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, ok := locked[fileID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
if file.IsDir {
|
||||
var count int64
|
||||
if err := tx.Model(&model.File{}).
|
||||
Where("parent_id = ? AND status = ?", fileID, model.StatusActive).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return model.ErrDirectoryNotEmpty
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Model(&model.File{}).
|
||||
Where("id = ? AND user_id = ? AND status = ?", fileID, userID, model.StatusActive).
|
||||
Update("status", model.StatusUserDeleted)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func lockActiveOwnedFiles(tx *gorm.DB, userID string, ids []string) (map[string]model.File, error) {
|
||||
unique := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
unique[id] = struct{}{}
|
||||
}
|
||||
ordered := make([]string, 0, len(unique))
|
||||
for id := range unique {
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
sort.Strings(ordered)
|
||||
|
||||
var files []model.File
|
||||
result := tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
Where("id IN ? AND user_id = ? AND status = ?", ordered, userID, model.StatusActive).
|
||||
Order("id ASC").
|
||||
Find(&files)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
locked := make(map[string]model.File, len(files))
|
||||
for i := range files {
|
||||
locked[files[i].ID] = files[i]
|
||||
}
|
||||
return locked, nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
@@ -24,6 +28,48 @@ func setupFileRepo(t *testing.T) FileRepository {
|
||||
return NewFileRepository(db)
|
||||
}
|
||||
|
||||
func createFileRecord(ctx context.Context, repo FileRepository, file *model.File) error {
|
||||
var err error
|
||||
if file.IsDir {
|
||||
_, err = repo.CreateDirectory(ctx, DirectoryParams{
|
||||
ID: file.ID, UserID: file.UserID, ParentID: file.ParentID, Name: file.Name,
|
||||
})
|
||||
} else {
|
||||
_, err = repo.CreateUploadedFile(ctx, UploadedFileParams{
|
||||
ID: file.ID, UserID: file.UserID, ParentID: file.ParentID, Name: file.Name,
|
||||
Size: file.Size, MimeType: file.MimeType, StoragePath: file.StoragePath, Hash: file.Hash,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if file.Status == model.StatusUserDeleted {
|
||||
return repo.SoftDeleteOwned(ctx, file.UserID, file.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupConcurrentFileRepo(t *testing.T) FileRepository {
|
||||
t.Helper()
|
||||
db, err := Open(config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "concurrency.db")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return NewFileRepository(db)
|
||||
}
|
||||
|
||||
func TestFileRepository_Create(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -36,7 +82,7 @@ func TestFileRepository_Create(t *testing.T) {
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +97,7 @@ func TestFileRepository_FindByID(t *testing.T) {
|
||||
Name: "test.txt",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -84,7 +130,7 @@ func TestFileRepository_FindByUserID(t *testing.T) {
|
||||
{ID: "f-3", UserID: "user-2", Name: "c.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
if err := createFileRecord(ctx, repo, f); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -106,13 +152,16 @@ func TestFileRepository_FindByParentID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
parentID := "dir-1"
|
||||
if err := createFileRecord(ctx, repo, &model.File{ID: parentID, UserID: "user-1", Name: "dir", Status: model.StatusActive, IsDir: true}); err != nil {
|
||||
t.Fatalf("Create parent = %v", err)
|
||||
}
|
||||
files := []*model.File{
|
||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
|
||||
{ID: "f-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt", Status: model.StatusActive},
|
||||
{ID: "f-3", UserID: "user-1", Name: "c.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
if err := createFileRecord(ctx, repo, f); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -131,12 +180,15 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
parentID := "dir-1"
|
||||
if err := createFileRecord(ctx, repo, &model.File{ID: parentID, UserID: "user-1", Name: "dir", Status: model.StatusActive, IsDir: true}); err != nil {
|
||||
t.Fatalf("Create parent = %v", err)
|
||||
}
|
||||
files := []*model.File{
|
||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
|
||||
{ID: "f-2", UserID: "user-1", Name: "root.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
if err := createFileRecord(ctx, repo, f); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -145,8 +197,8 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("FindByParentID(nil) = %v", err)
|
||||
}
|
||||
if len(children) != 1 {
|
||||
t.Errorf("len(children) = %d, want 1", len(children))
|
||||
if len(children) != 2 {
|
||||
t.Errorf("len(children) = %d, want 2", len(children))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,14 +206,26 @@ func TestFileRepository_Update(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
file := &model.File{ID: "file-1", UserID: "user-1", Name: "original.txt", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
file := &model.File{
|
||||
ID: "file-1",
|
||||
UserID: "user-1",
|
||||
Name: "original.txt",
|
||||
Size: 1024,
|
||||
MimeType: "text/plain",
|
||||
StoragePath: "data/user-1/file-1",
|
||||
Hash: "original-hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
before, err := repo.FindByID(ctx, file.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID before update = %v", err)
|
||||
}
|
||||
|
||||
file.Name = "renamed.txt"
|
||||
file.Size = 2048
|
||||
if err := repo.Update(ctx, file); err != nil {
|
||||
newName := "renamed.txt"
|
||||
if _, err := repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: file.ID, UserID: file.UserID, NewName: &newName}); err != nil {
|
||||
t.Fatalf("Update = %v", err)
|
||||
}
|
||||
|
||||
@@ -172,8 +236,11 @@ func TestFileRepository_Update(t *testing.T) {
|
||||
if found.Name != "renamed.txt" {
|
||||
t.Errorf("name = %q, want %q", found.Name, "renamed.txt")
|
||||
}
|
||||
if found.Size != 2048 {
|
||||
t.Errorf("size = %d, want %d", found.Size, 2048)
|
||||
if found.UserID != before.UserID || found.Status != before.Status || found.IsDir != before.IsDir ||
|
||||
found.Size != before.Size || found.MimeType != before.MimeType ||
|
||||
found.StoragePath != before.StoragePath || found.Hash != before.Hash ||
|
||||
!found.CreatedAt.Equal(before.CreatedAt) {
|
||||
t.Fatalf("metadata update changed protected fields: before=%+v after=%+v", before, found)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,11 +249,11 @@ func TestFileRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
file := &model.File{ID: "file-1", UserID: "user-1", Name: "test.txt", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -206,11 +273,11 @@ func TestFileRepository_SoftDelete(t *testing.T) {
|
||||
Name: "test.txt",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -237,10 +304,10 @@ func TestFileRepository_StatusFilter(t *testing.T) {
|
||||
Status: model.StatusUserDeleted,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, activeFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, activeFile); err != nil {
|
||||
t.Fatalf("Create active = %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, deletedFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, deletedFile); err != nil {
|
||||
t.Fatalf("Create deleted = %v", err)
|
||||
}
|
||||
|
||||
@@ -259,7 +326,7 @@ func TestFileRepository_StatusFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_DeleteIdempotent(t *testing.T) {
|
||||
func TestFileRepository_DeleteReturnsNotFoundAfterDeletion(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -269,15 +336,18 @@ func TestFileRepository_DeleteIdempotent(t *testing.T) {
|
||||
Name: "test.txt",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil {
|
||||
t.Fatalf("first Delete = %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
||||
t.Fatalf("second Delete = %v", err)
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("second Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "missing-file"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("missing Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,10 +368,10 @@ func TestFileRepository_StatusFilterCount(t *testing.T) {
|
||||
Status: model.StatusUserDeleted,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, activeFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, activeFile); err != nil {
|
||||
t.Fatalf("Create active = %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, deletedFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, deletedFile); err != nil {
|
||||
t.Fatalf("Create deleted = %v", err)
|
||||
}
|
||||
|
||||
@@ -313,3 +383,160 @@ func TestFileRepository_StatusFilterCount(t *testing.T) {
|
||||
t.Errorf("total = %d, want 1 (soft-deleted excluded from count)", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_UpdateCannotReviveDeletedFile(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
ctx := context.Background()
|
||||
file := &model.File{ID: "file-1", UserID: "user-1", Name: "before.txt", Status: model.StatusActive}
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
if err := repo.SoftDeleteOwned(ctx, file.UserID, file.ID); err != nil {
|
||||
t.Fatalf("SoftDeleteOwned = %v", err)
|
||||
}
|
||||
|
||||
newName := "after.txt"
|
||||
_, err := repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: file.ID, UserID: file.UserID, NewName: &newName})
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("UpdateOwnedMetadata = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, file.ID); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("deleted file became active again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_ConcurrentUpdateAndDeleteCannotReviveFile(t *testing.T) {
|
||||
repo := setupConcurrentFileRepo(t)
|
||||
ctx := context.Background()
|
||||
if _, err := repo.CreateUploadedFile(ctx, UploadedFileParams{
|
||||
ID: "file-1", UserID: "user-1", Name: "before.txt", StoragePath: "data/user-1/file-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateUploadedFile = %v", err)
|
||||
}
|
||||
|
||||
newName := "after.txt"
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var updateErr, deleteErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, updateErr = repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{
|
||||
FileID: "file-1", UserID: "user-1", NewName: &newName,
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
deleteErr = repo.SoftDeleteOwned(ctx, "user-1", "file-1")
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if deleteErr != nil {
|
||||
t.Fatalf("SoftDeleteOwned = %v, want success", deleteErr)
|
||||
}
|
||||
if updateErr != nil && !errors.Is(updateErr, model.ErrNotFound) {
|
||||
t.Fatalf("UpdateOwnedMetadata = %v, want success or ErrNotFound", updateErr)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, "file-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("concurrent update revived deleted file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_CreateAndParentDeleteAreSerialized(t *testing.T) {
|
||||
repo := setupConcurrentFileRepo(t)
|
||||
ctx := context.Background()
|
||||
dir, err := repo.CreateDirectory(ctx, DirectoryParams{ID: "dir-1", UserID: "user-1", Name: "dir"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDirectory = %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var createErr, deleteErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, createErr = repo.CreateUploadedFile(ctx, UploadedFileParams{
|
||||
ID: "child-1", UserID: "user-1", ParentID: &dir.ID, Name: "child.txt", StoragePath: "data/user-1/child-1",
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
deleteErr = repo.SoftDeleteOwned(ctx, "user-1", dir.ID)
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
switch {
|
||||
case createErr == nil:
|
||||
if !errors.Is(deleteErr, model.ErrDirectoryNotEmpty) {
|
||||
t.Fatalf("create succeeded but delete error = %v, want ErrDirectoryNotEmpty", deleteErr)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, dir.ID); err != nil {
|
||||
t.Fatalf("parent missing after child creation: %v", err)
|
||||
}
|
||||
case deleteErr == nil:
|
||||
if !errors.Is(createErr, model.ErrParentNotFound) {
|
||||
t.Fatalf("delete succeeded but create error = %v, want ErrParentNotFound", createErr)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, "child-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("active orphan child exists: %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("neither operation succeeded: create=%v delete=%v", createErr, deleteErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_MoveAndTargetDeleteAreSerialized(t *testing.T) {
|
||||
repo := setupConcurrentFileRepo(t)
|
||||
ctx := context.Background()
|
||||
target, err := repo.CreateDirectory(ctx, DirectoryParams{ID: "target-1", UserID: "user-1", Name: "target"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDirectory = %v", err)
|
||||
}
|
||||
if _, err := repo.CreateUploadedFile(ctx, UploadedFileParams{ID: "file-1", UserID: "user-1", Name: "file.txt", StoragePath: "data/user-1/file-1"}); err != nil {
|
||||
t.Fatalf("CreateUploadedFile = %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var moveErr, deleteErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, moveErr = repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: "file-1", UserID: "user-1", NewParentID: &target.ID})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
deleteErr = repo.SoftDeleteOwned(ctx, "user-1", target.ID)
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
switch {
|
||||
case moveErr == nil:
|
||||
if !errors.Is(deleteErr, model.ErrDirectoryNotEmpty) {
|
||||
t.Fatalf("move succeeded but delete error = %v, want ErrDirectoryNotEmpty", deleteErr)
|
||||
}
|
||||
case deleteErr == nil:
|
||||
if !errors.Is(moveErr, model.ErrParentNotFound) {
|
||||
t.Fatalf("delete succeeded but move error = %v, want ErrParentNotFound", moveErr)
|
||||
}
|
||||
file, err := repo.FindByID(ctx, "file-1")
|
||||
if err != nil {
|
||||
t.Fatalf("find source file: %v", err)
|
||||
}
|
||||
if file.ParentID != nil {
|
||||
t.Fatalf("failed move changed source parent to %v", *file.ParentID)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("neither operation succeeded: move=%v delete=%v", moveErr, deleteErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,33 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// SessionRepository provides access to refresh token sessions.
|
||||
// RefreshSessionParams contains the fields persisted for a refresh session.
|
||||
type RefreshSessionParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// AuthSessionRepository exposes only session operations required by AuthService.
|
||||
type AuthSessionRepository interface {
|
||||
CreateRefreshSession(ctx context.Context, params RefreshSessionParams) error
|
||||
ConsumeRefreshSession(ctx context.Context, tokenHash string) (*model.Session, error)
|
||||
RevokeRefreshSession(ctx context.Context, tokenHash string) error
|
||||
}
|
||||
|
||||
// SessionRepository is the composition-time union of session capabilities.
|
||||
type SessionRepository interface {
|
||||
Create(ctx context.Context, session *model.Session) error
|
||||
AuthSessionRepository
|
||||
FindByID(ctx context.Context, id string) (*model.Session, error)
|
||||
FindByTokenHash(ctx context.Context, tokenHash string) (*model.Session, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
DeleteByUserID(ctx context.Context, userID string) error
|
||||
DeleteExpired(ctx context.Context) (int64, error)
|
||||
DeleteSessionsByUserID(ctx context.Context, userID string) error
|
||||
DeleteExpiredSessions(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
type sessionRepository struct {
|
||||
@@ -29,7 +44,13 @@ func NewSessionRepository(db *gorm.DB) SessionRepository {
|
||||
return &sessionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Create(ctx context.Context, session *model.Session) error {
|
||||
func (r *sessionRepository) CreateRefreshSession(ctx context.Context, params RefreshSessionParams) error {
|
||||
session := &model.Session{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
TokenHash: params.TokenHash,
|
||||
ExpiresAt: params.ExpiresAt,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Create(session)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
@@ -64,15 +85,39 @@ func (r *sessionRepository) FindByTokenHash(ctx context.Context, tokenHash strin
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "id = ?", id)
|
||||
func (r *sessionRepository) ConsumeRefreshSession(ctx context.Context, tokenHash string) (*model.Session, error) {
|
||||
var consumed model.Session
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
First(&consumed, "token_hash = ?", tokenHash)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
result = tx.Delete(&model.Session{}, "id = ?", consumed.ID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &consumed, nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) DeleteByUserID(ctx context.Context, userID string) error {
|
||||
func (r *sessionRepository) RevokeRefreshSession(ctx context.Context, tokenHash string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "token_hash = ?", tokenHash)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func (r *sessionRepository) DeleteSessionsByUserID(ctx context.Context, userID string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "user_id = ?", userID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
@@ -80,7 +125,7 @@ func (r *sessionRepository) DeleteByUserID(ctx context.Context, userID string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) DeleteExpired(ctx context.Context) (int64, error) {
|
||||
func (r *sessionRepository) DeleteExpiredSessions(ctx context.Context) (int64, error) {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "expires_at < ?", time.Now())
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
|
||||
@@ -2,12 +2,16 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
@@ -25,6 +29,12 @@ func setupSessionRepo(t *testing.T) SessionRepository {
|
||||
return NewSessionRepository(db)
|
||||
}
|
||||
|
||||
func createSessionRecord(ctx context.Context, repo SessionRepository, session *model.Session) error {
|
||||
return repo.CreateRefreshSession(ctx, RefreshSessionParams{
|
||||
ID: session.ID, UserID: session.UserID, TokenHash: session.TokenHash, ExpiresAt: session.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionRepository_Create(t *testing.T) {
|
||||
repo := setupSessionRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -36,7 +46,7 @@ func TestSessionRepository_Create(t *testing.T) {
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +58,11 @@ func TestSessionRepository_CreateDuplicateHash(t *testing.T) {
|
||||
s1 := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
s2 := &model.Session{ID: "session-2", UserID: "user-2", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
if err := repo.Create(ctx, s1); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, s1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, s2)
|
||||
err := createSessionRecord(ctx, repo, s2)
|
||||
if err != model.ErrDuplicate {
|
||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||
}
|
||||
@@ -63,7 +73,7 @@ func TestSessionRepository_FindByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -81,7 +91,7 @@ func TestSessionRepository_FindByTokenHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -109,15 +119,19 @@ func TestSessionRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "session-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
consumed, err := repo.ConsumeRefreshSession(ctx, "hash-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeRefreshSession = %v", err)
|
||||
}
|
||||
if consumed.ID != "session-1" {
|
||||
t.Fatalf("consumed ID = %q, want session-1", consumed.ID)
|
||||
}
|
||||
|
||||
_, err := repo.FindByID(ctx, "session-1")
|
||||
_, err = repo.FindByID(ctx, "session-1")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
@@ -132,12 +146,12 @@ func TestSessionRepository_DeleteByUserID(t *testing.T) {
|
||||
s3 := &model.Session{ID: "session-3", UserID: "user-2", TokenHash: "hash-3", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
for _, s := range []*model.Session{s1, s2, s3} {
|
||||
if err := repo.Create(ctx, s); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, s); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo.DeleteByUserID(ctx, "user-1"); err != nil {
|
||||
if err := repo.DeleteSessionsByUserID(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("DeleteByUserID = %v", err)
|
||||
}
|
||||
|
||||
@@ -168,12 +182,12 @@ func TestSessionRepository_DeleteExpired(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range []*model.Session{expired, valid} {
|
||||
if err := repo.Create(ctx, s); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, s); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
count, err := repo.DeleteExpired(ctx)
|
||||
count, err := repo.DeleteExpiredSessions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteExpired = %v", err)
|
||||
}
|
||||
@@ -188,3 +202,55 @@ func TestSessionRepository_DeleteExpired(t *testing.T) {
|
||||
t.Fatalf("valid session should still exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRepository_ConsumeRefreshSessionIsAtomic(t *testing.T) {
|
||||
db, err := Open(config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "sessions.db")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repo := NewSessionRepository(db)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateRefreshSession(ctx, RefreshSessionParams{
|
||||
ID: "session-1", UserID: "user-1", TokenHash: "single-use", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRefreshSession = %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := repo.ConsumeRefreshSession(ctx, "single-use")
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
succeeded := 0
|
||||
notFound := 0
|
||||
for err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
succeeded++
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
notFound++
|
||||
default:
|
||||
t.Fatalf("ConsumeRefreshSession unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if succeeded != 1 || notFound != 1 {
|
||||
t.Fatalf("consume results: success=%d not_found=%d, want 1 each", succeeded, notFound)
|
||||
}
|
||||
}
|
||||
|
||||
+39
-24
@@ -15,19 +15,37 @@ func isDuplicateKeyError(err error) bool {
|
||||
return errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
|
||||
}
|
||||
|
||||
// UserRepository provides access to user records.
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, user *model.User) error
|
||||
// RegisteredUserParams contains the fields accepted when registering a user.
|
||||
// Administrative and lifecycle fields are intentionally excluded.
|
||||
type RegisteredUserParams struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
// AuthUserRepository exposes only user operations required by authentication.
|
||||
type AuthUserRepository interface {
|
||||
CreateRegisteredUser(ctx context.Context, params RegisteredUserParams) (*model.User, error)
|
||||
FindByID(ctx context.Context, id string) (*model.User, error)
|
||||
FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error)
|
||||
FindByEmail(ctx context.Context, email string) (*model.User, error)
|
||||
FindByUsername(ctx context.Context, username string) (*model.User, error)
|
||||
Update(ctx context.Context, user *model.User) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
List(ctx context.Context, offset, limit int) ([]model.User, int64, error)
|
||||
}
|
||||
|
||||
// AdminUserRepository exposes only user operations required by administration.
|
||||
type AdminUserRepository interface {
|
||||
FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error)
|
||||
MarkAdminDeleted(ctx context.Context, id string) error
|
||||
ListIncludeDeleted(ctx context.Context, offset, limit int) ([]model.User, int64, error)
|
||||
}
|
||||
|
||||
// UserRepository is the composition-time union of user capabilities.
|
||||
type UserRepository interface {
|
||||
AuthUserRepository
|
||||
AdminUserRepository
|
||||
FindByUsername(ctx context.Context, username string) (*model.User, error)
|
||||
List(ctx context.Context, offset, limit int) ([]model.User, int64, error)
|
||||
}
|
||||
|
||||
type userRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -37,15 +55,23 @@ func NewUserRepository(db *gorm.DB) UserRepository {
|
||||
return &userRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *userRepository) Create(ctx context.Context, user *model.User) error {
|
||||
func (r *userRepository) CreateRegisteredUser(ctx context.Context, params RegisteredUserParams) (*model.User, error) {
|
||||
user := &model.User{
|
||||
ID: params.ID,
|
||||
Username: params.Username,
|
||||
Email: params.Email,
|
||||
PasswordHash: params.PasswordHash,
|
||||
IsAdmin: false,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Create(user)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
return nil, model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
return nil, result.Error
|
||||
}
|
||||
return nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User, error) {
|
||||
@@ -97,18 +123,7 @@ func (r *userRepository) FindByUsername(ctx context.Context, username string) (*
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(ctx context.Context, user *model.User) error {
|
||||
result := r.db.WithContext(ctx).Save(user)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(ctx context.Context, id string) error {
|
||||
func (r *userRepository) MarkAdminDeleted(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", model.StatusAdminDeleted)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
|
||||
@@ -24,6 +24,16 @@ func setupUserRepo(t *testing.T) UserRepository {
|
||||
return NewUserRepository(db)
|
||||
}
|
||||
|
||||
func createUserRecord(ctx context.Context, repo UserRepository, user *model.User) error {
|
||||
_, err := repo.CreateRegisteredUser(ctx, RegisteredUserParams{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
PasswordHash: user.PasswordHash,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func TestUserRepository_Create(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -36,7 +46,7 @@ func TestUserRepository_Create(t *testing.T) {
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +58,11 @@ func TestUserRepository_CreateDuplicateUsername(t *testing.T) {
|
||||
u1 := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
u2 := &model.User{ID: "user-2", Username: "alice", Email: "alice2@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
|
||||
if err := repo.Create(ctx, u1); err != nil {
|
||||
if err := createUserRecord(ctx, repo, u1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, u2)
|
||||
err := createUserRecord(ctx, repo, u2)
|
||||
if err != model.ErrDuplicate {
|
||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||
}
|
||||
@@ -63,7 +73,7 @@ func TestUserRepository_FindByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -91,7 +101,7 @@ func TestUserRepository_FindByEmail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -109,7 +119,7 @@ func TestUserRepository_FindByUsername(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -122,26 +132,24 @@ func TestUserRepository_FindByUsername(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepository_Update(t *testing.T) {
|
||||
func TestUserRepository_CreateRegisteredUserControlsPrivilegedFields(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
user.Username = "alice2"
|
||||
if err := repo.Update(ctx, user); err != nil {
|
||||
t.Fatalf("Update = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
if found.Username != "alice2" {
|
||||
t.Errorf("username = %q, want %q", found.Username, "alice2")
|
||||
if found.IsAdmin {
|
||||
t.Error("registered user must not be an administrator")
|
||||
}
|
||||
if found.Status != model.StatusActive {
|
||||
t.Errorf("status = %q, want %q", found.Status, model.StatusActive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +158,11 @@ func TestUserRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -176,7 +184,7 @@ func TestUserRepository_List(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -204,11 +212,11 @@ func TestUserRepository_SoftDelete(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -231,11 +239,11 @@ func TestUserRepository_DisabledLogin(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -258,7 +266,7 @@ func TestUserRepository_StatusFilterList(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, u1); err != nil {
|
||||
if err := createUserRecord(ctx, repo, u1); err != nil {
|
||||
t.Fatalf("Create u1 = %v", err)
|
||||
}
|
||||
|
||||
@@ -269,12 +277,12 @@ func TestUserRepository_StatusFilterList(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, u2); err != nil {
|
||||
if err := createUserRecord(ctx, repo, u2); err != nil {
|
||||
t.Fatalf("Create u2 = %v", err)
|
||||
}
|
||||
|
||||
// Soft-delete bob
|
||||
if err := repo.Delete(ctx, "user-2"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-2"); err != nil {
|
||||
t.Fatalf("Delete u2 = %v", err)
|
||||
}
|
||||
|
||||
@@ -305,15 +313,15 @@ func TestUserRepository_DeleteIdempotent(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
// Soft-delete the same user twice should not error.
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("first Delete = %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("second Delete = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/app"
|
||||
"github.com/dhao2001/mygo/internal/auth"
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
"github.com/dhao2001/mygo/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
fileIntegrationJWTSecret = "test-secret"
|
||||
fileIntegrationMaxUploadSize = 20 << 20
|
||||
)
|
||||
|
||||
type fileIntegrationFixture struct {
|
||||
router http.Handler
|
||||
jwtSecret []byte
|
||||
}
|
||||
|
||||
type fileIntegrationTokens struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type fileIntegrationInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type fileIntegrationList struct {
|
||||
Files []fileIntegrationInfo `json:"files"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func assertFileIntegrationNotFound(
|
||||
t *testing.T,
|
||||
rec *httptest.ResponseRecorder,
|
||||
wantMessage string,
|
||||
) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode error response: %v", err)
|
||||
}
|
||||
if response.Error.Message != wantMessage {
|
||||
t.Fatalf("message=%q, want %q", response.Error.Message, wantMessage)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "access denied") {
|
||||
t.Fatalf("response leaks authorization result: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newFileIntegrationFixture(t *testing.T) *fileIntegrationFixture {
|
||||
t.Helper()
|
||||
|
||||
dbCfg := config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
SQLite: config.SQLiteConfig{Path: ":memory:"},
|
||||
}
|
||||
db, err := repository.Open(dbCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := repository.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
sessionRepo := repository.NewSessionRepository(db)
|
||||
fileRepo := repository.NewFileRepository(db)
|
||||
credentialRepo := repository.NewCredentialRepository(db)
|
||||
store, err := storage.NewLocalStorage(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("create local storage: %v", err)
|
||||
}
|
||||
|
||||
jwtSecret := []byte(fileIntegrationJWTSecret)
|
||||
authService := service.NewAuthService(
|
||||
userRepo,
|
||||
sessionRepo,
|
||||
credentialRepo,
|
||||
jwtSecret,
|
||||
15*time.Minute,
|
||||
168*time.Hour,
|
||||
)
|
||||
fileService := service.NewFileService(fileRepo, store, fileIntegrationMaxUploadSize, nil)
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: string(jwtSecret),
|
||||
AccessTTL: 15 * time.Minute,
|
||||
RefreshTTL: 168 * time.Hour,
|
||||
},
|
||||
Storage: config.StorageConfig{MaxUploadSize: fileIntegrationMaxUploadSize},
|
||||
}
|
||||
webApp := app.NewWebApp(
|
||||
cfg,
|
||||
db,
|
||||
authService,
|
||||
service.NewAdminService(userRepo),
|
||||
fileService,
|
||||
store,
|
||||
)
|
||||
t.Cleanup(func() {
|
||||
if err := webApp.Close(); err != nil {
|
||||
t.Errorf("close web app: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return &fileIntegrationFixture{
|
||||
router: NewRouter(webApp),
|
||||
jwtSecret: jwtSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fileIntegrationFixture) registerAndLogin(
|
||||
t *testing.T,
|
||||
username string,
|
||||
) (string, fileIntegrationTokens) {
|
||||
t.Helper()
|
||||
|
||||
email := username + "@example.com"
|
||||
password := "password123"
|
||||
registerBody, err := json.Marshal(map[string]string{
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal register body: %v", err)
|
||||
}
|
||||
registerRequest := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/register",
|
||||
bytes.NewReader(registerBody),
|
||||
)
|
||||
registerRequest.Header.Set("Content-Type", "application/json")
|
||||
registerResponse := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(registerResponse, registerRequest)
|
||||
if registerResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("register %s: status=%d, body=%s", username, registerResponse.Code, registerResponse.Body.String())
|
||||
}
|
||||
|
||||
var registered struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(registerResponse.Body.Bytes(), ®istered); err != nil {
|
||||
t.Fatalf("decode register response: %v", err)
|
||||
}
|
||||
if registered.ID == "" {
|
||||
t.Fatal("register response has empty user ID")
|
||||
}
|
||||
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal login body: %v", err)
|
||||
}
|
||||
loginRequest := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/login",
|
||||
bytes.NewReader(loginBody),
|
||||
)
|
||||
loginRequest.Header.Set("Content-Type", "application/json")
|
||||
loginResponse := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(loginResponse, loginRequest)
|
||||
if loginResponse.Code != http.StatusOK {
|
||||
t.Fatalf("login %s: status=%d, body=%s", username, loginResponse.Code, loginResponse.Body.String())
|
||||
}
|
||||
|
||||
var tokens fileIntegrationTokens
|
||||
if err := json.Unmarshal(loginResponse.Body.Bytes(), &tokens); err != nil {
|
||||
t.Fatalf("decode login response: %v", err)
|
||||
}
|
||||
if tokens.AccessToken == "" || tokens.RefreshToken == "" {
|
||||
t.Fatal("login response has an empty token")
|
||||
}
|
||||
|
||||
return registered.ID, tokens
|
||||
}
|
||||
|
||||
func newAuthenticatedFileRequest(method, target string, body io.Reader, token string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, body)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return req
|
||||
}
|
||||
|
||||
func newFileUploadRequest(
|
||||
t *testing.T,
|
||||
target, token, fileName string,
|
||||
content []byte,
|
||||
) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("create multipart file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("write multipart file: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := newAuthenticatedFileRequest(http.MethodPost, target, body, token)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func (f *fileIntegrationFixture) uploadFile(
|
||||
t *testing.T,
|
||||
token, fileName string,
|
||||
content []byte,
|
||||
) fileIntegrationInfo {
|
||||
t.Helper()
|
||||
|
||||
req := newFileUploadRequest(t, "/api/v1/files", token, fileName, content)
|
||||
rec := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("upload %s: status=%d, body=%s", fileName, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var uploaded fileIntegrationInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil {
|
||||
t.Fatalf("decode upload response: %v", err)
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
|
||||
func (f *fileIntegrationFixture) createDirectory(
|
||||
t *testing.T,
|
||||
token, name string,
|
||||
parentID *string,
|
||||
) fileIntegrationInfo {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"name": name,
|
||||
"parent_id": parentID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create directory body: %v", err)
|
||||
}
|
||||
req := newAuthenticatedFileRequest(http.MethodPost, "/api/v1/files", bytes.NewReader(body), token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create directory %s: status=%d, body=%s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var created fileIntegrationInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create directory response: %v", err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func (f *fileIntegrationFixture) listFiles(
|
||||
t *testing.T,
|
||||
token, target string,
|
||||
) fileIntegrationList {
|
||||
t.Helper()
|
||||
|
||||
req := newAuthenticatedFileRequest(http.MethodGet, target, nil, token)
|
||||
rec := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list files: status=%d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var list fileIntegrationList
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("decode file list: %v", err)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func TestAuthenticatedFileFlow(t *testing.T) {
|
||||
fixture := newFileIntegrationFixture(t)
|
||||
_, tokens := fixture.registerAndLogin(t, "web-user")
|
||||
|
||||
initialList := fixture.listFiles(t, tokens.AccessToken, "/api/v1/files?offset=0&limit=50")
|
||||
if initialList.Total != 0 {
|
||||
t.Fatalf("initial total=%d, want 0", initialList.Total)
|
||||
}
|
||||
|
||||
fileContent := []byte("MyGO browser milestone upload")
|
||||
uploadedFile := fixture.uploadFile(t, tokens.AccessToken, "milestone.txt", fileContent)
|
||||
if uploadedFile.Name != "milestone.txt" || uploadedFile.Size != int64(len(fileContent)) {
|
||||
t.Fatalf("uploaded file=%+v, want name milestone.txt and size %d", uploadedFile, len(fileContent))
|
||||
}
|
||||
|
||||
uploadedList := fixture.listFiles(t, tokens.AccessToken, "/api/v1/files?offset=0&limit=50")
|
||||
if uploadedList.Total != 1 || len(uploadedList.Files) != 1 || uploadedList.Files[0].ID != uploadedFile.ID {
|
||||
t.Fatalf("uploaded list=%+v, want the uploaded file", uploadedList)
|
||||
}
|
||||
|
||||
downloadRequest := newAuthenticatedFileRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/files/"+uploadedFile.ID+"/content",
|
||||
nil,
|
||||
tokens.AccessToken,
|
||||
)
|
||||
downloadResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(downloadResponse, downloadRequest)
|
||||
if downloadResponse.Code != http.StatusOK {
|
||||
t.Fatalf("download: status=%d, body=%s", downloadResponse.Code, downloadResponse.Body.String())
|
||||
}
|
||||
if !bytes.Equal(downloadResponse.Body.Bytes(), fileContent) {
|
||||
t.Fatalf("downloaded content=%q, want %q", downloadResponse.Body.Bytes(), fileContent)
|
||||
}
|
||||
if disposition := downloadResponse.Header().Get("Content-Disposition"); disposition == "" {
|
||||
t.Fatal("download response has empty Content-Disposition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRoutesRejectTestIdentityHeaders(t *testing.T) {
|
||||
fixture := newFileIntegrationFixture(t)
|
||||
routes := []struct {
|
||||
name string
|
||||
method string
|
||||
target string
|
||||
}{
|
||||
{name: "list", method: http.MethodGet, target: "/api/v1/files"},
|
||||
{name: "upload", method: http.MethodPost, target: "/api/v1/files"},
|
||||
{name: "get", method: http.MethodGet, target: "/api/v1/files/file-id"},
|
||||
{name: "download", method: http.MethodGet, target: "/api/v1/files/file-id/content"},
|
||||
{name: "update", method: http.MethodPut, target: "/api/v1/files/file-id"},
|
||||
{name: "delete", method: http.MethodDelete, target: "/api/v1/files/file-id"},
|
||||
}
|
||||
|
||||
for _, route := range routes {
|
||||
t.Run(route.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(route.method, route.target, nil)
|
||||
req.Header.Set("X-Test-User-ID", "attacker")
|
||||
req.Header.Set("X-User-ID", "attacker")
|
||||
rec := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRoutesRejectInvalidAccessTokens(t *testing.T) {
|
||||
fixture := newFileIntegrationFixture(t)
|
||||
userID, tokens := fixture.registerAndLogin(t, "token-user")
|
||||
|
||||
wrongSignatureToken, err := auth.GenerateAccessToken(userID, []byte("wrong-secret"), 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("generate wrong-signature token: %v", err)
|
||||
}
|
||||
expiredToken, err := auth.GenerateAccessToken(userID, fixture.jwtSecret, -time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("generate expired token: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
}{
|
||||
{name: "malformed", token: "not-a-jwt"},
|
||||
{name: "wrong-signature", token: wrongSignatureToken},
|
||||
{name: "expired", token: expiredToken},
|
||||
{name: "refresh-token", token: tokens.RefreshToken},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := newFileUploadRequest(t, "/api/v1/files", tt.token, tt.name+".txt", []byte("must not persist"))
|
||||
rec := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
list := fixture.listFiles(t, tokens.AccessToken, "/api/v1/files")
|
||||
if list.Total != 0 || len(list.Files) != 0 {
|
||||
t.Fatalf("files after rejected uploads=%+v, want empty list", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRoutesEnforceAuthenticatedOwnership(t *testing.T) {
|
||||
fixture := newFileIntegrationFixture(t)
|
||||
_, ownerTokens := fixture.registerAndLogin(t, "owner")
|
||||
_, otherTokens := fixture.registerAndLogin(t, "other-user")
|
||||
|
||||
privateDir := fixture.createDirectory(t, ownerTokens.AccessToken, "private", nil)
|
||||
fileContent := []byte("owner-only content")
|
||||
privateFile := fixture.uploadFile(t, ownerTokens.AccessToken, "owner.txt", fileContent)
|
||||
|
||||
otherList := fixture.listFiles(t, otherTokens.AccessToken, "/api/v1/files")
|
||||
if otherList.Total != 0 || len(otherList.Files) != 0 {
|
||||
t.Fatalf("other user's root list=%+v, want empty list", otherList)
|
||||
}
|
||||
otherFile := fixture.uploadFile(t, otherTokens.AccessToken, "other.txt", []byte("other content"))
|
||||
|
||||
createBody, err := json.Marshal(map[string]any{
|
||||
"name": "intrusion",
|
||||
"parent_id": privateDir.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cross-owner create body: %v", err)
|
||||
}
|
||||
moveBody, err := json.Marshal(map[string]any{"parent_id": privateDir.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cross-owner move body: %v", err)
|
||||
}
|
||||
parentRequests := []struct {
|
||||
name string
|
||||
newRequest func(*testing.T) *http.Request
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
newRequest: func(*testing.T) *http.Request {
|
||||
return newAuthenticatedFileRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/files?parent_id="+privateDir.ID,
|
||||
nil,
|
||||
otherTokens.AccessToken,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create directory",
|
||||
newRequest: func(*testing.T) *http.Request {
|
||||
req := newAuthenticatedFileRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/files",
|
||||
bytes.NewReader(createBody),
|
||||
otherTokens.AccessToken,
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "upload",
|
||||
newRequest: func(t *testing.T) *http.Request {
|
||||
return newFileUploadRequest(
|
||||
t,
|
||||
"/api/v1/files?parent_id="+privateDir.ID,
|
||||
otherTokens.AccessToken,
|
||||
"intrusion.txt",
|
||||
[]byte("must not persist"),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move",
|
||||
newRequest: func(*testing.T) *http.Request {
|
||||
req := newAuthenticatedFileRequest(
|
||||
http.MethodPut,
|
||||
"/api/v1/files/"+otherFile.ID,
|
||||
bytes.NewReader(moveBody),
|
||||
otherTokens.AccessToken,
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, request := range parentRequests {
|
||||
t.Run("parent/"+request.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(rec, request.newRequest(t))
|
||||
assertFileIntegrationNotFound(t, rec, "parent directory not found")
|
||||
})
|
||||
}
|
||||
missingParentRequest := newAuthenticatedFileRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/files?parent_id=missing-directory",
|
||||
nil,
|
||||
otherTokens.AccessToken,
|
||||
)
|
||||
missingParentResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(missingParentResponse, missingParentRequest)
|
||||
assertFileIntegrationNotFound(t, missingParentResponse, "parent directory not found")
|
||||
|
||||
updateBody := []byte(`{"name":"stolen.txt"}`)
|
||||
requests := []struct {
|
||||
name string
|
||||
method string
|
||||
target string
|
||||
body []byte
|
||||
contentType string
|
||||
}{
|
||||
{name: "get", method: http.MethodGet, target: "/api/v1/files/" + privateFile.ID},
|
||||
{name: "download", method: http.MethodGet, target: "/api/v1/files/" + privateFile.ID + "/content"},
|
||||
{name: "update", method: http.MethodPut, target: "/api/v1/files/" + privateFile.ID, body: updateBody, contentType: "application/json"},
|
||||
{name: "delete", method: http.MethodDelete, target: "/api/v1/files/" + privateFile.ID},
|
||||
}
|
||||
|
||||
for _, request := range requests {
|
||||
t.Run(request.name, func(t *testing.T) {
|
||||
req := newAuthenticatedFileRequest(
|
||||
request.method,
|
||||
request.target,
|
||||
bytes.NewReader(request.body),
|
||||
otherTokens.AccessToken,
|
||||
)
|
||||
if request.contentType != "" {
|
||||
req.Header.Set("Content-Type", request.contentType)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(rec, req)
|
||||
|
||||
assertFileIntegrationNotFound(t, rec, "file not found")
|
||||
})
|
||||
}
|
||||
|
||||
getRequest := newAuthenticatedFileRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/files/"+privateFile.ID,
|
||||
nil,
|
||||
ownerTokens.AccessToken,
|
||||
)
|
||||
getResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(getResponse, getRequest)
|
||||
if getResponse.Code != http.StatusOK {
|
||||
t.Fatalf("owner get after rejected access: status=%d, body=%s", getResponse.Code, getResponse.Body.String())
|
||||
}
|
||||
var retainedFile fileIntegrationInfo
|
||||
if err := json.Unmarshal(getResponse.Body.Bytes(), &retainedFile); err != nil {
|
||||
t.Fatalf("decode retained file: %v", err)
|
||||
}
|
||||
if retainedFile.Name != privateFile.Name {
|
||||
t.Fatalf("retained file name=%q, want %q", retainedFile.Name, privateFile.Name)
|
||||
}
|
||||
|
||||
downloadRequest := newAuthenticatedFileRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/files/"+privateFile.ID+"/content",
|
||||
nil,
|
||||
ownerTokens.AccessToken,
|
||||
)
|
||||
downloadResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(downloadResponse, downloadRequest)
|
||||
if downloadResponse.Code != http.StatusOK {
|
||||
t.Fatalf("owner download after rejected access: status=%d, body=%s", downloadResponse.Code, downloadResponse.Body.String())
|
||||
}
|
||||
if !bytes.Equal(downloadResponse.Body.Bytes(), fileContent) {
|
||||
t.Fatalf("retained file content=%q, want %q", downloadResponse.Body.Bytes(), fileContent)
|
||||
}
|
||||
|
||||
dirList := fixture.listFiles(
|
||||
t,
|
||||
ownerTokens.AccessToken,
|
||||
"/api/v1/files?parent_id="+privateDir.ID,
|
||||
)
|
||||
if dirList.Total != 0 || len(dirList.Files) != 0 {
|
||||
t.Fatalf("private directory after rejected create=%+v, want empty list", dirList)
|
||||
}
|
||||
|
||||
otherRootList := fixture.listFiles(t, otherTokens.AccessToken, "/api/v1/files")
|
||||
if otherRootList.Total != 1 || len(otherRootList.Files) != 1 || otherRootList.Files[0].ID != otherFile.ID {
|
||||
t.Fatalf("other user's root list after rejected move=%+v, want original file", otherRootList)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDeleteReturnsNotFoundForMissingAndDeletedFiles(t *testing.T) {
|
||||
fixture := newFileIntegrationFixture(t)
|
||||
_, tokens := fixture.registerAndLogin(t, "delete-user")
|
||||
|
||||
missingRequest := newAuthenticatedFileRequest(
|
||||
http.MethodDelete,
|
||||
"/api/v1/files/missing-file",
|
||||
nil,
|
||||
tokens.AccessToken,
|
||||
)
|
||||
missingResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(missingResponse, missingRequest)
|
||||
assertFileIntegrationNotFound(t, missingResponse, "file not found")
|
||||
|
||||
uploaded := fixture.uploadFile(t, tokens.AccessToken, "delete-me.txt", []byte("delete content"))
|
||||
deleteTarget := "/api/v1/files/" + uploaded.ID
|
||||
firstRequest := newAuthenticatedFileRequest(http.MethodDelete, deleteTarget, nil, tokens.AccessToken)
|
||||
firstResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(firstResponse, firstRequest)
|
||||
if firstResponse.Code != http.StatusNoContent {
|
||||
t.Fatalf("first delete: status=%d, want %d; body=%s", firstResponse.Code, http.StatusNoContent, firstResponse.Body.String())
|
||||
}
|
||||
|
||||
secondRequest := newAuthenticatedFileRequest(http.MethodDelete, deleteTarget, nil, tokens.AccessToken)
|
||||
secondResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(secondResponse, secondRequest)
|
||||
assertFileIntegrationNotFound(t, secondResponse, "file not found")
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
"github.com/dhao2001/mygo/internal/testutil"
|
||||
)
|
||||
|
||||
func TestVersionRoute(t *testing.T) {
|
||||
@@ -25,7 +25,7 @@ func TestVersionRoute(t *testing.T) {
|
||||
},
|
||||
}
|
||||
authService := service.NewAuthService(nil, nil, nil, nil, 15*time.Minute, 7*24*time.Hour)
|
||||
webApp := app.NewWebApp(cfg, nil, nil, nil, nil, nil, authService, nil, nil, nil)
|
||||
webApp := app.NewWebApp(cfg, nil, authService, nil, nil, nil)
|
||||
router := NewRouter(webApp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
|
||||
@@ -94,7 +94,7 @@ func TestAdminRoutes(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
webApp := app.NewWebApp(cfg, db, userRepo, sessionRepo, nil, credentialRepo, authService, adminService, nil, nil)
|
||||
webApp := app.NewWebApp(cfg, db, authService, adminService, nil, nil)
|
||||
router := NewRouter(webApp)
|
||||
|
||||
// Helper: register a user via POST /api/v1/auth/register and return the user ID.
|
||||
@@ -123,14 +123,7 @@ func TestAdminRoutes(t *testing.T) {
|
||||
|
||||
// Register admin user and promote to admin.
|
||||
adminID := register("admin", "admin@test.local", "adminpass1")
|
||||
adminUser, err := userRepo.FindByID(context.Background(), adminID)
|
||||
if err != nil {
|
||||
t.Fatalf("find admin user: %v", err)
|
||||
}
|
||||
adminUser.IsAdmin = true
|
||||
if err := userRepo.Update(context.Background(), adminUser); err != nil {
|
||||
t.Fatalf("promote to admin: %v", err)
|
||||
}
|
||||
testutil.SetUserAdmin(t, db, adminID, true)
|
||||
|
||||
// Generate admin JWT.
|
||||
adminToken, err := auth.GenerateAccessToken(adminID, jwtSecret, accessTTL)
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
|
||||
// AdminService handles administrator user-management operations.
|
||||
type AdminService struct {
|
||||
userRepo repository.UserRepository
|
||||
userRepo repository.AdminUserRepository
|
||||
}
|
||||
|
||||
// NewAdminService creates an AdminService.
|
||||
func NewAdminService(userRepo repository.UserRepository) *AdminService {
|
||||
func NewAdminService(userRepo repository.AdminUserRepository) *AdminService {
|
||||
return &AdminService{userRepo: userRepo}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *AdminService) DeleteUser(ctx context.Context, id string) error {
|
||||
if _, err := s.GetUser(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.userRepo.Delete(ctx, id); err != nil {
|
||||
if err := s.userRepo.MarkAdminDeleted(ctx, id); err != nil {
|
||||
return model.NewInternalError("delete admin user", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -46,13 +46,13 @@ func TestAdminServiceListIncludesDeletedUsers(t *testing.T) {
|
||||
|
||||
active := &model.User{ID: "active-1", Username: "active", Email: "active@example.com", Status: model.StatusActive}
|
||||
deleted := &model.User{ID: "deleted-1", Username: "deleted", Email: "deleted@example.com", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, active); err != nil {
|
||||
if _, err := repo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{ID: active.ID, Username: active.Username, Email: active.Email, PasswordHash: "hash"}); err != nil {
|
||||
t.Fatalf("create active: %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, deleted); err != nil {
|
||||
if _, err := repo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{ID: deleted.ID, Username: deleted.Username, Email: deleted.Email, PasswordHash: "hash"}); err != nil {
|
||||
t.Fatalf("create deleted: %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, deleted.ID); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, deleted.ID); err != nil {
|
||||
t.Fatalf("delete user: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+30
-43
@@ -34,8 +34,8 @@ type Principal struct {
|
||||
|
||||
// AuthService handles user authentication and session management.
|
||||
type AuthService struct {
|
||||
userRepo repository.UserRepository
|
||||
sessionRepo repository.SessionRepository
|
||||
userRepo repository.AuthUserRepository
|
||||
sessionRepo repository.AuthSessionRepository
|
||||
credentialRepo repository.CredentialRepository
|
||||
jwtSecret []byte
|
||||
accessTTL time.Duration
|
||||
@@ -44,8 +44,8 @@ type AuthService struct {
|
||||
|
||||
// NewAuthService creates an AuthService.
|
||||
func NewAuthService(
|
||||
userRepo repository.UserRepository,
|
||||
sessionRepo repository.SessionRepository,
|
||||
userRepo repository.AuthUserRepository,
|
||||
sessionRepo repository.AuthSessionRepository,
|
||||
credentialRepo repository.CredentialRepository,
|
||||
jwtSecret []byte,
|
||||
accessTTL time.Duration,
|
||||
@@ -72,15 +72,13 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
|
||||
return nil, model.NewInternalError("hash password", err)
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
user, err := s.userRepo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{
|
||||
ID: uuid.NewString(),
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("username or email already exists")
|
||||
}
|
||||
@@ -124,7 +122,7 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(refreshTokenStr)
|
||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||
session, err := s.sessionRepo.ConsumeRefreshSession(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
@@ -144,25 +142,13 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
|
||||
return nil, model.NewInternalError("delete old session", err)
|
||||
}
|
||||
|
||||
return s.issueTokens(ctx, claims.UserID)
|
||||
}
|
||||
|
||||
// Logout invalidates a refresh token by deleting its session.
|
||||
func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error {
|
||||
tokenHash := auth.HashToken(refreshTokenStr)
|
||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return model.NewInternalError("find session", err)
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
|
||||
if err := s.sessionRepo.RevokeRefreshSession(ctx, tokenHash); err != nil {
|
||||
return model.NewInternalError("delete session", err)
|
||||
}
|
||||
return nil
|
||||
@@ -175,20 +161,18 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (
|
||||
return nil, model.NewInternalError("generate token", err)
|
||||
}
|
||||
|
||||
cred := &model.Credential{
|
||||
ID: uuid.NewString(),
|
||||
credID := uuid.NewString()
|
||||
if err := s.credentialRepo.CreatePasskey(ctx, repository.PasskeyParams{
|
||||
ID: credID,
|
||||
UserID: userID,
|
||||
Type: "app_passkey",
|
||||
Label: label,
|
||||
SecretHash: hash,
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.Create(ctx, cred); err != nil {
|
||||
}); err != nil {
|
||||
return nil, model.NewInternalError("create credential", err)
|
||||
}
|
||||
|
||||
return &CreatedPasskey{
|
||||
ID: cred.ID,
|
||||
ID: credID,
|
||||
Raw: raw,
|
||||
Label: label,
|
||||
}, nil
|
||||
@@ -201,7 +185,7 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(tokenStr)
|
||||
cred, err := s.credentialRepo.FindByHash(ctx, tokenHash)
|
||||
cred, err := s.credentialRepo.FindPasskeyByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
@@ -209,19 +193,21 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
return nil, model.NewInternalError("find credential", err)
|
||||
}
|
||||
|
||||
user, err := s.userRepo.FindByIDIncludeDeleted(ctx, cred.UserID)
|
||||
user, err := s.userRepo.FindByID(ctx, cred.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
return nil, model.NewInternalError("find user", err)
|
||||
}
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
|
||||
if cred.Type != "app_passkey" {
|
||||
return nil, model.NewUnauthenticatedError("invalid credential type")
|
||||
if err := s.credentialRepo.RecordPasskeyUsed(ctx, cred.ID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil {
|
||||
return nil, model.NewInternalError("update last used", err)
|
||||
}
|
||||
|
||||
@@ -230,7 +216,7 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
|
||||
// ListPasskeys returns all app passkeys for a user.
|
||||
func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
creds, err := s.credentialRepo.FindByUserIDAndType(ctx, userID, "app_passkey")
|
||||
creds, err := s.credentialRepo.ListPasskeys(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, model.NewInternalError("list passkeys", err)
|
||||
}
|
||||
@@ -239,7 +225,7 @@ func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.
|
||||
|
||||
// RevokePasskey deletes an app passkey owned by the user.
|
||||
func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string) error {
|
||||
cred, err := s.credentialRepo.FindByID(ctx, credID)
|
||||
cred, err := s.credentialRepo.FindPasskeyByID(ctx, credID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
|
||||
@@ -251,7 +237,10 @@ func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string)
|
||||
return model.NewPermissionDeniedError("access denied", model.ErrForbidden)
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.Delete(ctx, credID); err != nil {
|
||||
if err := s.credentialRepo.RevokeOwnedPasskey(ctx, userID, credID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
|
||||
}
|
||||
return model.NewInternalError("delete credential", err)
|
||||
}
|
||||
return nil
|
||||
@@ -296,14 +285,12 @@ func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPai
|
||||
return nil, model.NewInternalError("generate refresh token", err)
|
||||
}
|
||||
|
||||
session := &model.Session{
|
||||
if err := s.sessionRepo.CreateRefreshSession(ctx, repository.RefreshSessionParams{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
TokenHash: auth.HashToken(refreshToken),
|
||||
ExpiresAt: time.Now().Add(s.refreshTTL),
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Create(ctx, session); err != nil {
|
||||
}); err != nil {
|
||||
return nil, model.NewInternalError("create session", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -10,18 +12,20 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/auth"
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/testutil"
|
||||
)
|
||||
|
||||
func setupAuthService(t *testing.T) *AuthService {
|
||||
svc, _ := setupAuthServiceWithRepos(t)
|
||||
svc, _, _ := setupAuthServiceWithRepos(t)
|
||||
return svc
|
||||
}
|
||||
|
||||
// setupAuthServiceWithRepos creates an AuthService and returns the UserRepository
|
||||
// for tests that need direct repo access (e.g., soft-deleting users).
|
||||
func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository) {
|
||||
func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -42,7 +46,7 @@ func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepos
|
||||
15*time.Minute,
|
||||
7*24*time.Hour,
|
||||
)
|
||||
return svc, userRepo
|
||||
return svc, userRepo, db
|
||||
}
|
||||
|
||||
func TestAuthService_Register(t *testing.T) {
|
||||
@@ -413,7 +417,7 @@ func TestAuthService_RefreshWithAccessToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_LoginDisabledUser(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
@@ -421,7 +425,7 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -442,7 +446,7 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
@@ -454,7 +458,7 @@ func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
||||
_, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword")
|
||||
|
||||
// Soft-delete user, then try to login
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
_, disabledErr := svc.Login(ctx, "alice@example.com", "password123")
|
||||
@@ -482,7 +486,7 @@ func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
@@ -506,7 +510,7 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
||||
}
|
||||
|
||||
// Soft-delete the user
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -527,7 +531,7 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
@@ -541,7 +545,7 @@ func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
||||
}
|
||||
|
||||
// Soft-delete the user
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -562,17 +566,14 @@ func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_AuthenticateAccessToken(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, _, db := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
user.IsAdmin = true
|
||||
if err := userRepo.Update(ctx, user); err != nil {
|
||||
t.Fatalf("promote user: %v", err)
|
||||
}
|
||||
testutil.SetUserAdmin(t, db, user.ID, true)
|
||||
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
@@ -592,7 +593,7 @@ func TestAuthService_AuthenticateAccessToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
@@ -605,7 +606,7 @@ func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -618,3 +619,72 @@ func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
|
||||
t.Fatalf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_RefreshTokenConcurrentUseAllowsOneSuccess(t *testing.T) {
|
||||
db, err := repository.Open(config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "auth.db")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := repository.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
svc := NewAuthService(
|
||||
repository.NewUserRepository(db),
|
||||
repository.NewSessionRepository(db),
|
||||
repository.NewCredentialRepository(db),
|
||||
[]byte("test-secret"),
|
||||
15*time.Minute,
|
||||
7*24*time.Hour,
|
||||
)
|
||||
ctx := context.Background()
|
||||
if _, err := svc.Register(ctx, "alice", "alice@example.com", "password123"); err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
type refreshResult struct {
|
||||
pair *TokenPair
|
||||
err error
|
||||
}
|
||||
start := make(chan struct{})
|
||||
results := make(chan refreshResult, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
refreshed, err := svc.Refresh(ctx, pair.RefreshToken)
|
||||
results <- refreshResult{pair: refreshed, err: err}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
succeeded := 0
|
||||
rejected := 0
|
||||
for result := range results {
|
||||
if result.err == nil {
|
||||
succeeded++
|
||||
if result.pair == nil || result.pair.AccessToken == "" || result.pair.RefreshToken == "" {
|
||||
t.Fatal("successful refresh returned an empty token pair")
|
||||
}
|
||||
continue
|
||||
}
|
||||
var appErr *model.AppError
|
||||
if !errors.As(result.err, &appErr) || appErr.Kind != model.KindUnauthenticated {
|
||||
t.Fatalf("concurrent refresh error = %v, want unauthenticated", result.err)
|
||||
}
|
||||
rejected++
|
||||
}
|
||||
if succeeded != 1 || rejected != 1 {
|
||||
t.Fatalf("refresh results: success=%d rejected=%d, want 1 each", succeeded, rejected)
|
||||
}
|
||||
}
|
||||
|
||||
+45
-55
@@ -136,7 +136,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
return nil, model.NewInternalError("promote staged file", err)
|
||||
}
|
||||
|
||||
file := &model.File{
|
||||
file, err := s.fileRepo.CreateUploadedFile(ctx, repository.UploadedFileParams{
|
||||
ID: fileID,
|
||||
UserID: userID,
|
||||
ParentID: parentID,
|
||||
@@ -145,16 +145,19 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
MimeType: mimeType,
|
||||
StoragePath: storagePath,
|
||||
Hash: hex.EncodeToString(hasher.Sum(nil)),
|
||||
Status: model.StatusActive,
|
||||
IsDir: false,
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Create(ctx, file); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
// Compensate: remove the promoted file.
|
||||
_ = s.storage.Delete(ctx, storagePath)
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("a file with this name already exists in this directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotFound) {
|
||||
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotDir) {
|
||||
return nil, model.NewBadRequestError("parent is not a directory")
|
||||
}
|
||||
return nil, model.NewInternalError("create file record", err)
|
||||
}
|
||||
|
||||
@@ -256,44 +259,42 @@ func (s *FileService) List(ctx context.Context, userID string, parentID *string,
|
||||
|
||||
// Update renames or moves a file or directory.
|
||||
func (s *FileService) Update(ctx context.Context, userID, fileID string, newName string, newParentID *string) (*FileInfo, error) {
|
||||
file, err := s.getOwnedFile(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate new name if provided.
|
||||
var newNamePtr *string
|
||||
if newName != "" {
|
||||
if err := validateFileName(newName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Name = newName
|
||||
newNamePtr = &newName
|
||||
}
|
||||
|
||||
// If parent is changing, verify the new parent.
|
||||
if newParentID != nil {
|
||||
if *newParentID == fileID {
|
||||
return nil, model.NewBadRequestError("cannot move a directory into itself")
|
||||
}
|
||||
if err := s.verifyParent(ctx, userID, *newParentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.ParentID = newParentID
|
||||
}
|
||||
|
||||
// Check for name conflicts in the target directory.
|
||||
if newName != "" || newParentID != nil {
|
||||
conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name)
|
||||
if err == nil && conflict.ID != fileID {
|
||||
return nil, model.NewConflictError("a file with this name already exists in the target directory")
|
||||
} else if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewInternalError("check name conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Update(ctx, file); err != nil {
|
||||
file, err := s.fileRepo.UpdateOwnedMetadata(ctx, repository.FileMetadataUpdate{
|
||||
FileID: fileID,
|
||||
UserID: userID,
|
||||
NewName: newNamePtr,
|
||||
NewParentID: newParentID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("a file with this name already exists in the target directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotFound) {
|
||||
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotDir) {
|
||||
return nil, model.NewBadRequestError("parent is not a directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrInvalidMove) {
|
||||
return nil, model.NewBadRequestError("cannot move a directory into itself")
|
||||
}
|
||||
return nil, model.NewInternalError("update file", err)
|
||||
}
|
||||
|
||||
@@ -302,27 +303,13 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
|
||||
|
||||
// Delete soft-deletes a file or (empty) directory. Storage content is preserved.
|
||||
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
|
||||
file, err := s.getOwnedFile(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
var ae *model.AppError
|
||||
if errors.As(err, &ae) && errors.Is(ae.Err, model.ErrNotFound) {
|
||||
return nil // idempotent: already deleted
|
||||
if err := s.fileRepo.SoftDeleteOwned(ctx, userID, fileID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.NewNotFoundError("file not found", model.ErrNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Directories must be empty before deletion.
|
||||
if file.IsDir {
|
||||
_, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, &fileID, 0, 1)
|
||||
if err != nil {
|
||||
return model.NewInternalError("check directory contents", err)
|
||||
}
|
||||
if total > 0 {
|
||||
if errors.Is(err, model.ErrDirectoryNotEmpty) {
|
||||
return model.NewConflictError("directory is not empty")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Delete(ctx, fileID); err != nil {
|
||||
return model.NewInternalError("delete file record", err)
|
||||
}
|
||||
|
||||
@@ -348,19 +335,22 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
|
||||
return nil, model.NewInternalError("check name conflict", err)
|
||||
}
|
||||
|
||||
dir := &model.File{
|
||||
dir, err := s.fileRepo.CreateDirectory(ctx, repository.DirectoryParams{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
ParentID: parentID,
|
||||
Name: dirName,
|
||||
Status: model.StatusActive,
|
||||
IsDir: true,
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Create(ctx, dir); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("a file with this name already exists in this directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotFound) {
|
||||
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotDir) {
|
||||
return nil, model.NewBadRequestError("parent is not a directory")
|
||||
}
|
||||
return nil, model.NewInternalError("create directory record", err)
|
||||
}
|
||||
|
||||
@@ -378,7 +368,7 @@ func (s *FileService) getOwnedFile(ctx context.Context, userID, fileID string) (
|
||||
}
|
||||
|
||||
if file.UserID != userID {
|
||||
return nil, model.NewForbiddenError("access denied", model.ErrForbidden)
|
||||
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
@@ -395,7 +385,7 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string)
|
||||
}
|
||||
|
||||
if parent.UserID != userID {
|
||||
return model.NewForbiddenError("access denied", model.ErrForbidden)
|
||||
return model.NewNotFoundError("parent directory not found", model.ErrNotFound)
|
||||
}
|
||||
|
||||
if !parent.IsDir {
|
||||
|
||||
+177
-15
@@ -32,12 +32,51 @@ func assertAppError(t *testing.T, err error, wantKind model.ErrorKind) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func assertAppErrorMessage(t *testing.T, err error, wantKind model.ErrorKind, wantMessage string) {
|
||||
t.Helper()
|
||||
if !assertAppError(t, err, wantKind) {
|
||||
return
|
||||
}
|
||||
var ae *model.AppError
|
||||
errors.As(err, &ae)
|
||||
if ae.Message != wantMessage {
|
||||
t.Errorf("message = %q, want %q", ae.Message, wantMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// memStorage is an in-memory implementation of storage.StorageBackend for tests.
|
||||
type memStorage struct {
|
||||
mu sync.RWMutex
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
type blockingPromoteStorage struct {
|
||||
*memStorage
|
||||
promoted chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func newBlockingPromoteStorage() *blockingPromoteStorage {
|
||||
return &blockingPromoteStorage{
|
||||
memStorage: newMemStorage(),
|
||||
promoted: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *blockingPromoteStorage) PromoteStaged(ctx context.Context, stagedPath, finalPath string) error {
|
||||
if err := s.memStorage.PromoteStaged(ctx, stagedPath, finalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
close(s.promoted)
|
||||
select {
|
||||
case <-s.release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func newMemStorage() *memStorage {
|
||||
return &memStorage{files: make(map[string][]byte)}
|
||||
}
|
||||
@@ -274,7 +313,7 @@ func TestFileService_Download(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_DownloadForbidden(t *testing.T) {
|
||||
func TestFileService_DownloadReturnsNotFoundForOtherUser(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -284,7 +323,7 @@ func TestFileService_DownloadForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
_, _, err = svc.Download(ctx, "user2", info.ID)
|
||||
assertAppError(t, err, model.KindPermissionDenied)
|
||||
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||
}
|
||||
|
||||
func TestFileService_DownloadDirectory(t *testing.T) {
|
||||
@@ -328,7 +367,7 @@ func TestFileService_GetNotFound(t *testing.T) {
|
||||
assertAppError(t, err, model.KindNotFound)
|
||||
}
|
||||
|
||||
func TestFileService_GetForbidden(t *testing.T) {
|
||||
func TestFileService_GetReturnsNotFoundForOtherUser(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -338,7 +377,7 @@ func TestFileService_GetForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = svc.Get(ctx, "user2", info.ID)
|
||||
assertAppError(t, err, model.KindPermissionDenied)
|
||||
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||
}
|
||||
|
||||
func TestFileService_List(t *testing.T) {
|
||||
@@ -395,6 +434,43 @@ func TestFileService_Update(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_UpdateRejectsDuplicateNameInRoot(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Upload(ctx, "user1", nil, "existing.txt", strings.NewReader("first")); err != nil {
|
||||
t.Fatalf("Upload existing = %v", err)
|
||||
}
|
||||
file, err := svc.Upload(ctx, "user1", nil, "rename-me.txt", strings.NewReader("second"))
|
||||
if err != nil {
|
||||
t.Fatalf("Upload source = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Update(ctx, "user1", file.ID, "existing.txt", nil)
|
||||
assertAppError(t, err, model.KindConflict)
|
||||
|
||||
unchanged, err := svc.Get(ctx, "user1", file.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get source after rejected rename = %v", err)
|
||||
}
|
||||
if unchanged.Name != "rename-me.txt" {
|
||||
t.Fatalf("source name = %q, want rename-me.txt", unchanged.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_UpdateReturnsNotFoundForOtherUser(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
info, err := svc.Upload(ctx, "user1", nil, "private.txt", strings.NewReader("data"))
|
||||
if err != nil {
|
||||
t.Fatalf("Upload = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Update(ctx, "user2", info.ID, "stolen.txt", nil)
|
||||
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||
}
|
||||
|
||||
func TestFileService_UpdateMove(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
@@ -458,7 +534,7 @@ func TestFileService_DeleteNonEmptyDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_DeleteForbidden(t *testing.T) {
|
||||
func TestFileService_DeleteReturnsNotFoundForOtherUser(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -468,7 +544,14 @@ func TestFileService_DeleteForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
err = svc.Delete(ctx, "user2", info.ID)
|
||||
assertAppError(t, err, model.KindPermissionDenied)
|
||||
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||
}
|
||||
|
||||
func TestFileService_DeleteReturnsNotFoundForMissingFile(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
|
||||
err := svc.Delete(context.Background(), "user1", "missing-file")
|
||||
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||
}
|
||||
|
||||
func TestFileService_CreateDir(t *testing.T) {
|
||||
@@ -502,7 +585,7 @@ func TestFileService_CreateDirDuplicateName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_VerifyParentForbidden(t *testing.T) {
|
||||
func TestFileService_ParentOperationsReturnNotFoundForOtherUser(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -510,9 +593,50 @@ func TestFileService_VerifyParentForbidden(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDir = %v", err)
|
||||
}
|
||||
otherFile, err := svc.Upload(ctx, "user2", nil, "other.txt", strings.NewReader("data"))
|
||||
if err != nil {
|
||||
t.Fatalf("Upload other file = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
|
||||
assertAppError(t, err, model.KindPermissionDenied)
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
run: func() error {
|
||||
_, err := svc.List(ctx, "user2", &dir.ID, 0, 50)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "upload",
|
||||
run: func() error {
|
||||
_, err := svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create directory",
|
||||
run: func() error {
|
||||
_, err := svc.CreateDir(ctx, "user2", &dir.ID, "stolen-dir")
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move",
|
||||
run: func() error {
|
||||
_, err := svc.Update(ctx, "user2", otherFile.ID, "", &dir.ID)
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assertAppErrorMessage(t, tt.run(), model.KindNotFound, "parent directory not found")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_SoftDelete(t *testing.T) {
|
||||
@@ -562,7 +686,7 @@ func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_SoftDeleteIdempotent(t *testing.T) {
|
||||
func TestFileService_SoftDeleteRepeatedReturnsNotFound(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -574,12 +698,11 @@ func TestFileService_SoftDeleteIdempotent(t *testing.T) {
|
||||
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
||||
t.Fatalf("first Delete = %v", err)
|
||||
}
|
||||
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
||||
t.Fatalf("second Delete should be idempotent, got: %v", err)
|
||||
}
|
||||
err = svc.Delete(ctx, "user1", info.ID)
|
||||
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||
}
|
||||
|
||||
func TestFileService_SoftDeleteForbidden(t *testing.T) {
|
||||
func TestFileService_SoftDeleteReturnsNotFoundForOtherUser(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -589,5 +712,44 @@ func TestFileService_SoftDeleteForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
err = svc.Delete(ctx, "user2", info.ID)
|
||||
assertAppError(t, err, model.KindPermissionDenied)
|
||||
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||
}
|
||||
|
||||
func TestFileService_UploadRechecksParentAfterStoragePromotion(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.File{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
store := newBlockingPromoteStorage()
|
||||
svc := NewFileService(repository.NewFileRepository(db), store, 0, nil)
|
||||
ctx := context.Background()
|
||||
dir, err := svc.CreateDir(ctx, "user1", nil, "parent")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDir = %v", err)
|
||||
}
|
||||
|
||||
uploadResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := svc.Upload(ctx, "user1", &dir.ID, "child.txt", strings.NewReader("content"))
|
||||
uploadResult <- err
|
||||
}()
|
||||
|
||||
<-store.promoted
|
||||
if err := svc.Delete(ctx, "user1", dir.ID); err != nil {
|
||||
t.Fatalf("Delete parent while upload is paused = %v", err)
|
||||
}
|
||||
close(store.release)
|
||||
|
||||
err = <-uploadResult
|
||||
assertAppErrorMessage(t, err, model.KindNotFound, "parent directory not found")
|
||||
store.mu.RLock()
|
||||
remainingObjects := len(store.files)
|
||||
store.mu.RUnlock()
|
||||
if remainingObjects != 0 {
|
||||
t.Fatalf("storage objects after rejected upload = %d, want 0", remainingObjects)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package testutil contains fixtures that intentionally bypass production
|
||||
// service ports. Production packages must not import it.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// SetUserAdmin changes a user's administrative flag for test fixture setup.
|
||||
func SetUserAdmin(t testing.TB, db *gorm.DB, userID string, isAdmin bool) {
|
||||
t.Helper()
|
||||
result := db.Model(&model.User{}).Where("id = ?", userID).Update("is_admin", isAdmin)
|
||||
if result.Error != nil {
|
||||
t.Fatalf("set test user admin flag: %v", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
t.Fatalf("set test user admin flag: user %q not found", userID)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
[tools]
|
||||
go = "1.26.2"
|
||||
node = "24"
|
||||
|
||||
[env]
|
||||
XDG_CACHE_HOME = "{{config_root}}/.cache"
|
||||
NPM_CONFIG_CACHE = "{{config_root}}/.cache/npm"
|
||||
PLAYWRIGHT_BROWSERS_PATH = "{{config_root}}/.cache/ms-playwright"
|
||||
|
||||
+71
-4
@@ -4,15 +4,82 @@ MyGO's browser client is a pure client-side rendered application built with Reac
|
||||
|
||||
## Development
|
||||
|
||||
Install the repository-pinned Go and Node.js versions from the repository root:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
mise install
|
||||
```
|
||||
|
||||
Start the Go API from the repository root:
|
||||
|
||||
```bash
|
||||
mise exec -- go run . serve
|
||||
```
|
||||
|
||||
Create a development account through the existing public API if needed:
|
||||
|
||||
```bash
|
||||
curl --request POST http://127.0.0.1:10086/api/v1/auth/register \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"username":"web-user","email":"web@example.com","password":"password123"}'
|
||||
```
|
||||
|
||||
Then start the browser client from `web/`:
|
||||
|
||||
```bash
|
||||
mise exec -- npm ci
|
||||
mise exec -- npm run dev
|
||||
```
|
||||
|
||||
Vite proxies `/api` to `http://127.0.0.1:10086`. Production deployments must serve the static build and `/api/v1` from the same origin, normally through a reverse proxy.
|
||||
|
||||
## Browser Testing and Debugging
|
||||
|
||||
The Web project uses the Playwright `@playwright/test` for both repeatable E2E tests and interactive browser debugging. The npm cache, browser binaries, CLI daemon state, screenshots, traces, and videos stay under the repository-level ignored `.cache/` and `.artifacts/` directories.
|
||||
|
||||
Install the Debian browser dependencies once per development container:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:install:deps
|
||||
```
|
||||
|
||||
Install the matching bundled Chromium revision:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:install
|
||||
```
|
||||
|
||||
E2E tests and interactive debugging intentionally share the same stable Playwright core and browser revision.
|
||||
|
||||
Currently Playwright's version is locked by the only `@playwright/test` dependency. Standalone `@playwright/cli` or `@playwright/mcp` won't be added unless additional requirement.
|
||||
|
||||
Run the headless browser tests with Chromium sandboxing enabled:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run test:e2e
|
||||
```
|
||||
|
||||
Run interactive commands through the repository wrapper:
|
||||
|
||||
```bash
|
||||
mise exec -- npm run playwright:cli -- open http://127.0.0.1:5173/login
|
||||
mise exec -- npm run playwright:cli -- snapshot
|
||||
mise exec -- npm run playwright:cli -- console
|
||||
mise exec -- npm run playwright:cli -- requests
|
||||
mise exec -- npm run playwright:cli -- screenshot --filename=.artifacts/playwright-cli/login.png
|
||||
mise exec -- npm run playwright:cli -- close
|
||||
```
|
||||
|
||||
The wrapper changes to the repository root before invoking the stable `playwright cli` bundled with the locked test runner. This loads `.playwright/cli.config.json`, which selects bundled Chromium in headless, isolated, sandboxed mode and limits generated output under `.artifacts/playwright-cli/` to 100 MB.
|
||||
|
||||
Do not invoke `playwright-cli`, a global Playwright installation, or a floating `npx` package.
|
||||
|
||||
If the upstream CLI entry point changes, update only the `playwright:cli` npm script. Agents should follow the repository-scoped `$playwright-cli` Skill in `.agents/skills/playwright-cli/` for snapshots, sessions, network diagnosis, test debugging, and artifact handling.
|
||||
|
||||
## Checks
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
mise exec -- npm run check
|
||||
```
|
||||
|
||||
The production build is emitted to `dist/` as static assets. See `docs/web/roadmap.md` at the repository root for architecture boundaries and planned dependencies.
|
||||
The production build is emitted to `dist/` as static assets. Browser E2E tests are run separately because they require the browser setup above. See `docs/web/roadmap.md` at the repository root for architecture boundaries and planned dependencies.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('renders the login page in a real browser', async ({ page }) => {
|
||||
const response = await page.goto('/login')
|
||||
|
||||
expect(response?.ok()).toBe(true)
|
||||
await expect(page.getByRole('heading', { name: 'Sign in to MyGO' })).toBeVisible()
|
||||
await expect(page.getByLabel('Email')).toBeVisible()
|
||||
await expect(page.getByLabel('Password')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible()
|
||||
})
|
||||
Generated
+442
-1
@@ -8,6 +8,7 @@
|
||||
"name": "mygo-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"antd": "^6.5.1",
|
||||
"react": "^19.2.7",
|
||||
@@ -15,6 +16,7 @@
|
||||
"react-router": "^8.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.61.1",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
@@ -23,7 +25,8 @@
|
||||
"oxlint": "^1.71.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
"vite": "^8.1.1",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24 <25"
|
||||
@@ -579,6 +582,69 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/async-validator": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-6.0.0.tgz",
|
||||
@@ -1546,6 +1612,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||
@@ -1855,6 +1928,31 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz",
|
||||
@@ -1911,6 +2009,119 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.10",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/antd": {
|
||||
"version": "6.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/antd/-/antd-6.5.1.tgz",
|
||||
@@ -1974,6 +2185,26 @@
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1989,6 +2220,13 @@
|
||||
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-es": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/cookie-es/-/cookie-es-3.1.1.tgz",
|
||||
@@ -2031,6 +2269,33 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2386,6 +2651,20 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.74.0",
|
||||
"resolved": "https://registry.npmmirror.com/oxlint/-/oxlint-1.74.0.tgz",
|
||||
@@ -2435,6 +2714,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2581,6 +2867,13 @@
|
||||
"compute-scroll-into-view": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2591,6 +2884,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-convert": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz",
|
||||
@@ -2633,6 +2940,23 @@
|
||||
"node": ">=12.22"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -2650,6 +2974,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -2756,6 +3090,113 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -11,11 +11,17 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"playwright:install": "playwright install chromium",
|
||||
"playwright:install:deps": "playwright install-deps chromium",
|
||||
"playwright:cli": "cd .. && playwright cli",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc -b",
|
||||
"check": "npm run lint && npm run build",
|
||||
"check": "npm run lint && npm run test && npm run build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"antd": "^6.5.1",
|
||||
"react": "^19.2.7",
|
||||
@@ -23,6 +29,7 @@
|
||||
"react-router": "^8.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.61.1",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
@@ -31,6 +38,7 @@
|
||||
"oxlint": "^1.71.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
"vite": "^8.1.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
outputDir: `${repositoryRoot}/.artifacts/playwright/test-results`,
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['list'],
|
||||
[
|
||||
'html',
|
||||
{
|
||||
open: 'never',
|
||||
outputFolder: `${repositoryRoot}/.artifacts/playwright/report`,
|
||||
},
|
||||
],
|
||||
],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:5173',
|
||||
launchOptions: {
|
||||
chromiumSandbox: true,
|
||||
},
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev -- --host 127.0.0.1',
|
||||
url: 'http://127.0.0.1:5173/login',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiClient } from './client.ts'
|
||||
import type { TokenPair } from './types.ts'
|
||||
|
||||
export function login(email: string, password: string) {
|
||||
return apiClient.request<TokenPair>('/auth/login', {
|
||||
auth: false,
|
||||
method: 'POST',
|
||||
json: { email, password },
|
||||
})
|
||||
}
|
||||
|
||||
export function logout(refreshToken: string) {
|
||||
return apiClient.request<void>('/auth/logout', {
|
||||
auth: false,
|
||||
method: 'POST',
|
||||
json: { refresh_token: refreshToken },
|
||||
responseType: 'void',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createApiClient } from './client.ts'
|
||||
import { createSessionStore } from './session.ts'
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('API client', () => {
|
||||
it('adds the current access token to authenticated requests', async () => {
|
||||
const store = createSessionStore()
|
||||
store.set({ access_token: 'access-one', refresh_token: 'refresh-one' })
|
||||
const fetchImplementation = vi.fn<typeof fetch>()
|
||||
fetchImplementation.mockResolvedValue(jsonResponse({ ok: true }))
|
||||
const client = createApiClient({
|
||||
apiBase: '/test-api',
|
||||
fetchImplementation,
|
||||
store,
|
||||
})
|
||||
|
||||
await expect(client.request('/files')).resolves.toEqual({ ok: true })
|
||||
|
||||
const [, options] = fetchImplementation.mock.calls[0]
|
||||
expect(new Headers(options?.headers).get('Authorization')).toBe(
|
||||
'Bearer access-one',
|
||||
)
|
||||
})
|
||||
|
||||
it('shares one refresh across concurrent 401 responses and retries both requests', async () => {
|
||||
const store = createSessionStore()
|
||||
store.set({ access_token: 'expired-access', refresh_token: 'refresh-one' })
|
||||
|
||||
let resolveRefresh: ((response: Response) => void) | undefined
|
||||
const refreshResponse = new Promise<Response>((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})
|
||||
const fetchImplementation = vi.fn<typeof fetch>(async (input, options) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/auth/refresh')) {
|
||||
return refreshResponse
|
||||
}
|
||||
|
||||
const authorization = new Headers(options?.headers).get('Authorization')
|
||||
if (authorization === 'Bearer expired-access') {
|
||||
return jsonResponse({ error: { message: 'expired' } }, 401)
|
||||
}
|
||||
return jsonResponse({ url })
|
||||
})
|
||||
const client = createApiClient({ fetchImplementation, store })
|
||||
|
||||
const requests = Promise.all([
|
||||
client.request('/files?offset=0'),
|
||||
client.request('/files?offset=50'),
|
||||
])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
fetchImplementation.mock.calls.filter(([input]) =>
|
||||
String(input).endsWith('/auth/refresh'),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
resolveRefresh?.(
|
||||
jsonResponse({
|
||||
access_token: 'access-two',
|
||||
refresh_token: 'refresh-two',
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(requests).resolves.toHaveLength(2)
|
||||
expect(store.get()).toEqual({
|
||||
access_token: 'access-two',
|
||||
refresh_token: 'refresh-two',
|
||||
})
|
||||
expect(fetchImplementation).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('clears the session when refresh fails', async () => {
|
||||
const store = createSessionStore()
|
||||
store.set({ access_token: 'expired-access', refresh_token: 'expired-refresh' })
|
||||
const fetchImplementation = vi.fn<typeof fetch>()
|
||||
fetchImplementation
|
||||
.mockResolvedValueOnce(jsonResponse({ error: { message: 'expired' } }, 401))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ error: { message: 'invalid token', log_id: 'log-123' } },
|
||||
401,
|
||||
),
|
||||
)
|
||||
const client = createApiClient({ fetchImplementation, store })
|
||||
|
||||
const request = client.request('/files')
|
||||
|
||||
await expect(request).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: 'invalid token',
|
||||
logId: 'log-123',
|
||||
})
|
||||
expect(store.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the session when the retried request is still unauthorized', async () => {
|
||||
const store = createSessionStore()
|
||||
store.set({ access_token: 'expired-access', refresh_token: 'refresh-one' })
|
||||
const fetchImplementation = vi.fn<typeof fetch>()
|
||||
fetchImplementation
|
||||
.mockResolvedValueOnce(jsonResponse({ error: { message: 'expired' } }, 401))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
access_token: 'access-two',
|
||||
refresh_token: 'refresh-two',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ error: { message: 'denied' } }, 401))
|
||||
const client = createApiClient({ fetchImplementation, store })
|
||||
|
||||
await expect(client.request('/files')).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: 'denied',
|
||||
})
|
||||
expect(store.get()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { sessionStore, type SessionStore } from './session.ts'
|
||||
import type { ErrorResponse, TokenPair } from './types.ts'
|
||||
|
||||
const defaultApiBase = '/api/v1'
|
||||
|
||||
type ResponseType = 'blob' | 'json' | 'void'
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
auth?: boolean
|
||||
body?: BodyInit | null
|
||||
headers?: HeadersInit
|
||||
json?: unknown
|
||||
method?: string
|
||||
responseType?: ResponseType
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface ApiClientOptions {
|
||||
apiBase?: string
|
||||
fetchImplementation?: typeof fetch
|
||||
store?: SessionStore
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly logId?: string
|
||||
|
||||
constructor(status: number, message: string, logId?: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.logId = logId
|
||||
}
|
||||
}
|
||||
|
||||
async function responseError(response: Response): Promise<ApiError> {
|
||||
let body: ErrorResponse | undefined
|
||||
try {
|
||||
body = (await response.json()) as ErrorResponse
|
||||
} catch {
|
||||
// Non-JSON failures use a stable fallback message.
|
||||
}
|
||||
|
||||
const message =
|
||||
body?.error?.message ?? `Request failed with status ${response.status}`
|
||||
return new ApiError(response.status, message, body?.error?.log_id)
|
||||
}
|
||||
|
||||
async function readResponse<T>(
|
||||
response: Response,
|
||||
responseType: ResponseType,
|
||||
): Promise<T> {
|
||||
if (!response.ok) {
|
||||
throw await responseError(response)
|
||||
}
|
||||
|
||||
if (responseType === 'blob') {
|
||||
return (await response.blob()) as T
|
||||
}
|
||||
if (responseType === 'void' || response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export function createApiClient({
|
||||
apiBase = defaultApiBase,
|
||||
fetchImplementation = fetch,
|
||||
store = sessionStore,
|
||||
}: ApiClientOptions = {}) {
|
||||
let refreshPromise: Promise<TokenPair> | null = null
|
||||
|
||||
const refreshSession = (): Promise<TokenPair> => {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
const refreshToken = store.get()?.refresh_token
|
||||
if (!refreshToken) {
|
||||
store.clear()
|
||||
return Promise.reject(new ApiError(401, 'Your session has expired'))
|
||||
}
|
||||
|
||||
refreshPromise = fetchImplementation(`${apiBase}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
.then((response) => readResponse<TokenPair>(response, 'json'))
|
||||
.then((session) => {
|
||||
store.set(session)
|
||||
return session
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
store.clear()
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
const request = async <T>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> => {
|
||||
const {
|
||||
auth = true,
|
||||
body,
|
||||
headers: providedHeaders,
|
||||
json,
|
||||
method = 'GET',
|
||||
responseType = 'json',
|
||||
signal,
|
||||
} = options
|
||||
|
||||
const run = (accessToken?: string) => {
|
||||
const headers = new Headers(providedHeaders)
|
||||
let requestBody = body
|
||||
|
||||
if (json !== undefined) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
requestBody = JSON.stringify(json)
|
||||
}
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
return fetchImplementation(`${apiBase}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: requestBody,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
const accessToken = auth ? store.get()?.access_token : undefined
|
||||
if (auth && !accessToken) {
|
||||
throw new ApiError(401, 'Authentication required')
|
||||
}
|
||||
|
||||
let response = await run(accessToken)
|
||||
if (auth && response.status === 401) {
|
||||
const refreshedSession = await refreshSession()
|
||||
response = await run(refreshedSession.access_token)
|
||||
if (response.status === 401) {
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
|
||||
return readResponse<T>(response, responseType)
|
||||
}
|
||||
|
||||
return { request }
|
||||
}
|
||||
|
||||
export const apiClient = createApiClient()
|
||||
@@ -0,0 +1,27 @@
|
||||
import { apiClient } from './client.ts'
|
||||
import type { FileInfo, FileListResponse } from './types.ts'
|
||||
|
||||
export function listRootFiles(offset: number, limit: number, signal?: AbortSignal) {
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
})
|
||||
return apiClient.request<FileListResponse>(`/files?${params}`, { signal })
|
||||
}
|
||||
|
||||
export function uploadRootFile(file: File) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
|
||||
return apiClient.request<FileInfo>('/files', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function downloadFile(fileID: string) {
|
||||
return apiClient.request<Blob>(
|
||||
`/files/${encodeURIComponent(fileID)}/content`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createSessionStore } from './session.ts'
|
||||
import type { TokenPair } from './types.ts'
|
||||
|
||||
class MemoryStorage {
|
||||
private readonly values = new Map<string, string>()
|
||||
|
||||
getItem(key: string) {
|
||||
return this.values.get(key) ?? null
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.values.delete(key)
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.values.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const session: TokenPair = {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
}
|
||||
|
||||
describe('session store', () => {
|
||||
it('persists and reloads a valid token pair', () => {
|
||||
const storage = new MemoryStorage()
|
||||
const store = createSessionStore(storage)
|
||||
|
||||
store.set(session)
|
||||
|
||||
expect(store.get()).toEqual(session)
|
||||
expect(createSessionStore(storage).get()).toEqual(session)
|
||||
})
|
||||
|
||||
it('notifies subscribers when the session changes', () => {
|
||||
const store = createSessionStore(new MemoryStorage())
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = store.subscribe(listener)
|
||||
|
||||
store.set(session)
|
||||
store.clear()
|
||||
unsubscribe()
|
||||
store.set(session)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('discards malformed browser state', () => {
|
||||
const storage = new MemoryStorage()
|
||||
storage.setItem('mygo.auth.session', '{"access_token":true}')
|
||||
|
||||
const store = createSessionStore(storage)
|
||||
|
||||
expect(store.get()).toBeNull()
|
||||
expect(storage.getItem('mygo.auth.session')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { TokenPair } from './types.ts'
|
||||
|
||||
const sessionStorageKey = 'mygo.auth.session'
|
||||
|
||||
type SessionListener = () => void
|
||||
type SessionStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>
|
||||
|
||||
export interface SessionStore {
|
||||
clear: () => void
|
||||
get: () => TokenPair | null
|
||||
set: (session: TokenPair) => void
|
||||
subscribe: (listener: SessionListener) => () => void
|
||||
}
|
||||
|
||||
function isTokenPair(value: unknown): value is TokenPair {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = value as Partial<TokenPair>
|
||||
return (
|
||||
typeof candidate.access_token === 'string' &&
|
||||
candidate.access_token.length > 0 &&
|
||||
typeof candidate.refresh_token === 'string' &&
|
||||
candidate.refresh_token.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function readStoredSession(storage?: SessionStorage): TokenPair | null {
|
||||
if (!storage) {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = storage.getItem(sessionStorageKey)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (isTokenPair(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
// Invalid browser state is treated as a signed-out session.
|
||||
}
|
||||
|
||||
storage.removeItem(sessionStorageKey)
|
||||
return null
|
||||
}
|
||||
|
||||
export function createSessionStore(storage?: SessionStorage): SessionStore {
|
||||
let currentSession = readStoredSession(storage)
|
||||
const listeners = new Set<SessionListener>()
|
||||
|
||||
const notify = () => {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: () => currentSession,
|
||||
set: (session) => {
|
||||
currentSession = session
|
||||
storage?.setItem(sessionStorageKey, JSON.stringify(session))
|
||||
notify()
|
||||
},
|
||||
clear: () => {
|
||||
if (currentSession === null && !storage?.getItem(sessionStorageKey)) {
|
||||
return
|
||||
}
|
||||
currentSession = null
|
||||
storage?.removeItem(sessionStorageKey)
|
||||
notify()
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const browserStorage =
|
||||
typeof window === 'undefined' ? undefined : window.sessionStorage
|
||||
|
||||
export const sessionStore = createSessionStore(browserStorage)
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface TokenPair {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: {
|
||||
message: string
|
||||
log_id?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
id: string
|
||||
user_id: string
|
||||
parent_id: string | null
|
||||
name: string
|
||||
size: number
|
||||
mime_type: string
|
||||
is_dir: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
files: FileInfo[]
|
||||
total: number
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router'
|
||||
|
||||
import { useAuthSession } from '../features/auth/useAuthSession.ts'
|
||||
|
||||
export function RequireAuth() {
|
||||
const session = useAuthSession()
|
||||
const location = useLocation()
|
||||
|
||||
if (!session) {
|
||||
const from = `${location.pathname}${location.search}${location.hash}`
|
||||
return <Navigate to="/login" replace state={{ from }} />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -1,14 +1,60 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { App as AntDesignApp, ConfigProvider } from 'antd'
|
||||
import { useEffect, useRef, type PropsWithChildren } from 'react'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
import { ApiError } from '../api/client.ts'
|
||||
import { useAuthSession } from '../features/auth/useAuthSession.ts'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status < 500) {
|
||||
return false
|
||||
}
|
||||
return failureCount < 2
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function AuthCacheBoundary({ children }: PropsWithChildren) {
|
||||
const session = useAuthSession()
|
||||
const activeQueryClient = useQueryClient()
|
||||
const previousSession = useRef(session)
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSession.current && !session) {
|
||||
activeQueryClient.clear()
|
||||
}
|
||||
previousSession.current = session
|
||||
}, [activeQueryClient, session])
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider>
|
||||
<AntDesignApp>{children}</AntDesignApp>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
borderRadius: 8,
|
||||
colorBgLayout: '#f5f7fb',
|
||||
colorPrimary: '#2563eb',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntDesignApp>
|
||||
<AuthCacheBoundary>{children}</AuthCacheBoundary>
|
||||
</AntDesignApp>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
+19
-2
@@ -1,12 +1,29 @@
|
||||
import { createBrowserRouter } from 'react-router'
|
||||
|
||||
import { HomePage } from '../pages/HomePage.tsx'
|
||||
import { AppShell } from '../components/AppShell.tsx'
|
||||
import { FilesPage } from '../pages/FilesPage.tsx'
|
||||
import { LoginPage } from '../pages/LoginPage.tsx'
|
||||
import { NotFoundPage } from '../pages/NotFoundPage.tsx'
|
||||
import { RequireAuth } from './RequireAuth.tsx'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: <HomePage />,
|
||||
element: <FilesPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
CloudOutlined,
|
||||
FolderOpenOutlined,
|
||||
LogoutOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { App, Button, Layout, Menu, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { logout } from '../api/auth.ts'
|
||||
import { ApiError } from '../api/client.ts'
|
||||
import { sessionStore } from '../api/session.ts'
|
||||
|
||||
const { Content, Header } = Layout
|
||||
|
||||
export function AppShell() {
|
||||
const { message } = App.useApp()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
const refreshToken = sessionStore.get()?.refresh_token
|
||||
setIsLoggingOut(true)
|
||||
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await logout(refreshToken)
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof ApiError ? error.message : 'Server unavailable'
|
||||
message.warning(`Signed out locally. ${detail}`)
|
||||
} finally {
|
||||
sessionStore.clear()
|
||||
queryClient.clear()
|
||||
navigate('/login', { replace: true })
|
||||
setIsLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="min-h-screen">
|
||||
<Header className="flex items-center gap-4 px-4 sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-white"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<CloudOutlined className="text-xl" />
|
||||
<Typography.Text className="hidden text-base font-semibold text-white sm:inline">
|
||||
MyGO
|
||||
</Typography.Text>
|
||||
</button>
|
||||
<Menu
|
||||
className="min-w-0 flex-1"
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
selectedKeys={location.pathname === '/' ? ['files'] : []}
|
||||
items={[
|
||||
{
|
||||
key: 'files',
|
||||
icon: <FolderOpenOutlined />,
|
||||
label: 'Files',
|
||||
onClick: () => navigate('/'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
icon={<LogoutOutlined />}
|
||||
loading={isLoggingOut}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<span className="hidden sm:inline">Log out</span>
|
||||
</Button>
|
||||
</Header>
|
||||
<Content className="p-4 sm:p-6">
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { sessionStore } from '../../api/session.ts'
|
||||
|
||||
export function useAuthSession() {
|
||||
return useSyncExternalStore(
|
||||
sessionStore.subscribe,
|
||||
sessionStore.get,
|
||||
sessionStore.get,
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -5,9 +5,10 @@ html,
|
||||
body,
|
||||
#root {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
DownloadOutlined,
|
||||
FileOutlined,
|
||||
FolderFilled,
|
||||
ReloadOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import { useRef, useState, type ChangeEvent } from 'react'
|
||||
|
||||
import { ApiError } from '../api/client.ts'
|
||||
import { downloadFile, listRootFiles, uploadRootFile } from '../api/files.ts'
|
||||
import type { FileInfo } from '../api/types.ts'
|
||||
|
||||
const pageSize = 50
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes === 0) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
const unitIndex = Math.min(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
)
|
||||
const value = bytes / 1024 ** unitIndex
|
||||
return `${value.toFixed(unitIndex === 0 || value >= 10 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof ApiError ? error.message : 'Unable to reach the server'
|
||||
}
|
||||
|
||||
export function FilesPage() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [downloadingID, setDownloadingID] = useState<string | null>(null)
|
||||
|
||||
const filesQuery = useQuery({
|
||||
queryKey: ['files', 'root', page],
|
||||
queryFn: ({ signal }) => listRootFiles((page - 1) * pageSize, pageSize, signal),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: uploadRootFile,
|
||||
onSuccess: async (file) => {
|
||||
message.success(`${file.name} uploaded`)
|
||||
setPage(1)
|
||||
await queryClient.invalidateQueries({ queryKey: ['files', 'root'] })
|
||||
},
|
||||
onError: (error) => message.error(errorMessage(error)),
|
||||
})
|
||||
|
||||
const handleFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (file && !uploadMutation.isPending) {
|
||||
uploadMutation.mutate(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (file: FileInfo) => {
|
||||
setDownloadingID(file.id)
|
||||
try {
|
||||
const blob = await downloadFile(file.id)
|
||||
const objectURL = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = objectURL
|
||||
anchor.download = file.name
|
||||
document.body.append(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectURL), 0)
|
||||
} catch (error) {
|
||||
message.error(errorMessage(error))
|
||||
} finally {
|
||||
setDownloadingID(null)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<FileInfo> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string, file) => (
|
||||
<Space>
|
||||
{file.is_dir ? (
|
||||
<FolderFilled className="text-amber-500" />
|
||||
) : (
|
||||
<FileOutlined className="text-blue-600" />
|
||||
)}
|
||||
<Typography.Text ellipsis={{ tooltip: name }}>{name}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
key: 'type',
|
||||
width: 220,
|
||||
responsive: ['md'],
|
||||
render: (_, file) =>
|
||||
file.is_dir ? <Tag color="gold">Folder</Tag> : file.mime_type || 'File',
|
||||
},
|
||||
{
|
||||
title: 'Size',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 120,
|
||||
responsive: ['sm'],
|
||||
render: (size: number, file) => (file.is_dir ? '—' : formatBytes(size)),
|
||||
},
|
||||
{
|
||||
title: 'Modified',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 190,
|
||||
responsive: ['lg'],
|
||||
render: formatDate,
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
key: 'action',
|
||||
align: 'right',
|
||||
width: 120,
|
||||
render: (_, file) =>
|
||||
file.is_dir ? (
|
||||
<Typography.Text type="secondary">—</Typography.Text>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={downloadingID === file.id}
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (filesQuery.isError && !filesQuery.data) {
|
||||
return (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="Unable to load files"
|
||||
description={errorMessage(filesQuery.error)}
|
||||
action={
|
||||
<Button icon={<ReloadOutlined />} onClick={() => filesQuery.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-5 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<Typography.Title level={2} className="mb-1!">
|
||||
Files
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
Files stored in your root directory.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
className="hidden"
|
||||
type="file"
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
Upload file
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table<FileInfo>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={filesQuery.data?.files ?? []}
|
||||
loading={filesQuery.isLoading || filesQuery.isFetching}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="No files in your root directory"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
Upload your first file
|
||||
</Button>
|
||||
</Empty>
|
||||
),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: filesQuery.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `${total} item${total === 1 ? '' : 's'}`,
|
||||
onChange: setPage,
|
||||
}}
|
||||
scroll={{ x: 640 }}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Card, Space, Tag, Typography } from 'antd'
|
||||
|
||||
const { Paragraph, Title } = Typography
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-50 p-6">
|
||||
<Card className="w-full max-w-xl">
|
||||
<Space orientation="vertical" size="middle">
|
||||
<Tag color="blue">MyGO Web</Tag>
|
||||
<Title level={1}>MyGO</Title>
|
||||
<Paragraph>
|
||||
The client-side web application foundation is ready.
|
||||
</Paragraph>
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { CloudOutlined, LockOutlined, MailOutlined } from '@ant-design/icons'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Alert, Button, Card, Form, Input, Space, Typography } from 'antd'
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { login } from '../api/auth.ts'
|
||||
import { ApiError } from '../api/client.ts'
|
||||
import { sessionStore } from '../api/session.ts'
|
||||
import { useAuthSession } from '../features/auth/useAuthSession.ts'
|
||||
|
||||
interface LoginValues {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface LoginLocationState {
|
||||
from?: unknown
|
||||
}
|
||||
|
||||
function destinationFromState(state: unknown) {
|
||||
const from = (state as LoginLocationState | null)?.from
|
||||
return typeof from === 'string' && from.startsWith('/') ? from : '/'
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const session = useAuthSession()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const destination = destinationFromState(location.state)
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: ({ email, password }: LoginValues) => login(email, password),
|
||||
onSuccess: (tokenPair) => {
|
||||
sessionStore.set(tokenPair)
|
||||
navigate(destination, { replace: true })
|
||||
},
|
||||
})
|
||||
|
||||
if (session) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
const errorMessage = loginMutation.error
|
||||
? loginMutation.error instanceof ApiError
|
||||
? loginMutation.error.message
|
||||
: 'Unable to reach the server'
|
||||
: null
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-100 p-4">
|
||||
<Card className="w-full max-w-md shadow-sm">
|
||||
<Space orientation="vertical" size="large" className="w-full">
|
||||
<div className="text-center">
|
||||
<CloudOutlined className="mb-3 text-4xl text-blue-600" />
|
||||
<Typography.Title level={1} className="mb-1!">
|
||||
Sign in to MyGO
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
Access your files from this browser session.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert type="error" showIcon message="Sign-in failed" description={errorMessage} />
|
||||
)}
|
||||
|
||||
<Form<LoginValues>
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={(values) => loginMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: 'Enter your email address' },
|
||||
{ type: 'email', message: 'Enter a valid email address' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="you@example.com"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Password"
|
||||
name="password"
|
||||
rules={[{ required: true, message: 'Enter your password' }]}
|
||||
>
|
||||
<Input.Password
|
||||
autoComplete="current-password"
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="Password"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
block
|
||||
htmlType="submit"
|
||||
loading={loginMutation.isPending}
|
||||
size="large"
|
||||
type="primary"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -4,4 +4,9 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:10086',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user