Compare commits
23 Commits
5eeb37389b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 519aa35f1f | |||
| f8494a44ca | |||
| a18f96912d | |||
| 6604ecb026 | |||
| bb86950632 | |||
| 52dd56ff06 | |||
| 29b176d2db | |||
| 04eb8727eb | |||
| 99e5758d4c | |||
| 159bb27493 | |||
| ceaeafd96c | |||
| 9f13c0a23a | |||
| b49bf648be | |||
| a62a5bc0e2 | |||
| 5674fa2eb5 | |||
| 803f195af1 | |||
| 3ad61244dc | |||
| 63ede5c237 | |||
| 28e17a5b08 | |||
| fc2b9312fa | |||
| b988b4b15e | |||
| bfeb4b26e4 | |||
| 17e346836c |
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: mygo-api-repo-review
|
||||||
|
description: Review the MyGO backend repository for material, evidence-backed architecture, security, resilience, data-consistency, performance, and implementation flaws. Use only when explicitly asked to run a MyGO repository audit in diff, main, or full mode, optionally limited to a target path, package, or component; do not use for ordinary implementation or debugging tasks.
|
||||||
|
---
|
||||||
|
|
||||||
|
# MyGO API Repository Review
|
||||||
|
|
||||||
|
Perform a read-only, evidence-first review of the MyGO backend. Find material defects and systemic risks without turning unfinished product scope, stylistic preferences, or unsupported hypotheticals into findings.
|
||||||
|
|
||||||
|
## Resolve the review scope
|
||||||
|
|
||||||
|
Resolve two independent inputs: `mode` and `target`.
|
||||||
|
|
||||||
|
- Default `mode` to `diff` when the user does not provide one.
|
||||||
|
- Default `target` to `all`.
|
||||||
|
- Treat `partly` as a request for a limited `target`, not as a fourth mode. Require or safely infer the path, Go package, or logical component to inspect.
|
||||||
|
|
||||||
|
Use these mode semantics:
|
||||||
|
|
||||||
|
- `diff`: compare the current workspace with `HEAD`. Include staged changes, unstaged changes, and relevant untracked files.
|
||||||
|
- `main`: compare the current workspace with the merge base of `HEAD` and the local `main` branch. Record the local `main` and merge-base commit IDs. Do not fetch or silently substitute a remote ref. If local `main` is unavailable, report the limitation.
|
||||||
|
- `full`: inspect the current repository as a whole, including tracked code, tests, configuration, and documentation plus relevant untracked source files. Exclude repository metadata, caches, runtime data, build outputs, and generated artifacts unless they are directly relevant.
|
||||||
|
|
||||||
|
For a limited target, inspect not only the named files but also the directly affected callers, dependencies, interfaces, tests, configuration, and documentation needed to judge the target correctly.
|
||||||
|
|
||||||
|
## Preserve the workspace
|
||||||
|
|
||||||
|
Keep the review read-only unless the user separately asks for fixes.
|
||||||
|
|
||||||
|
1. Capture the initial branch, `HEAD`, and complete Git status, including untracked files.
|
||||||
|
2. Do not run commands intended to rewrite source or dependency files, including `go fmt ./...` and `go mod tidy`.
|
||||||
|
3. Do not fetch, pull, commit, stage, or discard changes.
|
||||||
|
4. Capture Git status again before reporting. If the workspace changed, identify the change and do not erase it.
|
||||||
|
|
||||||
|
## Review the implementation
|
||||||
|
|
||||||
|
Read `AGENTS.md`, `docs/roadmap.md`, `docs/architecture.md`, `docs/decisions.md`, and `docs/development.md` before judging the implementation. Treat documented decisions as intent, not as proof that their implementation is correct.
|
||||||
|
|
||||||
|
Apply production-grade correctness, security, resilience, and architecture standards to behavior and components that already exist, including implemented paths within WIP features. Do not interpret that standard as requiring every capability of the finished product to exist now.
|
||||||
|
|
||||||
|
1. Map the reviewed packages, dependency direction, entry points, state owners, external resources, and lifecycle operations. Use `rg` and targeted reads; do not read `go.sum` in full.
|
||||||
|
2. Reconstruct behavior, boundaries, invariants, state transitions, and trust assumptions from code, tests, configuration, and documentation.
|
||||||
|
3. Trace important control, data, ownership, resource, concurrency, and failure flows without starting from a predetermined bug list.
|
||||||
|
4. Use the lenses below for minimum coverage, then challenge every candidate before reporting it.
|
||||||
|
|
||||||
|
Treat the lenses as perspectives, not an exhaustive checklist, taxonomy, or finding quota:
|
||||||
|
|
||||||
|
- **Architecture and evolution:** Evaluate whether responsibilities, dependencies, boundaries, data flows, or resource use materially obstruct supported behavior, scaling, committed evolution, extensibility, decoupling, customization, or maintenance.
|
||||||
|
- **Security:** Evaluate whether trust, identity, authorization, ownership, confidentiality, integrity, availability, or exposure assumptions can be violated through a reachable execution path or component interaction.
|
||||||
|
- **Resilience:** Evaluate whether plausible partial failures, dependency failures, cancellation, concurrency, malformed inputs, or interrupted lifecycle operations can cause disproportionate impact, cascading failure, loss of service, or an unrecoverable state.
|
||||||
|
- **Data consistency:** Evaluate whether persistent state, external resources, ownership, and lifecycle transitions preserve required invariants across normal, concurrent, retried, interrupted, and partially failed execution paths.
|
||||||
|
|
||||||
|
Continue searching for material problems outside these lenses. Do not manufacture a finding for every lens.
|
||||||
|
|
||||||
|
## Use parallel review selectively
|
||||||
|
|
||||||
|
Keep small `diff` and narrowly targeted reviews in the main agent by default. For a broad `main` or `full` review, use read-only subagents when they are available and parallel review materially improves coverage.
|
||||||
|
|
||||||
|
Give every reviewer the complete, non-exhaustive review objective and one primary emphasis such as architecture and performance, security, or resilience and data consistency. Make the emphasis a starting perspective rather than an exclusive boundary. Wait for all reviewers, then independently verify, deduplicate, and rank their candidates in the main agent.
|
||||||
|
|
||||||
|
## Verify and report
|
||||||
|
|
||||||
|
Run the repository-wide checks below unless the user explicitly limits command execution or the environment prevents them:
|
||||||
|
|
||||||
|
- `go build ./...`
|
||||||
|
- `go vet ./...`
|
||||||
|
- `go test ./...`
|
||||||
|
- a non-mutating `gofmt -l` check over tracked Go files
|
||||||
|
|
||||||
|
Record each check and its result. When a test fails, inspect the test first and follow the debugging principles in `AGENTS.md`. Do not convert a command failure into an architecture finding without validating its cause.
|
||||||
|
|
||||||
|
After open-ended discovery, read [review-calibration.md](references/review-calibration.md) and apply its admission and falsification rules. Then read [report-template.md](references/report-template.md) and follow it exactly. If no candidate qualifies, state `Findings: None` rather than lowering the threshold.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "MyGO API Repository Review"
|
||||||
|
short_description: "Evidence-based MyGO backend repository review"
|
||||||
|
default_prompt: "Use $mygo-api-repo-review to review the current working tree against HEAD."
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: false
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Review Report Template
|
||||||
|
|
||||||
|
Write the report in the language requested by the user; otherwise match the language of the review request. Preserve source identifiers, commands, file paths, and code symbols exactly.
|
||||||
|
|
||||||
|
Use category-based finding IDs:
|
||||||
|
|
||||||
|
- `ARCH`: architecture and evolution
|
||||||
|
- `SEC`: security
|
||||||
|
- `REL`: resilience
|
||||||
|
- `DATA`: data consistency
|
||||||
|
- `PERF`: performance and scaling
|
||||||
|
- `IMPL`: implementation correctness
|
||||||
|
|
||||||
|
Number IDs within the report, for example `SEC-001`. Assign one primary category to each root cause.
|
||||||
|
|
||||||
|
Use these severity levels independently of category:
|
||||||
|
|
||||||
|
- `Critical`: a reachable issue with catastrophic impact, such as broad authorization compromise, widespread sensitive-data exposure, irreversible systemic data loss, or reliable total-service failure.
|
||||||
|
- `High`: a realistic issue causing material authorization or ownership bypass, user-data exposure or corruption, service-wide outage, or a fundamental design failure that blocks supported behavior.
|
||||||
|
- `Medium`: a material but contained correctness, consistency, resilience, scalability, or maintainability failure with a plausible trigger.
|
||||||
|
- `Low`: a verified, bounded defect with limited impact. Do not use `Low` for style preferences or optional improvements.
|
||||||
|
|
||||||
|
Sort findings by severity, then by impact within the same severity. Security is a category, not a severity; a security finding can have any severity.
|
||||||
|
|
||||||
|
Make each `Analysis` identify the existing behavior, boundary, or invariant under review, the evidence and reachable violation path, and the material impact. Do not use any report section as a backlog for absent WIP, planned, or future capabilities.
|
||||||
|
|
||||||
|
Use this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Summary
|
||||||
|
- Mode: diff | main | full
|
||||||
|
- Target: all | <path/package/component>
|
||||||
|
- Baseline: <commit or none>
|
||||||
|
- Reviewed snapshot: <HEAD and workspace state>
|
||||||
|
- Verification: <build, vet, test, and format-check results>
|
||||||
|
|
||||||
|
- Findings
|
||||||
|
- ID: <CATEGORY-NNN>
|
||||||
|
- Severity: Critical | High | Medium | Low
|
||||||
|
- Location: <filename:line>
|
||||||
|
- Analysis: <existing behavior or invariant, evidence and reachable violation path, and material impact>
|
||||||
|
- Recommendation: <root-cause repair direction>
|
||||||
|
|
||||||
|
- ID: <CATEGORY-NNN>
|
||||||
|
- <same as above>
|
||||||
|
|
||||||
|
- Architecture drift
|
||||||
|
- <material current differences between documented and implemented architecture, referencing finding IDs instead of duplicating analysis>
|
||||||
|
|
||||||
|
- Documentation drift
|
||||||
|
- <documentation inaccuracies that can materially mislead implementation, operation, or review>
|
||||||
|
|
||||||
|
- Unverified areas
|
||||||
|
- <near-qualifying current defect, missing fact, and the specific validation needed>
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are no qualifying findings, write `Findings: None`. Write `None` for any other empty section. Do not omit verification failures or review limitations.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Review Calibration
|
||||||
|
|
||||||
|
Use this reference only after open-ended discovery. Apply it to decide which candidates deserve a place in the report.
|
||||||
|
|
||||||
|
## Admit a finding
|
||||||
|
|
||||||
|
Accept a candidate only when all of these are present:
|
||||||
|
|
||||||
|
- An existing implemented behavior, boundary, or intrinsic invariant, including code already exposed within a WIP feature. An absent part of a WIP, planned, or future capability is not itself a basis.
|
||||||
|
- Concrete evidence that current code violates that behavior or invariant through a reachable, plausible path.
|
||||||
|
- A material consequence under production-grade operation of the existing capability, without requiring the finished product's full feature set.
|
||||||
|
- A precise source location and a recommendation that primarily corrects existing behavior or structure rather than delivers a missing product capability.
|
||||||
|
|
||||||
|
Ask: **Does the repair mainly correct existing behavior, or design and deliver a capability that does not yet exist?** The amount of code required is not decisive; the repair's purpose is.
|
||||||
|
|
||||||
|
Seek the strongest disconfirming evidence. Check other layers, tests, supported scope, containment, and intentional tradeoffs before accepting a candidate.
|
||||||
|
|
||||||
|
## Apply the boundary
|
||||||
|
|
||||||
|
- A missing safeguard qualifies only when it is intrinsic to an existing interface or mechanism and its absence creates a demonstrated failure. Do not turn expected product policies or lifecycle subsystems into defects merely because a mature product will need them.
|
||||||
|
- For configuration, distinguish enforceable technical validity from operator judgment. Report values that violate an implemented mechanism's objective preconditions or are silently misapplied; do not claim the program can prove secret entropy or prevent every poor but technically valid policy choice.
|
||||||
|
- Report architecture or evolution risk only when current design causes concrete coupling, boundary leakage, unsafe change propagation, or material rework for a committed direction. A missing future subsystem is not an architecture flaw.
|
||||||
|
- Require a reachable hot path, resource-growth mechanism, and material impact for performance findings. Do not report style, naming, speculative scale, or optional preference as a defect.
|
||||||
|
- Keep one primary violated invariant and root cause per finding. Do not combine independent missing defenses to raise severity; assign one category and describe only supported secondary impacts.
|
||||||
|
- Preserve valid tests and intended behavior. Never weaken a test to dismiss an implementation failure.
|
||||||
|
|
||||||
|
Use `Unverified areas` only for a near-qualifying current defect that a specific factual check can resolve. Omit undecided product policy, future capability, and general hardening ideas rather than using this section as a backlog.
|
||||||
@@ -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
|
||||||
mise.local.toml
|
mise.local.toml
|
||||||
|
|
||||||
|
# Project-local development caches and browser artifacts
|
||||||
|
/.cache/
|
||||||
|
/.artifacts/
|
||||||
|
|
||||||
# MyGO configuration
|
# MyGO configuration
|
||||||
# Tracking config.example.yaml in version control
|
# Tracking config.example.yaml in version control
|
||||||
config.yaml
|
config.yaml
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"browser": {
|
||||||
|
"browserName": "chromium",
|
||||||
|
"isolated": true,
|
||||||
|
"launchOptions": {
|
||||||
|
"channel": "chromium",
|
||||||
|
"headless": true,
|
||||||
|
"chromiumSandbox": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputDir": ".artifacts/playwright-cli",
|
||||||
|
"outputMaxSize": 104857600
|
||||||
|
}
|
||||||
@@ -1,66 +1,141 @@
|
|||||||
# AGENTS.md — MyGO Backend
|
# AGENTS.md — MyGO
|
||||||
|
|
||||||
## Project Essentials
|
## Project Layout
|
||||||
|
|
||||||
- Module: `github.com/dhao2001/mygo`, Go 1.26.2
|
- Server: Go backend in the repository root
|
||||||
- WebDisk (cloud drive) backend; roadmap in `docs/roadmap.md`
|
- Web: React client in `web/`
|
||||||
- CLI framework: `github.com/spf13/cobra`
|
- Documentation index: `docs/README.md`
|
||||||
- Go version pinned in `mise.toml`
|
|
||||||
|
|
||||||
## Agent Workflow
|
## Agent Workflow
|
||||||
|
|
||||||
1. Read the task
|
1. Identify the affected project and required outcome.
|
||||||
2. Read relevant `docs/` files for context
|
2. Read `docs/README.md` and the relevant project documents.
|
||||||
3. Explore existing code before writing new code
|
3. Explore the existing code before making changes.
|
||||||
4. Implement following the conventions below
|
4. Follow the project conventions below.
|
||||||
5. Verify: `go vet ./... && go test ./...`
|
5. Run the verification commands for every affected project.
|
||||||
6. Update `docs/roadmap.md`, `docs/decisions.md`, or `docs/architecture.md` if anything changed
|
6. Update documentation when architecture, workflow, decisions, or feature status changes.
|
||||||
|
|
||||||
## Go Conventions
|
## Documentation Routing
|
||||||
|
|
||||||
- **Format**: `go fmt ./...` before every commit
|
| Change | Read | Update |
|
||||||
- **Imports**: stdlib / third-party / internal, blank-line separated
|
|--------|------|--------|
|
||||||
- **Errors**: wrap with `fmt.Errorf("context: %w", err)`
|
| Server packages or boundaries | `docs/server/architecture.md` | When the current architecture changes |
|
||||||
- **Context**: first param in I/O, storage, lifecycle funcs
|
| Server design or dependency decision | `docs/server/decisions.md` | When a decision is accepted or superseded |
|
||||||
- **Exported names**: doc-commented
|
| Server build, test, review, or config workflow | `docs/server/development.md` | When the workflow changes |
|
||||||
- **`init()`**: only in cobra cmd files for flag registration
|
| Server feature scope or status | `docs/server/roadmap.md` | When capability status changes |
|
||||||
- **`cmd/`** is thin; business logic goes in `internal/`
|
| Web design or tooling decision | `docs/web/decisions.md` | When a decision is accepted or superseded |
|
||||||
|
| Web feature scope or status | `docs/web/roadmap.md` | When capability status changes |
|
||||||
|
| Repository documentation | `docs/writing-guide.md` | When documentation policy changes |
|
||||||
|
|
||||||
## Documentation
|
Use the relevant decision index before making a significant architecture or dependency decision. Read only the ADRs relevant to the change.
|
||||||
|
|
||||||
| File | Read Before | Update After |
|
## Server — Go Backend
|
||||||
|------|-------------|--------------|
|
|
||||||
| `docs/architecture.md` | Adding new packages | Adding new packages |
|
|
||||||
| `docs/decisions.md` | Making technical decisions | Making technical decisions |
|
|
||||||
| `docs/roadmap.md` | Every task | Completing a feature |
|
|
||||||
| `docs/development.md` | Build/test/debug setup | Changing workflow |
|
|
||||||
|
|
||||||
## Commands
|
### Essentials
|
||||||
|
|
||||||
|
- Module: `github.com/dhao2001/mygo`
|
||||||
|
- Go: 1.26.2, pinned in `mise.toml`
|
||||||
|
- CLI: Cobra
|
||||||
|
- Application code: `internal/`
|
||||||
|
- CLI composition: `cmd/`
|
||||||
|
|
||||||
|
### Go Conventions
|
||||||
|
|
||||||
|
- Separate imports into standard library, third-party, and internal groups.
|
||||||
|
- Wrap errors with operation context: `fmt.Errorf("operation: %w", err)`.
|
||||||
|
- Pass `context.Context` first in I/O, storage, and lifecycle functions.
|
||||||
|
- Add doc comments to exported names.
|
||||||
|
- Keep business logic in `internal/`; keep `cmd/` focused on CLI composition and startup.
|
||||||
|
- Add required Go module dependencies before writing code that imports them.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build ./... # build all packages
|
go fmt ./...
|
||||||
go test ./... # all tests
|
go build ./...
|
||||||
go vet ./... # static analysis
|
go test ./...
|
||||||
go fmt ./... # format
|
go vet ./...
|
||||||
go mod tidy # clean deps after add/remove
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## DO / DON'T
|
Run `go mod tidy` after adding or removing imports.
|
||||||
|
|
||||||
- DO put business logic in `internal/`, keep `cmd/` thin
|
### Repository Review
|
||||||
- DO write all code, comments, and documentation in English
|
|
||||||
- DO add all Go module dependencies **before** writing code that uses them
|
Invoke `$mygo-api-repo-review` for repository-wide or scoped architecture, design, security, resilience, data-consistency, performance, and implementation audits.
|
||||||
- DON'T read `go.sum` entirely into context — use `grep` or other tools to search specific patterns if needed
|
|
||||||
- DON'T skip `go vet ./...` before finishing work
|
- `diff`: compare the workspace, including relevant untracked files, with `HEAD`.
|
||||||
- DON'T commit without explicit user request
|
- `main`: compare the workspace with the merge base of `HEAD` and local `main`.
|
||||||
- DON'T add, remove, or change Go module dependencies after debugging has started — ask for explicit permission first
|
- `full`: review the complete repository.
|
||||||
|
|
||||||
|
Reviews are read-only unless the user also requests fixes. A mode can target a path, Go package, or logical component.
|
||||||
|
|
||||||
|
### Server Constraints
|
||||||
|
|
||||||
|
- Do not load the complete `go.sum` file into context. Use `rg` to find the required module or version entry.
|
||||||
|
- Dependency changes made after debugging begins require explicit user approval.
|
||||||
|
- Complete `go vet ./...` before finishing server code changes.
|
||||||
|
|
||||||
|
## Web — React Client
|
||||||
|
|
||||||
|
### Essentials
|
||||||
|
|
||||||
|
- Source: `web/`
|
||||||
|
- Node.js: 24, pinned in `mise.toml`
|
||||||
|
- Rendering: client-side Vite application
|
||||||
|
- Stack: React, TypeScript, React Router, TanStack Query, Tailwind CSS 4, and Ant Design
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
Run from `web/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `$playwright-cli` for browser inspection and debugging. Run the repository wrapper from `web/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise exec -- npm run playwright:cli -- <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not invoke Playwright through a global installation or a floating `npx` package.
|
||||||
|
|
||||||
|
## Git Version Control
|
||||||
|
|
||||||
|
- Place worktrees under `.worktree/` and use a safe branch name, for example `git worktree add -b feat/partly-upload .worktree/feat-partly-upload`.
|
||||||
|
- Create a commit only when the user explicitly requests one.
|
||||||
|
- Run the required checks before proposing a commit. For documentation-only changes, run the relevant documentation checks.
|
||||||
|
- Resolve implementation issues in the affected area before proposing a commit. Report unrelated known failures separately.
|
||||||
|
- Write the complete commit message and obtain explicit approval for that exact message before running `git commit`.
|
||||||
|
- Commit messages contain no `Co-authored-by`, generated-tool signatures, or attribution trailers unless the user requests them.
|
||||||
|
|
||||||
|
### Commit Message Format
|
||||||
|
|
||||||
|
Use Conventional Commits:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<type>[optional scope][optional !]: <description>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use `fix`, `feat`, `build`, `docs`, `refactor`, or `test` as the type.
|
||||||
|
- Add `!` for a breaking API change.
|
||||||
|
- Keep the title concise.
|
||||||
|
- Write the body as a small set of bullets with typed prefixes such as `feat:`, `fix:`, `test:`, or `docs:`.
|
||||||
|
- Leave one blank line between the title and body.
|
||||||
|
- Summarize behavior and intent instead of copying code statements.
|
||||||
|
|
||||||
|
## Shared Constraints
|
||||||
|
|
||||||
|
- Write code, comments, and repository documentation in English.
|
||||||
|
- Follow the commit authorization workflow above.
|
||||||
|
|
||||||
## Debugging Principles
|
## Debugging Principles
|
||||||
|
|
||||||
When a test failure occurs, follow this strict order:
|
1. Confirm that the test expresses the intended behavior.
|
||||||
|
2. Correct the test when its expectation is wrong.
|
||||||
1. **Examine the test first** — ensure the test code correctly expresses the intended program behavior
|
3. Correct the implementation when the test is valid.
|
||||||
2. **Fix the test if it's wrong** — if the test doesn't represent correct expected behavior, correct the test to match the intended behavior
|
4. Preserve intended assertions and edge-case coverage. Never weaken a valid test to make it pass.
|
||||||
3. **Fix the implementation if the test is correct** — only after confirming the test is valid, locate and fix the bug in the implementation
|
5. Stop after six unsuccessful debugging rounds and report the current evidence.
|
||||||
4. **Never weaken tests to gain passing status** — do not relax assertions, remove edge cases, or simplify test logic just to make tests pass. Tests exist to catch problems, not to produce a 100% pass rate
|
|
||||||
5. **Escalate after 6 rounds** — if a problem remains unresolved after 6 debugging attempts, stop and report the current state to the user for further investigation
|
|
||||||
|
|||||||
@@ -1,48 +1,33 @@
|
|||||||
# MyGO
|
# MyGO
|
||||||
|
|
||||||
MyGO is a WebDisk (cloud drive) server, written in Go.
|
MyGO is a pre-alpha WebDisk server with a Go backend and a browser client.
|
||||||
|
|
||||||
**Current status**: pre-alpha — project skeleton with no core functionality implemented yet.
|
## Current Capabilities
|
||||||
|
|
||||||
---
|
- JWT access and refresh authentication
|
||||||
|
- Application passkeys
|
||||||
|
- File and directory management through `/api/v1`
|
||||||
|
- Local file storage with staged upload promotion
|
||||||
|
- Administrator user listing, lookup, and deletion
|
||||||
|
- React client with login, root file listing, upload, and download
|
||||||
|
|
||||||
## Roadmap
|
APIs and large-file behavior continue to evolve.
|
||||||
|
|
||||||
### v0
|
|
||||||
|
|
||||||
- [ ] CLI configuration management (`mygo config`)
|
|
||||||
- [ ] User authentication (JWT)
|
|
||||||
- [ ] File upload / download / management (HTTP API)
|
|
||||||
- [ ] Admin endpoints
|
|
||||||
- [ ] WebDAV support
|
|
||||||
|
|
||||||
### Future
|
|
||||||
|
|
||||||
- [ ] Image server
|
|
||||||
- [ ] Pastebin & code snippet editing in sharing
|
|
||||||
- [ ] S3 storage backend
|
|
||||||
- [ ] Nextcloud-compatible API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
`mygo` is the backend entrypoint with these subcommands:
|
The repository currently provides one server command:
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `mygo serve` | Start the backend server |
|
| `mygo serve` | Load configuration and start the HTTP server |
|
||||||
| `mygo config` | Manage instance configuration |
|
|
||||||
| `mygo status` | Show server status |
|
|
||||||
|
|
||||||
---
|
## Web Client
|
||||||
|
|
||||||
## Frontend
|
The browser client lives in `web/` and uses the same REST API as other clients. See [`web/README.md`](web/README.md) for development, testing, and browser-debugging instructions.
|
||||||
|
|
||||||
The frontend uses React + TypeScript and communicates with the backend via HTTP API. Frontend source is maintained in a separate repository.
|
## Documentation
|
||||||
|
|
||||||
---
|
- [Documentation index](docs/README.md)
|
||||||
|
- [Server roadmap](docs/server/roadmap.md)
|
||||||
## Development
|
- [Web roadmap](docs/web/roadmap.md)
|
||||||
|
- [Agent conventions](AGENTS.md)
|
||||||
See `docs/development.md` for build and test workflows. See `AGENTS.md` for behavioral conventions.
|
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/app"
|
"github.com/dhao2001/mygo/internal/app"
|
||||||
"github.com/dhao2001/mygo/internal/config"
|
"github.com/dhao2001/mygo/internal/config"
|
||||||
mygolog "github.com/dhao2001/mygo/internal/log"
|
"github.com/dhao2001/mygo/internal/logging"
|
||||||
"github.com/dhao2001/mygo/internal/server"
|
"github.com/dhao2001/mygo/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ var serveCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set up structured logging before anything else.
|
// Set up structured logging before anything else.
|
||||||
appLogger := mygolog.NewLogger(cfg.Log)
|
appLogger := logging.NewLogger(cfg.Log)
|
||||||
slog.SetDefault(appLogger)
|
slog.SetDefault(appLogger)
|
||||||
slog.Info("mygo server starting")
|
slog.Info("mygo server starting")
|
||||||
if loadInfo.EphemeralJWTSecret {
|
if loadInfo.EphemeralJWTSecret {
|
||||||
|
|||||||
+25
-7
@@ -1,8 +1,26 @@
|
|||||||
# Docs
|
# Documentation
|
||||||
|
|
||||||
| File | Content |
|
Read the documents required by the current task. Current-state documents, roadmaps, and decision records serve different purposes.
|
||||||
|------|---------|
|
|
||||||
| `architecture.md` | Module layout, package boundaries |
|
## Shared
|
||||||
| `decisions.md` | Technical decisions (ADR) |
|
|
||||||
| `roadmap.md` | Feature progress and status |
|
| Document | Purpose | Read when |
|
||||||
| `development.md` | Build, test, debug workflow |
|
|----------|---------|-----------|
|
||||||
|
| [`writing-guide.md`](writing-guide.md) | Documentation roles and writing rules | Writing or reviewing repository documentation |
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
| Document | Purpose | Read when |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| [`server/architecture.md`](server/architecture.md) | Current packages, responsibilities, and enforced boundaries | Changing server structure or cross-package behavior |
|
||||||
|
| [`server/development.md`](server/development.md) | Build, test, review, and configuration workflow | Developing or debugging the server |
|
||||||
|
| [`server/roadmap.md`](server/roadmap.md) | Available, planned, and future capabilities | Planning or completing server features |
|
||||||
|
| [`server/decisions.md`](server/decisions.md) | Server ADR index | Making or revisiting a significant server decision |
|
||||||
|
|
||||||
|
## Web
|
||||||
|
|
||||||
|
| Document | Purpose | Read when |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| [`../web/README.md`](../web/README.md) | Web development and browser workflow | Developing or debugging the Web client |
|
||||||
|
| [`web/roadmap.md`](web/roadmap.md) | Available, planned, and future capabilities | Planning or completing Web features |
|
||||||
|
| [`web/decisions.md`](web/decisions.md) | Web ADR index | Making or revisiting a significant Web decision |
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# Architecture
|
|
||||||
|
|
||||||
## Layered Design
|
|
||||||
|
|
||||||
```
|
|
||||||
Handler (Gin handlers) ← translates HTTP ↔ Service calls
|
|
||||||
↓
|
|
||||||
Service (business logic) ← orchestrates, authorizes, validates
|
|
||||||
↓ ↓
|
|
||||||
Repository (GORM data access) Storage (file I/O)
|
|
||||||
↓ ↓
|
|
||||||
[SQLite / PostgreSQL] [Local FS / S3]
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Handler has no business logic — parse request, call service, write response.
|
|
||||||
- Service has no HTTP awareness — operates on domain models and interfaces.
|
|
||||||
- Repository abstracts the database; Storage abstracts where bytes live.
|
|
||||||
- `internal/server` is the composition root — wires all dependencies together.
|
|
||||||
|
|
||||||
## Package Map
|
|
||||||
|
|
||||||
| Layer | Package | Purpose | Status |
|
|
||||||
|-------|---------|---------|--------|
|
|
||||||
| **CLI** | `cmd` | Cobra root command | 🛠 WIP |
|
|
||||||
| | `cmd/serve.go` | `mygo serve` — wire deps, start HTTP | ✅ |
|
|
||||||
| | `cmd/config.go` | `mygo config` — config subcommand | 🛠 WIP |
|
|
||||||
| | `cmd/status.go` | `mygo status` — health check | 🛠 WIP |
|
|
||||||
| **Config** | `internal/config` | Viper load (YAML + env + flags), typed Duration config via built-in decode hook | ✅ |
|
|
||||||
| **App** | `internal/app` | Runtime dependency container and build metadata | 🛠 WIP |
|
|
||||||
| **HTTP** | `internal/server` | Gin router init, route registration (public/protected split), graceful shutdown | ✅ |
|
|
||||||
| | `internal/handler` | HTTP handlers (auth, file, admin, webdav...) | 🛠 WIP |
|
|
||||||
| | `internal/middleware` | Gin middleware (logger, jwt, cors, auth) | 🛠 WIP |
|
|
||||||
| **Business** | `internal/service` | Business logic: `AuthService` (register, login, refresh, logout, passkey CRUD) | ✅ |
|
|
||||||
| | `internal/model` | Domain types (User, File, Credential, Session), error codes | ✅ |
|
|
||||||
| **Data** | `internal/repository` | Repository interfaces + GORM implementations (User, Session, File, Credential) | ✅ |
|
|
||||||
| | `internal/storage` | Storage backend interface + local disk impl | 🛠 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 | ✅ |
|
|
||||||
|
|
||||||
## API Routes (v0)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v1/version
|
|
||||||
|
|
||||||
POST /api/v1/auth/register
|
|
||||||
POST /api/v1/auth/login
|
|
||||||
POST /api/v1/auth/refresh
|
|
||||||
POST /api/v1/auth/logout
|
|
||||||
|
|
||||||
GET /api/v1/account
|
|
||||||
GET /api/v1/account/passkeys
|
|
||||||
POST /api/v1/account/passkeys
|
|
||||||
DELETE /api/v1/account/passkeys/:id
|
|
||||||
|
|
||||||
GET /api/v1/files
|
|
||||||
POST /api/v1/files
|
|
||||||
GET /api/v1/files/:id
|
|
||||||
GET /api/v1/files/:id/content
|
|
||||||
PUT /api/v1/files/:id
|
|
||||||
DELETE /api/v1/files/:id
|
|
||||||
|
|
||||||
GET /api/v1/admin/users
|
|
||||||
GET /api/v1/admin/users/:id
|
|
||||||
PUT /api/v1/admin/users/:id
|
|
||||||
DELETE /api/v1/admin/users/:id
|
|
||||||
```
|
|
||||||
|
|
||||||
## Middleware Chain
|
|
||||||
|
|
||||||
Applied globally by `gin.Default()`: logger → recovery
|
|
||||||
|
|
||||||
Planned globally: cors
|
|
||||||
|
|
||||||
Applied to protected groups: auth (JWT validation, inject user into gin.Context)
|
|
||||||
|
|
||||||
## Server Responsibilities
|
|
||||||
|
|
||||||
- `cmd/serve.go` loads config, calls `app.Bootstrap` to initialize DB + services, builds the router, and starts the HTTP server.
|
|
||||||
- `app.WebApp` carries runtime dependencies and build metadata needed to assemble handlers.
|
|
||||||
- `internal/server` owns Gin router setup (`router.go`), route registration split into `routes_public.go` (public auth) and `routes_protected.go` (JWT-protected account).
|
|
||||||
- Each route group creates its own handler instance: `routes_public.go` creates `AuthHandler`, `routes_protected.go` creates `AccountHandler` — no shared handler state between public and protected routes.
|
|
||||||
- `RunWithGracefulShutdown` stops accepting new requests on termination and gives in-flight requests time to finish.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Technical Decisions
|
|
||||||
|
|
||||||
## 2026-04-25: v0 Tech Stack & Architecture
|
|
||||||
|
|
||||||
**Context**: Project skeleton was created with only cobra CLI. We needed a concrete tech stack and package layout to begin implementation.
|
|
||||||
|
|
||||||
**Decisions**:
|
|
||||||
|
|
||||||
| Area | Choice | Rationale |
|
|
||||||
|------|--------|-----------|
|
|
||||||
| HTTP framework | Gin | Most widely adopted Go web framework, mature middleware ecosystem |
|
|
||||||
| ORM | GORM | SQLite-first dev, PostgreSQL option later; GORM abstracts dialect differences |
|
|
||||||
| Config management | Viper | YAML + env vars + CLI flags three-way merge, built for cobra integration |
|
|
||||||
| Database | SQLite (v0) → PostgreSQL (future) | SQLite zero setup for dev; repo interface isolates the switch |
|
|
||||||
| File storage | Local disk (v0) → S3 (future) | Backend interface (`internal/storage`) hides implementation |
|
|
||||||
| File identity | UUID | Distributed-friendly, no coordination needed; cost is negligible for file metadata |
|
|
||||||
| Token strategy | JWT, refresh token stored in DB | Enables server-side revocation (admin kick, logout-all-devices) |
|
|
||||||
| Pagination | OFFSET/LIMIT | Simple, sufficient for v0; migrate to cursor-based if needed |
|
|
||||||
| API response format | Direct JSON success bodies + unified error body | HTTP status codes carry request outcome; error body carries human-readable details |
|
|
||||||
|
|
||||||
**Architecture**: Four-layer model — Handler (Gin) → Service (business logic) → Repository (GORM data access) + Storage (file I/O). Each layer depends only on interfaces of the layer below.
|
|
||||||
|
|
||||||
**Consequences**:
|
|
||||||
- Handler layer has no business logic; Service layer is reusable across REST API, WebDAV, and future Nextcloud API.
|
|
||||||
- Repository interfaces keep DB swappable; future PostgreSQL implementation only needs a new package.
|
|
||||||
- Refresh token in DB adds a `sessions` table and a `repository.SessionRepository` interface.
|
|
||||||
- UUID dependency: `github.com/google/uuid` to be added.
|
|
||||||
- Gin middleware chain: default logger/recovery → cors → auth (route-group-scoped).
|
|
||||||
|
|
||||||
## 2026-04-27: Web API Foundation
|
|
||||||
|
|
||||||
**Context**: The project needed the first HTTP slice that can validate Gin wiring and provide a stable shape for future auth, file, and admin APIs.
|
|
||||||
|
|
||||||
**Decisions**:
|
|
||||||
|
|
||||||
| Area | Choice | Guidance |
|
|
||||||
|------|--------|----------|
|
|
||||||
| API versioning | All REST routes under `/api/v1` | Keep future REST handlers under the versioned group. |
|
|
||||||
| Initial public endpoint | `GET /api/v1/version` | Returns build metadata only; health/readiness endpoints need a separate security review. |
|
|
||||||
| Success responses | Direct JSON resource bodies | Use HTTP status codes as the request outcome signal. |
|
|
||||||
| Error responses | `{"error":{"message":"..."}}` | Add machine-readable error codes only when clients need stable branching behavior. |
|
|
||||||
| App composition | `internal/app.WebApp` | `cmd/serve.go` creates the app from config and build metadata, then passes it to router setup. |
|
|
||||||
| Router setup | `internal/server.NewRouter(*app.WebApp)` | Public routes (`routes_public.go`) and protected routes (`routes_protected.go`) split by auth boundary; `WebApp` serves as the unified dependency container. |
|
|
||||||
| Server lifecycle | `RunWithGracefulShutdown` | Preserve graceful shutdown while keeping command startup linear. |
|
|
||||||
| Default middleware | `gin.Default()` | Use default logger/recovery for the skeleton; add CORS/auth explicitly when their policies exist. |
|
|
||||||
|
|
||||||
**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.
|
|
||||||
- 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
|
|
||||||
|
|
||||||
**Context**: Auth layer had three structural weaknesses — handler duplication, indistinguishable token types, and fragile config duration parsing.
|
|
||||||
|
|
||||||
**Decisions**:
|
|
||||||
|
|
||||||
| Decision | Guidance |
|
|
||||||
|----------|----------|
|
|
||||||
| One handler per route group | `AuthHandler` owns `/auth/*` (public); `AccountHandler` owns `/account/*` (protected). A route group maps 1:1 to a handler type. |
|
|
||||||
| JWT `type` claim | `Claims.Type` distinguishes access from refresh tokens. Middleware and service enforce the correct type at their respective boundaries. `ParseToken` does no type check — it verifies cryptographic validity only. |
|
|
||||||
| Default JWT secret hardening | The development placeholder `jwt.secret` is replaced with an ephemeral runtime secret during config loading. Production and multi-instance deployments must set a stable secret. |
|
|
||||||
| `time.Duration` in config structs | Config fields representing durations use `time.Duration` directly. Viper's built-in `StringToTimeDurationHookFunc` handles string→Duration conversion at unmarshal time. No accessor methods, no runtime parsing. Invalid values fail at startup via `Load()`. |
|
|
||||||
|
|
||||||
**Consequences**:
|
|
||||||
- Handlers are independently extensible (caching, rate limiting scoped per handler).
|
|
||||||
- Refresh tokens cannot authenticate API requests; access tokens cannot be used to issue new token pairs.
|
|
||||||
- The placeholder JWT secret is safe for local startup, but tokens signed with it are invalidated on restart.
|
|
||||||
- New duration config fields require zero boilerplate — declare as `time.Duration` in the struct.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# Development
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Go 1.26.2 (pinned in `mise.toml`)
|
|
||||||
- `mise` (https://mise.jdx.dev) — run `mise install` to install toolchain
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go build ./...
|
|
||||||
go build -o mygo .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
go test -v -run TestName ./internal/...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lint & Format
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go vet ./...
|
|
||||||
go fmt ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go mod tidy # after adding/removing imports
|
|
||||||
```
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
Server config is loaded via viper from `config.yaml` (defaults in `internal/config/load.go`).
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
server:
|
|
||||||
host: 0.0.0.0
|
|
||||||
port: 10086
|
|
||||||
|
|
||||||
database:
|
|
||||||
driver: sqlite3
|
|
||||||
sqlite:
|
|
||||||
path: data/mygo.db
|
|
||||||
|
|
||||||
storage:
|
|
||||||
driver: local
|
|
||||||
local:
|
|
||||||
path: data/files
|
|
||||||
|
|
||||||
jwt:
|
|
||||||
secret: dev-secret-do-not-use-in-production
|
|
||||||
access_ttl: 15m
|
|
||||||
refresh_ttl: 168h
|
|
||||||
```
|
|
||||||
|
|
||||||
Environment variables use `MYGO_` prefix with underscore separators: `MYGO_SERVER_PORT=8080`, `MYGO_JWT_SECRET=...`
|
|
||||||
|
|
||||||
The default JWT secret is a development placeholder. At startup, MyGO replaces
|
|
||||||
it with an ephemeral runtime secret; set a stable `jwt.secret` or
|
|
||||||
`MYGO_JWT_SECRET` when tokens must survive restarts or when running multiple
|
|
||||||
instances.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Roadmap
|
|
||||||
|
|
||||||
## v0
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
| 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 CRUD for superusers |
|
|
||||||
| WebDAV | 🛠 WIP | future v0 or v1 |
|
|
||||||
|
|
||||||
## Implementation Tasks
|
|
||||||
|
|
||||||
Package-level implementation order (each task includes unit tests):
|
|
||||||
|
|
||||||
1. `internal/config` — Viper loader, config struct ✅
|
|
||||||
2. `internal/app` — runtime dependency container ✅
|
|
||||||
3. `internal/model` — domain types, error codes ✅
|
|
||||||
4. `internal/api` — error response helpers ✅
|
|
||||||
5. `internal/auth` — JWT utils ✅
|
|
||||||
6. `internal/storage` — backend interface + local fs
|
|
||||||
7. `internal/repository` — interfaces + GORM/SQLite impl ✅
|
|
||||||
8. `internal/service` — auth, file, admin services ✅ (auth done)
|
|
||||||
9. `internal/middleware` — logger, cors, auth ✅ (auth done)
|
|
||||||
10. `internal/handler` — auth, account, file, admin handlers 🛠 (auth + account done)
|
|
||||||
11. `internal/server` — Gin router, route registration, graceful shutdown ✅
|
|
||||||
12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done)
|
|
||||||
13. Integration tests
|
|
||||||
|
|
||||||
## Future
|
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| Image server | ⬜ plan | thumbnail generation |
|
|
||||||
| Pastebin & code snippets | ⬜ plan | in sharing context |
|
|
||||||
| S3 storage backend | ⬜ plan | new storage impl |
|
|
||||||
| Nextcloud-compatible API | ⬜ plan | new handler layer on existing services |
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Server Architecture
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP request
|
||||||
|
-> middleware
|
||||||
|
-> handler
|
||||||
|
-> service
|
||||||
|
-> repository and storage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layer Responsibilities
|
||||||
|
|
||||||
|
| Layer | Packages | Responsibility |
|
||||||
|
|-------|----------|----------------|
|
||||||
|
| CLI | `cmd` | Register Cobra commands and start the application |
|
||||||
|
| Composition | `internal/app` | Open runtime resources and construct services |
|
||||||
|
| HTTP | `internal/server`, `internal/handler`, `internal/middleware`, `internal/api` | Route requests, authenticate principals, map DTOs, and write HTTP responses |
|
||||||
|
| Business | `internal/service`, `internal/model` | Validate operations, enforce authorization, and represent domain results and errors |
|
||||||
|
| Data | `internal/repository`, `internal/storage` | Persist metadata and file content |
|
||||||
|
| Infrastructure | `internal/config`, `internal/logging`, `internal/auth` | Load configuration, create loggers, and provide token and password primitives |
|
||||||
|
| Test support | `internal/testutil` | Prepare privileged fixtures through test-only helpers |
|
||||||
|
|
||||||
|
Handlers define REST request and response DTOs. `internal/api` maps domain error kinds to HTTP status codes and error bodies.
|
||||||
|
|
||||||
|
Services use protocol-neutral domain types and errors. Each service constructor accepts the repository capabilities required by its use cases.
|
||||||
|
|
||||||
|
Repository query capabilities return domain records. Mutation capabilities use operation-specific parameter types, select writable columns explicitly, and apply the ownership and lifecycle predicates of the operation.
|
||||||
|
|
||||||
|
Storage backends manage staged and permanent file content. Repository records hold storage paths and file metadata.
|
||||||
|
|
||||||
|
## Runtime Composition
|
||||||
|
|
||||||
|
`app.Bootstrap` opens the configured database, runs migrations, creates repositories and local storage, constructs the services, and returns `app.WebApp`.
|
||||||
|
|
||||||
|
`server.NewRouter` creates a Gin engine and registers the public and protected route groups. `cmd/serve.go` loads configuration, initializes logging, bootstraps the application, and runs graceful shutdown.
|
||||||
|
|
||||||
|
`app.WebApp` owns the database lifecycle and exposes the services, storage backend, configuration, and build metadata required by HTTP setup.
|
||||||
|
|
||||||
|
## HTTP Boundaries
|
||||||
|
|
||||||
|
Global middleware runs in this order:
|
||||||
|
|
||||||
|
1. Request ID
|
||||||
|
2. Gin access logger
|
||||||
|
3. Gin recovery
|
||||||
|
|
||||||
|
Protected routes use `AuthRequired` to authenticate an access token and attach a service principal. Administrator routes add `AdminRequired` to verify the principal's administrator flag.
|
||||||
|
|
||||||
|
| Route group | Access | Capability |
|
||||||
|
|-------------|--------|------------|
|
||||||
|
| `/api/v1/version` | Public | Build metadata |
|
||||||
|
| `/api/v1/auth` | Public | Registration, login, refresh, and logout |
|
||||||
|
| `/api/v1/account` | Authenticated | Account and application passkeys |
|
||||||
|
| `/api/v1/files` | Authenticated | File and directory operations |
|
||||||
|
| `/api/v1/admin/users` | Administrator | User listing, lookup, and deletion |
|
||||||
|
|
||||||
|
The route declarations in `internal/server/routes_public.go` and `internal/server/routes_protected.go` are the source of truth for individual methods and paths.
|
||||||
|
|
||||||
|
## Persistence Commands
|
||||||
|
|
||||||
|
| Area | Mutation contract |
|
||||||
|
|------|-------------------|
|
||||||
|
| Users | Registration accepts `RegisteredUserParams` and creates an active non-admin user. Administration exposes a separate deletion command. |
|
||||||
|
| Sessions | Refresh sessions are created, atomically consumed, revoked by token hash, or removed by maintenance operations. |
|
||||||
|
| Files | Upload, directory creation, metadata update, and soft deletion use distinct parameter types and commands. |
|
||||||
|
| Credentials | Application passkeys have explicit creation, usage-recording, and revocation operations. |
|
||||||
|
|
||||||
|
Metadata updates can write `name`, `parent_id`, and GORM-managed `updated_at`. File ownership and active-state predicates scope user mutations.
|
||||||
|
|
||||||
|
## Transaction Boundaries
|
||||||
|
|
||||||
|
File child creation, movement, and directory deletion use one hierarchy transaction protocol. PostgreSQL locks active owned source and parent rows in sorted ID order. SQLite adds `_txlock=immediate` to the configured DSN and reserves the write transaction before reading hierarchy state.
|
||||||
|
|
||||||
|
Refresh rotation locks, returns, and deletes the session in one transaction. A refresh session can issue one replacement token pair.
|
||||||
|
|
||||||
|
Uploads write to a staging path before promotion. The service promotes validated content before creating the active file record and attempts to remove promoted content when the database create fails.
|
||||||
|
|
||||||
|
## Enforced Architecture Constraints
|
||||||
|
|
||||||
|
`internal/architecture_test.go` enforces these boundaries:
|
||||||
|
|
||||||
|
- `internal/model` and `internal/service` do not import `net/http`, Gin, or `internal/api`.
|
||||||
|
- `internal/handler` and `internal/middleware` do not import `internal/repository`.
|
||||||
|
- Repository implementations do not call GORM `Save`; mutation methods select writable columns explicitly.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Server Decisions
|
||||||
|
|
||||||
|
Read the ADRs relevant to the current change. Accepted records define active decisions. Superseded records preserve history and link to current guidance.
|
||||||
|
|
||||||
|
| ADR | Date | Status | Decision |
|
||||||
|
|-----|------|--------|----------|
|
||||||
|
| [ADR-0001](decisions/0001-server-foundation.md) | 2026-04-25 | Accepted | Select the server foundation |
|
||||||
|
| [ADR-0002](decisions/0002-rest-api-foundation.md) | 2026-04-27 | Accepted | Establish the REST API foundation |
|
||||||
|
| [ADR-0003](decisions/0003-auth-contracts.md) | 2026-04-29 | Accepted | Define authentication contracts |
|
||||||
|
| [ADR-0004](decisions/0004-staged-file-uploads.md) | 2026-07-05 | Accepted | Stage uploads before publication |
|
||||||
|
| [ADR-0005](decisions/0005-protocol-neutral-services.md) | 2026-07-05 | Accepted | Keep service contracts protocol-neutral |
|
||||||
|
| [ADR-0006](decisions/0006-conceal-file-resources.md) | 2026-07-15 | Accepted | Conceal file ownership boundaries |
|
||||||
|
| [ADR-0007](decisions/0007-command-scoped-persistence.md) | 2026-07-16 | Accepted | Use command-scoped persistence mutations |
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# ADR-0001: Select the Server Foundation
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-04-25
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
MyGO needed a server stack that supports local development, relational metadata, configurable deployment, and multiple file storage backends.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Use Gin for HTTP routing and middleware.
|
||||||
|
- Use GORM with SQLite and PostgreSQL drivers.
|
||||||
|
- Use Viper for YAML and environment configuration, with a Cobra flag for the config file path.
|
||||||
|
- Use local file storage first and expose storage through `internal/storage`.
|
||||||
|
- Use UUIDs for file and domain identities.
|
||||||
|
- Use JWT access tokens and database-backed refresh sessions.
|
||||||
|
- Use offset and limit pagination for the initial API.
|
||||||
|
- Return direct JSON success bodies and a shared JSON error shape.
|
||||||
|
- Organize the server as Handler -> Service -> Repository and Storage.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- SQLite supports a zero-service local setup; PostgreSQL supports multi-instance deployments.
|
||||||
|
- Repository and storage interfaces isolate infrastructure choices from services.
|
||||||
|
- Database-backed refresh sessions support rotation and revocation.
|
||||||
|
- Offset pagination remains suitable for the current dataset size and API scope.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ADR-0002: Establish the REST API Foundation
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-04-27
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The first HTTP slice needed stable versioning, response conventions, dependency composition, and server lifecycle behavior.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Place REST routes under `/api/v1`.
|
||||||
|
- Expose build metadata through `GET /api/v1/version`.
|
||||||
|
- Return resource bodies directly for successful requests.
|
||||||
|
- Return errors as `{"error":{"message":"..."}}` with optional diagnostic fields.
|
||||||
|
- Use `internal/app.Bootstrap` to construct runtime dependencies and return `app.WebApp`.
|
||||||
|
- Register public and protected routes in separate server functions.
|
||||||
|
- Stop accepting new requests during shutdown and allow in-flight requests to finish.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Future REST endpoints share one version boundary and response convention.
|
||||||
|
- `app.WebApp` provides the services and metadata required by route setup.
|
||||||
|
- HTTP lifecycle behavior stays in `internal/server`.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ADR-0003: Define Authentication Contracts
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-04-29
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Authentication requires distinct access and refresh behavior, stable duration parsing, and safe development defaults.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Use `AuthHandler` for public authentication routes and `AccountHandler` for authenticated account routes.
|
||||||
|
- Add a JWT `type` claim that distinguishes access and refresh tokens.
|
||||||
|
- Verify token type at the boundary that consumes the token.
|
||||||
|
- Store configuration durations as `time.Duration` values.
|
||||||
|
- Replace known development JWT placeholders with a random runtime secret during configuration loading.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Access tokens authenticate protected requests.
|
||||||
|
- Refresh tokens rotate token pairs through database sessions.
|
||||||
|
- Invalid duration values fail during startup.
|
||||||
|
- Tokens signed with the generated development secret expire when the process restarts.
|
||||||
|
- Multi-instance deployments require a stable configured secret.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ADR-0004: Stage Uploads Before Publication
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-07-05
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
File uploads need streaming size enforcement and a clear publication boundary between stored content and visible metadata.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Stream multipart bodies with `Request.MultipartReader`.
|
||||||
|
- Interpret `storage.max_upload_size = 0` as unlimited and positive values as a byte limit.
|
||||||
|
- Write upload content to a staging path.
|
||||||
|
- Validate and promote staged content before creating the active database record.
|
||||||
|
- Pass multipart `parent_id` as a query parameter.
|
||||||
|
- Convert HTTP body errors in the handler and domain upload errors in the service.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Upload size checks apply while the request streams.
|
||||||
|
- Interrupted and rejected uploads remain in the staging namespace.
|
||||||
|
- Local storage promotes with rename and uses copy and delete across filesystems.
|
||||||
|
- A failed database create can leave a promoted object; the service attempts cleanup and the object remains invisible to the file API.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR-0005: Keep Service Contracts Protocol-Neutral
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-07-05
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
MyGO exposes REST today and is designed to support additional protocols. Business services need contracts that transport adapters can reuse.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Services return domain results and `model.AppError` values.
|
||||||
|
- HTTP handlers own REST response DTOs.
|
||||||
|
- `internal/api` maps domain error kinds to REST responses.
|
||||||
|
- HTTP packages obtain application behavior through service contracts and authenticated principals.
|
||||||
|
- Architecture tests enforce the package dependency boundaries.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Additional protocol adapters can reuse the existing services.
|
||||||
|
- Each protocol adapter defines its own response mapping.
|
||||||
|
- Service tests exercise business behavior without constructing HTTP requests.
|
||||||
|
- Error responses can include `error.log_id` for failures recorded by the server.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ADR-0006: Conceal File Ownership Boundaries
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-07-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Authenticated callers must not infer the existence of files owned by another user from status codes or response messages.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Map missing, deleted, and cross-user file targets to the same not-found result.
|
||||||
|
- Apply the same rule to parent-directory lookups.
|
||||||
|
- Return success for one deletion of an active owned file.
|
||||||
|
- Return not found for repeated deletion and cross-user deletion.
|
||||||
|
- Guard soft deletion with ownership and active-state predicates.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- File responses conceal resource ownership from other authenticated users.
|
||||||
|
- The first successful deletion returns `204 No Content`; later attempts return `404 Not Found`.
|
||||||
|
- Non-empty directory deletion continues to return a conflict.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# ADR-0007: Use Command-Scoped Persistence Mutations
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-07-16
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Each business operation needs a persistence contract that exposes its writable fields, authorization predicates, and transaction boundary.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Give each mutation a use-case-specific method and parameter type.
|
||||||
|
- Inject the query and mutation capabilities required by each service.
|
||||||
|
- Select mutation columns explicitly.
|
||||||
|
- Include ownership, credential type, and active-state predicates where the operation requires them.
|
||||||
|
- Revalidate file hierarchy state inside the mutation transaction.
|
||||||
|
- Lock PostgreSQL hierarchy rows in sorted order.
|
||||||
|
- Start SQLite write transactions with `_txlock=immediate`.
|
||||||
|
- Consume a refresh session in one lock, read, and delete transaction.
|
||||||
|
- Use `internal/testutil` for privileged fixture setup.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Mutation inputs expose the fields writable by the use case.
|
||||||
|
- Guarded zero-row updates map to domain not-found results.
|
||||||
|
- File hierarchy mutations preserve active parent-child relationships under concurrency.
|
||||||
|
- One refresh session issues one replacement token pair.
|
||||||
|
- SQLite serializes these write transactions from their first database operation.
|
||||||
|
- Future mutations require a named command and an explicit capability.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Server Development
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go 1.26.2, pinned in `mise.toml`
|
||||||
|
- `mise` for installing the pinned toolchain
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
go build -o mygo .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go test -v -run TestName ./internal/...
|
||||||
|
go test -race ./internal/repository ./internal/service ./internal/server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Format and Static Analysis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go fmt ./...
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Run module cleanup after adding or removing imports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Review
|
||||||
|
|
||||||
|
Use `$mygo-api-repo-review` for evidence-based architecture, design, security, resilience, data-consistency, performance, and implementation audits.
|
||||||
|
|
||||||
|
| Mode | Scope |
|
||||||
|
|------|-------|
|
||||||
|
| `diff` | Workspace, including relevant untracked files, compared with `HEAD` |
|
||||||
|
| `main` | Workspace compared with the merge base of `HEAD` and local `main` |
|
||||||
|
| `full` | Complete repository |
|
||||||
|
|
||||||
|
Reviews are read-only unless the user also requests fixes. A mode can target a path, Go package, or logical component.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The server reads `config.yaml` from the working directory when present. Environment variables use the `MYGO_` prefix and underscores, for example `MYGO_SERVER_PORT` and `MYGO_JWT_SECRET`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 10086
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: sqlite3
|
||||||
|
sqlite:
|
||||||
|
path: data/mygo.db
|
||||||
|
|
||||||
|
storage:
|
||||||
|
driver: local
|
||||||
|
max_upload_size: 0
|
||||||
|
local:
|
||||||
|
path: data/files
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: dev-secret-do-not-use-in-production
|
||||||
|
access_ttl: 15m
|
||||||
|
refresh_ttl: 168h
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
file_path: ""
|
||||||
|
file_level: debug
|
||||||
|
```
|
||||||
|
|
||||||
|
| Setting | Behavior |
|
||||||
|
|---------|----------|
|
||||||
|
| `database.driver` | Selects `sqlite3` or `postgres` |
|
||||||
|
| `storage.max_upload_size` | `0` allows any upload size; a positive byte count enables request and service limits |
|
||||||
|
| `jwt.secret` | Signs access and refresh tokens |
|
||||||
|
| `log.level` | Sets stderr logging to `debug`, `info`, `warn`, or `error` |
|
||||||
|
| `log.file_path` | Enables an additional text log file when set |
|
||||||
|
| `log.file_level` | Sets the file log level independently |
|
||||||
|
|
||||||
|
PostgreSQL uses the `database.postgres` host, port, user, password, database name, and SSL mode fields defined in `internal/config`.
|
||||||
|
|
||||||
|
The SQLite connector preserves configured DSN parameters and sets `_txlock=immediate`. This setting establishes the transaction behavior required by hierarchy and refresh-session commands.
|
||||||
|
|
||||||
|
The default JWT secret is a development placeholder. Configuration loading replaces it with a random in-memory secret for the current process. Set a stable secret for multi-instance deployments and tokens that must survive a restart.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Server Roadmap
|
||||||
|
|
||||||
|
## Available Capabilities
|
||||||
|
|
||||||
|
- YAML and environment configuration loading
|
||||||
|
- SQLite and PostgreSQL metadata persistence
|
||||||
|
- JWT access and single-use refresh tokens
|
||||||
|
- Account and application-passkey APIs
|
||||||
|
- File listing, upload, download, directory creation, metadata update, and soft deletion
|
||||||
|
- Ownership concealment for file resources
|
||||||
|
- Administrator user listing, lookup, and deletion
|
||||||
|
- Local storage with staged upload promotion
|
||||||
|
- Request IDs, structured logging, and graceful shutdown
|
||||||
|
- Architecture, repository, service, handler, and route integration tests
|
||||||
|
|
||||||
|
## Planned v0 Work
|
||||||
|
|
||||||
|
- Add `mygo config` for instance configuration management.
|
||||||
|
- Add `mygo status` for server status inspection.
|
||||||
|
- Complete the remaining administrator workflows.
|
||||||
|
- Define the WebDAV delivery milestone and acceptance criteria.
|
||||||
|
|
||||||
|
## Future Candidates
|
||||||
|
|
||||||
|
- S3 storage
|
||||||
|
- Image thumbnails
|
||||||
|
- Paste and code-snippet sharing
|
||||||
|
- Nextcloud-compatible protocol adapters
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Web Decisions
|
||||||
|
|
||||||
|
Read the ADRs relevant to the current change. Accepted records define active decisions. Superseded records preserve history and link to current guidance.
|
||||||
|
|
||||||
|
| ADR | Date | Status | Decision |
|
||||||
|
|-----|------|--------|----------|
|
||||||
|
| [ADR-0001](decisions/0001-client-rendered-foundation.md) | 2026-07-14 | Accepted | Use a client-rendered Web foundation |
|
||||||
|
| [ADR-0002](decisions/0002-browser-auth-root-files.md) | 2026-07-14 | Accepted | Implement browser auth and the root file workflow |
|
||||||
|
| [ADR-0003](decisions/0003-project-local-browser-testing.md) | 2026-07-14 | Accepted | Keep browser testing resources project-local |
|
||||||
|
| [ADR-0004](decisions/0004-playwright-mcp-debugging.md) | 2026-07-14 | Superseded | Use Playwright MCP for agent debugging |
|
||||||
|
| [ADR-0005](decisions/0005-playwright-cli-debugging.md) | 2026-07-15 | Accepted | Use the pinned Playwright CLI for agent debugging |
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ADR-0001: Use a Client-Rendered Web Foundation
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-07-14
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
MyGO needs a browser client and expects native clients to consume the same application API.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Build a client-side application with Vite, React, and strict TypeScript.
|
||||||
|
- Use React Router for navigation and TanStack Query for remote state.
|
||||||
|
- Use Ant Design for reusable controls and theme tokens.
|
||||||
|
- Use Tailwind CSS for layout, spacing, and responsive utilities.
|
||||||
|
- Keep business API contracts client-neutral.
|
||||||
|
- Select additional dependencies when their feature is designed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Vite emits static assets for same-origin deployment.
|
||||||
|
- The Web project contains no SSR, React Server Components, or Node application server.
|
||||||
|
- Browser and native clients share the versioned REST API.
|
||||||
|
- MyGO components own file-browser and transfer behavior; Ant Design supplies presentation controls.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ADR-0002: Implement Browser Auth and the Root File Workflow
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-07-14
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The first Web milestone needed an authenticated workflow over the existing login, file listing, upload, and download APIs.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Use same-origin `/api/v1` requests.
|
||||||
|
- Store the access and refresh token pair in `sessionStorage`.
|
||||||
|
- Coordinate one refresh request across concurrent `401` responses.
|
||||||
|
- Retry each failed protected request once after refresh.
|
||||||
|
- Clear the session when refresh or the retry fails.
|
||||||
|
- List root entries and upload one file at a time to the root directory.
|
||||||
|
- Download authenticated file responses as browser blobs.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Reloading the tab preserves the session; closing the tab clears it.
|
||||||
|
- Query caches clear when the authenticated session ends.
|
||||||
|
- Directories appear as non-interactive root rows in the first milestone.
|
||||||
|
- Handwritten wire types remain until a shared OpenAPI contract is available.
|
||||||
|
- Directory management and large-transfer workflows require later feature decisions.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ADR-0003: Keep Browser Testing Resources Project-Local
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-07-14
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Browser tests and diagnostics run in a headless Debian development container. The toolchain needs repeatable versions and repository-scoped resources.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Use Playwright Test with bundled Chromium for repeatable E2E tests.
|
||||||
|
- Keep E2E tests separate from `npm run check` because browser installation is an explicit setup step.
|
||||||
|
- Run Chromium headlessly with profile isolation and sandboxing.
|
||||||
|
- Store npm and browser caches under `.cache/`.
|
||||||
|
- Store traces, screenshots, videos, and CLI output under `.artifacts/`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A fresh environment installs npm dependencies, browser libraries, and the pinned Chromium revision before browser work.
|
||||||
|
- Browser failures can retain diagnostics without adding generated files to source control.
|
||||||
|
- Repository configuration controls browser versions and artifact locations.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# ADR-0004: Use Playwright MCP for Agent Debugging
|
||||||
|
|
||||||
|
Status: Superseded by [ADR-0005](0005-playwright-cli-debugging.md)
|
||||||
|
Date: 2026-07-14
|
||||||
|
|
||||||
|
## Historical Decision
|
||||||
|
|
||||||
|
The project initially selected a repository-scoped Playwright MCP process for interactive agent debugging.
|
||||||
|
|
||||||
|
## Current Guidance
|
||||||
|
|
||||||
|
[ADR-0005](0005-playwright-cli-debugging.md) defines the current browser-debugging entry point. [ADR-0003](0003-project-local-browser-testing.md) defines the browser test and resource policy that remains active.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR-0005: Use the Pinned Playwright CLI for Agent Debugging
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
Date: 2026-07-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Playwright MCP package introduced a second Playwright core, a second Chromium revision, and a larger agent tool surface. The pinned test runner provides the required interactive commands through its CLI.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Use the `playwright:cli` npm script as the browser-debugging entry point.
|
||||||
|
- Use `@playwright/test` as the Playwright dependency for tests and diagnostics.
|
||||||
|
- Use the bundled Chromium revision selected by the pinned package.
|
||||||
|
- Load `.playwright/cli.config.json` for headless, isolated, and sandboxed execution.
|
||||||
|
- Use the repository-scoped `$playwright-cli` skill for agent browser work.
|
||||||
|
- Limit CLI output under `.artifacts/playwright-cli/`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- E2E tests and interactive diagnostics share one Playwright core and Chromium revision.
|
||||||
|
- The npm script isolates operators and agents from upstream CLI entry-point changes.
|
||||||
|
- The repository skill keeps the main workflow concise and exposes detailed references on demand.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Web Roadmap
|
||||||
|
|
||||||
|
## Product Model
|
||||||
|
|
||||||
|
MyGO Web is a client-side application built by Vite. It consumes the same `/api/v1` REST API as native clients. Production can serve `web/dist` from MyGO or a same-origin reverse proxy.
|
||||||
|
|
||||||
|
## Available Capabilities
|
||||||
|
|
||||||
|
- Email and password login
|
||||||
|
- Access and refresh tokens stored for the browser session
|
||||||
|
- One coordinated refresh retry after an authenticated request returns `401`
|
||||||
|
- Protected application shell
|
||||||
|
- Root file listing with 50 items per page
|
||||||
|
- Single-file upload to the root directory
|
||||||
|
- Authenticated file download
|
||||||
|
- Responsive Ant Design interface with Tailwind layout utilities
|
||||||
|
- Unit tests for the API client and session behavior
|
||||||
|
- Playwright smoke tests and repository-scoped browser diagnostics
|
||||||
|
|
||||||
|
## Current Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
web/src/
|
||||||
|
├── api/ # API client, session storage, and wire types
|
||||||
|
├── app/ # Router, providers, and authentication guard
|
||||||
|
├── components/ # Shared presentation components
|
||||||
|
├── features/ # Feature hooks and state
|
||||||
|
└── pages/ # Route entry points
|
||||||
|
```
|
||||||
|
|
||||||
|
## Planned Capabilities
|
||||||
|
|
||||||
|
- Directory navigation and management
|
||||||
|
- Account, settings, and administrator screens
|
||||||
|
- Shared OpenAPI contracts and generated client types
|
||||||
|
- Multi-file transfer queues and progress reporting
|
||||||
|
- Resumable large-file transfers
|
||||||
|
- Document preview
|
||||||
|
|
||||||
|
Select dependencies when designing the capability that requires them. Record significant choices in the Web decision log.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Documentation Writing Guide
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
Repository documentation must be accurate, concise, and useful to both people and coding agents.
|
||||||
|
|
||||||
|
## Document Roles
|
||||||
|
|
||||||
|
| Document type | Content |
|
||||||
|
|---------------|---------|
|
||||||
|
| README | Project entry point and runnable paths |
|
||||||
|
| Architecture | Current structure, responsibilities, and enforced boundaries |
|
||||||
|
| Development | Commands and operational procedures |
|
||||||
|
| Roadmap | Available, planned, and future capabilities |
|
||||||
|
| Architecture Decision Record (ADR) | One technical decision, its context, and its consequences |
|
||||||
|
| `AGENTS.md` | Durable agent workflow, conventions, verification, and constraints |
|
||||||
|
|
||||||
|
Keep each fact in its canonical document. Link to that document from other locations.
|
||||||
|
|
||||||
|
## Current-State Writing
|
||||||
|
|
||||||
|
- Use English, active voice, and present tense.
|
||||||
|
- Name the component that performs each action.
|
||||||
|
- Put one fact or instruction in each sentence or list item.
|
||||||
|
- Prefer short sections, lists, and tables.
|
||||||
|
- State the supported path before optional details.
|
||||||
|
- Use `must` for requirements, `can` for optional behavior, and present tense for existing behavior.
|
||||||
|
- Use current commands, symbols, routes, and examples.
|
||||||
|
|
||||||
|
## Constraints and Anti-Patterns
|
||||||
|
|
||||||
|
Keep a negative rule **only when** it prevents a current and plausible mistake with a meaningful cost. This includes:
|
||||||
|
|
||||||
|
- Tooling traps that reduce reproducibility or pollute context
|
||||||
|
- Architecture boundaries enforced by tests
|
||||||
|
- Security, authorization, and destructive-action limits
|
||||||
|
- Commit and dependency-change authorization
|
||||||
|
|
||||||
|
Place the rule near the affected workflow and provide the supported path. Keep one canonical copy when the same audience reads multiple documents.
|
||||||
|
|
||||||
|
Remove or move a negative statement when it describes a hypothetical design, repeats a positive contract, or explains an obsolete implementation. Preserve material history in the relevant ADR instead.
|
||||||
|
|
||||||
|
## Decision Records
|
||||||
|
|
||||||
|
- Record one logical decision per ADR.
|
||||||
|
- Put the status before the body.
|
||||||
|
- Describe requirements and forces in Context.
|
||||||
|
- State the selected design in Decision.
|
||||||
|
- Record current trade-offs in Consequences.
|
||||||
|
- Link superseded records to their replacement.
|
||||||
|
- Use past tense for historical implementation details.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
1. Confirm the document type and audience.
|
||||||
|
2. Verify facts against code and configuration.
|
||||||
|
3. Remove duplicated current-state information.
|
||||||
|
4. Check every negative statement for a current action or decision.
|
||||||
|
5. Verify commands, examples, and local links.
|
||||||
|
6. Run `git diff --check`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [OpenAI Codex best practices](https://learn.chatgpt.com/guides/best-practices)
|
||||||
|
- [Architectural Decision Records](https://adr.github.io/)
|
||||||
+70
-16
@@ -21,48 +21,102 @@ type ErrorResponse struct {
|
|||||||
// ErrorBody contains human-readable error details.
|
// ErrorBody contains human-readable error details.
|
||||||
type ErrorBody struct {
|
type ErrorBody struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
LogID string `json:"log_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error writes a JSON error response.
|
// Error writes a JSON error response.
|
||||||
func Error(c *gin.Context, status int, message string) {
|
func Error(c *gin.Context, status int, message string) {
|
||||||
|
writeError(c, status, message, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(c *gin.Context, status int, message, logID string) {
|
||||||
c.JSON(status, ErrorResponse{
|
c.JSON(status, ErrorResponse{
|
||||||
Error: ErrorBody{
|
Error: ErrorBody{
|
||||||
Message: message,
|
Message: message,
|
||||||
|
LogID: logID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespondError unpacks an error from the service layer and writes a JSON
|
// RespondError unpacks an error from the service layer and writes a JSON
|
||||||
// error response. It expects *model.AppError; unexpected non-AppError
|
// error response. It maps protocol-neutral domain error kinds to HTTP status
|
||||||
// values are treated as 500 with a safe message. For 500+ errors, a
|
// codes at the API boundary. Unexpected non-AppError values are treated as 500
|
||||||
// random reference hash is included in the response so operators can
|
// with a safe message. Recorded errors return a log_id for correlation.
|
||||||
// correlate it with server logs.
|
|
||||||
func RespondError(c *gin.Context, err error) {
|
func RespondError(c *gin.Context, err error) {
|
||||||
var ae *model.AppError
|
var ae *model.AppError
|
||||||
if !errors.As(err, &ae) {
|
if !errors.As(err, &ae) {
|
||||||
ref := randomHex(8)
|
logID := randomHex(8)
|
||||||
slog.ErrorContext(c.Request.Context(), "non-AppError escaped to handler",
|
slog.ErrorContext(c.Request.Context(), "non-AppError escaped to handler",
|
||||||
"ref", ref, "error", err, "type", fmt.Sprintf("%T", err))
|
"log_id", logID, "error", err, "type", fmt.Sprintf("%T", err))
|
||||||
Error(c, http.StatusInternalServerError, "internal server error (ref: "+ref+")")
|
writeError(c, http.StatusInternalServerError, "internal server error", logID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 500+ errors: log full detail with a reference hash, return sanitized message.
|
status := StatusForErrorKind(ae.Kind)
|
||||||
if ae.Status >= 500 {
|
message := ae.Message
|
||||||
ref := randomHex(8)
|
if status >= http.StatusInternalServerError {
|
||||||
|
message = "internal server error"
|
||||||
|
}
|
||||||
|
|
||||||
|
if status >= http.StatusInternalServerError {
|
||||||
|
logID := randomHex(8)
|
||||||
slog.ErrorContext(c.Request.Context(), "internal server error",
|
slog.ErrorContext(c.Request.Context(), "internal server error",
|
||||||
"ref", ref, slog.Any("error", ae.Err), "message", ae.Message)
|
"log_id", logID, slog.Any("error", ae.Err), "message", ae.Message)
|
||||||
Error(c, ae.Status, "internal server error (ref: "+ref+")")
|
writeError(c, status, message, logID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4xx errors with internal cause: log at WARN for diagnostics.
|
// 4xx errors with unexpected causes are recorded for diagnostics and return log_id.
|
||||||
if ae.Err != nil {
|
if isLoggableCause(ae.Err) {
|
||||||
|
logID := randomHex(8)
|
||||||
slog.WarnContext(c.Request.Context(), ae.Message,
|
slog.WarnContext(c.Request.Context(), ae.Message,
|
||||||
"status", ae.Status, slog.Any("error", ae.Err))
|
"log_id", logID, "status", status, slog.Any("error", ae.Err))
|
||||||
|
writeError(c, status, message, logID)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Error(c, ae.Status, ae.Message)
|
writeError(c, status, message, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLoggableCause(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expected := range []error{
|
||||||
|
model.ErrNotFound,
|
||||||
|
model.ErrDuplicate,
|
||||||
|
model.ErrUnauthorized,
|
||||||
|
model.ErrForbidden,
|
||||||
|
model.ErrUploadTooLarge,
|
||||||
|
} {
|
||||||
|
if errors.Is(err, expected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusForErrorKind maps a protocol-neutral error kind to a REST status code.
|
||||||
|
func StatusForErrorKind(kind model.ErrorKind) int {
|
||||||
|
switch kind {
|
||||||
|
case model.KindInvalidArgument:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
case model.KindUnauthenticated:
|
||||||
|
return http.StatusUnauthorized
|
||||||
|
case model.KindPermissionDenied:
|
||||||
|
return http.StatusForbidden
|
||||||
|
case model.KindNotFound:
|
||||||
|
return http.StatusNotFound
|
||||||
|
case model.KindConflict:
|
||||||
|
return http.StatusConflict
|
||||||
|
case model.KindPayloadTooLarge:
|
||||||
|
return http.StatusRequestEntityTooLarge
|
||||||
|
case model.KindInternal:
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func randomHex(n int) string {
|
func randomHex(n int) string {
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestError(t *testing.T) {
|
func TestError(t *testing.T) {
|
||||||
@@ -33,4 +36,66 @@ func TestError(t *testing.T) {
|
|||||||
if body.Error.Message != "invalid request" {
|
if body.Error.Message != "invalid request" {
|
||||||
t.Errorf("message = %q, want %q", body.Error.Message, "invalid request")
|
t.Errorf("message = %q, want %q", body.Error.Message, "invalid request")
|
||||||
}
|
}
|
||||||
|
if body.Error.LogID != "" {
|
||||||
|
t.Errorf("log_id = %q, want empty", body.Error.LogID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRespondErrorMapsDomainKinds(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantStatus int
|
||||||
|
wantLogID bool
|
||||||
|
}{
|
||||||
|
{"invalid argument", model.NewInvalidArgumentError("bad input"), http.StatusBadRequest, false},
|
||||||
|
{"unauthenticated", model.NewUnauthenticatedError("invalid token"), http.StatusUnauthorized, false},
|
||||||
|
{"permission denied", model.NewPermissionDeniedError("access denied", nil), http.StatusForbidden, false},
|
||||||
|
{"not found", model.NewNotFoundError("missing", nil), http.StatusNotFound, false},
|
||||||
|
{"conflict", model.NewConflictError("duplicate"), http.StatusConflict, false},
|
||||||
|
{"payload too large", model.NewPayloadTooLargeError("too large", nil), http.StatusRequestEntityTooLarge, false},
|
||||||
|
{"internal", model.NewInternalError("lookup", errors.New("database offline")), http.StatusInternalServerError, true},
|
||||||
|
{"unknown", errors.New("escaped error"), http.StatusInternalServerError, true},
|
||||||
|
{"permission denied sentinel cause", model.NewPermissionDeniedError("access denied", model.ErrForbidden), http.StatusForbidden, false},
|
||||||
|
{"not found sentinel cause", model.NewNotFoundError("missing", model.ErrNotFound), http.StatusNotFound, false},
|
||||||
|
{"payload too large sentinel cause", model.NewPayloadTooLargeError("too large", model.ErrUploadTooLarge), http.StatusRequestEntityTooLarge, false},
|
||||||
|
{"client with diagnostic cause", model.NewPermissionDeniedError("access denied", errors.New("policy backend failed")), http.StatusForbidden, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
rec := exerciseRespondError(tt.err)
|
||||||
|
if rec.Code != tt.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d; body = %s", rec.Code, tt.wantStatus, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var body ErrorResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantLogID && body.Error.LogID == "" {
|
||||||
|
t.Fatalf("log_id is empty, want populated; body = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if !tt.wantLogID && body.Error.LogID != "" {
|
||||||
|
t.Fatalf("log_id = %q, want empty", body.Error.LogID)
|
||||||
|
}
|
||||||
|
if rec.Code >= http.StatusInternalServerError && body.Error.Message != "internal server error" {
|
||||||
|
t.Fatalf("message = %q, want sanitized internal message", body.Error.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exerciseRespondError(err error) *httptest.ResponseRecorder {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/error", func(c *gin.Context) {
|
||||||
|
RespondError(c, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/error", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-32
@@ -17,14 +17,11 @@ type WebApp struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Version string
|
Version string
|
||||||
|
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
UserRepo repository.UserRepository
|
AuthService *service.AuthService
|
||||||
SessionRepo repository.SessionRepository
|
AdminService *service.AdminService
|
||||||
FileRepo repository.FileRepository
|
FileService *service.FileService
|
||||||
CredentialRepo repository.CredentialRepository
|
Storage storage.StorageBackend
|
||||||
AuthService *service.AuthService
|
|
||||||
FileService *service.FileService
|
|
||||||
Storage storage.StorageBackend
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bootstrap creates a fully initialized WebApp from config.
|
// Bootstrap creates a fully initialized WebApp from config.
|
||||||
@@ -58,42 +55,34 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileService := service.NewFileService(fileRepo, store, cfg.Storage.MaxUploadSize, slog.Default())
|
fileService := service.NewFileService(fileRepo, store, cfg.Storage.MaxUploadSize, slog.Default())
|
||||||
|
adminService := service.NewAdminService(userRepo)
|
||||||
|
|
||||||
return &WebApp{
|
return &WebApp{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Version: AppVersion,
|
Version: AppVersion,
|
||||||
DB: db,
|
DB: db,
|
||||||
UserRepo: userRepo,
|
AuthService: authService,
|
||||||
SessionRepo: sessionRepo,
|
AdminService: adminService,
|
||||||
FileRepo: fileRepo,
|
FileService: fileService,
|
||||||
CredentialRepo: credentialRepo,
|
Storage: store,
|
||||||
AuthService: authService,
|
|
||||||
FileService: fileService,
|
|
||||||
Storage: store,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebApp creates a WebApp with pre-built dependencies (useful for testing).
|
// NewWebApp creates a WebApp with pre-built dependencies (useful for testing).
|
||||||
func NewWebApp(cfg *config.Config, db *gorm.DB,
|
func NewWebApp(cfg *config.Config, db *gorm.DB,
|
||||||
userRepo repository.UserRepository,
|
|
||||||
sessionRepo repository.SessionRepository,
|
|
||||||
fileRepo repository.FileRepository,
|
|
||||||
credentialRepo repository.CredentialRepository,
|
|
||||||
authService *service.AuthService,
|
authService *service.AuthService,
|
||||||
|
adminService *service.AdminService,
|
||||||
fileService *service.FileService,
|
fileService *service.FileService,
|
||||||
store storage.StorageBackend,
|
store storage.StorageBackend,
|
||||||
) *WebApp {
|
) *WebApp {
|
||||||
return &WebApp{
|
return &WebApp{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Version: AppVersion,
|
Version: AppVersion,
|
||||||
DB: db,
|
DB: db,
|
||||||
UserRepo: userRepo,
|
AuthService: authService,
|
||||||
SessionRepo: sessionRepo,
|
AdminService: adminService,
|
||||||
FileRepo: fileRepo,
|
FileService: fileService,
|
||||||
CredentialRepo: credentialRepo,
|
Storage: store,
|
||||||
AuthService: authService,
|
|
||||||
FileService: fileService,
|
|
||||||
Storage: store,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
func TestNewWebApp(t *testing.T) {
|
func TestNewWebApp(t *testing.T) {
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
|
|
||||||
webApp := NewWebApp(cfg, nil, nil, nil, nil, nil, nil, nil, nil)
|
webApp := NewWebApp(cfg, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
if webApp.Config != cfg {
|
if webApp.Config != cfg {
|
||||||
t.Fatal("Config was not assigned")
|
t.Fatal("Config was not assigned")
|
||||||
@@ -20,7 +20,7 @@ func TestNewWebApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCloseNilDB(t *testing.T) {
|
func TestCloseNilDB(t *testing.T) {
|
||||||
webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil, nil, nil, nil)
|
webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil)
|
||||||
if err := webApp.Close(); err != nil {
|
if err := webApp.Close(); err != nil {
|
||||||
t.Errorf("Close with nil DB should not error: %v", err)
|
t.Errorf("Close with nil DB should not error: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package internal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArchitecture_ModelAndServiceAreProtocolNeutral(t *testing.T) {
|
||||||
|
for _, dir := range []string{"model", "service"} {
|
||||||
|
imports := packageImports(t, dir)
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
"net/http",
|
||||||
|
"github.com/gin-gonic/gin",
|
||||||
|
"github.com/dhao2001/mygo/internal/api",
|
||||||
|
} {
|
||||||
|
if imports[forbidden] {
|
||||||
|
t.Fatalf("internal/%s imports forbidden package %q", dir, forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArchitecture_HTTPDoesNotImportRepositories(t *testing.T) {
|
||||||
|
for _, dir := range []string{"handler", "middleware"} {
|
||||||
|
imports := packageImports(t, dir)
|
||||||
|
if imports["github.com/dhao2001/mygo/internal/repository"] {
|
||||||
|
t.Fatalf("internal/%s imports internal/repository; use service boundaries instead", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
imports := map[string]bool{}
|
||||||
|
pkgs := parsePackage(t, dir)
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
for _, file := range pkg.Files {
|
||||||
|
for _, spec := range file.Imports {
|
||||||
|
path, err := strconv.Unquote(spec.Path.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unquote import path %s: %v", spec.Path.Value, err)
|
||||||
|
}
|
||||||
|
imports[path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imports
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePackage(t *testing.T, dir string) map[string]*ast.Package {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool {
|
||||||
|
name := info.Name()
|
||||||
|
return !strings.HasSuffix(name, "_test.go")
|
||||||
|
}, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse internal/%s: %v", dir, err)
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -26,10 +27,29 @@ type createPasskeyRequest struct {
|
|||||||
Label string `json:"label" binding:"required"`
|
Label string `json:"label" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type accountResponse struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passkeyResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createdPasskeyResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
// GetAccount handles GET /api/v1/account.
|
// GetAccount handles GET /api/v1/account.
|
||||||
func (h *AccountHandler) GetAccount(c *gin.Context) {
|
func (h *AccountHandler) GetAccount(c *gin.Context) {
|
||||||
userID := middleware.MustGetUserID(c)
|
userID := middleware.MustGetUserID(c)
|
||||||
c.JSON(http.StatusOK, gin.H{"user_id": userID})
|
c.JSON(http.StatusOK, accountResponse{UserID: userID})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPasskeys handles GET /api/v1/account/passkeys.
|
// ListPasskeys handles GET /api/v1/account/passkeys.
|
||||||
@@ -46,7 +66,7 @@ func (h *AccountHandler) ListPasskeys(c *gin.Context) {
|
|||||||
creds = []model.Credential{}
|
creds = []model.Credential{}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, creds)
|
c.JSON(http.StatusOK, toPasskeyResponses(creds))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePasskey handles POST /api/v1/account/passkeys.
|
// CreatePasskey handles POST /api/v1/account/passkeys.
|
||||||
@@ -66,7 +86,11 @@ func (h *AccountHandler) CreatePasskey(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, pk)
|
c.JSON(http.StatusCreated, createdPasskeyResponse{
|
||||||
|
ID: pk.ID,
|
||||||
|
Raw: pk.Raw,
|
||||||
|
Label: pk.Label,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokePasskey handles DELETE /api/v1/account/passkeys/:id.
|
// RevokePasskey handles DELETE /api/v1/account/passkeys/:id.
|
||||||
@@ -86,3 +110,18 @@ func (h *AccountHandler) RevokePasskey(c *gin.Context) {
|
|||||||
|
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toPasskeyResponses(creds []model.Credential) []passkeyResponse {
|
||||||
|
items := make([]passkeyResponse, 0, len(creds))
|
||||||
|
for i := range creds {
|
||||||
|
items = append(items, passkeyResponse{
|
||||||
|
ID: creds[i].ID,
|
||||||
|
UserID: creds[i].UserID,
|
||||||
|
Type: creds[i].Type,
|
||||||
|
Label: creds[i].Label,
|
||||||
|
LastUsedAt: creds[i].LastUsedAt,
|
||||||
|
CreatedAt: creds[i].CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/middleware"
|
"github.com/dhao2001/mygo/internal/middleware"
|
||||||
"github.com/dhao2001/mygo/internal/model"
|
|
||||||
"github.com/dhao2001/mygo/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) {
|
func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) {
|
||||||
@@ -31,7 +29,7 @@ func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected := r.Group("/api/v1")
|
protected := r.Group("/api/v1")
|
||||||
protected.Use(middleware.AuthRequired(secret))
|
protected.Use(middleware.AuthRequired(svc))
|
||||||
{
|
{
|
||||||
account := protected.Group("/account")
|
account := protected.Group("/account")
|
||||||
{
|
{
|
||||||
@@ -65,7 +63,7 @@ func TestAccountEndpoint(t *testing.T) {
|
|||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var pair service.TokenPair
|
var pair testTokenPairResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||||
|
|
||||||
// Get /account
|
// Get /account
|
||||||
@@ -107,7 +105,7 @@ func TestPasskeyCRUD(t *testing.T) {
|
|||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var pair service.TokenPair
|
var pair testTokenPairResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||||
authHeader := "Bearer " + pair.AccessToken
|
authHeader := "Bearer " + pair.AccessToken
|
||||||
|
|
||||||
@@ -134,7 +132,7 @@ func TestPasskeyCRUD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Revoke passkey
|
// Revoke passkey
|
||||||
var creds []model.Credential
|
var creds []passkeyResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &creds)
|
json.Unmarshal(rec.Body.Bytes(), &creds)
|
||||||
if len(creds) != 1 {
|
if len(creds) != 1 {
|
||||||
t.Fatalf("expected 1 passkey, got %d", len(creds))
|
t.Fatalf("expected 1 passkey, got %d", len(creds))
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ import (
|
|||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/api"
|
"github.com/dhao2001/mygo/internal/api"
|
||||||
"github.com/dhao2001/mygo/internal/model"
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
"github.com/dhao2001/mygo/internal/repository"
|
"github.com/dhao2001/mygo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminHandler handles admin-only endpoints for user management.
|
// AdminHandler handles admin-only endpoints for user management.
|
||||||
type AdminHandler struct {
|
type AdminHandler struct {
|
||||||
userRepo repository.UserRepository
|
adminService *service.AdminService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminHandler creates an AdminHandler.
|
// NewAdminHandler creates an AdminHandler.
|
||||||
func NewAdminHandler(userRepo repository.UserRepository) *AdminHandler {
|
func NewAdminHandler(adminService *service.AdminService) *AdminHandler {
|
||||||
return &AdminHandler{userRepo: userRepo}
|
return &AdminHandler{adminService: adminService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminUserResponse exposes all user fields, including status, for admin views.
|
// adminUserResponse exposes all user fields, including status, for admin views.
|
||||||
@@ -55,7 +55,7 @@ func toAdminResponse(u *model.User) adminUserResponse {
|
|||||||
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
if err := h.userRepo.Delete(c.Request.Context(), id); err != nil {
|
if err := h.adminService.DeleteUser(c.Request.Context(), id); err != nil {
|
||||||
api.RespondError(c, err)
|
api.RespondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
|||||||
func (h *AdminHandler) GetUser(c *gin.Context) {
|
func (h *AdminHandler) GetUser(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
user, err := h.userRepo.FindByIDIncludeDeleted(c.Request.Context(), id)
|
user, err := h.adminService.GetUser(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.RespondError(c, err)
|
api.RespondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -93,7 +93,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
|
|||||||
limit = 200
|
limit = 200
|
||||||
}
|
}
|
||||||
|
|
||||||
users, total, err := h.userRepo.ListIncludeDeleted(c.Request.Context(), offset, limit)
|
users, total, err := h.adminService.ListUsers(c.Request.Context(), offset, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.RespondError(c, err)
|
api.RespondError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import (
|
|||||||
"github.com/dhao2001/mygo/internal/model"
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
"github.com/dhao2001/mygo/internal/repository"
|
"github.com/dhao2001/mygo/internal/repository"
|
||||||
"github.com/dhao2001/mygo/internal/service"
|
"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()
|
t.Helper()
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
@@ -42,7 +43,8 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
authHandler := NewAuthHandler(authService)
|
authHandler := NewAuthHandler(authService)
|
||||||
adminHandler := NewAdminHandler(userRepo)
|
adminService := service.NewAdminService(userRepo)
|
||||||
|
adminHandler := NewAdminHandler(adminService)
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -54,15 +56,15 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
admin := r.Group("/api/v1/admin")
|
admin := r.Group("/api/v1/admin")
|
||||||
admin.Use(middleware.AuthRequired(secret))
|
admin.Use(middleware.AuthRequired(authService))
|
||||||
admin.Use(middleware.AdminRequired(userRepo))
|
admin.Use(middleware.AdminRequired())
|
||||||
{
|
{
|
||||||
admin.GET("/users", adminHandler.ListUsers)
|
admin.GET("/users", adminHandler.ListUsers)
|
||||||
admin.GET("/users/:id", adminHandler.GetUser)
|
admin.GET("/users/:id", adminHandler.GetUser)
|
||||||
admin.DELETE("/users/:id", adminHandler.DeleteUser)
|
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.
|
// registerHelper creates a user via the HTTP endpoint and returns the user ID.
|
||||||
@@ -104,7 +106,7 @@ func loginHelper(t *testing.T, r *gin.Engine, email, password string) string {
|
|||||||
t.Fatalf("login %s: status = %d, body = %s", email, rec.Code, rec.Body.String())
|
t.Fatalf("login %s: status = %d, body = %s", email, rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var pair service.TokenPair
|
var pair testTokenPairResponse
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||||
t.Fatalf("unmarshal login response: %v", err)
|
t.Fatalf("unmarshal login response: %v", err)
|
||||||
}
|
}
|
||||||
@@ -112,24 +114,16 @@ func loginHelper(t *testing.T, r *gin.Engine, email, password string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// makeAdminHelper promotes a user to admin in the database.
|
// 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()
|
t.Helper()
|
||||||
|
testutil.SetUserAdmin(t, db, userID, true)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminDeleteUser(t *testing.T) {
|
func TestAdminDeleteUser(t *testing.T) {
|
||||||
r, userRepo := setupAdminRouter(t)
|
r, _, db := setupAdminRouter(t)
|
||||||
|
|
||||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
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")
|
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||||
|
|
||||||
@@ -158,10 +152,10 @@ func TestAdminDeleteUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminDeleteUserForbidden(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")
|
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")
|
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||||
|
|
||||||
@@ -180,7 +174,7 @@ func TestAdminDeleteUserForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminDeleteUserUnauthorized(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)
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/some-id", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -192,10 +186,10 @@ func TestAdminDeleteUserUnauthorized(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminGetUser(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")
|
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")
|
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||||
|
|
||||||
@@ -230,15 +224,15 @@ func TestAdminGetUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminGetDeletedUser(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")
|
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")
|
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||||
|
|
||||||
// Soft-delete regular user directly via repo
|
// 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)
|
t.Fatalf("soft-delete user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,17 +260,17 @@ func TestAdminGetDeletedUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminListIncludesDeleted(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")
|
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||||
makeAdminHelper(t, userRepo, adminID)
|
makeAdminHelper(t, db, adminID)
|
||||||
|
|
||||||
// Create 2 regular users
|
// Create 2 regular users
|
||||||
_ = registerHelper(t, r, "alice", "alice@example.com", "alicepass123")
|
_ = registerHelper(t, r, "alice", "alice@example.com", "alicepass123")
|
||||||
bobID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
bobID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||||
|
|
||||||
// Soft-delete bob
|
// 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)
|
t.Fatalf("soft-delete user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/api"
|
"github.com/dhao2001/mygo/internal/api"
|
||||||
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
"github.com/dhao2001/mygo/internal/service"
|
"github.com/dhao2001/mygo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +37,20 @@ type tokenRequest struct {
|
|||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type userResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenPairResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
|
||||||
// Register handles POST /api/v1/auth/register.
|
// Register handles POST /api/v1/auth/register.
|
||||||
func (h *AuthHandler) Register(c *gin.Context) {
|
func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
var req registerRequest
|
var req registerRequest
|
||||||
@@ -50,7 +66,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, user)
|
c.JSON(http.StatusCreated, toUserResponse(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login handles POST /api/v1/auth/login.
|
// Login handles POST /api/v1/auth/login.
|
||||||
@@ -68,7 +84,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, pair)
|
c.JSON(http.StatusOK, toTokenPairResponse(pair))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh handles POST /api/v1/auth/refresh.
|
// Refresh handles POST /api/v1/auth/refresh.
|
||||||
@@ -86,7 +102,7 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, pair)
|
c.JSON(http.StatusOK, toTokenPairResponse(pair))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout handles POST /api/v1/auth/logout.
|
// Logout handles POST /api/v1/auth/logout.
|
||||||
@@ -105,3 +121,21 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
|
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toUserResponse(user *model.User) userResponse {
|
||||||
|
return userResponse{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
IsAdmin: user.IsAdmin,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
UpdatedAt: user.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTokenPairResponse(pair *service.TokenPair) tokenPairResponse {
|
||||||
|
return tokenPairResponse{
|
||||||
|
AccessToken: pair.AccessToken,
|
||||||
|
RefreshToken: pair.RefreshToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import (
|
|||||||
"github.com/dhao2001/mygo/internal/service"
|
"github.com/dhao2001/mygo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type testTokenPairResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
|
||||||
func setupTestAuthService(t *testing.T) (*service.AuthService, []byte) {
|
func setupTestAuthService(t *testing.T) (*service.AuthService, []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -140,7 +145,7 @@ func TestLoginHandler(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var pair service.TokenPair
|
var pair testTokenPairResponse
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||||
t.Fatalf("unmarshal response: %v", err)
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
@@ -196,7 +201,7 @@ func TestRefreshHandler(t *testing.T) {
|
|||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var pair service.TokenPair
|
var pair testTokenPairResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
|
|||||||
+162
-17
@@ -1,13 +1,16 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime"
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -17,6 +20,19 @@ import (
|
|||||||
"github.com/dhao2001/mygo/internal/service"
|
"github.com/dhao2001/mygo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
jsonUploadBodyLimit = 64 << 10
|
||||||
|
multipartUploadOverhead = 1 << 20
|
||||||
|
multipartFileField = "file"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errMissingFileField = errors.New("missing file field")
|
||||||
|
errMissingFileName = errors.New("missing file name")
|
||||||
|
errUnexpectedMultipartField = errors.New("unexpected multipart field")
|
||||||
|
errInvalidMultipartForm = errors.New("invalid multipart form")
|
||||||
|
)
|
||||||
|
|
||||||
// FileHandler handles file management endpoints.
|
// FileHandler handles file management endpoints.
|
||||||
type FileHandler struct {
|
type FileHandler struct {
|
||||||
fileService *service.FileService
|
fileService *service.FileService
|
||||||
@@ -37,6 +53,23 @@ type updateFileRequest struct {
|
|||||||
ParentID *string `json:"parent_id"`
|
ParentID *string `json:"parent_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fileInfoResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
ParentID *string `json:"parent_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileListResponse struct {
|
||||||
|
Files []fileInfoResponse `json:"files"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
// Upload handles POST /api/v1/files.
|
// Upload handles POST /api/v1/files.
|
||||||
// If the content type is multipart/form-data, it uploads a file.
|
// If the content type is multipart/form-data, it uploads a file.
|
||||||
// If the content type is application/json, it creates a directory.
|
// If the content type is application/json, it creates a directory.
|
||||||
@@ -47,9 +80,15 @@ func (h *FileHandler) Upload(c *gin.Context) {
|
|||||||
// Directory creation via JSON.
|
// Directory creation via JSON.
|
||||||
mediaType, _, _ := mime.ParseMediaType(contentType)
|
mediaType, _, _ := mime.ParseMediaType(contentType)
|
||||||
if mediaType == "application/json" {
|
if mediaType == "application/json" {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, jsonUploadBodyLimit)
|
||||||
|
|
||||||
var req createDirRequest
|
var req createDirRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
slog.DebugContext(c.Request.Context(), "create dir bind failed", "error", err)
|
slog.DebugContext(c.Request.Context(), "create dir bind failed", "error", err)
|
||||||
|
if isRequestBodyTooLarge(err) {
|
||||||
|
api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
api.Error(c, http.StatusBadRequest, "invalid request body")
|
api.Error(c, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,44 +99,109 @@ func (h *FileHandler) Upload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, dir)
|
c.JSON(http.StatusCreated, toFileInfoResponse(dir))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// File upload via multipart.
|
// File upload via multipart.
|
||||||
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
|
if mediaType != "multipart/form-data" {
|
||||||
slog.DebugContext(c.Request.Context(), "parse multipart form failed", "error", err)
|
api.Error(c, http.StatusBadRequest, "unsupported content type")
|
||||||
api.Error(c, http.StatusBadRequest, "invalid multipart form")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
parentIDStr := c.PostForm("parent_id")
|
if maxUploadSize := h.fileService.MaxUploadSize(); maxUploadSize > 0 {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize+multipartUploadOverhead)
|
||||||
|
}
|
||||||
|
|
||||||
var parentID *string
|
var parentID *string
|
||||||
|
parentIDStr := c.Query("parent_id")
|
||||||
if parentIDStr != "" {
|
if parentIDStr != "" {
|
||||||
parentID = &parentIDStr
|
parentID = &parentIDStr
|
||||||
}
|
}
|
||||||
|
|
||||||
header, err := c.FormFile("file")
|
reader, err := c.Request.MultipartReader()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.DebugContext(c.Request.Context(), "form file missing", "error", err)
|
slog.DebugContext(c.Request.Context(), "create multipart reader failed", "error", err)
|
||||||
api.Error(c, http.StatusBadRequest, "missing file field")
|
if isRequestBodyTooLarge(err) {
|
||||||
|
api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Error(c, http.StatusBadRequest, "invalid multipart form")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := header.Open()
|
part, fileName, err := nextUploadFilePart(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.RespondError(c, model.NewInternalError("open uploaded file", err))
|
slog.DebugContext(c.Request.Context(), "read multipart file part failed", "error", err)
|
||||||
|
if isRequestBodyTooLarge(err) {
|
||||||
|
api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Error(c, http.StatusBadRequest, uploadPartErrorMessage(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer part.Close()
|
||||||
|
|
||||||
info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, header.Filename, file)
|
info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, fileName, uploadBodyReader{reader: part})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.RespondError(c, err)
|
api.RespondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, info)
|
c.JSON(http.StatusCreated, toFileInfoResponse(info))
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextUploadFilePart(reader *multipart.Reader) (*multipart.Part, string, error) {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil, "", errMissingFileField
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("%w: %v", errInvalidMultipartForm, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if part.FormName() != multipartFileField {
|
||||||
|
part.Close()
|
||||||
|
return nil, "", fmt.Errorf("%w: %q", errUnexpectedMultipartField, part.FormName())
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := part.FileName()
|
||||||
|
if fileName == "" {
|
||||||
|
part.Close()
|
||||||
|
return nil, "", errMissingFileName
|
||||||
|
}
|
||||||
|
|
||||||
|
return part, fileName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadPartErrorMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errMissingFileField):
|
||||||
|
return "missing file field"
|
||||||
|
case errors.Is(err, errMissingFileName):
|
||||||
|
return "missing file name"
|
||||||
|
case errors.Is(err, errUnexpectedMultipartField):
|
||||||
|
return "unexpected multipart field"
|
||||||
|
default:
|
||||||
|
return "invalid multipart form"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRequestBodyTooLarge(err error) bool {
|
||||||
|
var maxBytesErr *http.MaxBytesError
|
||||||
|
return errors.As(err, &maxBytesErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadBodyReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r uploadBodyReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := r.reader.Read(p)
|
||||||
|
if isRequestBodyTooLarge(err) {
|
||||||
|
return n, model.ErrUploadTooLarge
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// List handles GET /api/v1/files.
|
// List handles GET /api/v1/files.
|
||||||
@@ -131,7 +235,7 @@ func (h *FileHandler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, list)
|
c.JSON(http.StatusOK, toFileListResponse(list))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get handles GET /api/v1/files/:id.
|
// Get handles GET /api/v1/files/:id.
|
||||||
@@ -145,7 +249,7 @@ func (h *FileHandler) Get(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, info)
|
c.JSON(http.StatusOK, toFileInfoResponse(info))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download handles GET /api/v1/files/:id/content.
|
// Download handles GET /api/v1/files/:id/content.
|
||||||
@@ -170,7 +274,23 @@ func (h *FileHandler) Download(c *gin.Context) {
|
|||||||
c.Header("Content-Length", strconv.FormatInt(info.Size, 10))
|
c.Header("Content-Length", strconv.FormatInt(info.Size, 10))
|
||||||
|
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
io.Copy(c.Writer, reader)
|
written, err := io.Copy(c.Writer, reader)
|
||||||
|
if err != nil {
|
||||||
|
slog.WarnContext(c.Request.Context(), "download copy failed",
|
||||||
|
"user_id", userID,
|
||||||
|
"file_id", fileID,
|
||||||
|
"written", written,
|
||||||
|
"expected_size", info.Size,
|
||||||
|
"error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if written != info.Size {
|
||||||
|
slog.WarnContext(c.Request.Context(), "download size mismatch",
|
||||||
|
"user_id", userID,
|
||||||
|
"file_id", fileID,
|
||||||
|
"written", written,
|
||||||
|
"expected_size", info.Size)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update handles PUT /api/v1/files/:id.
|
// Update handles PUT /api/v1/files/:id.
|
||||||
@@ -197,7 +317,7 @@ func (h *FileHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, info)
|
c.JSON(http.StatusOK, toFileInfoResponse(info))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete handles DELETE /api/v1/files/:id.
|
// Delete handles DELETE /api/v1/files/:id.
|
||||||
@@ -212,3 +332,28 @@ func (h *FileHandler) Delete(c *gin.Context) {
|
|||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toFileInfoResponse(info *service.FileInfo) fileInfoResponse {
|
||||||
|
return fileInfoResponse{
|
||||||
|
ID: info.ID,
|
||||||
|
UserID: info.UserID,
|
||||||
|
ParentID: info.ParentID,
|
||||||
|
Name: info.Name,
|
||||||
|
Size: info.Size,
|
||||||
|
MimeType: info.MimeType,
|
||||||
|
IsDir: info.IsDir,
|
||||||
|
CreatedAt: info.CreatedAt,
|
||||||
|
UpdatedAt: info.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFileListResponse(list *service.FileList) fileListResponse {
|
||||||
|
items := make([]fileInfoResponse, 0, len(list.Files))
|
||||||
|
for i := range list.Files {
|
||||||
|
items = append(items, toFileInfoResponse(&list.Files[i]))
|
||||||
|
}
|
||||||
|
return fileListResponse{
|
||||||
|
Files: items,
|
||||||
|
Total: list.Total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+262
-42
@@ -4,10 +4,12 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -20,16 +22,19 @@ import (
|
|||||||
"github.com/dhao2001/mygo/internal/storage"
|
"github.com/dhao2001/mygo/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testUserIDHeader = "X-Test-User-ID"
|
||||||
|
|
||||||
// inMemStore implements storage.StorageBackend with an in-memory map.
|
// inMemStore implements storage.StorageBackend with an in-memory map.
|
||||||
type inMemStore struct {
|
type inMemStore struct {
|
||||||
files map[string][]byte
|
files map[string][]byte
|
||||||
|
failReads bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newInMemStore() *inMemStore {
|
func newInMemStore() *inMemStore {
|
||||||
return &inMemStore{files: make(map[string][]byte)}
|
return &inMemStore{files: make(map[string][]byte)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *inMemStore) Save(_ context.Context, path string, reader io.Reader) (int64, error) {
|
func (s *inMemStore) SaveStaged(_ context.Context, path string, reader io.Reader) (int64, error) {
|
||||||
data, err := io.ReadAll(reader)
|
data, err := io.ReadAll(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -38,14 +43,31 @@ func (s *inMemStore) Save(_ context.Context, path string, reader io.Reader) (int
|
|||||||
return int64(len(data)), nil
|
return int64(len(data)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *inMemStore) PromoteStaged(_ context.Context, stagedPath, finalPath string) error {
|
||||||
|
data, ok := s.files[stagedPath]
|
||||||
|
if !ok {
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
s.files[finalPath] = data
|
||||||
|
delete(s.files, stagedPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *inMemStore) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
func (s *inMemStore) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
||||||
data, ok := s.files[path]
|
data, ok := s.files[path]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, &fsNotFoundError{path}
|
return nil, &fsNotFoundError{path}
|
||||||
}
|
}
|
||||||
|
if s.failReads {
|
||||||
|
return io.NopCloser(&errorAfterReader{data: data, err: errors.New("download read failed")}), nil
|
||||||
|
}
|
||||||
return io.NopCloser(bytes.NewReader(data)), nil
|
return io.NopCloser(bytes.NewReader(data)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *inMemStore) DeleteStaged(ctx context.Context, path string) error {
|
||||||
|
return s.Delete(ctx, path)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *inMemStore) Delete(_ context.Context, path string) error {
|
func (s *inMemStore) Delete(_ context.Context, path string) error {
|
||||||
delete(s.files, path)
|
delete(s.files, path)
|
||||||
return nil
|
return nil
|
||||||
@@ -55,9 +77,27 @@ type fsNotFoundError struct{ path string }
|
|||||||
|
|
||||||
func (e *fsNotFoundError) Error() string { return "file not found on disk: " + e.path }
|
func (e *fsNotFoundError) Error() string { return "file not found on disk: " + e.path }
|
||||||
|
|
||||||
|
type errorAfterReader struct {
|
||||||
|
data []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *errorAfterReader) Read(p []byte) (int, error) {
|
||||||
|
if len(r.data) == 0 {
|
||||||
|
return 0, r.err
|
||||||
|
}
|
||||||
|
n := copy(p, r.data)
|
||||||
|
r.data = r.data[n:]
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
var _ storage.StorageBackend = (*inMemStore)(nil)
|
var _ storage.StorageBackend = (*inMemStore)(nil)
|
||||||
|
|
||||||
func setupFileHandler(t *testing.T) *FileHandler {
|
func setupFileHandler(t *testing.T) (*FileHandler, *inMemStore) {
|
||||||
|
return setupFileHandlerWithMaxUploadSize(t, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupFileHandlerWithMaxUploadSize(t *testing.T, maxUploadSize int64) (*FileHandler, *inMemStore) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
@@ -70,22 +110,31 @@ func setupFileHandler(t *testing.T) *FileHandler {
|
|||||||
|
|
||||||
fileRepo := repository.NewFileRepository(db)
|
fileRepo := repository.NewFileRepository(db)
|
||||||
store := newInMemStore()
|
store := newInMemStore()
|
||||||
fileService := service.NewFileService(fileRepo, store, 0, nil)
|
fileService := service.NewFileService(fileRepo, store, maxUploadSize, nil)
|
||||||
|
|
||||||
return NewFileHandler(fileService)
|
return NewFileHandler(fileService), store
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupFileRouter(t *testing.T) *gin.Engine {
|
func setupFileRouter(t *testing.T) *gin.Engine {
|
||||||
|
return setupFileRouterWithMaxUploadSize(t, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupFileRouterWithMaxUploadSize(t *testing.T, maxUploadSize int64) *gin.Engine {
|
||||||
|
r, _ := setupFileRouterWithMaxUploadSizeAndStore(t, maxUploadSize)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupFileRouterWithMaxUploadSizeAndStore(t *testing.T, maxUploadSize int64) (*gin.Engine, *inMemStore) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
handler := setupFileHandler(t)
|
handler, store := setupFileHandlerWithMaxUploadSize(t, maxUploadSize)
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
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) {
|
r.Use(func(c *gin.Context) {
|
||||||
userID := c.GetHeader("X-User-ID")
|
userID := c.GetHeader(testUserIDHeader)
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
c.Set("user_id", userID)
|
c.Set("user_id", userID)
|
||||||
}
|
}
|
||||||
@@ -102,7 +151,7 @@ func setupFileRouter(t *testing.T) *gin.Engine {
|
|||||||
files.DELETE("/:id", handler.Delete)
|
files.DELETE("/:id", handler.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r, store
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileHandler_Upload(t *testing.T) {
|
func TestFileHandler_Upload(t *testing.T) {
|
||||||
@@ -116,7 +165,7 @@ func TestFileHandler_Upload(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -124,7 +173,7 @@ func TestFileHandler_Upload(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var info service.FileInfo
|
var info fileInfoResponse
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -133,6 +182,66 @@ func TestFileHandler_Upload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileHandler_UploadRejectsOversizedFile(t *testing.T) {
|
||||||
|
r := setupFileRouterWithMaxUploadSize(t, 5)
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, _ := writer.CreateFormFile("file", "too-large.txt")
|
||||||
|
part.Write([]byte("123456"))
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileHandler_UploadAllowsExactMaxUploadSize(t *testing.T) {
|
||||||
|
r := setupFileRouterWithMaxUploadSize(t, 5)
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, _ := writer.CreateFormFile("file", "exact.txt")
|
||||||
|
part.Write([]byte("12345"))
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileHandler_UploadUnlimitedWhenMaxUploadSizeIsZero(t *testing.T) {
|
||||||
|
r := setupFileRouter(t)
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, _ := writer.CreateFormFile("file", "unlimited.txt")
|
||||||
|
part.Write([]byte(strings.Repeat("a", 128)))
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileHandler_UploadNoFile(t *testing.T) {
|
func TestFileHandler_UploadNoFile(t *testing.T) {
|
||||||
r := setupFileRouter(t)
|
r := setupFileRouter(t)
|
||||||
|
|
||||||
@@ -142,7 +251,7 @@ func TestFileHandler_UploadNoFile(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -151,13 +260,59 @@ func TestFileHandler_UploadNoFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileHandler_UploadUnexpectedFieldDoesNotEchoFieldName(t *testing.T) {
|
||||||
|
r := setupFileRouter(t)
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, _ := writer.CreateFormField("secret_token")
|
||||||
|
part.Write([]byte("secret"))
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "unexpected multipart field") {
|
||||||
|
t.Errorf("body = %s, want safe unexpected field message", rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "secret_token") {
|
||||||
|
t.Errorf("body leaks multipart field name: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileHandler_UploadMalformedMultipartDoesNotLeakParserError(t *testing.T) {
|
||||||
|
r := setupFileRouter(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(testUserIDHeader, "user1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "invalid multipart form") {
|
||||||
|
t.Errorf("body = %s, want safe invalid multipart message", rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "NextPart") || strings.Contains(rec.Body.String(), "EOF") {
|
||||||
|
t.Errorf("body leaks parser internals: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileHandler_CreateDir(t *testing.T) {
|
func TestFileHandler_CreateDir(t *testing.T) {
|
||||||
r := setupFileRouter(t)
|
r := setupFileRouter(t)
|
||||||
|
|
||||||
body, _ := json.Marshal(gin.H{"name": "mydir"})
|
body, _ := json.Marshal(gin.H{"name": "mydir"})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -165,7 +320,7 @@ func TestFileHandler_CreateDir(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var info service.FileInfo
|
var info fileInfoResponse
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -186,13 +341,13 @@ func TestFileHandler_List(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
// List files.
|
// List files.
|
||||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files", nil)
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/files", nil)
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -200,7 +355,7 @@ func TestFileHandler_List(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var list service.FileList
|
var list fileListResponse
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -221,16 +376,16 @@ func TestFileHandler_Get(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
// Get metadata.
|
// Get metadata.
|
||||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
|
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()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -238,7 +393,7 @@ func TestFileHandler_Get(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
var info service.FileInfo
|
var info fileInfoResponse
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -251,7 +406,7 @@ func TestFileHandler_GetNotFound(t *testing.T) {
|
|||||||
r := setupFileRouter(t)
|
r := setupFileRouter(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/files/nonexistent", nil)
|
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()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -273,16 +428,16 @@ func TestFileHandler_Download(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
// Download.
|
// Download.
|
||||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID+"/content", nil)
|
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()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -297,6 +452,42 @@ func TestFileHandler_Download(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileHandler_DownloadReadErrorAfterResponseStarted(t *testing.T) {
|
||||||
|
r, store := setupFileRouterWithMaxUploadSizeAndStore(t, 0)
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, _ := writer.CreateFormFile("file", "download-error.txt")
|
||||||
|
content := []byte("partial response")
|
||||||
|
part.Write(content)
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
var uploaded fileInfoResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil {
|
||||||
|
t.Fatalf("unmarshal upload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.failReads = true
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID+"/content", nil)
|
||||||
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rec.Body.Bytes(), content) {
|
||||||
|
t.Errorf("content = %q, want %q", rec.Body.String(), content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileHandler_Update(t *testing.T) {
|
func TestFileHandler_Update(t *testing.T) {
|
||||||
r := setupFileRouter(t)
|
r := setupFileRouter(t)
|
||||||
|
|
||||||
@@ -309,18 +500,18 @@ func TestFileHandler_Update(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
// Rename.
|
// Rename.
|
||||||
updateBody, _ := json.Marshal(gin.H{"name": "newname.txt"})
|
updateBody, _ := json.Marshal(gin.H{"name": "newname.txt"})
|
||||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody))
|
req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -328,7 +519,7 @@ func TestFileHandler_Update(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var updated service.FileInfo
|
var updated fileInfoResponse
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -349,17 +540,17 @@ func TestFileHandler_UpdateNoFields(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
updateBody, _ := json.Marshal(gin.H{})
|
updateBody, _ := json.Marshal(gin.H{})
|
||||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody))
|
req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -380,16 +571,16 @@ func TestFileHandler_Delete(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
// Delete.
|
// Delete.
|
||||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/files/"+uploaded.ID, nil)
|
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()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -397,9 +588,19 @@ func TestFileHandler_Delete(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String())
|
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.
|
// Verify gone.
|
||||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
|
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()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -408,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)
|
r := setupFileRouter(t)
|
||||||
|
|
||||||
// Upload as user1.
|
// Upload as user1.
|
||||||
@@ -420,20 +634,26 @@ func TestFileHandler_ForbiddenAccess(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
req.Header.Set("X-User-ID", "user1")
|
req.Header.Set(testUserIDHeader, "user1")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
// Try to access as user2.
|
// Try to access as user2.
|
||||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
|
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()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package log
|
package logging
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package log
|
package logging
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
+42
-30
@@ -1,21 +1,28 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/api"
|
"github.com/dhao2001/mygo/internal/api"
|
||||||
"github.com/dhao2001/mygo/internal/auth"
|
"github.com/dhao2001/mygo/internal/service"
|
||||||
"github.com/dhao2001/mygo/internal/repository"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const userIDKey = "user_id"
|
const (
|
||||||
|
userIDKey = "user_id"
|
||||||
|
principalKey = "principal"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accessAuthenticator interface {
|
||||||
|
AuthenticateAccessToken(ctx context.Context, token string) (*service.Principal, error)
|
||||||
|
}
|
||||||
|
|
||||||
// AuthRequired returns a Gin middleware that validates JWT access tokens.
|
// AuthRequired returns a Gin middleware that validates JWT access tokens.
|
||||||
// On success, it injects the user ID into the context via c.Get("user_id").
|
// On success, it injects the principal and user ID into the context.
|
||||||
func AuthRequired(jwtSecret []byte) gin.HandlerFunc {
|
func AuthRequired(authenticator accessAuthenticator) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
header := c.GetHeader("Authorization")
|
header := c.GetHeader("Authorization")
|
||||||
if header == "" {
|
if header == "" {
|
||||||
@@ -31,20 +38,15 @@ func AuthRequired(jwtSecret []byte) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := auth.ParseToken(parts[1], jwtSecret)
|
principal, err := authenticator.AuthenticateAccessToken(c.Request.Context(), parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.Error(c, http.StatusUnauthorized, "invalid or expired token")
|
api.RespondError(c, err)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims.Type != auth.TokenAccess {
|
c.Set(principalKey, *principal)
|
||||||
api.Error(c, http.StatusUnauthorized, "invalid token type")
|
c.Set(userIDKey, principal.UserID)
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Set(userIDKey, claims.UserID)
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,32 +67,42 @@ func GetUserID(c *gin.Context) string {
|
|||||||
func MustGetUserID(c *gin.Context) string {
|
func MustGetUserID(c *gin.Context) string {
|
||||||
userID := GetUserID(c)
|
userID := GetUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
panic("user_id not found in context — is AuthRequired middleware applied?")
|
panic("user_id not found in context - is AuthRequired middleware applied?")
|
||||||
}
|
}
|
||||||
return userID
|
return userID
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminRequired returns a Gin middleware that gates access to admin-only
|
// GetPrincipal extracts the authenticated principal injected by AuthRequired.
|
||||||
// endpoints. It must be applied AFTER AuthRequired. It fetches the user from
|
func GetPrincipal(c *gin.Context) (service.Principal, bool) {
|
||||||
// the repository, and returns 403 if the user is not an admin. Soft-deleted
|
v, ok := c.Get(principalKey)
|
||||||
// users are not found by FindByID and will receive a 401 response.
|
if !ok || v == nil {
|
||||||
func AdminRequired(userRepo repository.UserRepository) gin.HandlerFunc {
|
return service.Principal{}, false
|
||||||
|
}
|
||||||
|
principal, ok := v.(service.Principal)
|
||||||
|
return principal, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustGetPrincipal extracts the authenticated principal injected by AuthRequired.
|
||||||
|
func MustGetPrincipal(c *gin.Context) service.Principal {
|
||||||
|
principal, ok := GetPrincipal(c)
|
||||||
|
if !ok {
|
||||||
|
panic("principal not found in context - is AuthRequired middleware applied?")
|
||||||
|
}
|
||||||
|
return principal
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRequired returns a Gin middleware that gates access to admin-only endpoints.
|
||||||
|
// It must be applied after AuthRequired.
|
||||||
|
func AdminRequired() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
userID := GetUserID(c)
|
principal, ok := GetPrincipal(c)
|
||||||
if userID == "" {
|
if !ok {
|
||||||
api.Error(c, http.StatusUnauthorized, "missing user context")
|
api.Error(c, http.StatusUnauthorized, "missing user context")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := userRepo.FindByID(c.Request.Context(), userID)
|
if !principal.IsAdmin {
|
||||||
if err != nil {
|
|
||||||
api.Error(c, http.StatusUnauthorized, "user not found")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !user.IsAdmin {
|
|
||||||
api.Error(c, http.StatusForbidden, "admin access required")
|
api.Error(c, http.StatusForbidden, "admin access required")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,29 +7,47 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/auth"
|
|
||||||
"github.com/dhao2001/mygo/internal/model"
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
"github.com/dhao2001/mygo/internal/repository"
|
"github.com/dhao2001/mygo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestRouter(secret []byte) *gin.Engine {
|
type stubAccessAuthenticator struct {
|
||||||
|
wantToken string
|
||||||
|
principal service.Principal
|
||||||
|
err error
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubAccessAuthenticator) AuthenticateAccessToken(_ context.Context, token string) (*service.Principal, error) {
|
||||||
|
s.called = true
|
||||||
|
if s.wantToken != "" && token != s.wantToken {
|
||||||
|
return nil, model.NewUnauthenticatedError("invalid token")
|
||||||
|
}
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
return &s.principal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestRouter(authenticator accessAuthenticator) *gin.Engine {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(AuthRequired(secret))
|
r.Use(AuthRequired(authenticator))
|
||||||
r.GET("/protected", func(c *gin.Context) {
|
r.GET("/protected", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"user_id": MustGetUserID(c)})
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user_id": MustGetUserID(c),
|
||||||
|
"is_admin": MustGetPrincipal(c).IsAdmin,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthRequiredNoHeader(t *testing.T) {
|
func TestAuthRequiredNoHeader(t *testing.T) {
|
||||||
r := setupTestRouter([]byte("test-secret"))
|
authenticator := &stubAccessAuthenticator{}
|
||||||
|
r := setupTestRouter(authenticator)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -38,10 +56,14 @@ func TestAuthRequiredNoHeader(t *testing.T) {
|
|||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
if authenticator.called {
|
||||||
|
t.Fatal("authenticator should not be called without a bearer token")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthRequiredInvalidFormat(t *testing.T) {
|
func TestAuthRequiredInvalidFormat(t *testing.T) {
|
||||||
r := setupTestRouter([]byte("test-secret"))
|
authenticator := &stubAccessAuthenticator{}
|
||||||
|
r := setupTestRouter(authenticator)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
req.Header.Set("Authorization", "invalid")
|
req.Header.Set("Authorization", "invalid")
|
||||||
@@ -51,10 +73,14 @@ func TestAuthRequiredInvalidFormat(t *testing.T) {
|
|||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
if authenticator.called {
|
||||||
|
t.Fatal("authenticator should not be called for malformed authorization header")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthRequiredNotBearer(t *testing.T) {
|
func TestAuthRequiredNotBearer(t *testing.T) {
|
||||||
r := setupTestRouter([]byte("test-secret"))
|
authenticator := &stubAccessAuthenticator{}
|
||||||
|
r := setupTestRouter(authenticator)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
||||||
@@ -64,105 +90,51 @@ func TestAuthRequiredNotBearer(t *testing.T) {
|
|||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
if authenticator.called {
|
||||||
|
t.Fatal("authenticator should not be called for non-bearer authorization")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthRequiredExpiredToken(t *testing.T) {
|
func TestAuthRequiredServiceRejectsToken(t *testing.T) {
|
||||||
secret := []byte("test-secret")
|
authenticator := &stubAccessAuthenticator{err: model.NewUnauthenticatedError("invalid or expired token")}
|
||||||
token, err := auth.GenerateAccessToken("user-1", secret, -1*time.Minute)
|
r := setupTestRouter(authenticator)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateAccessToken = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := setupTestRouter(secret)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer expired")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
if !authenticator.called {
|
||||||
|
t.Fatal("authenticator was not called")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthRequiredValidToken(t *testing.T) {
|
func TestAuthRequiredValidToken(t *testing.T) {
|
||||||
secret := []byte("test-secret")
|
authenticator := &stubAccessAuthenticator{
|
||||||
token, err := auth.GenerateAccessToken("user-1", secret, 15*time.Minute)
|
wantToken: "valid",
|
||||||
if err != nil {
|
principal: service.Principal{
|
||||||
t.Fatalf("GenerateAccessToken = %v", err)
|
UserID: "user-1",
|
||||||
|
IsAdmin: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
r := setupTestRouter(authenticator)
|
||||||
|
|
||||||
r := setupTestRouter(secret)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer valid")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
if !strings.Contains(rec.Body.String(), "user-1") {
|
||||||
|
t.Errorf("response body %q does not contain user id", rec.Body.String())
|
||||||
func TestAuthRequiredRefreshTokenRejected(t *testing.T) {
|
|
||||||
secret := []byte("test-secret")
|
|
||||||
token, err := auth.GenerateRefreshToken("user-1", secret, 7*24*time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateRefreshToken = %v", err)
|
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "true") {
|
||||||
r := setupTestRouter(secret)
|
t.Errorf("response body %q does not contain admin flag", rec.Body.String())
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("status = %d, want %d (refresh token should be rejected)", rec.Code, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetUserID(t *testing.T) {
|
|
||||||
secret := []byte("test-secret")
|
|
||||||
token, err := auth.GenerateAccessToken("alice-42", secret, 15*time.Minute)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateAccessToken = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := setupTestRouter(secret)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "alice-42") {
|
|
||||||
t.Errorf("response body %q does not contain user id", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMustGetUserID(t *testing.T) {
|
|
||||||
secret := []byte("test-secret")
|
|
||||||
token, err := auth.GenerateAccessToken("bob-99", secret, 15*time.Minute)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateAccessToken = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := setupTestRouter(secret)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "bob-99") {
|
|
||||||
t.Errorf("response body %q does not contain user id", body)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +142,7 @@ func TestMustGetUserIDPanics(t *testing.T) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.GET("/naked", func(c *gin.Context) {
|
r.GET("/naked", func(c *gin.Context) {
|
||||||
MustGetUserID(c) // should panic — no AuthRequired middleware applied
|
MustGetUserID(c)
|
||||||
})
|
})
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/naked", nil)
|
req := httptest.NewRequest(http.MethodGet, "/naked", nil)
|
||||||
@@ -184,75 +156,27 @@ func TestMustGetUserIDPanics(t *testing.T) {
|
|||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthRequiredWrongSecret(t *testing.T) {
|
|
||||||
secret := []byte("test-secret")
|
|
||||||
token, err := auth.GenerateAccessToken("user-1", secret, 15*time.Minute)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateAccessToken = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a different secret for the middleware
|
|
||||||
r := setupTestRouter([]byte("different-secret"))
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- AdminRequired tests ---
|
|
||||||
|
|
||||||
// errorBody extracts the error.message field from a JSON response body.
|
|
||||||
type errorBody struct {
|
type errorBody struct {
|
||||||
Error struct {
|
Error struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupAdminTestDB(t *testing.T) repository.UserRepository {
|
func injectPrincipalMiddleware(principal *service.Principal) gin.HandlerFunc {
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
|
||||||
t.Fatalf("migrate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return repository.NewUserRepository(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
// injectUserIDMiddleware simulates AuthRequired by injecting a user_id into the context.
|
|
||||||
func injectUserIDMiddleware(userID string) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Set(userIDKey, userID)
|
if principal != nil {
|
||||||
|
c.Set(principalKey, *principal)
|
||||||
|
c.Set(userIDKey, principal.UserID)
|
||||||
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRequired_AdminPasses(t *testing.T) {
|
func TestAdminRequired_AdminPasses(t *testing.T) {
|
||||||
repo := setupAdminTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
user := &model.User{
|
|
||||||
ID: "admin-1",
|
|
||||||
Username: "admin",
|
|
||||||
Email: "admin@example.com",
|
|
||||||
IsAdmin: true,
|
|
||||||
Status: model.StatusActive,
|
|
||||||
}
|
|
||||||
if err := repo.Create(ctx, user); err != nil {
|
|
||||||
t.Fatalf("Create = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(injectUserIDMiddleware("admin-1"))
|
r.Use(injectPrincipalMiddleware(&service.Principal{UserID: "admin-1", IsAdmin: true}))
|
||||||
r.Use(AdminRequired(repo))
|
r.Use(AdminRequired())
|
||||||
r.GET("/admin", func(c *gin.Context) {
|
r.GET("/admin", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
@@ -267,24 +191,10 @@ func TestAdminRequired_AdminPasses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRequired_NonAdminForbidden(t *testing.T) {
|
func TestAdminRequired_NonAdminForbidden(t *testing.T) {
|
||||||
repo := setupAdminTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
user := &model.User{
|
|
||||||
ID: "user-1",
|
|
||||||
Username: "regular",
|
|
||||||
Email: "regular@example.com",
|
|
||||||
IsAdmin: false,
|
|
||||||
Status: model.StatusActive,
|
|
||||||
}
|
|
||||||
if err := repo.Create(ctx, user); err != nil {
|
|
||||||
t.Fatalf("Create = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(injectUserIDMiddleware("user-1"))
|
r.Use(injectPrincipalMiddleware(&service.Principal{UserID: "user-1", IsAdmin: false}))
|
||||||
r.Use(AdminRequired(repo))
|
r.Use(AdminRequired())
|
||||||
r.GET("/admin", func(c *gin.Context) {
|
r.GET("/admin", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
@@ -306,49 +216,10 @@ func TestAdminRequired_NonAdminForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRequired_SoftDeletedAdmin(t *testing.T) {
|
func TestAdminRequired_NoPrincipal(t *testing.T) {
|
||||||
repo := setupAdminTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
user := &model.User{
|
|
||||||
ID: "admin-2",
|
|
||||||
Username: "deleted_admin",
|
|
||||||
Email: "deleted_admin@example.com",
|
|
||||||
IsAdmin: true,
|
|
||||||
Status: model.StatusActive,
|
|
||||||
}
|
|
||||||
if err := repo.Create(ctx, user); err != nil {
|
|
||||||
t.Fatalf("Create = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Soft-delete the admin user
|
|
||||||
if err := repo.Delete(ctx, "admin-2"); err != nil {
|
|
||||||
t.Fatalf("Delete = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(injectUserIDMiddleware("admin-2"))
|
r.Use(AdminRequired())
|
||||||
r.Use(AdminRequired(repo))
|
|
||||||
r.GET("/admin", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminRequired_NoUserID(t *testing.T) {
|
|
||||||
repo := setupAdminTestDB(t)
|
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
r := gin.New()
|
|
||||||
r.Use(AdminRequired(repo))
|
|
||||||
r.GET("/admin", func(c *gin.Context) {
|
r.GET("/admin", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
mygolog "github.com/dhao2001/mygo/internal/log"
|
"github.com/dhao2001/mygo/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RequestID returns a Gin middleware that ensures every request has a
|
// RequestID returns a Gin middleware that ensures every request has a
|
||||||
@@ -19,7 +19,7 @@ func RequestID() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inject into Go context for slog.*Context calls.
|
// Inject into Go context for slog.*Context calls.
|
||||||
c.Request = c.Request.WithContext(mygolog.WithRequestID(c.Request.Context(), reqID))
|
c.Request = c.Request.WithContext(logging.WithRequestID(c.Request.Context(), reqID))
|
||||||
|
|
||||||
// Also set in Gin context for direct access.
|
// Also set in Gin context for direct access.
|
||||||
c.Set("req_id", reqID)
|
c.Set("req_id", reqID)
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CredentialTypeAppPasskey identifies an application passkey credential.
|
||||||
|
const CredentialTypeAppPasskey = "app_passkey"
|
||||||
|
|
||||||
// Credential represents an alternative authentication credential for a user.
|
// Credential represents an alternative authentication credential for a user.
|
||||||
// The primary password is stored on the User model; additional credentials
|
// The primary password is stored on the User model; additional credentials
|
||||||
// (app passkeys, WebAuthn, OAuth) are stored here with a type discriminator.
|
// (app passkeys, WebAuthn, OAuth) are stored here with a type discriminator.
|
||||||
type Credential struct {
|
type Credential struct {
|
||||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||||
UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"`
|
UserID string `gorm:"index;type:varchar(36);not null"`
|
||||||
Type string `gorm:"index;type:varchar(32);not null" json:"type"`
|
Type string `gorm:"index;type:varchar(32);not null"`
|
||||||
Label string `gorm:"type:varchar(128)" json:"label"`
|
Label string `gorm:"type:varchar(128)"`
|
||||||
SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
||||||
LastUsedAt *time.Time `json:"last_used_at"`
|
LastUsedAt *time.Time
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-13
@@ -3,33 +3,90 @@ package model
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNotFound = errors.New("resource not found")
|
ErrNotFound = errors.New("resource not found")
|
||||||
ErrDuplicate = errors.New("resource already exists")
|
ErrDuplicate = errors.New("resource already exists")
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrForbidden = errors.New("forbidden")
|
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")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppError is a service-layer error that carries an HTTP status, a user-safe
|
// ErrorKind classifies domain errors without tying them to a transport.
|
||||||
// message, and an optional internal cause for logging. Handlers unpack it
|
type ErrorKind string
|
||||||
// transparently via api.RespondError.
|
|
||||||
|
// Protocol-neutral error kinds.
|
||||||
|
const (
|
||||||
|
KindInvalidArgument ErrorKind = "invalid_argument"
|
||||||
|
KindUnauthenticated ErrorKind = "unauthenticated"
|
||||||
|
KindPermissionDenied ErrorKind = "permission_denied"
|
||||||
|
KindNotFound ErrorKind = "not_found"
|
||||||
|
KindConflict ErrorKind = "conflict"
|
||||||
|
KindPayloadTooLarge ErrorKind = "payload_too_large"
|
||||||
|
KindInternal ErrorKind = "internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppError is a service-layer error that carries a protocol-neutral kind, a
|
||||||
|
// user-safe message, and an optional internal cause for logging.
|
||||||
type AppError struct {
|
type AppError struct {
|
||||||
Status int // HTTP status code
|
Kind ErrorKind
|
||||||
Message string // safe for API response
|
Message string
|
||||||
Err error // internal cause (nil = user-caused, skip logging)
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *AppError) Error() string { return e.Message }
|
func (e *AppError) Error() string { return e.Message }
|
||||||
func (e *AppError) Unwrap() error { return e.Err }
|
func (e *AppError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
// NewInternalError creates a 500 AppError with a wrapped internal cause.
|
// NewInvalidArgumentError creates an invalid-argument AppError.
|
||||||
|
func NewInvalidArgumentError(message string) *AppError {
|
||||||
|
return &AppError{Kind: KindInvalidArgument, Message: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBadRequestError creates an invalid-argument AppError.
|
||||||
|
func NewBadRequestError(message string) *AppError {
|
||||||
|
return NewInvalidArgumentError(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUnauthenticatedError creates an unauthenticated AppError.
|
||||||
|
func NewUnauthenticatedError(message string) *AppError {
|
||||||
|
return &AppError{Kind: KindUnauthenticated, Message: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConflictError creates a conflict AppError.
|
||||||
|
func NewConflictError(message string) *AppError {
|
||||||
|
return &AppError{Kind: KindConflict, Message: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotFoundError creates a not-found AppError.
|
||||||
|
func NewNotFoundError(message string, cause error) *AppError {
|
||||||
|
return &AppError{Kind: KindNotFound, Message: message, Err: cause}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPermissionDeniedError creates a permission-denied AppError.
|
||||||
|
func NewPermissionDeniedError(message string, cause error) *AppError {
|
||||||
|
return &AppError{Kind: KindPermissionDenied, Message: message, Err: cause}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewForbiddenError creates a permission-denied AppError.
|
||||||
|
func NewForbiddenError(message string, cause error) *AppError {
|
||||||
|
return NewPermissionDeniedError(message, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPayloadTooLargeError creates a payload-too-large AppError.
|
||||||
|
func NewPayloadTooLargeError(message string, cause error) *AppError {
|
||||||
|
return &AppError{Kind: KindPayloadTooLarge, Message: message, Err: cause}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInternalError creates an internal AppError with a wrapped internal cause.
|
||||||
// msg describes the operation that failed; cause is the underlying error.
|
// msg describes the operation that failed; cause is the underlying error.
|
||||||
func NewInternalError(msg string, cause error) *AppError {
|
func NewInternalError(msg string, cause error) *AppError {
|
||||||
return &AppError{
|
return &AppError{
|
||||||
Status: http.StatusInternalServerError,
|
Kind: KindInternal,
|
||||||
Message: "internal server error",
|
Message: "internal server error",
|
||||||
Err: fmt.Errorf("%s: %w", msg, cause),
|
Err: fmt.Errorf("%s: %w", msg, cause),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppErrorConstructors(t *testing.T) {
|
||||||
|
cause := errors.New("database offline")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err *AppError
|
||||||
|
kind ErrorKind
|
||||||
|
message string
|
||||||
|
cause error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid argument",
|
||||||
|
err: NewInvalidArgumentError("bad input"),
|
||||||
|
kind: KindInvalidArgument,
|
||||||
|
message: "bad input",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unauthenticated",
|
||||||
|
err: NewUnauthenticatedError("invalid token"),
|
||||||
|
kind: KindUnauthenticated,
|
||||||
|
message: "invalid token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permission denied",
|
||||||
|
err: NewPermissionDeniedError("access denied", ErrForbidden),
|
||||||
|
kind: KindPermissionDenied,
|
||||||
|
message: "access denied",
|
||||||
|
cause: ErrForbidden,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not found",
|
||||||
|
err: NewNotFoundError("missing", ErrNotFound),
|
||||||
|
kind: KindNotFound,
|
||||||
|
message: "missing",
|
||||||
|
cause: ErrNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "conflict",
|
||||||
|
err: NewConflictError("duplicate"),
|
||||||
|
kind: KindConflict,
|
||||||
|
message: "duplicate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "payload too large",
|
||||||
|
err: NewPayloadTooLargeError("too large", ErrUploadTooLarge),
|
||||||
|
kind: KindPayloadTooLarge,
|
||||||
|
message: "too large",
|
||||||
|
cause: ErrUploadTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "internal",
|
||||||
|
err: NewInternalError("load record", cause),
|
||||||
|
kind: KindInternal,
|
||||||
|
message: "internal server error",
|
||||||
|
cause: cause,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.err.Kind != tt.kind {
|
||||||
|
t.Fatalf("Kind = %q, want %q", tt.err.Kind, tt.kind)
|
||||||
|
}
|
||||||
|
if tt.err.Message != tt.message {
|
||||||
|
t.Fatalf("Message = %q, want %q", tt.err.Message, tt.message)
|
||||||
|
}
|
||||||
|
if tt.cause != nil && !errors.Is(tt.err, tt.cause) {
|
||||||
|
t.Fatalf("errors.Is(%v, %v) = false", tt.err, tt.cause)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-12
@@ -9,16 +9,16 @@ const StatusUserDeleted = "user_deleted"
|
|||||||
|
|
||||||
// File represents a file or directory entry in the virtual filesystem.
|
// File represents a file or directory entry in the virtual filesystem.
|
||||||
type File struct {
|
type File struct {
|
||||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||||
UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null" json:"user_id"`
|
UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null"`
|
||||||
ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)" json:"parent_id"`
|
ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)"`
|
||||||
Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3" json:"name"`
|
Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3"`
|
||||||
Size int64 `gorm:"default:0" json:"size"`
|
Size int64 `gorm:"default:0"`
|
||||||
MimeType string `gorm:"type:varchar(127)" json:"mime_type"`
|
MimeType string `gorm:"type:varchar(127)"`
|
||||||
StoragePath string `gorm:"type:varchar(512)" json:"storage_path"`
|
StoragePath string `gorm:"type:varchar(512)" json:"-"`
|
||||||
Hash string `gorm:"type:varchar(64)" json:"-"`
|
Hash string `gorm:"type:varchar(64)" json:"-"`
|
||||||
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
||||||
IsDir bool `gorm:"default:false" json:"is_dir"`
|
IsDir bool `gorm:"default:false"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -6,18 +6,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFile_StatusExcludedFromJSON(t *testing.T) {
|
func TestFileJSONOmitsInternalFields(t *testing.T) {
|
||||||
f := &File{
|
f := &File{
|
||||||
ID: "1",
|
ID: "1",
|
||||||
UserID: "u1",
|
UserID: "u1",
|
||||||
ParentID: nil,
|
ParentID: nil,
|
||||||
Name: "test.txt",
|
Name: "test.txt",
|
||||||
Size: 100,
|
Size: 100,
|
||||||
MimeType: "text/plain",
|
MimeType: "text/plain",
|
||||||
Status: StatusActive,
|
StoragePath: "users/u1/files/1",
|
||||||
IsDir: false,
|
Hash: "abc123",
|
||||||
CreatedAt: time.Now(),
|
Status: StatusActive,
|
||||||
UpdatedAt: time.Now(),
|
IsDir: false,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(f)
|
data, err := json.Marshal(f)
|
||||||
@@ -30,13 +32,11 @@ func TestFile_StatusExcludedFromJSON(t *testing.T) {
|
|||||||
t.Fatalf("json.Unmarshal: %v", err)
|
t.Fatalf("json.Unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := result["status"]; ok {
|
assertJSONKeysAbsent(t, result,
|
||||||
t.Errorf("Status field should be excluded from JSON (json:\"-\"), but it appeared in output")
|
"StoragePath", "storage_path",
|
||||||
}
|
"Hash", "hash",
|
||||||
|
"Status", "status",
|
||||||
if _, ok := result["hash"]; ok {
|
)
|
||||||
t.Errorf("Hash field should be excluded from JSON (json:\"-\"), but it appeared in output")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusConstants(t *testing.T) {
|
func TestStatusConstants(t *testing.T) {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
|
|
||||||
// Session stores a refresh token for a user session.
|
// Session stores a refresh token for a user session.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||||
UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"`
|
UserID string `gorm:"index;type:varchar(36);not null"`
|
||||||
TokenHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
TokenHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
||||||
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
ExpiresAt time.Time `gorm:"not null"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import (
|
|||||||
|
|
||||||
// User represents a registered account.
|
// User represents a registered account.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||||
Username string `gorm:"uniqueIndex;type:varchar(64);not null" json:"username"`
|
Username string `gorm:"uniqueIndex;type:varchar(64);not null"`
|
||||||
Email string `gorm:"uniqueIndex;type:varchar(255);not null" json:"email"`
|
Email string `gorm:"uniqueIndex;type:varchar(255);not null"`
|
||||||
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
|
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
|
||||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
IsAdmin bool `gorm:"default:false"`
|
||||||
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
Status string `gorm:"type:varchar(32);not null;default:active;index"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// User status constants.
|
// User status constants.
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUserJSONOmitsStatusField(t *testing.T) {
|
func TestUserJSONOmitsPasswordHash(t *testing.T) {
|
||||||
u := User{
|
u := User{
|
||||||
ID: "user-1",
|
ID: "user-1",
|
||||||
Username: "alice",
|
Username: "alice",
|
||||||
Email: "alice@example.com",
|
Email: "alice@example.com",
|
||||||
Status: StatusActive,
|
PasswordHash: "hash-secret",
|
||||||
|
Status: StatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := json.Marshal(u)
|
raw, err := json.Marshal(u)
|
||||||
@@ -23,7 +24,57 @@ func TestUserJSONOmitsStatusField(t *testing.T) {
|
|||||||
t.Fatalf("json.Unmarshal: %v", err)
|
t.Fatalf("json.Unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := m["status"]; ok {
|
assertJSONKeysAbsent(t, m, "PasswordHash", "password_hash")
|
||||||
t.Error("Status field should not appear in JSON output")
|
}
|
||||||
|
|
||||||
|
func TestSessionJSONOmitsTokenHash(t *testing.T) {
|
||||||
|
s := Session{
|
||||||
|
ID: "session-1",
|
||||||
|
UserID: "user-1",
|
||||||
|
TokenHash: "token-hash-secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertJSONKeysAbsent(t, m, "TokenHash", "token_hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialJSONOmitsSecretHash(t *testing.T) {
|
||||||
|
c := Credential{
|
||||||
|
ID: "credential-1",
|
||||||
|
UserID: "user-1",
|
||||||
|
Type: "app_passkey",
|
||||||
|
Label: "Phone",
|
||||||
|
SecretHash: "credential-secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertJSONKeysAbsent(t, m, "SecretHash", "secret_hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertJSONKeysAbsent(t *testing.T, m map[string]interface{}, keys ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, ok := m[key]; ok {
|
||||||
|
t.Errorf("%s field should not appear in JSON output", key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"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.
|
// CredentialRepository provides access to alternative credential records.
|
||||||
type CredentialRepository interface {
|
type CredentialRepository interface {
|
||||||
Create(ctx context.Context, cred *model.Credential) error
|
CreatePasskey(ctx context.Context, params PasskeyParams) error
|
||||||
FindByID(ctx context.Context, id string) (*model.Credential, error)
|
FindPasskeyByID(ctx context.Context, id string) (*model.Credential, error)
|
||||||
FindByUserID(ctx context.Context, userID string) ([]model.Credential, error)
|
ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error)
|
||||||
FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error)
|
FindPasskeyByHash(ctx context.Context, hash string) (*model.Credential, error)
|
||||||
FindByHash(ctx context.Context, hash string) (*model.Credential, error)
|
RecordPasskeyUsed(ctx context.Context, id string) error
|
||||||
UpdateLastUsed(ctx context.Context, id string) error
|
RevokeOwnedPasskey(ctx context.Context, userID, id string) error
|
||||||
Delete(ctx context.Context, id string) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type credentialRepository struct {
|
type credentialRepository struct {
|
||||||
@@ -30,7 +37,14 @@ func NewCredentialRepository(db *gorm.DB) CredentialRepository {
|
|||||||
return &credentialRepository{db: db}
|
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)
|
result := r.db.WithContext(ctx).Create(cred)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if isDuplicateKeyError(result.Error) {
|
if isDuplicateKeyError(result.Error) {
|
||||||
@@ -41,9 +55,9 @@ func (r *credentialRepository) Create(ctx context.Context, cred *model.Credentia
|
|||||||
return nil
|
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
|
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) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -53,27 +67,18 @@ func (r *credentialRepository) FindByID(ctx context.Context, id string) (*model.
|
|||||||
return &cred, nil
|
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
|
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 {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
return creds, nil
|
return creds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *credentialRepository) FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error) {
|
func (r *credentialRepository) FindPasskeyByHash(ctx context.Context, hash 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) {
|
|
||||||
var cred model.Credential
|
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) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -83,19 +88,27 @@ func (r *credentialRepository) FindByHash(ctx context.Context, hash string) (*mo
|
|||||||
return &cred, nil
|
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()
|
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 {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *credentialRepository) Delete(ctx context.Context, id string) error {
|
func (r *credentialRepository) RevokeOwnedPasskey(ctx context.Context, userID, id string) error {
|
||||||
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ?", id)
|
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ? AND user_id = ? AND type = ?", id, userID, model.CredentialTypeAppPasskey)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ func setupCredentialRepo(t *testing.T) CredentialRepository {
|
|||||||
return NewCredentialRepository(db)
|
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) {
|
func TestCredentialRepository_Create(t *testing.T) {
|
||||||
repo := setupCredentialRepo(t)
|
repo := setupCredentialRepo(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -36,7 +42,7 @@ func TestCredentialRepository_Create(t *testing.T) {
|
|||||||
SecretHash: "hash-abc",
|
SecretHash: "hash-abc",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.Create(ctx, cred); err != nil {
|
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||||
t.Fatalf("Create = %v", err)
|
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"}
|
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"}
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := repo.Create(ctx, c2)
|
err := createPasskeyRecord(ctx, repo, c2)
|
||||||
if err != model.ErrDuplicate {
|
if err != model.ErrDuplicate {
|
||||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -63,11 +69,11 @@ func TestCredentialRepository_FindByID(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
cred := &model.Credential{ID: "cred-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
found, err := repo.FindByID(ctx, "cred-1")
|
found, err := repo.FindPasskeyByID(ctx, "cred-1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FindByID = %v", err)
|
t.Fatalf("FindByID = %v", err)
|
||||||
}
|
}
|
||||||
@@ -80,13 +86,13 @@ func TestCredentialRepository_FindByIDNotFound(t *testing.T) {
|
|||||||
repo := setupCredentialRepo(t)
|
repo := setupCredentialRepo(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
_, err := repo.FindByID(ctx, "nonexistent")
|
_, err := repo.FindPasskeyByID(ctx, "nonexistent")
|
||||||
if err != model.ErrNotFound {
|
if err != model.ErrNotFound {
|
||||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCredentialRepository_FindByUserID(t *testing.T) {
|
func TestCredentialRepository_ListPasskeys(t *testing.T) {
|
||||||
repo := setupCredentialRepo(t)
|
repo := setupCredentialRepo(t)
|
||||||
ctx := context.Background()
|
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"}
|
c3 := &model.Credential{ID: "c-3", UserID: "user-2", Type: "app_passkey", Label: "C", SecretHash: "h3"}
|
||||||
|
|
||||||
for _, c := range []*model.Credential{c1, c2, c3} {
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
creds, err := repo.FindByUserID(ctx, "user-1")
|
creds, err := repo.ListPasskeys(ctx, "user-1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FindByUserID = %v", err)
|
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)
|
repo := setupCredentialRepo(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
c1 := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "A", SecretHash: "h1"}
|
cred := &model.Credential{ID: "c-1", UserID: "user-1", Label: "A", SecretHash: "h1"}
|
||||||
c2 := &model.Credential{ID: "c-2", UserID: "user-1", Type: "oauth", Label: "Github", SecretHash: "h2"}
|
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||||
|
t.Fatalf("Create = %v", err)
|
||||||
for _, c := range []*model.Credential{c1, c2} {
|
|
||||||
if err := repo.Create(ctx, c); 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 {
|
if err != nil {
|
||||||
t.Fatalf("FindByUserIDAndType = %v", err)
|
t.Fatalf("ListPasskeys = %v", err)
|
||||||
}
|
}
|
||||||
if len(passkeys) != 1 {
|
if len(passkeys) != 1 {
|
||||||
t.Errorf("len(passkeys) = %d, want 1", len(passkeys))
|
t.Errorf("len(passkeys) = %d, want 1", len(passkeys))
|
||||||
}
|
}
|
||||||
if passkeys[0].Type != "app_passkey" {
|
if passkeys[0].Type != model.CredentialTypeAppPasskey {
|
||||||
t.Errorf("type = %q, want %q", passkeys[0].Type, "app_passkey")
|
t.Errorf("type = %q, want %q", passkeys[0].Type, model.CredentialTypeAppPasskey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,11 +141,11 @@ func TestCredentialRepository_FindByHash(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "hash-find"}
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
found, err := repo.FindByHash(ctx, "hash-find")
|
found, err := repo.FindPasskeyByHash(ctx, "hash-find")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FindByHash = %v", err)
|
t.Fatalf("FindByHash = %v", err)
|
||||||
}
|
}
|
||||||
@@ -157,15 +159,15 @@ func TestCredentialRepository_UpdateLastUsed(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
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)
|
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)
|
t.Fatalf("UpdateLastUsed = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
found, err := repo.FindByID(ctx, "c-1")
|
found, err := repo.FindPasskeyByID(ctx, "c-1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FindByID = %v", err)
|
t.Fatalf("FindByID = %v", err)
|
||||||
}
|
}
|
||||||
@@ -179,16 +181,35 @@ func TestCredentialRepository_Delete(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
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)
|
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)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := repo.FindByID(ctx, "c-1")
|
_, err := repo.FindPasskeyByID(ctx, "c-1")
|
||||||
if err != model.ErrNotFound {
|
if err != model.ErrNotFound {
|
||||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
@@ -23,7 +25,7 @@ func Open(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
|||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("create db directory: %w", err)
|
return nil, fmt.Errorf("create db directory: %w", err)
|
||||||
}
|
}
|
||||||
dialector = sqlite.Open(cfg.SQLite.Path)
|
dialector = sqlite.Open(sqliteImmediateDSN(cfg.SQLite.Path))
|
||||||
case "postgres":
|
case "postgres":
|
||||||
dsn := fmt.Sprintf(
|
dsn := fmt.Sprintf(
|
||||||
"host=%s user=%s password=%s dbname=%s port=%d sslmode=%s",
|
"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
|
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.
|
// AutoMigrate runs schema migration for all domain models.
|
||||||
func AutoMigrate(db *gorm.DB) error {
|
func AutoMigrate(db *gorm.DB) error {
|
||||||
return db.AutoMigrate(
|
return db.AutoMigrate(
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/config"
|
"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) {
|
func TestOpenSQLite(t *testing.T) {
|
||||||
cfg := config.DatabaseConfig{
|
cfg := config.DatabaseConfig{
|
||||||
Driver: "sqlite3",
|
Driver: "sqlite3",
|
||||||
|
|||||||
+226
-21
@@ -3,22 +3,54 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/model"
|
"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.
|
// FileRepository provides access to file records.
|
||||||
type FileRepository interface {
|
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)
|
FindByID(ctx context.Context, id string) (*model.File, error)
|
||||||
FindByUserID(ctx context.Context, userID string, offset, limit int) ([]model.File, int64, 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)
|
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)
|
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)
|
FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error)
|
||||||
Update(ctx context.Context, file *model.File) error
|
UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error)
|
||||||
Delete(ctx context.Context, id string) error
|
SoftDeleteOwned(ctx context.Context, userID, fileID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileRepository struct {
|
type fileRepository struct {
|
||||||
@@ -30,15 +62,65 @@ func NewFileRepository(db *gorm.DB) FileRepository {
|
|||||||
return &fileRepository{db: db}
|
return &fileRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fileRepository) Create(ctx context.Context, file *model.File) error {
|
func (r *fileRepository) CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error) {
|
||||||
result := r.db.WithContext(ctx).Create(file)
|
file := &model.File{
|
||||||
if result.Error != nil {
|
ID: params.ID,
|
||||||
if isDuplicateKeyError(result.Error) {
|
UserID: params.UserID,
|
||||||
return model.ErrDuplicate
|
ParentID: params.ParentID,
|
||||||
}
|
Name: params.Name,
|
||||||
return result.Error
|
Size: params.Size,
|
||||||
|
MimeType: params.MimeType,
|
||||||
|
StoragePath: params.StoragePath,
|
||||||
|
Hash: params.Hash,
|
||||||
|
Status: model.StatusActive,
|
||||||
|
IsDir: false,
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) {
|
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
|
return &file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fileRepository) Update(ctx context.Context, file *model.File) error {
|
func (r *fileRepository) UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error) {
|
||||||
result := r.db.WithContext(ctx).Save(file)
|
var updated model.File
|
||||||
if result.Error != nil {
|
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
|
||||||
|
}
|
||||||
|
ids = append(ids, *params.NewParentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
return result.Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return nil
|
return &updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fileRepository) Delete(ctx context.Context, id string) error {
|
func (r *fileRepository) SoftDeleteOwned(ctx context.Context, userID, fileID string) error {
|
||||||
result := r.db.WithContext(ctx).Model(&model.File{}).Where("id = ?", id).Update("status", model.StatusUserDeleted)
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if result.Error != nil {
|
locked, err := lockActiveOwnedFiles(tx, userID, []string{fileID})
|
||||||
return result.Error
|
if err != nil {
|
||||||
}
|
return err
|
||||||
return nil
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/dhao2001/mygo/internal/config"
|
||||||
"github.com/dhao2001/mygo/internal/model"
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,6 +28,48 @@ func setupFileRepo(t *testing.T) FileRepository {
|
|||||||
return NewFileRepository(db)
|
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) {
|
func TestFileRepository_Create(t *testing.T) {
|
||||||
repo := setupFileRepo(t)
|
repo := setupFileRepo(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -36,7 +82,7 @@ func TestFileRepository_Create(t *testing.T) {
|
|||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.Create(ctx, file); err != nil {
|
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||||
t.Fatalf("Create = %v", err)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +97,7 @@ func TestFileRepository_FindByID(t *testing.T) {
|
|||||||
Name: "test.txt",
|
Name: "test.txt",
|
||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
}
|
}
|
||||||
if err := repo.Create(ctx, file); err != nil {
|
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||||
t.Fatalf("Create = %v", err)
|
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},
|
{ID: "f-3", UserID: "user-2", Name: "c.txt", Status: model.StatusActive},
|
||||||
}
|
}
|
||||||
for _, f := range files {
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,13 +152,16 @@ func TestFileRepository_FindByParentID(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
parentID := "dir-1"
|
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{
|
files := []*model.File{
|
||||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
|
{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-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt", Status: model.StatusActive},
|
||||||
{ID: "f-3", UserID: "user-1", Name: "c.txt", Status: model.StatusActive},
|
{ID: "f-3", UserID: "user-1", Name: "c.txt", Status: model.StatusActive},
|
||||||
}
|
}
|
||||||
for _, f := range files {
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,12 +180,15 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
parentID := "dir-1"
|
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{
|
files := []*model.File{
|
||||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
|
{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},
|
{ID: "f-2", UserID: "user-1", Name: "root.txt", Status: model.StatusActive},
|
||||||
}
|
}
|
||||||
for _, f := range files {
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,8 +197,8 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FindByParentID(nil) = %v", err)
|
t.Fatalf("FindByParentID(nil) = %v", err)
|
||||||
}
|
}
|
||||||
if len(children) != 1 {
|
if len(children) != 2 {
|
||||||
t.Errorf("len(children) = %d, want 1", len(children))
|
t.Errorf("len(children) = %d, want 2", len(children))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,14 +206,26 @@ func TestFileRepository_Update(t *testing.T) {
|
|||||||
repo := setupFileRepo(t)
|
repo := setupFileRepo(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
file := &model.File{ID: "file-1", UserID: "user-1", Name: "original.txt", Status: model.StatusActive}
|
file := &model.File{
|
||||||
if err := repo.Create(ctx, file); err != nil {
|
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)
|
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"
|
newName := "renamed.txt"
|
||||||
file.Size = 2048
|
if _, err := repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: file.ID, UserID: file.UserID, NewName: &newName}); err != nil {
|
||||||
if err := repo.Update(ctx, file); err != nil {
|
|
||||||
t.Fatalf("Update = %v", err)
|
t.Fatalf("Update = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,8 +236,11 @@ func TestFileRepository_Update(t *testing.T) {
|
|||||||
if found.Name != "renamed.txt" {
|
if found.Name != "renamed.txt" {
|
||||||
t.Errorf("name = %q, want %q", found.Name, "renamed.txt")
|
t.Errorf("name = %q, want %q", found.Name, "renamed.txt")
|
||||||
}
|
}
|
||||||
if found.Size != 2048 {
|
if found.UserID != before.UserID || found.Status != before.Status || found.IsDir != before.IsDir ||
|
||||||
t.Errorf("size = %d, want %d", found.Size, 2048)
|
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()
|
ctx := context.Background()
|
||||||
|
|
||||||
file := &model.File{ID: "file-1", UserID: "user-1", Name: "test.txt", Status: model.StatusActive}
|
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)
|
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)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,11 +273,11 @@ func TestFileRepository_SoftDelete(t *testing.T) {
|
|||||||
Name: "test.txt",
|
Name: "test.txt",
|
||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
}
|
}
|
||||||
if err := repo.Create(ctx, file); err != nil {
|
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||||
t.Fatalf("Create = %v", err)
|
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)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,10 +304,10 @@ func TestFileRepository_StatusFilter(t *testing.T) {
|
|||||||
Status: model.StatusUserDeleted,
|
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)
|
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)
|
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)
|
repo := setupFileRepo(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -269,15 +336,18 @@ func TestFileRepository_DeleteIdempotent(t *testing.T) {
|
|||||||
Name: "test.txt",
|
Name: "test.txt",
|
||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
}
|
}
|
||||||
if err := repo.Create(ctx, file); err != nil {
|
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||||
t.Fatalf("Create = %v", err)
|
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)
|
t.Fatalf("first Delete = %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); !errors.Is(err, model.ErrNotFound) {
|
||||||
t.Fatalf("second Delete = %v", err)
|
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,
|
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)
|
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)
|
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)
|
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"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/model"
|
"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 {
|
type SessionRepository interface {
|
||||||
Create(ctx context.Context, session *model.Session) error
|
AuthSessionRepository
|
||||||
FindByID(ctx context.Context, id string) (*model.Session, error)
|
FindByID(ctx context.Context, id string) (*model.Session, error)
|
||||||
FindByTokenHash(ctx context.Context, tokenHash string) (*model.Session, error)
|
FindByTokenHash(ctx context.Context, tokenHash string) (*model.Session, error)
|
||||||
Delete(ctx context.Context, id string) error
|
DeleteSessionsByUserID(ctx context.Context, userID string) error
|
||||||
DeleteByUserID(ctx context.Context, userID string) error
|
DeleteExpiredSessions(ctx context.Context) (int64, error)
|
||||||
DeleteExpired(ctx context.Context) (int64, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionRepository struct {
|
type sessionRepository struct {
|
||||||
@@ -29,7 +44,13 @@ func NewSessionRepository(db *gorm.DB) SessionRepository {
|
|||||||
return &sessionRepository{db: db}
|
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)
|
result := r.db.WithContext(ctx).Create(session)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if isDuplicateKeyError(result.Error) {
|
if isDuplicateKeyError(result.Error) {
|
||||||
@@ -64,15 +85,39 @@ func (r *sessionRepository) FindByTokenHash(ctx context.Context, tokenHash strin
|
|||||||
return &session, nil
|
return &session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *sessionRepository) Delete(ctx context.Context, id string) error {
|
func (r *sessionRepository) ConsumeRefreshSession(ctx context.Context, tokenHash string) (*model.Session, error) {
|
||||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "id = ?", id)
|
var consumed model.Session
|
||||||
if result.Error != nil {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
return result.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 nil
|
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)
|
result := r.db.WithContext(ctx).Delete(&model.Session{}, "user_id = ?", userID)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
@@ -80,7 +125,7 @@ func (r *sessionRepository) DeleteByUserID(ctx context.Context, userID string) e
|
|||||||
return nil
|
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())
|
result := r.db.WithContext(ctx).Delete(&model.Session{}, "expires_at < ?", time.Now())
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return 0, result.Error
|
return 0, result.Error
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/dhao2001/mygo/internal/config"
|
||||||
"github.com/dhao2001/mygo/internal/model"
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +29,12 @@ func setupSessionRepo(t *testing.T) SessionRepository {
|
|||||||
return NewSessionRepository(db)
|
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) {
|
func TestSessionRepository_Create(t *testing.T) {
|
||||||
repo := setupSessionRepo(t)
|
repo := setupSessionRepo(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -36,7 +46,7 @@ func TestSessionRepository_Create(t *testing.T) {
|
|||||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
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)
|
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)}
|
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)}
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := repo.Create(ctx, s2)
|
err := createSessionRecord(ctx, repo, s2)
|
||||||
if err != model.ErrDuplicate {
|
if err != model.ErrDuplicate {
|
||||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -63,7 +73,7 @@ func TestSessionRepository_FindByID(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +91,7 @@ func TestSessionRepository_FindByTokenHash(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,15 +119,19 @@ func TestSessionRepository_Delete(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.Delete(ctx, "session-1"); err != nil {
|
consumed, err := repo.ConsumeRefreshSession(ctx, "hash-abc")
|
||||||
t.Fatalf("Delete = %v", err)
|
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 {
|
if err != model.ErrNotFound {
|
||||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
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)}
|
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} {
|
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)
|
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)
|
t.Fatalf("DeleteByUserID = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,12 +182,12 @@ func TestSessionRepository_DeleteExpired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range []*model.Session{expired, valid} {
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
count, err := repo.DeleteExpired(ctx)
|
count, err := repo.DeleteExpiredSessions(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeleteExpired = %v", err)
|
t.Fatalf("DeleteExpired = %v", err)
|
||||||
}
|
}
|
||||||
@@ -188,3 +202,55 @@ func TestSessionRepository_DeleteExpired(t *testing.T) {
|
|||||||
t.Fatalf("valid session should still exist: %v", err)
|
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")
|
return errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserRepository provides access to user records.
|
// RegisteredUserParams contains the fields accepted when registering a user.
|
||||||
type UserRepository interface {
|
// Administrative and lifecycle fields are intentionally excluded.
|
||||||
Create(ctx context.Context, user *model.User) error
|
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)
|
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)
|
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
|
// AdminUserRepository exposes only user operations required by administration.
|
||||||
List(ctx context.Context, offset, limit int) ([]model.User, int64, error)
|
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)
|
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 {
|
type userRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
@@ -37,15 +55,23 @@ func NewUserRepository(db *gorm.DB) UserRepository {
|
|||||||
return &userRepository{db: db}
|
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)
|
result := r.db.WithContext(ctx).Create(user)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if isDuplicateKeyError(result.Error) {
|
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) {
|
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
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) Update(ctx context.Context, user *model.User) error {
|
func (r *userRepository) MarkAdminDeleted(ctx context.Context, id string) 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 {
|
|
||||||
result := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", model.StatusAdminDeleted)
|
result := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", model.StatusAdminDeleted)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ func setupUserRepo(t *testing.T) UserRepository {
|
|||||||
return NewUserRepository(db)
|
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) {
|
func TestUserRepository_Create(t *testing.T) {
|
||||||
repo := setupUserRepo(t)
|
repo := setupUserRepo(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -36,7 +46,7 @@ func TestUserRepository_Create(t *testing.T) {
|
|||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.Create(ctx, user); err != nil {
|
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||||
t.Fatalf("Create = %v", err)
|
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}
|
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}
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := repo.Create(ctx, u2)
|
err := createUserRecord(ctx, repo, u2)
|
||||||
if err != model.ErrDuplicate {
|
if err != model.ErrDuplicate {
|
||||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -63,7 +73,7 @@ func TestUserRepository_FindByID(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +101,7 @@ func TestUserRepository_FindByEmail(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
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)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +119,7 @@ func TestUserRepository_FindByUsername(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
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)
|
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)
|
repo := setupUserRepo(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
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)
|
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")
|
found, err := repo.FindByID(ctx, "user-1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FindByID = %v", err)
|
t.Fatalf("FindByID = %v", err)
|
||||||
}
|
}
|
||||||
if found.Username != "alice2" {
|
if found.IsAdmin {
|
||||||
t.Errorf("username = %q, want %q", found.Username, "alice2")
|
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()
|
ctx := context.Background()
|
||||||
|
|
||||||
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
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)
|
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)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +184,7 @@ func TestUserRepository_List(t *testing.T) {
|
|||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
}
|
}
|
||||||
if err := repo.Create(ctx, user); err != nil {
|
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||||
t.Fatalf("Create = %v", err)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,11 +212,11 @@ func TestUserRepository_SoftDelete(t *testing.T) {
|
|||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
}
|
}
|
||||||
if err := repo.Create(ctx, user); err != nil {
|
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||||
t.Fatalf("Create = %v", err)
|
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)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,11 +239,11 @@ func TestUserRepository_DisabledLogin(t *testing.T) {
|
|||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
}
|
}
|
||||||
if err := repo.Create(ctx, user); err != nil {
|
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||||
t.Fatalf("Create = %v", err)
|
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)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +266,7 @@ func TestUserRepository_StatusFilterList(t *testing.T) {
|
|||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Status: model.StatusActive,
|
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)
|
t.Fatalf("Create u1 = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,12 +277,12 @@ func TestUserRepository_StatusFilterList(t *testing.T) {
|
|||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Status: model.StatusActive,
|
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)
|
t.Fatalf("Create u2 = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft-delete bob
|
// 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)
|
t.Fatalf("Delete u2 = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,15 +313,15 @@ func TestUserRepository_DeleteIdempotent(t *testing.T) {
|
|||||||
PasswordHash: "hash",
|
PasswordHash: "hash",
|
||||||
Status: model.StatusActive,
|
Status: model.StatusActive,
|
||||||
}
|
}
|
||||||
if err := repo.Create(ctx, user); err != nil {
|
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||||
t.Fatalf("Create = %v", err)
|
t.Fatalf("Create = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft-delete the same user twice should not error.
|
// 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)
|
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)
|
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")
|
||||||
|
}
|
||||||
@@ -9,12 +9,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
|
func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
|
||||||
jwtSecret := []byte(webApp.Config.JWT.Secret)
|
|
||||||
accountHandler := handler.NewAccountHandler(webApp.AuthService)
|
accountHandler := handler.NewAccountHandler(webApp.AuthService)
|
||||||
fileHandler := handler.NewFileHandler(webApp.FileService)
|
fileHandler := handler.NewFileHandler(webApp.FileService)
|
||||||
adminHandler := handler.NewAdminHandler(webApp.UserRepo)
|
adminHandler := handler.NewAdminHandler(webApp.AdminService)
|
||||||
|
|
||||||
rg.Use(middleware.AuthRequired(jwtSecret))
|
rg.Use(middleware.AuthRequired(webApp.AuthService))
|
||||||
|
|
||||||
account := rg.Group("/account")
|
account := rg.Group("/account")
|
||||||
{
|
{
|
||||||
@@ -39,7 +38,7 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
admin := rg.Group("/admin")
|
admin := rg.Group("/admin")
|
||||||
admin.Use(middleware.AdminRequired(webApp.UserRepo))
|
admin.Use(middleware.AdminRequired())
|
||||||
{
|
{
|
||||||
admin.GET("/users", adminHandler.ListUsers)
|
admin.GET("/users", adminHandler.ListUsers)
|
||||||
admin.GET("/users/:id", adminHandler.GetUser)
|
admin.GET("/users/:id", adminHandler.GetUser)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -14,6 +13,7 @@ import (
|
|||||||
"github.com/dhao2001/mygo/internal/config"
|
"github.com/dhao2001/mygo/internal/config"
|
||||||
"github.com/dhao2001/mygo/internal/repository"
|
"github.com/dhao2001/mygo/internal/repository"
|
||||||
"github.com/dhao2001/mygo/internal/service"
|
"github.com/dhao2001/mygo/internal/service"
|
||||||
|
"github.com/dhao2001/mygo/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVersionRoute(t *testing.T) {
|
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)
|
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)
|
webApp := app.NewWebApp(cfg, nil, authService, nil, nil, nil)
|
||||||
router := NewRouter(webApp)
|
router := NewRouter(webApp)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
|
||||||
@@ -84,6 +84,7 @@ func TestAdminRoutes(t *testing.T) {
|
|||||||
refreshTTL := 168 * time.Hour
|
refreshTTL := 168 * time.Hour
|
||||||
|
|
||||||
authService := service.NewAuthService(userRepo, sessionRepo, credentialRepo, jwtSecret, accessTTL, refreshTTL)
|
authService := service.NewAuthService(userRepo, sessionRepo, credentialRepo, jwtSecret, accessTTL, refreshTTL)
|
||||||
|
adminService := service.NewAdminService(userRepo)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
JWT: config.JWTConfig{
|
JWT: config.JWTConfig{
|
||||||
@@ -93,7 +94,7 @@ func TestAdminRoutes(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
webApp := app.NewWebApp(cfg, db, userRepo, sessionRepo, nil, credentialRepo, authService, nil, nil)
|
webApp := app.NewWebApp(cfg, db, authService, adminService, nil, nil)
|
||||||
router := NewRouter(webApp)
|
router := NewRouter(webApp)
|
||||||
|
|
||||||
// Helper: register a user via POST /api/v1/auth/register and return the user ID.
|
// Helper: register a user via POST /api/v1/auth/register and return the user ID.
|
||||||
@@ -122,14 +123,7 @@ func TestAdminRoutes(t *testing.T) {
|
|||||||
|
|
||||||
// Register admin user and promote to admin.
|
// Register admin user and promote to admin.
|
||||||
adminID := register("admin", "admin@test.local", "adminpass1")
|
adminID := register("admin", "admin@test.local", "adminpass1")
|
||||||
adminUser, err := userRepo.FindByID(context.Background(), adminID)
|
testutil.SetUserAdmin(t, db, adminID, true)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate admin JWT.
|
// Generate admin JWT.
|
||||||
adminToken, err := auth.GenerateAccessToken(adminID, jwtSecret, accessTTL)
|
adminToken, err := auth.GenerateAccessToken(adminID, jwtSecret, accessTTL)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
|
"github.com/dhao2001/mygo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminService handles administrator user-management operations.
|
||||||
|
type AdminService struct {
|
||||||
|
userRepo repository.AdminUserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminService creates an AdminService.
|
||||||
|
func NewAdminService(userRepo repository.AdminUserRepository) *AdminService {
|
||||||
|
return &AdminService{userRepo: userRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser returns a user by ID, including deleted users.
|
||||||
|
func (s *AdminService) GetUser(ctx context.Context, id string) (*model.User, error) {
|
||||||
|
user, err := s.userRepo.FindByIDIncludeDeleted(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return nil, model.NewNotFoundError("user not found", model.ErrNotFound)
|
||||||
|
}
|
||||||
|
return nil, model.NewInternalError("find admin user", err)
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns all users, including deleted users, with pagination.
|
||||||
|
func (s *AdminService) ListUsers(ctx context.Context, offset, limit int) ([]model.User, int64, error) {
|
||||||
|
users, total, err := s.userRepo.ListIncludeDeleted(ctx, offset, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, model.NewInternalError("list admin users", err)
|
||||||
|
}
|
||||||
|
return users, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser soft-deletes a user.
|
||||||
|
func (s *AdminService) DeleteUser(ctx context.Context, id string) error {
|
||||||
|
if _, err := s.GetUser(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.userRepo.MarkAdminDeleted(ctx, id); err != nil {
|
||||||
|
return model.NewInternalError("delete admin user", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
|
"github.com/dhao2001/mygo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupAdminService(t *testing.T) (*AdminService, repository.UserRepository) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepo := repository.NewUserRepository(db)
|
||||||
|
return NewAdminService(userRepo), userRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminServiceGetUserMissingReturnsNotFound(t *testing.T) {
|
||||||
|
svc, _ := setupAdminService(t)
|
||||||
|
|
||||||
|
_, err := svc.GetUser(context.Background(), "missing")
|
||||||
|
var ae *model.AppError
|
||||||
|
if !errors.As(err, &ae) {
|
||||||
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ae.Kind != model.KindNotFound {
|
||||||
|
t.Fatalf("kind = %q, want %q", ae.Kind, model.KindNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminServiceListIncludesDeletedUsers(t *testing.T) {
|
||||||
|
svc, repo := setupAdminService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
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.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.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.MarkAdminDeleted(ctx, deleted.ID); err != nil {
|
||||||
|
t.Fatalf("delete user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, total, err := svc.ListUsers(ctx, 0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUsers = %v", err)
|
||||||
|
}
|
||||||
|
if total != 2 {
|
||||||
|
t.Fatalf("total = %d, want 2", total)
|
||||||
|
}
|
||||||
|
if len(users) != 2 {
|
||||||
|
t.Fatalf("len(users) = %d, want 2", len(users))
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
-65
@@ -3,7 +3,6 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,21 +15,27 @@ import (
|
|||||||
|
|
||||||
// TokenPair contains the access and refresh tokens returned after authentication.
|
// TokenPair contains the access and refresh tokens returned after authentication.
|
||||||
type TokenPair struct {
|
type TokenPair struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatedPasskey contains the raw token for a newly created app passkey.
|
// CreatedPasskey contains the raw token for a newly created app passkey.
|
||||||
type CreatedPasskey struct {
|
type CreatedPasskey struct {
|
||||||
ID string `json:"id"`
|
ID string
|
||||||
Raw string `json:"raw"`
|
Raw string
|
||||||
Label string `json:"label"`
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Principal is the authenticated user identity used by transport layers.
|
||||||
|
type Principal struct {
|
||||||
|
UserID string
|
||||||
|
IsAdmin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthService handles user authentication and session management.
|
// AuthService handles user authentication and session management.
|
||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
userRepo repository.UserRepository
|
userRepo repository.AuthUserRepository
|
||||||
sessionRepo repository.SessionRepository
|
sessionRepo repository.AuthSessionRepository
|
||||||
credentialRepo repository.CredentialRepository
|
credentialRepo repository.CredentialRepository
|
||||||
jwtSecret []byte
|
jwtSecret []byte
|
||||||
accessTTL time.Duration
|
accessTTL time.Duration
|
||||||
@@ -39,8 +44,8 @@ type AuthService struct {
|
|||||||
|
|
||||||
// NewAuthService creates an AuthService.
|
// NewAuthService creates an AuthService.
|
||||||
func NewAuthService(
|
func NewAuthService(
|
||||||
userRepo repository.UserRepository,
|
userRepo repository.AuthUserRepository,
|
||||||
sessionRepo repository.SessionRepository,
|
sessionRepo repository.AuthSessionRepository,
|
||||||
credentialRepo repository.CredentialRepository,
|
credentialRepo repository.CredentialRepository,
|
||||||
jwtSecret []byte,
|
jwtSecret []byte,
|
||||||
accessTTL time.Duration,
|
accessTTL time.Duration,
|
||||||
@@ -59,7 +64,7 @@ func NewAuthService(
|
|||||||
// Register creates a new user account.
|
// Register creates a new user account.
|
||||||
func (s *AuthService) Register(ctx context.Context, username, email, password string) (*model.User, error) {
|
func (s *AuthService) Register(ctx context.Context, username, email, password string) (*model.User, error) {
|
||||||
if username == "" || email == "" || password == "" {
|
if username == "" || email == "" || password == "" {
|
||||||
return nil, &model.AppError{Status: http.StatusBadRequest, Message: "username, email, and password are required"}
|
return nil, model.NewInvalidArgumentError("username, email, and password are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
passwordHash, err := auth.HashPassword(password)
|
passwordHash, err := auth.HashPassword(password)
|
||||||
@@ -67,17 +72,15 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
|
|||||||
return nil, model.NewInternalError("hash password", err)
|
return nil, model.NewInternalError("hash password", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
user := &model.User{
|
user, err := s.userRepo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Username: username,
|
Username: username,
|
||||||
Email: email,
|
Email: email,
|
||||||
PasswordHash: passwordHash,
|
PasswordHash: passwordHash,
|
||||||
Status: model.StatusActive,
|
})
|
||||||
}
|
if err != nil {
|
||||||
|
|
||||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
|
||||||
if errors.Is(err, model.ErrDuplicate) {
|
if errors.Is(err, model.ErrDuplicate) {
|
||||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "username or email already exists"}
|
return nil, model.NewConflictError("username or email already exists")
|
||||||
}
|
}
|
||||||
return nil, model.NewInternalError("create user", err)
|
return nil, model.NewInternalError("create user", err)
|
||||||
}
|
}
|
||||||
@@ -90,17 +93,17 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token
|
|||||||
user, err := s.userRepo.FindByEmail(ctx, email)
|
user, err := s.userRepo.FindByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
|
return nil, model.NewUnauthenticatedError("invalid email or password")
|
||||||
}
|
}
|
||||||
return nil, model.NewInternalError("find user", err)
|
return nil, model.NewInternalError("find user", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Status != model.StatusActive {
|
if user.Status != model.StatusActive {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
|
return nil, model.NewUnauthenticatedError("invalid email or password")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
|
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
|
return nil, model.NewUnauthenticatedError("invalid email or password")
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.issueTokens(ctx, user.ID)
|
return s.issueTokens(ctx, user.ID)
|
||||||
@@ -111,36 +114,32 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token
|
|||||||
func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*TokenPair, error) {
|
func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*TokenPair, error) {
|
||||||
claims, err := auth.ParseToken(refreshTokenStr, s.jwtSecret)
|
claims, err := auth.ParseToken(refreshTokenStr, s.jwtSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
return nil, model.NewUnauthenticatedError("invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims.Type != auth.TokenRefresh {
|
if claims.Type != auth.TokenRefresh {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
return nil, model.NewUnauthenticatedError("invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenHash := auth.HashToken(refreshTokenStr)
|
tokenHash := auth.HashToken(refreshTokenStr)
|
||||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
session, err := s.sessionRepo.ConsumeRefreshSession(ctx, tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
return nil, model.NewUnauthenticatedError("invalid token")
|
||||||
}
|
}
|
||||||
return nil, model.NewInternalError("find session", err)
|
return nil, model.NewInternalError("find session", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.UserID != claims.UserID {
|
if session.UserID != claims.UserID {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
return nil, model.NewUnauthenticatedError("invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.userRepo.FindByID(ctx, claims.UserID)
|
user, err := s.userRepo.FindByID(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
return nil, model.NewUnauthenticatedError("invalid token")
|
||||||
}
|
}
|
||||||
if user.Status != model.StatusActive {
|
if user.Status != model.StatusActive {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
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)
|
return s.issueTokens(ctx, claims.UserID)
|
||||||
@@ -149,15 +148,10 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
|
|||||||
// Logout invalidates a refresh token by deleting its session.
|
// Logout invalidates a refresh token by deleting its session.
|
||||||
func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error {
|
func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error {
|
||||||
tokenHash := auth.HashToken(refreshTokenStr)
|
tokenHash := auth.HashToken(refreshTokenStr)
|
||||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
if err := s.sessionRepo.RevokeRefreshSession(ctx, tokenHash); err != nil {
|
||||||
if err != nil {
|
return model.NewInternalError("delete session", err)
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return model.NewInternalError("find session", err)
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
return s.sessionRepo.Delete(ctx, session.ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePasskey creates a new app passkey for the authenticated user.
|
// CreatePasskey creates a new app passkey for the authenticated user.
|
||||||
@@ -167,20 +161,18 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (
|
|||||||
return nil, model.NewInternalError("generate token", err)
|
return nil, model.NewInternalError("generate token", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cred := &model.Credential{
|
credID := uuid.NewString()
|
||||||
ID: uuid.NewString(),
|
if err := s.credentialRepo.CreatePasskey(ctx, repository.PasskeyParams{
|
||||||
|
ID: credID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Type: "app_passkey",
|
|
||||||
Label: label,
|
Label: label,
|
||||||
SecretHash: hash,
|
SecretHash: hash,
|
||||||
}
|
}); err != nil {
|
||||||
|
|
||||||
if err := s.credentialRepo.Create(ctx, cred); err != nil {
|
|
||||||
return nil, model.NewInternalError("create credential", err)
|
return nil, model.NewInternalError("create credential", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CreatedPasskey{
|
return &CreatedPasskey{
|
||||||
ID: cred.ID,
|
ID: credID,
|
||||||
Raw: raw,
|
Raw: raw,
|
||||||
Label: label,
|
Label: label,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -189,31 +181,33 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (
|
|||||||
// LoginWithPasskey authenticates a user using an app passkey token.
|
// LoginWithPasskey authenticates a user using an app passkey token.
|
||||||
func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*TokenPair, error) {
|
func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*TokenPair, error) {
|
||||||
if !strings.HasPrefix(tokenStr, "mygo_") {
|
if !strings.HasPrefix(tokenStr, "mygo_") {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey format"}
|
return nil, model.NewUnauthenticatedError("invalid passkey format")
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenHash := auth.HashToken(tokenStr)
|
tokenHash := auth.HashToken(tokenStr)
|
||||||
cred, err := s.credentialRepo.FindByHash(ctx, tokenHash)
|
cred, err := s.credentialRepo.FindPasskeyByHash(ctx, tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"}
|
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||||
}
|
}
|
||||||
return nil, model.NewInternalError("find credential", err)
|
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 err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||||
|
}
|
||||||
return nil, model.NewInternalError("find user", err)
|
return nil, model.NewInternalError("find user", err)
|
||||||
}
|
}
|
||||||
if user.Status != model.StatusActive {
|
if user.Status != model.StatusActive {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"}
|
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cred.Type != "app_passkey" {
|
if err := s.credentialRepo.RecordPasskeyUsed(ctx, cred.ID); err != nil {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"}
|
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)
|
return nil, model.NewInternalError("update last used", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,24 +216,62 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
|||||||
|
|
||||||
// ListPasskeys returns all app passkeys for a user.
|
// ListPasskeys returns all app passkeys for a user.
|
||||||
func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
|
func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||||
return s.credentialRepo.FindByUserIDAndType(ctx, userID, "app_passkey")
|
creds, err := s.credentialRepo.ListPasskeys(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, model.NewInternalError("list passkeys", err)
|
||||||
|
}
|
||||||
|
return creds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokePasskey deletes an app passkey owned by the user.
|
// RevokePasskey deletes an app passkey owned by the user.
|
||||||
func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string) error {
|
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 err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return &model.AppError{Status: http.StatusNotFound, Message: "passkey not found", Err: model.ErrNotFound}
|
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
|
||||||
}
|
}
|
||||||
return model.NewInternalError("find credential", err)
|
return model.NewInternalError("find credential", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cred.UserID != userID {
|
if cred.UserID != userID {
|
||||||
return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
|
return model.NewPermissionDeniedError("access denied", model.ErrForbidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.credentialRepo.Delete(ctx, credID)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticateAccessToken validates an access token and returns the active user principal.
|
||||||
|
func (s *AuthService) AuthenticateAccessToken(ctx context.Context, accessTokenStr string) (*Principal, error) {
|
||||||
|
claims, err := auth.ParseToken(accessTokenStr, s.jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, model.NewUnauthenticatedError("invalid or expired token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Type != auth.TokenAccess {
|
||||||
|
return nil, model.NewUnauthenticatedError("invalid token type")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.FindByID(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return nil, model.NewUnauthenticatedError("user not found")
|
||||||
|
}
|
||||||
|
return nil, model.NewInternalError("find access token user", err)
|
||||||
|
}
|
||||||
|
if user.Status != model.StatusActive {
|
||||||
|
return nil, model.NewUnauthenticatedError("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Principal{
|
||||||
|
UserID: user.ID,
|
||||||
|
IsAdmin: user.IsAdmin,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPair, error) {
|
func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPair, error) {
|
||||||
@@ -253,14 +285,12 @@ func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPai
|
|||||||
return nil, model.NewInternalError("generate refresh token", err)
|
return nil, model.NewInternalError("generate refresh token", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
session := &model.Session{
|
if err := s.sessionRepo.CreateRefreshSession(ctx, repository.RefreshSessionParams{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
TokenHash: auth.HashToken(refreshToken),
|
TokenHash: auth.HashToken(refreshToken),
|
||||||
ExpiresAt: time.Now().Add(s.refreshTTL),
|
ExpiresAt: time.Now().Add(s.refreshTTL),
|
||||||
}
|
}); err != nil {
|
||||||
|
|
||||||
if err := s.sessionRepo.Create(ctx, session); err != nil {
|
|
||||||
return nil, model.NewInternalError("create session", err)
|
return nil, model.NewInternalError("create session", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+147
-20
@@ -3,7 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,18 +12,20 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/auth"
|
"github.com/dhao2001/mygo/internal/auth"
|
||||||
|
"github.com/dhao2001/mygo/internal/config"
|
||||||
"github.com/dhao2001/mygo/internal/model"
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
"github.com/dhao2001/mygo/internal/repository"
|
"github.com/dhao2001/mygo/internal/repository"
|
||||||
|
"github.com/dhao2001/mygo/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupAuthService(t *testing.T) *AuthService {
|
func setupAuthService(t *testing.T) *AuthService {
|
||||||
svc, _ := setupAuthServiceWithRepos(t)
|
svc, _, _ := setupAuthServiceWithRepos(t)
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupAuthServiceWithRepos creates an AuthService and returns the UserRepository
|
// setupAuthServiceWithRepos creates an AuthService and returns the UserRepository
|
||||||
// for tests that need direct repo access (e.g., soft-deleting users).
|
// 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()
|
t.Helper()
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
@@ -43,7 +46,7 @@ func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepos
|
|||||||
15*time.Minute,
|
15*time.Minute,
|
||||||
7*24*time.Hour,
|
7*24*time.Hour,
|
||||||
)
|
)
|
||||||
return svc, userRepo
|
return svc, userRepo, db
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthService_Register(t *testing.T) {
|
func TestAuthService_Register(t *testing.T) {
|
||||||
@@ -347,8 +350,8 @@ func TestAuthService_RevokePasskeyNotOwner(t *testing.T) {
|
|||||||
|
|
||||||
err = svc.RevokePasskey(ctx, claimsBob.UserID, pk.ID)
|
err = svc.RevokePasskey(ctx, claimsBob.UserID, pk.ID)
|
||||||
var ae *model.AppError
|
var ae *model.AppError
|
||||||
if !errors.As(err, &ae) || ae.Status != http.StatusForbidden {
|
if !errors.As(err, &ae) || ae.Kind != model.KindPermissionDenied {
|
||||||
t.Fatalf("expected AppError 403 Forbidden, got %v", err)
|
t.Fatalf("expected permission denied AppError, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +417,7 @@ func TestAuthService_RefreshWithAccessToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthService_LoginDisabledUser(t *testing.T) {
|
func TestAuthService_LoginDisabledUser(t *testing.T) {
|
||||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||||
@@ -422,7 +425,7 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
|
|||||||
t.Fatalf("Register = %v", err)
|
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)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,8 +437,8 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
|
|||||||
if !errors.As(err, &ae) {
|
if !errors.As(err, &ae) {
|
||||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if ae.Status != http.StatusUnauthorized {
|
if ae.Kind != model.KindUnauthenticated {
|
||||||
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
|
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||||
}
|
}
|
||||||
if ae.Message != "invalid email or password" {
|
if ae.Message != "invalid email or password" {
|
||||||
t.Errorf("message = %q, want %q", ae.Message, "invalid email or password")
|
t.Errorf("message = %q, want %q", ae.Message, "invalid email or password")
|
||||||
@@ -443,7 +446,7 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
||||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||||
@@ -455,7 +458,7 @@ func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
|||||||
_, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword")
|
_, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword")
|
||||||
|
|
||||||
// Soft-delete user, then try to login
|
// 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)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
_, disabledErr := svc.Login(ctx, "alice@example.com", "password123")
|
_, disabledErr := svc.Login(ctx, "alice@example.com", "password123")
|
||||||
@@ -483,7 +486,7 @@ func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
||||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||||
@@ -507,7 +510,7 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Soft-delete the user
|
// 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)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,8 +522,8 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
|||||||
if !errors.As(err, &ae) {
|
if !errors.As(err, &ae) {
|
||||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if ae.Status != http.StatusUnauthorized {
|
if ae.Kind != model.KindUnauthenticated {
|
||||||
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
|
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||||
}
|
}
|
||||||
if ae.Message != "invalid passkey" {
|
if ae.Message != "invalid passkey" {
|
||||||
t.Errorf("message = %q, want %q", ae.Message, "invalid passkey")
|
t.Errorf("message = %q, want %q", ae.Message, "invalid passkey")
|
||||||
@@ -528,7 +531,7 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
||||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||||
@@ -542,7 +545,7 @@ func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Soft-delete the user
|
// 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)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,10 +557,134 @@ func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
|||||||
if !errors.As(err, &ae) {
|
if !errors.As(err, &ae) {
|
||||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if ae.Status != http.StatusUnauthorized {
|
if ae.Kind != model.KindUnauthenticated {
|
||||||
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
|
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||||
}
|
}
|
||||||
if ae.Message != "invalid token" {
|
if ae.Message != "invalid token" {
|
||||||
t.Errorf("message = %q, want %q", ae.Message, "invalid token")
|
t.Errorf("message = %q, want %q", ae.Message, "invalid token")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthService_AuthenticateAccessToken(t *testing.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)
|
||||||
|
}
|
||||||
|
testutil.SetUserAdmin(t, db, user.ID, true)
|
||||||
|
|
||||||
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
principal, err := svc.AuthenticateAccessToken(ctx, pair.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AuthenticateAccessToken = %v", err)
|
||||||
|
}
|
||||||
|
if principal.UserID != user.ID {
|
||||||
|
t.Fatalf("UserID = %q, want %q", principal.UserID, user.ID)
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin {
|
||||||
|
t.Fatal("IsAdmin = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
|
||||||
|
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||||
|
t.Fatalf("Delete = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.AuthenticateAccessToken(ctx, pair.AccessToken)
|
||||||
|
var ae *model.AppError
|
||||||
|
if !errors.As(err, &ae) {
|
||||||
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ae.Kind != model.KindUnauthenticated {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+141
-92
@@ -9,7 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,22 +22,22 @@ import (
|
|||||||
|
|
||||||
// FileInfo is the public representation of a file or directory.
|
// FileInfo is the public representation of a file or directory.
|
||||||
type FileInfo struct {
|
type FileInfo struct {
|
||||||
ID string `json:"id"`
|
ID string
|
||||||
UserID string `json:"user_id"`
|
UserID string
|
||||||
ParentID *string `json:"parent_id"`
|
ParentID *string
|
||||||
Name string `json:"name"`
|
Name string
|
||||||
Size int64 `json:"size"`
|
Size int64
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string
|
||||||
IsDir bool `json:"is_dir"`
|
IsDir bool
|
||||||
Hash string `json:"hash,omitempty"`
|
Hash string
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileList is a paginated list of files.
|
// FileList is a paginated list of files.
|
||||||
type FileList struct {
|
type FileList struct {
|
||||||
Files []FileInfo `json:"files"`
|
Files []FileInfo
|
||||||
Total int64 `json:"total"`
|
Total int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileService handles file management business logic.
|
// FileService handles file management business logic.
|
||||||
@@ -67,6 +66,12 @@ func NewFileService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaxUploadSize returns the configured maximum upload size in bytes.
|
||||||
|
// A value of 0 means uploads are not size-limited by MyGO.
|
||||||
|
func (s *FileService) MaxUploadSize() int64 {
|
||||||
|
return s.maxUploadSize
|
||||||
|
}
|
||||||
|
|
||||||
// Upload stores a file's content and creates its metadata record.
|
// Upload stores a file's content and creates its metadata record.
|
||||||
// The MIME type is always detected server-side from the file content.
|
// The MIME type is always detected server-side from the file content.
|
||||||
func (s *FileService) Upload(ctx context.Context, userID string, parentID *string, fileName string, reader io.Reader) (*FileInfo, error) {
|
func (s *FileService) Upload(ctx context.Context, userID string, parentID *string, fileName string, reader io.Reader) (*FileInfo, error) {
|
||||||
@@ -82,19 +87,21 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
|||||||
|
|
||||||
// Check for name conflicts in the target directory.
|
// Check for name conflicts in the target directory.
|
||||||
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, fileName); err == nil {
|
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, fileName); err == nil {
|
||||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"}
|
return nil, model.NewConflictError("a file with this name already exists in this directory")
|
||||||
} else if !errors.Is(err, model.ErrNotFound) {
|
} else if !errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, model.NewInternalError("check name conflict", err)
|
return nil, model.NewInternalError("check name conflict", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit the reader if a max upload size is configured.
|
|
||||||
if s.maxUploadSize > 0 {
|
if s.maxUploadSize > 0 {
|
||||||
reader = io.LimitReader(reader, s.maxUploadSize+1)
|
reader = &uploadLimitReader{reader: reader, remaining: s.maxUploadSize}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect MIME type from file content.
|
// Detect MIME type from file content.
|
||||||
head := make([]byte, 512)
|
head := make([]byte, 512)
|
||||||
n, readErr := io.ReadFull(reader, head)
|
n, readErr := io.ReadFull(reader, head)
|
||||||
|
if isUploadTooLargeError(readErr) {
|
||||||
|
return nil, uploadTooLargeError(s.maxUploadSize)
|
||||||
|
}
|
||||||
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
|
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
|
||||||
return nil, model.NewInternalError("read for mime detection", readErr)
|
return nil, model.NewInternalError("read for mime detection", readErr)
|
||||||
}
|
}
|
||||||
@@ -103,24 +110,33 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
|||||||
reader = io.MultiReader(bytes.NewReader(head), reader)
|
reader = io.MultiReader(bytes.NewReader(head), reader)
|
||||||
|
|
||||||
fileID := uuid.NewString()
|
fileID := uuid.NewString()
|
||||||
storagePath := fmt.Sprintf("%s/%s", userID, fileID)
|
stagedPath := fmt.Sprintf("staging/%s/%s", userID, uuid.NewString())
|
||||||
|
storagePath := fmt.Sprintf("data/%s/%s", userID, fileID)
|
||||||
|
|
||||||
// Compute SHA-256 hash while writing to storage.
|
// Compute SHA-256 hash while writing to staging storage.
|
||||||
hasher := sha256.New()
|
hasher := sha256.New()
|
||||||
teeReader := io.TeeReader(reader, hasher)
|
teeReader := io.TeeReader(reader, hasher)
|
||||||
|
|
||||||
written, err := s.storage.Save(ctx, storagePath, teeReader)
|
written, err := s.storage.SaveStaged(ctx, stagedPath, teeReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, model.NewInternalError("save file", err)
|
_ = s.storage.DeleteStaged(ctx, stagedPath)
|
||||||
|
if isUploadTooLargeError(err) {
|
||||||
|
return nil, uploadTooLargeError(s.maxUploadSize)
|
||||||
|
}
|
||||||
|
return nil, model.NewInternalError("save staged file", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.maxUploadSize > 0 && written > s.maxUploadSize {
|
if s.maxUploadSize > 0 && written > s.maxUploadSize {
|
||||||
// Clean up the oversized file.
|
_ = s.storage.DeleteStaged(ctx, stagedPath)
|
||||||
_ = s.storage.Delete(ctx, storagePath)
|
return nil, uploadTooLargeError(s.maxUploadSize)
|
||||||
return nil, &model.AppError{Status: http.StatusBadRequest, Message: fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", s.maxUploadSize)}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
file := &model.File{
|
if err := s.storage.PromoteStaged(ctx, stagedPath, storagePath); err != nil {
|
||||||
|
_ = s.storage.DeleteStaged(ctx, stagedPath)
|
||||||
|
return nil, model.NewInternalError("promote staged file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := s.fileRepo.CreateUploadedFile(ctx, repository.UploadedFileParams{
|
||||||
ID: fileID,
|
ID: fileID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
@@ -129,15 +145,18 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
|||||||
MimeType: mimeType,
|
MimeType: mimeType,
|
||||||
StoragePath: storagePath,
|
StoragePath: storagePath,
|
||||||
Hash: hex.EncodeToString(hasher.Sum(nil)),
|
Hash: hex.EncodeToString(hasher.Sum(nil)),
|
||||||
Status: model.StatusActive,
|
})
|
||||||
IsDir: false,
|
if err != nil {
|
||||||
}
|
// Compensate: remove the promoted file.
|
||||||
|
|
||||||
if err := s.fileRepo.Create(ctx, file); err != nil {
|
|
||||||
// Compensate: remove the stored file.
|
|
||||||
_ = s.storage.Delete(ctx, storagePath)
|
_ = s.storage.Delete(ctx, storagePath)
|
||||||
if errors.Is(err, model.ErrDuplicate) {
|
if errors.Is(err, model.ErrDuplicate) {
|
||||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"}
|
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)
|
return nil, model.NewInternalError("create file record", err)
|
||||||
}
|
}
|
||||||
@@ -145,6 +164,49 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
|||||||
return modelToFileInfo(file), nil
|
return modelToFileInfo(file), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type uploadLimitReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
remaining int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *uploadLimitReader) Read(p []byte) (int, error) {
|
||||||
|
if len(p) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.remaining == 0 {
|
||||||
|
var one [1]byte
|
||||||
|
n, err := r.reader.Read(one[:])
|
||||||
|
if n > 0 {
|
||||||
|
p[0] = one[0]
|
||||||
|
return 1, model.ErrUploadTooLarge
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(p)) > r.remaining {
|
||||||
|
p = p[:r.remaining]
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := r.reader.Read(p)
|
||||||
|
r.remaining -= int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUploadTooLargeError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return errors.Is(err, model.ErrUploadTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadTooLargeError(maxUploadSize int64) *model.AppError {
|
||||||
|
if maxUploadSize > 0 {
|
||||||
|
return model.NewPayloadTooLargeError(fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", maxUploadSize), model.ErrUploadTooLarge)
|
||||||
|
}
|
||||||
|
return model.NewPayloadTooLargeError("request body is too large", model.ErrUploadTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
// Download returns a reader for the file's content and its metadata.
|
// Download returns a reader for the file's content and its metadata.
|
||||||
// The caller must close the returned ReadCloser.
|
// The caller must close the returned ReadCloser.
|
||||||
func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.ReadCloser, *FileInfo, error) {
|
func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.ReadCloser, *FileInfo, error) {
|
||||||
@@ -154,7 +216,7 @@ func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
if file.IsDir {
|
if file.IsDir {
|
||||||
return nil, nil, &model.AppError{Status: http.StatusBadRequest, Message: "cannot download a directory"}
|
return nil, nil, model.NewBadRequestError("cannot download a directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := s.storage.Open(ctx, file.StoragePath)
|
reader, err := s.storage.Open(ctx, file.StoragePath)
|
||||||
@@ -197,43 +259,41 @@ func (s *FileService) List(ctx context.Context, userID string, parentID *string,
|
|||||||
|
|
||||||
// Update renames or moves a file or directory.
|
// Update renames or moves a file or directory.
|
||||||
func (s *FileService) Update(ctx context.Context, userID, fileID string, newName string, newParentID *string) (*FileInfo, error) {
|
func (s *FileService) Update(ctx context.Context, userID, fileID string, newName string, newParentID *string) (*FileInfo, error) {
|
||||||
file, err := s.getOwnedFile(ctx, userID, fileID)
|
var newNamePtr *string
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate new name if provided.
|
|
||||||
if newName != "" {
|
if newName != "" {
|
||||||
if err := validateFileName(newName); err != nil {
|
if err := validateFileName(newName); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
file.Name = newName
|
newNamePtr = &newName
|
||||||
}
|
}
|
||||||
|
|
||||||
// If parent is changing, verify the new parent.
|
|
||||||
if newParentID != nil {
|
if newParentID != nil {
|
||||||
if *newParentID == fileID {
|
if *newParentID == fileID {
|
||||||
return nil, &model.AppError{Status: http.StatusBadRequest, Message: "cannot move a directory into itself"}
|
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.AppError{Status: http.StatusConflict, Message: "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) {
|
if errors.Is(err, model.ErrDuplicate) {
|
||||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in the target directory"}
|
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)
|
return nil, model.NewInternalError("update file", err)
|
||||||
}
|
}
|
||||||
@@ -243,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.
|
// Delete soft-deletes a file or (empty) directory. Storage content is preserved.
|
||||||
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
|
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
|
||||||
file, err := s.getOwnedFile(ctx, userID, fileID)
|
if err := s.fileRepo.SoftDeleteOwned(ctx, userID, fileID); err != nil {
|
||||||
if err != nil {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
var ae *model.AppError
|
return model.NewNotFoundError("file not found", model.ErrNotFound)
|
||||||
if errors.As(err, &ae) && errors.Is(ae.Err, model.ErrNotFound) {
|
|
||||||
return nil // idempotent: already deleted
|
|
||||||
}
|
}
|
||||||
return err
|
if errors.Is(err, model.ErrDirectoryNotEmpty) {
|
||||||
}
|
return model.NewConflictError("directory is not empty")
|
||||||
|
|
||||||
// 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 {
|
|
||||||
return &model.AppError{Status: http.StatusConflict, Message: "directory is not empty"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.fileRepo.Delete(ctx, fileID); err != nil {
|
|
||||||
return model.NewInternalError("delete file record", err)
|
return model.NewInternalError("delete file record", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,23 +330,26 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
|
|||||||
|
|
||||||
// Check for name conflicts in the target directory.
|
// Check for name conflicts in the target directory.
|
||||||
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, dirName); err == nil {
|
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, dirName); err == nil {
|
||||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"}
|
return nil, model.NewConflictError("a file with this name already exists in this directory")
|
||||||
} else if !errors.Is(err, model.ErrNotFound) {
|
} else if !errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, model.NewInternalError("check name conflict", err)
|
return nil, model.NewInternalError("check name conflict", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := &model.File{
|
dir, err := s.fileRepo.CreateDirectory(ctx, repository.DirectoryParams{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
Name: dirName,
|
Name: dirName,
|
||||||
Status: model.StatusActive,
|
})
|
||||||
IsDir: true,
|
if err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.fileRepo.Create(ctx, dir); err != nil {
|
|
||||||
if errors.Is(err, model.ErrDuplicate) {
|
if errors.Is(err, model.ErrDuplicate) {
|
||||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"}
|
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)
|
return nil, model.NewInternalError("create directory record", err)
|
||||||
}
|
}
|
||||||
@@ -313,13 +362,13 @@ func (s *FileService) getOwnedFile(ctx context.Context, userID, fileID string) (
|
|||||||
file, err := s.fileRepo.FindByID(ctx, fileID)
|
file, err := s.fileRepo.FindByID(ctx, fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, &model.AppError{Status: http.StatusNotFound, Message: "file not found", Err: model.ErrNotFound}
|
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
|
||||||
}
|
}
|
||||||
return nil, model.NewInternalError("find file", err)
|
return nil, model.NewInternalError("find file", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if file.UserID != userID {
|
if file.UserID != userID {
|
||||||
return nil, &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
|
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return file, nil
|
return file, nil
|
||||||
@@ -330,17 +379,17 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string)
|
|||||||
parent, err := s.fileRepo.FindByID(ctx, parentID)
|
parent, err := s.fileRepo.FindByID(ctx, parentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return &model.AppError{Status: http.StatusNotFound, Message: "parent directory not found"}
|
return model.NewNotFoundError("parent directory not found", model.ErrNotFound)
|
||||||
}
|
}
|
||||||
return model.NewInternalError("find parent", err)
|
return model.NewInternalError("find parent", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parent.UserID != userID {
|
if parent.UserID != userID {
|
||||||
return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
|
return model.NewNotFoundError("parent directory not found", model.ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !parent.IsDir {
|
if !parent.IsDir {
|
||||||
return &model.AppError{Status: http.StatusBadRequest, Message: "parent is not a directory"}
|
return model.NewBadRequestError("parent is not a directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -350,13 +399,13 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string)
|
|||||||
// null bytes, or are reserved names "." and "..".
|
// null bytes, or are reserved names "." and "..".
|
||||||
func validateFileName(name string) error {
|
func validateFileName(name string) error {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return &model.AppError{Status: http.StatusBadRequest, Message: "file name must not be empty"}
|
return model.NewBadRequestError("file name must not be empty")
|
||||||
}
|
}
|
||||||
if strings.ContainsAny(name, "/\\\x00") {
|
if strings.ContainsAny(name, "/\\\x00") {
|
||||||
return &model.AppError{Status: http.StatusBadRequest, Message: "file name contains invalid characters"}
|
return model.NewBadRequestError("file name contains invalid characters")
|
||||||
}
|
}
|
||||||
if name == "." || name == ".." {
|
if name == "." || name == ".." {
|
||||||
return &model.AppError{Status: http.StatusBadRequest, Message: fmt.Sprintf("file name is reserved: %s", name)}
|
return model.NewBadRequestError(fmt.Sprintf("file name is reserved: %s", name))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+236
-26
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -18,32 +17,71 @@ import (
|
|||||||
"github.com/dhao2001/mygo/internal/storage"
|
"github.com/dhao2001/mygo/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// assertAppError checks that err is an *model.AppError with the expected status.
|
// assertAppError checks that err is an *model.AppError with the expected kind.
|
||||||
func assertAppError(t *testing.T, err error, wantStatus int) bool {
|
func assertAppError(t *testing.T, err error, wantKind model.ErrorKind) bool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var ae *model.AppError
|
var ae *model.AppError
|
||||||
if !errors.As(err, &ae) {
|
if !errors.As(err, &ae) {
|
||||||
t.Errorf("expected *model.AppError, got %T: %v", err, err)
|
t.Errorf("expected *model.AppError, got %T: %v", err, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if ae.Status != wantStatus {
|
if ae.Kind != wantKind {
|
||||||
t.Errorf("status = %d, want %d; message = %q", ae.Status, wantStatus, ae.Message)
|
t.Errorf("kind = %q, want %q; message = %q", ae.Kind, wantKind, ae.Message)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
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.
|
// memStorage is an in-memory implementation of storage.StorageBackend for tests.
|
||||||
type memStorage struct {
|
type memStorage struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
files map[string][]byte
|
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 {
|
func newMemStorage() *memStorage {
|
||||||
return &memStorage{files: make(map[string][]byte)}
|
return &memStorage{files: make(map[string][]byte)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *memStorage) Save(_ context.Context, path string, reader io.Reader) (int64, error) {
|
func (s *memStorage) SaveStaged(_ context.Context, path string, reader io.Reader) (int64, error) {
|
||||||
data, err := io.ReadAll(reader)
|
data, err := io.ReadAll(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -54,6 +92,19 @@ func (s *memStorage) Save(_ context.Context, path string, reader io.Reader) (int
|
|||||||
return int64(len(data)), nil
|
return int64(len(data)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *memStorage) PromoteStaged(_ context.Context, stagedPath, finalPath string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
data, ok := s.files[stagedPath]
|
||||||
|
if !ok {
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
s.files[finalPath] = data
|
||||||
|
delete(s.files, stagedPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *memStorage) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
func (s *memStorage) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
data, ok := s.files[path]
|
data, ok := s.files[path]
|
||||||
@@ -64,6 +115,10 @@ func (s *memStorage) Open(_ context.Context, path string) (io.ReadCloser, error)
|
|||||||
return io.NopCloser(bytes.NewReader(data)), nil
|
return io.NopCloser(bytes.NewReader(data)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *memStorage) DeleteStaged(ctx context.Context, path string) error {
|
||||||
|
return s.Delete(ctx, path)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *memStorage) Delete(_ context.Context, path string) error {
|
func (s *memStorage) Delete(_ context.Context, path string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.files, path)
|
delete(s.files, path)
|
||||||
@@ -74,6 +129,10 @@ func (s *memStorage) Delete(_ context.Context, path string) error {
|
|||||||
var _ storage.StorageBackend = (*memStorage)(nil)
|
var _ storage.StorageBackend = (*memStorage)(nil)
|
||||||
|
|
||||||
func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) {
|
func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) {
|
||||||
|
return setupFileServiceWithStorageAndMaxUploadSize(t, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupFileServiceWithStorageAndMaxUploadSize(t *testing.T, maxUploadSize int64) (*FileService, *memStorage) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
@@ -87,7 +146,7 @@ func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) {
|
|||||||
fileRepo := repository.NewFileRepository(db)
|
fileRepo := repository.NewFileRepository(db)
|
||||||
store := newMemStorage()
|
store := newMemStorage()
|
||||||
|
|
||||||
return NewFileService(fileRepo, store, 0, nil), store // 0 = unlimited, nil logger defaults to slog.Default()
|
return NewFileService(fileRepo, store, maxUploadSize, nil), store // nil logger defaults to slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupFileService(t *testing.T) *FileService {
|
func setupFileService(t *testing.T) *FileService {
|
||||||
@@ -125,6 +184,34 @@ func TestFileService_Upload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileService_UploadRejectsOversizedFileAndCleansStaging(t *testing.T) {
|
||||||
|
svc, store := setupFileServiceWithStorageAndMaxUploadSize(t, 5)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := svc.Upload(ctx, "user1", nil, "too-large.txt", strings.NewReader("123456"))
|
||||||
|
assertAppError(t, err, model.KindPayloadTooLarge)
|
||||||
|
|
||||||
|
store.mu.RLock()
|
||||||
|
defer store.mu.RUnlock()
|
||||||
|
if len(store.files) != 0 {
|
||||||
|
t.Fatalf("storage files = %#v, want empty after oversized upload", store.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileService_UploadUnlimitedWhenMaxUploadSizeIsZero(t *testing.T) {
|
||||||
|
svc := setupFileService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
content := strings.Repeat("a", 128)
|
||||||
|
info, err := svc.Upload(ctx, "user1", nil, "unlimited.txt", strings.NewReader(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upload = %v", err)
|
||||||
|
}
|
||||||
|
if info.Size != int64(len(content)) {
|
||||||
|
t.Errorf("Size = %d, want %d", info.Size, len(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileService_UploadIntoDirectory(t *testing.T) {
|
func TestFileService_UploadIntoDirectory(t *testing.T) {
|
||||||
svc := setupFileService(t)
|
svc := setupFileService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -226,7 +313,7 @@ func TestFileService_Download(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_DownloadForbidden(t *testing.T) {
|
func TestFileService_DownloadReturnsNotFoundForOtherUser(t *testing.T) {
|
||||||
svc := setupFileService(t)
|
svc := setupFileService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -236,7 +323,7 @@ func TestFileService_DownloadForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = svc.Download(ctx, "user2", info.ID)
|
_, _, err = svc.Download(ctx, "user2", info.ID)
|
||||||
assertAppError(t, err, http.StatusForbidden)
|
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_DownloadDirectory(t *testing.T) {
|
func TestFileService_DownloadDirectory(t *testing.T) {
|
||||||
@@ -277,10 +364,10 @@ func TestFileService_GetNotFound(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
_, err := svc.Get(ctx, "user1", "nonexistent")
|
_, err := svc.Get(ctx, "user1", "nonexistent")
|
||||||
assertAppError(t, err, http.StatusNotFound)
|
assertAppError(t, err, model.KindNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_GetForbidden(t *testing.T) {
|
func TestFileService_GetReturnsNotFoundForOtherUser(t *testing.T) {
|
||||||
svc := setupFileService(t)
|
svc := setupFileService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -290,7 +377,7 @@ func TestFileService_GetForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = svc.Get(ctx, "user2", info.ID)
|
_, err = svc.Get(ctx, "user2", info.ID)
|
||||||
assertAppError(t, err, http.StatusForbidden)
|
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_List(t *testing.T) {
|
func TestFileService_List(t *testing.T) {
|
||||||
@@ -347,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) {
|
func TestFileService_UpdateMove(t *testing.T) {
|
||||||
svc := setupFileService(t)
|
svc := setupFileService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -388,7 +512,7 @@ func TestFileService_Delete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = svc.Get(ctx, "user1", info.ID)
|
_, err = svc.Get(ctx, "user1", info.ID)
|
||||||
assertAppError(t, err, http.StatusNotFound)
|
assertAppError(t, err, model.KindNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_DeleteNonEmptyDirectory(t *testing.T) {
|
func TestFileService_DeleteNonEmptyDirectory(t *testing.T) {
|
||||||
@@ -410,7 +534,7 @@ func TestFileService_DeleteNonEmptyDirectory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_DeleteForbidden(t *testing.T) {
|
func TestFileService_DeleteReturnsNotFoundForOtherUser(t *testing.T) {
|
||||||
svc := setupFileService(t)
|
svc := setupFileService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -420,7 +544,14 @@ func TestFileService_DeleteForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = svc.Delete(ctx, "user2", info.ID)
|
err = svc.Delete(ctx, "user2", info.ID)
|
||||||
assertAppError(t, err, http.StatusForbidden)
|
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) {
|
func TestFileService_CreateDir(t *testing.T) {
|
||||||
@@ -454,7 +585,7 @@ func TestFileService_CreateDirDuplicateName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_VerifyParentForbidden(t *testing.T) {
|
func TestFileService_ParentOperationsReturnNotFoundForOtherUser(t *testing.T) {
|
||||||
svc := setupFileService(t)
|
svc := setupFileService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -462,9 +593,50 @@ func TestFileService_VerifyParentForbidden(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateDir = %v", err)
|
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"))
|
tests := []struct {
|
||||||
assertAppError(t, err, http.StatusForbidden)
|
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) {
|
func TestFileService_SoftDelete(t *testing.T) {
|
||||||
@@ -481,7 +653,7 @@ func TestFileService_SoftDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = svc.Get(ctx, "user1", info.ID)
|
_, err = svc.Get(ctx, "user1", info.ID)
|
||||||
assertAppError(t, err, http.StatusNotFound)
|
assertAppError(t, err, model.KindNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
|
func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
|
||||||
@@ -498,7 +670,7 @@ func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
|
|||||||
t.Fatalf("Delete = %v", err)
|
t.Fatalf("Delete = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
storagePath := "user1/" + info.ID
|
storagePath := "data/user1/" + info.ID
|
||||||
reader, err := store.Open(ctx, storagePath)
|
reader, err := store.Open(ctx, storagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("storage should retain content after soft-delete, got: %v", err)
|
t.Fatalf("storage should retain content after soft-delete, got: %v", err)
|
||||||
@@ -514,7 +686,7 @@ func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_SoftDeleteIdempotent(t *testing.T) {
|
func TestFileService_SoftDeleteRepeatedReturnsNotFound(t *testing.T) {
|
||||||
svc := setupFileService(t)
|
svc := setupFileService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -526,12 +698,11 @@ func TestFileService_SoftDeleteIdempotent(t *testing.T) {
|
|||||||
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
||||||
t.Fatalf("first Delete = %v", err)
|
t.Fatalf("first Delete = %v", err)
|
||||||
}
|
}
|
||||||
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
err = svc.Delete(ctx, "user1", info.ID)
|
||||||
t.Fatalf("second Delete should be idempotent, got: %v", err)
|
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_SoftDeleteForbidden(t *testing.T) {
|
func TestFileService_SoftDeleteReturnsNotFoundForOtherUser(t *testing.T) {
|
||||||
svc := setupFileService(t)
|
svc := setupFileService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -541,5 +712,44 @@ func TestFileService_SoftDeleteForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = svc.Delete(ctx, "user2", info.ID)
|
err = svc.Delete(ctx, "user2", info.ID)
|
||||||
assertAppError(t, err, http.StatusForbidden)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-2
@@ -2,13 +2,22 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dataPathPrefix = "data"
|
||||||
|
stagingPathPrefix = "staging"
|
||||||
|
)
|
||||||
|
|
||||||
|
var renameLocalFile = os.Rename
|
||||||
|
|
||||||
// LocalStorage implements StorageBackend using the local filesystem.
|
// LocalStorage implements StorageBackend using the local filesystem.
|
||||||
type LocalStorage struct {
|
type LocalStorage struct {
|
||||||
basePath string
|
basePath string
|
||||||
@@ -24,12 +33,15 @@ func NewLocalStorage(basePath string) (*LocalStorage, error) {
|
|||||||
return &LocalStorage{basePath: abs}, nil
|
return &LocalStorage{basePath: abs}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save writes the contents of reader to path under the storage root.
|
// SaveStaged writes the contents of reader to a staging path under the storage root.
|
||||||
func (s *LocalStorage) Save(_ context.Context, path string, reader io.Reader) (int64, error) {
|
func (s *LocalStorage) SaveStaged(_ context.Context, path string, reader io.Reader) (int64, error) {
|
||||||
fullPath := filepath.Join(s.basePath, path)
|
fullPath := filepath.Join(s.basePath, path)
|
||||||
if !isSubPath(s.basePath, fullPath) {
|
if !isSubPath(s.basePath, fullPath) {
|
||||||
return 0, fmt.Errorf("storage: path traversal attempt: %s", path)
|
return 0, fmt.Errorf("storage: path traversal attempt: %s", path)
|
||||||
}
|
}
|
||||||
|
if !hasPathPrefix(path, stagingPathPrefix) {
|
||||||
|
return 0, fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, path)
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0750); err != nil {
|
if err := os.MkdirAll(filepath.Dir(fullPath), 0750); err != nil {
|
||||||
return 0, fmt.Errorf("create parent directory: %w", err)
|
return 0, fmt.Errorf("create parent directory: %w", err)
|
||||||
@@ -51,6 +63,75 @@ func (s *LocalStorage) Save(_ context.Context, path string, reader io.Reader) (i
|
|||||||
return written, nil
|
return written, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PromoteStaged moves a staged file to its final path under the storage root.
|
||||||
|
func (s *LocalStorage) PromoteStaged(_ context.Context, stagedPath, finalPath string) error {
|
||||||
|
fullStagedPath := filepath.Join(s.basePath, stagedPath)
|
||||||
|
if !isSubPath(s.basePath, fullStagedPath) {
|
||||||
|
return fmt.Errorf("storage: path traversal attempt: %s", stagedPath)
|
||||||
|
}
|
||||||
|
if !hasPathPrefix(stagedPath, stagingPathPrefix) {
|
||||||
|
return fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, stagedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fullFinalPath := filepath.Join(s.basePath, finalPath)
|
||||||
|
if !isSubPath(s.basePath, fullFinalPath) {
|
||||||
|
return fmt.Errorf("storage: path traversal attempt: %s", finalPath)
|
||||||
|
}
|
||||||
|
if !hasPathPrefix(finalPath, dataPathPrefix) {
|
||||||
|
return fmt.Errorf("storage: final path must be under %s/: %s", dataPathPrefix, finalPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(fullFinalPath); err == nil {
|
||||||
|
return fmt.Errorf("final file already exists: %s", finalPath)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("check final file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fullFinalPath), 0750); err != nil {
|
||||||
|
return fmt.Errorf("create parent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := renameLocalFile(fullStagedPath, fullFinalPath); err != nil {
|
||||||
|
if errors.Is(err, syscall.EXDEV) {
|
||||||
|
return promoteByCopy(fullStagedPath, fullFinalPath)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("promote staged file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promoteByCopy(stagedPath, finalPath string) error {
|
||||||
|
source, err := os.Open(stagedPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open staged file: %w", err)
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
destination, err := os.OpenFile(finalPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create final file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(destination, source); err != nil {
|
||||||
|
destination.Close()
|
||||||
|
os.Remove(finalPath)
|
||||||
|
return fmt.Errorf("copy staged file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := destination.Close(); err != nil {
|
||||||
|
os.Remove(finalPath)
|
||||||
|
return fmt.Errorf("close final file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(stagedPath); err != nil {
|
||||||
|
os.Remove(finalPath)
|
||||||
|
return fmt.Errorf("delete staged file after copy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Open returns a reader for the file at path under the storage root.
|
// Open returns a reader for the file at path under the storage root.
|
||||||
func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
||||||
fullPath := filepath.Join(s.basePath, path)
|
fullPath := filepath.Join(s.basePath, path)
|
||||||
@@ -68,6 +149,23 @@ func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, erro
|
|||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteStaged removes a staged file under the storage root.
|
||||||
|
func (s *LocalStorage) DeleteStaged(_ context.Context, path string) error {
|
||||||
|
fullPath := filepath.Join(s.basePath, path)
|
||||||
|
if !isSubPath(s.basePath, fullPath) {
|
||||||
|
return fmt.Errorf("storage: path traversal attempt: %s", path)
|
||||||
|
}
|
||||||
|
if !hasPathPrefix(path, stagingPathPrefix) {
|
||||||
|
return fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.Remove(fullPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Delete removes the file at path under the storage root.
|
// Delete removes the file at path under the storage root.
|
||||||
func (s *LocalStorage) Delete(_ context.Context, path string) error {
|
func (s *LocalStorage) Delete(_ context.Context, path string) error {
|
||||||
fullPath := filepath.Join(s.basePath, path)
|
fullPath := filepath.Join(s.basePath, path)
|
||||||
@@ -95,3 +193,8 @@ func isSubPath(base, target string) bool {
|
|||||||
// filepath.Rel returns ".." or starts with "../" for paths outside base.
|
// filepath.Rel returns ".." or starts with "../" for paths outside base.
|
||||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasPathPrefix(path, prefix string) bool {
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
return clean == prefix || strings.HasPrefix(clean, prefix+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|||||||
+121
-10
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,15 +18,19 @@ func TestSaveAndOpen(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
content := "hello, mygo storage"
|
content := "hello, mygo storage"
|
||||||
written, err := store.Save(ctx, "test/hello.txt", strings.NewReader(content))
|
written, err := store.SaveStaged(ctx, "staging/test/hello.txt", strings.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Save: %v", err)
|
t.Fatalf("SaveStaged: %v", err)
|
||||||
}
|
}
|
||||||
if written != int64(len(content)) {
|
if written != int64(len(content)) {
|
||||||
t.Errorf("written = %d, want %d", written, len(content))
|
t.Errorf("written = %d, want %d", written, len(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := store.Open(ctx, "test/hello.txt")
|
if err := store.PromoteStaged(ctx, "staging/test/hello.txt", "data/test/hello.txt"); err != nil {
|
||||||
|
t.Fatalf("PromoteStaged: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := store.Open(ctx, "data/test/hello.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Open: %v", err)
|
t.Fatalf("Open: %v", err)
|
||||||
}
|
}
|
||||||
@@ -40,7 +45,7 @@ func TestSaveAndOpen(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify the file exists on disk under the base path.
|
// Verify the file exists on disk under the base path.
|
||||||
expectedPath := dir + "/test/hello.txt"
|
expectedPath := dir + "/data/test/hello.txt"
|
||||||
if _, err := os.Stat(expectedPath); err != nil {
|
if _, err := os.Stat(expectedPath); err != nil {
|
||||||
t.Errorf("file not found on disk at %s: %v", expectedPath, err)
|
t.Errorf("file not found on disk at %s: %v", expectedPath, err)
|
||||||
}
|
}
|
||||||
@@ -54,16 +59,19 @@ func TestDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
_, err = store.Save(ctx, "to_delete.txt", strings.NewReader("delete me"))
|
_, err = store.SaveStaged(ctx, "staging/to_delete.txt", strings.NewReader("delete me"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Save: %v", err)
|
t.Fatalf("SaveStaged: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.PromoteStaged(ctx, "staging/to_delete.txt", "data/to_delete.txt"); err != nil {
|
||||||
|
t.Fatalf("PromoteStaged: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Delete(ctx, "to_delete.txt"); err != nil {
|
if err := store.Delete(ctx, "data/to_delete.txt"); err != nil {
|
||||||
t.Fatalf("Delete: %v", err)
|
t.Fatalf("Delete: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = store.Open(ctx, "to_delete.txt")
|
_, err = store.Open(ctx, "data/to_delete.txt")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Open should fail after delete")
|
t.Error("Open should fail after delete")
|
||||||
}
|
}
|
||||||
@@ -91,9 +99,23 @@ func TestPathTraversalRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
_, err = store.Save(ctx, "../escape.txt", strings.NewReader("malicious"))
|
_, err = store.SaveStaged(ctx, "../escape.txt", strings.NewReader("malicious"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Save should reject path traversal")
|
t.Error("SaveStaged should reject path traversal")
|
||||||
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
||||||
|
t.Errorf("expected path traversal error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.PromoteStaged(ctx, "../escape.txt", "data/escape.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("PromoteStaged should reject staged path traversal")
|
||||||
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
||||||
|
t.Errorf("expected path traversal error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.PromoteStaged(ctx, "staging/escape.txt", "../escape.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("PromoteStaged should reject final path traversal")
|
||||||
} else if !strings.Contains(err.Error(), "path traversal") {
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
||||||
t.Errorf("expected path traversal error, got: %v", err)
|
t.Errorf("expected path traversal error, got: %v", err)
|
||||||
}
|
}
|
||||||
@@ -111,6 +133,95 @@ func TestPathTraversalRejected(t *testing.T) {
|
|||||||
} else if !strings.Contains(err.Error(), "path traversal") {
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
||||||
t.Errorf("expected path traversal error, got: %v", err)
|
t.Errorf("expected path traversal error, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = store.DeleteStaged(ctx, "../escape.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("DeleteStaged should reject path traversal")
|
||||||
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
||||||
|
t.Errorf("expected path traversal error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStagedPathsRequireStagingPrefix(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewLocalStorage(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocalStorage: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err = store.SaveStaged(ctx, "data/not-staged.txt", strings.NewReader("bad"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("SaveStaged should reject non-staging path")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.DeleteStaged(ctx, "data/not-staged.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("DeleteStaged should reject non-staging path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteRequiresDataFinalPrefix(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewLocalStorage(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocalStorage: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err = store.SaveStaged(ctx, "staging/promote.txt", strings.NewReader("content"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveStaged: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.PromoteStaged(ctx, "staging/promote.txt", "other/promote.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("PromoteStaged should reject non-data final path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteStagedFallsBackToCopyOnEXDEV(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewLocalStorage(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocalStorage: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
content := "copy across filesystems"
|
||||||
|
_, err = store.SaveStaged(ctx, "staging/cross-device.txt", strings.NewReader(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveStaged: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRename := renameLocalFile
|
||||||
|
renameLocalFile = func(_, _ string) error {
|
||||||
|
return &os.LinkError{Op: "rename", Err: syscall.EXDEV}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
renameLocalFile = originalRename
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := store.PromoteStaged(ctx, "staging/cross-device.txt", "data/cross-device.txt"); err != nil {
|
||||||
|
t.Fatalf("PromoteStaged: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := store.Open(ctx, "data/cross-device.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open promoted file: %v", err)
|
||||||
|
}
|
||||||
|
got, err := io.ReadAll(reader)
|
||||||
|
reader.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != content {
|
||||||
|
t.Errorf("content = %q, want %q", string(got), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(dir + "/staging/cross-device.txt"); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("staged file should be removed after fallback promote, stat err = %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenMissingFile(t *testing.T) {
|
func TestOpenMissingFile(t *testing.T) {
|
||||||
|
|||||||
@@ -6,18 +6,24 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StorageBackend abstracts where file content is written, read, and deleted.
|
// StorageBackend abstracts where file content is staged, promoted, read, and
|
||||||
// Paths passed to all methods are relative to the backend's root and must
|
// deleted. Paths passed to all methods are relative to the backend's root and
|
||||||
// not contain path traversal sequences.
|
// must not contain path traversal sequences.
|
||||||
type StorageBackend interface {
|
type StorageBackend interface {
|
||||||
// Save writes the contents of reader to path, creating parent directories
|
// SaveStaged writes the contents of reader to a staging path, creating
|
||||||
// as needed. It returns the number of bytes written.
|
// parent directories as needed. It returns the number of bytes written.
|
||||||
Save(ctx context.Context, path string, reader io.Reader) (int64, error)
|
SaveStaged(ctx context.Context, path string, reader io.Reader) (int64, error)
|
||||||
|
|
||||||
|
// PromoteStaged moves a staged object to its final path.
|
||||||
|
PromoteStaged(ctx context.Context, stagedPath, finalPath string) error
|
||||||
|
|
||||||
// Open returns a reader for the file at path. The caller must close the
|
// Open returns a reader for the file at path. The caller must close the
|
||||||
// returned ReadCloser.
|
// returned ReadCloser.
|
||||||
Open(ctx context.Context, path string) (io.ReadCloser, error)
|
Open(ctx context.Context, path string) (io.ReadCloser, error)
|
||||||
|
|
||||||
|
// DeleteStaged removes a staged object. It is idempotent.
|
||||||
|
DeleteStaged(ctx context.Context, path string) error
|
||||||
|
|
||||||
// Delete removes the file at path. It is idempotent — deleting a
|
// Delete removes the file at path. It is idempotent — deleting a
|
||||||
// non-existent file is not an error.
|
// non-existent file is not an error.
|
||||||
Delete(ctx context.Context, path string) error
|
Delete(ctx context.Context, path string) error
|
||||||
|
|||||||
@@ -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,2 +1,21 @@
|
|||||||
[tools]
|
[tools]
|
||||||
go = "1.26.2"
|
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"
|
||||||
|
|
||||||
|
[tasks."dev:api"]
|
||||||
|
description = "Start the MyGO API server"
|
||||||
|
run = "go run . serve"
|
||||||
|
|
||||||
|
[tasks."dev:web"]
|
||||||
|
description = "Start the Vite development server"
|
||||||
|
dir = "{{config_root}}/web"
|
||||||
|
run = "npm run dev -- --host ${MYGO_WEB_HOST:-127.0.0.1}"
|
||||||
|
|
||||||
|
[tasks.dev]
|
||||||
|
description = "Start the MyGO API and Web development servers"
|
||||||
|
depends = ["dev:api", "dev:web"]
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# MyGO Web
|
||||||
|
|
||||||
|
MyGO Web is a client-side React application built with TypeScript and Vite.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Install the pinned toolchains from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise install
|
||||||
|
```
|
||||||
|
|
||||||
|
Install Web dependencies from `web/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise exec -- npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Start the API from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise exec -- go run . serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a development account when 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"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Start Vite from `web/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise exec -- npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite proxies `/api` to `http://127.0.0.1:10086`. Production deployments serve the static build and `/api/v1` from the same origin.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
Run the Web checks from `web/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise exec -- npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
The command runs linting, unit tests, type checking, and the production build.
|
||||||
|
|
||||||
|
## Browser Testing
|
||||||
|
|
||||||
|
Install the Debian browser libraries once per development container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise exec -- npm run playwright:install:deps
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the repository-pinned Chromium revision:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise exec -- npm run playwright:install
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the headless E2E tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise exec -- npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Debugging
|
||||||
|
|
||||||
|
Use the repository wrapper for interactive diagnostics:
|
||||||
|
|
||||||
|
```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 uses the pinned `@playwright/test` package, bundled Chromium, and `.playwright/cli.config.json`. Browser caches and generated artifacts stay under the repository-level `.cache/` and `.artifacts/` directories.
|
||||||
|
|
||||||
|
Do not invoke Playwright through a global installation or a floating `npx` package. Agents use the repository-scoped `$playwright-cli` skill for browser work.
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user