Skip to content

Commit 684e0b7

Browse files
committed
feat(auth): update RequestEmailOTP to return TOTP requirement and adjust login flow
1 parent b84bcb4 commit 684e0b7

5 files changed

Lines changed: 240 additions & 22 deletions

File tree

internal/cli/client/auth.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,33 @@ package client
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"net/http"
78
"net/url"
89
)
910

10-
// RequestEmailOTP sends an OTP to the given email address.
11+
// RequestEmailOTP sends an OTP to the given email address and returns whether
12+
// the account requires TOTP instead of an email code.
1113
// Corresponds to POST /v1/auth/login/email.
12-
func (c *Client) RequestEmailOTP(ctx context.Context, email string) error {
14+
func (c *Client) RequestEmailOTP(ctx context.Context, email string) (requireTotp bool, err error) {
1315
q := url.Values{"client": {"cli"}}
1416
resp, err := c.do(ctx, http.MethodPost, "/auth/login/email", q, LoginEmailRequest{Email: email})
1517
if err != nil {
16-
return err
18+
return false, err
1719
}
1820
defer resp.Body.Close()
19-
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNoContent {
20-
return nil
21+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
22+
return false, readAPIError(resp)
2123
}
22-
return readAPIError(resp)
24+
25+
var body struct {
26+
Data struct {
27+
RequireTotp bool `json:"requireTotp"`
28+
} `json:"data"`
29+
}
30+
_ = json.NewDecoder(resp.Body).Decode(&body)
31+
return body.Data.RequireTotp, nil
2332
}
2433

2534
// VerifyEmailOTP submits the OTP code and returns the session cookie on success.

internal/cli/client/auth_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestRequestEmailOTP_OK(t *testing.T) {
1919
defer srv.Close()
2020

2121
c := New(&config.Config{BaseURL: srv.URL})
22-
if err := c.RequestEmailOTP(context.Background(), "user@example.com"); err != nil {
22+
if _, err := c.RequestEmailOTP(context.Background(), "user@example.com"); err != nil {
2323
t.Errorf("unexpected error: %v", err)
2424
}
2525
}
@@ -31,7 +31,7 @@ func TestRequestEmailOTP_ServerError(t *testing.T) {
3131
defer srv.Close()
3232

3333
c := New(&config.Config{BaseURL: srv.URL})
34-
if err := c.RequestEmailOTP(context.Background(), "bad@example.com"); err == nil {
34+
if _, err := c.RequestEmailOTP(context.Background(), "bad@example.com"); err == nil {
3535
t.Error("expected error for non-2xx response")
3636
}
3737
}

internal/cli/commands/auth.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,18 @@ For CI/CD environments use OIDC federation instead:
5858
}
5959

6060
func loginEmail(d *deps, email string) error {
61-
fmt.Printf("sending OTP to %s...\n", email)
62-
6361
ctx := context.Background()
64-
if err := d.client.RequestEmailOTP(ctx, email); err != nil {
62+
requireTotp, err := d.client.RequestEmailOTP(ctx, email)
63+
if err != nil {
6564
return fmt.Errorf("request OTP: %w", err)
6665
}
6766

68-
fmt.Print("enter the code from your email: ")
67+
if requireTotp {
68+
fmt.Print("enter your authenticator app code: ")
69+
} else {
70+
fmt.Printf("sending OTP to %s...\n", email)
71+
fmt.Print("enter the code from your email: ")
72+
}
6973
code, err := readLine()
7074
if err != nil {
7175
return fmt.Errorf("read code: %w", err)

internal/logs/handler.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ var (
2727

2828
// Handler implements the LogStreamer interface.
2929
type Handler struct {
30-
logger *shared.Logger
31-
config *shared.Config
30+
logger *shared.Logger
31+
config *shared.Config
32+
dataDir string // overrides OS-default paths when set (used in tests)
3233
}
3334

3435
// NewHandler creates a new log stream handler.
@@ -314,6 +315,9 @@ func (h *Handler) getLogPath(path string) string {
314315

315316
// System log (app.log)
316317
if clean == "app" {
318+
if h.dataDir != "" {
319+
return filepath.Join(h.dataDir, "logs", "app.log")
320+
}
317321
var sysLogDir string
318322
switch runtime.GOOS {
319323
case "windows":
@@ -332,6 +336,9 @@ func (h *Handler) getLogPath(path string) string {
332336
if !strings.HasSuffix(svcName, ".log") {
333337
svcName = svcName + ".log"
334338
}
339+
if h.dataDir != "" {
340+
return filepath.Join(h.dataDir, ".dployr", "logs", svcName)
341+
}
335342
switch runtime.GOOS {
336343
case "windows":
337344
return filepath.Join(os.Getenv("PROGRAMDATA"), "dployr", ".dployr", "logs", svcName)
@@ -341,21 +348,24 @@ func (h *Handler) getLogPath(path string) string {
341348
}
342349

343350
// Deployment logs
344-
var dataDir string
345-
switch runtime.GOOS {
346-
case "windows":
347-
dataDir = filepath.Join(os.Getenv("PROGRAMDATA"), "dployr")
348-
default:
349-
dataDir = "/var/lib/dployrd"
351+
var baseDir string
352+
if h.dataDir != "" {
353+
baseDir = h.dataDir
354+
} else {
355+
switch runtime.GOOS {
356+
case "windows":
357+
baseDir = filepath.Join(os.Getenv("PROGRAMDATA"), "dployr")
358+
default:
359+
baseDir = "/var/lib/dployrd"
360+
}
350361
}
351362

352363
clean = strings.ToLower(clean)
353-
354364
if !strings.HasSuffix(clean, ".log") {
355365
clean = clean + ".log"
356366
}
357367

358-
return filepath.Join(dataDir, ".dployr", "logs", clean)
368+
return filepath.Join(baseDir, ".dployr", "logs", clean)
359369
}
360370

361371
// parseDuration converts duration strings like "5m", "1h", "24h" to time.Duration.

internal/logs/handler_test.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright 2025 Emmanuel Madehin
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package logs
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"testing"
13+
"time"
14+
15+
"github.com/dployr-io/dployr/pkg/core/logs"
16+
"github.com/dployr-io/dployr/pkg/shared"
17+
)
18+
19+
func newTestHandler(t *testing.T, dataDir string) *Handler {
20+
t.Helper()
21+
h := &Handler{
22+
logger: shared.NewLogger(),
23+
dataDir: dataDir,
24+
config: &shared.Config{
25+
LogMaxChunkBytes: 8 * 1024 * 1024,
26+
LogBatchSize: 50,
27+
LogBatchTimeout: 250 * time.Millisecond,
28+
LogMaxBatchTimeout: 2 * time.Second,
29+
LogPollInterval: 20 * time.Millisecond,
30+
LogMaxFileReadBytes: 100 * 1024 * 1024,
31+
LogMaxStreams: 100,
32+
},
33+
}
34+
streamSemOnce.Do(func() {
35+
streamSemaphore = shared.NewSemaphore(h.config.LogMaxStreams)
36+
})
37+
return h
38+
}
39+
40+
// writeLogFile creates the .dployr/logs directory under dataDir and writes lines to <name>.log
41+
func writeLogFile(t *testing.T, dataDir, name string, lines []string) string {
42+
t.Helper()
43+
dir := filepath.Join(dataDir, ".dployr", "logs")
44+
if err := os.MkdirAll(dir, 0o755); err != nil {
45+
t.Fatalf("mkdir: %v", err)
46+
}
47+
path := filepath.Join(dir, name+".log")
48+
content := strings.Join(lines, "\n") + "\n"
49+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
50+
t.Fatalf("writeLogFile: %v", err)
51+
}
52+
return path
53+
}
54+
55+
func allEntries(chunks []logs.LogChunk) []logs.LogEntry {
56+
var out []logs.LogEntry
57+
for _, c := range chunks {
58+
out = append(out, c.Entries...)
59+
}
60+
return out
61+
}
62+
63+
// ---------------------------------------------------------------------------
64+
65+
func TestStreamLogs_EmptyDuration_ReadsFromBeginning(t *testing.T) {
66+
dir := t.TempDir()
67+
writeLogFile(t, dir, "my-svc", []string{
68+
`{"time":"2025-01-01T00:00:01Z","level":"INFO","msg":"line-1"}`,
69+
`{"time":"2025-01-01T00:00:02Z","level":"INFO","msg":"line-2"}`,
70+
`{"time":"2025-01-01T00:00:03Z","level":"INFO","msg":"line-3"}`,
71+
})
72+
73+
h := newTestHandler(t, dir)
74+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
75+
defer cancel()
76+
77+
var chunks []logs.LogChunk
78+
_ = h.StreamLogs(ctx, logs.StreamOptions{StreamID: "s1", Path: "my-svc", Duration: ""}, func(c logs.LogChunk) error {
79+
chunks = append(chunks, c)
80+
cancel() // stop after first flush
81+
return nil
82+
})
83+
84+
entries := allEntries(chunks)
85+
if len(entries) < 3 {
86+
t.Fatalf("expected at least 3 entries from beginning, got %d", len(entries))
87+
}
88+
}
89+
90+
func TestStreamLogs_LiveDuration_DoesNotReplayOldEntries(t *testing.T) {
91+
dir := t.TempDir()
92+
oldTime := time.Now().Add(-10 * time.Minute)
93+
var oldLines []string
94+
for i := range 5 {
95+
oldLines = append(oldLines, fmt.Sprintf(`{"time":%q,"level":"INFO","msg":"old-%d"}`, oldTime.Format(time.RFC3339), i))
96+
}
97+
writeLogFile(t, dir, "my-svc", oldLines)
98+
99+
h := newTestHandler(t, dir)
100+
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
101+
defer cancel()
102+
103+
var chunks []logs.LogChunk
104+
_ = h.StreamLogs(ctx, logs.StreamOptions{StreamID: "s-live", Path: "my-svc", Duration: "live"}, func(c logs.LogChunk) error {
105+
chunks = append(chunks, c)
106+
return nil
107+
})
108+
109+
for _, e := range allEntries(chunks) {
110+
if strings.Contains(e.RawLine, "old-") {
111+
t.Errorf("live mode must not replay entries older than 5 minutes, got: %s", e.RawLine)
112+
}
113+
}
114+
}
115+
116+
func TestStreamLogs_ContextCancellation_StopsStream(t *testing.T) {
117+
dir := t.TempDir()
118+
writeLogFile(t, dir, "my-svc", []string{`{"time":"2025-01-01T00:00:01Z","level":"INFO","msg":"ping"}`})
119+
120+
h := newTestHandler(t, dir)
121+
ctx, cancel := context.WithCancel(context.Background())
122+
123+
done := make(chan struct{})
124+
go func() {
125+
defer close(done)
126+
_ = h.StreamLogs(ctx, logs.StreamOptions{StreamID: "s1", Path: "my-svc", Duration: "live"}, func(logs.LogChunk) error {
127+
return nil
128+
})
129+
}()
130+
131+
cancel()
132+
select {
133+
case <-done:
134+
case <-time.After(2 * time.Second):
135+
t.Fatal("StreamLogs did not stop within 2s after context cancellation")
136+
}
137+
}
138+
139+
func TestStreamLogs_FileNotExistInitially_WaitsAndOpens(t *testing.T) {
140+
dir := t.TempDir()
141+
logDir := filepath.Join(dir, ".dployr", "logs")
142+
if err := os.MkdirAll(logDir, 0o755); err != nil {
143+
t.Fatalf("mkdir: %v", err)
144+
}
145+
146+
h := newTestHandler(t, dir)
147+
148+
go func() {
149+
time.Sleep(150 * time.Millisecond)
150+
path := filepath.Join(logDir, "late-svc.log")
151+
_ = os.WriteFile(path, []byte("{\"time\":\"2025-01-01T00:00:01Z\",\"level\":\"INFO\",\"msg\":\"appeared\"}\n"), 0o644)
152+
}()
153+
154+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
155+
defer cancel()
156+
157+
var got []logs.LogEntry
158+
_ = h.StreamLogs(ctx, logs.StreamOptions{StreamID: "s2", Path: "late-svc", Duration: ""}, func(c logs.LogChunk) error {
159+
got = append(got, c.Entries...)
160+
cancel()
161+
return nil
162+
})
163+
164+
if len(got) == 0 {
165+
t.Fatal("expected at least one entry after file appeared, got none")
166+
}
167+
}
168+
169+
func TestStreamLogs_BatchesMultipleLines(t *testing.T) {
170+
dir := t.TempDir()
171+
var lines []string
172+
for i := range 20 {
173+
lines = append(lines, fmt.Sprintf(`{"time":"2025-01-01T00:00:%02dZ","level":"INFO","msg":"entry-%d"}`, i%60, i))
174+
}
175+
writeLogFile(t, dir, "batchy-svc", lines)
176+
177+
h := newTestHandler(t, dir)
178+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
179+
defer cancel()
180+
181+
var chunks []logs.LogChunk
182+
_ = h.StreamLogs(ctx, logs.StreamOptions{StreamID: "s3", Path: "batchy-svc", Duration: ""}, func(c logs.LogChunk) error {
183+
chunks = append(chunks, c)
184+
cancel()
185+
return nil
186+
})
187+
188+
entries := allEntries(chunks)
189+
if len(entries) == 0 {
190+
t.Fatal("expected batched entries, got none")
191+
}
192+
if len(chunks) >= 20 {
193+
t.Errorf("expected batched chunks (< 20), got %d separate chunks", len(chunks))
194+
}
195+
}

0 commit comments

Comments
 (0)