package rotate import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "incredigo/internal/discover" "incredigo/internal/vault" ) // giteaEmu is a minimal Gitea API emulator that ENFORCES token validity: a token // authenticates only while it exists, and DELETE makes it stop working — so the // test can prove a real cutover (old token dead, new token alive). type giteaEmu struct { mu sync.Mutex byName map[string]string // name -> token value byValue map[string]string // token value -> name user string srv *httptest.Server createOK func(r *http.Request) bool // auth gate for create/delete } func newGiteaEmu(t *testing.T, user, seedName, seedValue string) *giteaEmu { e := &giteaEmu{ byName: map[string]string{seedName: seedValue}, byValue: map[string]string{seedValue: seedName}, user: user, } mux := http.NewServeMux() mux.HandleFunc("/api/v1/user", e.handleUser) mux.HandleFunc("/api/v1/users/", e.handleTokens) e.srv = httptest.NewServer(mux) t.Cleanup(e.srv.Close) return e } func (e *giteaEmu) valid(token string) bool { e.mu.Lock() defer e.mu.Unlock() _, ok := e.byValue[token] return ok } // presentedToken returns the bearer token from an "Authorization: token " header. func presentedToken(r *http.Request) string { h := r.Header.Get("Authorization") if strings.HasPrefix(h, "token ") { return strings.TrimPrefix(h, "token ") } return "" } func (e *giteaEmu) handleUser(w http.ResponseWriter, r *http.Request) { if !e.valid(presentedToken(r)) { w.WriteHeader(http.StatusUnauthorized) return } json.NewEncoder(w).Encode(map[string]any{"login": e.user, "id": 1}) } func (e *giteaEmu) handleTokens(w http.ResponseWriter, r *http.Request) { // Authorize management calls: a valid token (token auth) or any basic auth. authed := e.valid(presentedToken(r)) if u, _, ok := r.BasicAuth(); ok && u == e.user { authed = true } if e.createOK != nil { authed = e.createOK(r) } if !authed { w.WriteHeader(http.StatusForbidden) return } switch r.Method { case http.MethodPost: var body struct { Name string `json:"name"` } json.NewDecoder(r.Body).Decode(&body) raw := make([]byte, 20) rand.Read(raw) val := hex.EncodeToString(raw) e.mu.Lock() e.byName[body.Name] = val e.byValue[val] = body.Name e.mu.Unlock() w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"id": 2, "name": body.Name, "sha1": val}) case http.MethodDelete: // path: /api/v1/users/{user}/tokens/{name} parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") name := parts[len(parts)-1] e.mu.Lock() if v, ok := e.byName[name]; ok { delete(e.byValue, v) delete(e.byName, name) } e.mu.Unlock() w.WriteHeader(http.StatusNoContent) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func TestGiteaRotateRealCutover(t *testing.T) { v := vault.New() defer v.Purge() ctx := context.Background() const user, seedName, oldTok = "alice", "seed", "0123456789abcdef0123456789abcdef01234567" emu := newGiteaEmu(t, user, seedName, oldTok) oldBlob := emu.srv.URL + "/?name=" + seedName // becomes http://host/?name=seed with userinfo below // Build the blob with userinfo: http://alice:oldtok@host/?name=seed oldBlob = strings.Replace(emu.srv.URL, "http://", "http://"+user+":"+oldTok+"@", 1) + "/?name=" + seedName cred := discover.Credential{ Source: "gitea", Kind: discover.KindToken, Identity: user + " @ gitea", Location: "imported/gitea/alice", Secret: v.Store([]byte(oldBlob)), } g := &Gitea{} // Baseline: the seeded token authenticates. if !emu.valid(oldTok) { t.Fatal("seed token should be valid at start") } // 1. Rotate — create a new token (old still valid). newH, err := g.Rotate(ctx, cred, v) if err != nil { t.Fatalf("Rotate: %v", err) } ns, err := parseGiteaSecret(v, newH) if err != nil { t.Fatalf("parse new secret: %v", err) } if ns.token == oldTok { t.Fatal("new token equals old — rotation minted nothing") } if !emu.valid(oldTok) { t.Fatal("old token must remain valid before revoke") } if !emu.valid(ns.token) { t.Fatal("new token must be valid after create") } // 2. Verify(new) via the driver. if err := g.Verify(ctx, newH, v); err != nil { t.Fatalf("Verify(new): %v", err) } // 3. RevokeOld — delete the old token by name. if err := g.RevokeOld(ctx, cred, v); err != nil { t.Fatalf("RevokeOld: %v", err) } // Cutover assertions: old dead, new alive. if emu.valid(oldTok) { t.Fatal("old token must be invalid after revoke") } if err := g.Verify(ctx, newH, v); err != nil { t.Fatalf("new token must still authenticate after revoke: %v", err) } // Leak check: token values must not appear in non-secret credential fields. for _, f := range []string{cred.Identity, cred.Source, cred.Location, string(cred.Kind)} { if strings.Contains(f, oldTok) || strings.Contains(f, ns.token) { t.Errorf("token leaked into non-secret field %q", f) } } } func TestGiteaRevokeRequiresName(t *testing.T) { v := vault.New() defer v.Purge() g := &Gitea{} // Blob with no name= query → RevokeOld must refuse (guided), not silently pass. cred := discover.Credential{ Source: "gitea", Secret: v.Store([]byte("https://bob:tok123@gitea.example.com/")), } err := g.RevokeOld(context.Background(), cred, v) if err == nil || !strings.Contains(err.Error(), "no recorded name") { t.Fatalf("expected revoke-without-name error, got %v", err) } }