Compare commits

..

24 Commits

Author SHA1 Message Date
ld 159bb27493 feat: establish the core API framework and scaffold the Web client (WIP)
- feat: implement the WIP API foundation for file management, administration, logging, error handling, and local storage.
- feat: scaffold the WIP React Web client with routing, data-query, styling, and build foundations.
- docs: reorganize server and Web documentation and add repository review guidance.
2026-07-14 17:55:58 +08:00
ld ceaeafd96c docs: document worktree branch convention in AGENTS.md 2026-07-14 17:42:56 +08:00
ld 9f13c0a23a docs: reorganize documentation into server/ and web/ directories
- docs: move server docs to docs/server/ and update all cross-references
- docs: move web-roadmap.md to docs/web/roadmap.md and create
  docs/web/decisions.md
- docs: update AGENTS.md with separate Server and Web sections
2026-07-14 17:07:42 +08:00
ld b49bf648be feat(web): scaffold client-side rendered SPA foundation
- feat: initialize Vite project with React 19, React Router 8, TanStack
  Query, Ant Design 6, and Tailwind CSS 4
- feat: add Oxlint with React/TypeScript rules, strict TypeScript
  config, and layered CSS import order
- docs: add decision record for client-rendered Web foundation with
  React, Ant Design, and Tailwind CSS
- docs: create web roadmap defining architecture boundaries and deferred
  dependencies
- build: pin Node.js 24 in mise.toml
2026-07-14 16:50:13 +08:00
ld a62a5bc0e2 skill: refine repo-review skill instructions for clarity and calibration 2026-07-14 13:52:52 +08:00
ld 5674fa2eb5 skill: add mygo-api-repo-review skill. 2026-07-14 11:55:16 +08:00
ld 803f195af1 refactor: rename internal/log to internal/logging to avoid stdlib
shadowing

- refactor: rename `internal/log` → `internal/logging`, package `log` →
  `logging`
- refactor: update imports in `cmd/serve.go` and
  `internal/middleware/requestid.go`
2026-07-06 00:11:06 +08:00
ld 3ad61244dc fix: properly handle repository errors and remove unused hash field
- fix: wrap errors from sessionRepo.Delete, credentialRepo operations
  with internal error context in Logout, ListPasskeys, and RevokePasskey
- fix: remove Hash field from fileInfoResponse DTO (unused)
2026-07-05 23:57:19 +08:00
ld 63ede5c237 refactor(api): enforce protocol-neutral service boundaries
- refactor: move HTTP status and DTO concerns out of model and service
  layers into API and handler code.
- feat: add admin service boundary and route auth through services
  instead of direct repository access.
- test: add architecture and error tests covering package boundaries,
  redaction, and service behavior.
- docs: record the protocol-neutral boundary decision and update
  architecture and roadmap notes.
2026-07-05 23:30:17 +08:00
ld 28e17a5b08 refactor: add AppError helpers and harden upload/download error handling
- refactor: replace inline AppError literals with model.New*Error
  constructors in service and handler layers
- feat: PromoteStaged falls back to copy/delete when os.Rename returns
  EXDEV (cross-device link)
- fix: upload error messages no longer leak internal multipart field
  names or parser implementation details
- fix: upload size violations are converted to domain error
  ErrUploadTooLarge at the handler boundary
- fix: download logs error and size mismatch when io.Copy fails or
  writes fewer bytes than expected
- test: add tests for field name leak prevention, parser error
  sanitization, read errors after response start, and EXDEV fallback
2026-07-05 17:35:34 +08:00
ld fc2b9312fa docs: clarify conventional commit body blank-line rule 2026-07-05 17:18:59 +08:00
ld b988b4b15e fix(file)!: stream uploads through staged storage
- fix: replace multipart form parsing with streaming multipart reads and
  apply request body limits when max_upload_size is configured.
- refactor: route uploads through staging paths before promotion to
  long-term data paths, keeping incomplete uploads out of durable
  storage records.
- test: cover oversized uploads, unlimited uploads, staged cleanup, and
  local storage promotion boundaries.
- docs: document the staged upload model and multipart parent_id query
  parameter.
2026-07-05 17:18:19 +08:00
ld bfeb4b26e4 docs(AGENTS.md): clarify and tighten Git workflow instructions 2026-07-05 15:34:22 +08:00
ld 17e346836c docs(AGENTS.md): add Git Version Control section 2026-07-05 13:54:16 +08:00
ld 5eeb37389b fix(config): harden default JWT secret
Replace known development JWT secret placeholders with an ephemeral runtime secret during config loading.

Return LoadInfo so startup can warn when token persistence depends on a stable configured secret.

Update config docs and tests for default, legacy placeholder, custom, env, and empty secret behavior.
2026-07-05 13:25:44 +08:00
ld 7b6587a951 test(handler): TDD tests for AdminHandler user CRUD and visibility
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-04 17:31:01 +08:00
ld 3daad379e0 test(server): add admin route integration test 2026-07-04 17:23:35 +08:00
ld e04e39bea8 feat(svc): implement soft-delete in FileService
Delete now performs soft-delete (status='user_deleted') instead of physical deletion.

Removes storage content deletion — files are preserved on disk after soft-delete.

Second delete on already-deleted file returns nil (idempotent).

Add tests: SoftDelete, SoftDeleteKeepsStorage, SoftDeleteIdempotent, SoftDeleteForbidden.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-04 17:20:34 +08:00
ld ac98b5ddbd feat(middleware): add AdminRequired authorization middleware
AdminRequired gates admin endpoints by checking user IsAdmin flag.

Placed AFTER AuthRequired; fetches user from repository, returns 403 for non-admins.

Soft-deleted users are rejected with 401 since FindByID excludes them.

Tests: admin passes, non-admin forbidden, soft-deleted admin rejected, missing user ID.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-04 17:20:30 +08:00
ld 032f716e49 feat(repo): add soft-delete with status filter to file queries
File repository now filters by status=active in all query methods (FindByID, FindByUserID, FindByParentID, FindByNameAndParent).

Delete now performs soft-delete by setting status to 'user_deleted' instead of physical row deletion.

Add ListIncludeDeleted to UserRepository for admin views of all users.

Add tests: StatusFilter, SoftDelete, DeleteIdempotent, StatusFilterCount.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-04 17:20:22 +08:00
ld a8078f787c feat(model): add Status field and constants to User and File
Add Status field (gorm, indexed, default active) to both User and File models.

Add User status constants: StatusActive, StatusAdminDeleted.

Add File status constant: StatusUserDeleted.

Add tests verifying Status field is excluded from JSON serialization.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-04 17:20:14 +08:00
ld 170e54495d feat(handler): add AdminHandler with admin routes 2026-07-04 17:18:23 +08:00
ld 118b7e4d2a feat(svc): reject disabled users in Login, LoginWithPasskey, and Refresh 2026-07-04 17:13:02 +08:00
ld bff131ba42 feat(repo): soft-delete User with status filter on queries 2026-07-04 17:08:10 +08:00
77 changed files with 6615 additions and 477 deletions
@@ -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.
+103 -22
View File
@@ -1,22 +1,36 @@
# 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, with documentation in `docs/server/`
- WebDisk (cloud drive) backend; roadmap in `docs/roadmap.md` - Web: React client in `web/`, with documentation in `docs/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. Read the task and identify the affected project
2. Read relevant `docs/` files for context 2. Read `docs/README.md` and the relevant project documentation for context
3. Explore existing code before writing new code 3. Explore existing code before writing new code
4. Implement following the conventions below 4. Implement following the relevant 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 the relevant project roadmap, decisions, architecture, or development documentation if anything changed
## Go Conventions ## Server — Go Backend
### Project Essentials
- Module: `github.com/dhao2001/mygo`, Go 1.26.2
- WebDisk (cloud drive) backend; roadmap in `docs/server/roadmap.md`
- CLI framework: `github.com/spf13/cobra`
- Go version pinned in `mise.toml`
### Repository Review
- Explicitly invoke `$mygo-api-repo-review` for repository-wide or scoped architecture, design, security, resilience, data-consistency, performance, and implementation audits.
- Repository reviews are read-only unless the user separately requests fixes.
- Use `diff`, `main`, or `full` review mode and optionally limit any mode with a target path, Go package, or logical component.
### Go Conventions
- **Format**: `go fmt ./...` before every commit - **Format**: `go fmt ./...` before every commit
- **Imports**: stdlib / third-party / internal, blank-line separated - **Imports**: stdlib / third-party / internal, blank-line separated
@@ -26,16 +40,16 @@
- **`init()`**: only in cobra cmd files for flag registration - **`init()`**: only in cobra cmd files for flag registration
- **`cmd/`** is thin; business logic goes in `internal/` - **`cmd/`** is thin; business logic goes in `internal/`
## Documentation ### Documentation
| File | Read Before | Update After | | File | Read Before | Update After |
|------|-------------|--------------| |------|-------------|--------------|
| `docs/architecture.md` | Adding new packages | Adding new packages | | `docs/server/architecture.md` | Adding new packages | Adding new packages |
| `docs/decisions.md` | Making technical decisions | Making technical decisions | | `docs/server/decisions.md` | Making technical decisions | Making technical decisions |
| `docs/roadmap.md` | Every task | Completing a feature | | `docs/server/roadmap.md` | Every server task | Completing a server feature |
| `docs/development.md` | Build/test/debug setup | Changing workflow | | `docs/server/development.md` | Build/test/debug setup | Changing server workflow |
## Commands ### Commands
```bash ```bash
go build ./... # build all packages go build ./... # build all packages
@@ -45,16 +59,83 @@ go fmt ./... # format
go mod tidy # clean deps after add/remove go mod tidy # clean deps after add/remove
``` ```
## DO / DON'T ### Server DO / DON'T
- DO put business logic in `internal/`, keep `cmd/` thin - DO put business logic in `internal/`, keep `cmd/` thin
- DO write all code, comments, and documentation in English
- DO add all Go module dependencies **before** writing code that uses them - DO add all Go module dependencies **before** writing code that uses them
- DON'T read `go.sum` entirely into context — use `grep` or other tools to search specific patterns if needed - 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 - DON'T skip `go vet ./...` before finishing server work
- DON'T commit without explicit user request
- DON'T add, remove, or change Go module dependencies after debugging has started — ask for explicit permission first - DON'T add, remove, or change Go module dependencies after debugging has started — ask for explicit permission first
## Web — React Client
### Project Essentials
- Source: `web/`
- Pure client-side rendered application; roadmap in `docs/web/roadmap.md`
- Stack: Node.js 24, Vite, React, TypeScript, React Router, TanStack Query, Tailwind CSS 4, and Ant Design
- Node.js version pinned in `mise.toml`
### Documentation
| File | Read Before | Update After |
|------|-------------|--------------|
| `docs/web/decisions.md` | Making Web technical decisions | Making Web technical decisions |
| `docs/web/roadmap.md` | Every Web task | Completing a Web feature or changing the Web plan |
### Commands
Run from `web/`:
```bash
npm ci # install locked dependencies
npm run dev # start the development server
npm run check # lint, type-check, and build
```
## Git Version Control
- When using a worktree, create a new branch and place its worktree in `.worktree/`, naming safely (for example, `git worktree add -b feat/partly-upload .worktree/feat-partly-upload`).
- DON'T create a commit unless the user explicitly asks for one.
- Before any commit, verify the work with the required project checks. For code changes, run the checks for every affected project; for docs-only changes, run the most relevant non-mutating checks if available.
- Create a commit only when all required checks pass and the current implementation area has no unresolved issues.
- If a known failing check or unresolved issue belongs to another module and is outside the current task, report it clearly before asking for commit approval.
- Before running `git commit`, write the complete commit message first, show it to the user, and ask for explicit approval. DON'T commit until the user approves that exact message.
- Never include `Co-authored-by`, generated-tool signatures, or external attribution trailers unless the user explicitly asks for them.
### Commit Message Format
Use Conventional Commits:
```text
<type>[optional scope][optional !]: <description title>
<body>
```
- Choose the most accurate `type`: `fix`, `feat`, `build`, `docs`, `refactor`, or `test`.
- Add `!` after `type` or `scope` when the change contains a breaking API change.
- Keep the title to one concise sentence describing the main change.
- Write the body as bullet points grouped by change category. Each bullet starts with a typed prefix such as `feat:`, `fix:`, `test:`, `docs:`, `refactor:`, or `build:`.
- Use as few as possible bullets.
- Use one blank line between the title and body.
- DON'T paste full code sentences into the message body; summarize behavior and intent.
Example:
```text
feat(middleware): add AdminRequired authorization middleware
- feat: AdminRequired gates admin endpoints by checking user IsAdmin flag. Placed after AuthRequired; fetches user from repository, returns 403 for non-admins.
- fix: soft-deleted users are rejected with 401 since FindByID excludes them.
- test: admin passes, non-admin forbidden, soft-deleted admin rejected, missing user ID.
```
## Shared DO / DON'T
- DO write all code, comments, and documentation in English
- DON'T commit without following the Git Version Control rules above
## Debugging Principles ## Debugging Principles
When a test failure occurs, follow this strict order: When a test failure occurs, follow this strict order:
+2 -2
View File
@@ -39,10 +39,10 @@ MyGO is a WebDisk (cloud drive) server, written in Go.
## Frontend ## Frontend
The frontend uses React + TypeScript and communicates with the backend via HTTP API. Frontend source is maintained in a separate repository. The frontend uses React + TypeScript and communicates with the backend via HTTP API. Frontend source is maintained in `web/`.
--- ---
## Development ## Development
See `docs/development.md` for build and test workflows. See `AGENTS.md` for behavioral conventions. See `docs/README.md` for the server and Web documentation index. See `AGENTS.md` for behavioral conventions.
+8 -3
View File
@@ -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"
) )
@@ -23,15 +23,20 @@ var serveCmd = &cobra.Command{
Short: "Start the MyGO HTTP server", Short: "Start the MyGO HTTP server",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
v := config.New() v := config.New()
cfg, err := config.Load(v, serveConfigFile) cfg, loadInfo, err := config.Load(v, serveConfigFile)
if err != nil { if err != nil {
return fmt.Errorf("load config: %w", err) return fmt.Errorf("load config: %w", err)
} }
// 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 {
slog.Warn("jwt.secret is a development placeholder; using an ephemeral runtime secret. " +
"Set a stable jwt.secret or MYGO_JWT_SECRET for production, multi-instance deployments, " +
"and token persistence across restarts")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
+3 -1
View File
@@ -28,6 +28,8 @@ log:
file_level: debug # file: debug, info, warn, error file_level: debug # file: debug, info, warn, error
jwt: jwt:
secret: change-me-in-production # Development placeholder. MyGO replaces this with an ephemeral runtime
# secret at startup; set a stable value for production.
secret: dev-secret-do-not-use-in-production
access_ttl: 15m access_ttl: 15m
refresh_ttl: 168h refresh_ttl: 168h
+15 -4
View File
@@ -1,8 +1,19 @@
# Docs # Docs
Documentation is organized by project. Use `server/` for the Go backend and `web/` for the browser client.
## Server
| File | Content | | File | Content |
|------|---------| |------|---------|
| `architecture.md` | Module layout, package boundaries | | `server/architecture.md` | Module layout, package boundaries |
| `decisions.md` | Technical decisions (ADR) | | `server/decisions.md` | Technical decisions (ADR) |
| `roadmap.md` | Feature progress and status | | `server/roadmap.md` | Feature progress and status |
| `development.md` | Build, test, debug workflow | | `server/development.md` | Build, test, debug workflow |
## Web
| File | Content |
|------|---------|
| `web/decisions.md` | Technical decisions (ADR) |
| `web/roadmap.md` | Product boundary, foundation, planned structure, and deferred dependencies |
-60
View File
@@ -1,60 +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: changeme-in-production
access_ttl: 15m
refresh_ttl: 168h
```
Environment variables use `MYGO_` prefix with underscore separators: `MYGO_SERVER_PORT=8080`, `MYGO_JWT_SECRET=...`
@@ -14,8 +14,13 @@ Repository (GORM data access) Storage (file I/O)
Rules: Rules:
- Handler has no business logic — parse request, call service, write response. - Handler has no business logic — parse request, call service, write response.
- Handler and middleware never access repositories directly; they depend on services.
- Service has no HTTP awareness — operates on domain models and interfaces. - Service has no HTTP awareness — operates on domain models and interfaces.
- Repository abstracts the database; Storage abstracts where bytes live. - `internal/model` and `internal/service` do not import `net/http`, Gin, or `internal/api`.
- JSON response DTOs live in HTTP packages. Domain and service result structs are not the public REST schema.
- Domain models may use `json:"-"` for defensive redaction of sensitive fields; this is not a REST response contract.
- Domain errors carry a protocol-neutral kind, a safe message, and an optional cause. HTTP status mapping happens only at the API boundary.
- Repository abstracts the database; Storage abstracts where bytes live, including staged upload promotion.
- `internal/server` is the composition root — wires all dependencies together. - `internal/server` is the composition root — wires all dependencies together.
## Package Map ## Package Map
@@ -31,12 +36,12 @@ Rules:
| **HTTP** | `internal/server` | Gin router init, route registration (public/protected split), graceful shutdown | ✅ | | **HTTP** | `internal/server` | Gin router init, route registration (public/protected split), graceful shutdown | ✅ |
| | `internal/handler` | HTTP handlers (auth, file, admin, webdav...) | 🛠 WIP | | | `internal/handler` | HTTP handlers (auth, file, admin, webdav...) | 🛠 WIP |
| | `internal/middleware` | Gin middleware (logger, jwt, cors, auth) | 🛠 WIP | | | `internal/middleware` | Gin middleware (logger, jwt, cors, auth) | 🛠 WIP |
| **Business** | `internal/service` | Business logic: `AuthService` (register, login, refresh, logout, passkey CRUD) | ✅ | | **Business** | `internal/service` | Business logic: `AuthService`, `FileService`, `AdminService`; protocol-neutral results and errors | ✅ |
| | `internal/model` | Domain types (User, File, Credential, Session), error codes | ✅ | | | `internal/model` | Domain types (User, File, Credential, Session), protocol-neutral error kinds | ✅ |
| **Data** | `internal/repository` | Repository interfaces + GORM implementations (User, Session, File, Credential) | ✅ | | **Data** | `internal/repository` | Repository interfaces + GORM implementations (User, Session, File, Credential) | ✅ |
| | `internal/storage` | Storage backend interface + local disk impl | 🛠 WIP | | | `internal/storage` | Storage backend interface + local disk impl, with staging and promotion | 🛠 WIP |
| **Util** | `internal/auth` | JWT sign/verify (HS256), token type discrimination (access/refresh), password hashing (bcrypt), app passkey tokens | ✅ | | **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 | ✅ | | | `internal/api` | Unified JSON error response helpers and REST error-kind mapping | ✅ |
## API Routes (v0) ## API Routes (v0)
@@ -54,7 +59,7 @@ POST /api/v1/account/passkeys
DELETE /api/v1/account/passkeys/:id DELETE /api/v1/account/passkeys/:id
GET /api/v1/files GET /api/v1/files
POST /api/v1/files POST /api/v1/files # JSON creates directories; multipart uploads files. File upload parent_id is a query parameter.
GET /api/v1/files/:id GET /api/v1/files/:id
GET /api/v1/files/:id/content GET /api/v1/files/:id/content
PUT /api/v1/files/:id PUT /api/v1/files/:id
@@ -72,7 +77,9 @@ Applied globally by `gin.Default()`: logger → recovery
Planned globally: cors Planned globally: cors
Applied to protected groups: auth (JWT validation, inject user into gin.Context) Applied to protected groups: auth (extract bearer token, delegate access-token authentication to `AuthService`, inject principal into gin.Context)
Applied to admin groups: admin (check authenticated principal from context)
## Server Responsibilities ## Server Responsibilities
@@ -59,9 +59,50 @@
|----------|----------| |----------|----------|
| One handler per route group | `AuthHandler` owns `/auth/*` (public); `AccountHandler` owns `/account/*` (protected). A route group maps 1:1 to a handler type. | | 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. | | 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()`. | | `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**: **Consequences**:
- Handlers are independently extensible (caching, rate limiting scoped per handler). - 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. - 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. - New duration config fields require zero boilerplate — declare as `time.Duration` in the struct.
## 2026-07-05: Staged File Uploads
**Context**: Multipart uploads previously used `ParseMultipartForm`, which parses the request before service-level size checks and may spill oversized requests to temporary disk. The file service also wrote directly to the long-term storage path and then attempted compensating deletes on upload failure.
**Decisions**:
| Decision | Guidance |
|----------|----------|
| Stream multipart requests | Handlers use `Request.MultipartReader()` instead of `ParseMultipartForm`/`FormFile`, so uploads are streamed into the service. |
| Optional upload limits | `storage.max_upload_size = 0` means unlimited. Positive values enable both HTTP body limiting and service-level file content limiting. |
| Staging before promotion | Storage backends write upload bytes to a staging path first, then promote the object to the long-term data path only after validation succeeds. |
| Promote before DB create | The service promotes the object before creating the active file record, preventing visible DB rows from pointing at missing objects. If DB creation fails after promotion, the service best-effort deletes the promoted object. |
| Upload parent location | Multipart upload `parent_id` is passed as a query parameter, keeping the multipart body focused on the file stream. |
| Service-layer upload errors | HTTP transport errors are converted at the handler boundary; the file service only handles domain errors such as oversized uploads. |
**Consequences**:
- Interrupted, malformed, and oversized uploads leave only staging objects, which are safe to clean by path prefix and age.
- Local storage promotes with `os.Rename` and falls back to copy/delete on cross-device `EXDEV`; future S3 storage can implement promotion with copy/delete while keeping business visibility controlled by the DB row.
- A DB failure after promotion can still leave a long-term orphan object, but it is not visible through the file API and can be cleaned independently.
## 2026-07-05: Protocol-Neutral Service Boundary
**Context**: The service and model layers carried HTTP status codes and JSON response tags, and some middleware and handlers accessed repositories directly. That made the business layer harder to reuse for future WebDAV and Nextcloud-compatible APIs.
**Decisions**:
| Decision | Guidance |
|----------|----------|
| Protocol-neutral errors | `model.AppError` uses an error `Kind`, safe message, and optional cause. REST status codes are mapped in `internal/api` only. |
| API log references | REST error responses may include `error.log_id` for server-recorded errors: all 5xx responses and 4xx responses with internal causes. |
| Service boundaries | Handlers and middleware depend on services, not repositories. `AuthService` authenticates access tokens into a principal; `AdminService` owns admin user operations. |
| DTO ownership | Service and model structs are internal/domain data. HTTP handlers assemble response DTOs with JSON tags. |
| Architecture enforcement | Package-level tests reject HTTP imports in model/service and repository imports in handlers/middleware. Targeted model tests verify defensive JSON redaction for sensitive fields. |
**Consequences**:
- REST remains a handler/API concern, and future protocols can reuse services without HTTP leakage.
- Error responses keep the same top-level shape, with optional `log_id` instead of embedding log references in the message.
- Admin and auth middleware behavior is testable through service contracts rather than database access.
+81
View File
@@ -0,0 +1,81 @@
# 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 ./...
```
## Repository Review
Use the repository-scoped `$mygo-api-repo-review` skill for evidence-based architecture, design, security, resilience, data-consistency, performance, and implementation audits. Reviews are read-only unless fixes are requested separately.
Select one review mode:
- `diff` compares the current workspace, including relevant untracked files, with `HEAD`.
- `main` compares the current workspace with the merge base of `HEAD` and the local `main` branch without fetching remote refs.
- `full` reviews the current repository as a whole.
Optionally limit any mode to a target path, Go package, or logical component. For example:
```text
Use $mygo-api-repo-review in main mode with target internal/storage.
```
## 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.
+7 -6
View File
@@ -8,7 +8,7 @@
| JWT authentication | ✅ | access + refresh tokens, refresh token in DB, app passkey support | | 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` | | Web API foundation | ✅ | WebApp composition, Gin router, graceful shutdown, `GET /api/v1/version` |
| File upload/download/manage APIs | 🛠 WIP | REST API via Gin | | File upload/download/manage APIs | 🛠 WIP | REST API via Gin |
| Admin endpoints | 🛠 WIP | user CRUD for superusers | | Admin endpoints | 🛠 WIP | user service boundary in place for superusers |
| WebDAV | 🛠 WIP | future v0 or v1 | | WebDAV | 🛠 WIP | future v0 or v1 |
## Implementation Tasks ## Implementation Tasks
@@ -18,16 +18,17 @@ Package-level implementation order (each task includes unit tests):
1. `internal/config` — Viper loader, config struct ✅ 1. `internal/config` — Viper loader, config struct ✅
2. `internal/app` — runtime dependency container ✅ 2. `internal/app` — runtime dependency container ✅
3. `internal/model` — domain types, error codes ✅ 3. `internal/model` — domain types, error codes ✅
4. `internal/api`error response helpers 4. `internal/api`protocol-neutral error kind to REST response mapping
5. `internal/auth` — JWT utils ✅ 5. `internal/auth` — JWT utils ✅
6. `internal/storage` — backend interface + local fs 6. `internal/storage` — backend interface + local fs with staged upload promotion
7. `internal/repository` — interfaces + GORM/SQLite impl ✅ 7. `internal/repository` — interfaces + GORM/SQLite impl ✅
8. `internal/service` — auth, file, admin services ✅ (auth done) 8. `internal/service` — auth, file, admin services ✅
9. `internal/middleware` — logger, cors, auth ✅ (auth done) 9. `internal/middleware` — logger, cors, auth ✅ (auth done; principal boundary enforced)
10. `internal/handler` — auth, account, file, admin handlers 🛠 (auth + account done) 10. `internal/handler` — auth, account, file, admin handlers 🛠 (HTTP DTO mapping in place)
11. `internal/server` — Gin router, route registration, graceful shutdown ✅ 11. `internal/server` — Gin router, route registration, graceful shutdown ✅
12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done) 12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done)
13. Integration tests 13. Integration tests
14. Architecture boundary tests ✅
## Future ## Future
+19
View File
@@ -0,0 +1,19 @@
# Technical Decisions
## 2026-07-14: Client-rendered Web Foundation
**Context**: MyGO needs a browser client now and native clients later. The Web application must share the versioned REST API instead of introducing browser-only server logic.
**Decisions**:
| Area | Choice | Guidance |
|------|--------|----------|
| Rendering | Pure client-side rendered SPA | Vite emits static assets; do not introduce SSR, React Server Components, or a Node API server. |
| Application stack | React, strict TypeScript, React Router, and TanStack Query | Keep routing and remote-data state explicit and client-side. |
| UI system | Ant Design plus Tailwind CSS 4 | Ant Design owns reusable controls and theme tokens; Tailwind initially owns layout, spacing, and responsive utilities. |
| Dependency policy | Install capabilities when their feature starts | Keep API generation, transfer, virtualization, drag-and-drop, test, and preview libraries deferred in `docs/web/roadmap.md`. |
**Consequences**:
- The Web and future native clients consume the same client-neutral API contracts.
- MyGO or a reverse proxy may host `web/dist` with an SPA fallback without changing the rendering model.
- MyGO domain components own file-browser behavior and must not depend on Ant Design request behavior for business logic.
+49
View File
@@ -0,0 +1,49 @@
# Web Roadmap
## Product Boundary
- The Web client is a pure client-side rendered single-page application.
- Vite produces static assets only. The project does not use SSR, React Server Components, a Node API server, or a browser-specific business backend.
- The Web client consumes the same versioned REST API as future Android and other native clients.
- Production may serve `web/dist` from MyGO or a reverse proxy.
- Shared API contracts should remain client-neutral and eventually be described by OpenAPI.
## Foundation
The initial project contains only the framework and styling foundation:
- Node.js 24 and npm
- Vite
- React
- TypeScript in strict mode
- TanStack Query provider
- Tailwind CSS 4 through its Vite plugin
- Ant Design provider and components
- Oxlint from the Vite template
Ant Design owns reusable UI components and theme tokens. Tailwind CSS is initially limited to application layout, spacing, and responsive utilities.
## Planned Structure
```text
web/src/
├── app/ # Router, providers, and application composition
├── api/ # API client and generated contracts
├── components/ # Shared presentation components
├── features/ # Auth, files, account, and admin features
├── pages/ # Route entry points
├── lib/ # Framework-independent utilities
└── test/ # Shared test setup and fixtures
```
## Deferred Dependencies
Some dependencies are suggested for future implementation. Refer these only when the corresponding feature is implemented and propose better choices if any:
- `@ant-design/icons`: Add Ant Design-consistent application and action icons when real screens require them.
- `openapi-typescript`: Generate TypeScript API types from the shared OpenAPI document.
- `openapi-fetch`: Provide a small type-safe Fetch client based on generated OpenAPI types.
- `openapi-react-query`: Connect generated OpenAPI operations to TanStack Query if handwritten query adapters become repetitive.
- `zustand`: Manage a cross-route upload queue, bulk selection, or other complex client-only state if React state is insufficient.
- `vitest`: Run unit and integration tests using the Vite toolchain.
- `pdfjs-dist`: Preview PDF files in the browser when document preview is implemented.
+70 -16
View File
@@ -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 {
+65
View File
@@ -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
} }
+5
View File
@@ -23,6 +23,7 @@ type WebApp struct {
FileRepo repository.FileRepository FileRepo repository.FileRepository
CredentialRepo repository.CredentialRepository CredentialRepo repository.CredentialRepository
AuthService *service.AuthService AuthService *service.AuthService
AdminService *service.AdminService
FileService *service.FileService FileService *service.FileService
Storage storage.StorageBackend Storage storage.StorageBackend
} }
@@ -58,6 +59,7 @@ 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,
@@ -68,6 +70,7 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) {
FileRepo: fileRepo, FileRepo: fileRepo,
CredentialRepo: credentialRepo, CredentialRepo: credentialRepo,
AuthService: authService, AuthService: authService,
AdminService: adminService,
FileService: fileService, FileService: fileService,
Storage: store, Storage: store,
}, nil }, nil
@@ -80,6 +83,7 @@ func NewWebApp(cfg *config.Config, db *gorm.DB,
fileRepo repository.FileRepository, fileRepo repository.FileRepository,
credentialRepo repository.CredentialRepository, 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 {
@@ -92,6 +96,7 @@ func NewWebApp(cfg *config.Config, db *gorm.DB,
FileRepo: fileRepo, FileRepo: fileRepo,
CredentialRepo: credentialRepo, CredentialRepo: credentialRepo,
AuthService: authService, AuthService: authService,
AdminService: adminService,
FileService: fileService, FileService: fileService,
Storage: store, Storage: store,
} }
+2 -2
View File
@@ -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, 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, 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)
} }
+68
View File
@@ -0,0 +1,68 @@
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 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
}
+55 -6
View File
@@ -1,6 +1,8 @@
package config package config
import ( import (
"crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -8,6 +10,17 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
// DefaultJWTSecret is a development-only placeholder. Load replaces it with
// an ephemeral secret before returning a usable Config.
const DefaultJWTSecret = "dev-secret-do-not-use-in-production"
// LoadInfo describes runtime decisions made while loading configuration.
type LoadInfo struct {
// EphemeralJWTSecret is true when a placeholder jwt.secret was replaced
// with a random in-memory value for the current process.
EphemeralJWTSecret bool
}
func defaults(v *viper.Viper) { func defaults(v *viper.Viper) {
v.SetDefault("server.host", "0.0.0.0") v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 10086) v.SetDefault("server.port", 10086)
@@ -24,7 +37,7 @@ func defaults(v *viper.Viper) {
v.SetDefault("storage.driver", "local") v.SetDefault("storage.driver", "local")
v.SetDefault("storage.local.path", "data/files") v.SetDefault("storage.local.path", "data/files")
v.SetDefault("jwt.secret", "dev-secret-do-not-use-in-production") v.SetDefault("jwt.secret", DefaultJWTSecret)
v.SetDefault("jwt.access_ttl", "15m") v.SetDefault("jwt.access_ttl", "15m")
v.SetDefault("jwt.refresh_ttl", "168h") v.SetDefault("jwt.refresh_ttl", "168h")
@@ -42,7 +55,7 @@ func New() *viper.Viper {
return v return v
} }
func Load(v *viper.Viper, cfgFile string) (*Config, error) { func Load(v *viper.Viper, cfgFile string) (*Config, LoadInfo, error) {
if cfgFile != "" { if cfgFile != "" {
v.SetConfigFile(cfgFile) v.SetConfigFile(cfgFile)
} else { } else {
@@ -54,18 +67,54 @@ func Load(v *viper.Viper, cfgFile string) (*Config, error) {
if err := v.ReadInConfig(); err != nil { if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) { if !errors.As(err, &notFound) {
return nil, fmt.Errorf("read config: %w", err) return nil, LoadInfo{}, fmt.Errorf("read config: %w", err)
} }
} }
var cfg Config var cfg Config
if err := v.Unmarshal(&cfg); err != nil { if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err) return nil, LoadInfo{}, fmt.Errorf("unmarshal config: %w", err)
}
info, err := hardenRuntimeSecrets(&cfg)
if err != nil {
return nil, LoadInfo{}, err
} }
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate config: %w", err) return nil, LoadInfo{}, fmt.Errorf("validate config: %w", err)
} }
return &cfg, nil return &cfg, info, nil
}
func hardenRuntimeSecrets(cfg *Config) (LoadInfo, error) {
if !isWeakJWTSecret(cfg.JWT.Secret) {
return LoadInfo{}, nil
}
secret, err := generateEphemeralJWTSecret()
if err != nil {
return LoadInfo{}, fmt.Errorf("generate ephemeral jwt secret: %w", err)
}
cfg.JWT.Secret = secret
return LoadInfo{EphemeralJWTSecret: true}, nil
}
func isWeakJWTSecret(secret string) bool {
switch secret {
case DefaultJWTSecret, "change-me-in-production", "changeme-in-production":
return true
default:
return false
}
}
func generateEphemeralJWTSecret() (string, error) {
var secret [32]byte
if _, err := rand.Read(secret[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(secret[:]), nil
} }
+59 -7
View File
@@ -9,7 +9,7 @@ import (
func TestDefaults(t *testing.T) { func TestDefaults(t *testing.T) {
v := New() v := New()
cfg, err := Load(v, "") cfg, info, err := Load(v, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -36,6 +36,16 @@ func TestDefaults(t *testing.T) {
} }
}) })
} }
if !info.EphemeralJWTSecret {
t.Fatal("expected default jwt.secret to be replaced with an ephemeral secret")
}
if cfg.JWT.Secret == DefaultJWTSecret {
t.Fatal("default jwt.secret was not replaced")
}
if cfg.JWT.Secret == "" {
t.Fatal("ephemeral jwt.secret is empty")
}
} }
func TestFromYAML(t *testing.T) { func TestFromYAML(t *testing.T) {
@@ -67,10 +77,13 @@ jwt:
} }
v := New() v := New()
cfg, err := Load(v, path) cfg, info, err := Load(v, path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if info.EphemeralJWTSecret {
t.Fatal("custom jwt.secret should not be replaced")
}
if cfg.Server.Host != "127.0.0.1" { if cfg.Server.Host != "127.0.0.1" {
t.Errorf("server.host = %q, want %q", cfg.Server.Host, "127.0.0.1") t.Errorf("server.host = %q, want %q", cfg.Server.Host, "127.0.0.1")
@@ -102,10 +115,13 @@ func TestEnvOverride(t *testing.T) {
t.Setenv("MYGO_DATABASE_SQLITE_PATH", "/env/path/db.sqlite") t.Setenv("MYGO_DATABASE_SQLITE_PATH", "/env/path/db.sqlite")
v := New() v := New()
cfg, err := Load(v, "") cfg, info, err := Load(v, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if info.EphemeralJWTSecret {
t.Fatal("env jwt.secret should not be replaced")
}
if cfg.Server.Port != 8080 { if cfg.Server.Port != 8080 {
t.Errorf("server.port = %d, want %d", cfg.Server.Port, 8080) t.Errorf("server.port = %d, want %d", cfg.Server.Port, 8080)
@@ -137,7 +153,7 @@ server:
t.Setenv("MYGO_SERVER_PORT", "9999") t.Setenv("MYGO_SERVER_PORT", "9999")
v := New() v := New()
cfg, err := Load(v, path) cfg, _, err := Load(v, path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -164,7 +180,7 @@ server:
} }
v := New() v := New()
_, err := Load(v, path) _, _, err := Load(v, path)
if err == nil { if err == nil {
t.Fatal("expected error for port 0, got nil") t.Fatal("expected error for port 0, got nil")
} }
@@ -183,7 +199,7 @@ jwt:
} }
v := New() v := New()
_, err := Load(v, path) _, _, err := Load(v, path)
if err == nil { if err == nil {
t.Fatal("expected error for empty jwt.secret, got nil") t.Fatal("expected error for empty jwt.secret, got nil")
} }
@@ -191,12 +207,48 @@ jwt:
func TestExplicitConfigFileNotFound(t *testing.T) { func TestExplicitConfigFileNotFound(t *testing.T) {
v := New() v := New()
_, err := Load(v, "/nonexistent/path/config.yaml") _, _, err := Load(v, "/nonexistent/path/config.yaml")
if err == nil { if err == nil {
t.Fatal("expected error when explicitly specifying a nonexistent config file") t.Fatal("expected error when explicitly specifying a nonexistent config file")
} }
} }
func TestWeakJWTSecretsAreReplaced(t *testing.T) {
tests := []string{
DefaultJWTSecret,
"change-me-in-production",
"changeme-in-production",
}
for _, secret := range tests {
t.Run(secret, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
yaml := "jwt:\n secret: " + secret + "\n"
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
v := New()
cfg, info, err := Load(v, path)
if err != nil {
t.Fatal(err)
}
if !info.EphemeralJWTSecret {
t.Fatal("expected weak jwt.secret to be replaced")
}
if cfg.JWT.Secret == secret {
t.Fatal("weak jwt.secret was not replaced")
}
if cfg.JWT.Secret == "" {
t.Fatal("ephemeral jwt.secret is empty")
}
})
}
}
func TestJWTConfigAccessDuration(t *testing.T) { func TestJWTConfigAccessDuration(t *testing.T) {
j := JWTConfig{AccessTTL: 15 * time.Minute} j := JWTConfig{AccessTTL: 15 * time.Minute}
if j.AccessTTL != 15*time.Minute { if j.AccessTTL != 15*time.Minute {
+42 -3
View File
@@ -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
}
+4 -6
View File
@@ -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))
+111
View File
@@ -0,0 +1,111 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/dhao2001/mygo/internal/api"
"github.com/dhao2001/mygo/internal/model"
"github.com/dhao2001/mygo/internal/service"
)
// AdminHandler handles admin-only endpoints for user management.
type AdminHandler struct {
adminService *service.AdminService
}
// NewAdminHandler creates an AdminHandler.
func NewAdminHandler(adminService *service.AdminService) *AdminHandler {
return &AdminHandler{adminService: adminService}
}
// adminUserResponse exposes all user fields, including status, for admin views.
type adminUserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// adminUserListResponse wraps a paginated list of admin user views.
type adminUserListResponse struct {
Users []adminUserResponse `json:"users"`
Total int64 `json:"total"`
}
func toAdminResponse(u *model.User) adminUserResponse {
return adminUserResponse{
ID: u.ID,
Username: u.Username,
Email: u.Email,
IsAdmin: u.IsAdmin,
Status: u.Status,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
// DeleteUser handles DELETE /api/v1/admin/users/:id — soft-deletes a user.
func (h *AdminHandler) DeleteUser(c *gin.Context) {
id := c.Param("id")
if err := h.adminService.DeleteUser(c.Request.Context(), id); err != nil {
api.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// GetUser handles GET /api/v1/admin/users/:id — returns a user including deleted.
func (h *AdminHandler) GetUser(c *gin.Context) {
id := c.Param("id")
user, err := h.adminService.GetUser(c.Request.Context(), id)
if err != nil {
api.RespondError(c, err)
return
}
c.JSON(http.StatusOK, toAdminResponse(user))
}
// ListUsers handles GET /api/v1/admin/users — returns a paginated list of all users.
func (h *AdminHandler) ListUsers(c *gin.Context) {
offset, err := strconv.Atoi(c.DefaultQuery("offset", "0"))
if err != nil || offset < 0 {
api.Error(c, http.StatusBadRequest, "invalid offset")
return
}
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
if err != nil || limit < 1 {
api.Error(c, http.StatusBadRequest, "invalid limit")
return
}
if limit > 200 {
limit = 200
}
users, total, err := h.adminService.ListUsers(c.Request.Context(), offset, limit)
if err != nil {
api.RespondError(c, err)
return
}
items := make([]adminUserResponse, 0, len(users))
for i := range users {
items = append(items, toAdminResponse(&users[i]))
}
c.JSON(http.StatusOK, adminUserListResponse{
Users: items,
Total: total,
})
}
+331
View File
@@ -0,0 +1,331 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/dhao2001/mygo/internal/middleware"
"github.com/dhao2001/mygo/internal/model"
"github.com/dhao2001/mygo/internal/repository"
"github.com/dhao2001/mygo/internal/service"
)
func setupAdminRouter(t *testing.T) (*gin.Engine, 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{}, &model.Session{}, &model.Credential{}); err != nil {
t.Fatalf("migrate: %v", err)
}
secret := []byte("test-secret")
userRepo := repository.NewUserRepository(db)
authService := service.NewAuthService(
userRepo,
repository.NewSessionRepository(db),
repository.NewCredentialRepository(db),
secret,
15*time.Minute,
7*24*time.Hour,
)
authHandler := NewAuthHandler(authService)
adminService := service.NewAdminService(userRepo)
adminHandler := NewAdminHandler(adminService)
gin.SetMode(gin.TestMode)
r := gin.New()
auth := r.Group("/api/v1/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
admin := r.Group("/api/v1/admin")
admin.Use(middleware.AuthRequired(authService))
admin.Use(middleware.AdminRequired())
{
admin.GET("/users", adminHandler.ListUsers)
admin.GET("/users/:id", adminHandler.GetUser)
admin.DELETE("/users/:id", adminHandler.DeleteUser)
}
return r, userRepo
}
// registerHelper creates a user via the HTTP endpoint and returns the user ID.
func registerHelper(t *testing.T, r *gin.Engine, username, email, password string) string {
t.Helper()
body, _ := json.Marshal(gin.H{
"username": username,
"email": email,
"password": password,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("register %s: status = %d, body = %s", username, rec.Code, rec.Body.String())
}
var user model.User
if err := json.Unmarshal(rec.Body.Bytes(), &user); err != nil {
t.Fatalf("unmarshal register response: %v", err)
}
return user.ID
}
// loginHelper logs in a user and returns the access token.
func loginHelper(t *testing.T, r *gin.Engine, email, password string) string {
t.Helper()
body, _ := json.Marshal(gin.H{"email": email, "password": password})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %s: status = %d, body = %s", email, rec.Code, rec.Body.String())
}
var pair testTokenPairResponse
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
t.Fatalf("unmarshal login response: %v", err)
}
return pair.AccessToken
}
// makeAdminHelper promotes a user to admin in the database.
func makeAdminHelper(t *testing.T, userRepo repository.UserRepository, userID string) {
t.Helper()
user, err := userRepo.FindByID(context.Background(), userID)
if err != nil {
t.Fatalf("find user %s: %v", userID, err)
}
user.IsAdmin = true
if err := userRepo.Update(context.Background(), user); err != nil {
t.Fatalf("update user: %v", err)
}
}
func TestAdminDeleteUser(t *testing.T) {
r, userRepo := setupAdminRouter(t)
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
makeAdminHelper(t, userRepo, adminID)
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
adminToken := loginHelper(t, r, "admin@example.com", "adminpass123")
// Delete regular user as admin
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/"+userID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("delete: status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String())
}
// Verify regular user cannot login (soft-deleted)
loginBody, _ := json.Marshal(gin.H{"email": "bob@example.com", "password": "bobpass123"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("login after soft-delete: status = %d, want %d; body = %s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
}
func TestAdminDeleteUserForbidden(t *testing.T) {
r, userRepo := setupAdminRouter(t)
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
makeAdminHelper(t, userRepo, adminID)
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
// Login as regular user (non-admin)
userToken := loginHelper(t, r, "bob@example.com", "bobpass123")
// Attempt to delete as non-admin
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/"+userID, nil)
req.Header.Set("Authorization", "Bearer "+userToken)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusForbidden, rec.Body.String())
}
}
func TestAdminDeleteUserUnauthorized(t *testing.T) {
r, _ := setupAdminRouter(t)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/some-id", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
}
func TestAdminGetUser(t *testing.T) {
r, userRepo := setupAdminRouter(t)
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
makeAdminHelper(t, userRepo, adminID)
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
adminToken := loginHelper(t, r, "admin@example.com", "adminpass123")
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/"+userID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get user: status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
Status string `json:"status"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Username != "bob" {
t.Errorf("username = %q, want %q", resp.Username, "bob")
}
if resp.Status != model.StatusActive {
t.Errorf("status = %q, want %q", resp.Status, model.StatusActive)
}
}
func TestAdminGetDeletedUser(t *testing.T) {
r, userRepo := setupAdminRouter(t)
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
makeAdminHelper(t, userRepo, adminID)
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
// Soft-delete regular user directly via repo
if err := userRepo.Delete(context.Background(), userID); err != nil {
t.Fatalf("soft-delete user: %v", err)
}
adminToken := loginHelper(t, r, "admin@example.com", "adminpass123")
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/"+userID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get deleted user: status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Status string `json:"status"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Status != model.StatusAdminDeleted {
t.Errorf("status = %q, want %q", resp.Status, model.StatusAdminDeleted)
}
}
func TestAdminListIncludesDeleted(t *testing.T) {
r, userRepo := setupAdminRouter(t)
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
makeAdminHelper(t, userRepo, adminID)
// Create 2 regular users
_ = registerHelper(t, r, "alice", "alice@example.com", "alicepass123")
bobID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
// Soft-delete bob
if err := userRepo.Delete(context.Background(), bobID); err != nil {
t.Fatalf("soft-delete user: %v", err)
}
adminToken := loginHelper(t, r, "admin@example.com", "adminpass123")
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("list users: status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Users []struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"users"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// Total should be 3: admin + alice (active) + bob (soft-deleted)
if resp.Total != 3 {
t.Errorf("total = %d, want %d", resp.Total, 3)
}
// Verify both active and soft-deleted users appear
foundAlice := false
foundBob := false
for _, u := range resp.Users {
switch u.Status {
case model.StatusActive:
// alice or admin
foundAlice = true
case model.StatusAdminDeleted:
if u.ID == bobID {
foundBob = true
}
}
}
if !foundAlice {
t.Error("active user not found in list")
}
if !foundBob {
t.Error("soft-deleted user not found in list")
}
}
+37 -3
View File
@@ -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,
}
}
+7 -2
View File
@@ -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
View File
@@ -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 return
} }
defer file.Close() api.Error(c, http.StatusBadRequest, uploadPartErrorMessage(err))
return
}
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,
}
}
+206 -17
View File
@@ -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"
@@ -23,13 +25,14 @@ import (
// 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 +41,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 +75,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,15 +108,24 @@ 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()
@@ -102,7 +149,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) {
@@ -124,7 +171,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 +180,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("X-User-ID", "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("X-User-ID", "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("X-User-ID", "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)
@@ -151,6 +258,52 @@ 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("X-User-ID", "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("X-User-ID", "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)
@@ -165,7 +318,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)
} }
@@ -200,7 +353,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)
} }
@@ -225,7 +378,7 @@ func TestFileHandler_Get(t *testing.T) {
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.
@@ -238,7 +391,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)
} }
@@ -277,7 +430,7 @@ func TestFileHandler_Download(t *testing.T) {
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.
@@ -297,6 +450,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("X-User-ID", "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("X-User-ID", "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)
@@ -313,7 +502,7 @@ func TestFileHandler_Update(t *testing.T) {
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.
@@ -328,7 +517,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)
} }
@@ -353,7 +542,7 @@ func TestFileHandler_UpdateNoFields(t *testing.T) {
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{})
@@ -384,7 +573,7 @@ func TestFileHandler_Delete(t *testing.T) {
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.
@@ -424,7 +613,7 @@ func TestFileHandler_ForbiddenAccess(t *testing.T) {
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.
@@ -1,4 +1,4 @@
package log package logging
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package log package logging
import ( import (
"context" "context"
+57 -14
View File
@@ -1,20 +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"
) )
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 == "" {
@@ -30,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()
} }
} }
@@ -64,7 +67,47 @@ 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
} }
// GetPrincipal extracts the authenticated principal injected by AuthRequired.
func GetPrincipal(c *gin.Context) (service.Principal, bool) {
v, ok := c.Get(principalKey)
if !ok || v == nil {
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) {
principal, ok := GetPrincipal(c)
if !ok {
api.Error(c, http.StatusUnauthorized, "missing user context")
c.Abort()
return
}
if !principal.IsAdmin {
api.Error(c, http.StatusForbidden, "admin access required")
c.Abort()
return
}
c.Next()
}
}
+130 -94
View File
@@ -1,29 +1,53 @@
package middleware package middleware
import ( import (
"context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/dhao2001/mygo/internal/auth" "github.com/dhao2001/mygo/internal/model"
"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()
@@ -32,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")
@@ -45,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")
@@ -58,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())
} }
if !strings.Contains(rec.Body.String(), "true") {
func TestAuthRequiredRefreshTokenRejected(t *testing.T) { t.Errorf("response body %q does not contain admin flag", rec.Body.String())
secret := []byte("test-secret")
token, err := auth.GenerateRefreshToken("user-1", secret, 7*24*time.Hour)
if err != nil {
t.Fatalf("GenerateRefreshToken = %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.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)
} }
} }
@@ -164,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)
@@ -178,17 +156,75 @@ func TestMustGetUserIDPanics(t *testing.T) {
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)
} }
func TestAuthRequiredWrongSecret(t *testing.T) { type errorBody struct {
secret := []byte("test-secret") Error struct {
token, err := auth.GenerateAccessToken("user-1", secret, 15*time.Minute) Message string `json:"message"`
if err != nil { } `json:"error"`
t.Fatalf("GenerateAccessToken = %v", err)
} }
// Use a different secret for the middleware func injectPrincipalMiddleware(principal *service.Principal) gin.HandlerFunc {
r := setupTestRouter([]byte("different-secret")) return func(c *gin.Context) {
req := httptest.NewRequest(http.MethodGet, "/protected", nil) if principal != nil {
req.Header.Set("Authorization", "Bearer "+token) c.Set(principalKey, *principal)
c.Set(userIDKey, principal.UserID)
}
c.Next()
}
}
func TestAdminRequired_AdminPasses(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(injectPrincipalMiddleware(&service.Principal{UserID: "admin-1", IsAdmin: true}))
r.Use(AdminRequired())
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.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestAdminRequired_NonAdminForbidden(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(injectPrincipalMiddleware(&service.Principal{UserID: "user-1", IsAdmin: false}))
r.Use(AdminRequired())
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.StatusForbidden {
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if body.Error.Message != "admin access required" {
t.Errorf("message = %q, want %q", body.Error.Message, "admin access required")
}
}
func TestAdminRequired_NoPrincipal(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(AdminRequired())
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() rec := httptest.NewRecorder()
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)
+2 -2
View File
@@ -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)
+6 -6
View File
@@ -8,11 +8,11 @@ import (
// 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
} }
+62 -9
View File
@@ -3,7 +3,6 @@ package model
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/http"
) )
var ( var (
@@ -11,25 +10,79 @@ var (
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")
) )
// 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),
} }
+79
View File
@@ -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)
}
})
}
}
+14 -10
View File
@@ -4,17 +4,21 @@ import (
"time" "time"
) )
// StatusUserDeleted marks a file that has been deleted by the user (soft delete).
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:"-"`
IsDir bool `gorm:"default:false" json:"is_dir"` Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
CreatedAt time.Time `json:"created_at"` IsDir bool `gorm:"default:false"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time
UpdatedAt time.Time
} }
+49
View File
@@ -0,0 +1,49 @@
package model
import (
"encoding/json"
"testing"
"time"
)
func TestFileJSONOmitsInternalFields(t *testing.T) {
f := &File{
ID: "1",
UserID: "u1",
ParentID: nil,
Name: "test.txt",
Size: 100,
MimeType: "text/plain",
StoragePath: "users/u1/files/1",
Hash: "abc123",
Status: StatusActive,
IsDir: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
data, err := json.Marshal(f)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
assertJSONKeysAbsent(t, result,
"StoragePath", "storage_path",
"Hash", "hash",
"Status", "status",
)
}
func TestStatusConstants(t *testing.T) {
if StatusActive != "active" {
t.Errorf("StatusActive = %q, want \"active\"", StatusActive)
}
if StatusUserDeleted != "user_deleted" {
t.Errorf("StatusUserDeleted = %q, want \"user_deleted\"", StatusUserDeleted)
}
}
+4 -4
View File
@@ -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
} }
+13 -6
View File
@@ -6,11 +6,18 @@ 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"`
CreatedAt time.Time `json:"created_at"` Status string `gorm:"type:varchar(32);not null;default:active;index"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time
UpdatedAt time.Time
} }
// User status constants.
const (
StatusActive = "active"
StatusAdminDeleted = "admin_deleted"
)
+80
View File
@@ -0,0 +1,80 @@
package model
import (
"encoding/json"
"testing"
)
func TestUserJSONOmitsPasswordHash(t *testing.T) {
u := User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
PasswordHash: "hash-secret",
Status: StatusActive,
}
raw, err := json.Marshal(u)
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, "PasswordHash", "password_hash")
}
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)
}
}
}
+8 -8
View File
@@ -43,7 +43,7 @@ func (r *fileRepository) Create(ctx context.Context, file *model.File) error {
func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) { func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) {
var file model.File var file model.File
result := r.db.WithContext(ctx).First(&file, "id = ?", id) result := r.db.WithContext(ctx).First(&file, "id = ? AND status = ?", id, model.StatusActive)
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
@@ -57,11 +57,11 @@ func (r *fileRepository) FindByUserID(ctx context.Context, userID string, offset
var files []model.File var files []model.File
var total int64 var total int64
if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ?", userID).Count(&total).Error; err != nil { if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND status = ?", userID, model.StatusActive).Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
result := r.db.WithContext(ctx).Where("user_id = ?", userID).Offset(offset).Limit(limit).Find(&files) result := r.db.WithContext(ctx).Where("user_id = ? AND status = ?", userID, model.StatusActive).Offset(offset).Limit(limit).Find(&files)
if result.Error != nil { if result.Error != nil {
return nil, 0, result.Error return nil, 0, result.Error
} }
@@ -71,7 +71,7 @@ func (r *fileRepository) FindByUserID(ctx context.Context, userID string, offset
func (r *fileRepository) FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error) { func (r *fileRepository) FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error) {
var files []model.File var files []model.File
result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ?", userID, parentID).Find(&files) result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Find(&files)
if result.Error != nil { if result.Error != nil {
return nil, result.Error return nil, result.Error
} }
@@ -82,11 +82,11 @@ func (r *fileRepository) FindByParentIDPaginated(ctx context.Context, userID str
var files []model.File var files []model.File
var total int64 var total int64
if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND parent_id IS ?", userID, parentID).Count(&total).Error; err != nil { if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ?", userID, parentID).Offset(offset).Limit(limit).Order("is_dir DESC, name ASC").Find(&files) result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Offset(offset).Limit(limit).Order("is_dir DESC, name ASC").Find(&files)
if result.Error != nil { if result.Error != nil {
return nil, 0, result.Error return nil, 0, result.Error
} }
@@ -96,7 +96,7 @@ func (r *fileRepository) FindByParentIDPaginated(ctx context.Context, userID str
func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error) { func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error) {
var file model.File var file model.File
result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND name = ?", userID, parentID, name).First(&file) result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ?", userID, parentID, name, model.StatusActive).First(&file)
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
@@ -115,7 +115,7 @@ func (r *fileRepository) Update(ctx context.Context, file *model.File) error {
} }
func (r *fileRepository) Delete(ctx context.Context, id string) error { func (r *fileRepository) Delete(ctx context.Context, id string) error {
result := r.db.WithContext(ctx).Delete(&model.File{}, "id = ?", id) result := r.db.WithContext(ctx).Model(&model.File{}).Where("id = ?", id).Update("status", model.StatusUserDeleted)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
+130 -10
View File
@@ -33,6 +33,7 @@ func TestFileRepository_Create(t *testing.T) {
UserID: "user-1", UserID: "user-1",
Name: "test.txt", Name: "test.txt",
Size: 1024, Size: 1024,
Status: model.StatusActive,
} }
if err := repo.Create(ctx, file); err != nil { if err := repo.Create(ctx, file); err != nil {
@@ -48,6 +49,7 @@ func TestFileRepository_FindByID(t *testing.T) {
ID: "file-1", ID: "file-1",
UserID: "user-1", UserID: "user-1",
Name: "test.txt", Name: "test.txt",
Status: model.StatusActive,
} }
if err := repo.Create(ctx, file); err != nil { if err := repo.Create(ctx, file); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
@@ -77,9 +79,9 @@ func TestFileRepository_FindByUserID(t *testing.T) {
ctx := context.Background() ctx := context.Background()
files := []*model.File{ files := []*model.File{
{ID: "f-1", UserID: "user-1", Name: "a.txt"}, {ID: "f-1", UserID: "user-1", Name: "a.txt", Status: model.StatusActive},
{ID: "f-2", UserID: "user-1", Name: "b.txt"}, {ID: "f-2", UserID: "user-1", Name: "b.txt", Status: model.StatusActive},
{ID: "f-3", UserID: "user-2", Name: "c.txt"}, {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 := repo.Create(ctx, f); err != nil {
@@ -105,9 +107,9 @@ func TestFileRepository_FindByParentID(t *testing.T) {
parentID := "dir-1" parentID := "dir-1"
files := []*model.File{ files := []*model.File{
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt"}, {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"}, {ID: "f-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt", Status: model.StatusActive},
{ID: "f-3", UserID: "user-1", Name: "c.txt"}, {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 := repo.Create(ctx, f); err != nil {
@@ -130,8 +132,8 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
parentID := "dir-1" parentID := "dir-1"
files := []*model.File{ files := []*model.File{
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt"}, {ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
{ID: "f-2", UserID: "user-1", Name: "root.txt"}, {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 := repo.Create(ctx, f); err != nil {
@@ -152,7 +154,7 @@ 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"} file := &model.File{ID: "file-1", UserID: "user-1", Name: "original.txt", Status: model.StatusActive}
if err := repo.Create(ctx, file); err != nil { if err := repo.Create(ctx, file); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -179,7 +181,7 @@ func TestFileRepository_Delete(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: "test.txt"} 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 := repo.Create(ctx, file); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -193,3 +195,121 @@ func TestFileRepository_Delete(t *testing.T) {
t.Fatalf("expected ErrNotFound after delete, got %v", err) t.Fatalf("expected ErrNotFound after delete, got %v", err)
} }
} }
func TestFileRepository_SoftDelete(t *testing.T) {
repo := setupFileRepo(t)
ctx := context.Background()
file := &model.File{
ID: "file-1",
UserID: "user-1",
Name: "test.txt",
Status: model.StatusActive,
}
if err := repo.Create(ctx, file); err != nil {
t.Fatalf("Create = %v", err)
}
if err := repo.Delete(ctx, "file-1"); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err := repo.FindByID(ctx, "file-1")
if err != model.ErrNotFound {
t.Fatalf("expected ErrNotFound after soft-delete, got %v", err)
}
}
func TestFileRepository_StatusFilter(t *testing.T) {
repo := setupFileRepo(t)
ctx := context.Background()
activeFile := &model.File{
ID: "f-1",
UserID: "user-1",
Name: "active.txt",
Status: model.StatusActive,
}
deletedFile := &model.File{
ID: "f-2",
UserID: "user-1",
Name: "deleted.txt",
Status: model.StatusUserDeleted,
}
if err := repo.Create(ctx, activeFile); err != nil {
t.Fatalf("Create active = %v", err)
}
if err := repo.Create(ctx, deletedFile); err != nil {
t.Fatalf("Create deleted = %v", err)
}
result, total, err := repo.FindByUserID(ctx, "user-1", 0, 10)
if err != nil {
t.Fatalf("FindByUserID = %v", err)
}
if len(result) != 1 {
t.Errorf("len(result) = %d, want 1", len(result))
}
if total != 1 {
t.Errorf("total = %d, want 1", total)
}
if result[0].ID != "f-1" {
t.Errorf("result[0].ID = %q, want %q", result[0].ID, "f-1")
}
}
func TestFileRepository_DeleteIdempotent(t *testing.T) {
repo := setupFileRepo(t)
ctx := context.Background()
file := &model.File{
ID: "file-1",
UserID: "user-1",
Name: "test.txt",
Status: model.StatusActive,
}
if err := repo.Create(ctx, file); err != nil {
t.Fatalf("Create = %v", err)
}
if err := repo.Delete(ctx, "file-1"); err != nil {
t.Fatalf("first Delete = %v", err)
}
if err := repo.Delete(ctx, "file-1"); err != nil {
t.Fatalf("second Delete = %v", err)
}
}
func TestFileRepository_StatusFilterCount(t *testing.T) {
repo := setupFileRepo(t)
ctx := context.Background()
activeFile := &model.File{
ID: "f-1",
UserID: "user-1",
Name: "active.txt",
Status: model.StatusActive,
}
deletedFile := &model.File{
ID: "f-2",
UserID: "user-1",
Name: "deleted.txt",
Status: model.StatusUserDeleted,
}
if err := repo.Create(ctx, activeFile); err != nil {
t.Fatalf("Create active = %v", err)
}
if err := repo.Create(ctx, deletedFile); err != nil {
t.Fatalf("Create deleted = %v", err)
}
_, total, err := repo.FindByUserID(ctx, "user-1", 0, 10)
if err != nil {
t.Fatalf("FindByUserID = %v", err)
}
if total != 1 {
t.Errorf("total = %d, want 1 (soft-deleted excluded from count)", total)
}
}
+35 -3
View File
@@ -19,11 +19,13 @@ func isDuplicateKeyError(err error) bool {
type UserRepository interface { type UserRepository interface {
Create(ctx context.Context, user *model.User) error Create(ctx context.Context, user *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) FindByUsername(ctx context.Context, username string) (*model.User, error)
Update(ctx context.Context, user *model.User) error Update(ctx context.Context, user *model.User) error
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
List(ctx context.Context, offset, limit int) ([]model.User, int64, error) List(ctx context.Context, offset, limit int) ([]model.User, int64, error)
ListIncludeDeleted(ctx context.Context, offset, limit int) ([]model.User, int64, error)
} }
type userRepository struct { type userRepository struct {
@@ -47,6 +49,19 @@ func (r *userRepository) Create(ctx context.Context, user *model.User) error {
} }
func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User, error) { func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User, error) {
var user model.User
result := r.db.WithContext(ctx).First(&user, "id = ? AND status = ?", id, model.StatusActive)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound
}
if result.Error != nil {
return nil, result.Error
}
return &user, nil
}
// FindByIDIncludeDeleted finds a user by ID regardless of status.
func (r *userRepository) FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error) {
var user model.User var user model.User
result := r.db.WithContext(ctx).First(&user, "id = ?", id) result := r.db.WithContext(ctx).First(&user, "id = ?", id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
@@ -60,7 +75,7 @@ func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User,
func (r *userRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) { func (r *userRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
var user model.User var user model.User
result := r.db.WithContext(ctx).First(&user, "email = ?", email) result := r.db.WithContext(ctx).First(&user, "email = ? AND status = ?", email, model.StatusActive)
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
@@ -72,7 +87,7 @@ func (r *userRepository) FindByEmail(ctx context.Context, email string) (*model.
func (r *userRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) { func (r *userRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
var user model.User var user model.User
result := r.db.WithContext(ctx).First(&user, "username = ?", username) result := r.db.WithContext(ctx).First(&user, "username = ? AND status = ?", username, model.StatusActive)
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
@@ -94,7 +109,7 @@ func (r *userRepository) Update(ctx context.Context, user *model.User) error {
} }
func (r *userRepository) Delete(ctx context.Context, id string) error { func (r *userRepository) Delete(ctx context.Context, id string) error {
result := r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", id) 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
} }
@@ -105,6 +120,23 @@ func (r *userRepository) List(ctx context.Context, offset, limit int) ([]model.U
var users []model.User var users []model.User
var total int64 var total int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", model.StatusActive).Count(&total).Error; err != nil {
return nil, 0, err
}
result := r.db.WithContext(ctx).Where("status = ?", model.StatusActive).Offset(offset).Limit(limit).Find(&users)
if result.Error != nil {
return nil, 0, result.Error
}
return users, total, nil
}
// ListIncludeDeleted returns all users (including soft-deleted), paginated.
func (r *userRepository) ListIncludeDeleted(ctx context.Context, offset, limit int) ([]model.User, int64, error) {
var users []model.User
var total int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil { if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
+134 -7
View File
@@ -33,6 +33,7 @@ func TestUserRepository_Create(t *testing.T) {
Username: "alice", Username: "alice",
Email: "alice@example.com", Email: "alice@example.com",
PasswordHash: "hash", PasswordHash: "hash",
Status: model.StatusActive,
} }
if err := repo.Create(ctx, user); err != nil { if err := repo.Create(ctx, user); err != nil {
@@ -44,8 +45,8 @@ func TestUserRepository_CreateDuplicateUsername(t *testing.T) {
repo := setupUserRepo(t) repo := setupUserRepo(t)
ctx := context.Background() ctx := context.Background()
u1 := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} 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"} 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 := repo.Create(ctx, u1); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
@@ -61,7 +62,7 @@ func TestUserRepository_FindByID(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"} 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 := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -89,7 +90,7 @@ func TestUserRepository_FindByEmail(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"} 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 := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -107,7 +108,7 @@ func TestUserRepository_FindByUsername(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"} 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 := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -125,7 +126,7 @@ func TestUserRepository_Update(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"} 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 := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -148,7 +149,7 @@ func TestUserRepository_Delete(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"} 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 := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -173,6 +174,7 @@ func TestUserRepository_List(t *testing.T) {
Username: "user" + string(rune('0'+i)), Username: "user" + string(rune('0'+i)),
Email: "user" + string(rune('0'+i)) + "@example.com", Email: "user" + string(rune('0'+i)) + "@example.com",
PasswordHash: "hash", PasswordHash: "hash",
Status: model.StatusActive,
} }
if err := repo.Create(ctx, user); err != nil { if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
@@ -190,3 +192,128 @@ func TestUserRepository_List(t *testing.T) {
t.Errorf("total = %d, want %d", total, 5) t.Errorf("total = %d, want %d", total, 5)
} }
} }
func TestUserRepository_SoftDelete(t *testing.T) {
repo := setupUserRepo(t)
ctx := context.Background()
user := &model.User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
PasswordHash: "hash",
Status: model.StatusActive,
}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err)
}
if err := repo.Delete(ctx, "user-1"); err != nil {
t.Fatalf("Delete = %v", err)
}
// After soft-delete, FindByID should return ErrNotFound
// because the status filter excludes soft-deleted users.
_, err := repo.FindByID(ctx, "user-1")
if err != model.ErrNotFound {
t.Fatalf("expected ErrNotFound after soft-delete, got %v", err)
}
}
func TestUserRepository_DisabledLogin(t *testing.T) {
repo := setupUserRepo(t)
ctx := context.Background()
user := &model.User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
PasswordHash: "hash",
Status: model.StatusActive,
}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err)
}
if err := repo.Delete(ctx, "user-1"); err != nil {
t.Fatalf("Delete = %v", err)
}
// Soft-deleted users should not be findable by email,
// preventing login for disabled accounts.
_, err := repo.FindByEmail(ctx, "alice@example.com")
if err != model.ErrNotFound {
t.Fatalf("expected ErrNotFound for soft-deleted user login, got %v", err)
}
}
func TestUserRepository_StatusFilterList(t *testing.T) {
repo := setupUserRepo(t)
ctx := context.Background()
u1 := &model.User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
PasswordHash: "hash",
Status: model.StatusActive,
}
if err := repo.Create(ctx, u1); err != nil {
t.Fatalf("Create u1 = %v", err)
}
u2 := &model.User{
ID: "user-2",
Username: "bob",
Email: "bob@example.com",
PasswordHash: "hash",
Status: model.StatusActive,
}
if err := repo.Create(ctx, u2); err != nil {
t.Fatalf("Create u2 = %v", err)
}
// Soft-delete bob
if err := repo.Delete(ctx, "user-2"); err != nil {
t.Fatalf("Delete u2 = %v", err)
}
// List with status filter should only return active users.
users, total, err := repo.List(ctx, 0, 10)
if err != nil {
t.Fatalf("List = %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 active user, got %d", len(users))
}
if total != 1 {
t.Fatalf("expected total 1, got %d", total)
}
if users[0].ID != "user-1" {
t.Errorf("expected active user user-1, got %s", users[0].ID)
}
}
func TestUserRepository_DeleteIdempotent(t *testing.T) {
repo := setupUserRepo(t)
ctx := context.Background()
user := &model.User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
PasswordHash: "hash",
Status: model.StatusActive,
}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err)
}
// Soft-delete the same user twice should not error.
if err := repo.Delete(ctx, "user-1"); err != nil {
t.Fatalf("first Delete = %v", err)
}
if err := repo.Delete(ctx, "user-1"); err != nil {
t.Fatalf("second Delete = %v", err)
}
}
+10 -2
View File
@@ -9,11 +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.AdminService)
rg.Use(middleware.AuthRequired(jwtSecret)) rg.Use(middleware.AuthRequired(webApp.AuthService))
account := rg.Group("/account") account := rg.Group("/account")
{ {
@@ -36,4 +36,12 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
files.PUT("/:id", fileHandler.Update) files.PUT("/:id", fileHandler.Update)
files.DELETE("/:id", fileHandler.Delete) files.DELETE("/:id", fileHandler.Delete)
} }
admin := rg.Group("/admin")
admin.Use(middleware.AdminRequired())
{
admin.GET("/users", adminHandler.ListUsers)
admin.GET("/users/:id", adminHandler.GetUser)
admin.DELETE("/users/:id", adminHandler.DeleteUser)
}
} }
+126 -1
View File
@@ -1,6 +1,8 @@
package server package server
import ( import (
"bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -8,7 +10,9 @@ import (
"time" "time"
"github.com/dhao2001/mygo/internal/app" "github.com/dhao2001/mygo/internal/app"
"github.com/dhao2001/mygo/internal/auth"
"github.com/dhao2001/mygo/internal/config" "github.com/dhao2001/mygo/internal/config"
"github.com/dhao2001/mygo/internal/repository"
"github.com/dhao2001/mygo/internal/service" "github.com/dhao2001/mygo/internal/service"
) )
@@ -21,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, nil, nil, nil, 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)
@@ -56,3 +60,124 @@ func TestAddress(t *testing.T) {
t.Errorf("Address() = %q, want %q", got, want) t.Errorf("Address() = %q, want %q", got, want)
} }
} }
func TestAdminRoutes(t *testing.T) {
// Setup SQLite :memory:
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)
credentialRepo := repository.NewCredentialRepository(db)
jwtSecret := []byte("test-secret")
accessTTL := 15 * time.Minute
refreshTTL := 168 * time.Hour
authService := service.NewAuthService(userRepo, sessionRepo, credentialRepo, jwtSecret, accessTTL, refreshTTL)
adminService := service.NewAdminService(userRepo)
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "test-secret",
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
},
}
webApp := app.NewWebApp(cfg, db, userRepo, sessionRepo, nil, credentialRepo, authService, adminService, nil, nil)
router := NewRouter(webApp)
// Helper: register a user via POST /api/v1/auth/register and return the user ID.
register := func(username, email, password string) string {
body, _ := json.Marshal(map[string]string{
"username": username,
"email": email,
"password": password,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("register %s: status=%d, body=%s", username, rec.Code, rec.Body.String())
}
var user struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &user); err != nil {
t.Fatalf("unmarshal register response: %v", err)
}
return user.ID
}
// Register admin user and promote to admin.
adminID := register("admin", "admin@test.local", "adminpass1")
adminUser, err := userRepo.FindByID(context.Background(), adminID)
if err != nil {
t.Fatalf("find admin user: %v", err)
}
adminUser.IsAdmin = true
if err := userRepo.Update(context.Background(), adminUser); err != nil {
t.Fatalf("promote to admin: %v", err)
}
// Generate admin JWT.
adminToken, err := auth.GenerateAccessToken(adminID, jwtSecret, accessTTL)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
// Register a regular (non-admin) user.
regularID := register("regular", "regular@test.local", "regularpass1")
regularToken, err := auth.GenerateAccessToken(regularID, jwtSecret, accessTTL)
if err != nil {
t.Fatalf("generate regular token: %v", err)
}
// Register a victim user to delete.
victimID := register("victim", "victim@test.local", "victimpass1")
// Helper: make a DELETE request with optional auth header.
deleteUser := func(userID, token string) *httptest.ResponseRecorder {
url := "/api/v1/admin/users/" + userID
req := httptest.NewRequest(http.MethodDelete, url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// Test 1: Admin can delete a user → 204.
rec := deleteUser(victimID, adminToken)
if rec.Code != http.StatusNoContent {
t.Errorf("admin delete: status=%d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
}
// Register a second victim for the remaining tests (first victim is soft-deleted).
victim2ID := register("victim2", "victim2@test.local", "victim2pass1")
// Test 2: Non-admin JWT → 403.
rec = deleteUser(victim2ID, regularToken)
if rec.Code != http.StatusForbidden {
t.Errorf("non-admin delete: status=%d, want %d, body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
}
// Test 3: No auth → 401.
rec = deleteUser(victim2ID, "")
if rec.Code != http.StatusUnauthorized {
t.Errorf("no-auth delete: status=%d, want %d, body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
}
+51
View File
@@ -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.UserRepository
}
// NewAdminService creates an AdminService.
func NewAdminService(userRepo repository.UserRepository) *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.Delete(ctx, id); err != nil {
return model.NewInternalError("delete admin user", err)
}
return nil
}
+69
View File
@@ -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.Create(ctx, active); err != nil {
t.Fatalf("create active: %v", err)
}
if err := repo.Create(ctx, deleted); err != nil {
t.Fatalf("create deleted: %v", err)
}
if err := repo.Delete(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))
}
}
+86 -22
View File
@@ -3,7 +3,6 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"net/http"
"strings" "strings"
"time" "time"
@@ -16,15 +15,21 @@ 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.
@@ -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)
@@ -72,11 +77,12 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
Username: username, Username: username,
Email: email, Email: email,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Status: model.StatusActive,
} }
if err := s.userRepo.Create(ctx, user); 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)
} }
@@ -89,13 +95,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 {
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)
@@ -106,24 +116,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.FindByTokenHash(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)
if err != nil {
return nil, model.NewUnauthenticatedError("invalid token")
}
if user.Status != model.StatusActive {
return nil, model.NewUnauthenticatedError("invalid token")
} }
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil { if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
@@ -144,7 +162,10 @@ func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error
return model.NewInternalError("find session", err) return model.NewInternalError("find session", err)
} }
return s.sessionRepo.Delete(ctx, session.ID) if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
return model.NewInternalError("delete session", err)
}
return nil
} }
// CreatePasskey creates a new app passkey for the authenticated user. // CreatePasskey creates a new app passkey for the authenticated user.
@@ -176,20 +197,28 @@ 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.FindByHash(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)
if err != nil {
return nil, model.NewInternalError("find user", err)
}
if user.Status != model.StatusActive {
return nil, model.NewUnauthenticatedError("invalid passkey")
}
if cred.Type != "app_passkey" { if cred.Type != "app_passkey" {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"} return nil, model.NewUnauthenticatedError("invalid credential type")
} }
if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil { if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil {
@@ -201,7 +230,11 @@ 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.FindByUserIDAndType(ctx, userID, "app_passkey")
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.
@@ -209,16 +242,47 @@ func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string)
cred, err := s.credentialRepo.FindByID(ctx, credID) cred, err := s.credentialRepo.FindByID(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.Delete(ctx, credID); err != nil {
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) {
+218 -4
View File
@@ -3,7 +3,6 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"net/http"
"testing" "testing"
"time" "time"
@@ -16,6 +15,13 @@ import (
) )
func setupAuthService(t *testing.T) *AuthService { func setupAuthService(t *testing.T) *AuthService {
svc, _ := setupAuthServiceWithRepos(t)
return svc
}
// setupAuthServiceWithRepos creates an AuthService and returns the UserRepository
// for tests that need direct repo access (e.g., soft-deleting users).
func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -30,12 +36,13 @@ func setupAuthService(t *testing.T) *AuthService {
sessionRepo := repository.NewSessionRepository(db) sessionRepo := repository.NewSessionRepository(db)
credentialRepo := repository.NewCredentialRepository(db) credentialRepo := repository.NewCredentialRepository(db)
return NewAuthService( svc := NewAuthService(
userRepo, sessionRepo, credentialRepo, userRepo, sessionRepo, credentialRepo,
[]byte("test-secret"), []byte("test-secret"),
15*time.Minute, 15*time.Minute,
7*24*time.Hour, 7*24*time.Hour,
) )
return svc, userRepo
} }
func TestAuthService_Register(t *testing.T) { func TestAuthService_Register(t *testing.T) {
@@ -339,8 +346,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)
} }
} }
@@ -404,3 +411,210 @@ func TestAuthService_RefreshWithAccessToken(t *testing.T) {
t.Fatal("expected error when using access token for refresh, got nil") t.Fatal("expected error when using access token for refresh, got nil")
} }
} }
func TestAuthService_LoginDisabledUser(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)
}
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.Login(ctx, "alice@example.com", "password123")
if err == nil {
t.Fatal("expected error for disabled user login, got nil")
}
var ae *model.AppError
if !errors.As(err, &ae) {
t.Fatalf("expected AppError, got %T: %v", err, err)
}
if ae.Kind != model.KindUnauthenticated {
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
}
if ae.Message != "invalid email or password" {
t.Errorf("message = %q, want %q", ae.Message, "invalid email or password")
}
}
func TestAuthService_LoginDisabledUserSameMessage(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)
}
// Get error for wrong password (active user)
_, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword")
// Soft-delete user, then try to login
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, disabledErr := svc.Login(ctx, "alice@example.com", "password123")
// Both errors should have the same message (no user enumeration)
if disabledErr == nil {
t.Fatal("expected error for disabled user login, got nil")
}
var aeDisabled *model.AppError
if !errors.As(disabledErr, &aeDisabled) {
t.Fatalf("expected AppError, got %T: %v", disabledErr, disabledErr)
}
var aeWrongPw *model.AppError
if !errors.As(wrongPwErr, &aeWrongPw) {
t.Fatalf("expected AppError for wrong password, got %T: %v", wrongPwErr, wrongPwErr)
}
if aeDisabled.Message != aeWrongPw.Message {
t.Errorf("disabled message = %q, want %q (must match wrong-password message to prevent user enumeration)", aeDisabled.Message, aeWrongPw.Message)
}
if aeDisabled.Message != "invalid email or password" {
t.Errorf("disabled message = %q, want %q", aeDisabled.Message, "invalid email or password")
}
}
func TestAuthService_LoginWithPasskeyDisabledUser(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)
}
claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
if err != nil {
t.Fatalf("ParseToken = %v", err)
}
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
if err != nil {
t.Fatalf("CreatePasskey = %v", err)
}
// Soft-delete the user
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.LoginWithPasskey(ctx, pk.Raw)
if err == nil {
t.Fatal("expected error for disabled user passkey login, got nil")
}
var ae *model.AppError
if !errors.As(err, &ae) {
t.Fatalf("expected AppError, got %T: %v", err, err)
}
if ae.Kind != model.KindUnauthenticated {
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
}
if ae.Message != "invalid passkey" {
t.Errorf("message = %q, want %q", ae.Message, "invalid passkey")
}
}
func TestAuthService_RefreshDisabledUser(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)
}
// Soft-delete the user
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.Refresh(ctx, pair.RefreshToken)
if err == nil {
t.Fatal("expected error for disabled user refresh, got nil")
}
var ae *model.AppError
if !errors.As(err, &ae) {
t.Fatalf("expected AppError, got %T: %v", err, err)
}
if ae.Kind != model.KindUnauthenticated {
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
}
if ae.Message != "invalid token" {
t.Errorf("message = %q, want %q", ae.Message, "invalid token")
}
}
func TestAuthService_AuthenticateAccessToken(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)
}
user.IsAdmin = true
if err := userRepo.Update(ctx, user); err != nil {
t.Fatalf("promote user: %v", err)
}
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.Delete(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)
}
}
+106 -49
View File
@@ -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,21 +110,30 @@ 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)} }
if err := s.storage.PromoteStaged(ctx, stagedPath, storagePath); err != nil {
_ = s.storage.DeleteStaged(ctx, stagedPath)
return nil, model.NewInternalError("promote staged file", err)
} }
file := &model.File{ file := &model.File{
@@ -129,14 +145,15 @@ 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, IsDir: false,
} }
if err := s.fileRepo.Create(ctx, file); err != nil { if err := s.fileRepo.Create(ctx, file); err != nil {
// Compensate: remove the stored file. // Compensate: remove the promoted 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")
} }
return nil, model.NewInternalError("create file record", err) return nil, model.NewInternalError("create file record", err)
} }
@@ -144,6 +161,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) {
@@ -153,7 +213,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)
@@ -212,7 +272,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
// If parent is changing, verify the new parent. // 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 { if err := s.verifyParent(ctx, userID, *newParentID); err != nil {
return nil, err return nil, err
@@ -224,7 +284,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
if newName != "" || newParentID != nil { if newName != "" || newParentID != nil {
conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name) conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name)
if err == nil && conflict.ID != fileID { 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"} return nil, model.NewConflictError("a file with this name already exists in the target directory")
} else if err != nil && !errors.Is(err, model.ErrNotFound) { } else if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, model.NewInternalError("check name conflict", err) return nil, model.NewInternalError("check name conflict", err)
} }
@@ -232,7 +292,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
if err := s.fileRepo.Update(ctx, file); err != nil { if err := s.fileRepo.Update(ctx, file); 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")
} }
return nil, model.NewInternalError("update file", err) return nil, model.NewInternalError("update file", err)
} }
@@ -240,10 +300,14 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
return modelToFileInfo(file), nil return modelToFileInfo(file), nil
} }
// Delete removes a file or (empty) directory. // 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) file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil { if err != nil {
var ae *model.AppError
if errors.As(err, &ae) && errors.Is(ae.Err, model.ErrNotFound) {
return nil // idempotent: already deleted
}
return err return err
} }
@@ -254,7 +318,7 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
return model.NewInternalError("check directory contents", err) return model.NewInternalError("check directory contents", err)
} }
if total > 0 { if total > 0 {
return &model.AppError{Status: http.StatusConflict, Message: "directory is not empty"} return model.NewConflictError("directory is not empty")
} }
} }
@@ -262,14 +326,6 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
return model.NewInternalError("delete file record", err) return model.NewInternalError("delete file record", err)
} }
// Remove content from storage (directories have no stored content).
if !file.IsDir {
if err := s.storage.Delete(ctx, file.StoragePath); err != nil {
s.logger.WarnContext(ctx, "failed to delete file from storage",
"path", file.StoragePath, "error", err)
}
}
return nil return nil
} }
@@ -287,7 +343,7 @@ 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)
} }
@@ -297,12 +353,13 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
UserID: userID, UserID: userID,
ParentID: parentID, ParentID: parentID,
Name: dirName, Name: dirName,
Status: model.StatusActive,
IsDir: true, IsDir: true,
} }
if err := s.fileRepo.Create(ctx, dir); 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")
} }
return nil, model.NewInternalError("create directory record", err) return nil, model.NewInternalError("create directory record", err)
} }
@@ -315,13 +372,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.NewForbiddenError("access denied", model.ErrForbidden)
} }
return file, nil return file, nil
@@ -332,17 +389,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.NewForbiddenError("access denied", model.ErrForbidden)
} }
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
@@ -352,13 +409,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
} }
+144 -14
View File
@@ -5,7 +5,6 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"net/http"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -18,16 +17,16 @@ 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
@@ -43,7 +42,7 @@ 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 +53,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 +76,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)
@@ -73,7 +89,11 @@ func (s *memStorage) Delete(_ context.Context, path string) error {
var _ storage.StorageBackend = (*memStorage)(nil) var _ storage.StorageBackend = (*memStorage)(nil)
func setupFileService(t *testing.T) *FileService { 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 +107,12 @@ func setupFileService(t *testing.T) *FileService {
fileRepo := repository.NewFileRepository(db) fileRepo := repository.NewFileRepository(db)
store := newMemStorage() store := newMemStorage()
return NewFileService(fileRepo, store, 0, nil) // 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 {
svc, _ := setupFileServiceWithStorage(t)
return svc
} }
func TestFileService_Upload(t *testing.T) { func TestFileService_Upload(t *testing.T) {
@@ -120,6 +145,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()
@@ -231,7 +284,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) assertAppError(t, err, model.KindPermissionDenied)
} }
func TestFileService_DownloadDirectory(t *testing.T) { func TestFileService_DownloadDirectory(t *testing.T) {
@@ -272,7 +325,7 @@ 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_GetForbidden(t *testing.T) {
@@ -285,7 +338,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) assertAppError(t, err, model.KindPermissionDenied)
} }
func TestFileService_List(t *testing.T) { func TestFileService_List(t *testing.T) {
@@ -383,7 +436,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) {
@@ -415,7 +468,7 @@ 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) assertAppError(t, err, model.KindPermissionDenied)
} }
func TestFileService_CreateDir(t *testing.T) { func TestFileService_CreateDir(t *testing.T) {
@@ -459,5 +512,82 @@ func TestFileService_VerifyParentForbidden(t *testing.T) {
} }
_, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data")) _, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
assertAppError(t, err, http.StatusForbidden) assertAppError(t, err, model.KindPermissionDenied)
}
func TestFileService_SoftDelete(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
info, err := svc.Upload(ctx, "user1", nil, "soft-delete.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.Get(ctx, "user1", info.ID)
assertAppError(t, err, model.KindNotFound)
}
func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
svc, store := setupFileServiceWithStorage(t)
ctx := context.Background()
content := "keep me after soft-delete"
info, err := svc.Upload(ctx, "user1", nil, "keep.txt", strings.NewReader(content))
if err != nil {
t.Fatalf("Upload = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
storagePath := "data/user1/" + info.ID
reader, err := store.Open(ctx, storagePath)
if err != nil {
t.Fatalf("storage should retain content after soft-delete, got: %v", err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read storage: %v", err)
}
if string(data) != content {
t.Errorf("storage content = %q, want %q", string(data), content)
}
}
func TestFileService_SoftDeleteIdempotent(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
info, err := svc.Upload(ctx, "user1", nil, "idem.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("first Delete = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("second Delete should be idempotent, got: %v", err)
}
}
func TestFileService_SoftDeleteForbidden(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
info, err := svc.Upload(ctx, "user1", nil, "forbidden.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload = %v", err)
}
err = svc.Delete(ctx, "user2", info.ID)
assertAppError(t, err, model.KindPermissionDenied)
} }
+105 -2
View File
@@ -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
View File
@@ -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) {
+12 -6
View File
@@ -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
+1
View File
@@ -1,2 +1,3 @@
[tools] [tools]
go = "1.26.2" go = "1.26.2"
node = "24"
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+18
View File
@@ -0,0 +1,18 @@
# MyGO Web
MyGO's browser client is a pure client-side rendered application built with React, TypeScript, and Vite.
## Development
```bash
npm install
npm run dev
```
## Checks
```bash
npm run check
```
The production build is emitted to `dist/` as static assets. See `docs/web/roadmap.md` at the repository root for architecture boundaries and planned dependencies.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MyGO web client" />
<title>MyGO</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2761
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "mygo-web",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=24 <25"
},
"packageManager": "npm@11.9.0",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"typecheck": "tsc -b",
"check": "npm run lint && npm run build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"antd": "^6.5.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^8.2.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { RouterProvider } from 'react-router'
import { router } from './app/router.tsx'
function App() {
return <RouterProvider router={router} />
}
export default App
+15
View File
@@ -0,0 +1,15 @@
import type { PropsWithChildren } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App as AntDesignApp, ConfigProvider } from 'antd'
const queryClient = new QueryClient()
export function AppProviders({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider>
<AntDesignApp>{children}</AntDesignApp>
</ConfigProvider>
</QueryClientProvider>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { createBrowserRouter } from 'react-router'
import { HomePage } from '../pages/HomePage.tsx'
import { NotFoundPage } from '../pages/NotFoundPage.tsx'
export const router = createBrowserRouter([
{
path: '/',
element: <HomePage />,
},
{
path: '*',
element: <NotFoundPage />,
},
])
+13
View File
@@ -0,0 +1,13 @@
@layer theme, base, antd, components, utilities;
@import 'tailwindcss';
html,
body,
#root {
min-width: 320px;
min-height: 100%;
}
body {
margin: 0;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AppProviders } from './app/providers.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AppProviders>
<App />
</AppProviders>
</StrictMode>,
)
+19
View File
@@ -0,0 +1,19 @@
import { Card, Space, Tag, Typography } from 'antd'
const { Paragraph, Title } = Typography
export function HomePage() {
return (
<main className="grid min-h-screen place-items-center bg-slate-50 p-6">
<Card className="w-full max-w-xl">
<Space orientation="vertical" size="middle">
<Tag color="blue">MyGO Web</Tag>
<Title level={1}>MyGO</Title>
<Paragraph>
The client-side web application foundation is ready.
</Paragraph>
</Space>
</Card>
</main>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { Button, Result } from 'antd'
import { Link } from 'react-router'
export function NotFoundPage() {
return (
<Result
status="404"
title="Page not found"
extra={
<Button type="primary">
<Link to="/">Back home</Link>
</Button>
}
/>
)
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"strict": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
"strict": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})