diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1de4fe66 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,245 @@ +# AGENTS.md + +## Project Overview + +This repository contains protobuf definitions and generated code for TuiHub platform. It serves as the central API contract repository that supports multiple programming languages (Go, JavaScript/TypeScript, Rust, C#, and Dart). + +**Key Architecture:** +- **Proto definitions**: Located in `proto/` directory +- **Generated code**: Multiple language targets in `pkg/`, `node/`, `src/`, `Assets/src/`, and `lib/` +- **Services**: Four main service types: + - `LibrarianSephirahService`: Core service for the platform + - `LibrarianPorterService`: Integration service + - `LibrarianMinerService`: Data mining service + - `LibrarianSentinelService`: Monitoring service +- **Build tool**: Uses [buf](https://buf.build) for proto management and code generation + +**Important**: Generated code directories (`pkg/`, `node/`, `src/`, `Assets/src/`, `lib/`) should NOT be directly edited. Always modify proto files in `proto/` and regenerate. + +## Setup Commands + +### Prerequisites + +Install required tools: +```bash +# buf CLI +# Install manually + +# Optional: Install all language plugins +make install-plugins +``` + +### Quick Start + +```bash +# Install dependencies for all languages +npm install # JavaScript/TypeScript +go mod tidy # Go +cargo check # Rust +dart pub get # Dart +``` + +## Build Instructions + +### Lint Proto Files + +```bash +make buf-lint +``` + +### Generate Code Locally (Optional) + +```bash +make generate +``` + +### Individual Language Builds + +```bash +# Go +make go + +# Rust (with full proto features) +make rust +cargo check --features proto_full + +# Dart +make dart +dart analyze + +# JavaScript/TypeScript +npm install +``` + +## Testing Instructions + +### Proto Validation + +```bash +make check +``` + +### Language-Specific Tests + +```bash +# Go +go test ./pkg/... + +# Rust +cargo test + +# Dart +dart test +``` + +## Code Style Guidelines + +### Proto Files +- **Location**: All `.proto` files must be in `proto/` directory +- **Versioning**: Use versioned paths (e.g., `v1/`) +- **Naming**: Follow standard protobuf naming conventions + - Services: PascalCase with "Service" suffix (e.g., `LibrarianSephirahService`) + - Messages: PascalCase (e.g., `GetUserRequest`) + - Fields: snake_case (e.g., `user_id`) +- **Comments**: Use line comments (`//`) for documentation +- **Validation**: Use buf.build/bufbuild/protovalidate for field validation + +### Linting Rules +- Uses `buf lint` with STANDARD ruleset +- Exceptions: + - `PACKAGE_VERSION_SUFFIX` allowed for `proto/errors/errors.proto` +- Breaking change detection enabled (FILE strategy) + +### Generated Code +- **Never edit generated code directly** +- Generated code directories are cleaned and regenerated on each build +- If you need changes, modify the proto files + +## Development Workflow + +### Adding New Proto Definitions + +1. Create or modify `.proto` files in appropriate `proto/` subdirectory +2. Run `buf format -w` to format +3. Run `buf lint` to validate +4. Test locally with `make generate` (optional) +5. Commit proto files only (CI will handle generation) + +### Modifying Existing APIs + +1. Check for breaking changes: + ```bash + buf breaking --against '.git#branch=master' + ``` +2. If breaking changes are necessary, document them in CHANGELOG.md +3. Update proto files following the same workflow as new definitions + +### PR Guidelines + +- **Title format**: `[scope] Brief description` + - Scope examples: `proto`, `docs`, `build`, `ci` +- **Always run before committing**: + ```bash + buf format -w + buf lint + ``` +- **Do NOT commit**: + - Generated code (it will be auto-generated by CI) + - Build artifacts from `target/` directory + - IDE-specific files + +### Commit Message Format + +Follow conventional commits: +- `feat(proto)`: New proto definitions +- `fix(proto)`: Bug fixes in proto +- `docs`: Documentation updates +- `build`: Build system changes +- `ci`: CI configuration changes + +## Directory Structure + +``` +proto/ # Source proto definitions (EDIT HERE) +├── errors/ # Error definitions +└── librarian/ # Main service definitions + ├── miner/v1/ # Miner service + ├── porter/v1/ # Porter service + ├── sentinel/v1/ # Sentinel service + ├── sephirah/v1/ # Sephirah service + └── v1/ # Common definitions + +Generated Code (DO NOT EDIT): +├── pkg/ # Go +├── node/ # JavaScript/TypeScript +├── src/ # Rust +├── Assets/src/ # C# +└── lib/ # Dart + +Configuration: +├── buf.yaml # Buf configuration +├── buf.gen.yaml # Code generation config +├── Makefile # Build automation +└── docs/ # Documentation +``` + +## Documentation + +- **Generated docs**: https://tuihub.github.io/protos +- **Proto files**: [proto/](proto/) +- **Feature Sets**: [docs/feature_sets/](docs/feature_sets/) + +## Useful Commands Reference + +```bash +# Clean generated code +make clean + +# Full regeneration +make clean && make generate + +# Check specific language +make go # Go modules +make rust # Rust check +make dart # Dart analysis + +# View generated OpenAPI +cat docs/openapi.json +``` + +## Troubleshooting + +### "buf: command not found" +Install buf: https://buf.build/docs/installation + +### Generated code conflicts +```bash +make clean +make generate +``` + +### Lint failures +```bash +# Auto-fix formatting +buf format -w + +# View detailed lint errors +buf lint --error-format=json +``` + +### Breaking changes detected +Review changes carefully. If intentional, document in CHANGELOG.md. Breaking changes should be rare and well-justified. + +## CI/CD Behavior + +- **On push**: CI automatically generates code for all languages +- **On PR**: Linting and breaking change detection runs +- **Documentation**: Auto-generated and deployed to GitHub Pages +- **Releases**: Managed by release-please bot + +## Additional Notes + +- This is a polyglot repository supporting 5+ languages +- Generated code is committed to the repository for easy consumption +- Each language has its own package distribution method (see README.md) +- Feature Sets documentation uses Gherkin format for behavior specification diff --git a/Makefile b/Makefile index fae28173..8da5f024 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ install-plugins: cargo install --locked protoc-gen-prost-crate dart pub global activate protoc_plugin 22.5.0 -generate: clean buf go rust dart +generate: clean buf go rust dart dependency-tree check: buf-lint go rust dart @@ -24,6 +24,9 @@ buf-lint: buf-generate: PATH="${PATH}:${HOME}/.pub-cache/bin" buf generate --include-imports +testsuite-tree: + go run ./cmd/testsuite tree > docs/testsuite_tree.md + go: GO111MODULE=on go mod tidy @@ -37,6 +40,7 @@ dart: clean: -rm -r Assets/src -rm docs/protos.md + -rm docs/dependency-tree.md -rm -r pkg -rm -r node -rm -r src diff --git a/cmd/testsuite/main.go b/cmd/testsuite/main.go new file mode 100644 index 00000000..6d8aac58 --- /dev/null +++ b/cmd/testsuite/main.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + featuresets "github.com/tuihub/protos/docs/feature_sets" +) + +// Testsuite is the entry point of testsuite command +// It supports the following subcommands: +// - run: Run the test suite (default if no subcommand specified) +// - tree: Generate dependency tree visualization +// - help: Show usage information +func main() { + if len(os.Args) < 2 { + // Default to run command when no arguments provided + runCommand(os.Args[1:]) + return + } + + subcommand := os.Args[1] + + // Check if first argument looks like a flag (backward compatibility) + if len(subcommand) > 0 && subcommand[0] == '-' { + // Treat as run command with flags + runCommand(os.Args[1:]) + return + } + + switch subcommand { + case "run": + runCommand(os.Args[2:]) + case "tree": + treeCommand(os.Args[2:]) + case "help", "--help", "-h": + printUsage() + default: + // Unknown subcommand, default to run for backward compatibility + runCommand(os.Args[1:]) + } +} + +func runCommand(args []string) { + fs := flag.NewFlagSet("run", flag.ExitOnError) + serverHost := fs.String("server-host", "127.0.0.1", "Server host") + serverPort := fs.Int("server-port", 10000, "Server port") + verboseFlag := fs.Bool("v", false, "Verbose") + veryVerboseFlag := fs.Bool("vv", false, "Very verbose") + extremelyVerboseFlag := fs.Bool("vvv", false, "Extremely verbose") + + fs.Parse(args) + + sephirahServerHost := *serverHost + sephirahServerPort := *serverPort + + verbose := 0 + if *verboseFlag { + verbose = 1 + } + if *veryVerboseFlag { + verbose = 2 + } + if *extremelyVerboseFlag { + verbose = 3 + } + + fmt.Printf("Running TestSuite on %s:%d, Verbose Level: %d\n", sephirahServerHost, sephirahServerPort, verbose) + err := featuresets.RunTestSuite(context.Background(), sephirahServerHost, sephirahServerPort, verbose) + if err != nil { + fmt.Printf("TestSuite Failed to Run, Error: %v\n", err) + os.Exit(1) + } + fmt.Printf("TestSuite Ran Successfully\n") +} + +func treeCommand(args []string) { + fs := flag.NewFlagSet("tree", flag.ExitOnError) + fs.Parse(args) + + if err := featuresets.PrintMermaidTree(os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "Failed to generate dependency tree: %v\n", err) + os.Exit(1) + } +} + +func printUsage() { + fmt.Println(`Usage: testsuite [subcommand] [flags] + +Subcommands: + run Run the test suite (default) + tree Generate dependency tree visualization in Mermaid format + help Show this help message + +Run command flags: + -server-host string + Server host (default "127.0.0.1") + -server-port int + Server port (default 10000) + -v Verbose output (level 1) + -vv Very verbose output (level 2) + -vvv Extremely verbose output (level 3) + +Examples: + testsuite run --server-host=localhost --server-port=8080 + testsuite tree > dependency-tree.md + testsuite tree | pbcopy # Copy to clipboard on macOS + +For more information, see docs/feature_sets/AGENTS.md`) +} diff --git a/docs/.gitignore b/docs/.gitignore index 1f312935..27289235 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,2 +1,3 @@ protos.md -openapi.json \ No newline at end of file +openapi.json +testsuite_tree.md \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index df63939e..98b509d2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,16 @@ -## [文档已迁移](https://github.com/tuihub/docs) +# TuiHub Protos -## 目前仅提供proto接口文档 +This site hosts the API documentation for the TuiHub platform. + +## Start Here + +- Protobuf API reference: [protos.md](protos.md) +- OpenAPI (Swagger UI): [openapi.md](openapi.md) + +## Behavior Specs (Feature Sets) + +Feature Sets are short, testable behavior requirements (MUST/SHOULD/MAY) with an executable testsuite. + +- Overview: [feature_sets/FS_overview.md](feature_sets/FS_overview.md) +- Testsuite guide: [testsuite.md](testsuite.md) +- Dependency graph: [testsuite_tree.md](testsuite_tree.md) diff --git a/docs/_sidebar.md b/docs/_sidebar.md index fbe32ec9..9f114b05 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -1,3 +1,10 @@ * [Home](/) * [Protos](protos.md) -* [OpenAPI](openapi.md) \ No newline at end of file +* [OpenAPI](openapi.md) +* [Feature Sets](feature_sets/FS_overview.md) + * [FS-0000-INIT](feature_sets/FS-0000-INIT.md) + * [FS-0001-AUTH](feature_sets/FS-0001-AUTH.md) + * [FS-0002-USER](feature_sets/FS-0002-USER.md) + * [FS-0003-SESSION](feature_sets/FS-0003-SESSION.md) +* [Testsuite](testsuite.md) +* [Testsuite Tree](testsuite_tree.md) diff --git a/docs/feature_sets/AGENTS.md b/docs/feature_sets/AGENTS.md new file mode 100644 index 00000000..0e13175d --- /dev/null +++ b/docs/feature_sets/AGENTS.md @@ -0,0 +1,461 @@ +# AGENTS.md - Feature Sets + +## Overview + +This directory contains Feature Sets documentation for the TuiHub platform. Feature Sets (FS) are behavior specifications that define system requirements and their corresponding test implementations. + +## What are Feature Sets? + +Feature Sets describe collections of related features using: +- **Markdown files** (`.md`): Human-readable specifications following RFC 2119 (MUST, SHOULD, MAY) +- **Go test files** (`.go`): Executable test cases that validate the specifications +- **Test suite**: A comprehensive test runner that validates implementations against specs + +## File Structure + +``` +docs/feature_sets/ +├── AGENTS.md # This file +├── FS_overview.md # General writing guide +├── FS_testsuite.go # Test suite framework +├── FS_testsuite_test.go # Test suite entry point +├── FS-XXXX-SCOPE.md # Specification document +└── FS-XXXX-SCOPE.go # Test implementation +``` + +## Writing Feature Set Specifications + +### Naming Convention + +**Feature Set Document ID Format:** +``` +FS-- +``` + +**Feature Definition ID Format:** +``` +FS--- +``` + +- Use uppercase for scope and feature names +- Separate multiple words with underscores: `TOKEN_STRUCTURE`, `GRPC_AUTHENTICATION` +- Numbers are zero-padded to 4 digits: `FS-0001`, `FS-0042` + +### Document Template + +Create new specification files with this structure: + +```markdown +--- +id: FS-XXXX-SCOPE +title: Brief title +version: 0.0.1 +status: draft +created: YYYY-MM-DD +last_updated: YYYY-MM-DD +--- + +## FS-XXXX-SCOPE-FEATURE_NAME + +Description of the feature requirement using RFC 2119 keywords: +- MUST: Absolute requirement +- SHOULD: Strong recommendation +- MAY: Optional feature + +Additional details and context. + +## FS-XXXX-SCOPE-ANOTHER_FEATURE + +Next feature definition... +``` + +### Example + +See [FS-0001-AUTH.md](FS-0001-AUTH.md) for a complete example covering token validation behavior. + +## Implementing Test Cases + +### Test Case Structure + +Each feature definition in the markdown file should have a corresponding test case in the `.go` file. + +```go +package featuresets + +import ( + "context" + pb "github.com/tuihub/protos/pkg/librarian/sephirah/v1" +) + +func init() { + registerTestCase("FS-XXXX-SCOPE-FEATURE_NAME", must, func(ctx context.Context, g *globals) error { + // Test implementation + // Return error if test fails, nil if passes + return nil + }, withDependOnIDs("FS-YYYY-DEPENDENCY")) +} +``` + +### Test Case Components + +1. **ID**: Must match the feature definition ID from markdown +2. **Require Level**: + - `must`: For MUST requirements (mandatory) + - `should`: For SHOULD requirements (recommended) + - `may`: For MAY requirements (optional) +3. **Test Function**: Implements the actual test logic +4. **Dependencies**: Optional list of test IDs that must pass first + +### Test Case Validation + +- Test case IDs MUST match pattern: `^FS-[0-9]{4}-[A-Z]+-[A-Z_]+$` +- Test cases are automatically sorted by dependencies before execution +- Failed dependencies will affect dependent tests + +## Running Tests + +### Available Commands + +The testsuite provides two main commands: + +```bash +# From the repository root + +# Run the test suite (default command) +go run ./cmd/testsuite run --server-host=localhost --server-port=8080 + +# Generate dependency tree visualization +go run ./cmd/testsuite tree + +# Show help +go run ./cmd/testsuite help +``` + +### Local Testing + +```bash +# From the repository root + +# Run the test suite against a server (explicit) +go run ./cmd/testsuite run --server-host=localhost --server-port=8080 + +# Run with default parameters (127.0.0.1:10000) +go run ./cmd/testsuite run + +# Backward compatible - omitting 'run' defaults to run command +go run ./cmd/testsuite --server-host=localhost --server-port=8080 + +# Run with verbose output +go run ./cmd/testsuite run --server-host=localhost --server-port=8080 -vv +``` + +### Verbose Levels + +- `0` (default): Summary only (pass/fail counts by requirement level) +- `1` (`-v`): Show test results +- `2` (`-vv`): Show detailed test execution +- `3` (`-vvv`): Extremely verbose output + +### Test Output + +``` +Running test case: FS-0001-AUTH-TOKEN_STRUCTURE + PASSED +Running test case: FS-0001-AUTH-GRPC_AUTHENTICATION + PASSED +... + +MUST Cases 5/5 +SHOULD Cases 3/4 +MAY Cases 2/3 +``` + +### Visualizing Dependencies + +Generate a Mermaid diagram showing test case dependencies: + +```bash +# From the repository root + +# Output to terminal +go run ./cmd/testsuite tree + +# Save to file +go run ./cmd/testsuite tree > dependency-tree.md +``` + +The tree command generates: +- **Mermaid diagram**: Visual representation of test dependencies organized by Feature Set scope +- **Subgraphs**: Each Feature Set (FS-XXXX-SCOPE) is displayed in its own subgraph +- **Edge styles**: + - Solid arrows (`-->`) for dependencies within the same Feature Set + - Dashed arrows (`-.->`) for dependencies across different Feature Sets +- **Statistics**: Test counts by requirement level, per-FS breakdown, max depth, root/leaf nodes +- **Color coding**: + - 🟢 Green = MUST requirements + - 🔵 Blue = SHOULD requirements + - 🟡 Yellow = MAY requirements + +**Example tree output:** + +```mermaid +graph TD + + subgraph FS_0000_INIT["FS-0000-INIT"] + FS_0000_INIT_SEPHIRAH_CLIENT["FS-0000-INIT-SEPHIRAH_CLIENT
MUST"] + end + + subgraph FS_0001_AUTH["FS-0001-AUTH"] + FS_0001_AUTH_ADMIN_ACCOUNT["FS-0001-AUTH-ADMIN_ACCOUNT
MUST"] + FS_0001_AUTH_TOKEN_STRUCTURE["FS-0001-AUTH-TOKEN_STRUCTURE
MUST"] + end + + %% Dependencies + %% Same Feature Set dependencies (solid arrows) + FS_0001_AUTH_ADMIN_ACCOUNT --> FS_0001_AUTH_TOKEN_STRUCTURE + + %% Cross Feature Set dependencies (dashed arrows) + FS_0000_INIT_SEPHIRAH_CLIENT -.-> FS_0001_AUTH_ADMIN_ACCOUNT + + %% Styling by requirement level + style FS_0000_INIT_SEPHIRAH_CLIENT fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff + style FS_0001_AUTH_ADMIN_ACCOUNT fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff + style FS_0001_AUTH_TOKEN_STRUCTURE fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff +``` + +## Dependency Tree Statistics + +- **Total test cases**: 3 +- **MUST**: 3 cases (100.0%) +- **Maximum depth**: 3 levels +- **Root nodes**: 1 +- **Leaf nodes**: 1 + +### Test Cases by Feature Set + +- **FS-0000-INIT**: 1 cases +- **FS-0001-AUTH**: 2 cases + +### Dependencies + +- **Same-FS dependencies**: 1 +- **Cross-FS dependencies**: 1 +- **Total dependencies**: 2 (50.0% cross-FS) + +## Development Workflow + +### Adding a New Feature Set + +1. **Choose an ID**: + ```bash + # Find the next available number + ls FS-*.md | sort | tail -1 + # Use next incremental number + ``` + +2. **Create specification**: + ```bash + touch FS-XXXX-SCOPE.md + ``` + +3. **Write specification** following the template above + +4. **Create test file**: + ```bash + touch FS-XXXX-SCOPE.go + ``` + +5. **Implement test cases** for each feature definition + +6. **Test locally**: + ```bash + cd /path/to/protos + go run ./cmd/testsuite run --server-host=your-test-server --server-port=port + ``` + +7. **Visualize dependencies** (optional): + ```bash + go run ./cmd/testsuite tree + ``` + +8. **Commit both files together** + +Note: The dependency tree visualization is automatically generated as `docs/dependency-tree.md` when running `make generate`. This file is used for documentation website deployment alongside `docs/protos.md` and `docs/openapi.json`. + +### Modifying Existing Feature Sets + +1. Update the `last_updated` date in the markdown frontmatter +2. Increment the `version` if making significant changes +3. Ensure test implementation matches the specification +4. Test all affected cases locally +5. Update dependent test cases if needed + +### Best Practices + +- **One feature per definition**: Keep feature definitions atomic +- **Clear test names**: Test case IDs should be self-descriptive +- **Meaningful errors**: Return descriptive error messages from test functions +- **Use dependencies**: Leverage `withDependOnIDs()` to establish test order +- **Visualize complex dependencies**: Use `testsuite tree` to understand test relationships +- **Test isolation**: Each test should clean up after itself when possible +- **Update docs**: Keep markdown specs synchronized with test implementation + +## Test Suite Framework + +### Global Context + +The `globals` struct maintains shared state across test cases: + +```go +type globals struct { + SephirahServerHost string + SephirahServerPort int + SephirahClient pb.LibrarianSephirahServiceClient + AccessToken string + RefreshToken string + // Add more shared state as needed +} +``` + +### Helper Functions + +Common helper functions are available: + +- `withBearerToken(ctx, token)`: Add authorization header to context +- `withDependOnIDs(ids...)`: Specify test dependencies +- `registerTestCase()`: Register a new test case + +### Adding Shared State + +When features require shared state: + +1. Add fields to `globals` struct in [FS_testsuite.go](FS_testsuite.go) +2. Initialize in early test cases (like `FS-0000-INIT-*`) +3. Access in dependent test cases via the `g *globals` parameter + +## Code Style Guidelines + +### Go Test Code + +- **Package**: Always use `package featuresets` +- **Imports**: Import proto packages as `pb "github.com/tuihub/protos/pkg/..."` +- **Error handling**: Always check and wrap errors with context +- **Formatting**: Use `gofmt` before committing +- **Comments**: Document complex test logic + +### Markdown Specifications + +- **Headers**: Use `##` for feature definitions +- **Keywords**: Use RFC 2119 keywords (MUST, SHOULD, MAY) in **bold** +- **Code**: Use backticks for technical terms and code +- **Lists**: Use `-` for bullet points +- **Examples**: Include examples when helpful + +## Common Patterns + +### Authentication Setup + +```go +registerTestCase("FS-XXXX-SCOPE-AUTH_REQUIRED", must, func(ctx context.Context, g *globals) error { + ctx = withBearerToken(ctx, g.AccessToken) + // Make authenticated call + resp, err := g.SephirahClient.SomeMethod(ctx, req) + // ... +}, withDependOnIDs("FS-0001-AUTH-TOKEN_STRUCTURE")) +``` + +### Checking Response Structure + +```go +if resp.Field == "" { + return fmt.Errorf("expected field to be non-empty") +} +if len(resp.Items) == 0 { + return fmt.Errorf("expected at least one item") +} +``` + +### Testing Error Cases + +```go +_, err := g.SephirahClient.SomeMethod(ctx, invalidReq) +if err == nil { + return fmt.Errorf("expected error for invalid request") +} +// Optionally check error type/message +``` + +## Integration with CI/CD + +The test suite can be integrated into CI pipelines: + +```bash +# In CI environment +cd docs/feature_sets +go test -v -timeout 5m +``` + +Set up test environment variables: +- `SEPHIRAH_HOST`: Server hostname (default: localhost) +- `SEPHIRAH_PORT`: Server port (default: varies) + +## Troubleshooting + +### Test case ID format error + +``` +Test case ID FS-001-AUTH format is invalid +``` +**Fix**: Ensure ID uses 4-digit zero-padded numbers: `FS-0001-AUTH` + +### Circular dependency detected + +``` +Error: circular dependency in test cases +``` +**Fix**: Review `withDependOnIDs()` calls and remove circular references + +### Test hangs + +**Possible causes**: +- Server not responding +- Network timeout +- Test logic has infinite loop + +**Fix**: +- Verify server is running and accessible +- Add timeout to context +- Review test implementation + +### Compilation errors + +``` +undefined: pb.SomeMethod +``` +**Fix**: +- Ensure proto files are generated: `cd ../.. && make generate` +- Check import paths in test file +- Verify proto definition exists + +## Related Documentation + +- [FS_overview.md](FS_overview.md): Writing guide overview +- [Main AGENTS.md](../../AGENTS.md): Root project documentation +- [Proto definitions](../../proto/librarian/): Service proto files +- Test suite implementation: [FS_testsuite.go](FS_testsuite.go) + +## Contributing + +When contributing Feature Sets: + +1. Follow the naming conventions strictly +2. Write clear, testable specifications +3. Implement comprehensive test coverage +4. Test locally before submitting PR +5. Update this guide if adding new patterns + +## Questions? + +- Check existing Feature Sets for examples: [FS-0001-AUTH.md](FS-0001-AUTH.md), [FS-0002-USER.md](FS-0002-USER.md) +- Review [FS_testsuite.go](FS_testsuite.go) for framework details +- Refer to RFC 2119 for keyword definitions: https://www.ietf.org/rfc/rfc2119.txt diff --git a/docs/feature_sets/FS-0000-INIT.go b/docs/feature_sets/FS-0000-INIT.go new file mode 100644 index 00000000..785a872c --- /dev/null +++ b/docs/feature_sets/FS-0000-INIT.go @@ -0,0 +1,33 @@ +package featuresets + +import ( + "context" + "fmt" + + "github.com/go-kratos/kratos/v2/middleware/recovery" + "github.com/go-kratos/kratos/v2/transport/grpc" + pb "github.com/tuihub/protos/pkg/librarian/sephirah/v1" +) + +func init() { + registerTestCase("FS-0000-INIT-SEPHIRAH_CLIENT", must, func(ctx context.Context, g *globals) error { + conn, err := grpc.DialInsecure( + context.Background(), + grpc.WithEndpoint(fmt.Sprintf("%s:%d", g.SephirahServerHost, g.SephirahServerPort)), + grpc.WithMiddleware( + recovery.Recovery(), + ), + ) + if err != nil { + return err + } + cli := pb.NewLibrarianSephirahServiceClient(conn) + g.SephirahClient = cli + resp, err := cli.GetServerInformation(ctx, &pb.GetServerInformationRequest{}) + if err != nil { + return err + } + g.SephirahServerInformation = resp.GetServerInformation() + return nil + }) +} diff --git a/docs/feature_sets/FS-0000-INIT.md b/docs/feature_sets/FS-0000-INIT.md new file mode 100644 index 00000000..710e3be8 --- /dev/null +++ b/docs/feature_sets/FS-0000-INIT.md @@ -0,0 +1,12 @@ +--- +id: FS-0000-INIT +title: Initialize +version: 0.0.1 +status: stable +created: 2026-02-02 +last_updated: 2026-02-02 +--- + +## FS-0000-INIT-SEPHIRAH_CLIENT + +The system MUST provide an available gRPC service for `LibrarianSephirahService`. The service MUST allow anonymous calls to the `GetServerInformation` RPC. A successful call MUST return server information in the response. diff --git a/docs/feature_sets/FS-0001-AUTH.go b/docs/feature_sets/FS-0001-AUTH.go new file mode 100644 index 00000000..a83d9ff5 --- /dev/null +++ b/docs/feature_sets/FS-0001-AUTH.go @@ -0,0 +1,253 @@ +package featuresets + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + pb "github.com/tuihub/protos/pkg/librarian/sephirah/v1" + "google.golang.org/grpc/metadata" +) + +// AuthState holds state for FS-0001-AUTH test cases +type AuthState struct { + AccessToken string + RefreshToken string +} + +func getAuthState(g *globals) *AuthState { + if state, ok := g.State["fs0001_auth"]; ok { + return state.(*AuthState) + } + state := &AuthState{} + g.State["fs0001_auth"] = state + return state +} + +func init() { + registerTestCase("FS-0001-AUTH-ADMIN_ACCOUNT", must, func(ctx context.Context, g *globals) error { + state := getAuthState(g) + + // Verify admin account exists with username "admin" and password "admin" + resp, err := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: "admin", + Password: "admin", + }) + if err != nil { + return fmt.Errorf("admin account login failed (username: admin, password: admin): %w", err) + } + if resp.AccessToken == "" { + return fmt.Errorf("admin login returned empty access_token") + } + if resp.RefreshToken == "" { + return fmt.Errorf("admin login returned empty refresh_token") + } + + // Verify the access_token is valid + _, err = g.SephirahClient.GetServerInformation(withBearerToken(ctx, resp.AccessToken), &pb.GetServerInformationRequest{}) + if err != nil { + return fmt.Errorf("failed to verify access_token with GetServerInformation: %w", err) + } + + // Store admin tokens for subsequent tests + state.AccessToken = resp.AccessToken + state.RefreshToken = resp.RefreshToken + + return nil + }, withDependOnIDs("FS-0000-INIT-SEPHIRAH_CLIENT")) + + registerTestCase("FS-0001-AUTH-TOKEN_STRUCTURE", must, func(ctx context.Context, g *globals) error { + state := getAuthState(g) + + // Verify both access_token and refresh_token are present from ADMIN_ACCOUNT + if state.AccessToken == "" { + return fmt.Errorf("access_token is empty") + } + if state.RefreshToken == "" { + return fmt.Errorf("refresh_token is empty") + } + + // This test only validates structure, does not perform refresh + // (to avoid state pollution - refresh is tested in TOKEN_REFRESH) + return nil + }, withDependOnIDs("FS-0001-AUTH-ADMIN_ACCOUNT")) + + registerTestCase("FS-0001-AUTH-GRPC_AUTHENTICATION", must, func(ctx context.Context, g *globals) error { + state := getAuthState(g) + + // Test access_token authentication with GetServerInformation + resp, err := g.SephirahClient.GetServerInformation(withBearerToken(ctx, state.AccessToken), &pb.GetServerInformationRequest{}) + if err != nil { + return fmt.Errorf("GetServerInformation with access_token failed: %w", err) + } + if resp.ServerInformation == nil { + return fmt.Errorf("GetServerInformation response server_information is nil") + } + + // Test refresh_token authentication + _, err = g.SephirahClient.RefreshToken(withBearerToken(ctx, state.RefreshToken), &pb.RefreshTokenRequest{}) + if err != nil { + return fmt.Errorf("RefreshToken with refresh_token failed: %w", err) + } + return nil + }, withDependOnIDs("FS-0001-AUTH-TOKEN_STRUCTURE")) + + registerTestCase("FS-0001-AUTH-TOKEN_REFRESH", must, func(ctx context.Context, g *globals) error { + // Self-contained: get a fresh token pair to avoid polluting other tests + loginResp, err := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: "admin", + Password: "admin", + }) + if err != nil { + return fmt.Errorf("GetToken failed: %w", err) + } + + oldAccessToken := loginResp.AccessToken + oldRefreshToken := loginResp.RefreshToken + + // Perform refresh + refreshResp, err := g.SephirahClient.RefreshToken(withBearerToken(ctx, oldRefreshToken), &pb.RefreshTokenRequest{}) + if err != nil { + return fmt.Errorf("RefreshToken failed: %w", err) + } + + // Verify new tokens are returned + if refreshResp.AccessToken == "" { + return fmt.Errorf("new access_token is empty") + } + if refreshResp.RefreshToken == "" { + return fmt.Errorf("new refresh_token is empty") + } + + // Verify tokens are different + if refreshResp.AccessToken == oldAccessToken { + return fmt.Errorf("new access_token is same as old one") + } + if refreshResp.RefreshToken == oldRefreshToken { + return fmt.Errorf("new refresh_token is same as old one") + } + + // Verify old refresh_token is invalidated + _, err = g.SephirahClient.RefreshToken(withBearerToken(ctx, oldRefreshToken), &pb.RefreshTokenRequest{}) + if err == nil { + return fmt.Errorf("RefreshToken with used refresh_token should fail") + } + + return nil + }, withDependOnIDs("FS-0001-AUTH-TOKEN_STRUCTURE")) + + registerTestCase("FS-0001-AUTH-TOKEN_EXPIRATION", should, func(ctx context.Context, g *globals) error { + state := getAuthState(g) + + if !isValidJWT(state.AccessToken) || !isValidJWT(state.RefreshToken) { + return fmt.Errorf("tokens are not in JWT format, cannot verify expiration") + } + + // Check access_token expiration + exp, err := extractExpirationFromToken(state.AccessToken) + if err != nil { + return fmt.Errorf("failed to extract expiration from access_token: %w", err) + } + maxExpiration := time.Now().Add(1 * time.Hour) + if exp.After(maxExpiration) { + return fmt.Errorf("access_token expiration %v exceeds 1 hour", exp) + } + + // Check refresh_token expiration + exp, err = extractExpirationFromToken(state.RefreshToken) + if err != nil { + return fmt.Errorf("failed to extract expiration from refresh_token: %w", err) + } + maxExpiration = time.Now().Add(7 * 24 * time.Hour) + if exp.After(maxExpiration) { + return fmt.Errorf("refresh_token expiration %v exceeds 7 days", exp) + } + return nil + }, withDependOnIDs("FS-0001-AUTH-TOKEN_STRUCTURE")) + + registerTestCase("FS-0001-AUTH-TOKEN_FORMAT", may, func(ctx context.Context, g *globals) error { + state := getAuthState(g) + + if !isValidJWT(state.AccessToken) { + return fmt.Errorf("access_token is not a valid JWT") + } + if !isValidJWT(state.RefreshToken) { + return fmt.Errorf("refresh_token is not a valid JWT") + } + + payload, err := extractPayloadFromToken(state.AccessToken) + if err != nil { + return fmt.Errorf("failed to extract payload from access_token: %w", err) + } + if payload.Exp == 0 { + return fmt.Errorf("access_token does not have expiration claim") + } + + payload, err = extractPayloadFromToken(state.RefreshToken) + if err != nil { + return fmt.Errorf("failed to extract payload from refresh_token: %w", err) + } + if payload.Exp == 0 { + return fmt.Errorf("refresh_token does not have expiration claim") + } + return nil + }, withDependOnIDs("FS-0001-AUTH-TOKEN_STRUCTURE")) +} + +func withBearerToken(ctx context.Context, token string) context.Context { + return metadata.AppendToOutgoingContext(ctx, "authorization", fmt.Sprintf("Bearer %s", token)) +} + +type jwtPayload struct { + Exp int64 `json:"exp"` +} + +func extractPayloadFromToken(token string) (*jwtPayload, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("token is not a valid JWT") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + + var jwtPayload jwtPayload + if err := json.Unmarshal(payload, &jwtPayload); err != nil { + return nil, fmt.Errorf("failed to unmarshal payload: %w", err) + } + + return &jwtPayload, nil +} + +func extractExpirationFromToken(token string) (time.Time, error) { + payload, err := extractPayloadFromToken(token) + if err != nil { + return time.Time{}, err + } + + if payload.Exp == 0 { + return time.Time{}, fmt.Errorf("token does not have expiration claim") + } + + return time.Unix(payload.Exp, 0), nil +} + +func isValidJWT(token string) bool { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return false + } + + for _, part := range parts { + if _, err := base64.RawURLEncoding.DecodeString(part); err != nil { + return false + } + } + + return true +} diff --git a/docs/feature_sets/FS-0001-AUTH.md b/docs/feature_sets/FS-0001-AUTH.md new file mode 100644 index 00000000..6fa0e0fd --- /dev/null +++ b/docs/feature_sets/FS-0001-AUTH.md @@ -0,0 +1,34 @@ +--- +id: FS-0001-AUTH +title: User token validation behavior specification +version: 0.0.1 +status: draft +created: 2026-01-08 +last_updated: 2026-02-03 +--- + +## FS-0001-AUTH-ADMIN_ACCOUNT + +The system MUST provide a pre-configured administrator account with `username="admin"` and `password="admin"`. The account MUST be able to obtain valid access tokens through `GetToken` RPC. + +## FS-0001-AUTH-TOKEN_STRUCTURE + +`GetTokenResponse` and `RefreshTokenResponse` MUST contain both `access_token` and `refresh_token` fields with non-empty values. + +## FS-0001-AUTH-GRPC_AUTHENTICATION + +Authenticated gRPC requests MUST include `authorization` metadata field with value `Bearer `. The `RefreshToken` RPC MUST accept `authorization` metadata field with value `Bearer `. + +## FS-0001-AUTH-TOKEN_REFRESH + +`RefreshTokenRequest` with valid `refresh_token` in `authorization` metadata MUST return new `access_token` and `refresh_token` in `RefreshTokenResponse`. The previous `refresh_token` MUST be invalidated after successful refresh. Reusing an invalidated `refresh_token` MUST fail. + +## FS-0001-AUTH-TOKEN_EXPIRATION + +`access_token` expiration time SHOULD NOT exceed 1 hour. + +`refresh_token` expiration time SHOULD NOT exceed 7 days. + +## FS-0001-AUTH-TOKEN_FORMAT + +`access_token` and `refresh_token` MAY use JWT format. JWT payload SHOULD include `exp` claim for expiration time. diff --git a/docs/feature_sets/FS-0002-USER.go b/docs/feature_sets/FS-0002-USER.go new file mode 100644 index 00000000..d712d026 --- /dev/null +++ b/docs/feature_sets/FS-0002-USER.go @@ -0,0 +1,419 @@ +package featuresets + +import ( + "context" + "fmt" + "math/rand" + "time" + + pb "github.com/tuihub/protos/pkg/librarian/sephirah/v1" + v1 "github.com/tuihub/protos/pkg/librarian/v1" +) + +// UserState holds state for FS-0002-USER test cases +type UserState struct { + NormalUsername string + NormalPassword string + NormalUserID *v1.InternalID + NormalAccessToken string + AdminUserID *v1.InternalID +} + +func getUserState(g *globals) *UserState { + if state, ok := g.State["fs0002_user"]; ok { + return state.(*UserState) + } + state := &UserState{} + g.State["fs0002_user"] = state + return state +} + +func init() { + // FS-0002-USER-REGISTRATION_AVAILABILITY + registerTestCase("FS-0002-USER-REGISTRATION_AVAILABILITY", should, func(ctx context.Context, g *globals) error { + state := getUserState(g) + + // Generate random username and password + rand.Seed(time.Now().UnixNano()) + state.NormalUsername = fmt.Sprintf("testuser_%d", rand.Int63()) + state.NormalPassword = fmt.Sprintf("testpass_%d", rand.Int63()) + + resp, err := g.SephirahClient.RegisterUser(ctx, &pb.RegisterUserRequest{ + Username: state.NormalUsername, + Password: state.NormalPassword, + }) + if err != nil { + return fmt.Errorf("RegisterUser failed: %w", err) + } + + // Check if captcha is unset (should not be required in test mode) + if resp.GetCaptcha() != nil { + return fmt.Errorf("captcha verification is enabled, but should be disabled for testing") + } + + // Check if registration was successful + if resp.GetRefreshToken() == "" { + return fmt.Errorf("registration did not return refresh_token") + } + + return nil + }, withDependOnIDs("FS-0000-INIT-SEPHIRAH_CLIENT")) + + // FS-0002-USER-ADMIN_ACCOUNT_TYPE + registerTestCase("FS-0002-USER-ADMIN_ACCOUNT_TYPE", must, func(ctx context.Context, g *globals) error { + state := getUserState(g) + authState := getAuthState(g) + + // Verify admin account has USER_TYPE_ADMIN + adminResp, err := g.SephirahClient.GetUser(withBearerToken(ctx, authState.AccessToken), &pb.GetUserRequest{}) + if err != nil { + return fmt.Errorf("GetUser for admin failed: %w", err) + } + if adminResp.User == nil { + return fmt.Errorf("GetUser returned nil user for admin") + } + if adminResp.User.Type != pb.UserType_USER_TYPE_ADMIN { + return fmt.Errorf("admin account type is %v, expected USER_TYPE_ADMIN", adminResp.User.Type) + } + + // Store admin user ID for other tests + state.AdminUserID = adminResp.User.Id + + return nil + }, withDependOnIDs("FS-0001-AUTH-ADMIN_ACCOUNT")) + + // FS-0002-USER-REGISTRATION_USER_TYPE (merged IMMEDIATE_LOGIN verification) + registerTestCase("FS-0002-USER-REGISTRATION_USER_TYPE", must, func(ctx context.Context, g *globals) error { + state := getUserState(g) + + // Get token for the newly registered user (tests immediate login) + tokenResp, err := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: state.NormalUsername, + Password: state.NormalPassword, + }) + if err != nil { + return fmt.Errorf("GetToken failed for registered user (immediate login should work): %w", err) + } + state.NormalAccessToken = tokenResp.AccessToken + + // Get user info to verify user type + userResp, err := g.SephirahClient.GetUser(withBearerToken(ctx, state.NormalAccessToken), &pb.GetUserRequest{}) + if err != nil { + return fmt.Errorf("GetUser failed: %w", err) + } + + if userResp.User == nil { + return fmt.Errorf("GetUser returned nil user") + } + + state.NormalUserID = userResp.User.Id + + if userResp.User.Type != pb.UserType_USER_TYPE_NORMAL { + return fmt.Errorf("registered user type is %v, expected USER_TYPE_NORMAL", userResp.User.Type) + } + + // Verify username matches + if userResp.User.Username != state.NormalUsername { + return fmt.Errorf("username mismatch: got %s, expected %s", userResp.User.Username, state.NormalUsername) + } + + return nil + }, withDependOnIDs("FS-0002-USER-REGISTRATION_AVAILABILITY")) + + // FS-0002-USER-GET_USER_INFO + registerTestCase("FS-0002-USER-GET_USER_INFO", must, func(ctx context.Context, g *globals) error { + state := getUserState(g) + authState := getAuthState(g) + + // Test 1: Get self user info (empty id) + selfResp, err := g.SephirahClient.GetUser(withBearerToken(ctx, state.NormalAccessToken), &pb.GetUserRequest{}) + if err != nil { + return fmt.Errorf("GetUser with empty id failed: %w", err) + } + if selfResp.User == nil { + return fmt.Errorf("GetUser returned nil user") + } + if selfResp.User.Username != state.NormalUsername { + return fmt.Errorf("GetUser with empty id returned wrong user") + } + + // Test 2: Get admin user info (with specific id) + adminResp, err := g.SephirahClient.GetUser(withBearerToken(ctx, authState.AccessToken), &pb.GetUserRequest{}) + if err != nil { + return fmt.Errorf("GetUser for admin failed: %w", err) + } + if adminResp.User == nil { + return fmt.Errorf("GetUser returned nil admin user") + } + + // Test 3: Normal user can get another user's info + otherUserResp, err := g.SephirahClient.GetUser(withBearerToken(ctx, state.NormalAccessToken), &pb.GetUserRequest{ + Id: state.AdminUserID, + }) + if err != nil { + return fmt.Errorf("GetUser for other user failed: %w", err) + } + if otherUserResp.User == nil { + return fmt.Errorf("GetUser returned nil for other user") + } + + return nil + }, withDependOnIDs("FS-0002-USER-REGISTRATION_USER_TYPE", "FS-0002-USER-ADMIN_ACCOUNT_TYPE")) + + // FS-0002-USER-PASSWORD_PRIVACY + registerTestCase("FS-0002-USER-PASSWORD_PRIVACY", must, func(ctx context.Context, g *globals) error { + state := getUserState(g) + authState := getAuthState(g) + + // Test 1: Self query returns empty password + selfResp, err := g.SephirahClient.GetUser(withBearerToken(ctx, state.NormalAccessToken), &pb.GetUserRequest{}) + if err != nil { + return fmt.Errorf("GetUser failed: %w", err) + } + if selfResp.User.Password != "" { + return fmt.Errorf("GetUser returned non-empty password for self query: %s", selfResp.User.Password) + } + + // Test 2: Admin querying other user returns empty password + userResp, err := g.SephirahClient.GetUser(withBearerToken(ctx, authState.AccessToken), &pb.GetUserRequest{ + Id: state.NormalUserID, + }) + if err != nil { + return fmt.Errorf("GetUser by admin failed: %w", err) + } + if userResp.User.Password != "" { + return fmt.Errorf("GetUser by admin returned non-empty password: %s", userResp.User.Password) + } + + return nil + }, withDependOnIDs("FS-0002-USER-GET_USER_INFO")) + + // FS-0002-USER-SELF_UPDATE_PERMISSION + registerTestCase("FS-0002-USER-SELF_UPDATE_PERMISSION", must, func(ctx context.Context, g *globals) error { + state := getUserState(g) + authState := getAuthState(g) + + // Test 1: Normal user can update their own username + newUsername := state.NormalUsername + "_updated" + _, err := g.SephirahClient.UpdateUser(withBearerToken(ctx, state.NormalAccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.NormalUserID, + Username: newUsername, + Type: pb.UserType_USER_TYPE_NORMAL, + Status: pb.UserStatus_USER_STATUS_ACTIVE, + }, + }) + if err != nil { + return fmt.Errorf("UpdateUser for self failed: %w", err) + } + + // Verify the update + verifyResp, err := g.SephirahClient.GetUser(withBearerToken(ctx, state.NormalAccessToken), &pb.GetUserRequest{}) + if err != nil { + return fmt.Errorf("GetUser after update failed: %w", err) + } + if verifyResp.User.Username != newUsername { + return fmt.Errorf("username not updated: got %s, expected %s", verifyResp.User.Username, newUsername) + } + state.NormalUsername = newUsername + + // Test 2: Normal user cannot update other user's info + _, err = g.SephirahClient.UpdateUser(withBearerToken(ctx, state.NormalAccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.AdminUserID, + Username: "admin_hacked", + Type: pb.UserType_USER_TYPE_ADMIN, + Status: pb.UserStatus_USER_STATUS_ACTIVE, + }, + }) + if err == nil { + return fmt.Errorf("normal user should not be able to update other user's info") + } + + // Test 3: Admin can update other user's info (not password) + anotherNewUsername := state.NormalUsername + "_by_admin" + _, err = g.SephirahClient.UpdateUser(withBearerToken(ctx, authState.AccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.NormalUserID, + Username: anotherNewUsername, + Type: pb.UserType_USER_TYPE_NORMAL, + Status: pb.UserStatus_USER_STATUS_ACTIVE, + }, + }) + if err != nil { + return fmt.Errorf("admin UpdateUser for other user failed: %w", err) + } + + // Verify admin's update + verifyResp2, err := g.SephirahClient.GetUser(withBearerToken(ctx, state.NormalAccessToken), &pb.GetUserRequest{}) + if err != nil { + return fmt.Errorf("GetUser after admin update failed: %w", err) + } + if verifyResp2.User.Username != anotherNewUsername { + return fmt.Errorf("admin update not applied: got %s, expected %s", verifyResp2.User.Username, anotherNewUsername) + } + state.NormalUsername = anotherNewUsername + + return nil + }, withDependOnIDs("FS-0002-USER-PASSWORD_PRIVACY")) + + // FS-0002-USER-PASSWORD_UPDATE_REQUIREMENT + registerTestCase("FS-0002-USER-PASSWORD_UPDATE_REQUIREMENT", must, func(ctx context.Context, g *globals) error { + state := getUserState(g) + + // Test 1: Update password without providing old password should fail + newPassword := state.NormalPassword + "_new" + _, err := g.SephirahClient.UpdateUser(withBearerToken(ctx, state.NormalAccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.NormalUserID, + Username: state.NormalUsername, + Password: newPassword, + Type: pb.UserType_USER_TYPE_NORMAL, + Status: pb.UserStatus_USER_STATUS_ACTIVE, + }, + }) + if err == nil { + return fmt.Errorf("password update without old password should fail") + } + + // Test 2: Update password with wrong old password should fail + wrongPassword := "wrong_password" + _, err = g.SephirahClient.UpdateUser(withBearerToken(ctx, state.NormalAccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.NormalUserID, + Username: state.NormalUsername, + Password: newPassword, + Type: pb.UserType_USER_TYPE_NORMAL, + Status: pb.UserStatus_USER_STATUS_ACTIVE, + }, + Password: &wrongPassword, + }) + if err == nil { + return fmt.Errorf("password update with wrong old password should fail") + } + + // Test 3: Update password with correct old password should succeed + _, err = g.SephirahClient.UpdateUser(withBearerToken(ctx, state.NormalAccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.NormalUserID, + Username: state.NormalUsername, + Password: newPassword, + Type: pb.UserType_USER_TYPE_NORMAL, + Status: pb.UserStatus_USER_STATUS_ACTIVE, + }, + Password: &state.NormalPassword, + }) + if err != nil { + return fmt.Errorf("password update with correct old password failed: %w", err) + } + + // Verify the new password works + tokenResp, err := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: state.NormalUsername, + Password: newPassword, + }) + if err != nil { + return fmt.Errorf("GetToken with new password failed: %w", err) + } + if tokenResp.AccessToken == "" { + return fmt.Errorf("GetToken with new password returned empty token") + } + state.NormalPassword = newPassword + state.NormalAccessToken = tokenResp.AccessToken + + return nil + }, withDependOnIDs("FS-0002-USER-SELF_UPDATE_PERMISSION")) + + // FS-0002-USER-TYPE_STATUS_UPDATE_RESTRICTION + registerTestCase("FS-0002-USER-TYPE_STATUS_UPDATE_RESTRICTION", must, func(ctx context.Context, g *globals) error { + state := getUserState(g) + authState := getAuthState(g) + + // Test 1: Normal user cannot change their own type + _, err := g.SephirahClient.UpdateUser(withBearerToken(ctx, state.NormalAccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.NormalUserID, + Username: state.NormalUsername, + Type: pb.UserType_USER_TYPE_ADMIN, // Try to become admin + Status: pb.UserStatus_USER_STATUS_ACTIVE, + }, + }) + if err == nil { + return fmt.Errorf("normal user should not be able to change their own type") + } + + // Test 2: Normal user cannot change their own status + _, err = g.SephirahClient.UpdateUser(withBearerToken(ctx, state.NormalAccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.NormalUserID, + Username: state.NormalUsername, + Type: pb.UserType_USER_TYPE_NORMAL, + Status: pb.UserStatus_USER_STATUS_BLOCKED, // Try to change status + }, + }) + if err == nil { + return fmt.Errorf("normal user should not be able to change their own status") + } + + // Test 3: Admin can change user type + _, err = g.SephirahClient.UpdateUser(withBearerToken(ctx, authState.AccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.NormalUserID, + Username: state.NormalUsername, + Type: pb.UserType_USER_TYPE_ADMIN, // Promote to admin + Status: pb.UserStatus_USER_STATUS_ACTIVE, + }, + }) + if err != nil { + return fmt.Errorf("admin should be able to change user type: %w", err) + } + + // Verify type change + verifyResp, err := g.SephirahClient.GetUser(withBearerToken(ctx, state.NormalAccessToken), &pb.GetUserRequest{}) + if err != nil { + return fmt.Errorf("GetUser after type change failed: %w", err) + } + if verifyResp.User.Type != pb.UserType_USER_TYPE_ADMIN { + return fmt.Errorf("user type not changed: got %v, expected USER_TYPE_ADMIN", verifyResp.User.Type) + } + + // Test 4: Admin can change user status + _, err = g.SephirahClient.UpdateUser(withBearerToken(ctx, authState.AccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.NormalUserID, + Username: state.NormalUsername, + Type: pb.UserType_USER_TYPE_ADMIN, + Status: pb.UserStatus_USER_STATUS_BLOCKED, // Block the user + }, + }) + if err != nil { + return fmt.Errorf("admin should be able to change user status: %w", err) + } + + // Verify status change + verifyResp2, err := g.SephirahClient.GetUser(withBearerToken(ctx, authState.AccessToken), &pb.GetUserRequest{ + Id: state.NormalUserID, + }) + if err != nil { + return fmt.Errorf("GetUser after status change failed: %w", err) + } + if verifyResp2.User.Status != pb.UserStatus_USER_STATUS_BLOCKED { + return fmt.Errorf("user status not changed: got %v, expected USER_STATUS_BLOCKED", verifyResp2.User.Status) + } + + // Restore user to normal status for cleanup + _, err = g.SephirahClient.UpdateUser(withBearerToken(ctx, authState.AccessToken), &pb.UpdateUserRequest{ + User: &pb.User{ + Id: state.NormalUserID, + Username: state.NormalUsername, + Type: pb.UserType_USER_TYPE_NORMAL, + Status: pb.UserStatus_USER_STATUS_ACTIVE, + }, + }) + if err != nil { + return fmt.Errorf("failed to restore user to normal: %w", err) + } + + return nil + }, withDependOnIDs("FS-0002-USER-PASSWORD_UPDATE_REQUIREMENT")) +} diff --git a/docs/feature_sets/FS-0002-USER.md b/docs/feature_sets/FS-0002-USER.md new file mode 100644 index 00000000..2ae076dd --- /dev/null +++ b/docs/feature_sets/FS-0002-USER.md @@ -0,0 +1,40 @@ +--- +id: FS-0002-USER +title: User Management +version: 0.0.1 +status: draft +created: 2026-01-31 +last_updated: 2026-02-03 +--- + +## FS-0002-USER-REGISTRATION_AVAILABILITY + +`RegisterUser` RPC SHOULD be available for anonymous user registration. When enabled, `RegisterUserResponse.captcha` MUST be unset to allow automated testing. + +## FS-0002-USER-ADMIN_ACCOUNT_TYPE + +The pre-configured administrator account MUST have `User.type=USER_TYPE_ADMIN`. This can be verified through `GetUser` RPC with admin credentials. + +## FS-0002-USER-REGISTRATION_USER_TYPE + +`RegisterUser` RPC MUST create accounts with `User.type=USER_TYPE_NORMAL`. Self-registration with `User.type=USER_TYPE_ADMIN` MUST be rejected. `GetTokenRequest` with credentials from successful `RegisterUser` MUST succeed without additional activation steps (immediate login). + +## FS-0002-USER-GET_USER_INFO + +`GetUserRequest` with empty `id` field MUST return authenticated user's information. `GetUserRequest` with specified `id` field MUST return target user's information if requester has permission. + +## FS-0002-USER-PASSWORD_PRIVACY + +`GetUserResponse.user.password` field MUST be empty string in all cases, regardless of whether `GetUserRequest` queries self or other users. + +## FS-0002-USER-SELF_UPDATE_PERMISSION + +`UpdateUserRequest` MUST succeed when `User.id` matches authenticated user's ID and requester has `USER_TYPE_NORMAL`. `UpdateUserRequest` MUST fail when `User.id` differs from authenticated user's ID and requester has `USER_TYPE_NORMAL`. `UpdateUserRequest` with any `User.id` MUST succeed when requester has `USER_TYPE_ADMIN`, except for password updates. + +## FS-0002-USER-PASSWORD_UPDATE_REQUIREMENT + +`UpdateUserRequest` with non-empty `User.password` field MUST include correct current password in `UpdateUserRequest.password` field. `UpdateUserRequest` with non-empty `User.password` and missing or incorrect `UpdateUserRequest.password` MUST fail. + +## FS-0002-USER-TYPE_STATUS_UPDATE_RESTRICTION + +`UpdateUserRequest` modifying `User.type` or `User.status` fields MUST fail when requester has `USER_TYPE_NORMAL`. `UpdateUserRequest` modifying `User.type` or `User.status` fields MUST succeed when requester has `USER_TYPE_ADMIN`. diff --git a/docs/feature_sets/FS-0003-SESSION.go b/docs/feature_sets/FS-0003-SESSION.go new file mode 100644 index 00000000..8ab5afa8 --- /dev/null +++ b/docs/feature_sets/FS-0003-SESSION.go @@ -0,0 +1,872 @@ +package featuresets + +import ( + "context" + "fmt" + "time" + + pb "github.com/tuihub/protos/pkg/librarian/sephirah/v1" + v1 "github.com/tuihub/protos/pkg/librarian/v1" +) + +// SessionState holds state for FS-0003-SESSION test cases +type SessionState struct { + // Session tracking + InitialSessionID *v1.InternalID + InitialRefreshToken string + InitialAccessToken string + SecondSessionID *v1.InternalID + SecondRefreshToken string + SecondAccessToken string + // Device tracking + Device1ID *v1.InternalID + Device1LocalID string + Device1SessionID *v1.InternalID + Device1RefreshToken string + Device1AccessToken string + Device2ID *v1.InternalID + Device2LocalID string + // Multi-user tracking + NormalUserDevice1RefreshToken string + NormalUserDevice1AccessToken string +} + +func getSessionState(g *globals) *SessionState { + if state, ok := g.State["fs0003_session"]; ok { + return state.(*SessionState) + } + state := &SessionState{} + g.State["fs0003_session"] = state + return state +} + +func init() { + // FS-0003-SESSION-AUTO_CREATION + registerTestCase("FS-0003-SESSION-AUTO_CREATION", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + + // Login to create a session + resp, err := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: "admin", + Password: "admin", + }) + if err != nil { + return fmt.Errorf("GetToken failed: %w", err) + } + + // List sessions to verify auto-creation + listResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, resp.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions failed: %w", err) + } + + if len(listResp.Sessions) == 0 { + return fmt.Errorf("expected at least one session after login, got 0") + } + + // Find the most recent session + var mostRecentSession *pb.UserSession + for _, session := range listResp.Sessions { + if mostRecentSession == nil || session.CreateTime.AsTime().After(mostRecentSession.CreateTime.AsTime()) { + mostRecentSession = session + } + } + + if mostRecentSession == nil { + return fmt.Errorf("no sessions found after login") + } + + // Store session info for later tests + state.InitialSessionID = mostRecentSession.Id + state.InitialRefreshToken = resp.RefreshToken + state.InitialAccessToken = resp.AccessToken + + return nil + }, withDependOnIDs("FS-0001-AUTH-ADMIN_ACCOUNT")) + + // FS-0003-SESSION-LOGIN_NEW_ID + registerTestCase("FS-0003-SESSION-LOGIN_NEW_ID", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + + // Login again to create a new session + resp, err := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: "admin", + Password: "admin", + }) + if err != nil { + return fmt.Errorf("GetToken failed: %w", err) + } + + // List sessions to get the new session + listResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, resp.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions failed: %w", err) + } + + // Find the most recent session + var newSessionID *v1.InternalID + var mostRecentTime time.Time + for _, session := range listResp.Sessions { + if session.CreateTime.AsTime().After(mostRecentTime) { + mostRecentTime = session.CreateTime.AsTime() + newSessionID = session.Id + } + } + + if newSessionID == nil { + return fmt.Errorf("no new session found after second login") + } + + // Verify it's a different session ID + if newSessionID.Id == state.InitialSessionID.Id { + return fmt.Errorf("expected new session_id after login, got same session_id: %d", newSessionID.Id) + } + + // Store for next test + state.SecondSessionID = newSessionID + state.SecondRefreshToken = resp.RefreshToken + state.SecondAccessToken = resp.AccessToken + + return nil + }, withDependOnIDs("FS-0003-SESSION-AUTO_CREATION")) + + // FS-0003-SESSION-ONE_VALID_TOKEN (merged REFRESH_REUSE_ID) + // Tests: refresh reuses session_id, session persists (create_time unchanged), + // session count doesn't increase, and old refresh_token is invalidated + registerTestCase("FS-0003-SESSION-ONE_VALID_TOKEN", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + + // Get the session before refresh + listBefore, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, state.SecondAccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions before refresh failed: %w", err) + } + + // Find current session + var currentSession *pb.UserSession + for _, session := range listBefore.Sessions { + if session.Id.Id == state.SecondSessionID.Id { + currentSession = session + break + } + } + if currentSession == nil { + return fmt.Errorf("current session not found in list") + } + + sessionCountBefore := len(listBefore.Sessions) + oldRefreshToken := state.SecondRefreshToken + + // Refresh token + refreshResp, err := g.SephirahClient.RefreshToken( + withBearerToken(ctx, state.SecondRefreshToken), + &pb.RefreshTokenRequest{}, + ) + if err != nil { + return fmt.Errorf("RefreshToken failed: %w", err) + } + + // Get session list after refresh + listAfter, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, refreshResp.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions after refresh failed: %w", err) + } + + // Verify session count hasn't increased (no new session created) + if len(listAfter.Sessions) != sessionCountBefore { + return fmt.Errorf("session count changed after refresh: before=%d, after=%d (expected same count)", + sessionCountBefore, len(listAfter.Sessions)) + } + + // Verify the same session still exists with same create_time (session_id reused) + var sessionAfter *pb.UserSession + for _, session := range listAfter.Sessions { + if session.Id.Id == state.SecondSessionID.Id { + sessionAfter = session + break + } + } + if sessionAfter == nil { + return fmt.Errorf("session disappeared after refresh (session_id not reused)") + } + if !sessionAfter.CreateTime.AsTime().Equal(currentSession.CreateTime.AsTime()) { + return fmt.Errorf("session create_time changed after refresh (new session was created instead of reusing)") + } + + // Verify old refresh_token is invalidated + _, err = g.SephirahClient.RefreshToken( + withBearerToken(ctx, oldRefreshToken), + &pb.RefreshTokenRequest{}, + ) + if err == nil { + return fmt.Errorf("expected old refresh_token to be invalidated, but it still works") + } + + // Update tokens for subsequent tests + state.SecondRefreshToken = refreshResp.RefreshToken + state.SecondAccessToken = refreshResp.AccessToken + + return nil + }, withDependOnIDs("FS-0003-SESSION-LOGIN_NEW_ID")) + + // FS-0003-SESSION-REVOKE_INVALIDATE + registerTestCase("FS-0003-SESSION-REVOKE_INVALIDATE", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + + // Store the current refresh token + tokenBeforeRevoke := state.SecondRefreshToken + + // Revoke the session + _, err := g.SephirahClient.RevokeUserSession( + withBearerToken(ctx, state.SecondAccessToken), + &pb.RevokeUserSessionRequest{ + SessionId: state.SecondSessionID, + }, + ) + if err != nil { + return fmt.Errorf("RevokeUserSession failed: %w", err) + } + + // Try to use the refresh token (should fail immediately) + _, err = g.SephirahClient.RefreshToken( + withBearerToken(ctx, tokenBeforeRevoke), + &pb.RefreshTokenRequest{}, + ) + if err == nil { + return fmt.Errorf("expected refresh_token to be invalidated after session revoke, but it still works") + } + + return nil + }, withDependOnIDs("FS-0003-SESSION-ONE_VALID_TOKEN")) + + // FS-0003-SESSION-DEVICE_REGISTRATION + registerTestCase("FS-0003-SESSION-DEVICE_REGISTRATION", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + authState := getAuthState(g) + + // Register a device + resp, err := g.SephirahClient.RegisterDevice( + withBearerToken(ctx, authState.AccessToken), + &pb.RegisterDeviceRequest{ + DeviceInfo: &pb.Device{ + DeviceName: "Test Device 1", + SystemType: pb.SystemType_SYSTEM_TYPE_LINUX, + SystemVersion: "Ubuntu 22.04", + ClientName: "testsuite", + ClientVersion: "0.0.1", + }, + ClientLocalId: strPtr("test-device-local-id-1"), + }, + ) + if err != nil { + return fmt.Errorf("RegisterDevice failed: %w", err) + } + + if resp.DeviceId == nil || resp.DeviceId.Id == 0 { + return fmt.Errorf("RegisterDevice returned invalid device_id") + } + + // Store device ID for later tests + state.Device1ID = resp.DeviceId + state.Device1LocalID = "test-device-local-id-1" + + return nil + }, withDependOnIDs("FS-0001-AUTH-ADMIN_ACCOUNT")) + + // FS-0003-SESSION-DEVICE_IDEMPOTENCY + registerTestCase("FS-0003-SESSION-DEVICE_IDEMPOTENCY", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + authState := getAuthState(g) + + // Register device with same client_local_id (should return same device_id) + resp1, err := g.SephirahClient.RegisterDevice( + withBearerToken(ctx, authState.AccessToken), + &pb.RegisterDeviceRequest{ + DeviceInfo: &pb.Device{ + DeviceName: "Test Device 1 - Registered Again", + SystemType: pb.SystemType_SYSTEM_TYPE_LINUX, + SystemVersion: "Ubuntu 22.04", + ClientName: "testsuite", + ClientVersion: "0.0.1", + }, + ClientLocalId: strPtr(state.Device1LocalID), + }, + ) + if err != nil { + return fmt.Errorf("RegisterDevice with same client_local_id failed: %w", err) + } + + if resp1.DeviceId.Id != state.Device1ID.Id { + return fmt.Errorf("expected same device_id for same client_local_id, got %d, expected %d", + resp1.DeviceId.Id, state.Device1ID.Id) + } + + // Register device with different client_local_id (should return different device_id) + resp2, err := g.SephirahClient.RegisterDevice( + withBearerToken(ctx, authState.AccessToken), + &pb.RegisterDeviceRequest{ + DeviceInfo: &pb.Device{ + DeviceName: "Test Device 2", + SystemType: pb.SystemType_SYSTEM_TYPE_WINDOWS, + SystemVersion: "Windows 11", + ClientName: "testsuite", + ClientVersion: "0.0.1", + }, + ClientLocalId: strPtr("test-device-local-id-2"), + }, + ) + if err != nil { + return fmt.Errorf("RegisterDevice with different client_local_id failed: %w", err) + } + + if resp2.DeviceId.Id == state.Device1ID.Id { + return fmt.Errorf("expected different device_id for different client_local_id, got same: %d", resp2.DeviceId.Id) + } + + // Store second device + state.Device2ID = resp2.DeviceId + state.Device2LocalID = "test-device-local-id-2" + + // Register device without client_local_id (should return new device_id) + resp3, err := g.SephirahClient.RegisterDevice( + withBearerToken(ctx, authState.AccessToken), + &pb.RegisterDeviceRequest{ + DeviceInfo: &pb.Device{ + DeviceName: "Test Device 3", + SystemType: pb.SystemType_SYSTEM_TYPE_MACOS, + SystemVersion: "macOS 14", + ClientName: "testsuite", + ClientVersion: "0.0.1", + }, + }, + ) + if err != nil { + return fmt.Errorf("RegisterDevice without client_local_id failed: %w", err) + } + + if resp3.DeviceId.Id == state.Device1ID.Id || resp3.DeviceId.Id == state.Device2ID.Id { + return fmt.Errorf("expected new device_id without client_local_id, got duplicate: %d", resp3.DeviceId.Id) + } + + return nil + }, withDependOnIDs("FS-0003-SESSION-DEVICE_REGISTRATION")) + + // FS-0003-SESSION-DEVICE_BINDING_LOGIN + registerTestCase("FS-0003-SESSION-DEVICE_BINDING_LOGIN", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + + // Login with device_id + resp, err := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: "admin", + Password: "admin", + DeviceId: state.Device1ID, + }) + if err != nil { + return fmt.Errorf("GetToken with device_id failed: %w", err) + } + + // List sessions to verify device binding + listResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, resp.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions failed: %w", err) + } + + // Find the most recent session and verify device binding + var mostRecentSession *pb.UserSession + var mostRecentTime time.Time + for _, session := range listResp.Sessions { + if session.CreateTime.AsTime().After(mostRecentTime) { + mostRecentTime = session.CreateTime.AsTime() + mostRecentSession = session + } + } + + if mostRecentSession == nil { + return fmt.Errorf("no session found after login with device_id") + } + + if mostRecentSession.DeviceId == nil { + return fmt.Errorf("session is not bound to any device") + } + + if mostRecentSession.DeviceId.Id != state.Device1ID.Id { + return fmt.Errorf("session bound to wrong device, expected %d, got %d", + state.Device1ID.Id, mostRecentSession.DeviceId.Id) + } + + // Store for later tests + state.Device1SessionID = mostRecentSession.Id + state.Device1RefreshToken = resp.RefreshToken + state.Device1AccessToken = resp.AccessToken + + return nil + }, withDependOnIDs("FS-0003-SESSION-DEVICE_IDEMPOTENCY")) + + // FS-0003-SESSION-DEVICE_BINDING_REFRESH + registerTestCase("FS-0003-SESSION-DEVICE_BINDING_REFRESH", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + + // Create a session without device (login without device_id) + resp, err := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: "admin", + Password: "admin", + }) + if err != nil { + return fmt.Errorf("GetToken failed: %w", err) + } + + // Find the new session + listResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, resp.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions failed: %w", err) + } + + var newSessionID *v1.InternalID + var mostRecentTime time.Time + for _, session := range listResp.Sessions { + if session.CreateTime.AsTime().After(mostRecentTime) { + mostRecentTime = session.CreateTime.AsTime() + newSessionID = session.Id + } + } + + if newSessionID == nil { + return fmt.Errorf("no session found after login") + } + + // Refresh token with device_id to bind device + refreshResp, err := g.SephirahClient.RefreshToken( + withBearerToken(ctx, resp.RefreshToken), + &pb.RefreshTokenRequest{ + DeviceId: state.Device2ID, + }, + ) + if err != nil { + return fmt.Errorf("RefreshToken with device_id failed: %w", err) + } + + // Verify the session is now bound to the device + listResp2, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, refreshResp.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions after refresh failed: %w", err) + } + + var boundSession *pb.UserSession + for _, session := range listResp2.Sessions { + if session.Id.Id == newSessionID.Id { + boundSession = session + break + } + } + + if boundSession == nil { + return fmt.Errorf("session not found after refresh") + } + + if boundSession.DeviceId == nil { + return fmt.Errorf("session not bound to device after RefreshToken with device_id") + } + + if boundSession.DeviceId.Id != state.Device2ID.Id { + return fmt.Errorf("session bound to wrong device, expected %d, got %d", + state.Device2ID.Id, boundSession.DeviceId.Id) + } + + return nil + }, withDependOnIDs("FS-0003-SESSION-DEVICE_BINDING_LOGIN")) + + // FS-0003-SESSION-DEVICE_MULTI_USER + registerTestCase("FS-0003-SESSION-DEVICE_MULTI_USER", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + userState := getUserState(g) + + // First ensure we have a normal user + if userState.NormalUsername == "" { + return fmt.Errorf("normal user not registered, dependency test might have failed") + } + + // Check if admin's device1 token is still valid, re-login if needed + _, err := g.SephirahClient.GetServerInformation(withBearerToken(ctx, state.Device1AccessToken), &pb.GetServerInformationRequest{}) + if err != nil { + // Token invalid, re-login with device1 + loginResp, loginErr := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: "admin", + Password: "admin", + DeviceId: state.Device1ID, + }) + if loginErr != nil { + return fmt.Errorf("failed to re-login admin with device1: %w", loginErr) + } + state.Device1AccessToken = loginResp.AccessToken + state.Device1RefreshToken = loginResp.RefreshToken + } + + // Admin user login with device1 (already has a session from earlier test) + adminSessionCountBefore := 0 + listResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, state.Device1AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + DeviceIdFilter: []*v1.InternalID{state.Device1ID}, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions for admin failed: %w", err) + } + adminSessionCountBefore = len(listResp.Sessions) + + // Normal user login with device1 + normalResp, err := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: userState.NormalUsername, + Password: userState.NormalPassword, + DeviceId: state.Device1ID, + }) + if err != nil { + return fmt.Errorf("GetToken for normal user with device1 failed: %w", err) + } + + // Verify normal user has a session on device1 + normalListResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, normalResp.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + DeviceIdFilter: []*v1.InternalID{state.Device1ID}, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions for normal user failed: %w", err) + } + + if len(normalListResp.Sessions) == 0 { + return fmt.Errorf("normal user has no session on device1 after login") + } + + // Verify admin user still has their session on device1 + adminListResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, state.Device1AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + DeviceIdFilter: []*v1.InternalID{state.Device1ID}, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions for admin after normal user login failed: %w", err) + } + + if len(adminListResp.Sessions) < adminSessionCountBefore { + return fmt.Errorf("admin user sessions on device1 were affected by normal user login") + } + + // Store normal user session info + state.NormalUserDevice1RefreshToken = normalResp.RefreshToken + state.NormalUserDevice1AccessToken = normalResp.AccessToken + + return nil + }, withDependOnIDs("FS-0003-SESSION-DEVICE_BINDING_REFRESH", "FS-0002-USER-REGISTRATION_AVAILABILITY")) + + // FS-0003-SESSION-DEVICE_SINGLE_SESSION + registerTestCase("FS-0003-SESSION-DEVICE_SINGLE_SESSION", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + + // Check if admin's device1 token is still valid, re-login if needed + _, err := g.SephirahClient.GetServerInformation(withBearerToken(ctx, state.Device1AccessToken), &pb.GetServerInformationRequest{}) + if err != nil { + // Token invalid, re-login with device1 + loginResp, loginErr := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: "admin", + Password: "admin", + DeviceId: state.Device1ID, + }) + if loginErr != nil { + return fmt.Errorf("failed to re-login admin with device1: %w", loginErr) + } + state.Device1AccessToken = loginResp.AccessToken + state.Device1RefreshToken = loginResp.RefreshToken + } + + // Get current session count for admin on device1 + listResp1, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, state.Device1AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + DeviceIdFilter: []*v1.InternalID{state.Device1ID}, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions before second login failed: %w", err) + } + + oldSessionCount := len(listResp1.Sessions) + if oldSessionCount == 0 { + return fmt.Errorf("no existing session found on device1 before test") + } + + // Admin user login again with device1 + resp, err := g.SephirahClient.GetToken(ctx, &pb.GetTokenRequest{ + Username: "admin", + Password: "admin", + DeviceId: state.Device1ID, + }) + if err != nil { + return fmt.Errorf("GetToken for admin with device1 failed: %w", err) + } + + // List sessions again + listResp2, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, resp.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + DeviceIdFilter: []*v1.InternalID{state.Device1ID}, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions after second login failed: %w", err) + } + + // Count active (non-expired) sessions + activeSessionCount := 0 + for _, session := range listResp2.Sessions { + if session.ExpireTime.AsTime().After(time.Now()) { + activeSessionCount++ + } + } + + // Should have exactly one active session + if activeSessionCount != 1 { + return fmt.Errorf("expected exactly 1 active session for admin on device1, got %d", activeSessionCount) + } + + // Verify old refresh token is invalidated + _, err = g.SephirahClient.RefreshToken( + withBearerToken(ctx, state.Device1RefreshToken), + &pb.RefreshTokenRequest{}, + ) + if err == nil { + return fmt.Errorf("old refresh_token should be invalidated after new login on same device") + } + + return nil + }, withDependOnIDs("FS-0003-SESSION-DEVICE_MULTI_USER")) + + // FS-0003-SESSION-LIST_FILTER + registerTestCase("FS-0003-SESSION-LIST_FILTER", must, func(ctx context.Context, g *globals) error { + state := getSessionState(g) + authState := getAuthState(g) + + // List all sessions without filter + allResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, authState.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions without filter failed: %w", err) + } + + if len(allResp.Sessions) == 0 { + return fmt.Errorf("expected at least one session, got 0") + } + + // List sessions with device1 filter + device1Resp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, authState.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + DeviceIdFilter: []*v1.InternalID{state.Device1ID}, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions with device1 filter failed: %w", err) + } + + // Check if device binding is implemented + deviceBindingImplemented := true + for _, session := range allResp.Sessions { + if session.DeviceId == nil || session.DeviceId.Id == 0 { + deviceBindingImplemented = false + break + } + } + + if !deviceBindingImplemented { + // Device binding not implemented, only verify filter doesn't error + return nil + } + + // Device binding IS implemented, do full validation + // Verify all returned sessions are bound to device1 + for _, session := range device1Resp.Sessions { + if session.DeviceId == nil { + return fmt.Errorf("filtered session has no device_id") + } + if session.DeviceId.Id != state.Device1ID.Id { + return fmt.Errorf("filtered session has wrong device_id, expected %d, got %d", + state.Device1ID.Id, session.DeviceId.Id) + } + } + + // Verify filtered results are less than or equal to all results + if len(device1Resp.Sessions) > len(allResp.Sessions) { + return fmt.Errorf("filtered sessions count (%d) is greater than all sessions count (%d)", + len(device1Resp.Sessions), len(allResp.Sessions)) + } + + return nil + }, withDependOnIDs("FS-0003-SESSION-DEVICE_SINGLE_SESSION")) + + // FS-0003-SESSION-EXPIRE_EXCLUSION + registerTestCase("FS-0003-SESSION-EXPIRE_EXCLUSION", should, func(ctx context.Context, g *globals) error { + authState := getAuthState(g) + + // List sessions excluding expired + excludeResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, authState.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + IncludeExpired: false, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions with include_expired=false failed: %w", err) + } + + // Verify all returned sessions are not expired + now := time.Now() + for _, session := range excludeResp.Sessions { + if session.ExpireTime.AsTime().Before(now) { + return fmt.Errorf("found expired session when include_expired=false, session_id: %d", session.Id.Id) + } + } + + // List sessions including expired + includeResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, authState.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + IncludeExpired: true, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions with include_expired=true failed: %w", err) + } + + // Verify count with include_expired=true is >= count without + if len(includeResp.Sessions) < len(excludeResp.Sessions) { + return fmt.Errorf("session count with include_expired=true (%d) is less than without (%d)", + len(includeResp.Sessions), len(excludeResp.Sessions)) + } + + return nil + }, withDependOnIDs("FS-0003-SESSION-LIST_FILTER")) + + // FS-0003-SESSION-EXPIRED_TOKEN_REJECT + registerTestCase("FS-0003-SESSION-EXPIRED_TOKEN_REJECT", should, func(ctx context.Context, g *globals) error { + authState := getAuthState(g) + + // List sessions including expired to find an expired session + listResp, err := g.SephirahClient.ListUserSessions( + withBearerToken(ctx, authState.AccessToken), + &pb.ListUserSessionsRequest{ + Paging: &v1.PagingRequest{ + PageSize: 100, + }, + IncludeExpired: true, + }, + ) + if err != nil { + return fmt.Errorf("ListUserSessions failed: %w", err) + } + + // Find an expired session + var expiredSessionFound bool + now := time.Now() + for _, session := range listResp.Sessions { + if session.ExpireTime.AsTime().Before(now) { + expiredSessionFound = true + break + } + } + + if !expiredSessionFound { + // If no expired session found, this test is inconclusive but not a failure + return nil + } + + // Note: We cannot directly test expired token rejection because we don't have + // the refresh_token for expired sessions. + return nil + }, withDependOnIDs("FS-0003-SESSION-EXPIRE_EXCLUSION")) +} + +// Helper function to create string pointer +func strPtr(s string) *string { + return &s +} diff --git a/docs/feature_sets/FS-0003-SESSION.md b/docs/feature_sets/FS-0003-SESSION.md new file mode 100644 index 00000000..6ffb35b5 --- /dev/null +++ b/docs/feature_sets/FS-0003-SESSION.md @@ -0,0 +1,60 @@ +--- +id: FS-0003-SESSION +title: Session and Device Management +version: 0.0.1 +status: draft +created: 2026-01-31 +last_updated: 2026-01-31 +--- + +## FS-0003-SESSION-AUTO_CREATION + +When a valid `refresh_token` is issued via `GetToken` or `RefreshToken`, the system MUST automatically create a corresponding session. The session MUST be retrievable via `ListUserSessions` API. + +## FS-0003-SESSION-LOGIN_NEW_ID + +When a user authenticates via `GetToken`, the system MUST generate a new `session_id` for the session. + +## FS-0003-SESSION-ONE_VALID_TOKEN + +Each `session_id` MUST have only one valid `refresh_token` at any given time. When a user refreshes tokens via `RefreshToken`, the system MUST reuse the existing `session_id` from the previous session. After a successful token refresh, the previous `refresh_token` MUST be invalidated and the session MUST persist (same `session_id`, `create_time` unchanged). The system MUST NOT create a new session during token refresh, ensuring session persistence across token rotations. Reusing an invalidated `refresh_token` MUST fail. + +## FS-0003-SESSION-REVOKE_INVALIDATE + +When a session is revoked via `RevokeUserSession`, the corresponding `refresh_token` MUST be immediately invalidated. Subsequent attempts to use the revoked `refresh_token` MUST fail. + +## FS-0003-SESSION-DEVICE_REGISTRATION + +The system MUST support device registration via `RegisterDevice`. Device registration MUST succeed and return a valid `device_id`. + +## FS-0003-SESSION-DEVICE_IDEMPOTENCY + +When `RegisterDevice` is called with the same `client_local_id`, the system MUST return the same `device_id`. When called with different `client_local_id` values or without `client_local_id`, the system MUST return different `device_id` values. + +## FS-0003-SESSION-DEVICE_BINDING_LOGIN + +When `GetToken` is called with a `device_id`, the created session MUST be bound to that device. The session's `device_id` field MUST match the provided `device_id`. + +## FS-0003-SESSION-DEVICE_BINDING_REFRESH + +When `RefreshToken` is called with a `device_id` for the first time on a session, the session MUST be bound to that device. Subsequent `ListUserSessions` calls MUST show the session bound to the device. + +## FS-0003-SESSION-DEVICE_MULTI_USER + +A single device MUST support multiple concurrent sessions from different users. Each user on the same device MUST have an independent session. + +## FS-0003-SESSION-DEVICE_SINGLE_SESSION + +For a given device and user combination, only one active session MUST exist at any time. When the same user logs in again on the same device, the previous session MUST be automatically revoked. + +## FS-0003-SESSION-LIST_FILTER + +`ListUserSessions` with `device_id_filter` MUST return only sessions associated with the specified device(s). Without filter, it MUST return all sessions for the authenticated user. + +## FS-0003-SESSION-EXPIRE_EXCLUSION + +`ListUserSessions` with `include_expired=false` MUST exclude sessions where `expire_time` has passed. With `include_expired=true`, it MUST include expired sessions. + +## FS-0003-SESSION-EXPIRED_TOKEN_REJECT + +When a session's `expire_time` has passed, attempts to use its `refresh_token` SHOULD fail. The system SHOULD reject expired tokens. Note: This requirement is downgraded to SHOULD as testing requires controllable expiration times or test-specific capabilities. diff --git a/docs/feature_sets/FS_overview.md b/docs/feature_sets/FS_overview.md new file mode 100644 index 00000000..fe8df451 --- /dev/null +++ b/docs/feature_sets/FS_overview.md @@ -0,0 +1,12 @@ +# Feature Sets Overview + +Feature Sets describe collections of related features in this system. + +## writing guide + +- FSD contains a markdown file and a gherkin file with the same filename +- FSD follows RFC 2119 for defining requirement levels +- FSD id is unique within the system and follows the format `FS--` +- Each FSD contains metadata including title, version, status, date created, and last updated +- Each FSD contains several feature definitions, each definition has a unique id, and follows the format `FS---` +- If `` or `` need multiple words, they should be separated by `_` \ No newline at end of file diff --git a/docs/feature_sets/FS_testsuite.go b/docs/feature_sets/FS_testsuite.go new file mode 100644 index 00000000..792eca3e --- /dev/null +++ b/docs/feature_sets/FS_testsuite.go @@ -0,0 +1,506 @@ +package featuresets + +import ( + "context" + "fmt" + "io" + "regexp" + "sort" + "strconv" + "strings" + + pb "github.com/tuihub/protos/pkg/librarian/sephirah/v1" +) + +func RunTestSuite(ctx context.Context, host string, port int, verbose int) error { + // Initialize globals + g := &globals{ + SephirahServerHost: host, + SephirahServerPort: port, + State: make(map[string]any), + } + // Check test case name pattern + for _, tc := range testCases { + if !regexp.MustCompile(`^FS-[0-9]{4}-[A-Z]+-[A-Z_]+$`).MatchString(tc.ID) { + return fmt.Errorf("Test case ID %s format is invalid, this is an error of testsuite itself", tc.ID) + } + } + // Sort testCases by DependOnIDs + if err := sortTestCases(); err != nil { + return err + } + // Run testCases + testCaseErr := make([]error, len(testCases)) + for i, tc := range testCases { + if verbose > 1 { + fmt.Printf("Running test case: %s\n", tc.ID) + } + if err := tc.Runner(ctx, g); err != nil { + testCaseErr[i] = err + if verbose > 1 { + fmt.Printf(" FAILED: %v\n", err) + } + } else { + if verbose > 1 { + fmt.Printf(" PASSED\n") + } + } + } + if verbose == 0 { + passCount := make(map[testCaseRequireLevel]int) + totalCount := make(map[testCaseRequireLevel]int) + for i, err := range testCaseErr { + totalCount[testCases[i].RequireLevel]++ + if err == nil { + passCount[testCases[i].RequireLevel]++ + } + } + fmt.Printf("MUST Cases\t%d/%d\n", passCount[must], totalCount[must]) + fmt.Printf("SHOULD Cases\t%d/%d\n", passCount[should], totalCount[should]) + fmt.Printf("MAY Cases\t%d/%d\n", passCount[may], totalCount[may]) + } + return nil +} + +var ( + testCases []testCase +) + +type globals struct { + // Test Command + SephirahServerHost string + SephirahServerPort int + SephirahClient pb.LibrarianSephirahServiceClient + SephirahServerInformation *pb.ServerInformation + // Generic state container for feature-specific data + State map[string]any +} + +type testCase struct { + ID string + DependOnIDs []string + RequireLevel testCaseRequireLevel + Runner testCaseRunner +} + +type testCaseRequireLevel string // RFC 2119 + +const ( + must testCaseRequireLevel = "MUST" + should testCaseRequireLevel = "SHOULD" + may testCaseRequireLevel = "MAY" +) + +type testCaseRunner func(ctx context.Context, g *globals) error + +func registerTestCase(id string, level testCaseRequireLevel, runner testCaseRunner, opts ...registerOption) { + tc := testCase{ + ID: id, + RequireLevel: level, + Runner: runner, + } + for _, opt := range opts { + opt(&tc) + } + testCases = append(testCases, tc) +} + +type registerOption func(tc *testCase) + +func withDependOnIDs(ids ...string) registerOption { + return func(tc *testCase) { + tc.DependOnIDs = ids + } +} + +func sortTestCases() error { + idToIndex := make(map[string]int) + for i, tc := range testCases { + idToIndex[tc.ID] = i + } + + inDegree := make([]int, len(testCases)) + adjList := make([][]int, len(testCases)) + for i, tc := range testCases { + for _, depID := range tc.DependOnIDs { + depIndex, exists := idToIndex[depID] + if !exists { + return fmt.Errorf("test case %s has unknown dependency: %s", tc.ID, depID) + } + adjList[depIndex] = append(adjList[depIndex], i) + inDegree[i]++ + } + } + + queue := make([]int, 0) + for i, deg := range inDegree { + if deg == 0 { + queue = append(queue, i) + } + } + + sort.Slice(queue, func(i, j int) bool { + id1 := testCases[queue[i]].ID + id2 := testCases[queue[j]].ID + isFS0000_1 := len(id1) >= 7 && id1[3:7] == "0000" + isFS0000_2 := len(id2) >= 7 && id2[3:7] == "0000" + if isFS0000_1 && !isFS0000_2 { + return true + } + if !isFS0000_1 && isFS0000_2 { + return false + } + num1, _ := strconv.Atoi(id1[3:7]) + num2, _ := strconv.Atoi(id2[3:7]) + return num1 < num2 + }) + + sorted := make([]testCase, 0) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + sorted = append(sorted, testCases[current]) + + for _, neighbor := range adjList[current] { + inDegree[neighbor]-- + if inDegree[neighbor] == 0 { + queue = append(queue, neighbor) + } + } + + sort.Slice(queue, func(i, j int) bool { + id1 := testCases[queue[i]].ID + id2 := testCases[queue[j]].ID + isFS0000_1 := len(id1) >= 7 && id1[3:7] == "0000" + isFS0000_2 := len(id2) >= 7 && id2[3:7] == "0000" + if isFS0000_1 && !isFS0000_2 { + return true + } + if !isFS0000_1 && isFS0000_2 { + return false + } + num1, _ := strconv.Atoi(id1[3:7]) + num2, _ := strconv.Atoi(id2[3:7]) + return num1 < num2 + }) + } + + if len(sorted) != len(testCases) { + return fmt.Errorf("circular dependency detected in test cases") + } + + testCases = sorted + return nil +} + +// PrintMermaidTree generates a Mermaid diagram showing the dependency tree of test cases +func PrintMermaidTree(w io.Writer) error { + // Ensure test cases are sorted and validated + if err := sortTestCases(); err != nil { + return fmt.Errorf("cannot generate tree: %w", err) + } + + // Build dependency graph + graph, err := buildDependencyGraph() + if err != nil { + return err + } + + // Generate Mermaid output + if err := graph.toMermaid(w); err != nil { + return err + } + + // Print statistics + graph.printStatistics(w) + + return nil +} + +// dependencyGraph represents the test case dependency structure +type dependencyGraph struct { + nodes map[string]*graphNode + roots []string + levelCount map[testCaseRequireLevel]int + scopeCount map[string]int + maxDepth int + leafCount int + sameFSEdges int + crossFSEdges int +} + +// graphNode represents a single test case node in the dependency graph +type graphNode struct { + testCase testCase + children []string + depth int +} + +// extractFSScope extracts the Feature Set scope from a test case ID +// For example: "FS-0001-AUTH-TOKEN_STRUCTURE" -> "FS-0001-AUTH" +func extractFSScope(id string) string { + parts := strings.Split(id, "-") + if len(parts) >= 3 { + return strings.Join(parts[:3], "-") + } + return id +} + +// groupTestCasesByScope groups test cases by their Feature Set scope +func groupTestCasesByScope() map[string][]testCase { + scopeMap := make(map[string][]testCase) + for _, tc := range testCases { + scope := extractFSScope(tc.ID) + scopeMap[scope] = append(scopeMap[scope], tc) + } + return scopeMap +} + +// buildDependencyGraph constructs the dependency graph from testCases +func buildDependencyGraph() (*dependencyGraph, error) { + graph := &dependencyGraph{ + nodes: make(map[string]*graphNode), + roots: make([]string, 0), + levelCount: make(map[testCaseRequireLevel]int), + scopeCount: make(map[string]int), + } + + // Create nodes for all test cases + for _, tc := range testCases { + graph.nodes[tc.ID] = &graphNode{ + testCase: tc, + children: make([]string, 0), + } + graph.levelCount[tc.RequireLevel]++ + + // Count by scope + scope := extractFSScope(tc.ID) + graph.scopeCount[scope]++ + } + + // Build parent-child relationships + for _, tc := range testCases { + if len(tc.DependOnIDs) == 0 { + // This is a root node + graph.roots = append(graph.roots, tc.ID) + } else { + // Add this node as a child to all its dependencies + for _, depID := range tc.DependOnIDs { + if parentNode, exists := graph.nodes[depID]; exists { + parentNode.children = append(parentNode.children, tc.ID) + + // Count edge types + if extractFSScope(tc.ID) == extractFSScope(depID) { + graph.sameFSEdges++ + } else { + graph.crossFSEdges++ + } + } + } + } + } + + // Calculate depths + graph.calculateDepths() + + // Count leaf nodes (nodes with no children) + for _, node := range graph.nodes { + if len(node.children) == 0 { + graph.leafCount++ + } + } + + return graph, nil +} + +// calculateDepths computes the depth of each node from root nodes +func (g *dependencyGraph) calculateDepths() { + // BFS from all root nodes + queue := make([]string, len(g.roots)) + copy(queue, g.roots) + + // Initialize root depths + for _, rootID := range g.roots { + g.nodes[rootID].depth = 0 + } + + // Process queue + for len(queue) > 0 { + currentID := queue[0] + queue = queue[1:] + currentNode := g.nodes[currentID] + + // Update max depth + if currentNode.depth > g.maxDepth { + g.maxDepth = currentNode.depth + } + + // Process children + for _, childID := range currentNode.children { + childNode := g.nodes[childID] + // Set child depth to max of all parent depths + 1 + newDepth := currentNode.depth + 1 + if newDepth > childNode.depth { + childNode.depth = newDepth + } + queue = append(queue, childID) + } + } +} + +// toMermaid generates Mermaid diagram syntax +func (g *dependencyGraph) toMermaid(w io.Writer) error { + fmt.Fprintln(w, "```mermaid") + fmt.Fprintln(w, "graph TD") + + // Group test cases by scope + scopeMap := groupTestCasesByScope() + + // Get sorted scope list for consistent ordering + scopes := make([]string, 0, len(scopeMap)) + for scope := range scopeMap { + scopes = append(scopes, scope) + } + sort.Strings(scopes) + + // Generate subgraph for each Feature Set scope + for _, scope := range scopes { + scopeTestCases := scopeMap[scope] + fmt.Fprintln(w) + + // Create subgraph with sanitized ID and readable title + subgraphID := sanitizeNodeID(scope) + fmt.Fprintf(w, " subgraph %s[\"%s\"]\n", subgraphID, scope) + + // Define nodes within this subgraph + for _, tc := range scopeTestCases { + nodeID := sanitizeNodeID(tc.ID) + label := fmt.Sprintf("%s
%s", tc.ID, tc.RequireLevel) + fmt.Fprintf(w, " %s[\"%s\"]\n", nodeID, label) + } + + fmt.Fprintln(w, " end") + } + + // Add edges (outside subgraphs) + fmt.Fprintln(w) + fmt.Fprintln(w, " %% Dependencies") + + // Separate same-FS and cross-FS edges for clarity + fmt.Fprintln(w, " %% Same Feature Set dependencies (solid arrows)") + for _, tc := range testCases { + nodeID := sanitizeNodeID(tc.ID) + node := g.nodes[tc.ID] + for _, childID := range node.children { + // Same-FS dependency: solid arrow + if extractFSScope(tc.ID) == extractFSScope(childID) { + childNodeID := sanitizeNodeID(childID) + fmt.Fprintf(w, " %s --> %s\n", nodeID, childNodeID) + } + } + } + + fmt.Fprintln(w) + fmt.Fprintln(w, " %% Cross Feature Set dependencies (dashed arrows)") + for _, tc := range testCases { + nodeID := sanitizeNodeID(tc.ID) + node := g.nodes[tc.ID] + for _, childID := range node.children { + // Cross-FS dependency: dashed arrow + if extractFSScope(tc.ID) != extractFSScope(childID) { + childNodeID := sanitizeNodeID(childID) + fmt.Fprintf(w, " %s -.-> %s\n", nodeID, childNodeID) + } + } + } + + // Add styling + fmt.Fprintln(w) + fmt.Fprintln(w, " %% Styling by requirement level") + for _, tc := range testCases { + nodeID := sanitizeNodeID(tc.ID) + fillColor, textColor := getColorByLevel(tc.RequireLevel) + fmt.Fprintf(w, " style %s fill:%s,stroke:#333,stroke-width:2px,color:%s\n", + nodeID, fillColor, textColor) + } + + fmt.Fprintln(w, "```") + return nil +} + +// printStatistics outputs statistics about the dependency tree +func (g *dependencyGraph) printStatistics(w io.Writer) { + total := len(testCases) + + fmt.Fprintln(w) + fmt.Fprintln(w, "## Dependency Tree Statistics") + fmt.Fprintln(w) + fmt.Fprintf(w, "- **Total test cases**: %d\n", total) + + // Print counts by requirement level + if count := g.levelCount[must]; count > 0 { + percentage := float64(count) / float64(total) * 100 + fmt.Fprintf(w, "- **MUST**: %d cases (%.1f%%)\n", count, percentage) + } + if count := g.levelCount[should]; count > 0 { + percentage := float64(count) / float64(total) * 100 + fmt.Fprintf(w, "- **SHOULD**: %d cases (%.1f%%)\n", count, percentage) + } + if count := g.levelCount[may]; count > 0 { + percentage := float64(count) / float64(total) * 100 + fmt.Fprintf(w, "- **MAY**: %d cases (%.1f%%)\n", count, percentage) + } + + fmt.Fprintf(w, "- **Maximum depth**: %d levels\n", g.maxDepth+1) + fmt.Fprintf(w, "- **Root nodes**: %d\n", len(g.roots)) + fmt.Fprintf(w, "- **Leaf nodes**: %d\n", g.leafCount) + + // Print per-Feature-Set breakdown + fmt.Fprintln(w) + fmt.Fprintln(w, "### Test Cases by Feature Set") + fmt.Fprintln(w) + + // Get sorted scope list + scopes := make([]string, 0, len(g.scopeCount)) + for scope := range g.scopeCount { + scopes = append(scopes, scope) + } + sort.Strings(scopes) + + for _, scope := range scopes { + count := g.scopeCount[scope] + fmt.Fprintf(w, "- **%s**: %d cases\n", scope, count) + } + + // Print dependency statistics + fmt.Fprintln(w) + fmt.Fprintln(w, "### Dependencies") + fmt.Fprintln(w) + fmt.Fprintf(w, "- **Same-FS dependencies**: %d\n", g.sameFSEdges) + fmt.Fprintf(w, "- **Cross-FS dependencies**: %d\n", g.crossFSEdges) + totalEdges := g.sameFSEdges + g.crossFSEdges + if totalEdges > 0 { + crossPercentage := float64(g.crossFSEdges) / float64(totalEdges) * 100 + fmt.Fprintf(w, "- **Total dependencies**: %d (%.1f%% cross-FS)\n", totalEdges, crossPercentage) + } +} + +// sanitizeNodeID converts a test case ID to a valid Mermaid node identifier +func sanitizeNodeID(id string) string { + // Replace hyphens with underscores for Mermaid compatibility + return strings.ReplaceAll(id, "-", "_") +} + +// getColorByLevel returns fill and text colors for a requirement level +func getColorByLevel(level testCaseRequireLevel) (fillColor, textColor string) { + switch level { + case must: + return "#4CAF50", "#fff" // Green with white text + case should: + return "#2196F3", "#fff" // Blue with white text + case may: + return "#FFC107", "#000" // Amber with black text + default: + return "#9E9E9E", "#fff" // Gray with white text + } +} diff --git a/docs/feature_sets/FS_testsuite_test.go b/docs/feature_sets/FS_testsuite_test.go new file mode 100644 index 00000000..a464093b --- /dev/null +++ b/docs/feature_sets/FS_testsuite_test.go @@ -0,0 +1,245 @@ +package featuresets + +import ( + "testing" +) + +func TestSortTestCases(t *testing.T) { + originalTestCases := make([]testCase, len(testCases)) + copy(originalTestCases, testCases) + + defer func() { + testCases = originalTestCases + }() + + testCases = []testCase{ + {ID: "FS-0001-TEST-A", DependOnIDs: []string{"FS-0000-INIT-SEPHIRAH_CLIENT"}, RequireLevel: must, Runner: nil}, + {ID: "FS-0002-TEST-B", DependOnIDs: []string{"FS-0001-TEST-A"}, RequireLevel: must, Runner: nil}, + {ID: "FS-0003-TEST-C", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0000-INIT-SEPHIRAH_CLIENT", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0004-TEST-D", DependOnIDs: []string{"FS-0002-TEST-B", "FS-0003-TEST-C"}, RequireLevel: must, Runner: nil}, + } + + err := sortTestCases() + if err != nil { + t.Fatalf("sortTestCases() error = %v", err) + } + + expectedOrder := []string{ + "FS-0000-INIT-SEPHIRAH_CLIENT", + "FS-0001-TEST-A", + "FS-0002-TEST-B", + "FS-0003-TEST-C", + "FS-0004-TEST-D", + } + + for i, expected := range expectedOrder { + if i >= len(testCases) { + t.Fatalf("Expected at least %d test cases, got %d", i+1, len(testCases)) + } + if testCases[i].ID != expected { + t.Errorf("TestCases[%d].ID = %v, want %v", i, testCases[i].ID, expected) + } + } +} + +func TestSortTestCasesWithFS0000Priority(t *testing.T) { + originalTestCases := make([]testCase, len(testCases)) + copy(originalTestCases, testCases) + + defer func() { + testCases = originalTestCases + }() + + testCases = []testCase{ + {ID: "FS-0005-TEST-E", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0003-TEST-C", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0000-INIT-SEPHIRAH_CLIENT", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0001-TEST-A", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + } + + err := sortTestCases() + if err != nil { + t.Fatalf("sortTestCases() error = %v", err) + } + + if testCases[0].ID != "FS-0000-INIT-SEPHIRAH_CLIENT" { + t.Errorf("First test case should be FS-0000-INIT-SEPHIRAH_CLIENT, got %v", testCases[0].ID) + } + + for i := 1; i < len(testCases); i++ { + if testCases[i].ID == "FS-0000-INIT-SEPHIRAH_CLIENT" { + t.Errorf("FS-0000 should only appear at position 0, found at position %d", i) + } + } +} + +func TestSortTestCasesWithNumberOrder(t *testing.T) { + originalTestCases := make([]testCase, len(testCases)) + copy(originalTestCases, testCases) + + defer func() { + testCases = originalTestCases + }() + + testCases = []testCase{ + {ID: "FS-0005-TEST-E", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0003-TEST-C", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0001-TEST-A", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0004-TEST-D", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0002-TEST-B", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + } + + err := sortTestCases() + if err != nil { + t.Fatalf("sortTestCases() error = %v", err) + } + + expectedOrder := []string{ + "FS-0001-TEST-A", + "FS-0002-TEST-B", + "FS-0003-TEST-C", + "FS-0004-TEST-D", + "FS-0005-TEST-E", + } + + for i, expected := range expectedOrder { + if testCases[i].ID != expected { + t.Errorf("TestCases[%d].ID = %v, want %v", i, testCases[i].ID, expected) + } + } +} + +func TestSortTestCasesWithCircularDependency(t *testing.T) { + originalTestCases := make([]testCase, len(testCases)) + copy(originalTestCases, testCases) + + defer func() { + testCases = originalTestCases + }() + + testCases = []testCase{ + {ID: "FS-0001-TEST-A", DependOnIDs: []string{"FS-0002-TEST-B"}, RequireLevel: must, Runner: nil}, + {ID: "FS-0002-TEST-B", DependOnIDs: []string{"FS-0001-TEST-A"}, RequireLevel: must, Runner: nil}, + } + + err := sortTestCases() + if err == nil { + t.Error("sortTestCases() expected error for circular dependency, got nil") + } + + expectedError := "circular dependency detected in test cases" + if err != nil && err.Error() != expectedError { + t.Errorf("sortTestCases() error = %v, want %v", err.Error(), expectedError) + } +} + +func TestSortTestCasesWithComplexDependencies(t *testing.T) { + originalTestCases := make([]testCase, len(testCases)) + copy(originalTestCases, testCases) + + defer func() { + testCases = originalTestCases + }() + + testCases = []testCase{ + {ID: "FS-0000-INIT-SEPHIRAH_CLIENT", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0005-TEST-E", DependOnIDs: []string{"FS-0003-TEST-C", "FS-0004-TEST-D"}, RequireLevel: must, Runner: nil}, + {ID: "FS-0003-TEST-C", DependOnIDs: []string{"FS-0001-TEST-A"}, RequireLevel: must, Runner: nil}, + {ID: "FS-0001-TEST-A", DependOnIDs: []string{"FS-0000-INIT-SEPHIRAH_CLIENT"}, RequireLevel: must, Runner: nil}, + {ID: "FS-0004-TEST-D", DependOnIDs: []string{"FS-0002-TEST-B"}, RequireLevel: must, Runner: nil}, + {ID: "FS-0002-TEST-B", DependOnIDs: []string{"FS-0001-TEST-A"}, RequireLevel: must, Runner: nil}, + } + + err := sortTestCases() + if err != nil { + t.Fatalf("sortTestCases() error = %v", err) + } + + expectedOrder := []string{ + "FS-0000-INIT-SEPHIRAH_CLIENT", + "FS-0001-TEST-A", + "FS-0002-TEST-B", + "FS-0003-TEST-C", + "FS-0004-TEST-D", + "FS-0005-TEST-E", + } + + for i, expected := range expectedOrder { + if testCases[i].ID != expected { + t.Errorf("TestCases[%d].ID = %v, want %v", i, testCases[i].ID, expected) + } + } + + for i := 0; i < len(testCases); i++ { + for _, depID := range testCases[i].DependOnIDs { + found := false + for j := 0; j < i; j++ { + if testCases[j].ID == depID { + found = true + break + } + } + if !found { + t.Errorf("Dependency %v of test case %v not found before it", depID, testCases[i].ID) + } + } + } +} + +func TestSortTestCasesWithMultipleFS0000(t *testing.T) { + originalTestCases := make([]testCase, len(testCases)) + copy(originalTestCases, testCases) + + defer func() { + testCases = originalTestCases + }() + + testCases = []testCase{ + {ID: "FS-0000-INIT-A", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0000-INIT-B", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + {ID: "FS-0001-TEST-A", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + } + + err := sortTestCases() + if err != nil { + t.Fatalf("sortTestCases() error = %v", err) + } + + for i := 0; i < 2; i++ { + if len(testCases[i].ID) < 7 || testCases[i].ID[3:7] != "0000" { + t.Errorf("TestCases[%d].ID = %v should start with FS-0000", i, testCases[i].ID) + } + } + + if len(testCases[2].ID) >= 7 && testCases[2].ID[3:7] == "0000" { + t.Errorf("TestCases[2].ID = %v should not start with FS-0000", testCases[2].ID) + } +} + +func TestSortTestCasesWithMissingDependency(t *testing.T) { + originalTestCases := make([]testCase, len(testCases)) + copy(originalTestCases, testCases) + + defer func() { + testCases = originalTestCases + }() + + testCases = []testCase{ + {ID: "FS-0001-TEST-A", DependOnIDs: []string{"FS-9999-NON-EXISTENT"}, RequireLevel: must, Runner: nil}, + {ID: "FS-0002-TEST-B", DependOnIDs: []string{}, RequireLevel: must, Runner: nil}, + } + + err := sortTestCases() + if err != nil { + t.Fatalf("sortTestCases() error = %v", err) + } + + if testCases[0].ID != "FS-0001-TEST-A" { + t.Errorf("TestCases[0].ID = %v, want FS-0001-TEST-A (missing dependency ignored)", testCases[0].ID) + } + + if testCases[1].ID != "FS-0002-TEST-B" { + t.Errorf("TestCases[1].ID = %v, want FS-0002-TEST-B", testCases[1].ID) + } +} diff --git a/docs/index.html b/docs/index.html index be103c52..aae308a2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -30,6 +30,68 @@ }) }); }, + function (hook, vm) { + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function fmtValue(v) { + if (v == null) return ''; + if (v instanceof Date && !isNaN(v.getTime())) return v.toISOString().slice(0, 10); + if (typeof v === 'object') return escapeHtml(JSON.stringify(v)); + return escapeHtml(v); + } + + function renderFsMeta(frontmatter) { + var fields = [ + ['id', 'ID'], + ['title', 'Title'], + ['version', 'Version'], + ['status', 'Status'], + ['created', 'Created'], + ['last_updated', 'Last Updated'], + ]; + + var active = fields.filter(function (kv) { + var v = frontmatter[kv[0]]; + return v != null && String(v).length > 0; + }); + + if (active.length === 0) return ''; + + var headerRow = active + .map(function (kv) { return '' + kv[1] + ''; }) + .join(''); + + var valueRow = active + .map(function (kv) { return '' + fmtValue(frontmatter[kv[0]]) + ''; }) + .join(''); + + // Render as a regular table so it inherits Docsify table styles. + return ( + '' + + '' + headerRow + '' + + '' + valueRow + '' + + '
' + ); + } + + hook.afterEach(function (html, next) { + var fm = vm.frontmatter || {}; + var file = (vm.route && vm.route.file) ? String(vm.route.file) : ''; + var id = (fm && fm.id != null) ? String(fm.id) : ''; + + var isFs = (/^FS-\d{4}-/.test(id)) || (/^feature_sets\/FS-\d{4}-/.test(file)); + if (!isFs) return next(html); + + next(renderFsMeta(fm) + html); + }); + }, function (hook, vm) { hook.ready(function () { mermaid.initialize({ startOnLoad: false }); @@ -55,10 +117,11 @@ + - \ No newline at end of file + diff --git a/docs/testsuite.md b/docs/testsuite.md new file mode 100644 index 00000000..3273f53d --- /dev/null +++ b/docs/testsuite.md @@ -0,0 +1,72 @@ +# Feature Sets Testsuite + +The Feature Sets testsuite validates server behavior against executable requirements. +It runs a set of Go test cases defined in `docs/feature_sets/` against a running Sephirah gRPC server. + +## What It Validates + +- Each requirement is labeled with RFC 2119 level: `MUST`, `SHOULD`, or `MAY`. +- Each Markdown requirement (e.g. `FS-0001-AUTH-TOKEN_STRUCTURE`) has a corresponding Go test case with the same ID. +- Test cases can declare dependencies; the runner orders execution accordingly. + +## Prerequisites + +- Go toolchain installed +- A running server endpoint reachable at `host:port` + +## Run The Testsuite + +From the repository root: + +```bash +# Default target: 127.0.0.1:10000 +go run ./cmd/testsuite run + +# Specify server address +go run ./cmd/testsuite run --server-host=localhost --server-port=8080 +``` + +Verbose output: + +```bash +go run ./cmd/testsuite run -v +go run ./cmd/testsuite run -vv +go run ./cmd/testsuite run -vvv +``` + +Notes: + +- `run` is the default subcommand. For backward compatibility, you can omit it. +- When verbosity is not enabled, the runner prints a short pass/total summary grouped by requirement level. + +## Dependency Graph + +To print the dependency graph in Mermaid format: + +```bash +go run ./cmd/testsuite tree + +# Save as Markdown +go run ./cmd/testsuite tree > docs/testsuite_tree.md +``` + +The documentation site renders the latest snapshot at [testsuite_tree.md](testsuite_tree.md). + +## Adding Or Updating A Feature Set + +Checklist: + +1. Add or edit a spec file under `docs/feature_sets/` (example: `FS-0001-AUTH.md`). +2. Ensure each requirement header uses a unique ID: `FS-<4digits>--`. +3. Add or edit the matching Go test file (example: `FS-0001-AUTH.go`) and register each test case with the exact same ID. +4. Use dependencies to enforce ordering when a test needs prior state. +5. Run the testsuite and regenerate the dependency graph snapshot. + +For the full authoring rules and conventions, see `docs/feature_sets/AGENTS.md`. + +## Troubleshooting + +- Connection failures: verify `--server-host` / `--server-port` and server availability. +- ID format errors: IDs must match `FS-0000-SCOPE-FEATURE_NAME` (4-digit number, uppercase scope/name). +- Unknown dependency: a test references an ID that is not registered. +- Circular dependency: remove or redesign dependencies to form a DAG. diff --git a/go.mod b/go.mod index 4a46bb72..95362c10 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,15 @@ require ( ) require ( + github.com/go-kratos/aegis v0.2.0 // indirect + github.com/go-playground/form/v4 v4.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/kr/text v0.2.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.27.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c8c4cddc..18ddbd09 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,51 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.10-20250912141014-52f32327d4b0.1 h1:31on4W/yPcV4nZHL4+UCiCvLPsMqe/vJcNg8Rci0scc= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.10-20250912141014-52f32327d4b0.1/go.mod h1:fUl8CEN/6ZAMk6bP8ahBJPUJw7rbp+j4x+wCcYi2IG4= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/go-kratos/aegis v0.2.0 h1:dObzCDWn3XVjUkgxyBp6ZeWtx/do0DPZ7LY3yNSJLUQ= +github.com/go-kratos/aegis v0.2.0/go.mod h1:v0R2m73WgEEYB3XYu6aE2WcMwsZkJ/Rzuf5eVccm7bI= github.com/go-kratos/kratos/v2 v2.9.1 h1:EGif6/S/aK/RCR5clIbyhioTNyoSrii3FC118jG40Z0= github.com/go-kratos/kratos/v2 v2.9.1/go.mod h1:a1MQLjMhIh7R0kcJS9SzJYR43BRI7EPzzN0J1Ksu2bA= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/form/v4 v4.2.0 h1:N1wh+Goz61e6w66vo8vJkQt+uwZSoLz50kZPJWR8eic= +github.com/go-playground/form/v4 v4.2.0/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= @@ -28,15 +60,24 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/proto/librarian/sephirah/v1/base.proto b/proto/librarian/sephirah/v1/base.proto index a7b58c46..0c6a97c3 100644 --- a/proto/librarian/sephirah/v1/base.proto +++ b/proto/librarian/sephirah/v1/base.proto @@ -9,9 +9,9 @@ option csharp_namespace = "TuiHub.Protos.Librarian.Sephirah.V1"; option go_package = "github.com/tuihub/protos/pkg/librarian/sephirah/v1;v1"; message ServerInformation { - // For manual inspection only, the client may display but should not parse the response. + // For manual inspection only, the client may display but should not parse the response texts. ServerBinarySummary server_binary_summary = 1; - // For manual inspection only, the client may display but should not parse the response. + // For manual inspection only, the client may display but should not parse the response texts. ServerProtocolSummary protocol_summary = 2; // The time that server received the request, // note that there is a transmission delay between server and client. @@ -48,7 +48,8 @@ message ServerProtocolSummary { message ServerInstanceSummary { string name = 1; string description = 2; - string website_url = 3; - string logo_url = 4; - string background_url = 5; + optional string website_url = 3; + optional string logo_image_url = 4; + optional string background_image_url = 5; + bool is_registerable = 6; } diff --git a/proto/librarian/sephirah/v1/sephirah_service.proto b/proto/librarian/sephirah/v1/sephirah_service.proto index 90dbcf65..c48ee74b 100644 --- a/proto/librarian/sephirah/v1/sephirah_service.proto +++ b/proto/librarian/sephirah/v1/sephirah_service.proto @@ -23,9 +23,9 @@ option go_package = "github.com/tuihub/protos/pkg/librarian/sephirah/v1;v1"; * 5. `Netzach` handles notifications */ service LibrarianSephirahService { - // Allow anonymous call, use accessToken to get complete information + // `anonymous` `access_token` Allow anonymous call, use accessToken to get complete information rpc GetServerInformation(GetServerInformationRequest) returns (GetServerInformationResponse); - // `Normal` Client can use this to subscribe to server events. + // `access_token` Client can use this to subscribe to server events. // // Server should send `SERVER_EVENT_LISTENER_CONNECTED` event immediately if the connection is valid. // Otherwise, client should treat the connection as failed. @@ -34,45 +34,47 @@ service LibrarianSephirahService { // Only used to improve real-time experience, no guarantee of delivery. rpc ListenServerEvent(ListenServerEventRequest) returns (stream ListenServerEventResponse); - // `Tiphereth` `Normal` Login via password and get two token + // `Tiphereth` `anonymous` Login via password and get two token rpc GetToken(GetTokenRequest) returns (GetTokenResponse); - // `Tiphereth` `Normal` Use valid refresh_token and get two new token, a refresh_token can only be used once + // `Tiphereth` `access_token` Use valid refresh_token and get two new token, a refresh_token can only be used once rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse); - // `Tiphereth` + // `Tiphereth` `access_token` rpc GetUser(GetUserRequest) returns (GetUserResponse); - // `Tiphereth` Self register as a new normal user + // `Tiphereth` `anonymous` Self register as a new normal user rpc RegisterUser(RegisterUserRequest) returns (RegisterUserResponse); - // `Tiphereth` `Normal` Update self user info + // `Tiphereth` `access_token` Update self user info rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse); - // `Tiphereth` `Normal` Client should register device after the first login + // `Tiphereth` `access_token` Client should register device after the first login // and store the device_id locally. // The server could add extra limits to non-registered device rpc RegisterDevice(RegisterDeviceRequest) returns (RegisterDeviceResponse); - // `Tiphereth` `Normal` + // `Tiphereth` `access_token` + rpc GetDevice(GetDeviceRequest) returns (GetDeviceResponse); + // `Tiphereth` `access_token` rpc ListUserSessions(ListUserSessionsRequest) returns (ListUserSessionsResponse); - // `Tiphereth` `Normal` delete session will revoke refresh_token immediately. + // `Tiphereth` `access_token` revoke session will revoke refresh_token immediately. // NOTE: This can also be used to log out at server side. - // NOTE2: Delete session will not affect device registration. - rpc DeleteUserSession(DeleteUserSessionRequest) returns (DeleteUserSessionResponse); + // NOTE2: Revoke session will not affect device registration. + rpc RevokeUserSession(RevokeUserSessionRequest) returns (RevokeUserSessionResponse); - // `Tiphereth` `Normal` Bind third-party account to current user. + // `Tiphereth` `access_token` Bind third-party account to current user. rpc LinkAccount(LinkAccountRequest) returns (LinkAccountResponse); - // `Tiphereth` `Normal` Unbind third-party account from current user. + // `Tiphereth` `access_token` Unbind third-party account from current user. rpc UnLinkAccount(UnLinkAccountRequest) returns (UnLinkAccountResponse); - // `Tiphereth` `Normal` List third-party account binded to current user. + // `Tiphereth` `access_token` List third-party account binded to current user. rpc ListLinkAccounts(ListLinkAccountsRequest) returns (ListLinkAccountsResponse); - // `Tiphereth` `Normal` + // `Tiphereth` `access_token` rpc ListPorterDigests(ListPorterDigestsRequest) returns (ListPorterDigestsResponse); - // `Tiphereth` `Normal` + // `Tiphereth` `access_token` rpc CreatePorterContext(CreatePorterContextRequest) returns (CreatePorterContextResponse); - // `Tiphereth` `Normal` + // `Tiphereth` `access_token` rpc ListPorterContexts(ListPorterContextsRequest) returns (ListPorterContextsResponse); - // `Tiphereth` `Normal` Set porter context. + // `Tiphereth` `access_token` Set porter context. rpc UpdatePorterContext(UpdatePorterContextRequest) returns (UpdatePorterContextResponse); - // `Binah` `Normal` + // `Binah` `access_token` rpc GetStorageCapacityUsage(GetStorageCapacityUsageRequest) returns (GetStorageCapacityUsageResponse); // `Binah` `upload_token` rpc UploadFile(stream UploadFileRequest) returns (stream UploadFileResponse); @@ -96,140 +98,140 @@ service LibrarianSephirahService { // Download file through http url rpc PresignedDownloadFile(PresignedDownloadFileRequest) returns (PresignedDownloadFileResponse); - // `Chesed` `Normal` + // `Chesed` `access_token` rpc UploadImage(UploadImageRequest) returns (UploadImageResponse); - // `Chesed` `Normal` + // `Chesed` `access_token` rpc UpdateImage(UpdateImageRequest) returns (UpdateImageResponse); - // `Chesed` `Normal` + // `Chesed` `access_token` rpc ListImages(ListImagesRequest) returns (ListImagesResponse); - // `Chesed` `Normal` + // `Chesed` `access_token` rpc SearchImages(SearchImagesRequest) returns (SearchImagesResponse); - // `Chesed` `Normal` + // `Chesed` `access_token` rpc GetImage(GetImageRequest) returns (GetImageResponse); - // `Chesed` `Normal` + // `Chesed` `access_token` rpc DownloadImage(DownloadImageRequest) returns (DownloadImageResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc SearchStoreApps(SearchStoreAppsRequest) returns (SearchStoreAppsResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc GetStoreAppSummary(GetStoreAppSummaryRequest) returns (GetStoreAppSummaryResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc AcquireStoreApp(AcquireStoreAppRequest) returns (AcquireStoreAppResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc ListStoreAppBinaries(ListStoreAppBinariesRequest) returns (ListStoreAppBinariesResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc ListStoreAppBinaryFiles(ListStoreAppBinaryFilesRequest) returns (ListStoreAppBinaryFilesResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc DownloadStoreAppBinary(DownloadStoreAppBinaryRequest) returns (DownloadStoreAppBinaryResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc ListStoreAppSaveFiles(ListStoreAppSaveFilesRequest) returns (ListStoreAppSaveFilesResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc DownloadStoreAppSaveFile(DownloadStoreAppSaveFileRequest) returns (DownloadStoreAppSaveFileResponse); - // `Gebura` `Normal` Search app infos + // `Gebura` `access_token` Search app infos rpc SearchAppInfos(SearchAppInfosRequest) returns (SearchAppInfosResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc CreateApp(CreateAppRequest) returns (CreateAppResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc UpdateApp(UpdateAppRequest) returns (UpdateAppResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc ListApps(ListAppsRequest) returns (ListAppsResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc DeleteApp(DeleteAppRequest) returns (DeleteAppResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc BatchCreateAppRunTime(BatchCreateAppRunTimeRequest) returns (BatchCreateAppRunTimeResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc SumAppRunTime(SumAppRunTimeRequest) returns (SumAppRunTimeResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc ListAppRunTimes(ListAppRunTimesRequest) returns (ListAppRunTimesResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc DeleteAppRunTime(DeleteAppRunTimeRequest) returns (DeleteAppRunTimeResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc UploadAppSaveFile(UploadAppSaveFileRequest) returns (UploadAppSaveFileResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc DownloadAppSaveFile(DownloadAppSaveFileRequest) returns (DownloadAppSaveFileResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc ListAppSaveFiles(ListAppSaveFilesRequest) returns (ListAppSaveFilesResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc DeleteAppSaveFile(DeleteAppSaveFileRequest) returns (DeleteAppSaveFileResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc PinAppSaveFile(PinAppSaveFileRequest) returns (PinAppSaveFileResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc UnpinAppSaveFile(UnpinAppSaveFileRequest) returns (UnpinAppSaveFileResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc GetAppSaveFileCapacity(GetAppSaveFileCapacityRequest) returns (GetAppSaveFileCapacityResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc SetAppSaveFileCapacity(SetAppSaveFileCapacityRequest) returns (SetAppSaveFileCapacityResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc ListAppCategories(ListAppCategoriesRequest) returns (ListAppCategoriesResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc CreateAppCategory(CreateAppCategoryRequest) returns (CreateAppCategoryResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc UpdateAppCategory(UpdateAppCategoryRequest) returns (UpdateAppCategoryResponse); - // `Gebura` `Normal` + // `Gebura` `access_token` rpc DeleteAppCategory(DeleteAppCategoryRequest) returns (DeleteAppCategoryResponse); - // `Netzach` `Normal` + // `Netzach` `access_token` rpc CreateNotifyTarget(CreateNotifyTargetRequest) returns (CreateNotifyTargetResponse); - // `Netzach` `Normal` + // `Netzach` `access_token` rpc UpdateNotifyTarget(UpdateNotifyTargetRequest) returns (UpdateNotifyTargetResponse); - // `Netzach` `Normal` + // `Netzach` `access_token` rpc ListNotifyTargets(ListNotifyTargetsRequest) returns (ListNotifyTargetsResponse); - // `Netzach` `Normal` + // `Netzach` `access_token` rpc CreateNotifyFlow(CreateNotifyFlowRequest) returns (CreateNotifyFlowResponse); - // `Netzach` `Normal` + // `Netzach` `access_token` rpc UpdateNotifyFlow(UpdateNotifyFlowRequest) returns (UpdateNotifyFlowResponse); - // `Netzach` `Normal` + // `Netzach` `access_token` rpc ListNotifyFlows(ListNotifyFlowsRequest) returns (ListNotifyFlowsResponse); - // `Netzach` `Normal` + // `Netzach` `access_token` rpc ListSystemNotifications(ListSystemNotificationsRequest) returns (ListSystemNotificationsResponse); - // `Netzach` `Normal` + // `Netzach` `access_token` rpc UpdateSystemNotification(UpdateSystemNotificationRequest) returns (UpdateSystemNotificationResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc CreateFeedConfig(CreateFeedConfigRequest) returns (CreateFeedConfigResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc UpdateFeedConfig(UpdateFeedConfigRequest) returns (UpdateFeedConfigResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc ListFeedConfigs(ListFeedConfigsRequest) returns (ListFeedConfigsResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc CreateFeedActionSet(CreateFeedActionSetRequest) returns (CreateFeedActionSetResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc UpdateFeedActionSet(UpdateFeedActionSetRequest) returns (UpdateFeedActionSetResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc ListFeedActionSets(ListFeedActionSetsRequest) returns (ListFeedActionSetsResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc ListFeedCategories(ListFeedCategoriesRequest) returns (ListFeedCategoriesResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc ListFeedPlatforms(ListFeedPlatformsRequest) returns (ListFeedPlatformsResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc ListFeedItems(ListFeedItemsRequest) returns (ListFeedItemsResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc GetFeedItem(GetFeedItemRequest) returns (GetFeedItemResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc GetBatchFeedItems(GetBatchFeedItemsRequest) returns (GetBatchFeedItemsResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc ReadFeedItem(ReadFeedItemRequest) returns (ReadFeedItemResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc CreateFeedItemCollection(CreateFeedItemCollectionRequest) returns (CreateFeedItemCollectionResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc UpdateFeedItemCollection(UpdateFeedItemCollectionRequest) returns (UpdateFeedItemCollectionResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc ListFeedItemCollections(ListFeedItemCollectionsRequest) returns (ListFeedItemCollectionsResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc AddFeedItemToCollection(AddFeedItemToCollectionRequest) returns (AddFeedItemToCollectionResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc RemoveFeedItemFromCollection(RemoveFeedItemFromCollectionRequest) returns (RemoveFeedItemFromCollectionResponse); - // `Yesod` `Normal` + // `Yesod` `access_token` rpc ListFeedItemsInCollection(ListFeedItemsInCollectionRequest) returns (ListFeedItemsInCollectionResponse); } diff --git a/proto/librarian/sephirah/v1/tiphereth.proto b/proto/librarian/sephirah/v1/tiphereth.proto index 90890ed4..567b7712 100644 --- a/proto/librarian/sephirah/v1/tiphereth.proto +++ b/proto/librarian/sephirah/v1/tiphereth.proto @@ -12,8 +12,7 @@ option go_package = "github.com/tuihub/protos/pkg/librarian/sephirah/v1;v1"; message GetTokenRequest { string username = 1; string password = 2; - // Always ignore this if client don't impl device info feature. - // Otherwise, re-login after registered device with this and every time after + // Ignore this if client don't impl device info feature. optional librarian.v1.InternalID device_id = 3; } @@ -23,7 +22,7 @@ message GetTokenResponse { } message RefreshTokenRequest { - // Always ignore this if client don't impl device info feature. + // Ignore this if client don't impl device info feature. // Be used to add device info after registered device. // Only first device_id will be used. optional librarian.v1.InternalID device_id = 3; @@ -66,15 +65,26 @@ message RegisterDeviceResponse { librarian.v1.InternalID device_id = 1; } -message ListUserSessionsRequest {} +message GetDeviceRequest { + librarian.v1.InternalID device_id = 1; +} +message GetDeviceResponse { + Device device_info = 1; +} + +message ListUserSessionsRequest { + librarian.v1.PagingRequest paging = 1; + bool include_expired = 2; + repeated librarian.v1.InternalID device_id_filter = 3; +} message ListUserSessionsResponse { repeated UserSession sessions = 1; } -message DeleteUserSessionRequest { +message RevokeUserSessionRequest { librarian.v1.InternalID session_id = 1; } -message DeleteUserSessionResponse {} +message RevokeUserSessionResponse {} message UpdateUserRequest { User user = 1; @@ -170,7 +180,7 @@ message User { message UserSession { librarian.v1.InternalID id = 1; librarian.v1.InternalID user_id = 2; - Device device_info = 3; + optional librarian.v1.InternalID device_id = 3; google.protobuf.Timestamp create_time = 4; google.protobuf.Timestamp expire_time = 5; }