diff --git a/internal/store/sql_safety_test.go b/internal/store/sql_safety_test.go new file mode 100644 index 0000000..c194149 --- /dev/null +++ b/internal/store/sql_safety_test.go @@ -0,0 +1,118 @@ +package store + +import ( + "reflect" + "testing" + + "github.com/RandomCodeSpace/kb/internal/board" +) + +func TestTasksByLinkBindsInjectionShapedScopeAndLink(t *testing.T) { + s := newStore(t) + scope := "alice' OR 1=1 --" + link := "link::gitlab#1')OR(1=1)--" + own, err := s.AddTask(scope, board.Task{Title: "own", Tags: []string{link}}) + if err != nil { + t.Fatalf("AddTask(injection-shaped scope): %v", err) + } + if _, err := s.AddTask(scope, board.Task{Title: "wrong link", Tags: []string{"link::gitlab#other"}}); err != nil { + t.Fatalf("AddTask(wrong link): %v", err) + } + if _, err := s.AddTask("victim", board.Task{Title: "foreign", Tags: []string{link}}); err != nil { + t.Fatalf("AddTask(foreign scope): %v", err) + } + + got, err := s.TasksByLink(scope, link) + if err != nil { + t.Fatalf("TasksByLink: %v", err) + } + want := []SimilarHit{{ID: own.ID, Title: own.Title, Status: string(own.Status), Via: "card", Link: link}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("TasksByLink = %+v, want only scoped exact match %+v", got, want) + } +} + +func TestImportedAsBindsInjectionShapedScopeAndExternalKeys(t *testing.T) { + s := newStore(t) + scope := "alice' OR 1=1 --" + keys := []string{"shared-key", "key') OR 1=1 --", "foreign-only", "missing"} + own := []ImportLink{ + {ExternalKey: keys[0], Link: "link::own-shared", Title: "own shared"}, + {ExternalKey: keys[1], Link: "link::own-shaped", Title: "own shaped"}, + } + foreign := []ImportLink{ + {ExternalKey: keys[0], Link: "link::foreign-shared", Title: "foreign shared"}, + {ExternalKey: keys[1], Link: "link::foreign-shaped", Title: "foreign shaped"}, + {ExternalKey: keys[2], Link: "link::foreign-only", Title: "foreign only"}, + } + if err := s.RecordImportLinks(scope, own); err != nil { + t.Fatalf("RecordImportLinks(injection-shaped scope): %v", err) + } + if err := s.RecordImportLinks("victim", foreign); err != nil { + t.Fatalf("RecordImportLinks(foreign scope): %v", err) + } + + got, err := s.ImportedAs(scope, keys) + if err != nil { + t.Fatalf("ImportedAs: %v", err) + } + if _, ok := got[keys[2]]; ok { + t.Fatalf("ImportedAs returned foreign-only key %q: %+v", keys[2], got) + } + want := map[string]ImportLink{keys[0]: own[0], keys[1]: own[1]} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ImportedAs = %+v, want only scoped requested keys %+v", got, want) + } +} + +func TestReplaceBoardBindsInjectionShapedScopeWhenLoadingExistingTasks(t *testing.T) { + s := newStore(t) + scope := "alice' OR 1=1 --" + scopedSeed := board.Board{Title: "Scoped", Tasks: []board.Task{ + {Title: "dummy", Status: board.StatusTodo, Prio: 3}, + {Title: "same title", Status: board.StatusTodo, Prio: 3}, + }} + foreignSeed := board.Board{Title: "Foreign", Tasks: []board.Task{{Title: "same title", Status: board.StatusTodo, Prio: 3}}} + if err := s.ReplaceBoard(scope, scopedSeed); err != nil { + t.Fatalf("ReplaceBoard(injection-shaped scope): %v", err) + } + if err := s.ReplaceBoard("victim", foreignSeed); err != nil { + t.Fatalf("ReplaceBoard(foreign scope): %v", err) + } + before, err := s.Board(scope) + if err != nil { + t.Fatalf("Board(injection-shaped scope): %v", err) + } + foreignBefore, err := s.Board("victim") + if err != nil { + t.Fatalf("Board(foreign scope): %v", err) + } + if len(before.Tasks) != 2 || before.Tasks[0].Title != "dummy" || before.Tasks[0].Position != 0 || before.Tasks[1].Title != "same title" || before.Tasks[1].Position != 1 { + t.Fatalf("scoped ordering precondition failed: %+v", before.Tasks) + } + if len(foreignBefore.Tasks) != 1 || foreignBefore.Tasks[0].Title != "same title" || foreignBefore.Tasks[0].Position != 0 { + t.Fatalf("foreign ordering precondition failed: %+v", foreignBefore.Tasks) + } + + updated := board.Board{Title: "Scoped", Tasks: []board.Task{ + {Title: "dummy", Status: board.StatusTodo, Prio: 3}, + {Title: "same title", Desc: "updated", Status: board.StatusTodo, Prio: 3}, + }} + if err := s.ReplaceBoard(scope, updated); err != nil { + t.Fatalf("ReplaceBoard update: %v", err) + } + after, err := s.Board(scope) + if err != nil { + t.Fatalf("Board after update: %v", err) + } + foreignAfter, err := s.Board("victim") + if err != nil { + t.Fatalf("Board(foreign scope) after update: %v", err) + } + if len(before.Tasks) != 2 || len(after.Tasks) != 2 || after.Tasks[1].ID != before.Tasks[1].ID { + t.Fatalf("scoped identity changed: before=%+v after=%+v", before.Tasks, after.Tasks) + } + if !reflect.DeepEqual(foreignAfter, foreignBefore) { + t.Fatalf("foreign board changed: before=%+v after=%+v", foreignBefore, foreignAfter) + } +} diff --git a/src/components/IdentityGate.dom.test.tsx b/src/components/IdentityGate.dom.test.tsx index 9c35a7e..f71f51c 100644 --- a/src/components/IdentityGate.dom.test.tsx +++ b/src/components/IdentityGate.dom.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { StrictMode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { IdentityGate } from './IdentityGate'; @@ -42,14 +43,29 @@ describe('IdentityGate DOM', () => { expect((await screen.findByRole('alert')).textContent).toContain('Sign-in failed'); }); - it('does not publish Azure availability after unmount', async () => { - let resolve!: (value: boolean) => void; - auth.azureAvailable.mockReturnValue(new Promise((r) => { resolve = r; })); - const { container, unmount } = render(); - unmount(); - resolve(true); - await Promise.resolve(); - expect(container).toBeEmptyDOMElement(); + it('ignores Azure availability from a cleaned-up StrictMode effect', async () => { + const availability = Array.from({ length: 2 }, () => { + let resolve!: (value: boolean) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; + }); + auth.azureAvailable + .mockReturnValueOnce(availability[0].promise) + .mockReturnValueOnce(availability[1].promise); + + render( + + + , + ); + await waitFor(() => expect(auth.azureAvailable).toHaveBeenCalledTimes(2)); + const azure = screen.getByRole('button', { name: 'Sign in with Microsoft' }); + + await act(async () => availability[0].resolve(true)); + expect(azure).toBeDisabled(); + + await act(async () => availability[1].resolve(true)); + await waitFor(() => expect(azure).toBeEnabled()); }); it('handles availability rejection, provider Error, blank submit, invalid characters, and token omission', async () => { diff --git a/src/lib/outbox.test.ts b/src/lib/outbox.test.ts index 996e72d..a264913 100644 --- a/src/lib/outbox.test.ts +++ b/src/lib/outbox.test.ts @@ -59,6 +59,21 @@ const importRequest = { }], }; +const unorderedExternalKeys = ['a', '😀', '2', 'é', 'A', '10']; +const codeUnitOrderedExternalKeys = ['é', '😀', '10', '2', 'A', 'a']; + +function importRequestFor(externalKey: string) { + return { + source: 'ordering', + items: [{ + external_key: externalKey, + link: `link-${externalKey}`, + url: `https://example.test/${encodeURIComponent(externalKey)}`, + title: `title-${externalKey}`, + }], + }; +} + function deferred() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; @@ -83,6 +98,62 @@ beforeEach(() => { }); describe('MetadataOutbox', () => { + it('lists mixed external keys in storage-key code-unit order regardless of insertion order', async () => { + const outbox = new MetadataOutbox('alice', { + storage, locks: locks as unknown as LockManager, generation: generations(), + }); + for (const externalKey of unorderedExternalKeys) { + await outbox.enqueueImportLinks(importRequestFor(externalKey)); + } + + expect(outbox.records().map((record) => + record.kind === 'import' ? record.item.external_key : record.clientTaskId, + )).toEqual(codeUnitOrderedExternalKeys); + }); + + it('drains mixed external keys in storage-key code-unit order regardless of insertion order', async () => { + const sent: string[] = []; + const outbox = new MetadataOutbox('alice', { + storage, + locks: locks as unknown as LockManager, + generation: generations(), + sendImport: (_identity, request) => { + sent.push(request.items[0]!.external_key); + return Promise.resolve(); + }, + }); + for (const externalKey of unorderedExternalKeys) { + await outbox.enqueueImportLinks(importRequestFor(externalKey)); + } + + await outbox.drain(identity); + + expect(sent).toEqual(codeUnitOrderedExternalKeys); + }); + + it('reconciles mixed external keys in storage-key code-unit order regardless of insertion order', async () => { + const outbox = new MetadataOutbox('alice', { + storage, locks: locks as unknown as LockManager, generation: generations(), + }); + for (const externalKey of unorderedExternalKeys) { + await outbox.enqueueImportLinks(importRequestFor(externalKey)); + } + for (const [key, raw] of storage.values) { + storage.values.set(key, JSON.stringify({ ...JSON.parse(raw), state: 'sending' })); + } + const reconciled: string[] = []; + const setItem = vi.spyOn(storage, 'setItem').mockImplementation((key, value) => { + storage.values.set(key, String(value)); + const record = JSON.parse(String(value)) as { item?: { external_key?: string } }; + if (record.item?.external_key) reconciled.push(record.item.external_key); + }); + + await outbox.reconcile(cancelled, new Map()); + + expect(reconciled).toEqual(codeUnitOrderedExternalKeys); + setItem.mockRestore(); + }); + it.each(['acknowledgement', 'startup'])( 'blocks %s drain when durable reconciliation fails', async () => { diff --git a/src/lib/outbox.ts b/src/lib/outbox.ts index 0212d99..3bf9c08 100644 --- a/src/lib/outbox.ts +++ b/src/lib/outbox.ts @@ -290,6 +290,10 @@ function logicalKey(record: OutboxRecord): string { : importLogicalKey(record.item.external_key); } +function compareCodeUnits(left: string, right: string): number { + return Number(left > right) - Number(left < right); +} + /** Durable, credential-free metadata journal for one immutable user namespace. */ export class MetadataOutbox { private readonly storage: Storage; @@ -375,7 +379,7 @@ export class MetadataOutbox { const key = this.storage.key(i); if (key && namespaceStorageSuffix(PREFIX, this.ns, key) !== null) keys.push(key); } - return keys.sort((left, right) => left.localeCompare(right)); + return keys.sort(compareCodeUnits); } private async locked(fn: () => T | Promise): Promise {