1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package session_test
  6
  7import (
  8	"context"
  9	"errors"
 10	"os"
 11	"path/filepath"
 12	"testing"
 13	"time"
 14
 15	"git.secluded.site/np/internal/db"
 16	"git.secluded.site/np/internal/session"
 17	"git.secluded.site/np/internal/testutil"
 18)
 19
 20func TestStoreStartAndArchive(t *testing.T) {
 21	ctx := context.Background()
 22	database := testutil.OpenDB(t)
 23
 24	clock := &testutil.SequenceClock{
 25		Times: []time.Time{
 26			time.Date(2025, time.April, 4, 9, 15, 0, 0, time.FixedZone("A", 3600)),
 27			time.Date(2025, time.April, 4, 11, 45, 0, 0, time.FixedZone("B", -3600)),
 28			time.Date(2025, time.April, 4, 12, 0, 0, 0, time.UTC),
 29		},
 30	}
 31
 32	store := session.NewStore(database, clock)
 33
 34	workdir := t.TempDir()
 35	canonical, hash, err := db.CanonicalizeAndHash(workdir)
 36	if err != nil {
 37		t.Fatalf("canonicalise temp dir: %v", err)
 38	}
 39
 40	started, err := store.Start(ctx, workdir)
 41	if err != nil {
 42		t.Fatalf("Start: %v", err)
 43	}
 44
 45	if started.SID == "" {
 46		t.Fatalf("expected SID to be populated")
 47	}
 48	if len(started.SID) != 26 {
 49		t.Fatalf("expected ULID SID length 26, got %d", len(started.SID))
 50	}
 51	if started.DirPath != canonical {
 52		t.Fatalf("DirPath mismatch: want %q got %q", canonical, started.DirPath)
 53	}
 54	if started.DirHash != hash {
 55		t.Fatalf("DirHash mismatch: want %q got %q", hash, started.DirHash)
 56	}
 57	if started.State != session.StateActive {
 58		t.Fatalf("expected state active, got %s", started.State)
 59	}
 60	if started.ArchivedAt != nil {
 61		t.Fatalf("expected ArchivedAt to be nil")
 62	}
 63
 64	createdAt := clock.Times[0].UTC()
 65	if !started.CreatedAt.Equal(createdAt) || !started.LastUpdatedAt.Equal(createdAt) {
 66		t.Fatalf("timestamp mismatch: created=%v updated=%v want=%v", started.CreatedAt, started.LastUpdatedAt, createdAt)
 67	}
 68
 69	err = database.View(ctx, func(txn *db.Txn) error {
 70		active, err := txn.Get(db.KeyDirActive(hash))
 71		if err != nil {
 72			return err
 73		}
 74		if string(active) != started.SID {
 75			t.Fatalf("active SID mismatch: got %s want %s", string(active), started.SID)
 76		}
 77
 78		idxActive, err := txn.Get(db.KeyIdxActive(started.SID))
 79		if err != nil {
 80			return err
 81		}
 82		if string(idxActive) != hash {
 83			t.Fatalf("idx active value mismatch: got %s want %s", string(idxActive), hash)
 84		}
 85
 86		seq, err := txn.Get(db.KeySessionEventSeq(started.SID))
 87		if err != nil {
 88			return err
 89		}
 90		if len(seq) != 8 {
 91			t.Fatalf("expected event seq zero buffer length 8, got %d", len(seq))
 92		}
 93		for i, b := range seq {
 94			if b != 0 {
 95				t.Fatalf("expected zeroed event seq, byte %d was %d", i, b)
 96			}
 97		}
 98		return nil
 99	})
100	if err != nil {
101		t.Fatalf("validate stored keys: %v", err)
102	}
103
104	_, err = store.Start(ctx, workdir)
105	if err == nil {
106		t.Fatalf("expected AlreadyActiveError")
107	}
108	var already session.AlreadyActiveError
109	if !errors.As(err, &already) {
110		t.Fatalf("expected AlreadyActiveError, got %v", err)
111	}
112	if already.Session.SID != started.SID {
113		t.Fatalf("AlreadyActiveError returned unexpected SID: %s", already.Session.SID)
114	}
115
116	childDir := filepath.Join(workdir, "nested")
117	if err := os.MkdirAll(childDir, 0o755); err != nil {
118		t.Fatalf("mkdir nested: %v", err)
119	}
120
121	found, ok, err := store.ActiveByPath(ctx, childDir)
122	if err != nil {
123		t.Fatalf("ActiveByPath child: %v", err)
124	}
125	if !ok {
126		t.Fatalf("expected ActiveByPath to find parent session")
127	}
128	if found.SID != started.SID {
129		t.Fatalf("ActiveByPath returned different SID: %s", found.SID)
130	}
131
132	archived, err := store.Archive(ctx, started.SID)
133	if err != nil {
134		t.Fatalf("Archive: %v", err)
135	}
136	if archived.State != session.StateArchived {
137		t.Fatalf("expected archived state, got %s", archived.State)
138	}
139	wantArchivedAt := clock.Times[1].UTC()
140	if archived.ArchivedAt == nil || !archived.ArchivedAt.Equal(wantArchivedAt) {
141		t.Fatalf("ArchivedAt mismatch: got %v want %v", archived.ArchivedAt, wantArchivedAt)
142	}
143	if !archived.LastUpdatedAt.Equal(wantArchivedAt) {
144		t.Fatalf("LastUpdatedAt mismatch: got %v want %v", archived.LastUpdatedAt, wantArchivedAt)
145	}
146
147	err = database.View(ctx, func(txn *db.Txn) error {
148		if exists, err := txn.Exists(db.KeyDirActive(hash)); err != nil {
149			return err
150		} else if exists {
151			t.Fatalf("expected dir active key to be removed")
152		}
153		if exists, err := txn.Exists(db.KeyIdxActive(started.SID)); err != nil {
154			return err
155		} else if exists {
156			t.Fatalf("expected idx active key to be removed")
157		}
158
159		tsHex := db.Uint64Hex(uint64(wantArchivedAt.UnixNano()))
160		idxVal, err := txn.Get(db.KeyIdxArchived(tsHex, started.SID))
161		if err != nil {
162			return err
163		}
164		if string(idxVal) != hash {
165			t.Fatalf("idx archived value mismatch: got %s want %s", string(idxVal), hash)
166		}
167
168		dirArchivedKey := db.KeyDirArchived(hash, tsHex, started.SID)
169		if exists, err := txn.Exists(dirArchivedKey); err != nil {
170			return err
171		} else if !exists {
172			t.Fatalf("expected dir archived key to exist")
173		}
174		return nil
175	})
176	if err != nil {
177		t.Fatalf("validate archival keys: %v", err)
178	}
179
180	repeat, err := store.Archive(ctx, started.SID)
181	if err != nil {
182		t.Fatalf("Archive second time: %v", err)
183	}
184	if repeat.State != session.StateArchived {
185		t.Fatalf("expected archived state on second archive")
186	}
187
188	if _, found, err := store.ActiveByPath(ctx, workdir); err != nil {
189		t.Fatalf("post-archive ActiveByPath error: %v", err)
190	} else if found {
191		t.Fatalf("ActiveByPath should not find archived session")
192	}
193}