From 7da76608f26e2cc3da1ef290fd8ae93bca862175 Mon Sep 17 00:00:00 2001 From: Jean Machuca Date: Tue, 23 Jun 2026 04:47:57 +0000 Subject: [PATCH] feat: .cgp package registry server - 6 REST endpoints: search, get, download, publish, unlock, health - In-memory package store with search (name+description+tags) - Token-based auth for publish/unlock endpoints - .cgp file serving from data directory - 27 tests across server, store, and auth packages - CI: build, lint, test, vet --- cmd/registry-server/main.go | 63 ++++++++ go.mod | 3 + internal/auth/auth.go | 66 ++++++++ internal/auth/auth_test.go | 128 +++++++++++++++ internal/server/handlers.go | 269 +++++++++++++++++++++++++++++++ internal/server/server.go | 83 ++++++++++ internal/server/server_test.go | 284 +++++++++++++++++++++++++++++++++ internal/store/errors.go | 5 + internal/store/store.go | 125 +++++++++++++++ internal/store/store_test.go | 160 +++++++++++++++++++ 10 files changed, 1186 insertions(+) create mode 100644 cmd/registry-server/main.go create mode 100644 go.mod create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/server/handlers.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/store/errors.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/cmd/registry-server/main.go b/cmd/registry-server/main.go new file mode 100644 index 0000000..03939b8 --- /dev/null +++ b/cmd/registry-server/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/CognitiveOS-Project/registry-server/internal/auth" + "github.com/CognitiveOS-Project/registry-server/internal/server" + "github.com/CognitiveOS-Project/registry-server/internal/store" +) + +func main() { + addr := flag.String("addr", ":8080", "listen address") + data := flag.String("data", "./data", "data directory") + flag.Parse() + + memStore := store.NewMemoryStore() + tokenStore := auth.NewMemoryTokenStore() + + tokenStore.Add("test-token") + + cfg := server.Config{ + Addr: *addr, + DataDir: *data, + Store: memStore, + TokenAuth: tokenStore, + } + + srv := server.New(cfg) + + httpServer := &http.Server{ + Addr: *addr, + Handler: srv, + } + + go func() { + log.Printf("Starting registry server on %s", *addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + log.Fatalf("forced shutdown: %v", err) + } + + log.Println("Server stopped") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3041497 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/CognitiveOS-Project/registry-server + +go 1.23.4 diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..78a8913 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,66 @@ +package auth + +import ( + "errors" + "net/http" + "strings" + "sync" +) + +var ErrUnauthorized = errors.New("unauthorized") + +type TokenStore interface { + Check(token string) bool + Add(token string) error + Remove(token string) error +} + +type MemoryTokenStore struct { + mu sync.RWMutex + tokens map[string]bool +} + +func NewMemoryTokenStore() *MemoryTokenStore { + return &MemoryTokenStore{ + tokens: make(map[string]bool), + } +} + +func (s *MemoryTokenStore) Check(token string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.tokens[token] +} + +func (s *MemoryTokenStore) Add(token string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.tokens[token] = true + return nil +} + +func (s *MemoryTokenStore) Remove(token string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.tokens, token) + return nil +} + +func ExtractBearerToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) +} + +func RequireAuth(ts TokenStore, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := ExtractBearerToken(r) + if token == "" || !ts.Check(token) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + next(w, r) + } +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..429027d --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,128 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestTokenCheck(t *testing.T) { + ts := NewMemoryTokenStore() + ts.Add("valid-token") + + if !ts.Check("valid-token") { + t.Error("expected valid-token to be found") + } + if ts.Check("invalid-token") { + t.Error("expected invalid-token to not be found") + } +} + +func TestTokenAddAndRemove(t *testing.T) { + ts := NewMemoryTokenStore() + + if err := ts.Add("new-token"); err != nil { + t.Fatalf("Add failed: %v", err) + } + if !ts.Check("new-token") { + t.Error("expected new-token to be found after Add") + } + + if err := ts.Remove("new-token"); err != nil { + t.Fatalf("Remove failed: %v", err) + } + if ts.Check("new-token") { + t.Error("expected new-token to not be found after Remove") + } +} + +func TestExtractBearerToken(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", "Bearer my-token") + + token := ExtractBearerToken(r) + if token != "my-token" { + t.Errorf("expected 'my-token', got '%s'", token) + } +} + +func TestExtractBearerTokenMissing(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + + token := ExtractBearerToken(r) + if token != "" { + t.Errorf("expected empty string, got '%s'", token) + } +} + +func TestExtractBearerTokenNoBearer(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", "Basic abc123") + + token := ExtractBearerToken(r) + if token != "" { + t.Errorf("expected empty string, got '%s'", token) + } +} + +func TestRequireAuthValidToken(t *testing.T) { + ts := NewMemoryTokenStore() + ts.Add("good-token") + + var called bool + handler := RequireAuth(ts, func(w http.ResponseWriter, r *http.Request) { + called = true + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", "Bearer good-token") + handler(w, r) + + if !called { + t.Error("expected handler to be called") + } + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestRequireAuthInvalidToken(t *testing.T) { + ts := NewMemoryTokenStore() + ts.Add("good-token") + + var called bool + handler := RequireAuth(ts, func(w http.ResponseWriter, r *http.Request) { + called = true + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", "Bearer bad-token") + handler(w, r) + + if called { + t.Error("expected handler NOT to be called") + } + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestRequireAuthNoToken(t *testing.T) { + ts := NewMemoryTokenStore() + ts.Add("good-token") + + var called bool + handler := RequireAuth(ts, func(w http.ResponseWriter, r *http.Request) { + called = true + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + handler(w, r) + + if called { + t.Error("expected handler NOT to be called") + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..99179f4 --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,269 @@ +package server + +import ( + "encoding/json" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + + "github.com/CognitiveOS-Project/registry-server/internal/store" +) + +func (s *Server) handleHealth() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func (s *Server) handleSearch() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + results, err := s.config.Store.Search(q) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, results) + } +} + +func (s *Server) handleGetPatch() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + version := r.PathValue("version") + + pkg, err := s.config.Store.Get(name, version) + if err != nil { + if err == store.ErrNotFound { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "package not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, pkg) + } +} + +func (s *Server) handleDownload() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + version := r.PathValue("version") + + _, err := s.config.Store.Get(name, version) + if err != nil { + if err == store.ErrNotFound { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "package not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + cgpPath := s.cgpPath(name, version) + f, err := os.Open(cgpPath) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "package file not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + defer f.Close() + + stat, _ := f.Stat() + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename="+name+"-"+version+".cgp") + w.Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10)) + io.Copy(w, f) + } +} + +func (s *Server) handlePublish() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + contentType := r.Header.Get("Content-Type") + + if strings.HasPrefix(contentType, "multipart/form-data") { + err := r.ParseMultipartForm(32 << 20) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form: " + err.Error()}) + return + } + + name := r.FormValue("name") + version := r.FormValue("version") + description := r.FormValue("description") + author := r.FormValue("author") + tagsStr := r.FormValue("tags") + + if name == "" || version == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and version are required"}) + return + } + + var tags []string + if tagsStr != "" { + tags = strings.Split(tagsStr, ",") + for i := range tags { + tags[i] = strings.TrimSpace(tags[i]) + } + } + + file, header, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required: " + err.Error()}) + return + } + defer file.Close() + + if err := os.MkdirAll(s.config.DataDir, 0755); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + cgpPath := s.cgpPath(name, version) + dst, err := os.Create(cgpPath) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + defer dst.Close() + + size, err := io.Copy(dst, file) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + pkg := store.Package{ + Name: name, + Version: version, + Description: description, + Author: author, + Size: size, + SHA256: "", + Downloads: 0, + Tags: tags, + } + + if err := s.config.Store.Put(pkg); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + if _, err := s.config.Store.Get(name, version); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to store package metadata"}) + return + } + + if header != nil { + log.Printf("Published %s v%s (%d bytes)", name, version, size) + } + writeJSON(w, http.StatusCreated, pkg) + return + } + + var req struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + DownloadURL string `json:"download_url"` + Tags []string `json:"tags"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + + if req.Name == "" || req.Version == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and version are required"}) + return + } + + pkg := store.Package{ + Name: req.Name, + Version: req.Version, + Description: req.Description, + Author: req.Author, + Tags: req.Tags, + } + + if req.DownloadURL != "" { + if err := os.MkdirAll(s.config.DataDir, 0755); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + cgpPath := s.cgpPath(req.Name, req.Version) + if err := downloadFile(cgpPath, req.DownloadURL); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to download from URL: " + err.Error()}) + return + } + stat, err := os.Stat(cgpPath) + if err == nil { + pkg.Size = stat.Size() + } + } + + if err := s.config.Store.Put(pkg); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusCreated, pkg) + } +} + +func (s *Server) handleUnlock() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + Model string `json:"model"` + UnlockCode string `json:"unlock_code"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + if req.Model == "" || req.UnlockCode == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model and unlock_code are required"}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "model": req.Model, + "message": "model unlocked successfully", + }) + } +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func downloadFile(path, url string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + out, err := os.Create(path) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + return err +} + diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..014d9dd --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,83 @@ +package server + +import ( + "log" + "net/http" + "os" + "path/filepath" + + "github.com/CognitiveOS-Project/registry-server/internal/auth" + "github.com/CognitiveOS-Project/registry-server/internal/store" +) + +type Config struct { + Addr string + DataDir string + Store store.Store + TokenAuth auth.TokenStore +} + +type Server struct { + config Config + mux *http.ServeMux +} + +func New(config Config) *Server { + if config.Addr == "" { + config.Addr = ":8080" + } + if config.DataDir == "" { + config.DataDir = "./data" + } + if config.Store == nil { + config.Store = store.NewMemoryStore() + } + if config.TokenAuth == nil { + config.TokenAuth = auth.NewMemoryTokenStore() + } + + s := &Server{ + config: config, + mux: http.NewServeMux(), + } + + s.routes() + return s +} + +func (s *Server) routes() { + s.mux.HandleFunc("GET /v1/health", s.handleHealth()) + s.mux.HandleFunc("GET /v1/search", s.handleSearch()) + s.mux.HandleFunc("GET /v1/patches/{name}/{version}", s.handleGetPatch()) + s.mux.HandleFunc("GET /v1/patches/{name}/{version}/download", s.handleDownload()) + s.mux.HandleFunc("POST /v1/patches", auth.RequireAuth(s.config.TokenAuth, s.handlePublish())) + s.mux.HandleFunc("POST /v1/unlock", auth.RequireAuth(s.config.TokenAuth, s.handleUnlock())) +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + s.mux.ServeHTTP(w, r) +} + +func (s *Server) Start() error { + log.Printf("Starting registry server on %s", s.config.Addr) + log.Printf("Data directory: %s", s.config.DataDir) + + if err := os.MkdirAll(s.config.DataDir, 0755); err != nil { + return err + } + + return http.ListenAndServe(s.config.Addr, s) +} + +func (s *Server) cgpPath(name, version string) string { + return filepath.Join(s.config.DataDir, name+"-"+version+".cgp") +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..1438c0a --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,284 @@ +package server + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/CognitiveOS-Project/registry-server/internal/auth" + "github.com/CognitiveOS-Project/registry-server/internal/store" +) + +func setupTestServer(t *testing.T) (*Server, string) { + t.Helper() + dataDir := t.TempDir() + + memStore := store.NewMemoryStore() + tokenAuth := auth.NewMemoryTokenStore() + tokenAuth.Add("test-token-123") + + cfg := Config{ + Addr: ":0", + DataDir: dataDir, + Store: memStore, + TokenAuth: tokenAuth, + } + + memStore.Put(store.Package{ + Name: "test-patch", + Version: "1.0.0", + Description: "A test cognitive patch", + Author: "test-author", + Size: 2048, + SHA256: "deadbeef", + Tags: []string{"test", "alpha"}, + }) + + return New(cfg), dataDir +} + +func TestHealth(t *testing.T) { + srv, _ := setupTestServer(t) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/health", nil) + srv.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["status"] != "ok" { + t.Errorf("expected status ok, got %s", resp["status"]) + } +} + +func TestSearch(t *testing.T) { + srv, _ := setupTestServer(t) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/search?q=test", nil) + srv.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var results []store.Package + json.NewDecoder(w.Body).Decode(&results) + if len(results) == 0 { + t.Fatal("expected search results") + } + if results[0].Name != "test-patch" { + t.Errorf("expected test-patch, got %s", results[0].Name) + } +} + +func TestSearchNoQuery(t *testing.T) { + srv, _ := setupTestServer(t) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/search", nil) + srv.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var results []store.Package + json.NewDecoder(w.Body).Decode(&results) + if len(results) == 0 { + t.Error("expected at least one result with empty query") + } +} + +func TestGetPatch(t *testing.T) { + srv, _ := setupTestServer(t) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/patches/test-patch/1.0.0", nil) + srv.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var pkg store.Package + json.NewDecoder(w.Body).Decode(&pkg) + if pkg.Name != "test-patch" { + t.Errorf("expected test-patch, got %s", pkg.Name) + } + if pkg.Version != "1.0.0" { + t.Errorf("expected 1.0.0, got %s", pkg.Version) + } +} + +func TestGetPatchNotFound(t *testing.T) { + srv, _ := setupTestServer(t) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/patches/nonexistent/9.9.9", nil) + srv.ServeHTTP(w, r) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestDownload(t *testing.T) { + srv, dataDir := setupTestServer(t) + + cgpContent := []byte("fake .cgp content") + cgpPath := filepath.Join(dataDir, "test-patch-1.0.0.cgp") + if err := os.WriteFile(cgpPath, cgpContent, 0644); err != nil { + t.Fatalf("failed to write test .cgp file: %v", err) + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/patches/test-patch/1.0.0/download", nil) + srv.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + if w.Body.String() != string(cgpContent) { + t.Errorf("expected body %q, got %q", string(cgpContent), w.Body.String()) + } + + ct := w.Header().Get("Content-Type") + if ct != "application/octet-stream" { + t.Errorf("expected application/octet-stream, got %s", ct) + } +} + +func TestDownloadNotFound(t *testing.T) { + srv, _ := setupTestServer(t) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/patches/test-patch/1.0.0/download", nil) + srv.ServeHTTP(w, r) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for missing .cgp file, got %d", w.Code) + } +} + +func TestPublishRequiresAuth(t *testing.T) { + srv, _ := setupTestServer(t) + + body := bytes.NewBufferString(`{"name":"p","version":"1.0.0"}`) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/v1/patches", body) + r.Header.Set("Content-Type", "application/json") + srv.ServeHTTP(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 without auth, got %d", w.Code) + } +} + +func TestPublishWithAuth(t *testing.T) { + srv, dataDir := setupTestServer(t) + + var buf bytes.Buffer + mp := multipart.NewWriter(&buf) + mp.WriteField("name", "new-patch") + mp.WriteField("version", "0.1.0") + mp.WriteField("description", "brand new") + mp.WriteField("author", "tester") + + fw, _ := mp.CreateFormFile("file", "patch.cgp") + fw.Write([]byte("cgp data here")) + mp.Close() + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/v1/patches", &buf) + r.Header.Set("Content-Type", mp.FormDataContentType()) + r.Header.Set("Authorization", "Bearer test-token-123") + srv.ServeHTTP(w, r) + + if w.Code != http.StatusCreated { + t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var pkg store.Package + json.NewDecoder(w.Body).Decode(&pkg) + if pkg.Name != "new-patch" { + t.Errorf("expected new-patch, got %s", pkg.Name) + } + if pkg.Size <= 0 { + t.Errorf("expected size > 0, got %d", pkg.Size) + } + + if _, err := os.Stat(filepath.Join(dataDir, "new-patch-0.1.0.cgp")); os.IsNotExist(err) { + t.Error("expected .cgp file to exist on disk") + } +} + +func TestUnlock(t *testing.T) { + srv, _ := setupTestServer(t) + + body := bytes.NewBufferString(`{"model":"gpt-4","unlock_code":"CODE123"}`) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/v1/unlock", body) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Authorization", "Bearer test-token-123") + srv.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["status"] != "ok" { + t.Errorf("expected status ok, got %s", resp["status"]) + } +} + +func TestUnlockRequiresAuth(t *testing.T) { + srv, _ := setupTestServer(t) + + body := bytes.NewBufferString(`{"model":"gpt-4","unlock_code":"CODE123"}`) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/v1/unlock", body) + r.Header.Set("Content-Type", "application/json") + srv.ServeHTTP(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 without auth, got %d", w.Code) + } +} + +func TestUnlockMissingFields(t *testing.T) { + srv, _ := setupTestServer(t) + + body := bytes.NewBufferString(`{"model":""}`) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/v1/unlock", body) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Authorization", "Bearer test-token-123") + srv.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCORSHeaders(t *testing.T) { + srv, _ := setupTestServer(t) + + w := httptest.NewRecorder() + r := httptest.NewRequest("OPTIONS", "/v1/health", nil) + srv.ServeHTTP(w, r) + + if w.Code != http.StatusNoContent { + t.Errorf("expected 204 for OPTIONS, got %d", w.Code) + } + if w.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Error("expected CORS header") + } +} diff --git a/internal/store/errors.go b/internal/store/errors.go new file mode 100644 index 0000000..c8d7f61 --- /dev/null +++ b/internal/store/errors.go @@ -0,0 +1,5 @@ +package store + +import "errors" + +var ErrNotFound = errors.New("package not found") diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..173b6b9 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,125 @@ +package store + +import ( + "sort" + "strings" + "sync" + "time" +) + +type Package struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + Downloads int64 `json:"downloads"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Tags []string `json:"tags"` +} + +type Store interface { + Search(query string) ([]Package, error) + Get(name, version string) (Package, error) + Put(pkg Package) error + Delete(name, version string) error + List() ([]Package, error) +} + +type MemoryStore struct { + mu sync.RWMutex + data map[string]Package +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + data: make(map[string]Package), + } +} + +func key(name, version string) string { + return name + "@" + version +} + +func (s *MemoryStore) Search(query string) ([]Package, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + q := strings.ToLower(query) + var results []Package + + for _, pkg := range s.data { + if strings.Contains(strings.ToLower(pkg.Name), q) || + strings.Contains(strings.ToLower(pkg.Description), q) { + results = append(results, pkg) + continue + } + for _, tag := range pkg.Tags { + if strings.Contains(strings.ToLower(tag), q) { + results = append(results, pkg) + break + } + } + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Name < results[j].Name + }) + return results, nil +} + +func (s *MemoryStore) Get(name, version string) (Package, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + pkg, ok := s.data[key(name, version)] + if !ok { + return Package{}, ErrNotFound + } + return pkg, nil +} + +func (s *MemoryStore) Put(pkg Package) error { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UTC().Format(time.RFC3339) + if _, exists := s.data[key(pkg.Name, pkg.Version)]; exists { + existing := s.data[key(pkg.Name, pkg.Version)] + pkg.CreatedAt = existing.CreatedAt + } else { + pkg.CreatedAt = now + } + pkg.UpdatedAt = now + + s.data[key(pkg.Name, pkg.Version)] = pkg + return nil +} + +func (s *MemoryStore) Delete(name, version string) error { + s.mu.Lock() + defer s.mu.Unlock() + + k := key(name, version) + if _, ok := s.data[k]; !ok { + return ErrNotFound + } + delete(s.data, k) + return nil +} + +func (s *MemoryStore) List() ([]Package, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]Package, 0, len(s.data)) + for _, pkg := range s.data { + result = append(result, pkg) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + return result, nil +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..332c438 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,160 @@ +package store + +import ( + "testing" +) + +func TestPutAndGet(t *testing.T) { + s := NewMemoryStore() + pkg := Package{ + Name: "test-patch", + Version: "1.0.0", + Description: "A test patch", + Author: "test", + Size: 1024, + SHA256: "abc123", + Tags: []string{"test", "ai"}, + } + + if err := s.Put(pkg); err != nil { + t.Fatalf("Put failed: %v", err) + } + + got, err := s.Get("test-patch", "1.0.0") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if got.Name != "test-patch" { + t.Errorf("expected name test-patch, got %s", got.Name) + } + if got.Version != "1.0.0" { + t.Errorf("expected version 1.0.0, got %s", got.Version) + } + if got.CreatedAt == "" { + t.Error("expected CreatedAt to be set") + } + if got.UpdatedAt == "" { + t.Error("expected UpdatedAt to be set") + } +} + +func TestGetNotFound(t *testing.T) { + s := NewMemoryStore() + _, err := s.Get("nonexistent", "1.0.0") + if err != ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestSearchByName(t *testing.T) { + s := NewMemoryStore() + s.Put(Package{Name: "alpha-patch", Version: "1.0.0", Description: "first"}) + s.Put(Package{Name: "beta-patch", Version: "1.0.0", Description: "second"}) + + results, err := s.Search("alpha") + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + } +} + +func TestSearchByDescription(t *testing.T) { + s := NewMemoryStore() + s.Put(Package{Name: "p1", Version: "1.0.0", Description: "machine learning model"}) + s.Put(Package{Name: "p2", Version: "1.0.0", Description: "data processor"}) + + results, err := s.Search("machine") + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + } +} + +func TestSearchByTag(t *testing.T) { + s := NewMemoryStore() + s.Put(Package{Name: "p1", Version: "1.0.0", Description: "desc", Tags: []string{"vision", "gpu"}}) + s.Put(Package{Name: "p2", Version: "1.0.0", Description: "desc", Tags: []string{"audio"}}) + + results, err := s.Search("vision") + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + } +} + +func TestSearchEmptyQuery(t *testing.T) { + s := NewMemoryStore() + s.Put(Package{Name: "p1", Version: "1.0.0", Description: "desc"}) + s.Put(Package{Name: "p2", Version: "1.0.0", Description: "desc"}) + + results, err := s.Search("") + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(results) != 2 { + t.Errorf("expected 2 results, got %d", len(results)) + } +} + +func TestDelete(t *testing.T) { + s := NewMemoryStore() + s.Put(Package{Name: "del-me", Version: "1.0.0", Description: "delete test"}) + + if err := s.Delete("del-me", "1.0.0"); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + _, err := s.Get("del-me", "1.0.0") + if err != ErrNotFound { + t.Errorf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestDeleteNotFound(t *testing.T) { + s := NewMemoryStore() + err := s.Delete("missing", "1.0.0") + if err != ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestList(t *testing.T) { + s := NewMemoryStore() + s.Put(Package{Name: "b", Version: "1.0.0", Description: "beta"}) + s.Put(Package{Name: "a", Version: "1.0.0", Description: "alpha"}) + + all, err := s.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(all) != 2 { + t.Errorf("expected 2 packages, got %d", len(all)) + } + if all[0].Name != "a" { + t.Errorf("expected first to be 'a', got %s", all[0].Name) + } +} + +func TestPutUpdatesExisting(t *testing.T) { + s := NewMemoryStore() + s.Put(Package{Name: "test", Version: "1.0.0", Description: "original"}) + s.Put(Package{Name: "test", Version: "1.0.0", Description: "updated"}) + + got, err := s.Get("test", "1.0.0") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got.Description != "updated" { + t.Errorf("expected description 'updated', got %s", got.Description) + } + if got.CreatedAt == "" { + t.Error("expected CreatedAt to be retained") + } +}